@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.
- package/dist/components/form-builder/index.d.cts +8 -0
- package/dist/components/form-builder/index.d.mts +8 -0
- package/dist/components/form-builder/index.d.ts +8 -0
- package/dist/packages/ui/src/components/form-builder/components/index.cjs +2 -1
- package/dist/packages/ui/src/components/form-builder/components/index.mjs +2 -1
- package/dist/packages/ui/src/components/form-builder/schema-utils.cjs +14 -6
- package/dist/packages/ui/src/components/form-builder/schema-utils.mjs +14 -6
- package/package.json +1 -1
- package/src/plugins/form-builder/__tests__/schema-utils.test.ts +449 -0
|
@@ -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 (
|
|
65
|
-
field.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
|
-
...
|
|
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(
|
|
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 (
|
|
63
|
-
field.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
|
-
...
|
|
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(
|
|
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
|
@@ -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
|
+
});
|