Skip to content

Commit b448b0a

Browse files
authored
ENG-3714: Form builder base — feature flag, deps, and data layer (#8203)
1 parent 99e2ec6 commit b448b0a

17 files changed

Lines changed: 1790 additions & 5 deletions

clients/admin-ui/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"@dnd-kit/sortable": "^10.0.0",
4343
"@dnd-kit/utilities": "^3.2.2",
4444
"@fontsource/inter": "^4.5.15",
45+
"@json-render/core": "^0.18.0",
46+
"@json-render/react": "^0.18.0",
4547
"@monaco-editor/react": "^4.6.0",
4648
"@reduxjs/toolkit": "^2.6.0",
4749
"@tanstack/react-table": "^8.10.7",
@@ -74,10 +76,12 @@
7476
"react-dom": "^19.2.0",
7577
"react-dropzone": "^14.2.3",
7678
"react-hotkeys-hook": "^5.2.1",
79+
"react-phone-number-input": "^3.4.1",
7780
"react-redux": "^9.1.2",
7881
"redux-persist": "^6.0.0",
7982
"valibot": "^1.2.0",
80-
"yup": "^1.4.0"
83+
"yup": "^1.4.0",
84+
"zod": "^4.3.6"
8185
},
8286
"devDependencies": {
8387
"@ant-design/cli": "^6.3.7",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { catalog } from "../catalog";
2+
3+
describe("form builder catalog", () => {
4+
it("defines exactly the supported component types", () => {
5+
expect(Object.keys(catalog.components).sort()).toEqual([
6+
"Email",
7+
"Form",
8+
"Location",
9+
"MultiSelect",
10+
"Name",
11+
"Phone",
12+
"Radio",
13+
"Select",
14+
"Text",
15+
]);
16+
});
17+
18+
it("validates a Text component's props", () => {
19+
const result = catalog.components.Text.props.safeParse({
20+
name: "email",
21+
label: "Email",
22+
required: true,
23+
});
24+
expect(result.success).toBe(true);
25+
});
26+
27+
it("rejects a Select with no options", () => {
28+
const result = catalog.components.Select.props.safeParse({
29+
name: "reason",
30+
label: "Reason",
31+
required: true,
32+
});
33+
expect(result.success).toBe(false);
34+
});
35+
36+
it("rejects a Text with a non-snake_case name", () => {
37+
const result = catalog.components.Text.props.safeParse({
38+
name: "First Name",
39+
label: "First Name",
40+
required: true,
41+
});
42+
expect(result.success).toBe(false);
43+
});
44+
});
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { jsonSpecToPcShape } from "../jsonSpecToPcShape";
2+
3+
const buildSpec = (elements: Record<string, any>, children: string[]) => ({
4+
root: "form",
5+
elements: {
6+
form: { type: "Form", props: {}, children },
7+
...elements,
8+
},
9+
});
10+
11+
describe("jsonSpecToPcShape", () => {
12+
it("maps a Text field", () => {
13+
const spec = buildSpec(
14+
{
15+
f1: {
16+
type: "Text",
17+
props: { name: "notes", label: "Notes", required: false },
18+
children: [],
19+
},
20+
},
21+
["f1"],
22+
);
23+
24+
const result = jsonSpecToPcShape(spec);
25+
26+
expect(result.errors).toEqual([]);
27+
expect(result.droppedFeatures).toEqual([]);
28+
expect(result.pcShape).toEqual({
29+
notes: { label: "Notes", field_type: "text", required: false },
30+
});
31+
});
32+
33+
it("maps Select / MultiSelect / Location field types", () => {
34+
const spec = buildSpec(
35+
{
36+
s: {
37+
type: "Select",
38+
props: {
39+
name: "reason",
40+
label: "Reason",
41+
required: true,
42+
options: ["A", "B"],
43+
},
44+
children: [],
45+
},
46+
m: {
47+
type: "MultiSelect",
48+
props: {
49+
name: "topics",
50+
label: "Topics",
51+
required: false,
52+
options: ["X", "Y"],
53+
},
54+
children: [],
55+
},
56+
l: {
57+
type: "Location",
58+
props: {
59+
name: "country",
60+
label: "Country",
61+
required: true,
62+
},
63+
children: [],
64+
},
65+
},
66+
["s", "m", "l"],
67+
);
68+
69+
const result = jsonSpecToPcShape(spec);
70+
71+
expect(result.errors).toEqual([]);
72+
expect(result.pcShape.reason.field_type).toBe("select");
73+
expect(result.pcShape.topics.field_type).toBe("multiselect");
74+
expect(result.pcShape.country.field_type).toBe("location");
75+
});
76+
77+
it("flags duplicate names as a validation error", () => {
78+
const spec = buildSpec(
79+
{
80+
a: {
81+
type: "Text",
82+
props: { name: "email", label: "Email", required: true },
83+
children: [],
84+
},
85+
b: {
86+
type: "Text",
87+
props: { name: "email", label: "Email 2", required: false },
88+
children: [],
89+
},
90+
},
91+
["a", "b"],
92+
);
93+
94+
const result = jsonSpecToPcShape(spec);
95+
96+
expect(result.errors).toContainEqual(
97+
expect.objectContaining({ kind: "duplicate_name", name: "email" }),
98+
);
99+
});
100+
101+
it("records dropped features for `watch` and $-expressions, and for malformed `visible` entries", () => {
102+
const spec = buildSpec(
103+
{
104+
a: {
105+
type: "Text",
106+
props: { name: "country", label: "Country", required: true },
107+
children: [],
108+
},
109+
b: {
110+
type: "Text",
111+
props: {
112+
name: "state",
113+
label: "State",
114+
required: false,
115+
},
116+
children: [],
117+
// unsupported $state path → mapper can't translate it, so it's dropped
118+
visible: [{ $state: "/somewhere/else", eq: "US" }],
119+
},
120+
c: {
121+
type: "Text",
122+
props: {
123+
name: "computed",
124+
// eslint-disable-next-line no-template-curly-in-string
125+
label: { $template: "Hello ${/x}" },
126+
required: false,
127+
},
128+
children: [],
129+
watch: { "/form/country": { action: "loadStates" } },
130+
},
131+
},
132+
["a", "b", "c"],
133+
);
134+
135+
const result = jsonSpecToPcShape(spec);
136+
137+
const kinds = result.droppedFeatures.map((d) => d.kind).sort();
138+
expect(kinds).toContain("visible");
139+
expect(kinds).toContain("watch");
140+
expect(kinds).toContain("expression");
141+
});
142+
143+
it("passes `placeholder` through to the legacy shape", () => {
144+
const spec = buildSpec(
145+
{
146+
f1: {
147+
type: "Text",
148+
props: {
149+
name: "country",
150+
label: "Country",
151+
required: false,
152+
placeholder: "e.g. US",
153+
},
154+
children: [],
155+
},
156+
f2: {
157+
type: "Select",
158+
props: {
159+
name: "reason",
160+
label: "Reason",
161+
required: false,
162+
placeholder: "Pick one",
163+
options: ["A", "B"],
164+
},
165+
children: [],
166+
},
167+
f3: {
168+
type: "Text",
169+
props: { name: "notes", label: "Notes", required: false },
170+
children: [],
171+
},
172+
},
173+
["f1", "f2", "f3"],
174+
);
175+
176+
const result = jsonSpecToPcShape(spec);
177+
178+
expect(result.errors).toEqual([]);
179+
expect(result.pcShape.country.placeholder).toBe("e.g. US");
180+
expect(result.pcShape.reason.placeholder).toBe("Pick one");
181+
expect(result.pcShape.notes.placeholder).toBeUndefined();
182+
});
183+
184+
it("emits fieldOrder reflecting children order across identity and custom fields", () => {
185+
const spec = buildSpec(
186+
{
187+
f_email: { type: "Email", props: { required: true }, children: [] },
188+
f_reason: {
189+
type: "Text",
190+
props: { name: "reason", label: "Reason", required: false },
191+
children: [],
192+
},
193+
f_name: { type: "Name", props: { required: false }, children: [] },
194+
f_topics: {
195+
type: "MultiSelect",
196+
props: {
197+
name: "topics",
198+
label: "Topics",
199+
required: false,
200+
options: ["A", "B"],
201+
},
202+
children: [],
203+
},
204+
},
205+
["f_email", "f_reason", "f_name", "f_topics"],
206+
);
207+
208+
const result = jsonSpecToPcShape(spec);
209+
210+
expect(result.errors).toEqual([]);
211+
expect(result.fieldOrder).toEqual(["email", "reason", "name", "topics"]);
212+
expect(result.identityInputs).toEqual({
213+
email: "required",
214+
name: "optional",
215+
});
216+
expect(Object.keys(result.pcShape)).toEqual(["reason", "topics"]);
217+
});
218+
219+
it("returns an empty fieldOrder when the spec has no children", () => {
220+
const spec = buildSpec({}, []);
221+
const result = jsonSpecToPcShape(spec);
222+
expect(result.fieldOrder).toEqual([]);
223+
});
224+
225+
it("excludes unknown components from fieldOrder while reporting them as dropped", () => {
226+
const spec = buildSpec(
227+
{
228+
ok: {
229+
type: "Text",
230+
props: { name: "notes", label: "Notes", required: false },
231+
children: [],
232+
},
233+
weird: {
234+
type: "Mystery",
235+
props: {},
236+
children: [],
237+
},
238+
},
239+
["ok", "weird"],
240+
);
241+
242+
const result = jsonSpecToPcShape(spec);
243+
244+
expect(result.fieldOrder).toEqual(["notes"]);
245+
expect(result.droppedFeatures.map((d) => d.kind)).toContain(
246+
"unknown_component",
247+
);
248+
});
249+
250+
it("translates json-render `visible` into legacy `visible_when` and does not drop it", () => {
251+
const spec = buildSpec(
252+
{
253+
a: {
254+
type: "Text",
255+
props: { name: "country", label: "Country", required: true },
256+
children: [],
257+
},
258+
b: {
259+
type: "Text",
260+
props: { name: "state", label: "State", required: false },
261+
children: [],
262+
visible: [
263+
{ $state: "/form/country", eq: "US" },
264+
{ $state: "/form/country", set: true },
265+
],
266+
},
267+
},
268+
["a", "b"],
269+
);
270+
271+
const result = jsonSpecToPcShape(spec);
272+
273+
expect(result.droppedFeatures).toEqual([]);
274+
expect(result.pcShape.state.visible_when).toEqual([
275+
{ source_field: "country", operator: "eq", value: "US" },
276+
{ source_field: "country", operator: "set" },
277+
]);
278+
});
279+
});

0 commit comments

Comments
 (0)