Skip to content

Commit 7e5e74f

Browse files
authored
Merge pull request #119 from better-stack-ai/fix/form-builder-multistep
fix: form-builder multistep data parsing
2 parents 0081473 + 6415a25 commit 7e5e74f

6 files changed

Lines changed: 408 additions & 10 deletions

File tree

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.11.5",
3+
"version": "2.11.6",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",

packages/stack/registry/btst-form-builder.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/**
2+
* Tests for the JSON Schema ↔ FormBuilderField conversion utilities.
3+
*
4+
* The form-builder ecosystem accepts two flavours of multi-step encoding:
5+
* - **Path A**: per-property `stepGroup` on each field (what the visual
6+
* FormBuilder writes on save — canonical wire format).
7+
* - **Path B**: root-level `stepGroupMap` (what `zodToFormSchema(schema, {
8+
* steps, stepGroupMap })` produces and what `SteppedAutoForm` reads).
9+
*
10+
* Both must be accepted on read so a Zod-seeded form opens with all fields
11+
* correctly assigned to their step tabs in the admin canvas. Without Path B
12+
* support, every field in a Zod-seeded multi-step form collapses onto step 0.
13+
*
14+
* @see packages/ui/src/components/form-builder/schema-utils.ts
15+
* @see packages/ui/src/lib/schema-converter.ts
16+
*/
17+
18+
import { describe, it, expect } from "vitest";
19+
import {
20+
fieldsToJSONSchema,
21+
jsonSchemaToFieldsAndSteps,
22+
} from "@workspace/ui/components/form-builder/schema-utils";
23+
import { defaultComponents } from "@workspace/ui/components/form-builder";
24+
import type {
25+
JSONSchema,
26+
FormStep,
27+
} from "@workspace/ui/components/form-builder/types";
28+
29+
const STEPS: FormStep[] = [
30+
{ id: "goals", title: "Your goals" },
31+
{ id: "experience", title: "Experience" },
32+
{ id: "constraints", title: "Constraints" },
33+
];
34+
35+
describe("jsonSchemaToFieldsAndSteps", () => {
36+
describe("core usage", () => {
37+
it("returns empty result for null/undefined schema", () => {
38+
expect(jsonSchemaToFieldsAndSteps(null, defaultComponents)).toEqual({
39+
fields: [],
40+
steps: [],
41+
});
42+
expect(jsonSchemaToFieldsAndSteps(undefined, defaultComponents)).toEqual({
43+
fields: [],
44+
steps: [],
45+
});
46+
});
47+
48+
it("returns empty result when schema has no properties", () => {
49+
const schema = { type: "object", properties: {} } as JSONSchema;
50+
expect(jsonSchemaToFieldsAndSteps(schema, defaultComponents)).toEqual({
51+
fields: [],
52+
steps: [],
53+
});
54+
});
55+
56+
it("parses a flat single-step schema with mixed field types", () => {
57+
const schema: JSONSchema = {
58+
type: "object",
59+
properties: {
60+
name: { type: "string", label: "Full Name" },
61+
email: { type: "string", format: "email", label: "Email" },
62+
age: { type: "number", label: "Age", minimum: 0 },
63+
},
64+
required: ["name", "email"],
65+
};
66+
67+
const { fields, steps } = jsonSchemaToFieldsAndSteps(
68+
schema,
69+
defaultComponents,
70+
);
71+
72+
expect(steps).toEqual([]);
73+
expect(fields).toHaveLength(3);
74+
75+
const [name, email, age] = fields;
76+
expect(name).toMatchObject({
77+
id: "name",
78+
type: "text",
79+
props: { label: "Full Name", required: true },
80+
});
81+
expect(email).toMatchObject({
82+
id: "email",
83+
type: "email",
84+
props: { label: "Email", required: true },
85+
});
86+
expect(age).toMatchObject({
87+
id: "age",
88+
type: "number",
89+
props: { label: "Age", required: false },
90+
});
91+
expect(name?.stepGroup).toBeUndefined();
92+
expect(email?.stepGroup).toBeUndefined();
93+
expect(age?.stepGroup).toBeUndefined();
94+
});
95+
96+
it("preserves the steps array on the output", () => {
97+
const schema: JSONSchema = {
98+
type: "object",
99+
properties: {
100+
name: { type: "string", label: "Name", stepGroup: 0 },
101+
},
102+
steps: STEPS,
103+
};
104+
105+
const { steps } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
106+
expect(steps).toEqual(STEPS);
107+
});
108+
});
109+
110+
describe("multi-step: Path A — per-property stepGroup", () => {
111+
// Canonical format written by the visual FormBuilder when saving.
112+
const schema: JSONSchema = {
113+
type: "object",
114+
properties: {
115+
name: { type: "string", label: "Name", stepGroup: 0 },
116+
email: {
117+
type: "string",
118+
format: "email",
119+
label: "Email",
120+
stepGroup: 0,
121+
},
122+
experience: {
123+
type: "string",
124+
enum: ["beginner", "advanced"],
125+
label: "Experience",
126+
stepGroup: 1,
127+
},
128+
agree: {
129+
type: "boolean",
130+
fieldType: "switch",
131+
label: "I agree",
132+
stepGroup: 2,
133+
},
134+
},
135+
steps: STEPS,
136+
};
137+
138+
it("assigns each field to the step encoded on its property", () => {
139+
const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
140+
141+
expect(fields).toHaveLength(4);
142+
const byId = new Map(fields.map((f) => [f.id, f]));
143+
expect(byId.get("name")?.stepGroup).toBe(0);
144+
expect(byId.get("email")?.stepGroup).toBe(0);
145+
expect(byId.get("experience")?.stepGroup).toBe(1);
146+
expect(byId.get("agree")?.stepGroup).toBe(2);
147+
});
148+
});
149+
150+
describe("multi-step: Path B — root-level stepGroupMap (regression)", () => {
151+
// What `zodToFormSchema(LooksmaxQuizSchema, { steps, stepGroupMap })` writes.
152+
// Before the fix, every field collapsed onto step 0 because the parser
153+
// only consulted per-property `stepGroup`.
154+
const schema: JSONSchema = {
155+
type: "object",
156+
properties: {
157+
primaryGoal: {
158+
type: "string",
159+
enum: ["skin", "hair", "jaw", "lean", "all"],
160+
label: "Primary goal",
161+
fieldType: "radio",
162+
},
163+
experience: {
164+
type: "string",
165+
enum: ["none", "some-peptides", "full-cycle-history"],
166+
label: "Experience",
167+
fieldType: "radio",
168+
},
169+
riskTolerance: {
170+
type: "string",
171+
enum: ["low", "moderate", "high"],
172+
label: "Risk tolerance",
173+
fieldType: "radio",
174+
},
175+
noInjections: {
176+
type: "boolean",
177+
fieldType: "switch",
178+
label: "No injections",
179+
},
180+
email: {
181+
type: "string",
182+
format: "email",
183+
label: "Email",
184+
},
185+
},
186+
steps: STEPS,
187+
stepGroupMap: {
188+
primaryGoal: 0,
189+
experience: 1,
190+
riskTolerance: 1,
191+
noInjections: 2,
192+
email: 2,
193+
},
194+
};
195+
196+
it("assigns step from root-level stepGroupMap when properties omit stepGroup", () => {
197+
const { fields, steps } = jsonSchemaToFieldsAndSteps(
198+
schema,
199+
defaultComponents,
200+
);
201+
202+
expect(steps).toEqual(STEPS);
203+
expect(fields).toHaveLength(5);
204+
205+
const byId = new Map(fields.map((f) => [f.id, f]));
206+
expect(byId.get("primaryGoal")?.stepGroup).toBe(0);
207+
expect(byId.get("experience")?.stepGroup).toBe(1);
208+
expect(byId.get("riskTolerance")?.stepGroup).toBe(1);
209+
expect(byId.get("noInjections")?.stepGroup).toBe(2);
210+
expect(byId.get("email")?.stepGroup).toBe(2);
211+
});
212+
213+
it("works for fields that fall back to the generic text component", () => {
214+
// Fields with unknown shapes fall through to the fallback `text` field
215+
// in propertiesToFields. The fix must apply there too.
216+
const schemaWithUnknown: JSONSchema = {
217+
type: "object",
218+
properties: {
219+
mystery: {
220+
// no `type` → no component matches → fallback to text
221+
label: "Mystery field",
222+
} as JSONSchema["properties"][string],
223+
},
224+
steps: STEPS,
225+
stepGroupMap: { mystery: 2 },
226+
};
227+
228+
const { fields } = jsonSchemaToFieldsAndSteps(
229+
schemaWithUnknown,
230+
defaultComponents,
231+
);
232+
233+
expect(fields).toHaveLength(1);
234+
expect(fields[0]?.id).toBe("mystery");
235+
expect(fields[0]?.stepGroup).toBe(2);
236+
});
237+
});
238+
239+
describe("multi-step: precedence when both encodings are present", () => {
240+
it("per-property stepGroup wins over stepGroupMap", () => {
241+
const schema: JSONSchema = {
242+
type: "object",
243+
properties: {
244+
primary: {
245+
type: "string",
246+
label: "Primary",
247+
stepGroup: 0, // per-property says step 0
248+
},
249+
secondary: {
250+
type: "string",
251+
label: "Secondary",
252+
stepGroup: 1, // per-property says step 1
253+
},
254+
tertiary: {
255+
type: "string",
256+
label: "Tertiary",
257+
// no per-property — should use map
258+
},
259+
},
260+
steps: STEPS,
261+
stepGroupMap: {
262+
primary: 99, // map disagrees, but per-property wins
263+
secondary: 99,
264+
tertiary: 2, // map applies
265+
},
266+
};
267+
268+
const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
269+
270+
const byId = new Map(fields.map((f) => [f.id, f]));
271+
expect(byId.get("primary")?.stepGroup).toBe(0);
272+
expect(byId.get("secondary")?.stepGroup).toBe(1);
273+
expect(byId.get("tertiary")?.stepGroup).toBe(2);
274+
});
275+
276+
it("missing entries in stepGroupMap leave stepGroup undefined", () => {
277+
const schema: JSONSchema = {
278+
type: "object",
279+
properties: {
280+
mapped: { type: "string", label: "Mapped" },
281+
unmapped: { type: "string", label: "Unmapped" },
282+
},
283+
steps: STEPS,
284+
stepGroupMap: { mapped: 1 },
285+
};
286+
287+
const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
288+
const byId = new Map(fields.map((f) => [f.id, f]));
289+
expect(byId.get("mapped")?.stepGroup).toBe(1);
290+
expect(byId.get("unmapped")?.stepGroup).toBeUndefined();
291+
});
292+
});
293+
});
294+
295+
describe("fieldsToJSONSchema → jsonSchemaToFieldsAndSteps round-trip", () => {
296+
it("normalises Path B (stepGroupMap) input to Path A (per-property) output", () => {
297+
// This is the workflow that breaks the original bug: open a Zod-seeded
298+
// form (Path B), save without changes — the FormBuilder writes Path A.
299+
const inputSchema: JSONSchema = {
300+
type: "object",
301+
properties: {
302+
goal: { type: "string", label: "Goal" },
303+
risk: { type: "string", label: "Risk" },
304+
},
305+
steps: STEPS.slice(0, 2),
306+
stepGroupMap: { goal: 0, risk: 1 },
307+
};
308+
309+
const { fields, steps } = jsonSchemaToFieldsAndSteps(
310+
inputSchema,
311+
defaultComponents,
312+
);
313+
314+
// Re-serialise with the same steps array
315+
const roundTripped = fieldsToJSONSchema(fields, defaultComponents, steps);
316+
317+
// Output uses Path A (per-property stepGroup) and drops the root-level map
318+
expect(roundTripped.properties.goal?.stepGroup).toBe(0);
319+
expect(roundTripped.properties.risk?.stepGroup).toBe(1);
320+
expect(roundTripped.steps).toEqual(STEPS.slice(0, 2));
321+
expect(
322+
(roundTripped as JSONSchema & { stepGroupMap?: unknown }).stepGroupMap,
323+
).toBeUndefined();
324+
325+
// And re-parsing the normalised output produces the same field shape
326+
const reparsed = jsonSchemaToFieldsAndSteps(
327+
roundTripped,
328+
defaultComponents,
329+
);
330+
const byIdOriginal = new Map(fields.map((f) => [f.id, f.stepGroup]));
331+
const byIdRoundTrip = new Map(
332+
reparsed.fields.map((f) => [f.id, f.stepGroup]),
333+
);
334+
expect(byIdRoundTrip).toEqual(byIdOriginal);
335+
});
336+
337+
it("preserves Path A through a full round-trip", () => {
338+
const inputSchema: JSONSchema = {
339+
type: "object",
340+
properties: {
341+
name: { type: "string", label: "Name", stepGroup: 0 },
342+
email: {
343+
type: "string",
344+
format: "email",
345+
label: "Email",
346+
stepGroup: 1,
347+
},
348+
},
349+
steps: STEPS.slice(0, 2),
350+
};
351+
352+
const { fields, steps } = jsonSchemaToFieldsAndSteps(
353+
inputSchema,
354+
defaultComponents,
355+
);
356+
const roundTripped = fieldsToJSONSchema(fields, defaultComponents, steps);
357+
358+
expect(roundTripped.properties.name?.stepGroup).toBe(0);
359+
expect(roundTripped.properties.email?.stepGroup).toBe(1);
360+
expect(roundTripped.steps).toEqual(STEPS.slice(0, 2));
361+
});
362+
});

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"./components/auto-form/helpers": "./src/components/auto-form/helpers.tsx",
141141
"./components/form-builder": "./src/components/form-builder/index.tsx",
142142
"./components/form-builder/types": "./src/components/form-builder/types.ts",
143+
"./components/form-builder/schema-utils": "./src/components/form-builder/schema-utils.ts",
143144
"./components/ui-builder": "./src/components/ui-builder/index.tsx",
144145
"./components/ui-builder/types": "./src/components/ui-builder/types.ts",
145146
"./components/ui-builder/server-layer-renderer": "./src/components/ui-builder/server-layer-renderer.tsx",

0 commit comments

Comments
 (0)