-
Notifications
You must be signed in to change notification settings - Fork 603
feat: add SchemaForm component for JSON Schema-driven plugin config UI #3316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ── 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Mar 6, 2026
There was a problem hiding this comment.
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
AI
Mar 6, 2026
There was a problem hiding this comment.
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
AI
Mar 6, 2026
There was a problem hiding this comment.
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
AI
Mar 6, 2026
There was a problem hiding this comment.
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
AI
Mar 6, 2026
There was a problem hiding this comment.
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).
| 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); | |
| } | |
| }} |
There was a problem hiding this comment.
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-formatforsrc/**/*.{ts,tsx}(seeeslint.config.ts), so this will fail lint unless the standard header is added at the top.