diff --git a/src/components/schema-form/SchemaForm.tsx b/src/components/schema-form/SchemaForm.tsx new file mode 100644 index 0000000000..9e57d1130f --- /dev/null +++ b/src/components/schema-form/SchemaForm.tsx @@ -0,0 +1,368 @@ +/** + * SchemaForm.tsx + * Main component that renders a form automatically + * from a JSON Schema definition. + * + * Usage: + * console.log(values)} + * /> + */ + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { + Box, + Button, + Group, + Switch, + Select, + TextInput, + NumberInput, + Textarea, + PasswordInput, + JsonInput, + TagsInput, + Text, + Stack, + Paper, + Title, + Alert, +} from '@mantine/core'; +import Ajv from 'ajv'; + +import { parseSchema, type JSONSchema, type ParsedField } from './schemaParser'; +import { getWidget, getValidationRules } from './widgetMapper'; + +// ── AJV setup ──────────────────────────────────────────────────────────────── +const ajv = new Ajv({ allErrors: true }); + +// ── Props ───────────────────────────────────────────────────────────────────── +interface SchemaFormProps { + // The JSON Schema to render + schema: JSONSchema; + // Called when form is submitted with valid data + onSubmit: (values: Record) => void; + // Optional initial values + defaultValues?: Record; + // Optional submit button label + submitLabel?: string; +} + +// ── Main Component ──────────────────────────────────────────────────────────── +export const SchemaForm = ({ + schema, + onSubmit, + defaultValues = {}, + submitLabel = 'Save', +}: SchemaFormProps) => { + const [ajvErrors, setAjvErrors] = useState([]); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ defaultValues }); + + // Parse schema into fields + const fields = parseSchema(schema, schema.required || []); + + // Handle form submission with AJV validation + const onFormSubmit = (values: Record) => { + const validate = ajv.compile(schema); + const valid = validate(values); + + if (!valid && validate.errors) { + const errorMessages = validate.errors.map( + (e) => `${e.instancePath || 'Field'} ${e.message}` + ); + setAjvErrors(errorMessages); + return; + } + + setAjvErrors([]); + onSubmit(values); + }; + + return ( + + + {/* AJV validation errors */} + {ajvErrors.length > 0 && ( + + {ajvErrors.map((err, i) => ( + {err} + ))} + + )} + + {/* Render each field */} + {fields.map((field) => ( + + ))} + + {/* Submit button */} + + + + + + ); +}; + +// ── Field Renderer ──────────────────────────────────────────────────────────── +interface FieldRendererProps { + field: ParsedField; + register: ReturnType['register']; + errors: ReturnType['formState']['errors']; + setValue: ReturnType['setValue']; + watch: ReturnType['watch']; + schema: JSONSchema; +} + +const FieldRenderer = ({ + field, + register, + errors, + setValue, + watch, + schema, +}: FieldRendererProps) => { + const widget = getWidget(field); + const rules = getValidationRules(field); + const error = errors[field.name]?.message as string | undefined; + const value = watch(field.name as never); + + // ── OneOf Widget ──────────────────────────────────────────────────────── + if (widget === 'OneOfInput' && field.oneOf) { + return ( + + ); + } + + // ── Nested Object ─────────────────────────────────────────────────────── + if (field.type === 'object' && field.fields && field.fields.length > 0) { + return ( + + {field.label} + {field.description && ( + {field.description} + )} + + {field.fields.map((subField) => ( + + ))} + + + ); + } + + // ── Switch (boolean) ──────────────────────────────────────────────────── + if (widget === 'Switch') { + return ( + setValue(field.name as never, e.currentTarget.checked)} + error={error} + /> + ); + } + + // ── Select (enum) ─────────────────────────────────────────────────────── + if (widget === 'Select') { + return ( +