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]


Reply via email to