From e6166a734eccb49f96fba9f0e5cfa7add21b2618 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 15:17:54 +0200 Subject: [PATCH] feat: add new schema requirements to DynamicJsonForm --- client/src/components/DynamicJsonForm.tsx | 161 +++++++- .../__tests__/DynamicJsonForm.test.tsx | 384 ++++++++++++++++++ 2 files changed, 523 insertions(+), 22 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c3..77023b28 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -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) || {}; + + return ( +
+ {Object.entries(propSchema.properties).map( + ([fieldName, fieldSchema]) => { + const fieldPath = [...path, fieldName]; + const fieldValue = objectValue[fieldName]; + const fieldRequired = isFieldRequired([fieldName]); + + return ( +
+ + {fieldSchema.description && ( +

+ {fieldSchema.description} +

+ )} +
+ {renderFieldInput( + fieldSchema, + fieldValue, + fieldPath, + fieldRequired, + )} +
+
+ ); + }, + )} +
+ ); + } + + 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 ( + + ); + } + + 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 ( { 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 ( { 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 ( { 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 ( - handleFieldChange(path, e.target.checked)} - className="w-4 h-4" - required={propSchema.required} - /> +
+ handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={fieldRequired} + /> + + {propSchema.description || "Enable this option"} + +
); + default: return null; } diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index afd435d7..2a0da799 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 = {