Copilot commented on code in PR #3312:
URL: https://github.com/apache/apisix-dashboard/pull/3312#discussion_r2869789371


##########
src/components/schema-form/validation.ts:
##########
@@ -0,0 +1,178 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Ajv from 'ajv';
+import addFormats from 'ajv-formats';
+import type { FieldErrors } from 'react-hook-form';
+
+import type { JSONSchema } from './types';
+
+let ajvInstance: Ajv | null = null;
+
+function getAjv(): Ajv {
+  if (!ajvInstance) {
+    ajvInstance = new Ajv({
+      allErrors: true,
+      verbose: true,
+      strict: false,
+      validateFormats: true,
+    });
+    addFormats(ajvInstance);
+  }
+  return ajvInstance;
+}
+
+export interface ValidationError {
+  path: string;
+  message: string;
+}
+
+export function validateAgainstSchema(
+  schema: JSONSchema,
+  data: unknown
+): ValidationError[] {
+  const ajv = getAjv();
+  const errors: ValidationError[] = [];
+
+  // Remove APISIX-specific fields that AJV doesn't understand
+  const cleanSchema = stripNonStandardFields(schema);
+
+  let validate: ReturnType<Ajv['compile']>;
+  try {
+    validate = ajv.compile(cleanSchema);
+  } catch {
+    return [{ path: '', message: 'Failed to compile schema for validation' }];
+  }
+
+  const valid = validate(data);
+  if (!valid && validate.errors) {
+    for (const err of validate.errors) {
+      const path = ajvErrorToFieldPath(err.instancePath, err.params);
+      const message = formatAjvError(err);
+      errors.push({ path, message });
+    }
+  }
+
+  return errors;
+}
+
+export function validationErrorsToFieldErrors(
+  errors: ValidationError[]
+): FieldErrors {
+  const fieldErrors: FieldErrors = {};
+  for (const err of errors) {
+    const key = err.path || 'root';
+    if (!fieldErrors[key]) {
+      fieldErrors[key] = { message: err.message, type: 'validate' };
+    }
+  }
+  return fieldErrors;
+}
+
+function ajvErrorToFieldPath(
+  instancePath: string,
+  params?: Record<string, unknown>
+): string {
+  let path = instancePath.replace(/^\//, '').replace(/\//g, '.');
+
+  if (params?.missingProperty) {
+    const missing = params.missingProperty as string;
+    path = path ? `${path}.${missing}` : missing;
+  }
+
+  return path;
+}
+
+function formatAjvError(error: {
+  keyword: string;
+  message?: string;
+  params?: Record<string, unknown>;
+  instancePath: string;
+}): string {
+  const { keyword, message, params } = error;
+
+  switch (keyword) {
+    case 'required':
+      return `${params?.missingProperty ?? 'Field'} is required`;
+    case 'type':
+      return `Must be of type ${params?.type ?? 'unknown'}`;
+    case 'minimum':
+      return `Must be >= ${params?.limit}`;
+    case 'maximum':
+      return `Must be <= ${params?.limit}`;
+    case 'exclusiveMinimum':
+      return `Must be > ${params?.limit}`;
+    case 'exclusiveMaximum':
+      return `Must be < ${params?.limit}`;
+    case 'minLength':
+      return `Must be at least ${params?.limit} characters`;
+    case 'maxLength':
+      return `Must be at most ${params?.limit} characters`;
+    case 'pattern':
+      return `Must match pattern: ${params?.pattern}`;
+    case 'enum':
+      return `Must be one of: ${(params?.allowedValues as unknown[])?.join(', 
')}`;
+    case 'minItems':
+      return `Must have at least ${params?.limit} items`;
+    case 'maxItems':
+      return `Must have at most ${params?.limit} items`;
+    case 'uniqueItems':
+      return 'Items must be unique';
+    case 'oneOf':
+      return 'Must match exactly one of the options';
+    case 'anyOf':
+      return 'Must match at least one of the options';
+    default:
+      return message ?? `Validation failed: ${keyword}`;
+  }
+}
+
+function stripNonStandardFields(schema: JSONSchema): JSONSchema {
+  const cleaned = { ...schema };
+
+  delete (cleaned as Record<string, unknown>)['encrypt_fields'];
+  delete (cleaned as Record<string, unknown>)['_meta'];
+  delete (cleaned as Record<string, unknown>)['$comment'];
+
+  if (cleaned.properties) {
+    const props: Record<string, JSONSchema> = {};
+    for (const [key, value] of Object.entries(cleaned.properties)) {
+      props[key] = stripNonStandardFields(value);
+    }

Review 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.



##########
src/components/schema-form/utils.ts:
##########
@@ -0,0 +1,180 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { JSONSchema } from './types';
+
+export function getSchemaType(schema: JSONSchema): string | undefined {
+  if (schema.type) {
+    return Array.isArray(schema.type) ? schema.type[0] : schema.type;
+  }
+  if (schema.enum) return 'string';
+  if (schema.properties) return 'object';
+  if (schema.items) return 'array';
+  if (schema.oneOf) return 'oneOf';
+  if (schema.anyOf) return 'anyOf';
+  return undefined;
+}
+
+export function isEnumField(schema: JSONSchema): boolean {
+  return Array.isArray(schema.enum) && schema.enum.length > 0;
+}
+
+export function isEncryptField(
+  fieldName: string,
+  encryptFields?: string[]
+): boolean {
+  if (!encryptFields || encryptFields.length === 0) return false;
+  const baseName = fieldName.split('.').pop() ?? fieldName;
+  return encryptFields.includes(baseName);
+}
+
+export function isRequired(fieldName: string, parentSchema: JSONSchema): 
boolean {
+  if (!parentSchema.required) return false;
+  const baseName = fieldName.split('.').pop() ?? fieldName;
+  return parentSchema.required.includes(baseName);
+}
+
+export function getRequiredSet(schema: JSONSchema): Set<string> {
+  return new Set(schema.required ?? []);
+}
+
+export function resolveDefaults(schema: JSONSchema): Record<string, unknown> {
+  const defaults: Record<string, unknown> = {};
+  if (schema.type !== 'object' || !schema.properties) return defaults;
+
+  for (const [key, propSchema] of Object.entries(schema.properties)) {
+    if (propSchema.default !== undefined) {
+      defaults[key] = propSchema.default;
+    } else if (propSchema.type === 'object' && propSchema.properties) {
+      const nested = resolveDefaults(propSchema);
+      if (Object.keys(nested).length > 0) {
+        defaults[key] = nested;
+      }
+    }
+  }
+  return defaults;
+}
+
+export function fieldPath(rootPath: string, fieldName: string): string {
+  if (!rootPath) return fieldName;
+  return `${rootPath}.${fieldName}`;
+}
+
+export function getFieldLabel(name: string, schema: JSONSchema): string {
+  if (schema.title) return schema.title;
+  const baseName = name.split('.').pop() ?? name;
+  return baseName
+    .replace(/([A-Z])/g, ' $1')
+    .replace(/[_-]/g, ' ')
+    .replace(/^\w/, (c) => c.toUpperCase())
+    .trim();
+}
+
+export function getFieldDescription(schema: JSONSchema): string | undefined {
+  return schema.description;
+}
+
+export function hasConditionals(schema: JSONSchema): boolean {
+  return !!(schema.if && (schema.then || schema.else));
+}
+
+export function hasDependencies(schema: JSONSchema): boolean {
+  return !!(
+    schema.dependencies && Object.keys(schema.dependencies).length > 0
+  );
+}
+
+export function evaluateIfCondition(
+  schema: JSONSchema,
+  data: Record<string, unknown>
+): 'then' | 'else' | null {
+  if (!schema.if) return null;
+
+  const ifSchema = schema.if;
+  if (ifSchema.properties) {
+    for (const [key, propSchema] of Object.entries(ifSchema.properties)) {
+      const value = data[key];
+      if (propSchema.enum) {
+        if (!propSchema.enum.includes(value)) return 'else';
+      }
+      if (propSchema.type) {
+        const expectedType = Array.isArray(propSchema.type)
+          ? propSchema.type[0]
+          : propSchema.type;
+        if (typeof value !== expectedType) return 'else';
+      }

Review 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.



##########
src/components/schema-form/fields/ArrayField.tsx:
##########
@@ -0,0 +1,214 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  ActionIcon,
+  Box,
+  Button,
+  Fieldset,
+  Group,
+  TagsInput,
+  Text,
+} from '@mantine/core';
+import { useController, useFieldArray, useFormContext } from 'react-hook-form';
+
+import type { FieldProps, JSONSchema } from '../types';
+import { getFieldDescription, getFieldLabel, getSchemaType } from '../utils';
+import { SchemaField } from './SchemaField';
+
+export const ArrayField: React.FC<FieldProps> = ({
+  schema,
+  name,
+  required,
+  encryptFields,
+  disabled,
+}) => {
+  const label = getFieldLabel(name, schema);
+  const description = getFieldDescription(schema);
+
+  const itemSchema = (schema.items && !Array.isArray(schema.items))
+    ? schema.items
+    : undefined;
+
+  const itemType = itemSchema ? getSchemaType(itemSchema) : 'string';
+
+  // For simple string/number arrays, use TagsInput
+  if (itemType === 'string' && !itemSchema?.enum && !itemSchema?.properties) {
+    return (
+      <SimpleStringArray
+        schema={schema}
+        name={name}
+        label={label}
+        description={description}
+        required={required}
+        disabled={disabled}
+      />
+    );
+  }
+
+  // For object arrays, render repeatable fieldsets
+  if (itemSchema && (itemType === 'object' || itemSchema.properties)) {
+    return (
+      <ObjectArrayField
+        schema={schema}
+        itemSchema={itemSchema}
+        name={name}
+        label={label}
+        description={description}
+        required={required}
+        encryptFields={encryptFields}
+        disabled={disabled}
+      />
+    );
+  }
+
+  // Fallback: simple tags input
+  return (
+    <SimpleStringArray
+      schema={schema}
+      name={name}
+      label={label}
+      description={description}
+      required={required}
+      disabled={disabled}
+    />
+  );
+};
+
+const SimpleStringArray: React.FC<{
+  schema: JSONSchema;
+  name: string;
+  label: string;
+  description?: string;
+  required?: boolean;
+  disabled?: boolean;
+}> = ({ schema, name, label, description, required, disabled }) => {
+  const { control } = useFormContext();
+  const {
+    field: { value, onChange, onBlur, ref },
+  } = useController({
+    name,
+    control,
+    defaultValue: schema.default ?? [],
+  });
+
+  return (
+    <TagsInput
+      label={label}
+      description={description}
+      required={required}
+      disabled={disabled}
+      ref={ref}
+      onBlur={onBlur}
+      value={Array.isArray(value) ? value.map(String) : []}
+      onChange={(vals) => onChange(vals)}
+      acceptValueOnBlur

Review 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.



##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,176 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, Box, Button, Group, Stack } from '@mantine/core';
+import { useCallback, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { SchemaField } from './fields/SchemaField';
+import type { JSONSchema, SchemaFormProps } from './types';
+import { resolveDefaults } from './utils';
+import {
+  validateAgainstSchema,
+  type ValidationError,
+} from './validation';
+
+export const SchemaForm: React.FC<SchemaFormProps> = ({
+  schema,
+  value,
+  onChange,
+  onSubmit,
+  encryptFields,
+  disabled,
+  rootPath = '',
+}) => {
+  const defaults = resolveDefaults(schema);
+  const initialValues = { ...defaults, ...value };
+  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+    []
+  );
+
+  const methods = useForm({
+    defaultValues: initialValues,
+    mode: 'onBlur',
+  });
+
+  const handleChange = useCallback(
+    (data: Record<string, unknown>) => {
+      onChange?.(data);
+    },
+    [onChange]
+  );
+
+  const handleSubmit = useCallback(
+    (data: Record<string, unknown>) => {
+      // Run AJV validation against the full schema
+      const errors = validateAgainstSchema(schema, data);
+      setValidationErrors(errors);
+
+      if (errors.length > 0) {
+        // Map errors back to form fields
+        for (const err of errors) {
+          if (err.path) {
+            methods.setError(err.path, {
+              type: 'validate',
+              message: err.message,
+            });
+          }
+        }
+        return;
+      }
+
+      onSubmit?.(data);
+    },
+    [schema, onSubmit, methods]
+  );
+
+  // Watch for changes and propagate
+  if (onChange) {
+    methods.watch((data) => {
+      handleChange(data as Record<string, unknown>);
+    });
+  }

Review 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.



##########
src/components/schema-form/fields/StringField.tsx:
##########
@@ -0,0 +1,99 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PasswordInput, TextInput, Textarea } from '@mantine/core';
+import { useController, useFormContext } from 'react-hook-form';
+
+import type { FieldProps } from '../types';
+import { getFieldDescription, getFieldLabel, isEncryptField } from '../utils';
+
+export const StringField: React.FC<FieldProps> = ({
+  schema,
+  name,
+  required,
+  encryptFields,
+  disabled,
+}) => {
+  const { control } = useFormContext();
+  const {
+    field: { value, onChange, onBlur, ref },
+    fieldState: { error },
+  } = useController({
+    name,
+    control,
+    defaultValue: schema.default ?? '',
+    rules: {
+      required: required ? 'This field is required' : false,
+      minLength: schema.minLength
+        ? { value: schema.minLength, message: `Minimum ${schema.minLength} 
characters` }
+        : undefined,
+      maxLength: schema.maxLength
+        ? { value: schema.maxLength, message: `Maximum ${schema.maxLength} 
characters` }
+        : undefined,
+      pattern: schema.pattern
+        ? { value: new RegExp(schema.pattern), message: `Must match pattern: 
${schema.pattern}` }
+        : undefined,

Review 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.



##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,176 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, Box, Button, Group, Stack } from '@mantine/core';
+import { useCallback, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { SchemaField } from './fields/SchemaField';
+import type { JSONSchema, SchemaFormProps } from './types';
+import { resolveDefaults } from './utils';
+import {
+  validateAgainstSchema,
+  type ValidationError,
+} from './validation';
+
+export const SchemaForm: React.FC<SchemaFormProps> = ({
+  schema,
+  value,
+  onChange,
+  onSubmit,
+  encryptFields,
+  disabled,
+  rootPath = '',
+}) => {
+  const defaults = resolveDefaults(schema);
+  const initialValues = { ...defaults, ...value };
+  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+    []
+  );
+
+  const methods = useForm({
+    defaultValues: initialValues,
+    mode: 'onBlur',
+  });
+
+  const handleChange = useCallback(
+    (data: Record<string, unknown>) => {
+      onChange?.(data);
+    },
+    [onChange]
+  );
+
+  const handleSubmit = useCallback(
+    (data: Record<string, unknown>) => {
+      // Run AJV validation against the full schema
+      const errors = validateAgainstSchema(schema, data);
+      setValidationErrors(errors);
+
+      if (errors.length > 0) {
+        // Map errors back to form fields
+        for (const err of errors) {
+          if (err.path) {
+            methods.setError(err.path, {
+              type: 'validate',
+              message: err.message,
+            });
+          }
+        }
+        return;
+      }
+
+      onSubmit?.(data);
+    },
+    [schema, onSubmit, methods]
+  );
+
+  // Watch for changes and propagate
+  if (onChange) {
+    methods.watch((data) => {
+      handleChange(data as Record<string, unknown>);
+    });
+  }
+
+  if (!schema || !schema.properties) {
+    return (
+      <Alert color="yellow" title="No schema">
+        No renderable schema properties found.
+      </Alert>
+    );
+  }
+
+  const requiredSet = new Set(schema.required ?? []);
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={methods.handleSubmit(handleSubmit)}>
+        <Stack gap="md">
+          {Object.entries(schema.properties).map(([key, propSchema]) => (
+            <Box key={key}>
+              <SchemaField
+                schema={propSchema}
+                name={rootPath ? `${rootPath}.${key}` : key}
+                required={requiredSet.has(key)}
+                encryptFields={encryptFields ?? schema.encrypt_fields}
+                disabled={disabled}
+              />
+            </Box>
+          ))}
+
+          <ConditionalTopLevel
+            schema={schema}
+            encryptFields={encryptFields ?? schema.encrypt_fields}
+            disabled={disabled}
+            rootPath={rootPath}
+          />
+
+          {validationErrors.length > 0 && (
+            <Alert color="red" title="Validation Errors">
+              <ul style={{ margin: 0, paddingLeft: 16 }}>
+                {validationErrors.map((err, i) => (
+                  <li key={i}>
+                    {err.path ? <strong>{err.path}:</strong> : null}{' '}
+                    {err.message}
+                  </li>
+                ))}
+              </ul>
+            </Alert>
+          )}
+
+          {onSubmit && (
+            <Group justify="flex-end">
+              <Button type="submit" disabled={disabled}>
+                Submit
+              </Button>
+            </Group>
+          )}
+        </Stack>
+      </form>
+    </FormProvider>
+  );
+};
+
+const ConditionalTopLevel: React.FC<{
+  schema: JSONSchema;
+  encryptFields?: string[];
+  disabled?: boolean;
+  rootPath: string;
+}> = ({ schema, encryptFields, disabled, rootPath }) => {
+  // 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
+

Review 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.



##########
src/components/schema-form/fields/ObjectField.tsx:
##########
@@ -0,0 +1,251 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Box, Fieldset, Text } from '@mantine/core';
+import { useFormContext, useWatch } from 'react-hook-form';
+
+import type { FieldProps, JSONSchema } from '../types';
+import {
+  evaluateIfCondition,
+  getFieldDescription,
+  getFieldLabel,
+  mergeSchemas,
+} from '../utils';
+import { SchemaField } from './SchemaField';
+
+export const ObjectField: React.FC<FieldProps> = ({
+  schema,
+  name,
+  required: _required,
+  encryptFields,
+  disabled,
+}) => {
+  const label = getFieldLabel(name, schema);
+  const description = getFieldDescription(schema);
+
+  if (!schema.properties && !schema.patternProperties) {

Review 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.
   ```suggestion
     if (!schema.properties) {
   ```



##########
docs/en/schema-form-developer-guide.md:
##########
@@ -0,0 +1,293 @@
+---
+title: Schema Form Developer Guide
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# SchemaForm Developer Guide
+
+The `SchemaForm` component renders APISIX plugin configuration forms directly 
from JSON Schema definitions. This eliminates manual UI maintenance per plugin 
and ensures form UIs stay in sync with plugin schemas automatically.
+
+## Quick Start
+
+```tsx
+import { SchemaForm } from '@/components/schema-form';
+
+// Schema fetched from Admin API: GET /apisix/admin/schema/plugins/{name}
+const pluginSchema = {
+  type: 'object',
+  properties: {
+    key: { type: 'string', minLength: 1 },
+    timeout: { type: 'integer', minimum: 1, default: 3 },
+    ssl_verify: { type: 'boolean', default: true },
+  },
+  required: ['key'],
+};
+
+function PluginConfigForm() {
+  return (
+    <SchemaForm
+      schema={pluginSchema}
+      value={{ key: 'my-api-key' }}
+      onSubmit={(data) => console.log('Valid config:', data)}
+      onChange={(data) => console.log('Changed:', data)}
+    />
+  );
+}
+```
+
+## Architecture
+
+```
+SchemaForm (entry point, provides react-hook-form context)
+  └── SchemaField (dispatcher — routes to correct field component)
+        ├── StringField      → Mantine TextInput / PasswordInput / Textarea
+        ├── NumberField       → Mantine NumberInput
+        ├── BooleanField      → Mantine Switch
+        ├── EnumField         → Mantine Select
+        ├── ObjectField       → Mantine Fieldset (recursive)
+        │     ├── ConditionalProperties  (if/then/else)
+        │     └── DependencyProperties   (dependencies)
+        ├── ArrayField        → TagsInput (primitives) or repeatable Fieldset 
(objects)
+        ├── OneOfField        → SegmentedControl + variant fields
+        ├── AnyOfField        → SegmentedControl + variant fields
+        └── PatternPropertiesField → dynamic key-value editor
+```
+
+### Data Flow
+
+1. **Schema** is passed as a prop (fetched from APISIX Admin API at runtime).
+2. `SchemaForm` initializes `react-hook-form` with defaults resolved from the 
schema.
+3. `SchemaField` dispatches to the correct field component based on schema 
type/features.
+4. Field components use `useController` to bind to react-hook-form state.
+5. On submit, **AJV** validates the full form data against the original JSON 
Schema.
+6. Validation errors are mapped back to individual form fields.
+
+## Supported JSON Schema Features
+
+### Basic Types
+
+| Schema Type | Widget | Notes |
+|---|---|---|
+| `string` | `TextInput` | Auto-uses `Textarea` for `maxLength > 256` |
+| `string` + `encrypt_fields` | `PasswordInput` | Masked input with reveal 
toggle |
+| `string` + `enum` | `Select` | Dropdown with enum values |
+| `number` / `integer` | `NumberInput` | Respects `min`/`max`/`step` |
+| `boolean` | `Switch` | Left-positioned label |
+| `object` | `Fieldset` | Recursive rendering of properties |
+| `array` of strings | `TagsInput` | Tag-style input |
+| `array` of objects | Repeatable `Fieldset` | Add/remove items |
+
+### Constraints
+
+All standard JSON Schema constraints are supported:
+
+- `required` — field marked as required, validated on submit
+- `default` — pre-populated in the form
+- `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum`
+- `minLength` / `maxLength`
+- `pattern` — shown as placeholder hint
+- `minItems` / `maxItems` — controls add/remove in arrays
+- `enum` — rendered as Select dropdown
+
+### Composition Keywords
+
+#### `oneOf`
+
+Used in two patterns in APISIX:
+
+1. **Top-level required alternatives** (e.g., limit-conn: `conn+burst+key` vs 
`rules`):
+   Renders a `SegmentedControl` to select the configuration mode.
+
+2. **Field-level type unions** (e.g., `conn: oneOf [{integer}, {string}]`):
+   Renders the primary type (first option). The schema allows either an 
integer value or a string variable reference.
+
+#### `anyOf`
+
+Similar to `oneOf` but allows matching multiple schemas. Rendered with a 
`SegmentedControl` selector.
+
+#### `if / then / else`
+
+Conditional sub-schemas that show/hide fields based on other field values. 
Common APISIX pattern:
+
+```json
+{
+  "if": { "properties": { "policy": { "enum": ["redis"] } } },
+  "then": { "properties": { "redis_host": { "type": "string" } }, "required": 
["redis_host"] },
+  "else": {}
+}
+```
+
+The form watches the `policy` field and dynamically shows `redis_host` when 
`policy === "redis"`.
+
+Supports **nested** if/then/else chains (e.g., limit-conn: redis → 
redis-cluster fallback).
+
+#### `dependencies`
+
+Two forms:
+
+1. **Simple dependencies** (`dependencies: { "a": ["b"] }`): If `a` is 
present, `b` is required. Handled by AJV validation.
+
+2. **Schema dependencies with `oneOf`** (e.g., jwt-auth consumer schema):
+   ```json
+   {
+     "dependencies": {
+       "algorithm": {
+         "oneOf": [
+           { "properties": { "algorithm": { "enum": ["HS256", "HS384"] } } },
+           { "properties": { "public_key": { "type": "string" } }, "required": 
["public_key"] }
+         ]
+       }
+     }
+   }
+   ```
+   Watches the `algorithm` field and shows `public_key` when a non-HMAC 
algorithm is selected.
+
+### APISIX-Specific Features
+
+- **`encrypt_fields`**: Fields listed here are rendered as `PasswordInput` 
(masked).
+- **`_meta`**: The plugin injected meta schema is stripped during validation.
+
+## Validation
+
+Validation uses [AJV](https://ajv.js.org/) (JSON Schema draft-07) configured 
with:
+
+- `allErrors: true` — collect all errors, not just the first
+- `strict: false` — allow APISIX-specific keywords
+- `ajv-formats` — support for `format: "ipv4"`, `format: "ipv6"`, etc.
+
+APISIX-specific fields (`encrypt_fields`, `_meta`, `$comment`) are stripped 
before compilation.
+
+### Error Mapping
+
+AJV errors are mapped to react-hook-form field paths:
+
+```
+/properties/redis_host  →  redis_host
+/items/0/host           →  items.0.host

Review Comment:
   The AJV error-path mapping example appears incorrect: AJV’s `instancePath` 
for a failing property is typically `/redis_host` (not 
`/properties/redis_host`). Since `ajvErrorToFieldPath` is based on 
`instancePath`, the docs should reflect that to avoid confusing future 
maintainers.
   ```suggestion
   /redis_host           →  redis_host
   /items/0/host         →  items.0.host
   ```



##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,176 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, Box, Button, Group, Stack } from '@mantine/core';
+import { useCallback, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { SchemaField } from './fields/SchemaField';
+import type { JSONSchema, SchemaFormProps } from './types';
+import { resolveDefaults } from './utils';
+import {
+  validateAgainstSchema,
+  type ValidationError,
+} from './validation';
+
+export const SchemaForm: React.FC<SchemaFormProps> = ({
+  schema,
+  value,
+  onChange,
+  onSubmit,
+  encryptFields,
+  disabled,
+  rootPath = '',
+}) => {
+  const defaults = resolveDefaults(schema);
+  const initialValues = { ...defaults, ...value };
+  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+    []
+  );
+
+  const methods = useForm({
+    defaultValues: initialValues,
+    mode: 'onBlur',
+  });
+
+  const handleChange = useCallback(
+    (data: Record<string, unknown>) => {
+      onChange?.(data);
+    },
+    [onChange]
+  );
+
+  const handleSubmit = useCallback(
+    (data: Record<string, unknown>) => {
+      // Run AJV validation against the full schema
+      const errors = validateAgainstSchema(schema, data);
+      setValidationErrors(errors);
+
+      if (errors.length > 0) {
+        // Map errors back to form fields
+        for (const err of errors) {
+          if (err.path) {
+            methods.setError(err.path, {

Review 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`.



##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,176 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, Box, Button, Group, Stack } from '@mantine/core';
+import { useCallback, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { SchemaField } from './fields/SchemaField';
+import type { JSONSchema, SchemaFormProps } from './types';
+import { resolveDefaults } from './utils';
+import {
+  validateAgainstSchema,
+  type ValidationError,
+} from './validation';
+
+export const SchemaForm: React.FC<SchemaFormProps> = ({
+  schema,
+  value,
+  onChange,
+  onSubmit,
+  encryptFields,
+  disabled,
+  rootPath = '',
+}) => {
+  const defaults = resolveDefaults(schema);
+  const initialValues = { ...defaults, ...value };
+  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+    []
+  );
+
+  const methods = useForm({
+    defaultValues: initialValues,
+    mode: 'onBlur',
+  });
+
+  const handleChange = useCallback(
+    (data: Record<string, unknown>) => {
+      onChange?.(data);
+    },
+    [onChange]
+  );
+
+  const handleSubmit = useCallback(
+    (data: Record<string, unknown>) => {
+      // Run AJV validation against the full schema
+      const errors = validateAgainstSchema(schema, data);
+      setValidationErrors(errors);
+
+      if (errors.length > 0) {
+        // Map errors back to form fields
+        for (const err of errors) {
+          if (err.path) {
+            methods.setError(err.path, {
+              type: 'validate',
+              message: err.message,
+            });
+          }
+        }
+        return;
+      }
+
+      onSubmit?.(data);
+    },
+    [schema, onSubmit, methods]
+  );
+
+  // Watch for changes and propagate
+  if (onChange) {
+    methods.watch((data) => {
+      handleChange(data as Record<string, unknown>);
+    });
+  }
+
+  if (!schema || !schema.properties) {
+    return (
+      <Alert color="yellow" title="No schema">
+        No renderable schema properties found.
+      </Alert>
+    );
+  }
+
+  const requiredSet = new Set(schema.required ?? []);
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={methods.handleSubmit(handleSubmit)}>
+        <Stack gap="md">
+          {Object.entries(schema.properties).map(([key, propSchema]) => (
+            <Box key={key}>
+              <SchemaField
+                schema={propSchema}
+                name={rootPath ? `${rootPath}.${key}` : key}
+                required={requiredSet.has(key)}
+                encryptFields={encryptFields ?? schema.encrypt_fields}
+                disabled={disabled}
+              />
+            </Box>
+          ))}
+
+          <ConditionalTopLevel
+            schema={schema}
+            encryptFields={encryptFields ?? schema.encrypt_fields}
+            disabled={disabled}
+            rootPath={rootPath}
+          />
+
+          {validationErrors.length > 0 && (
+            <Alert color="red" title="Validation Errors">
+              <ul style={{ margin: 0, paddingLeft: 16 }}>
+                {validationErrors.map((err, i) => (
+                  <li key={i}>
+                    {err.path ? <strong>{err.path}:</strong> : null}{' '}
+                    {err.message}
+                  </li>
+                ))}
+              </ul>
+            </Alert>
+          )}
+
+          {onSubmit && (
+            <Group justify="flex-end">
+              <Button type="submit" disabled={disabled}>
+                Submit
+              </Button>
+            </Group>
+          )}
+        </Stack>
+      </form>
+    </FormProvider>
+  );
+};
+
+const ConditionalTopLevel: React.FC<{
+  schema: JSONSchema;
+  encryptFields?: string[];
+  disabled?: boolean;
+  rootPath: string;
+}> = ({ schema, encryptFields, disabled, rootPath }) => {
+  // 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
+
+  // Handle top-level if/then/else
+  if (schema.if && (schema.then || schema.else)) {
+    return (
+      <SchemaField
+        schema={{
+          type: 'object',
+          properties: schema.properties,

Review 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.
   ```suggestion
             // Do not re-pass top-level properties; SchemaForm already renders 
them.
             // Use an empty properties object so only conditional branch 
fields render.
             properties: {},
   ```



##########
src/components/schema-form/SchemaForm.tsx:
##########
@@ -0,0 +1,176 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Alert, Box, Button, Group, Stack } from '@mantine/core';
+import { useCallback, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { SchemaField } from './fields/SchemaField';
+import type { JSONSchema, SchemaFormProps } from './types';
+import { resolveDefaults } from './utils';
+import {
+  validateAgainstSchema,
+  type ValidationError,
+} from './validation';
+
+export const SchemaForm: React.FC<SchemaFormProps> = ({
+  schema,
+  value,
+  onChange,
+  onSubmit,
+  encryptFields,
+  disabled,
+  rootPath = '',
+}) => {
+  const defaults = resolveDefaults(schema);
+  const initialValues = { ...defaults, ...value };
+  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+    []
+  );
+
+  const methods = useForm({
+    defaultValues: initialValues,
+    mode: 'onBlur',
+  });

Review 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).



-- 
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