feat(plugin): JSON Schema-driven form renderer (SchemaForm) for plugin configuration#3312
feat(plugin): JSON Schema-driven form renderer (SchemaForm) for plugin configuration#3312AryanVBW wants to merge 7 commits intoapache:masterfrom
Conversation
…peline Add core infrastructure for rendering plugin config forms from JSON Schema. - types.ts: TypeScript types for JSON Schema subset (oneOf, anyOf, if/then/else, dependencies, patternProperties, encrypt_fields) - utils.ts: 16 helper functions for schema introspection and runtime conditional evaluation - validation.ts: AJV draft-07 validation with ajv-formats, strips APISIX-specific fields, maps errors to react-hook-form field paths
Add field components that render JSON Schema properties as Mantine widgets: - StringField: TextInput, PasswordInput (encrypt_fields), Textarea (long) - NumberField: NumberInput with min/max/step, integer vs decimal support - BooleanField: Switch toggle with left-positioned label - EnumField: Select dropdown with type-preserving value conversion - ObjectField: Recursive Fieldset with if/then/else conditional rendering, dependency-based field visibility, and nested if/then/else chains - ArrayField: TagsInput for string arrays, repeatable Fieldset for objects with add/remove controls and maxItems enforcement - SchemaField: Central dispatcher that routes schemas to correct widget based on type, enum, oneOf, anyOf, encrypt_fields, patternProperties
Support complex JSON Schema composition patterns used by APISIX plugins: - OneOfField: SegmentedControl variant selector for oneOf schemas. Handles top-level required alternatives (limit-conn: conn+burst vs rules) and field-level type unions (integer | string). Auto-detects matching variant from current form data. - AnyOfField: Similar to OneOfField for anyOf schemas (ai-proxy-multi fallback_strategy, schema_def id_schema). - PatternPropertiesField: Dynamic key-value editor for schemas with patternProperties (ai-proxy auth_schema, labels_def). Supports add/remove entries with pattern-based key validation.
- SchemaForm.tsx: Main entry point that wraps react-hook-form FormProvider, resolves defaults from schema, runs AJV validation on submit, maps errors back to form fields, and renders top-level properties + conditionals. Props: schema, value, onChange, onSubmit, encryptFields, disabled, rootPath. - index.ts: Barrel exports for all public APIs (SchemaForm, SchemaField, types, validation functions, utility functions).
Add comprehensive test suite with Vitest + Testing Library: - vitest.config.ts: Vitest config with jsdom environment, react-swc plugin - src/test-setup.ts: Mantine jsdom mocks (matchMedia, ResizeObserver) Test files: - utils.test.ts (41 tests): getSchemaType, isEnumField, isEncryptField, isRequired, getRequiredSet, resolveDefaults, fieldPath, getFieldLabel, hasConditionals, hasDependencies, evaluateIfCondition, mergeSchemas, getOneOfMatchIndex, getOneOfLabel - validation.test.ts (9 tests): valid data, missing required, type/string/ enum constraints, limit-count-like schema, APISIX-specific field stripping, error-to-field mapping - SchemaForm.test.tsx (10 tests): renders string/number/boolean/enum/object fields, empty schema alert, key-auth-like schema, encrypt_fields password input, submit button conditional rendering
… scripts Dependencies added: - ajv@8.18.0, ajv-formats@3.0.1: JSON Schema draft-07 validation - vitest@4.0.18: Unit test runner - @testing-library/react@16.3.2, @testing-library/jest-dom@6.9.1, @testing-library/user-event@14.6.1: React component testing - jsdom@28.1.0: DOM environment for tests Scripts added: - "test": "vitest run" (single run) - "test:watch": "vitest" (watch mode)
- schema-form-developer-guide.md: Complete developer guide covering architecture, data flow, supported JSON Schema features (basic types, constraints, oneOf, anyOf, if/then/else, dependencies), APISIX-specific features (encrypt_fields), validation pipeline, how to extend with custom widgets, testing instructions, and file structure reference. - github-issue-schema-form.md: Ready-to-file GitHub issue template for apache/apisix-dashboard with motivation, deliverables (P0/P1/P2), technical notes, and APISIX plugin schema patterns table.
There was a problem hiding this comment.
Pull request overview
This PR introduces a JSON Schema-driven SchemaForm system to render APISIX plugin configuration UIs from schema definitions, along with a Vitest-based test setup and developer documentation to support ongoing extension.
Changes:
- Added a new
src/components/schema-form/module (types, utilities, AJV validation, and Mantine-based field components) plus barrel exports. - Added Vitest + Testing Library configuration and setup, and introduced unit/integration tests for the new SchemaForm stack.
- Added developer documentation and an issue-template reference document for SchemaForm feature work.
Reviewed changes
Copilot reviewed 23 out of 24 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Adds Vitest configuration (jsdom, setup file, include patterns). |
| src/test-setup.ts | Adds jsdom test shims for Mantine dependencies (ResizeObserver, matchMedia). |
| src/components/schema-form/validation.ts | Implements AJV draft-07 validation and error formatting/path mapping. |
| src/components/schema-form/utils.ts | Adds schema introspection, defaults, conditional evaluation, and oneOf helpers. |
| src/components/schema-form/types.ts | Defines the JSON Schema subset types and SchemaForm/field prop types. |
| src/components/schema-form/index.ts | Barrel exports for SchemaForm, fields, types, utils, validation. |
| src/components/schema-form/fields/StringField.tsx | String widget (TextInput/PasswordInput/Textarea) with RHF rules. |
| src/components/schema-form/fields/SchemaField.tsx | Central dispatcher routing schemas to specific field components. |
| src/components/schema-form/fields/PatternPropertiesField.tsx | Implements a dynamic key/value editor UI. |
| src/components/schema-form/fields/OneOfField.tsx | Adds oneOf selector UI + branch rendering. |
| src/components/schema-form/fields/ObjectField.tsx | Renders object properties plus conditional/dependency-driven fields. |
| src/components/schema-form/fields/NumberField.tsx | Number/integer widget with min/max/step handling. |
| src/components/schema-form/fields/EnumField.tsx | Enum widget using Mantine Select with type-preserving mapping. |
| src/components/schema-form/fields/BooleanField.tsx | Boolean widget using Mantine Switch. |
| src/components/schema-form/fields/ArrayField.tsx | Array widget (TagsInput for primitives; repeatable fieldsets for objects). |
| src/components/schema-form/fields/AnyOfField.tsx | Adds anyOf selector UI + branch rendering. |
| src/components/schema-form/tests/validation.test.ts | Unit tests for AJV pipeline and schema stripping. |
| src/components/schema-form/tests/utils.test.ts | Unit tests for schema utilities. |
| src/components/schema-form/tests/SchemaForm.test.tsx | Integration-ish rendering tests for SchemaForm with MantineProvider. |
| src/components/schema-form/SchemaForm.tsx | Top-level form wrapper (RHF provider, defaults, AJV validation on submit). |
| package.json | Adds AJV, Vitest, Testing Library, jsdom dependencies and test scripts. |
| pnpm-lock.yaml | Lockfile updates for the new dependencies. |
| docs/en/schema-form-developer-guide.md | Adds an architecture and extension guide for SchemaForm. |
| docs/en/github-issue-schema-form.md | Adds a document describing the SchemaForm feature-request template. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (cleaned.properties) { | ||
| const props: Record<string, JSONSchema> = {}; | ||
| for (const [key, value] of Object.entries(cleaned.properties)) { | ||
| props[key] = stripNonStandardFields(value); | ||
| } |
There was a problem hiding this comment.
stripNonStandardFields only recurses through properties, items (non-tuple), and composition/conditional keywords. It does not recurse into other schema-bearing locations like patternProperties, additionalProperties (when it’s a schema), dependencies (schema form), or not. As a result, APISIX-specific keys can remain in nested subschemas and still break AJV compilation. Consider making the stripper fully recursive across all JSONSchema fields that can contain subschemas.
| pattern: schema.pattern | ||
| ? { value: new RegExp(schema.pattern), message: `Must match pattern: ${schema.pattern}` } | ||
| : undefined, |
There was a problem hiding this comment.
new RegExp(schema.pattern) will throw at runtime if the schema provides an invalid regex, which would break rendering of the entire form. Wrap RegExp construction in a try/catch (or pre-validate) and fall back to skipping the pattern rule / showing a safe error message when the pattern is invalid.
| if (propSchema.type) { | ||
| const expectedType = Array.isArray(propSchema.type) | ||
| ? propSchema.type[0] | ||
| : propSchema.type; | ||
| if (typeof value !== expectedType) return 'else'; | ||
| } |
There was a problem hiding this comment.
evaluateIfCondition compares JSON Schema types using typeof value !== expectedType, but JSON Schema uses 'integer' while typeof returns 'number'. This makes if conditions with type: 'integer' always evaluate to 'else' even when the data is valid. Handle 'integer' explicitly (e.g., typeof value === 'number' && Number.isInteger(value)) and map schema types to JS runtime types before comparing.
| const label = getFieldLabel(name, schema); | ||
| const description = getFieldDescription(schema); | ||
|
|
||
| if (!schema.properties && !schema.patternProperties) { |
There was a problem hiding this comment.
ObjectField checks for schema.patternProperties (so the schema is considered renderable), but it never actually renders any UI for patternProperties. This means schemas that rely on patternProperties (especially when combined with regular properties) will silently drop those fields. Add a dedicated rendering section for patternProperties here, or ensure SchemaField routes to PatternPropertiesField in the mixed case too.
| if (!schema.properties && !schema.patternProperties) { | |
| if (!schema.properties) { |
| value={Array.isArray(value) ? value.map(String) : []} | ||
| onChange={(vals) => onChange(vals)} | ||
| acceptValueOnBlur |
There was a problem hiding this comment.
SimpleStringArray always stringifies values (value.map(String)) and returns the TagsInput values (strings) directly. Since ArrayField falls back to SimpleStringArray for non-object arrays, arrays of number/integer will be coerced to string[], breaking type fidelity for plugin configs. Add explicit handling for numeric arrays (parse to numbers, validate NaN) or render a numeric array editor instead of TagsInput for those schemas.
| // Handle top-level oneOf (required-field alternatives) | ||
| // These are typically just mode selectors and don't add extra properties | ||
| // The actual rendering is handled by individual field components | ||
|
|
There was a problem hiding this comment.
Top-level oneOf on an object schema (the common APISIX “required alternatives” pattern) is not actually rendered: this component comments about handling it but returns nothing. Since SchemaField treats schemas with properties as object, the oneOf selector UI never appears for top-level object schemas. Implement top-level oneOf handling here (or in ObjectField) so schemas like limit-conn/limit-count can select a mode and render the matching branch.
| if (onChange) { | ||
| methods.watch((data) => { | ||
| handleChange(data as Record<string, unknown>); | ||
| }); | ||
| } |
There was a problem hiding this comment.
methods.watch(...) is being called during render whenever onChange is provided. That creates a new subscription on every render (and never unsubscribes), which can lead to duplicate callbacks and memory leaks. Move the watch subscription into a useEffect, store the unsubscribe function returned by watch, and clean it up on unmount / when onChange changes.
| <SchemaField | ||
| schema={{ | ||
| type: 'object', | ||
| properties: schema.properties, |
There was a problem hiding this comment.
ConditionalTopLevel passes properties: schema.properties into SchemaField, but SchemaForm already renders all schema.properties above. This will cause the top-level fields to be rendered twice for schemas that use top-level if/then/else, and can register duplicate form inputs with the same names. Consider passing an empty properties object (so only conditional branch fields render), or refactor SchemaForm to delegate top-level rendering entirely to the object/conditional renderer when conditionals are present.
| properties: schema.properties, | |
| // Do not re-pass top-level properties; SchemaForm already renders them. | |
| // Use an empty properties object so only conditional branch fields render. | |
| properties: {}, |
| if (errors.length > 0) { | ||
| // Map errors back to form fields | ||
| for (const err of errors) { | ||
| if (err.path) { | ||
| methods.setError(err.path, { |
There was a problem hiding this comment.
AJV validation errors are set via methods.setError(...), but when a later submission has no AJV errors the previously-set react-hook-form errors are not cleared. This can leave stale error messages in the UI even after the data becomes valid. Call methods.clearErrors() (or selectively clear the affected paths) when errors.length === 0.
| const methods = useForm({ | ||
| defaultValues: initialValues, | ||
| mode: 'onBlur', | ||
| }); |
There was a problem hiding this comment.
useForm({ defaultValues: initialValues }) only applies defaultValues on the first render; later value prop changes won’t update the form state. If SchemaForm is meant to be controlled/updated when editing an existing plugin config, add an effect that calls methods.reset(initialValues) when schema or value changes (being careful to avoid clobbering user edits if that’s not desired).
Summary
This PR implements a reusable
SchemaFormcomponent that renders APISIX plugin configuration forms directly from JSON Schema definitions, eliminating manual UI maintenance per plugin.Resolves #3311
What this PR does
Core Infrastructure
types.ts: TypeScript types for JSON Schema subset used by APISIX plugins (oneOf, anyOf, if/then/else, dependencies, patternProperties, encrypt_fields)utils.ts: 16 helper functions for schema introspection, defaults resolution, conditional evaluation, and human-readable label generationvalidation.ts: AJV draft-07 validation pipeline with ajv-formats, strips APISIX-specific fields, maps errors to react-hook-form field pathsField Components
Entry Point
Tests (60 passing)
utils.test.ts(41 tests): All utility functionsvalidation.test.ts(9 tests): AJV validation pipelineSchemaForm.test.tsx(10 tests): Component rendering with real-world schemasDocumentation
schema-form-developer-guide.md: Architecture, supported features, how to extendgithub-issue-schema-form.md: Issue template referenceDependencies Added
ajv@8.18.0,ajv-formats@3.0.1: JSON Schema validationvitest,@testing-library/react,@testing-library/jest-dom,jsdom: Test infrastructureAPISIX Schema Patterns Supported
oneOf(required alternatives)oneOf(type union)dependencies+oneOfdependencies+notif/then/elseanyOfenumpatternPropertiesencrypt_fields