feat: add new schema requirements to DynamicJsonForm
This commit is contained in:
parent
e163aea0e0
commit
e6166a734e
2 changed files with 523 additions and 22 deletions
|
|
@ -140,26 +140,136 @@ const DynamicJsonForm = ({
|
|||
);
|
||||
}
|
||||
|
||||
const isFieldRequired = (fieldPath: string[]): boolean => {
|
||||
if (typeof schema.required === "boolean") {
|
||||
return schema.required;
|
||||
}
|
||||
if (Array.isArray(schema.required) && fieldPath.length > 0) {
|
||||
return schema.required.includes(fieldPath[fieldPath.length - 1]);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (propSchema.type === "object" && propSchema.properties) {
|
||||
const objectValue = (currentValue as Record<string, JsonValue>) || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(propSchema.properties).map(
|
||||
([fieldName, fieldSchema]) => {
|
||||
const fieldPath = [...path, fieldName];
|
||||
const fieldValue = objectValue[fieldName];
|
||||
const fieldRequired = isFieldRequired([fieldName]);
|
||||
|
||||
return (
|
||||
<div key={fieldName} className="space-y-2">
|
||||
<label
|
||||
htmlFor={fieldName}
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
{fieldSchema.title || fieldName}
|
||||
{fieldRequired && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{fieldSchema.description && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{fieldSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
{renderFieldInput(
|
||||
fieldSchema,
|
||||
fieldValue,
|
||||
fieldPath,
|
||||
fieldRequired,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldRequired = isFieldRequired(path);
|
||||
return renderFieldInput(propSchema, currentValue, path, fieldRequired);
|
||||
};
|
||||
|
||||
const renderFieldInput = (
|
||||
propSchema: JsonSchemaType,
|
||||
currentValue: JsonValue,
|
||||
path: string[],
|
||||
fieldRequired: boolean,
|
||||
) => {
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
case "string": {
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !fieldRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={fieldRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.enum.map((option, index) => (
|
||||
<option key={option} value={option}>
|
||||
{propSchema.enumNames?.[index] || option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
let inputType = "text";
|
||||
switch (propSchema.format) {
|
||||
case "email":
|
||||
inputType = "email";
|
||||
break;
|
||||
case "uri":
|
||||
inputType = "url";
|
||||
break;
|
||||
case "date":
|
||||
inputType = "date";
|
||||
break;
|
||||
case "date-time":
|
||||
inputType = "datetime-local";
|
||||
break;
|
||||
default:
|
||||
inputType = "text";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
type={inputType}
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required fields by setting undefined
|
||||
// This preserves the distinction between empty string and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !fieldRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={fieldRequired}
|
||||
minLength={propSchema.minLength}
|
||||
maxLength={propSchema.maxLength}
|
||||
pattern={propSchema.pattern}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -167,9 +277,7 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required number fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !fieldRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
|
|
@ -179,9 +287,12 @@ const DynamicJsonForm = ({
|
|||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={fieldRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -190,32 +301,38 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required integer fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !fieldRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
// Only update if it's a valid integer
|
||||
if (!isNaN(num) && Number.isInteger(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={fieldRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={propSchema.required}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={fieldRequired}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{propSchema.description || "Enable this option"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
|
@ -35,6 +36,130 @@ describe("DynamicJsonForm String Fields", () => {
|
|||
expect(input).toHaveProperty("type", "text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Format Support", () => {
|
||||
it("should render email input for email format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "email",
|
||||
description: "Email address",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "email");
|
||||
});
|
||||
|
||||
it("should render url input for uri format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description: "Website URL",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "url");
|
||||
});
|
||||
|
||||
it("should render date input for date format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "Birth date",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("");
|
||||
expect(input).toHaveProperty("type", "date");
|
||||
});
|
||||
|
||||
it("should render datetime-local input for date-time format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description: "Event datetime",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("");
|
||||
expect(input).toHaveProperty("type", "datetime-local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enum Support", () => {
|
||||
it("should render select dropdown for enum fields", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
enum: ["option1", "option2", "option3"],
|
||||
description: "Select an option",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select.tagName).toBe("SELECT");
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should use enumNames for option labels", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
enum: ["val1", "val2"],
|
||||
enumNames: ["Label 1", "Label 2"],
|
||||
description: "Select with labels",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[1]).toHaveProperty("textContent", "Label 1");
|
||||
expect(options[2]).toHaveProperty("textContent", "Label 2");
|
||||
});
|
||||
|
||||
it("should call onChange with selected enum value", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
enum: ["option1", "option2"],
|
||||
description: "Select an option",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "option1" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("option1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation Attributes", () => {
|
||||
it("should apply minLength and maxLength", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
description: "Username",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("minLength", 3);
|
||||
expect(input).toHaveProperty("maxLength", 10);
|
||||
});
|
||||
|
||||
it("should apply pattern validation", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
pattern: "^[A-Za-z]+$",
|
||||
description: "Letters only",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("pattern", "^[A-Za-z]+$");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Integer Fields", () => {
|
||||
|
|
@ -81,6 +206,38 @@ describe("DynamicJsonForm Integer Fields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should apply min and max constraints", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description: "Age",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveProperty("min", "0");
|
||||
expect(input).toHaveProperty("max", "100");
|
||||
});
|
||||
|
||||
it("should only accept integer values", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "integer",
|
||||
description: "Count",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "3.14" } });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle non-numeric input by not calling onChange", () => {
|
||||
const onChange = jest.fn();
|
||||
|
|
@ -94,6 +251,233 @@ describe("DynamicJsonForm Integer Fields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Number Fields", () => {
|
||||
describe("Validation", () => {
|
||||
it("should apply min and max constraints", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "number",
|
||||
minimum: 0.5,
|
||||
maximum: 99.9,
|
||||
description: "Score",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveProperty("min", "0.5");
|
||||
expect(input).toHaveProperty("max", "99.9");
|
||||
});
|
||||
|
||||
it("should accept decimal values", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "number",
|
||||
description: "Temperature",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "98.6" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(98.6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Boolean Fields", () => {
|
||||
describe("Basic Operations", () => {
|
||||
it("should render checkbox for boolean type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Enable notifications",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toHaveProperty("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should call onChange with boolean value", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Accept terms",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should render boolean field with description", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Enable dark mode",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toHaveProperty("checked", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Object Fields", () => {
|
||||
describe("Property Rendering", () => {
|
||||
it("should render input fields for object properties", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Full Name",
|
||||
description: "Your full name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
description: "Your age in years",
|
||||
minimum: 18,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
format: "email",
|
||||
title: "Email",
|
||||
description: "Your email address",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const textInputs = screen.getAllByRole("textbox");
|
||||
const numberInput = screen.getByRole("spinbutton");
|
||||
|
||||
expect(textInputs).toHaveLength(2);
|
||||
expect(textInputs[0]).toHaveProperty("type", "text");
|
||||
expect(textInputs[1]).toHaveProperty("type", "email");
|
||||
expect(numberInput).toHaveProperty("type", "number");
|
||||
expect(numberInput).toHaveProperty("min", "18");
|
||||
});
|
||||
|
||||
it("should handle object field changes correctly", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: {
|
||||
type: "string",
|
||||
description: "Your username",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "testuser" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ username: "testuser" });
|
||||
});
|
||||
|
||||
it("should handle nested object values correctly", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm
|
||||
schema={schema}
|
||||
value={{ name: "John" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue("John");
|
||||
fireEvent.change(input, { target: { value: "Jane" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ name: "Jane" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Required Fields", () => {
|
||||
it("should mark required fields with required attribute", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
title: "Email",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
const nameInput = inputs[0];
|
||||
const emailInput = inputs[1];
|
||||
|
||||
expect(nameInput).toHaveProperty("required", true);
|
||||
expect(emailInput).toHaveProperty("required", false);
|
||||
});
|
||||
|
||||
it("should mark required fields with required attribute", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
optional: {
|
||||
type: "string",
|
||||
title: "Optional",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const nameLabel = screen.getByText("Name");
|
||||
const optionalLabel = screen.getByText("Optional");
|
||||
|
||||
const nameInput = nameLabel.closest("div")?.querySelector("input");
|
||||
const optionalInput = optionalLabel
|
||||
.closest("div")
|
||||
?.querySelector("input");
|
||||
|
||||
expect(nameInput).toHaveProperty("required", true);
|
||||
expect(optionalInput).toHaveProperty("required", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Complex Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue