You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by "Geetanjali-147 (via GitHub)" <gi...@apache.org> on 2026/03/04 14:20:49 UTC
[PR] feat: add SchemaForm component for JSON Schema-driven plugin config UI [apisix-dashboard]
Geetanjali-147 opened a new pull request, #3316:
URL: https://github.com/apache/apisix-dashboard/pull/3316
## Summary
Implements a reusable `SchemaForm` component that automatically renders
form fields from a JSON Schema definition, eliminating manual UI work
for APISIX plugin configuration forms.
## Motivation
Closes #2986
APISIX plugins ship JSON Schema definitions for their configuration.
This PR enables the dashboard to render plugin config forms directly
from those schemas, improving developer experience significantly.
## Changes
- Add `SchemaForm` component — renders forms from JSON Schema
- Add `schemaParser.ts` — parses JSON Schema into field definitions
- Add `widgetMapper.ts` — maps field types to existing UI components
- Add unit tests (33 tests passing)
- Add README documentation with developer guide
## Supported Schema Types
- [x] `string` → TextInput
- [x] `number` / `integer` → NumberInput
- [x] `boolean` → Switch
- [x] `enum` → Select dropdown
- [x] `array` → TagsInput
- [x] `object` → JsonInput / nested fieldset
- [x] `oneOf` → Type selector + dynamic fields
- [x] `format: password` → PasswordInput
## Supported Constraints
- [x] `required` fields
- [x] `minimum` / `maximum` for numbers
- [x] `minLength` / `maxLength` for strings
- [x] `pattern` regex validation
- [x] `default` values
## Validation
Two-layer validation pipeline:
1. `react-hook-form` — inline field validation on change
2. `AJV` — full JSON Schema validation on submit
## Tests
33 unit tests covering:
- All basic field type parsing
- Required field detection
- Constraint parsing
- Widget mapping
- Validation rules
- Real APISIX plugin schema (limit-req)
## How to Test
```tsx
import { SchemaForm } from '@/components/schema-form/SchemaForm';
<SchemaForm
schema={{
type: 'object',
properties: {
rate: { type: 'number', minimum: 0 },
burst: { type: 'number', minimum: 0 },
key: {
type: 'string',
enum: ['remote_addr', 'server_addr', 'consumer_name']
}
},
required: ['rate', 'burst', 'key']
}}
onSubmit={(values) => console.log(values)}
/>
```
**Why submit this pull request?**
- [x] New feature provided
- [x] Did you explain what problem does this PR solve?
- [x] Have you added corresponding test cases?
- [x] Have you modified the corresponding document?
- [x] Is this PR backward compatible?
**What changes will this PR take into?**
This PR adds a reusable `SchemaForm` component that automatically
renders form fields from a JSON Schema definition.
### New Files
- `src/components/schema-form/SchemaForm.tsx` — Main component that
renders forms automatically from any JSON Schema
- `src/components/schema-form/schemaParser.ts` — Parses JSON Schema
into a flat list of typed field definitions
- `src/components/schema-form/widgetMapper.ts` — Maps field types to
existing Mantine UI components
- `src/components/schema-form/__tests__/` — 33 unit tests
- `src/components/schema-form/README.md` — Developer guide
### Supported Types
string, number, integer, boolean, enum, array, object, oneOf
### Validation
Two-layer validation using react-hook-form + AJV
fix/resolve #2986
**Checklist:**
- [x] Did you explain what problem does this PR solve? Yes
- [x] Have you added corresponding test cases? Yes — 33 unit tests
- [x] Have you modified the corresponding document? Yes — README.md added
- [x] Is this PR backward compatible? Yes — new files only, no existing code modified
<img width="1375" height="868" alt="Screenshot 2026-03-04 194036" src="https://github.com/user-attachments/assets/2d413be6-415b-498c-ad1f-f061e323586f" />
--
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: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat: add SchemaForm component for JSON Schema-driven plugin config UI [apisix-dashboard]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
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: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org