Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 368 additions & 0 deletions src/components/schema-form/SchemaForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
/**
* SchemaForm.tsx
* Main component that renders a form automatically
* from a JSON Schema definition.
*
Comment on lines +1 to +5
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is missing the required ASF license header block. ESLint enforces headers/header-format for src/**/*.{ts,tsx} (see eslint.config.ts), so this will fail lint unless the standard header is added at the top.

Copilot uses AI. Check for mistakes.
* Usage:
* <SchemaForm
* schema={pluginSchema}
* onSubmit={(values) => 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 });

Comment on lines +33 to +40
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ajv is imported here, but it is not listed in package.json dependencies/devDependencies. This will fail installs/builds in a clean environment unless ajv is added as a direct dependency (and any required ajv plugins, e.g. formats, if needed).

Copilot uses AI. Check for mistakes.
// ── Props ─────────────────────────────────────────────────────────────────────
interface SchemaFormProps {
// The JSON Schema to render
schema: JSONSchema;
// Called when form is submitted with valid data
onSubmit: (values: Record<string, unknown>) => void;
// Optional initial values
defaultValues?: Record<string, unknown>;
// Optional submit button label
submitLabel?: string;
}

// ── Main Component ────────────────────────────────────────────────────────────
export const SchemaForm = ({
schema,
onSubmit,
defaultValues = {},
submitLabel = 'Save',
}: SchemaFormProps) => {
const [ajvErrors, setAjvErrors] = useState<string[]>([]);

const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({ defaultValues });

// Parse schema into fields
const fields = parseSchema(schema, schema.required || []);

Comment on lines +62 to +72
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

field.defaultValue parsed from schema is never applied to the form state. This means schema default values won’t appear unless callers manually pass them via defaultValues. Consider building initial values from parseSchema(...) (and nested fields) and merging with the defaultValues prop.

Suggested change
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({ defaultValues });
// Parse schema into fields
const fields = parseSchema(schema, schema.required || []);
// Parse schema into fields
const fields = parseSchema(schema, schema.required || []);
// Build initial default values from schema-parsed fields
const schemaDefaults = fields.reduce<Record<string, unknown>>((acc, field) => {
if (field.defaultValue !== undefined) {
acc[field.name] = field.defaultValue;
}
return acc;
}, {});
// Merge schema defaults with provided defaultValues (prop overrides schema)
const mergedDefaultValues = { ...schemaDefaults, ...defaultValues };
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({ defaultValues: mergedDefaultValues });

Copilot uses AI. Check for mistakes.
// Handle form submission with AJV validation
const onFormSubmit = (values: Record<string, unknown>) => {
const validate = ajv.compile(schema);
const valid = validate(values);

Comment on lines +74 to +77
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AJV compilation happens on every submit (ajv.compile(schema)), which is relatively expensive and can throw if the schema uses unsupported dialect/keywords. Consider memoizing the compiled validator (e.g. useMemo) and wrapping compilation/validation in try/catch so a bad schema can be surfaced as a form error instead of crashing the component.

Copilot uses AI. Check for mistakes.
if (!valid && validate.errors) {
const errorMessages = validate.errors.map(
(e) => `${e.instancePath || 'Field'} ${e.message}`
);
setAjvErrors(errorMessages);
return;
}

setAjvErrors([]);
onSubmit(values);
};

return (
<Box component="form" onSubmit={handleSubmit(onFormSubmit)}>
<Stack gap="md">
{/* AJV validation errors */}
{ajvErrors.length > 0 && (
<Alert color="red" title="Validation Errors">
{ajvErrors.map((err, i) => (
<Text key={i} size="sm">{err}</Text>
))}
</Alert>
Comment on lines +94 to +99
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component includes multiple hard-coded strings in JSX (e.g. title="Validation Errors", label="Select type", and various placeholder values). eslint-plugin-i18n is enabled for src/**/*.{ts,tsx} and will flag literal text in JSX; these should be replaced with t(...) keys (and added to locale files).

Copilot uses AI. Check for mistakes.
)}

{/* Render each field */}
{fields.map((field) => (
<FieldRenderer
key={field.name}
field={field}
register={register}
errors={errors}
setValue={setValue}
watch={watch}
schema={schema}
/>
))}

{/* Submit button */}
<Group justify="flex-end">
<Button type="submit">{submitLabel}</Button>
</Group>
</Stack>
</Box>
);
};

// ── Field Renderer ────────────────────────────────────────────────────────────
interface FieldRendererProps {
field: ParsedField;
register: ReturnType<typeof useForm>['register'];
errors: ReturnType<typeof useForm>['formState']['errors'];
setValue: ReturnType<typeof useForm>['setValue'];
watch: ReturnType<typeof useForm>['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);
Comment on lines +143 to +145
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errors[field.name] won’t work for nested field names like parent.child (RHF nests errors as objects). This breaks error display for nested objects and oneOf subfields. Use useController/Controller (preferred in this codebase) or a safe getter (e.g. get(errors, field.name)) to resolve nested errors.

Copilot uses AI. Check for mistakes.

// ── OneOf Widget ────────────────────────────────────────────────────────
if (widget === 'OneOfInput' && field.oneOf) {
return (
<OneOfRenderer
field={field}
register={register}
errors={errors}
setValue={setValue}
watch={watch}
/>
);
}

// ── Nested Object ───────────────────────────────────────────────────────
if (field.type === 'object' && field.fields && field.fields.length > 0) {
return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="sm">{field.label}</Title>
{field.description && (
<Text size="xs" c="dimmed" mb="sm">{field.description}</Text>
)}
<Stack gap="sm">
{field.fields.map((subField) => (
<FieldRenderer
key={`${field.name}.${subField.name}`}
field={{ ...subField, name: `${field.name}.${subField.name}` }}
register={register}
errors={errors}
setValue={setValue}
watch={watch}
schema={schema}
/>
))}
</Stack>
</Paper>
);
}

// ── Switch (boolean) ────────────────────────────────────────────────────
if (widget === 'Switch') {
return (
<Switch
label={field.label}
description={field.description}
checked={!!value}
onChange={(e) => setValue(field.name as never, e.currentTarget.checked)}
Comment on lines +188 to +192
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These controlled widgets (e.g. Switch here, and also Select/NumberInput/TagsInput/JsonInput below) are driven via watch/setValue but are never registered with react-hook-form. That means getValidationRules(field) won’t run for them and required/min/max validation won’t happen inline. Consider using Controller/useController (or the existing FormItem* components under src/components/form/*) so rules and errors are wired consistently.

Copilot uses AI. Check for mistakes.
error={error}
/>
);
}

// ── Select (enum) ───────────────────────────────────────────────────────
if (widget === 'Select') {
return (
<Select
label={field.label}
description={field.description}
data={field.options || []}
value={value as string}
onChange={(val) => setValue(field.name as never, val)}
error={error}
required={field.required}
placeholder={`Select ${field.label}`}
/>
);
}

// ── Number Input ────────────────────────────────────────────────────────
if (widget === 'NumberInput') {
return (
<NumberInput
label={field.label}
description={field.description}
value={value as number}
onChange={(val) => setValue(field.name as never, val)}
min={field.minimum}
max={field.maximum}
error={error}
required={field.required}
/>
);
}

// ── Tags Input (array) ──────────────────────────────────────────────────
if (widget === 'TagInput') {
return (
<TagsInput
label={field.label}
description={field.description}
value={(value as string[]) || []}
onChange={(val) => setValue(field.name as never, val)}
error={error}
required={field.required}
placeholder={`Add ${field.label}`}
/>
);
}

// ── JSON Input (object) ─────────────────────────────────────────────────
if (widget === 'JsonInput') {
return (
<JsonInput
label={field.label}
description={field.description}
value={value ? JSON.stringify(value, null, 2) : ''}
onChange={(val) => {
try {
setValue(field.name as never, JSON.parse(val));
} catch {
// invalid JSON — ignore
}
}}
error={error}
required={field.required}
formatOnBlur
autosize
minRows={3}
/>
);
}

// ── Password Input ──────────────────────────────────────────────────────
if (widget === 'PasswordInput') {
return (
<PasswordInput
label={field.label}
description={field.description}
error={error}
required={field.required}
{...register(field.name as never, rules)}
/>
);
}

// ── Textarea ────────────────────────────────────────────────────────────
if (widget === 'Textarea') {
return (
<Textarea
label={field.label}
description={field.description}
error={error}
required={field.required}
autosize
minRows={2}
{...register(field.name as never, rules)}
/>
);
}

// ── Default: Text Input ─────────────────────────────────────────────────
return (
<TextInput
label={field.label}
description={field.description}
error={error}
required={field.required}
{...register(field.name as never, rules)}
/>
);
};

// ── OneOf Renderer ────────────────────────────────────────────────────────────
interface OneOfRendererProps {
field: ParsedField;
register: ReturnType<typeof useForm>['register'];
errors: ReturnType<typeof useForm>['formState']['errors'];
setValue: ReturnType<typeof useForm>['setValue'];
watch: ReturnType<typeof useForm>['watch'];
}

const OneOfRenderer = ({
field,
register,
errors,
setValue,
watch,
}: OneOfRendererProps) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const options = field.oneOf || [];
const selectedSchema = options[selectedIndex]?.schema;
const selectedFields = selectedSchema
? parseSchema(selectedSchema, selectedSchema.required || [])
: [];

return (
<Paper withBorder p="md" radius="md">
<Title order={5} mb="sm">{field.label}</Title>
{field.description && (
<Text size="xs" c="dimmed" mb="sm">{field.description}</Text>
)}

{/* Dropdown to pick which oneOf option */}
<Select
label="Select type"
data={options.map((opt, i) => ({
value: String(i),
label: opt.title,
}))}
value={String(selectedIndex)}
onChange={(val) => setSelectedIndex(Number(val))}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching oneOf options only updates selectedIndex and doesn’t clear/unregister values from the previously selected option. This can leave stale values in the submitted payload and cause AJV oneOf validation to fail unexpectedly. Consider resetting that subtree on change (and/or unregistering previous option fields).

Suggested change
onChange={(val) => setSelectedIndex(Number(val))}
onChange={(val) => {
const nextIndex = Number(val);
if (!Number.isNaN(nextIndex)) {
// Reset the subtree for this oneOf field to avoid stale values
setValue(field.name, {});
setSelectedIndex(nextIndex);
}
}}

Copilot uses AI. Check for mistakes.
mb="md"
/>

{/* Render fields for selected option */}
<Stack gap="sm">
{selectedFields.map((subField) => (
<FieldRenderer
key={`${field.name}.${subField.name}`}
field={{ ...subField, name: `${field.name}.${subField.name}` }}
register={register}
errors={errors}
setValue={setValue}
watch={watch}
schema={selectedSchema!}
/>
))}
</Stack>
</Paper>
);
};

export default SchemaForm;
Loading
Loading