@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.
- 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/stack/src/plugins/cms/client/components/forms/content-form.cjs +58 -34
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +58 -34
- package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +29 -7
- package/dist/packages/stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +29 -7
- package/dist/packages/ui/src/components/auto-form/helpers.cjs +24 -1
- package/dist/packages/ui/src/components/auto-form/helpers.mjs +24 -1
- package/dist/packages/ui/src/components/form-builder/schema-utils.cjs +11 -5
- package/dist/packages/ui/src/components/form-builder/schema-utils.mjs +11 -5
- package/dist/plugins/blog/api/index.d.cts +3 -2
- package/dist/plugins/blog/api/index.d.mts +3 -2
- package/dist/plugins/blog/api/index.d.ts +3 -2
- package/dist/plugins/blog/client/components/index.cjs +21 -0
- package/dist/plugins/blog/client/components/index.d.cts +71 -1
- package/dist/plugins/blog/client/components/index.d.mts +71 -1
- package/dist/plugins/blog/client/components/index.d.ts +71 -1
- package/dist/plugins/blog/client/components/index.mjs +15 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +3 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +3 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +3 -2
- package/dist/plugins/blog/client/index.d.cts +3 -2
- package/dist/plugins/blog/client/index.d.mts +3 -2
- package/dist/plugins/blog/client/index.d.ts +3 -2
- package/dist/plugins/blog/query-keys.d.cts +3 -2
- package/dist/plugins/blog/query-keys.d.mts +3 -2
- package/dist/plugins/blog/query-keys.d.ts +3 -2
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/shared/{stack.6mEHS2WH.d.mts → stack.BnSAhKJi.d.ts} +4 -3
- package/dist/shared/{stack.C1jLzFZ6.d.mts → stack.CLWhMntf.d.cts} +6 -5
- package/dist/shared/{stack.BQmuNl5p.d.mts → stack.CuhcFsAx.d.cts} +3 -38
- package/dist/shared/{stack.BQmuNl5p.d.ts → stack.CuhcFsAx.d.mts} +3 -38
- package/dist/shared/{stack.BQmuNl5p.d.cts → stack.CuhcFsAx.d.ts} +3 -38
- package/dist/shared/{stack.AJTXI7kw.d.cts → stack.DNLuzDb3.d.cts} +4 -3
- package/dist/shared/{stack.QYn-Px94.d.ts → stack.Dfc44JiE.d.mts} +4 -3
- package/dist/shared/{stack.DF1ewpVE.d.ts → stack.DiodAFAa.d.mts} +6 -5
- package/dist/shared/stack.Dsatjh0B.d.cts +35 -0
- package/dist/shared/stack.Dsatjh0B.d.mts +35 -0
- package/dist/shared/stack.Dsatjh0B.d.ts +35 -0
- package/dist/shared/{stack.XulPeAD3.d.cts → stack.ufcExQIR.d.ts} +6 -5
- package/package.json +14 -1
- package/src/plugins/blog/client/components/index.tsx +52 -0
- package/src/plugins/cms/__tests__/auto-form-nested-config.test.ts +177 -0
- package/src/plugins/cms/client/components/forms/content-form.tsx +93 -47
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +49 -1
- package/src/plugins/form-builder/__tests__/schema-utils.test.ts +362 -0
- package/dist/shared/{stack.j5SFLC1d.d.ts → stack.9dFHtvXR.d.ts} +15 -15
- package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.BzKW7ToW.d.mts} +15 -15
- 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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
prop.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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}
|
|
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
|
+
});
|