Skip to content

Commit d433fe0

Browse files
IzumiSytoiroakr
andauthored
Add nested object support in json schema generation (#166)
* Add nested object support in json schema generation * Add changeset * Update packages/fabrix/src/renderers/form/validation.ts Co-authored-by: Akira HIGUCHI <akira_higuchi@tailor.tech> * Add more test cases --------- Co-authored-by: Akira HIGUCHI <akira_higuchi@tailor.tech>
1 parent 2b69b73 commit d433fe0

File tree

3 files changed

+234
-14
lines changed

3 files changed

+234
-14
lines changed

.changeset/brave-apples-love.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fabrix-framework/fabrix": minor
3+
---
4+
5+
Support constraints for nested object field in fabrixForm directive API
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Path } from "@visitor/path";
3+
import { buildAjvSchema } from "./validation";
4+
5+
describe("buildAjvSchema", () => {
6+
it("empty input should return an empty schema", () => {
7+
expect(buildAjvSchema([])).toEqual({
8+
type: "object",
9+
properties: {},
10+
required: [],
11+
additionalProperties: true,
12+
});
13+
});
14+
15+
const id = {
16+
input: {
17+
meta: {
18+
fieldType: { type: "Scalar", name: "ID" },
19+
isRequired: true,
20+
},
21+
constraint: null,
22+
config: {
23+
hidden: false,
24+
gridCol: 12,
25+
},
26+
},
27+
expected: {
28+
id: {
29+
type: "string",
30+
},
31+
},
32+
} as const;
33+
34+
const name = {
35+
input: {
36+
meta: {
37+
fieldType: { type: "Scalar", name: "String" },
38+
isRequired: true,
39+
},
40+
constraint: { minLength: 3, maxLength: 5 },
41+
config: {
42+
hidden: false,
43+
gridCol: 12,
44+
},
45+
},
46+
expected: {
47+
name: {
48+
type: "string",
49+
minLength: 3,
50+
maxLength: 5,
51+
},
52+
},
53+
} as const;
54+
55+
const age = {
56+
input: {
57+
meta: {
58+
fieldType: { type: "Scalar", name: "Int" },
59+
isRequired: false,
60+
},
61+
constraint: { min: 0, max: 65 },
62+
config: {
63+
hidden: false,
64+
gridCol: 12,
65+
},
66+
},
67+
expected: {
68+
age: {
69+
type: "number",
70+
minimum: 0,
71+
maximum: 65,
72+
},
73+
},
74+
} as const;
75+
76+
it.each([
77+
[
78+
"fields - no level",
79+
[
80+
{
81+
field: new Path(["name"]),
82+
...name.input,
83+
},
84+
{
85+
field: new Path(["age"]),
86+
...age.input,
87+
},
88+
],
89+
{
90+
type: "object",
91+
properties: {
92+
...name.expected,
93+
...age.expected,
94+
},
95+
required: ["name"],
96+
additionalProperties: true,
97+
},
98+
],
99+
100+
[
101+
"containing some nested fields - 2 level",
102+
[
103+
{
104+
field: new Path(["id"]),
105+
...id.input,
106+
},
107+
{
108+
field: new Path(["input", "name"]),
109+
...name.input,
110+
},
111+
{
112+
field: new Path(["input", "age"]),
113+
...age.input,
114+
},
115+
],
116+
{
117+
type: "object",
118+
properties: {
119+
...id.expected,
120+
input: {
121+
type: "object",
122+
properties: {
123+
...name.expected,
124+
...age.expected,
125+
},
126+
required: ["name"],
127+
additionalProperties: true,
128+
},
129+
},
130+
required: ["id"],
131+
additionalProperties: true,
132+
},
133+
],
134+
135+
[
136+
"containing nested fields - 3 level",
137+
[
138+
{
139+
field: new Path(["id"]),
140+
...id.input,
141+
},
142+
{
143+
field: new Path(["input", "id"]),
144+
...id.input,
145+
},
146+
{
147+
field: new Path(["input", "nested", "name"]),
148+
...name.input,
149+
},
150+
{
151+
field: new Path(["input", "nested", "age"]),
152+
...age.input,
153+
},
154+
],
155+
{
156+
type: "object",
157+
properties: {
158+
...id.expected,
159+
input: {
160+
type: "object",
161+
properties: {
162+
...id.expected,
163+
nested: {
164+
type: "object",
165+
properties: {
166+
...name.expected,
167+
...age.expected,
168+
},
169+
required: ["name"],
170+
additionalProperties: true,
171+
},
172+
},
173+
required: ["id"],
174+
additionalProperties: true,
175+
},
176+
},
177+
required: ["id"],
178+
additionalProperties: true,
179+
},
180+
],
181+
])("should return a schema (%s)", (_, input, expected) => {
182+
expect(buildAjvSchema(input)).toEqual(expected);
183+
});
184+
});

packages/fabrix/src/renderers/form/validation.ts

+45-14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FormField, FormFields } from "@renderers/form";
2+
import { JSONSchemaType } from "ajv";
23

34
const convertToAjvProperty = (field: FormField) => {
45
switch (field.meta?.fieldType?.type) {
@@ -31,27 +32,57 @@ const convertToAjvProperty = (field: FormField) => {
3132
type: "boolean",
3233
} as const;
3334
}
35+
case "Enum": {
36+
return {
37+
type: "string",
38+
enum: field.meta.fieldType.meta.values,
39+
} as const;
40+
}
3441
default:
3542
// TODO: handle other types (e.g. object, array)
3643
return null;
3744
}
3845
};
3946

40-
export const buildAjvSchema = (fields: FormFields) => {
41-
const visibleFields = fields.filter((field) => !field.config.hidden);
42-
const requiredFields = visibleFields.filter(
43-
(field) => field.meta?.isRequired,
44-
);
47+
type SchemaType = {
48+
type: "object";
49+
properties: Record<string, unknown>;
50+
required: Array<string>;
51+
additionalProperties: true;
52+
};
4553

46-
return {
54+
export const buildAjvSchema = (fields: FormFields) => {
55+
const schema: SchemaType = {
4756
type: "object",
48-
properties: visibleFields.reduce((acc, field) => {
49-
const property = convertToAjvProperty(field);
50-
return property === null
51-
? acc
52-
: { ...acc, [field.field.asKey()]: property };
53-
}, {}),
54-
required: requiredFields.map((field) => field.field.asKey()),
57+
properties: {},
58+
required: [],
5559
additionalProperties: true,
56-
} as const;
60+
};
61+
62+
fields.forEach((field) => {
63+
const path = field.field.asKey().split(".");
64+
const current = path.slice(0, -1).reduce<SchemaType>((acc, key) => {
65+
if (!acc.properties[key]) {
66+
acc.properties[key] = {
67+
type: "object",
68+
properties: {},
69+
required: [],
70+
additionalProperties: true,
71+
};
72+
}
73+
return acc.properties[key] as SchemaType;
74+
}, schema);
75+
76+
const lastKey = path[path.length - 1];
77+
const property = convertToAjvProperty(field);
78+
79+
if (property !== null) {
80+
current.properties[lastKey] = property;
81+
if (field.meta?.isRequired) {
82+
current.required.push(lastKey);
83+
}
84+
}
85+
});
86+
87+
return schema as unknown as JSONSchemaType<Record<string, unknown>>;
5788
};

0 commit comments

Comments
 (0)