Skip to content

Commit fb8581c

Browse files
committed
feat(schema-form): add array support and AJV validation
- Add ArrayField component using useFieldArray for object arrays - Add string array support using TagsInput - Create validation.ts with AJV integration - Add createSchemaResolver for React Hook Form - Map AJV errors to user-friendly messages - Test with real proxy-rewrite plugin schema - Add tabs to demo page for schema comparison Addresses feedback from Issue #2986
1 parent 0f057c1 commit fb8581c

File tree

9 files changed

+2528
-1987
lines changed

9 files changed

+2528
-1987
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ dist-ssr
3232
/playwright/.cache/
3333

3434
.eslintcache
35+
jsonschema-poc/POC_SUMMARY.md
36+
jsonschema-poc/README.md

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"@monaco-editor/react": "^4.7.0",
2424
"@tanstack/react-query": "^5.74.4",
2525
"@tanstack/react-router": "^1.116.0",
26+
"ajv": "^8.17.1",
27+
"ajv-formats": "^3.0.1",
2628
"antd": "^5.24.8",
2729
"axios": "^1.13.2",
2830
"clsx": "^2.1.1",

pnpm-lock.yaml

Lines changed: 1851 additions & 1926 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
/* eslint-disable i18next/no-literal-string */
18+
import { ActionIcon, Button, Fieldset, Group, Stack, Text } from '@mantine/core';
19+
import type { Control, FieldValues } from 'react-hook-form';
20+
import { useFieldArray } from 'react-hook-form';
21+
import IconPlus from '~icons/material-symbols/add';
22+
import IconTrash from '~icons/material-symbols/delete-outline';
23+
24+
import { SchemaField } from './SchemaField';
25+
import type { JSONSchema7 } from './types';
26+
27+
export type ArrayFieldProps = {
28+
name: string;
29+
schema: JSONSchema7;
30+
control: Control<FieldValues>;
31+
required?: boolean;
32+
};
33+
34+
/**
35+
* ArrayField - Renders a dynamic array of fields using useFieldArray
36+
*
37+
* Handles:
38+
* - Arrays of objects with Add/Remove functionality
39+
* - Nested object schemas within array items
40+
*/
41+
export const ArrayField = ({ name, schema, control, required: _required }: ArrayFieldProps) => {
42+
const { fields, append, remove } = useFieldArray({
43+
control,
44+
name,
45+
});
46+
47+
const itemSchema = schema.items as JSONSchema7;
48+
49+
// Generate default values for new items based on schema
50+
const getDefaultItem = (): Record<string, unknown> => {
51+
if (itemSchema.type !== 'object' || !itemSchema.properties) {
52+
return {};
53+
}
54+
55+
const defaultItem: Record<string, unknown> = {};
56+
Object.entries(itemSchema.properties).forEach(([key, propSchema]) => {
57+
const prop = propSchema as JSONSchema7;
58+
if (prop.default !== undefined) {
59+
defaultItem[key] = prop.default;
60+
} else if (prop.type === 'string') {
61+
defaultItem[key] = '';
62+
} else if (prop.type === 'number' || prop.type === 'integer') {
63+
defaultItem[key] = 0;
64+
} else if (prop.type === 'boolean') {
65+
defaultItem[key] = false;
66+
}
67+
});
68+
return defaultItem;
69+
};
70+
71+
const handleAdd = () => {
72+
append(getDefaultItem());
73+
};
74+
75+
const formatLabel = (fieldName: string): string => {
76+
const name = fieldName.split('.').pop() || fieldName;
77+
return name
78+
.split('_')
79+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
80+
.join(' ');
81+
};
82+
83+
return (
84+
<Fieldset legend={schema.title || formatLabel(name)} mt="md">
85+
<Stack gap="md">
86+
{schema.description && (
87+
<Text size="sm" c="dimmed">
88+
{schema.description}
89+
</Text>
90+
)}
91+
92+
{fields.length === 0 && (
93+
<Text size="sm" c="dimmed" fs="italic">
94+
No items added yet. Click "Add Item" to begin.
95+
</Text>
96+
)}
97+
98+
{fields.map((field, index) => (
99+
<Fieldset key={field.id} variant="filled">
100+
<Group justify="space-between" mb="sm">
101+
<Text size="sm" fw={500}>
102+
Item {index + 1}
103+
</Text>
104+
<ActionIcon
105+
variant="subtle"
106+
color="red"
107+
size="sm"
108+
onClick={() => remove(index)}
109+
aria-label={`Remove item ${index + 1}`}
110+
>
111+
<IconTrash />
112+
</ActionIcon>
113+
</Group>
114+
115+
<Stack gap="sm">
116+
{itemSchema.properties &&
117+
Object.entries(itemSchema.properties).map(
118+
([key, propSchema]) => (
119+
<SchemaField
120+
key={`${name}.${index}.${key}`}
121+
name={`${name}.${index}.${key}`}
122+
schema={propSchema as JSONSchema7}
123+
control={control}
124+
required={itemSchema.required?.includes(key)}
125+
/>
126+
)
127+
)}
128+
</Stack>
129+
</Fieldset>
130+
))}
131+
132+
<Button
133+
variant="light"
134+
leftSection={<IconPlus />}
135+
onClick={handleAdd}
136+
size="sm"
137+
>
138+
Add Item
139+
</Button>
140+
</Stack>
141+
</Fieldset>
142+
);
143+
};

src/components/form/SchemaForm/SchemaField.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import type { Control, FieldValues } from 'react-hook-form';
2020
import { FormItemNumberInput } from '../NumberInput';
2121
import { FormItemSelect } from '../Select';
2222
import { FormItemSwitch } from '../Switch';
23+
import { FormItemTextArray } from '../TextArray';
2324
import { FormItemTextInput } from '../TextInput';
25+
import { ArrayField } from './ArrayField';
2426
import type { JSONSchema7 } from './types';
2527

2628
export type SchemaFieldProps = {
@@ -61,6 +63,36 @@ export const SchemaField = (
6163
);
6264
}
6365

66+
// Handle arrays
67+
if (schema.type === 'array' && schema.items) {
68+
const itemSchema = schema.items as JSONSchema7;
69+
70+
// Simple string/number arrays → TagsInput
71+
if (itemSchema.type === 'string' || itemSchema.type === 'number') {
72+
return (
73+
<FormItemTextArray
74+
name={name}
75+
control={control}
76+
label={schema.title || formatLabel(name)}
77+
description={schema.description}
78+
placeholder={`Add ${formatLabel(name).toLowerCase()} and press Enter`}
79+
/>
80+
);
81+
}
82+
83+
// Object arrays → Use ArrayField with useFieldArray
84+
if (itemSchema.type === 'object') {
85+
return (
86+
<ArrayField
87+
name={name}
88+
schema={schema}
89+
control={control}
90+
required={required}
91+
/>
92+
);
93+
}
94+
}
95+
6496
// Handle enums → Select dropdown
6597
if (schema.enum) {
6698
return (

src/components/form/SchemaForm/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,5 +239,7 @@ function findDiscriminators(oneOf: JSONSchema7['oneOf']): string[] {
239239
return Array.from(discriminators);
240240
}
241241

242+
export { ArrayField } from './ArrayField';
242243
export { SchemaField } from './SchemaField';
243244
export type { JSONSchema7 } from './types';
245+
export { createSchemaResolver, validateWithSchema } from './validation';

src/components/form/SchemaForm/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface JSONSchema7 {
5050
properties?: Record<string, JSONSchema7>;
5151
required?: string[];
5252
additionalProperties?: boolean | JSONSchema7;
53+
minProperties?: number;
54+
maxProperties?: number;
5355

5456
// Enum
5557
enum?: unknown[];
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import Ajv, { type ErrorObject } from 'ajv';
18+
import addFormats from 'ajv-formats';
19+
import type { FieldErrors, FieldValues } from 'react-hook-form';
20+
21+
import type { JSONSchema7 } from './types';
22+
23+
// Create AJV instance with formats support
24+
const ajv = new Ajv({
25+
allErrors: true, // Report all errors, not just the first one
26+
verbose: true, // Include schema and data in error objects
27+
strict: false, // Be lenient with schema keywords
28+
});
29+
30+
// Add format validators (email, uri, date, etc.)
31+
addFormats(ajv);
32+
33+
/**
34+
* Validates form data against a JSON Schema using AJV
35+
* @param schema - The JSON Schema to validate against
36+
* @param data - The form data to validate
37+
* @returns Object containing isValid flag and any errors
38+
*/
39+
export function validateWithSchema<T extends FieldValues>(
40+
schema: JSONSchema7,
41+
data: T
42+
): { isValid: boolean; errors: FieldErrors<T> } {
43+
const validate = ajv.compile(schema);
44+
const isValid = validate(data);
45+
46+
if (isValid) {
47+
return { isValid: true, errors: {} as FieldErrors<T> };
48+
}
49+
50+
// Map AJV errors to React Hook Form format
51+
const errors = mapAjvErrorsToFormErrors<T>(validate.errors || []);
52+
return { isValid: false, errors };
53+
}
54+
55+
/**
56+
* Maps AJV error objects to React Hook Form field errors format
57+
* AJV uses JSON Pointer format (e.g., "/headers/add/0/name")
58+
* React Hook Form uses dot notation (e.g., "headers.add.0.name")
59+
*/
60+
function mapAjvErrorsToFormErrors<T extends FieldValues>(
61+
ajvErrors: ErrorObject[]
62+
): FieldErrors<T> {
63+
const errors: Record<string, { type: string; message: string }> = {};
64+
65+
ajvErrors.forEach((error) => {
66+
// Convert AJV instance path to form field path
67+
// AJV: "/headers/add/0/name" -> RHF: "headers.add.0.name"
68+
let fieldPath = error.instancePath
69+
.replace(/^\//, '') // Remove leading slash
70+
.replace(/\//g, '.'); // Replace slashes with dots
71+
72+
// Handle root-level errors (required fields at root)
73+
if (error.keyword === 'required' && error.params.missingProperty) {
74+
const missingProp = error.params.missingProperty as string;
75+
fieldPath = fieldPath ? `${fieldPath}.${missingProp}` : missingProp;
76+
}
77+
78+
// Handle additionalProperties error
79+
if (error.keyword === 'additionalProperties' && error.params.additionalProperty) {
80+
const extraProp = error.params.additionalProperty as string;
81+
fieldPath = fieldPath ? `${fieldPath}.${extraProp}` : extraProp;
82+
}
83+
84+
// Skip if no field path (root-level schema errors)
85+
if (!fieldPath && !['required', 'minProperties'].includes(error.keyword)) {
86+
return;
87+
}
88+
89+
// Generate user-friendly error message
90+
const message = generateErrorMessage(error);
91+
92+
// Don't overwrite existing errors for the same field
93+
if (!errors[fieldPath || '_root']) {
94+
errors[fieldPath || '_root'] = {
95+
type: error.keyword,
96+
message,
97+
};
98+
}
99+
});
100+
101+
return errors as unknown as FieldErrors<T>;
102+
}
103+
104+
/**
105+
* Generates a user-friendly error message from an AJV error
106+
*/
107+
function generateErrorMessage(error: ErrorObject): string {
108+
const { keyword, params, message } = error;
109+
110+
switch (keyword) {
111+
case 'required':
112+
return `${formatFieldName(params.missingProperty as string)} is required`;
113+
case 'type':
114+
return `Must be a ${params.type}`;
115+
case 'minLength':
116+
return `Must be at least ${params.limit} characters`;
117+
case 'maxLength':
118+
return `Must be at most ${params.limit} characters`;
119+
case 'minimum':
120+
return `Must be at least ${params.limit}`;
121+
case 'maximum':
122+
return `Must be at most ${params.limit}`;
123+
case 'pattern':
124+
return `Invalid format`;
125+
case 'enum':
126+
return `Must be one of: ${(params.allowedValues as string[]).join(', ')}`;
127+
case 'minItems':
128+
return `Must have at least ${params.limit} items`;
129+
case 'maxItems':
130+
return `Must have at most ${params.limit} items`;
131+
case 'format':
132+
return `Invalid ${params.format} format`;
133+
case 'additionalProperties':
134+
return `Unknown field: ${params.additionalProperty}`;
135+
default:
136+
return message || 'Invalid value';
137+
}
138+
}
139+
140+
/**
141+
* Formats a field name for display in error messages
142+
* e.g., "oauth_client_id" -> "OAuth Client ID"
143+
*/
144+
function formatFieldName(name: string): string {
145+
if (!name) return 'This field';
146+
return name
147+
.split('_')
148+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
149+
.join(' ');
150+
}
151+
152+
/**
153+
* Creates a React Hook Form resolver that uses AJV for validation
154+
* This can be passed to useForm({ resolver: createSchemaResolver(schema) })
155+
*/
156+
export function createSchemaResolver(schema: JSONSchema7) {
157+
return async <T extends FieldValues>(data: T) => {
158+
const { isValid, errors } = validateWithSchema(schema, data);
159+
160+
if (isValid) {
161+
return { values: data, errors: {} };
162+
}
163+
164+
return {
165+
values: {},
166+
errors,
167+
};
168+
};
169+
}
170+
171+
export { ajv };

0 commit comments

Comments
 (0)