Copilot commented on code in PR #3316:
URL: https://github.com/apache/apisix-dashboard/pull/3316#discussion_r2895150464
##########
src/components/schema-form/widgetMapper.ts:
##########
@@ -0,0 +1,126 @@
+/**
+ * widgetMapper.ts
+ * Maps a ParsedField type to the correct
+ * existing form component in the project.
+ */
Review Comment:
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.
##########
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:
+ * <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 });
+
Review Comment:
`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).
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
+ // Handle form submission with AJV validation
+ const onFormSubmit = (values: Record<string, unknown>) => {
+ 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 (
+ <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>
+ )}
+
+ {/* 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);
Review Comment:
`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.
##########
src/components/schema-form/widgetMapper.ts:
##########
@@ -0,0 +1,126 @@
+/**
+ * widgetMapper.ts
+ * Maps a ParsedField type to the correct
+ * existing form component in the project.
+ */
+
+import { type ParsedField } from './schemaParser';
+
+export type WidgetType =
+ | 'TextInput'
+ | 'NumberInput'
+ | 'Switch'
+ | 'Select'
+ | 'Textarea'
+ | 'JsonInput'
+ | 'TagInput'
+ | 'PasswordInput'
+ | 'OneOfInput';
+
+/**
+ * Main mapper function
+ * Takes a ParsedField and returns which widget to use
+ *
+ * Example:
+ * { type: 'string' } → 'TextInput'
+ * { type: 'number' } → 'NumberInput'
+ * { type: 'boolean' } → 'Switch'
+ * { type: 'enum' } → 'Select'
+ * { type: 'array' } → 'TagInput'
+ * { type: 'object' } → 'JsonInput'
+ */
+export function getWidget(field: ParsedField): WidgetType {
+ // oneOf gets its own special widget
+ if (field.oneOf && field.oneOf.length > 0) {
+ return 'OneOfInput';
+ }
+
+ // password format
+ if (
+ field.name.toLowerCase().includes('password') ||
+ field.name.toLowerCase().includes('secret') ||
+ field.name.toLowerCase().includes('token')
+ ) {
+ return 'PasswordInput';
+ }
+
+ switch (field.type) {
+ case 'string':
+ // long text gets textarea
+ if (
+ field.maxLength === undefined ||
+ field.maxLength > 100
+ ) {
+ return 'TextInput';
+ }
+ return 'Textarea';
+
+ case 'number':
+ case 'integer':
+ return 'NumberInput';
+
+ case 'boolean':
+ return 'Switch';
+
+ case 'enum':
+ return 'Select';
+
+ case 'array':
+ return 'TagInput';
+
+ case 'object':
+ return 'JsonInput';
+
+ default:
+ return 'TextInput';
+ }
+}
+
+/**
+ * Returns validation rules for react-hook-form
+ * based on the field schema constraints
+ */
+export function getValidationRules(field: ParsedField) {
+ const rules: Record<string, unknown> = {};
+
+ if (field.required) {
+ rules.required = `${field.label} is required`;
+ }
+
+ if (field.minimum !== undefined) {
+ rules.min = {
+ value: field.minimum,
+ message: `Minimum value is ${field.minimum}`,
+ };
+ }
+
+ if (field.maximum !== undefined) {
+ rules.max = {
+ value: field.maximum,
+ message: `Maximum value is ${field.maximum}`,
+ };
+ }
+
+ if (field.minLength !== undefined) {
+ rules.minLength = {
+ value: field.minLength,
+ message: `Minimum length is ${field.minLength}`,
+ };
+ }
+
+ if (field.maxLength !== undefined) {
+ rules.maxLength = {
+ value: field.maxLength,
+ message: `Maximum length is ${field.maxLength}`,
+ };
+ }
+
+ if (field.pattern) {
+ rules.pattern = {
+ value: new RegExp(field.pattern),
+ message: `Must match pattern: ${field.pattern}`,
+ };
Review Comment:
`new RegExp(field.pattern)` can throw at runtime if the schema provides an
invalid regex pattern, which would break rendering/validation. Wrap RegExp
construction in try/catch (and fall back to no pattern rule or surface a schema
error) to avoid crashing the form.
```suggestion
try {
rules.pattern = {
value: new RegExp(field.pattern),
message: `Must match pattern: ${field.pattern}`,
};
} catch {
// If the pattern is invalid, skip adding a pattern rule to avoid
runtime errors
}
```
##########
src/components/schema-form/widgetMapper.ts:
##########
@@ -0,0 +1,126 @@
+/**
+ * widgetMapper.ts
+ * Maps a ParsedField type to the correct
+ * existing form component in the project.
+ */
+
+import { type ParsedField } from './schemaParser';
+
+export type WidgetType =
+ | 'TextInput'
+ | 'NumberInput'
+ | 'Switch'
+ | 'Select'
+ | 'Textarea'
+ | 'JsonInput'
+ | 'TagInput'
+ | 'PasswordInput'
+ | 'OneOfInput';
+
+/**
+ * Main mapper function
+ * Takes a ParsedField and returns which widget to use
+ *
+ * Example:
+ * { type: 'string' } → 'TextInput'
+ * { type: 'number' } → 'NumberInput'
+ * { type: 'boolean' } → 'Switch'
+ * { type: 'enum' } → 'Select'
+ * { type: 'array' } → 'TagInput'
+ * { type: 'object' } → 'JsonInput'
+ */
+export function getWidget(field: ParsedField): WidgetType {
+ // oneOf gets its own special widget
+ if (field.oneOf && field.oneOf.length > 0) {
+ return 'OneOfInput';
+ }
+
+ // password format
+ if (
+ field.name.toLowerCase().includes('password') ||
+ field.name.toLowerCase().includes('secret') ||
+ field.name.toLowerCase().includes('token')
+ ) {
+ return 'PasswordInput';
+ }
+
+ switch (field.type) {
+ case 'string':
+ // long text gets textarea
+ if (
+ field.maxLength === undefined ||
+ field.maxLength > 100
Review Comment:
The “long text gets textarea” branch appears inverted: when `maxLength` is
undefined or > 100 it currently returns `TextInput`. That will render
long/unknown-length strings as a single-line input. Either swap the returned
widgets or adjust the comment/threshold logic so it matches the intended
behavior.
```suggestion
field.maxLength !== undefined &&
field.maxLength <= 100
```
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
+ // Handle form submission with AJV validation
+ const onFormSubmit = (values: Record<string, unknown>) => {
+ 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 (
+ <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>
+ )}
+
+ {/* 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);
+
+ // ── 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)}
Review Comment:
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.
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
+ // Handle form submission with AJV validation
+ const onFormSubmit = (values: Record<string, unknown>) => {
+ const validate = ajv.compile(schema);
+ const valid = validate(values);
+
Review Comment:
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.
##########
src/components/schema-form/schemaParser.ts:
##########
@@ -0,0 +1,172 @@
+/**
+ * schemaParser.ts
+ * Parses a JSON Schema object into a flat list of fields
+ * that SchemaForm can render.
+ */
+
+export type FieldType =
+ | 'string'
+ | 'number'
+ | 'integer'
+ | 'boolean'
+ | 'object'
+ | 'array'
+ | 'enum';
+
+export interface ParsedField {
+ // unique key for the field
+ name: string;
+ // display label
+ label: string;
+ // field type
+ type: FieldType;
+ // is this field required?
+ required: boolean;
+ // default value if any
+ defaultValue?: unknown;
+ // enum options if type is enum
+ options?: string[];
+ // min/max for numbers
+ minimum?: number;
+ maximum?: number;
+ // minLength/maxLength for strings
+ minLength?: number;
+ maxLength?: number;
+ // regex pattern for strings
+ pattern?: string;
+ // nested fields for objects
+ fields?: ParsedField[];
+ // description/help text
+ description?: string;
+ // oneOf options
+ oneOf?: { title: string; schema: JSONSchema }[];
+ // raw schema for complex types
+ schema?: JSONSchema;
+}
+
+export interface JSONSchema {
+ type?: string | string[];
+ properties?: Record<string, JSONSchema>;
+ required?: string[];
+ enum?: unknown[];
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ minLength?: number;
+ maxLength?: number;
+ pattern?: string;
+ description?: string;
+ title?: string;
+ oneOf?: JSONSchema[];
+ anyOf?: JSONSchema[];
+ items?: JSONSchema;
+ dependencies?: Record<string, JSONSchema>;
+ if?: JSONSchema;
+ then?: JSONSchema;
+ else?: JSONSchema;
+}
Review Comment:
PR description mentions supporting `format: password`, but the schema types
here do not include `format`, and parsing/mapping never checks it. Add
`format?: string` to `JSONSchema` (and carry it into `ParsedField`) so
`getWidget` can reliably map `format: 'password'` to `PasswordInput` instead of
inferring from the field name.
##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,368 @@
+/**
+ * SchemaForm.tsx
+ * Main component that renders a form automatically
+ * from a JSON Schema definition.
+ *
Review Comment:
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.
##########
src/components/schema-form/__tests__/schemaParser.test.ts:
##########
@@ -0,0 +1,184 @@
+/**
+ * schemaParser.test.ts
+ * Unit tests for the schema parser utility
+ */
+
Review Comment:
This test file is missing the required ASF license header block. ESLint
enforces `headers/header-format` for `src/**/*.{ts,tsx}`, so this will fail
lint unless the standard header is added at the top.
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
+ // Handle form submission with AJV validation
+ const onFormSubmit = (values: Record<string, unknown>) => {
+ 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 (
+ <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>
Review Comment:
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).
##########
src/components/schema-form/schemaParser.ts:
##########
@@ -0,0 +1,172 @@
+/**
+ * schemaParser.ts
+ * Parses a JSON Schema object into a flat list of fields
+ * that SchemaForm can render.
+ */
Review Comment:
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.
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
Review Comment:
`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.
```suggestion
// 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 });
```
##########
src/components/schema-form/__tests__/widgetMapper.test.ts:
##########
@@ -0,0 +1,161 @@
+/**
+ * widgetMapper.test.ts
+ * Unit tests for the widget mapper utility
+ */
+
Review Comment:
This test file is missing the required ASF license header block. ESLint
enforces `headers/header-format` for `src/**/*.{ts,tsx}`, so this will fail
lint unless the standard header is added at the top.
##########
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:
+ * <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 });
+
+// ── 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 || []);
+
+ // Handle form submission with AJV validation
+ const onFormSubmit = (values: Record<string, unknown>) => {
+ 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 (
+ <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>
+ )}
+
+ {/* 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);
+
+ // ── 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)}
+ 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))}
Review Comment:
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).
```suggestion
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);
}
}}
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]