@btst/stack 2.11.5 → 2.11.7

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.
@@ -23,6 +23,14 @@ interface JSONSchema {
23
23
  $schema?: string;
24
24
  /** Step definitions for multi-step forms (stored in schema meta) */
25
25
  steps?: FormStep[];
26
+ /**
27
+ * Optional root-level field-name → step-index map. This is the format produced
28
+ * by `zodToFormSchema(schema, { steps, stepGroupMap })` and consumed by
29
+ * `SteppedAutoForm`. The visual FormBuilder writes per-property `stepGroup`
30
+ * instead, but we accept both on read so a Zod-seeded form opens with its
31
+ * fields correctly assigned to their steps.
32
+ */
33
+ stepGroupMap?: Record<string, number>;
26
34
  additionalProperties?: boolean;
27
35
  }
28
36
  /**
@@ -23,6 +23,14 @@ interface JSONSchema {
23
23
  $schema?: string;
24
24
  /** Step definitions for multi-step forms (stored in schema meta) */
25
25
  steps?: FormStep[];
26
+ /**
27
+ * Optional root-level field-name → step-index map. This is the format produced
28
+ * by `zodToFormSchema(schema, { steps, stepGroupMap })` and consumed by
29
+ * `SteppedAutoForm`. The visual FormBuilder writes per-property `stepGroup`
30
+ * instead, but we accept both on read so a Zod-seeded form opens with its
31
+ * fields correctly assigned to their steps.
32
+ */
33
+ stepGroupMap?: Record<string, number>;
26
34
  additionalProperties?: boolean;
27
35
  }
28
36
  /**
@@ -23,6 +23,14 @@ interface JSONSchema {
23
23
  $schema?: string;
24
24
  /** Step definitions for multi-step forms (stored in schema meta) */
25
25
  steps?: FormStep[];
26
+ /**
27
+ * Optional root-level field-name → step-index map. This is the format produced
28
+ * by `zodToFormSchema(schema, { steps, stepGroupMap })` and consumed by
29
+ * `SteppedAutoForm`. The visual FormBuilder writes per-property `stepGroup`
30
+ * instead, but we accept both on read so a Zod-seeded form opens with its
31
+ * fields correctly assigned to their steps.
32
+ */
33
+ stepGroupMap?: Record<string, number>;
26
34
  additionalProperties?: boolean;
27
35
  }
28
36
  /**
@@ -4,6 +4,7 @@ const LucideIcons = require('lucide-react');
4
4
  const z = require('zod');
5
5
  const types = require('../types.cjs');
6
6
  const validationSchemas = require('../validation-schemas.cjs');
7
+ const helpers = require('../../auto-form/helpers.cjs');
7
8
 
8
9
  function toNumber(value) {
9
10
  if (value == null || value === "") return void 0;
@@ -20,7 +21,7 @@ function getPlaceholder(prop) {
20
21
  return prop.inputProps?.placeholder || prop.placeholder;
21
22
  }
22
23
  function getLabel(prop, key) {
23
- return prop.label || prop.title || key;
24
+ return prop.label || prop.title || helpers.beautifyObjectName(key);
24
25
  }
25
26
  const textFieldDefinition = types.defineComponent({
26
27
  type: "text",
@@ -2,6 +2,7 @@ import { FolderOpen, List, Mail, Lock, Globe, Phone, Calendar, AlignLeft, Toggle
2
2
  import { z } from 'zod';
3
3
  import { defineComponent } from '../types.mjs';
4
4
  import { baseMetaSchemaWithPlaceholder, stringValidationSchema, DEFAULT_VALUE_SCHEMAS, numberValidationSchema, baseMetaSchema, booleanValidationSchema, enumOptionsSchema, dateValidationSchema, objectValidationSchema, arrayValidationSchema } from '../validation-schemas.mjs';
5
+ import { beautifyObjectName } from '../../auto-form/helpers.mjs';
5
6
 
6
7
  function toNumber(value) {
7
8
  if (value == null || value === "") return void 0;
@@ -18,7 +19,7 @@ function getPlaceholder(prop) {
18
19
  return prop.inputProps?.placeholder || prop.placeholder;
19
20
  }
20
21
  function getLabel(prop, key) {
21
- return prop.label || prop.title || key;
22
+ return prop.label || prop.title || beautifyObjectName(key);
22
23
  }
23
24
  const textFieldDefinition = defineComponent({
24
25
  type: "text",
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const helpers = require('../auto-form/helpers.cjs');
4
+
3
5
  function fieldsToProperties(fields, components, includeStepGroup = false) {
4
6
  const properties = {};
5
7
  for (const field of fields) {
@@ -49,7 +51,7 @@ function fieldsToJSONSchema(fields, components, steps) {
49
51
  ...hasMultipleSteps ? { steps } : {}
50
52
  };
51
53
  }
52
- function propertiesToFields(properties, requiredSet, components) {
54
+ function propertiesToFields(properties, requiredSet, components, stepGroupMap) {
53
55
  const fields = [];
54
56
  for (const [key, prop] of Object.entries(properties)) {
55
57
  const isRequired = requiredSet.has(key);
@@ -60,9 +62,10 @@ function propertiesToFields(properties, requiredSet, components) {
60
62
  break;
61
63
  }
62
64
  }
65
+ const resolvedStepGroup = prop.stepGroup !== void 0 ? prop.stepGroup : stepGroupMap?.[key];
63
66
  if (field) {
64
- if (prop.stepGroup !== void 0) {
65
- field.stepGroup = prop.stepGroup;
67
+ if (resolvedStepGroup !== void 0) {
68
+ field.stepGroup = resolvedStepGroup;
66
69
  }
67
70
  if (field.type === "object" && prop.properties) {
68
71
  const childRequiredSet = new Set(prop.required || []);
@@ -79,13 +82,13 @@ function propertiesToFields(properties, requiredSet, components) {
79
82
  id: key,
80
83
  type: "text",
81
84
  props: {
82
- label: prop.title || key,
85
+ label: prop.label || prop.title || helpers.beautifyObjectName(key),
83
86
  description: prop.description,
84
87
  placeholder: prop.placeholder,
85
88
  required: isRequired
86
89
  },
87
90
  // Include stepGroup even for fallback fields
88
- ...prop.stepGroup !== void 0 ? { stepGroup: prop.stepGroup } : {}
91
+ ...resolvedStepGroup !== void 0 ? { stepGroup: resolvedStepGroup } : {}
89
92
  });
90
93
  }
91
94
  }
@@ -96,7 +99,12 @@ function jsonSchemaToFieldsAndSteps(schema, components) {
96
99
  return { fields: [], steps: [] };
97
100
  }
98
101
  const requiredSet = new Set(schema.required || []);
99
- const fields = propertiesToFields(schema.properties, requiredSet, components);
102
+ const fields = propertiesToFields(
103
+ schema.properties,
104
+ requiredSet,
105
+ components,
106
+ schema.stepGroupMap
107
+ );
100
108
  const steps = schema.steps || [];
101
109
  return { fields, steps };
102
110
  }
@@ -1,3 +1,5 @@
1
+ import { beautifyObjectName } from '../auto-form/helpers.mjs';
2
+
1
3
  function fieldsToProperties(fields, components, includeStepGroup = false) {
2
4
  const properties = {};
3
5
  for (const field of fields) {
@@ -47,7 +49,7 @@ function fieldsToJSONSchema(fields, components, steps) {
47
49
  ...hasMultipleSteps ? { steps } : {}
48
50
  };
49
51
  }
50
- function propertiesToFields(properties, requiredSet, components) {
52
+ function propertiesToFields(properties, requiredSet, components, stepGroupMap) {
51
53
  const fields = [];
52
54
  for (const [key, prop] of Object.entries(properties)) {
53
55
  const isRequired = requiredSet.has(key);
@@ -58,9 +60,10 @@ function propertiesToFields(properties, requiredSet, components) {
58
60
  break;
59
61
  }
60
62
  }
63
+ const resolvedStepGroup = prop.stepGroup !== void 0 ? prop.stepGroup : stepGroupMap?.[key];
61
64
  if (field) {
62
- if (prop.stepGroup !== void 0) {
63
- field.stepGroup = prop.stepGroup;
65
+ if (resolvedStepGroup !== void 0) {
66
+ field.stepGroup = resolvedStepGroup;
64
67
  }
65
68
  if (field.type === "object" && prop.properties) {
66
69
  const childRequiredSet = new Set(prop.required || []);
@@ -77,13 +80,13 @@ function propertiesToFields(properties, requiredSet, components) {
77
80
  id: key,
78
81
  type: "text",
79
82
  props: {
80
- label: prop.title || key,
83
+ label: prop.label || prop.title || beautifyObjectName(key),
81
84
  description: prop.description,
82
85
  placeholder: prop.placeholder,
83
86
  required: isRequired
84
87
  },
85
88
  // Include stepGroup even for fallback fields
86
- ...prop.stepGroup !== void 0 ? { stepGroup: prop.stepGroup } : {}
89
+ ...resolvedStepGroup !== void 0 ? { stepGroup: resolvedStepGroup } : {}
87
90
  });
88
91
  }
89
92
  }
@@ -94,7 +97,12 @@ function jsonSchemaToFieldsAndSteps(schema, components) {
94
97
  return { fields: [], steps: [] };
95
98
  }
96
99
  const requiredSet = new Set(schema.required || []);
97
- const fields = propertiesToFields(schema.properties, requiredSet, components);
100
+ const fields = propertiesToFields(
101
+ schema.properties,
102
+ requiredSet,
103
+ components,
104
+ schema.stepGroupMap
105
+ );
98
106
  const steps = schema.steps || [];
99
107
  return { fields, steps };
100
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "2.11.5",
3
+ "version": "2.11.7",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,449 @@
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("label resolution", () => {
111
+ // Label resolution must mirror what `AutoForm` shows on the public form,
112
+ // so a Zod-seeded schema (whose properties only carry `description`,
113
+ // not `label`/`title`) reads identically in the admin canvas.
114
+ // See `auto-form/fields/object.tsx` → `beautifyObjectName(name)`.
115
+
116
+ it("uses an explicit `label` when present", () => {
117
+ const schema: JSONSchema = {
118
+ type: "object",
119
+ properties: {
120
+ primaryGoal: {
121
+ type: "string",
122
+ label: "What's your main focus?",
123
+ },
124
+ },
125
+ };
126
+
127
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
128
+ expect(fields[0]?.props.label).toBe("What's your main focus?");
129
+ });
130
+
131
+ it("falls back to JSON Schema `title` when `label` is absent", () => {
132
+ const schema: JSONSchema = {
133
+ type: "object",
134
+ properties: {
135
+ primaryGoal: {
136
+ type: "string",
137
+ title: "Primary Goal (titled)",
138
+ },
139
+ },
140
+ };
141
+
142
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
143
+ expect(fields[0]?.props.label).toBe("Primary Goal (titled)");
144
+ });
145
+
146
+ it("humanises the field key when no `label` or `title` is present", () => {
147
+ // This is the regression: previously the admin showed the raw key
148
+ // (`primaryGoal`) when no label/title was set, while the public form
149
+ // showed `Primary Goal`. Now both paths agree.
150
+ const schema: JSONSchema = {
151
+ type: "object",
152
+ properties: {
153
+ primaryGoal: { type: "string" },
154
+ riskTolerance: {
155
+ type: "string",
156
+ enum: ["low", "moderate", "high"],
157
+ },
158
+ noInjections: { type: "boolean", fieldType: "switch" },
159
+ email: { type: "string", format: "email" },
160
+ age: { type: "number" },
161
+ notes: { type: "string", fieldType: "textarea" },
162
+ },
163
+ };
164
+
165
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
166
+ const labels = Object.fromEntries(
167
+ fields.map((f) => [f.id, f.props.label]),
168
+ );
169
+
170
+ expect(labels).toEqual({
171
+ primaryGoal: "Primary Goal",
172
+ riskTolerance: "Risk Tolerance",
173
+ noInjections: "No Injections",
174
+ email: "Email",
175
+ age: "Age",
176
+ notes: "Notes",
177
+ });
178
+ });
179
+
180
+ it("humanises the field key in the unknown-field-type fallback", () => {
181
+ // When no component matches, propertiesToFields creates a generic
182
+ // text field. That branch must humanise the key too.
183
+ const schema: JSONSchema = {
184
+ type: "object",
185
+ properties: {
186
+ mysteriousField: {
187
+ // no `type` — falls through to the generic text fallback
188
+ } as JSONSchema["properties"][string],
189
+ },
190
+ };
191
+
192
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
193
+ expect(fields[0]?.props.label).toBe("Mysterious Field");
194
+ });
195
+ });
196
+
197
+ describe("multi-step: Path A — per-property stepGroup", () => {
198
+ // Canonical format written by the visual FormBuilder when saving.
199
+ const schema: JSONSchema = {
200
+ type: "object",
201
+ properties: {
202
+ name: { type: "string", label: "Name", stepGroup: 0 },
203
+ email: {
204
+ type: "string",
205
+ format: "email",
206
+ label: "Email",
207
+ stepGroup: 0,
208
+ },
209
+ experience: {
210
+ type: "string",
211
+ enum: ["beginner", "advanced"],
212
+ label: "Experience",
213
+ stepGroup: 1,
214
+ },
215
+ agree: {
216
+ type: "boolean",
217
+ fieldType: "switch",
218
+ label: "I agree",
219
+ stepGroup: 2,
220
+ },
221
+ },
222
+ steps: STEPS,
223
+ };
224
+
225
+ it("assigns each field to the step encoded on its property", () => {
226
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
227
+
228
+ expect(fields).toHaveLength(4);
229
+ const byId = new Map(fields.map((f) => [f.id, f]));
230
+ expect(byId.get("name")?.stepGroup).toBe(0);
231
+ expect(byId.get("email")?.stepGroup).toBe(0);
232
+ expect(byId.get("experience")?.stepGroup).toBe(1);
233
+ expect(byId.get("agree")?.stepGroup).toBe(2);
234
+ });
235
+ });
236
+
237
+ describe("multi-step: Path B — root-level stepGroupMap (regression)", () => {
238
+ // What `zodToFormSchema(LooksmaxQuizSchema, { steps, stepGroupMap })` writes.
239
+ // Before the fix, every field collapsed onto step 0 because the parser
240
+ // only consulted per-property `stepGroup`.
241
+ const schema: JSONSchema = {
242
+ type: "object",
243
+ properties: {
244
+ primaryGoal: {
245
+ type: "string",
246
+ enum: ["skin", "hair", "jaw", "lean", "all"],
247
+ label: "Primary goal",
248
+ fieldType: "radio",
249
+ },
250
+ experience: {
251
+ type: "string",
252
+ enum: ["none", "some-peptides", "full-cycle-history"],
253
+ label: "Experience",
254
+ fieldType: "radio",
255
+ },
256
+ riskTolerance: {
257
+ type: "string",
258
+ enum: ["low", "moderate", "high"],
259
+ label: "Risk tolerance",
260
+ fieldType: "radio",
261
+ },
262
+ noInjections: {
263
+ type: "boolean",
264
+ fieldType: "switch",
265
+ label: "No injections",
266
+ },
267
+ email: {
268
+ type: "string",
269
+ format: "email",
270
+ label: "Email",
271
+ },
272
+ },
273
+ steps: STEPS,
274
+ stepGroupMap: {
275
+ primaryGoal: 0,
276
+ experience: 1,
277
+ riskTolerance: 1,
278
+ noInjections: 2,
279
+ email: 2,
280
+ },
281
+ };
282
+
283
+ it("assigns step from root-level stepGroupMap when properties omit stepGroup", () => {
284
+ const { fields, steps } = jsonSchemaToFieldsAndSteps(
285
+ schema,
286
+ defaultComponents,
287
+ );
288
+
289
+ expect(steps).toEqual(STEPS);
290
+ expect(fields).toHaveLength(5);
291
+
292
+ const byId = new Map(fields.map((f) => [f.id, f]));
293
+ expect(byId.get("primaryGoal")?.stepGroup).toBe(0);
294
+ expect(byId.get("experience")?.stepGroup).toBe(1);
295
+ expect(byId.get("riskTolerance")?.stepGroup).toBe(1);
296
+ expect(byId.get("noInjections")?.stepGroup).toBe(2);
297
+ expect(byId.get("email")?.stepGroup).toBe(2);
298
+ });
299
+
300
+ it("works for fields that fall back to the generic text component", () => {
301
+ // Fields with unknown shapes fall through to the fallback `text` field
302
+ // in propertiesToFields. The fix must apply there too.
303
+ const schemaWithUnknown: JSONSchema = {
304
+ type: "object",
305
+ properties: {
306
+ mystery: {
307
+ // no `type` → no component matches → fallback to text
308
+ label: "Mystery field",
309
+ } as JSONSchema["properties"][string],
310
+ },
311
+ steps: STEPS,
312
+ stepGroupMap: { mystery: 2 },
313
+ };
314
+
315
+ const { fields } = jsonSchemaToFieldsAndSteps(
316
+ schemaWithUnknown,
317
+ defaultComponents,
318
+ );
319
+
320
+ expect(fields).toHaveLength(1);
321
+ expect(fields[0]?.id).toBe("mystery");
322
+ expect(fields[0]?.stepGroup).toBe(2);
323
+ });
324
+ });
325
+
326
+ describe("multi-step: precedence when both encodings are present", () => {
327
+ it("per-property stepGroup wins over stepGroupMap", () => {
328
+ const schema: JSONSchema = {
329
+ type: "object",
330
+ properties: {
331
+ primary: {
332
+ type: "string",
333
+ label: "Primary",
334
+ stepGroup: 0, // per-property says step 0
335
+ },
336
+ secondary: {
337
+ type: "string",
338
+ label: "Secondary",
339
+ stepGroup: 1, // per-property says step 1
340
+ },
341
+ tertiary: {
342
+ type: "string",
343
+ label: "Tertiary",
344
+ // no per-property — should use map
345
+ },
346
+ },
347
+ steps: STEPS,
348
+ stepGroupMap: {
349
+ primary: 99, // map disagrees, but per-property wins
350
+ secondary: 99,
351
+ tertiary: 2, // map applies
352
+ },
353
+ };
354
+
355
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
356
+
357
+ const byId = new Map(fields.map((f) => [f.id, f]));
358
+ expect(byId.get("primary")?.stepGroup).toBe(0);
359
+ expect(byId.get("secondary")?.stepGroup).toBe(1);
360
+ expect(byId.get("tertiary")?.stepGroup).toBe(2);
361
+ });
362
+
363
+ it("missing entries in stepGroupMap leave stepGroup undefined", () => {
364
+ const schema: JSONSchema = {
365
+ type: "object",
366
+ properties: {
367
+ mapped: { type: "string", label: "Mapped" },
368
+ unmapped: { type: "string", label: "Unmapped" },
369
+ },
370
+ steps: STEPS,
371
+ stepGroupMap: { mapped: 1 },
372
+ };
373
+
374
+ const { fields } = jsonSchemaToFieldsAndSteps(schema, defaultComponents);
375
+ const byId = new Map(fields.map((f) => [f.id, f]));
376
+ expect(byId.get("mapped")?.stepGroup).toBe(1);
377
+ expect(byId.get("unmapped")?.stepGroup).toBeUndefined();
378
+ });
379
+ });
380
+ });
381
+
382
+ describe("fieldsToJSONSchema → jsonSchemaToFieldsAndSteps round-trip", () => {
383
+ it("normalises Path B (stepGroupMap) input to Path A (per-property) output", () => {
384
+ // This is the workflow that breaks the original bug: open a Zod-seeded
385
+ // form (Path B), save without changes — the FormBuilder writes Path A.
386
+ const inputSchema: JSONSchema = {
387
+ type: "object",
388
+ properties: {
389
+ goal: { type: "string", label: "Goal" },
390
+ risk: { type: "string", label: "Risk" },
391
+ },
392
+ steps: STEPS.slice(0, 2),
393
+ stepGroupMap: { goal: 0, risk: 1 },
394
+ };
395
+
396
+ const { fields, steps } = jsonSchemaToFieldsAndSteps(
397
+ inputSchema,
398
+ defaultComponents,
399
+ );
400
+
401
+ // Re-serialise with the same steps array
402
+ const roundTripped = fieldsToJSONSchema(fields, defaultComponents, steps);
403
+
404
+ // Output uses Path A (per-property stepGroup) and drops the root-level map
405
+ expect(roundTripped.properties.goal?.stepGroup).toBe(0);
406
+ expect(roundTripped.properties.risk?.stepGroup).toBe(1);
407
+ expect(roundTripped.steps).toEqual(STEPS.slice(0, 2));
408
+ expect(
409
+ (roundTripped as JSONSchema & { stepGroupMap?: unknown }).stepGroupMap,
410
+ ).toBeUndefined();
411
+
412
+ // And re-parsing the normalised output produces the same field shape
413
+ const reparsed = jsonSchemaToFieldsAndSteps(
414
+ roundTripped,
415
+ defaultComponents,
416
+ );
417
+ const byIdOriginal = new Map(fields.map((f) => [f.id, f.stepGroup]));
418
+ const byIdRoundTrip = new Map(
419
+ reparsed.fields.map((f) => [f.id, f.stepGroup]),
420
+ );
421
+ expect(byIdRoundTrip).toEqual(byIdOriginal);
422
+ });
423
+
424
+ it("preserves Path A through a full round-trip", () => {
425
+ const inputSchema: JSONSchema = {
426
+ type: "object",
427
+ properties: {
428
+ name: { type: "string", label: "Name", stepGroup: 0 },
429
+ email: {
430
+ type: "string",
431
+ format: "email",
432
+ label: "Email",
433
+ stepGroup: 1,
434
+ },
435
+ },
436
+ steps: STEPS.slice(0, 2),
437
+ };
438
+
439
+ const { fields, steps } = jsonSchemaToFieldsAndSteps(
440
+ inputSchema,
441
+ defaultComponents,
442
+ );
443
+ const roundTripped = fieldsToJSONSchema(fields, defaultComponents, steps);
444
+
445
+ expect(roundTripped.properties.name?.stepGroup).toBe(0);
446
+ expect(roundTripped.properties.email?.stepGroup).toBe(1);
447
+ expect(roundTripped.steps).toEqual(STEPS.slice(0, 2));
448
+ });
449
+ });