@btst/stack 2.11.4 → 2.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/components/form-builder/index.d.cts +8 -0
  2. package/dist/components/form-builder/index.d.mts +8 -0
  3. package/dist/components/form-builder/index.d.ts +8 -0
  4. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +58 -34
  5. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +58 -34
  6. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +29 -7
  7. package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +29 -7
  8. package/dist/packages/ui/src/components/auto-form/helpers.cjs +24 -1
  9. package/dist/packages/ui/src/components/auto-form/helpers.mjs +24 -1
  10. package/dist/packages/ui/src/components/form-builder/schema-utils.cjs +11 -5
  11. package/dist/packages/ui/src/components/form-builder/schema-utils.mjs +11 -5
  12. package/dist/plugins/blog/api/index.d.cts +3 -2
  13. package/dist/plugins/blog/api/index.d.mts +3 -2
  14. package/dist/plugins/blog/api/index.d.ts +3 -2
  15. package/dist/plugins/blog/client/components/index.cjs +21 -0
  16. package/dist/plugins/blog/client/components/index.d.cts +71 -1
  17. package/dist/plugins/blog/client/components/index.d.mts +71 -1
  18. package/dist/plugins/blog/client/components/index.d.ts +71 -1
  19. package/dist/plugins/blog/client/components/index.mjs +15 -1
  20. package/dist/plugins/blog/client/hooks/index.d.cts +3 -2
  21. package/dist/plugins/blog/client/hooks/index.d.mts +3 -2
  22. package/dist/plugins/blog/client/hooks/index.d.ts +3 -2
  23. package/dist/plugins/blog/client/index.d.cts +3 -2
  24. package/dist/plugins/blog/client/index.d.mts +3 -2
  25. package/dist/plugins/blog/client/index.d.ts +3 -2
  26. package/dist/plugins/blog/query-keys.d.cts +3 -2
  27. package/dist/plugins/blog/query-keys.d.mts +3 -2
  28. package/dist/plugins/blog/query-keys.d.ts +3 -2
  29. package/dist/plugins/kanban/api/index.d.cts +1 -1
  30. package/dist/plugins/kanban/api/index.d.mts +1 -1
  31. package/dist/plugins/kanban/api/index.d.ts +1 -1
  32. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  33. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  34. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  35. package/dist/shared/{stack.6mEHS2WH.d.mts → stack.BnSAhKJi.d.ts} +4 -3
  36. package/dist/shared/{stack.C1jLzFZ6.d.mts → stack.CLWhMntf.d.cts} +6 -5
  37. package/dist/shared/{stack.BQmuNl5p.d.mts → stack.CuhcFsAx.d.cts} +3 -38
  38. package/dist/shared/{stack.BQmuNl5p.d.ts → stack.CuhcFsAx.d.mts} +3 -38
  39. package/dist/shared/{stack.BQmuNl5p.d.cts → stack.CuhcFsAx.d.ts} +3 -38
  40. package/dist/shared/{stack.AJTXI7kw.d.cts → stack.DNLuzDb3.d.cts} +4 -3
  41. package/dist/shared/{stack.QYn-Px94.d.ts → stack.Dfc44JiE.d.mts} +4 -3
  42. package/dist/shared/{stack.DF1ewpVE.d.ts → stack.DiodAFAa.d.mts} +6 -5
  43. package/dist/shared/stack.Dsatjh0B.d.cts +35 -0
  44. package/dist/shared/stack.Dsatjh0B.d.mts +35 -0
  45. package/dist/shared/stack.Dsatjh0B.d.ts +35 -0
  46. package/dist/shared/{stack.XulPeAD3.d.cts → stack.ufcExQIR.d.ts} +6 -5
  47. package/package.json +14 -1
  48. package/src/plugins/blog/client/components/index.tsx +52 -0
  49. package/src/plugins/cms/__tests__/auto-form-nested-config.test.ts +177 -0
  50. package/src/plugins/cms/client/components/forms/content-form.tsx +93 -47
  51. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +49 -1
  52. package/src/plugins/form-builder/__tests__/schema-utils.test.ts +362 -0
  53. package/dist/shared/{stack.j5SFLC1d.d.ts → stack.9dFHtvXR.d.ts} +15 -15
  54. package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.BzKW7ToW.d.mts} +15 -15
  55. package/dist/shared/{stack.B6S3cgwN.d.cts → stack.f6MrG7ys.d.cts} +15 -15
@@ -63,63 +63,109 @@ function buildFieldConfigFromJsonSchema(
63
63
  isRequired?: boolean;
64
64
  }>,
65
65
  ): FieldConfig<Record<string, unknown>> {
66
- // Get base config from shared utility (handles fieldType from JSON Schema)
66
+ // Get base config from shared utility (handles fieldType from JSON Schema,
67
+ // including per-item configs for arrays of objects).
67
68
  const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
68
69
 
69
- // Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists
70
- // Custom fieldComponents take priority - don't override if user provided one
71
- const properties = jsonSchema.properties as Record<
72
- string,
73
- JsonSchemaProperty
74
- >;
70
+ const properties = jsonSchema.properties as
71
+ | Record<string, JsonSchemaProperty>
72
+ | undefined;
75
73
 
76
74
  if (!properties) return baseConfig;
77
75
 
78
- for (const [key, prop] of Object.entries(properties)) {
79
- // Handle "file" fieldType when there's NO custom component for "file"
80
- if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
81
- // Use CMSFileUpload as the default file component
82
- if (!uploadImage && !imageInputField) {
83
- // Show a clear error message if neither uploadImage nor imageInputField is provided
84
- baseConfig[key] = {
85
- ...baseConfig[key],
86
- fieldType: () => (
87
- <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
88
- File upload requires an <code>uploadImage</code> or{" "}
89
- <code>imageInputField</code> function in CMS overrides.
90
- </div>
91
- ),
92
- };
93
- } else {
94
- baseConfig[key] = {
95
- ...baseConfig[key],
96
- fieldType: (props: AutoFormInputComponentProps) => (
97
- <CMSFileUpload
98
- {...props}
99
- uploadImage={uploadImage ?? (() => Promise.resolve(""))}
100
- imageInputField={imageInputField}
101
- imagePicker={imagePicker}
102
- />
76
+ // Recursively walk the JSON Schema properties and inject CMS-specific custom
77
+ // components (file upload, relation picker) for any field with a matching
78
+ // fieldType, regardless of nesting depth. Targets:
79
+ // - top-level fields
80
+ // - properties of nested object fields
81
+ // - properties of array items (e.g. `components: z.array(z.object({...}))`)
82
+ //
83
+ // `targetConfig` is the FieldConfigObject slot to mutate for the property at
84
+ // `key`. The recursion mirrors how AutoFormObject + AutoFormArray look up
85
+ // per-property configs: nested object/array per-item configs live as keys
86
+ // alongside their parent's meta on the same FieldConfigObject.
87
+ const injectCustomFieldTypes = (
88
+ props: Record<string, JsonSchemaProperty>,
89
+ targetConfig: Record<string, unknown>,
90
+ ) => {
91
+ for (const [key, prop] of Object.entries(props)) {
92
+ // Ensure a slot exists so we can mutate it whether or not the base
93
+ // helper produced an entry for this key.
94
+ const existing =
95
+ (targetConfig[key] as Record<string, unknown> | undefined) ?? {};
96
+
97
+ let updated = existing;
98
+
99
+ // Handle "file" fieldType when there's NO custom component for "file"
100
+ if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
101
+ if (!uploadImage && !imageInputField) {
102
+ updated = {
103
+ ...updated,
104
+ fieldType: () => (
105
+ <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
106
+ File upload requires an <code>uploadImage</code> or{" "}
107
+ <code>imageInputField</code> function in CMS overrides.
108
+ </div>
109
+ ),
110
+ };
111
+ } else {
112
+ updated = {
113
+ ...updated,
114
+ fieldType: (componentProps: AutoFormInputComponentProps) => (
115
+ <CMSFileUpload
116
+ {...componentProps}
117
+ uploadImage={uploadImage ?? (() => Promise.resolve(""))}
118
+ imageInputField={imageInputField}
119
+ imagePicker={imagePicker}
120
+ />
121
+ ),
122
+ };
123
+ }
124
+ }
125
+
126
+ // Handle "relation" fieldType when there's NO custom component for "relation"
127
+ if (
128
+ prop.fieldType === "relation" &&
129
+ prop.relation &&
130
+ !fieldComponents?.["relation"]
131
+ ) {
132
+ const relationConfig = prop.relation;
133
+ updated = {
134
+ ...updated,
135
+ fieldType: (componentProps: AutoFormInputComponentProps) => (
136
+ <RelationField {...componentProps} relation={relationConfig} />
103
137
  ),
104
138
  };
105
139
  }
106
- }
107
140
 
108
- // Handle "relation" fieldType when there's NO custom component for "relation"
109
- if (
110
- prop.fieldType === "relation" &&
111
- prop.relation &&
112
- !fieldComponents?.["relation"]
113
- ) {
114
- const relationConfig = prop.relation;
115
- baseConfig[key] = {
116
- ...baseConfig[key],
117
- fieldType: (props: AutoFormInputComponentProps) => (
118
- <RelationField {...props} relation={relationConfig} />
119
- ),
120
- };
141
+ // Recurse into nested objects — their per-property configs live as
142
+ // keys on the same parent FieldConfigObject.
143
+ if (prop.properties) {
144
+ injectCustomFieldTypes(
145
+ prop.properties as Record<string, JsonSchemaProperty>,
146
+ updated,
147
+ );
148
+ }
149
+
150
+ // Recurse into array items — same convention as nested objects.
151
+ const items = prop.items as JsonSchemaProperty | undefined;
152
+ if (items?.properties) {
153
+ injectCustomFieldTypes(
154
+ items.properties as Record<string, JsonSchemaProperty>,
155
+ updated,
156
+ );
157
+ }
158
+
159
+ if (Object.keys(updated).length > 0) {
160
+ targetConfig[key] = updated;
161
+ }
121
162
  }
122
- }
163
+ };
164
+
165
+ injectCustomFieldTypes(
166
+ properties,
167
+ baseConfig as unknown as Record<string, unknown>,
168
+ );
123
169
 
124
170
  return baseConfig;
125
171
  }
@@ -95,6 +95,19 @@ export function InverseRelationsPanel({
95
95
  return null;
96
96
  }
97
97
 
98
+ // When a single source content type has multiple belongsTo fields pointing
99
+ // at this target type (e.g. StackSynergy has both compoundAId and
100
+ // compoundBId → compound), the section title alone ("Stack Synergy") is
101
+ // ambiguous — two cards would render with identical headings. Mark those
102
+ // relations so we can disambiguate them by field name.
103
+ const sourceTypeCounts = new Map<string, number>();
104
+ for (const rel of inverseRelations) {
105
+ sourceTypeCounts.set(
106
+ rel.sourceType,
107
+ (sourceTypeCounts.get(rel.sourceType) ?? 0) + 1,
108
+ );
109
+ }
110
+
98
111
  return (
99
112
  <div className="space-y-4">
100
113
  <h3 className="text-lg font-semibold">Related Items</h3>
@@ -109,12 +122,32 @@ export function InverseRelationsPanel({
109
122
  Link={Link}
110
123
  client={client}
111
124
  headers={headers}
125
+ ambiguous={(sourceTypeCounts.get(relation.sourceType) ?? 0) > 1}
112
126
  />
113
127
  ))}
114
128
  </div>
115
129
  );
116
130
  }
117
131
 
132
+ /**
133
+ * Turn a relation field name like `compoundAId` / `categoryIds` into a
134
+ * friendlier label like `Compound A` / `Category` for display in the
135
+ * inverse-relations panel when two sections would otherwise share a title.
136
+ *
137
+ * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and
138
+ * title-cases the result. Leaves unrecognised shapes as-is so we never
139
+ * produce an empty string.
140
+ */
141
+ function humanizeFieldName(fieldName: string): string {
142
+ const stripped = fieldName.replace(/Ids?$/, "") || fieldName;
143
+ const words = stripped
144
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
145
+ .replace(/[_-]+/g, " ")
146
+ .trim()
147
+ .split(/\s+/);
148
+ return words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(" ");
149
+ }
150
+
118
151
  interface InverseRelationSectionProps {
119
152
  relation: InverseRelation;
120
153
  contentTypeSlug: string;
@@ -128,6 +161,12 @@ interface InverseRelationSectionProps {
128
161
  }>;
129
162
  client: ReturnType<typeof createApiClient<CMSApiRouter>>;
130
163
  headers?: HeadersInit;
164
+ /**
165
+ * True when another inverse relation from the same `sourceType` is also
166
+ * being rendered — in which case the field-name suffix is shown so the
167
+ * user can tell the two cards apart.
168
+ */
169
+ ambiguous: boolean;
131
170
  }
132
171
 
133
172
  function InverseRelationSection({
@@ -139,6 +178,7 @@ function InverseRelationSection({
139
178
  Link,
140
179
  client,
141
180
  headers,
181
+ ambiguous,
142
182
  }: InverseRelationSectionProps) {
143
183
  const [isExpanded, setIsExpanded] = useState(true);
144
184
  const [deleteItemId, setDeleteItemId] = useState<string | null>(null);
@@ -205,6 +245,7 @@ function InverseRelationSection({
205
245
  };
206
246
 
207
247
  const LinkComponent = Link ?? "a";
248
+ const fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null;
208
249
 
209
250
  return (
210
251
  <Card>
@@ -220,7 +261,13 @@ function InverseRelationSection({
220
261
  ) : (
221
262
  <ChevronRight className="h-4 w-4" />
222
263
  )}
223
- {relation.sourceTypeName} ({total})
264
+ <span>{relation.sourceTypeName}</span>
265
+ {fieldLabel && (
266
+ <span className="font-normal text-muted-foreground">
267
+ · {fieldLabel}
268
+ </span>
269
+ )}
270
+ <span className="font-normal text-muted-foreground">({total})</span>
224
271
  </CardTitle>
225
272
  </button>
226
273
  </CardHeader>
@@ -269,6 +316,7 @@ function InverseRelationSection({
269
316
  >
270
317
  <Plus className="h-4 w-4 mr-2" />
271
318
  Add {relation.sourceTypeName}
319
+ {fieldLabel ? ` (${fieldLabel})` : ""}
272
320
  </Button>
273
321
  </div>
274
322
  </CardContent>
@@ -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
+ });