@webiny/app-admin 6.1.0 → 6.2.0
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/base/Admin.js +2 -0
- package/base/Admin.js.map +1 -1
- package/base/Base/DefaultFieldRenderers.d.ts +2 -0
- package/base/Base/DefaultFieldRenderers.js +15 -0
- package/base/Base/DefaultFieldRenderers.js.map +1 -0
- package/base/Base/FieldRenderers/SelectRenderer.d.ts +5 -0
- package/base/Base/FieldRenderers/SelectRenderer.js +24 -0
- package/base/Base/FieldRenderers/SelectRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/TextRenderer.d.ts +5 -0
- package/base/Base/FieldRenderers/TextRenderer.js +21 -0
- package/base/Base/FieldRenderers/TextRenderer.js.map +1 -0
- package/base/Base/Menus.js +7 -0
- package/base/Base/Menus.js.map +1 -1
- package/base/Base.js +2 -1
- package/base/Base.js.map +1 -1
- package/base/createRootContainer.js +2 -0
- package/base/createRootContainer.js.map +1 -1
- package/components/Dialogs/DialogParamsContext.d.ts +6 -0
- package/components/Dialogs/DialogParamsContext.js +11 -0
- package/components/Dialogs/DialogParamsContext.js.map +1 -0
- package/components/Dialogs/DialogsContext.d.ts +2 -0
- package/components/Dialogs/DialogsContext.js +32 -4
- package/components/Dialogs/DialogsContext.js.map +1 -1
- package/components/OptionsMenu/OptionsMenu.d.ts +6 -0
- package/components/OptionsMenu/OptionsMenu.js +3 -3
- package/components/OptionsMenu/OptionsMenu.js.map +1 -1
- package/components/RegisterFeature.js +5 -5
- package/components/RegisterFeature.js.map +1 -1
- package/components/Wcp.d.ts +2 -0
- package/components/Wcp.js +7 -0
- package/components/Wcp.js.map +1 -1
- package/config/AdminConfig/Dialog.d.ts +10 -0
- package/config/AdminConfig/Dialog.js +21 -0
- package/config/AdminConfig/Dialog.js.map +1 -0
- package/config/AdminConfig/FieldRenderer.d.ts +11 -0
- package/config/AdminConfig/FieldRenderer.js +21 -0
- package/config/AdminConfig/FieldRenderer.js.map +1 -0
- package/config/AdminConfig/Form.d.ts +3 -0
- package/config/AdminConfig/Form.js +6 -0
- package/config/AdminConfig/Form.js.map +1 -0
- package/config/AdminConfig.d.ts +10 -0
- package/config/AdminConfig.js +7 -1
- package/config/AdminConfig.js.map +1 -1
- package/exports/admin/security.d.ts +6 -0
- package/exports/admin/security.js +5 -0
- package/exports/admin/security.js.map +1 -1
- package/exports/admin/ui.d.ts +2 -0
- package/exports/admin/ui.js +2 -0
- package/exports/admin/ui.js.map +1 -1
- package/exports/admin.d.ts +0 -3
- package/exports/admin.js +0 -3
- package/exports/admin.js.map +1 -1
- package/features/formModel/Field.d.ts +52 -0
- package/features/formModel/Field.js +201 -0
- package/features/formModel/Field.js.map +1 -0
- package/features/formModel/FieldBuilder.d.ts +45 -0
- package/features/formModel/FieldBuilder.js +158 -0
- package/features/formModel/FieldBuilder.js.map +1 -0
- package/features/formModel/FieldBuilder.test.js +106 -0
- package/features/formModel/FieldBuilder.test.js.map +1 -0
- package/features/formModel/FormModel.d.ts +61 -0
- package/features/formModel/FormModel.js +573 -0
- package/features/formModel/FormModel.js.map +1 -0
- package/features/formModel/FormModel.test.d.ts +1 -0
- package/features/formModel/FormModel.test.js +1140 -0
- package/features/formModel/FormModel.test.js.map +1 -0
- package/features/formModel/FormModelFactory.d.ts +9 -0
- package/features/formModel/FormModelFactory.js +13 -0
- package/features/formModel/FormModelFactory.js.map +1 -0
- package/features/formModel/FormView.d.ts +23 -0
- package/features/formModel/FormView.js +138 -0
- package/features/formModel/FormView.js.map +1 -0
- package/features/formModel/abstractions.d.ts +286 -0
- package/features/formModel/abstractions.js +54 -0
- package/features/formModel/abstractions.js.map +1 -0
- package/features/formModel/feature.d.ts +3 -0
- package/features/formModel/feature.js +16 -0
- package/features/formModel/feature.js.map +1 -0
- package/features/formModel/index.d.ts +10 -0
- package/features/formModel/index.js +14 -0
- package/features/formModel/index.js.map +1 -0
- package/features/formModel/useFieldRenderers.d.ts +2 -0
- package/features/formModel/useFieldRenderers.js +19 -0
- package/features/formModel/useFieldRenderers.js.map +1 -0
- package/features/security/LogIn/LogInGateway.d.ts +2 -2
- package/features/security/LogIn/LogInGateway.js +2 -2
- package/features/security/LogIn/LogInGateway.js.map +1 -1
- package/features/wcp/WcpGateway.d.ts +2 -2
- package/features/wcp/WcpGateway.js +2 -2
- package/features/wcp/WcpGateway.js.map +1 -1
- package/hooks/index.d.ts +1 -0
- package/hooks/index.js +1 -0
- package/hooks/index.js.map +1 -1
- package/hooks/useDialog.d.ts +9 -29
- package/hooks/useDialog.js +16 -24
- package/hooks/useDialog.js.map +1 -1
- package/hooks/useOpenDialog.d.ts +7 -0
- package/hooks/useOpenDialog.js +18 -0
- package/hooks/useOpenDialog.js.map +1 -0
- package/index.d.ts +5 -0
- package/index.js +5 -0
- package/index.js.map +1 -1
- package/package.json +30 -30
- package/permissions/createHasPermission.d.ts +3 -2
- package/permissions/createHasPermission.js +4 -8
- package/permissions/createHasPermission.js.map +1 -1
- package/permissions/createPermissions.d.ts +6 -0
- package/permissions/createPermissions.js +201 -0
- package/permissions/createPermissions.js.map +1 -0
- package/permissions/createPermissions.test.d.ts +1 -0
- package/permissions/createPermissions.test.js +177 -0
- package/permissions/createPermissions.test.js.map +1 -0
- package/permissions/index.d.ts +1 -0
- package/permissions/index.js +1 -0
- package/permissions/index.js.map +1 -1
- package/permissions/types.d.ts +5 -0
- package/permissions/types.js.map +1 -1
- package/permissions/usePermissions.d.ts +2 -1
- package/permissions/usePermissions.js +4 -175
- package/permissions/usePermissions.js.map +1 -1
- package/presentation/installation/presenters/SystemInstaller/SystemInstallerGateway.d.ts +2 -2
- package/presentation/installation/presenters/SystemInstaller/SystemInstallerGateway.js +2 -2
- package/presentation/installation/presenters/SystemInstaller/SystemInstallerGateway.js.map +1 -1
- package/permissions/createHasPermission.test.js +0 -206
- package/permissions/createHasPermission.test.js.map +0 -1
- /package/{permissions/createHasPermission.test.d.ts → features/formModel/FieldBuilder.test.d.ts} +0 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { FormModel } from "./FormModel.js";
|
|
4
|
+
function asRow(node) {
|
|
5
|
+
if (node.type !== "row") {
|
|
6
|
+
throw new Error(`Expected row node, got "${node.type}"`);
|
|
7
|
+
}
|
|
8
|
+
return node;
|
|
9
|
+
}
|
|
10
|
+
function createBasicForm() {
|
|
11
|
+
return new FormModel({
|
|
12
|
+
fields: fields => ({
|
|
13
|
+
title: fields.text().label("Title").required("Title is required"),
|
|
14
|
+
path: fields.text().label("Path").required("Path is required")
|
|
15
|
+
})
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
describe("FormModel", () => {
|
|
19
|
+
describe("field creation", () => {
|
|
20
|
+
it("should create fields from builder definitions", () => {
|
|
21
|
+
const form = createBasicForm();
|
|
22
|
+
expect(form.field("title")).toBeDefined();
|
|
23
|
+
expect(form.field("path")).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
it("should throw on unknown field access", () => {
|
|
26
|
+
const form = createBasicForm();
|
|
27
|
+
expect(() => form.field("unknown")).toThrow('Field "unknown" not found.');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("setValue / getValue", () => {
|
|
31
|
+
it("should set and get field values", () => {
|
|
32
|
+
const form = createBasicForm();
|
|
33
|
+
form.field("title").setValue("Hello");
|
|
34
|
+
expect(form.field("title").getValue()).toBe("Hello");
|
|
35
|
+
});
|
|
36
|
+
it("should default to null when no defaultValue is set", () => {
|
|
37
|
+
const form = createBasicForm();
|
|
38
|
+
expect(form.field("title").getValue()).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it("should use defaultValue when provided", () => {
|
|
41
|
+
const form = new FormModel({
|
|
42
|
+
fields: fields => ({
|
|
43
|
+
status: fields.text().defaultValue("draft")
|
|
44
|
+
})
|
|
45
|
+
});
|
|
46
|
+
expect(form.field("status").getValue()).toBe("draft");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("getData / setData", () => {
|
|
50
|
+
it("should return all field values including hidden", () => {
|
|
51
|
+
const form = new FormModel({
|
|
52
|
+
fields: fields => ({
|
|
53
|
+
title: fields.text().label("Title"),
|
|
54
|
+
pageType: fields.text().hidden().defaultValue("static")
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
form.field("title").setValue("Hello");
|
|
58
|
+
const data = form.getData();
|
|
59
|
+
expect(data).toEqual({
|
|
60
|
+
title: "Hello",
|
|
61
|
+
pageType: "static"
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it("should hydrate fields from data object", () => {
|
|
65
|
+
const form = createBasicForm();
|
|
66
|
+
form.setData({
|
|
67
|
+
title: "My Page",
|
|
68
|
+
path: "/my-page"
|
|
69
|
+
});
|
|
70
|
+
expect(form.field("title").getValue()).toBe("My Page");
|
|
71
|
+
expect(form.field("path").getValue()).toBe("/my-page");
|
|
72
|
+
});
|
|
73
|
+
it("should ignore unknown fields in setData", () => {
|
|
74
|
+
const form = createBasicForm();
|
|
75
|
+
form.setData({
|
|
76
|
+
title: "Test",
|
|
77
|
+
unknown: "value"
|
|
78
|
+
});
|
|
79
|
+
expect(form.field("title").getValue()).toBe("Test");
|
|
80
|
+
expect(() => form.field("unknown")).toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("isDirty", () => {
|
|
84
|
+
it("should not be dirty initially", () => {
|
|
85
|
+
const form = createBasicForm();
|
|
86
|
+
expect(form.isDirty).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it("should be dirty after setValue", () => {
|
|
89
|
+
const form = createBasicForm();
|
|
90
|
+
form.field("title").setValue("Changed");
|
|
91
|
+
expect(form.isDirty).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
it("should not be dirty after setData", () => {
|
|
94
|
+
const form = createBasicForm();
|
|
95
|
+
form.setData({
|
|
96
|
+
title: "Loaded",
|
|
97
|
+
path: "/loaded"
|
|
98
|
+
});
|
|
99
|
+
expect(form.isDirty).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it("should not be dirty after reverting to baseline value", () => {
|
|
102
|
+
const form = createBasicForm();
|
|
103
|
+
form.setData({
|
|
104
|
+
title: "Original",
|
|
105
|
+
path: "/original"
|
|
106
|
+
});
|
|
107
|
+
form.field("title").setValue("Changed");
|
|
108
|
+
expect(form.isDirty).toBe(true);
|
|
109
|
+
form.field("title").setValue("Original");
|
|
110
|
+
expect(form.isDirty).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe("reset", () => {
|
|
114
|
+
it("should revert values to setData baseline", () => {
|
|
115
|
+
const form = createBasicForm();
|
|
116
|
+
form.setData({
|
|
117
|
+
title: "Original",
|
|
118
|
+
path: "/original"
|
|
119
|
+
});
|
|
120
|
+
form.field("title").setValue("Changed");
|
|
121
|
+
form.reset();
|
|
122
|
+
expect(form.field("title").getValue()).toBe("Original");
|
|
123
|
+
expect(form.isDirty).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
it("should clear validation state on reset", async () => {
|
|
126
|
+
const form = createBasicForm();
|
|
127
|
+
await form.validate();
|
|
128
|
+
expect(form.errors.length).toBeGreaterThan(0);
|
|
129
|
+
form.reset();
|
|
130
|
+
expect(form.errors).toEqual([]);
|
|
131
|
+
expect(form.isValid).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("validation", () => {
|
|
135
|
+
it("should fail validation for empty required fields", async () => {
|
|
136
|
+
const form = createBasicForm();
|
|
137
|
+
const valid = await form.validate();
|
|
138
|
+
expect(valid).toBe(false);
|
|
139
|
+
expect(form.isValid).toBe(false);
|
|
140
|
+
expect(form.errors).toHaveLength(2);
|
|
141
|
+
expect(form.errors[0].path).toBe("title");
|
|
142
|
+
expect(form.errors[0].message).toBe("Title is required");
|
|
143
|
+
expect(form.errors[1].path).toBe("path");
|
|
144
|
+
});
|
|
145
|
+
it("should pass validation when required fields have values", async () => {
|
|
146
|
+
const form = createBasicForm();
|
|
147
|
+
form.field("title").setValue("My Page");
|
|
148
|
+
form.field("path").setValue("/my-page");
|
|
149
|
+
const valid = await form.validate();
|
|
150
|
+
expect(valid).toBe(true);
|
|
151
|
+
expect(form.isValid).toBe(true);
|
|
152
|
+
expect(form.errors).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
it("should validate zod schemas", async () => {
|
|
155
|
+
const form = new FormModel({
|
|
156
|
+
fields: fields => ({
|
|
157
|
+
email: fields.text().label("Email").schema(z.string().email("Invalid email"))
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
form.field("email").setValue("not-an-email");
|
|
161
|
+
const valid = await form.validate();
|
|
162
|
+
expect(valid).toBe(false);
|
|
163
|
+
expect(form.errors[0].message).toBe("Invalid email");
|
|
164
|
+
});
|
|
165
|
+
it("should run required check before zod schema", async () => {
|
|
166
|
+
const form = new FormModel({
|
|
167
|
+
fields: fields => ({
|
|
168
|
+
email: fields.text().label("Email").required("Email is required").schema(z.string().email("Invalid email"))
|
|
169
|
+
})
|
|
170
|
+
});
|
|
171
|
+
const valid = await form.validate();
|
|
172
|
+
expect(valid).toBe(false);
|
|
173
|
+
expect(form.errors[0].message).toBe("Email is required");
|
|
174
|
+
});
|
|
175
|
+
it("should expose field-level validation in field.vm", async () => {
|
|
176
|
+
const form = createBasicForm();
|
|
177
|
+
await form.validate();
|
|
178
|
+
const titleVm = form.field("title").vm;
|
|
179
|
+
expect(titleVm.validation.isValid).toBe(false);
|
|
180
|
+
expect(titleVm.validation.message).toBe("Title is required");
|
|
181
|
+
});
|
|
182
|
+
it("isValid should be null before first validation", () => {
|
|
183
|
+
const form = createBasicForm();
|
|
184
|
+
expect(form.isValid).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe("submit", () => {
|
|
188
|
+
it("should return data when valid", async () => {
|
|
189
|
+
const form = createBasicForm();
|
|
190
|
+
form.field("title").setValue("My Page");
|
|
191
|
+
form.field("path").setValue("/my-page");
|
|
192
|
+
const result = await form.submit();
|
|
193
|
+
expect(result).toEqual({
|
|
194
|
+
title: "My Page",
|
|
195
|
+
path: "/my-page"
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
it("should return false when invalid", async () => {
|
|
199
|
+
const form = createBasicForm();
|
|
200
|
+
const result = await form.submit();
|
|
201
|
+
expect(result).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe("vm", () => {
|
|
205
|
+
it("should expose layout with field VMs", () => {
|
|
206
|
+
const form = createBasicForm();
|
|
207
|
+
const vm = form.vm;
|
|
208
|
+
expect(vm.layout).toHaveLength(2);
|
|
209
|
+
expect(vm.layout[0].type).toBe("row");
|
|
210
|
+
expect(asRow(vm.layout[0]).fields[0].name).toBe("title");
|
|
211
|
+
expect(asRow(vm.layout[1]).fields[0].name).toBe("path");
|
|
212
|
+
});
|
|
213
|
+
it("should exclude hidden fields from layout", () => {
|
|
214
|
+
const form = new FormModel({
|
|
215
|
+
fields: fields => ({
|
|
216
|
+
title: fields.text().label("Title"),
|
|
217
|
+
pageType: fields.text().hidden().defaultValue("static")
|
|
218
|
+
})
|
|
219
|
+
});
|
|
220
|
+
const vm = form.vm;
|
|
221
|
+
expect(vm.layout).toHaveLength(1);
|
|
222
|
+
expect(asRow(vm.layout[0]).fields[0].name).toBe("title");
|
|
223
|
+
});
|
|
224
|
+
it("should expose isDirty and isValid", () => {
|
|
225
|
+
const form = createBasicForm();
|
|
226
|
+
expect(form.vm.isDirty).toBe(false);
|
|
227
|
+
expect(form.vm.isValid).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
it("should expose field onChange that calls setValue", () => {
|
|
230
|
+
const form = createBasicForm();
|
|
231
|
+
const fieldVM = asRow(form.vm.layout[0]).fields[0];
|
|
232
|
+
fieldVM.onChange("New Value");
|
|
233
|
+
expect(form.field("title").getValue()).toBe("New Value");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe("layout", () => {
|
|
237
|
+
it("should generate default layout (one row per non-hidden field)", () => {
|
|
238
|
+
const form = createBasicForm();
|
|
239
|
+
expect(form.vm.layout).toHaveLength(2);
|
|
240
|
+
});
|
|
241
|
+
it("should use explicit layout when provided", () => {
|
|
242
|
+
const form = new FormModel({
|
|
243
|
+
fields: fields => ({
|
|
244
|
+
title: fields.text().label("Title"),
|
|
245
|
+
path: fields.text().label("Path")
|
|
246
|
+
}),
|
|
247
|
+
layout: layout => [layout.row("title", "path")]
|
|
248
|
+
});
|
|
249
|
+
expect(form.vm.layout).toHaveLength(1);
|
|
250
|
+
expect(asRow(form.vm.layout[0]).fields).toHaveLength(2);
|
|
251
|
+
});
|
|
252
|
+
it("should warn about orphan fields in explicit layout", () => {
|
|
253
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
254
|
+
new FormModel({
|
|
255
|
+
fields: fields => ({
|
|
256
|
+
title: fields.text().label("Title"),
|
|
257
|
+
path: fields.text().label("Path")
|
|
258
|
+
}),
|
|
259
|
+
layout: layout => [layout.row("title")]
|
|
260
|
+
});
|
|
261
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Field "path" is not in the layout'));
|
|
262
|
+
warnSpy.mockRestore();
|
|
263
|
+
});
|
|
264
|
+
it("should not warn about hidden orphan fields", () => {
|
|
265
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
266
|
+
new FormModel({
|
|
267
|
+
fields: fields => ({
|
|
268
|
+
title: fields.text().label("Title"),
|
|
269
|
+
pageType: fields.text().hidden().defaultValue("static")
|
|
270
|
+
}),
|
|
271
|
+
layout: layout => [layout.row("title")]
|
|
272
|
+
});
|
|
273
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
274
|
+
warnSpy.mockRestore();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
describe("beforeChange / afterChange", () => {
|
|
278
|
+
it("should run beforeChange pipeline in order, transforming value", () => {
|
|
279
|
+
const form = new FormModel({
|
|
280
|
+
fields: fields => ({
|
|
281
|
+
path: fields.text().label("Path").beforeChange(value => String(value).trim()).beforeChange(value => String(value).toLowerCase())
|
|
282
|
+
})
|
|
283
|
+
});
|
|
284
|
+
form.field("path").setValue(" Hello World ");
|
|
285
|
+
expect(form.field("path").getValue()).toBe("hello world");
|
|
286
|
+
});
|
|
287
|
+
it("should run afterChange after value is stored", () => {
|
|
288
|
+
const received = [];
|
|
289
|
+
const form = new FormModel({
|
|
290
|
+
fields: fields => ({
|
|
291
|
+
title: fields.text().label("Title").afterChange(value => {
|
|
292
|
+
received.push(value);
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
});
|
|
296
|
+
form.field("title").setValue("Hello");
|
|
297
|
+
expect(received).toEqual(["Hello"]);
|
|
298
|
+
});
|
|
299
|
+
it("should pass transformed value to afterChange", () => {
|
|
300
|
+
const received = [];
|
|
301
|
+
const form = new FormModel({
|
|
302
|
+
fields: fields => ({
|
|
303
|
+
path: fields.text().label("Path").beforeChange(value => String(value).toLowerCase()).afterChange(value => {
|
|
304
|
+
received.push(value);
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
});
|
|
308
|
+
form.field("path").setValue("HELLO");
|
|
309
|
+
expect(form.field("path").getValue()).toBe("hello");
|
|
310
|
+
expect(received).toEqual(["hello"]);
|
|
311
|
+
});
|
|
312
|
+
it("should not fire afterChange when value does not change (recursion guard)", () => {
|
|
313
|
+
const calls = [];
|
|
314
|
+
const form = new FormModel({
|
|
315
|
+
fields: fields => ({
|
|
316
|
+
title: fields.text().label("Title").beforeChange(() => "constant").afterChange(() => {
|
|
317
|
+
calls.push("afterChange");
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
form.field("title").setValue("anything");
|
|
322
|
+
expect(calls).toEqual(["afterChange"]);
|
|
323
|
+
expect(form.field("title").getValue()).toBe("constant");
|
|
324
|
+
|
|
325
|
+
// Setting again — beforeChange still produces "constant", which === current value.
|
|
326
|
+
// afterChange should NOT fire.
|
|
327
|
+
form.field("title").setValue("something else");
|
|
328
|
+
expect(calls).toEqual(["afterChange"]);
|
|
329
|
+
});
|
|
330
|
+
it("should not trigger beforeChange or afterChange on setData", () => {
|
|
331
|
+
const calls = [];
|
|
332
|
+
const form = new FormModel({
|
|
333
|
+
fields: fields => ({
|
|
334
|
+
title: fields.text().label("Title").beforeChange(value => {
|
|
335
|
+
calls.push("before");
|
|
336
|
+
return value;
|
|
337
|
+
}).afterChange(() => {
|
|
338
|
+
calls.push("after");
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
});
|
|
342
|
+
form.setData({
|
|
343
|
+
title: "Loaded"
|
|
344
|
+
});
|
|
345
|
+
expect(calls).toEqual([]);
|
|
346
|
+
expect(form.field("title").getValue()).toBe("Loaded");
|
|
347
|
+
});
|
|
348
|
+
it("should support cross-field afterChange triggering target field pipeline", () => {
|
|
349
|
+
const form = new FormModel({
|
|
350
|
+
fields: fields => ({
|
|
351
|
+
title: fields.text().label("Title").afterChange((value, f) => {
|
|
352
|
+
// Auto-generate path from title
|
|
353
|
+
const path = "/" + String(value).toLowerCase().replace(/\s+/g, "-");
|
|
354
|
+
f.field("path").setValue(path);
|
|
355
|
+
}),
|
|
356
|
+
path: fields.text().label("Path").beforeChange(value => {
|
|
357
|
+
// Ensure path starts with /
|
|
358
|
+
const str = String(value);
|
|
359
|
+
return str.startsWith("/") ? str : "/" + str;
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
});
|
|
363
|
+
form.field("title").setValue("Hello World");
|
|
364
|
+
expect(form.field("path").getValue()).toBe("/hello-world");
|
|
365
|
+
});
|
|
366
|
+
it("should allow appending callbacks to existing fields at runtime", () => {
|
|
367
|
+
const form = new FormModel({
|
|
368
|
+
fields: fields => ({
|
|
369
|
+
title: fields.text().label("Title")
|
|
370
|
+
})
|
|
371
|
+
});
|
|
372
|
+
const field = form.field("title");
|
|
373
|
+
field.addBeforeChange(value => String(value).toUpperCase());
|
|
374
|
+
field.setValue("hello");
|
|
375
|
+
expect(field.getValue()).toBe("HELLO");
|
|
376
|
+
});
|
|
377
|
+
it("should chain builder callbacks with runtime-appended callbacks", () => {
|
|
378
|
+
const form = new FormModel({
|
|
379
|
+
fields: fields => ({
|
|
380
|
+
path: fields.text().label("Path").beforeChange(value => String(value).trim())
|
|
381
|
+
})
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Append another transform at runtime (simulating a modifier)
|
|
385
|
+
form.field("path").addBeforeChange(value => String(value).toLowerCase());
|
|
386
|
+
form.field("path").setValue(" HELLO ");
|
|
387
|
+
expect(form.field("path").getValue()).toBe("hello");
|
|
388
|
+
});
|
|
389
|
+
it("should demonstrate title→path with path-dirty tracking", () => {
|
|
390
|
+
const form = new FormModel({
|
|
391
|
+
fields: fields => ({
|
|
392
|
+
title: fields.text().label("Title").required("Title is required").afterChange((value, f) => {
|
|
393
|
+
// Only auto-generate if path is empty
|
|
394
|
+
if (f.field("path").getValue()) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const slug = String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
398
|
+
f.field("path").setValue("/" + slug);
|
|
399
|
+
}),
|
|
400
|
+
path: fields.text().label("Path").required("Path is required").beforeChange(value => {
|
|
401
|
+
const str = String(value);
|
|
402
|
+
return "/" + str.replace(/^\//, "").toLowerCase().replace(/[^a-z0-9/-]+/g, "-").replace(/^-|-$/g, "");
|
|
403
|
+
})
|
|
404
|
+
}),
|
|
405
|
+
layout: layout => [layout.row("title"), layout.row("path")]
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Type title → path auto-fills (path was null/empty)
|
|
409
|
+
form.field("title").setValue("Hello World");
|
|
410
|
+
expect(form.field("path").getValue()).toBe("/hello-world");
|
|
411
|
+
|
|
412
|
+
// Manually edit path → title changes no longer overwrite
|
|
413
|
+
form.field("path").setValue("/custom-path");
|
|
414
|
+
expect(form.field("path").getValue()).toBe("/custom-path");
|
|
415
|
+
form.field("title").setValue("New Title");
|
|
416
|
+
// Path still "/custom-path" because path is no longer empty
|
|
417
|
+
expect(form.field("path").getValue()).toBe("/custom-path");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
describe("select field with options", () => {
|
|
421
|
+
it("should resolve static options in field VM", () => {
|
|
422
|
+
const form = new FormModel({
|
|
423
|
+
fields: fields => ({
|
|
424
|
+
lang: fields.select().label("Language").options([{
|
|
425
|
+
label: "English",
|
|
426
|
+
value: "en"
|
|
427
|
+
}, {
|
|
428
|
+
label: "German",
|
|
429
|
+
value: "de"
|
|
430
|
+
}])
|
|
431
|
+
})
|
|
432
|
+
});
|
|
433
|
+
const fieldVM = asRow(form.vm.layout[0]).fields[0];
|
|
434
|
+
expect(fieldVM.options).toEqual([{
|
|
435
|
+
label: "English",
|
|
436
|
+
value: "en"
|
|
437
|
+
}, {
|
|
438
|
+
label: "German",
|
|
439
|
+
value: "de"
|
|
440
|
+
}]);
|
|
441
|
+
});
|
|
442
|
+
it("should resolve reactive options function in field VM", () => {
|
|
443
|
+
const form = new FormModel({
|
|
444
|
+
fields: fields => ({
|
|
445
|
+
lang: fields.select().label("Language").options(() => {
|
|
446
|
+
// Dynamic options based on form state
|
|
447
|
+
return [{
|
|
448
|
+
label: "Dynamic",
|
|
449
|
+
value: "dynamic"
|
|
450
|
+
}];
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
});
|
|
454
|
+
const fieldVM = asRow(form.vm.layout[0]).fields[0];
|
|
455
|
+
expect(fieldVM.options).toEqual([{
|
|
456
|
+
label: "Dynamic",
|
|
457
|
+
value: "dynamic"
|
|
458
|
+
}]);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
describe("modifiers (Phase 3)", () => {
|
|
462
|
+
describe("form.fields() — add / replace / remove", () => {
|
|
463
|
+
it("should add a new field via form.fields()", () => {
|
|
464
|
+
const form = createBasicForm();
|
|
465
|
+
form.fields(fields => ({
|
|
466
|
+
language: fields.select().label("Language").options([{
|
|
467
|
+
label: "English",
|
|
468
|
+
value: "en"
|
|
469
|
+
}, {
|
|
470
|
+
label: "German",
|
|
471
|
+
value: "de"
|
|
472
|
+
}])
|
|
473
|
+
}));
|
|
474
|
+
expect(form.field("language")).toBeDefined();
|
|
475
|
+
expect(form.field("language").type).toBe("select");
|
|
476
|
+
expect(form.getData()).toHaveProperty("language");
|
|
477
|
+
});
|
|
478
|
+
it("should add a field that appears in getData but not layout until positioned", () => {
|
|
479
|
+
const form = new FormModel({
|
|
480
|
+
fields: fields => ({
|
|
481
|
+
title: fields.text().label("Title")
|
|
482
|
+
})
|
|
483
|
+
});
|
|
484
|
+
form.fields(fields => ({
|
|
485
|
+
description: fields.text().label("Description")
|
|
486
|
+
}));
|
|
487
|
+
|
|
488
|
+
// Field exists in data
|
|
489
|
+
expect(form.getData()).toHaveProperty("description");
|
|
490
|
+
|
|
491
|
+
// But not in layout until explicitly positioned
|
|
492
|
+
const fieldNames = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
493
|
+
expect(fieldNames).not.toContain("description");
|
|
494
|
+
|
|
495
|
+
// Position it
|
|
496
|
+
form.layout(layout => [layout.row("description").after("title")]);
|
|
497
|
+
const updatedNames = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
498
|
+
expect(updatedNames).toEqual(["title", "description"]);
|
|
499
|
+
});
|
|
500
|
+
it("should replace an existing field when key matches", () => {
|
|
501
|
+
const form = createBasicForm();
|
|
502
|
+
form.field("title").setValue("Old Value");
|
|
503
|
+
form.fields(fields => ({
|
|
504
|
+
title: fields.text().label("Replaced Title").placeholder("New placeholder")
|
|
505
|
+
}));
|
|
506
|
+
|
|
507
|
+
// The field is replaced entirely — old value is gone
|
|
508
|
+
expect(form.field("title").config.label).toBe("Replaced Title");
|
|
509
|
+
expect(form.field("title").config.placeholder).toBe("New placeholder");
|
|
510
|
+
expect(form.field("title").getValue()).toBeNull();
|
|
511
|
+
});
|
|
512
|
+
it("should remove a field via undefined", () => {
|
|
513
|
+
const form = createBasicForm();
|
|
514
|
+
form.fields(() => ({
|
|
515
|
+
path: undefined
|
|
516
|
+
}));
|
|
517
|
+
expect(() => form.field("path")).toThrow('Field "path" not found.');
|
|
518
|
+
expect(form.getData()).not.toHaveProperty("path");
|
|
519
|
+
});
|
|
520
|
+
it("should remove a field via field.remove()", () => {
|
|
521
|
+
const form = createBasicForm();
|
|
522
|
+
form.field("path").remove();
|
|
523
|
+
expect(() => form.field("path")).toThrow('Field "path" not found.');
|
|
524
|
+
expect(form.getData()).not.toHaveProperty("path");
|
|
525
|
+
});
|
|
526
|
+
it("should remove field from layout when removed via field.remove()", () => {
|
|
527
|
+
const form = createBasicForm();
|
|
528
|
+
expect(form.vm.layout).toHaveLength(2);
|
|
529
|
+
form.field("path").remove();
|
|
530
|
+
expect(form.vm.layout).toHaveLength(1);
|
|
531
|
+
expect(asRow(form.vm.layout[0]).fields[0].name).toBe("title");
|
|
532
|
+
});
|
|
533
|
+
it("should handle add + remove in the same fields() call", () => {
|
|
534
|
+
const form = createBasicForm();
|
|
535
|
+
form.fields(fields => ({
|
|
536
|
+
path: undefined,
|
|
537
|
+
slug: fields.text().label("Slug")
|
|
538
|
+
}));
|
|
539
|
+
expect(() => form.field("path")).toThrow();
|
|
540
|
+
expect(form.field("slug")).toBeDefined();
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
describe("form.field().setDisabled()", () => {
|
|
544
|
+
it("should disable a field via setDisabled(true)", () => {
|
|
545
|
+
const form = createBasicForm();
|
|
546
|
+
form.field("title").setDisabled(true);
|
|
547
|
+
expect(form.field("title").vm.disabled).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
it("should re-enable a field via setDisabled(false)", () => {
|
|
550
|
+
const form = createBasicForm();
|
|
551
|
+
form.field("title").setDisabled(true);
|
|
552
|
+
form.field("title").setDisabled(false);
|
|
553
|
+
expect(form.field("title").vm.disabled).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
describe("form.field().as() — type narrowing", () => {
|
|
557
|
+
it("should return the field when type matches", () => {
|
|
558
|
+
const form = new FormModel({
|
|
559
|
+
fields: fields => ({
|
|
560
|
+
lang: fields.select().label("Language").options([{
|
|
561
|
+
label: "English",
|
|
562
|
+
value: "en"
|
|
563
|
+
}])
|
|
564
|
+
})
|
|
565
|
+
});
|
|
566
|
+
const selectField = form.field("lang").as("select");
|
|
567
|
+
expect(selectField).toBe(form.field("lang"));
|
|
568
|
+
});
|
|
569
|
+
it("should throw when type does not match", () => {
|
|
570
|
+
const form = createBasicForm();
|
|
571
|
+
expect(() => form.field("title").as("select")).toThrow('Field "title" is type "text", not "select".');
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
describe("modifier appends callbacks to existing fields", () => {
|
|
575
|
+
it("should append beforeChange to an existing field", () => {
|
|
576
|
+
const form = new FormModel({
|
|
577
|
+
fields: fields => ({
|
|
578
|
+
path: fields.text().label("Path").beforeChange(value => String(value).trim())
|
|
579
|
+
})
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Modifier appends another transform
|
|
583
|
+
form.field("path").addBeforeChange(value => String(value).toLowerCase());
|
|
584
|
+
form.field("path").setValue(" HELLO ");
|
|
585
|
+
expect(form.field("path").getValue()).toBe("hello");
|
|
586
|
+
});
|
|
587
|
+
it("should append afterChange to an existing field", () => {
|
|
588
|
+
const received = [];
|
|
589
|
+
const form = new FormModel({
|
|
590
|
+
fields: fields => ({
|
|
591
|
+
title: fields.text().label("Title"),
|
|
592
|
+
path: fields.text().label("Path")
|
|
593
|
+
})
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Modifier appends afterChange
|
|
597
|
+
form.field("title").addAfterChange((value, f) => {
|
|
598
|
+
received.push(value);
|
|
599
|
+
f.field("path").setValue("/" + String(value).toLowerCase());
|
|
600
|
+
});
|
|
601
|
+
form.field("title").setValue("Hello");
|
|
602
|
+
expect(received).toEqual(["Hello"]);
|
|
603
|
+
expect(form.field("path").getValue()).toBe("/hello");
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
describe("layout positional modifiers", () => {
|
|
607
|
+
function createFormWithLayout() {
|
|
608
|
+
return new FormModel({
|
|
609
|
+
fields: fields => ({
|
|
610
|
+
title: fields.text().label("Title"),
|
|
611
|
+
path: fields.text().label("Path"),
|
|
612
|
+
description: fields.text().label("Description")
|
|
613
|
+
}),
|
|
614
|
+
layout: layout => [layout.row("title"), layout.row("path"), layout.row("description")]
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
it("should insert a row before a target", () => {
|
|
618
|
+
const form = createFormWithLayout();
|
|
619
|
+
form.fields(fields => ({
|
|
620
|
+
language: fields.select().label("Language").options([])
|
|
621
|
+
}));
|
|
622
|
+
form.layout(layout => [layout.row("language").before("path")]);
|
|
623
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
624
|
+
expect(names).toEqual(["title", "language", "path", "description"]);
|
|
625
|
+
});
|
|
626
|
+
it("should insert a row after a target", () => {
|
|
627
|
+
const form = createFormWithLayout();
|
|
628
|
+
form.fields(fields => ({
|
|
629
|
+
language: fields.select().label("Language").options([])
|
|
630
|
+
}));
|
|
631
|
+
form.layout(layout => [layout.row("language").after("path")]);
|
|
632
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
633
|
+
expect(names).toEqual(["title", "path", "language", "description"]);
|
|
634
|
+
});
|
|
635
|
+
it("should replace a target row", () => {
|
|
636
|
+
const form = createFormWithLayout();
|
|
637
|
+
form.fields(fields => ({
|
|
638
|
+
slug: fields.text().label("Slug")
|
|
639
|
+
}));
|
|
640
|
+
form.layout(layout => [layout.row("slug").replace("path")]);
|
|
641
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
642
|
+
expect(names).toEqual(["title", "slug", "description"]);
|
|
643
|
+
});
|
|
644
|
+
it("should remove a field from layout", () => {
|
|
645
|
+
const form = createFormWithLayout();
|
|
646
|
+
form.layout(layout => {
|
|
647
|
+
layout.remove("path");
|
|
648
|
+
return [];
|
|
649
|
+
});
|
|
650
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
651
|
+
expect(names).toEqual(["title", "description"]);
|
|
652
|
+
});
|
|
653
|
+
it("should append when no position is specified", () => {
|
|
654
|
+
const form = createFormWithLayout();
|
|
655
|
+
form.fields(fields => ({
|
|
656
|
+
language: fields.select().label("Language").options([])
|
|
657
|
+
}));
|
|
658
|
+
form.layout(layout => [layout.row("language")]);
|
|
659
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
660
|
+
expect(names).toEqual(["title", "path", "description", "language"]);
|
|
661
|
+
});
|
|
662
|
+
it("should append when target is not found", () => {
|
|
663
|
+
const form = createFormWithLayout();
|
|
664
|
+
form.fields(fields => ({
|
|
665
|
+
language: fields.select().label("Language").options([])
|
|
666
|
+
}));
|
|
667
|
+
form.layout(layout => [layout.row("language").after("nonexistent")]);
|
|
668
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
669
|
+
expect(names).toEqual(["title", "path", "description", "language"]);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
describe("IFormModifier integration", () => {
|
|
673
|
+
it("should support a full modifier workflow: add field + position in layout + append callbacks", () => {
|
|
674
|
+
const form = new FormModel({
|
|
675
|
+
fields: fields => ({
|
|
676
|
+
title: fields.text().label("Title").required("Title is required"),
|
|
677
|
+
path: fields.text().label("Path").required("Path is required").beforeChange(value => {
|
|
678
|
+
const str = String(value);
|
|
679
|
+
return "/" + str.replace(/^\//, "").toLowerCase().replace(/[^a-z0-9/-]+/g, "-").replace(/^-|-$/g, "");
|
|
680
|
+
})
|
|
681
|
+
}),
|
|
682
|
+
layout: layout => [layout.row("title"), layout.row("path")]
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Simulate a language modifier
|
|
686
|
+
const modifier = {
|
|
687
|
+
modify(form) {
|
|
688
|
+
// Add language field
|
|
689
|
+
form.fields(fields => ({
|
|
690
|
+
language: fields.select().label("Language").options([{
|
|
691
|
+
label: "English",
|
|
692
|
+
value: "en"
|
|
693
|
+
}, {
|
|
694
|
+
label: "German",
|
|
695
|
+
value: "de"
|
|
696
|
+
}]).afterChange((value, f) => {
|
|
697
|
+
const current = String(f.field("path").getValue() || "");
|
|
698
|
+
const stripped = current.replace(/^\/[a-z]{2}\//, "/");
|
|
699
|
+
if (value && value !== "en") {
|
|
700
|
+
f.field("path").setValue("/" + value + stripped);
|
|
701
|
+
} else {
|
|
702
|
+
f.field("path").setValue(stripped);
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
}));
|
|
706
|
+
|
|
707
|
+
// Position after path
|
|
708
|
+
form.layout(layout => [layout.row("language").after("path")]);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
modifier.modify(form);
|
|
712
|
+
|
|
713
|
+
// Verify layout order
|
|
714
|
+
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
715
|
+
expect(names).toEqual(["title", "path", "language"]);
|
|
716
|
+
|
|
717
|
+
// Verify language field works
|
|
718
|
+
form.field("path").setValue("/demo");
|
|
719
|
+
form.field("language").setValue("de");
|
|
720
|
+
expect(form.field("path").getValue()).toBe("/de/demo");
|
|
721
|
+
|
|
722
|
+
// Verify getData includes language
|
|
723
|
+
const data = form.getData();
|
|
724
|
+
expect(data.language).toBe("de");
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
describe("layout system expansion (Phase 5)", () => {
|
|
729
|
+
describe("separator", () => {
|
|
730
|
+
it("should include separator nodes in the resolved layout", () => {
|
|
731
|
+
const form = new FormModel({
|
|
732
|
+
fields: fields => ({
|
|
733
|
+
title: fields.text().label("Title"),
|
|
734
|
+
description: fields.text().label("Description")
|
|
735
|
+
}),
|
|
736
|
+
layout: layout => [layout.row("title"), layout.separator(), layout.row("description")]
|
|
737
|
+
});
|
|
738
|
+
const vm = form.vm;
|
|
739
|
+
expect(vm.layout).toHaveLength(3);
|
|
740
|
+
expect(vm.layout[0].type).toBe("row");
|
|
741
|
+
expect(vm.layout[1].type).toBe("separator");
|
|
742
|
+
expect(vm.layout[2].type).toBe("row");
|
|
743
|
+
});
|
|
744
|
+
it("should support separator via modifier layout API", () => {
|
|
745
|
+
const form = new FormModel({
|
|
746
|
+
fields: fields => ({
|
|
747
|
+
title: fields.text().label("Title"),
|
|
748
|
+
description: fields.text().label("Description")
|
|
749
|
+
}),
|
|
750
|
+
layout: layout => [layout.row("title"), layout.row("description")]
|
|
751
|
+
});
|
|
752
|
+
form.layout(layout => [layout.separator().after("title")]);
|
|
753
|
+
expect(form.vm.layout).toHaveLength(3);
|
|
754
|
+
expect(form.vm.layout[1].type).toBe("separator");
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
describe("tabs", () => {
|
|
758
|
+
function createFormWithTabs() {
|
|
759
|
+
return new FormModel({
|
|
760
|
+
fields: fields => ({
|
|
761
|
+
title: fields.text().label("Title"),
|
|
762
|
+
slug: fields.text().label("Slug"),
|
|
763
|
+
description: fields.text().label("Description"),
|
|
764
|
+
metaTitle: fields.text().label("Meta Title"),
|
|
765
|
+
metaDescription: fields.text().label("Meta Description")
|
|
766
|
+
}),
|
|
767
|
+
layout: layout => [layout.row("title", "slug"), layout.tabs({
|
|
768
|
+
id: "settings",
|
|
769
|
+
tabs: [{
|
|
770
|
+
id: "general",
|
|
771
|
+
label: "General",
|
|
772
|
+
layout: [layout.row("description")]
|
|
773
|
+
}, {
|
|
774
|
+
id: "seo",
|
|
775
|
+
label: "SEO",
|
|
776
|
+
description: "Optimize how this page appears in search",
|
|
777
|
+
layout: [layout.row("metaTitle"), layout.row("metaDescription")]
|
|
778
|
+
}]
|
|
779
|
+
})]
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
it("should resolve tabs layout node with tab definitions", () => {
|
|
783
|
+
const form = createFormWithTabs();
|
|
784
|
+
const vm = form.vm;
|
|
785
|
+
expect(vm.layout).toHaveLength(2);
|
|
786
|
+
expect(vm.layout[0].type).toBe("row");
|
|
787
|
+
expect(vm.layout[1].type).toBe("tabs");
|
|
788
|
+
const tabsNode = vm.layout[1];
|
|
789
|
+
expect(tabsNode.id).toBe("settings");
|
|
790
|
+
expect(tabsNode.tabs).toHaveLength(2);
|
|
791
|
+
expect(tabsNode.tabs[0].id).toBe("general");
|
|
792
|
+
expect(tabsNode.tabs[0].label).toBe("General");
|
|
793
|
+
expect(tabsNode.tabs[1].id).toBe("seo");
|
|
794
|
+
expect(tabsNode.tabs[1].label).toBe("SEO");
|
|
795
|
+
expect(tabsNode.tabs[1].description).toBe("Optimize how this page appears in search");
|
|
796
|
+
});
|
|
797
|
+
it("should resolve fields inside tab layouts", () => {
|
|
798
|
+
const form = createFormWithTabs();
|
|
799
|
+
const tabsNode = form.vm.layout[1];
|
|
800
|
+
const generalTab = tabsNode.tabs[0];
|
|
801
|
+
expect(generalTab.layout).toHaveLength(1);
|
|
802
|
+
expect(generalTab.layout[0].type).toBe("row");
|
|
803
|
+
const generalRow = generalTab.layout[0];
|
|
804
|
+
expect(generalRow.fields[0].name).toBe("description");
|
|
805
|
+
const seoTab = tabsNode.tabs[1];
|
|
806
|
+
expect(seoTab.layout).toHaveLength(2);
|
|
807
|
+
});
|
|
808
|
+
it("should default activeTabId to the first tab", () => {
|
|
809
|
+
const form = createFormWithTabs();
|
|
810
|
+
const tabsNode = form.vm.layout[1];
|
|
811
|
+
expect(tabsNode.activeTabId).toBe("general");
|
|
812
|
+
});
|
|
813
|
+
it("should switch active tab via setActiveTab", () => {
|
|
814
|
+
const form = createFormWithTabs();
|
|
815
|
+
let tabsNode = form.vm.layout[1];
|
|
816
|
+
tabsNode.setActiveTab("seo");
|
|
817
|
+
tabsNode = form.vm.layout[1];
|
|
818
|
+
expect(tabsNode.activeTabId).toBe("seo");
|
|
819
|
+
});
|
|
820
|
+
it("should fall back to first tab when active tab ID is invalid", () => {
|
|
821
|
+
const form = createFormWithTabs();
|
|
822
|
+
let tabsNode = form.vm.layout[1];
|
|
823
|
+
tabsNode.setActiveTab("nonexistent");
|
|
824
|
+
tabsNode = form.vm.layout[1];
|
|
825
|
+
expect(tabsNode.activeTabId).toBe("general");
|
|
826
|
+
});
|
|
827
|
+
it("should compute hasErrors for tabs based on referenced fields", async () => {
|
|
828
|
+
const form = new FormModel({
|
|
829
|
+
fields: fields => ({
|
|
830
|
+
title: fields.text().label("Title").required("Title is required"),
|
|
831
|
+
metaTitle: fields.text().label("Meta Title").required("Required")
|
|
832
|
+
}),
|
|
833
|
+
layout: layout => [layout.tabs({
|
|
834
|
+
id: "settings",
|
|
835
|
+
tabs: [{
|
|
836
|
+
id: "general",
|
|
837
|
+
label: "General",
|
|
838
|
+
layout: [layout.row("title")]
|
|
839
|
+
}, {
|
|
840
|
+
id: "seo",
|
|
841
|
+
label: "SEO",
|
|
842
|
+
layout: [layout.row("metaTitle")]
|
|
843
|
+
}]
|
|
844
|
+
})]
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// Before validation, no errors
|
|
848
|
+
let tabsNode = form.vm.layout[0];
|
|
849
|
+
expect(tabsNode.tabs[0].hasErrors).toBe(false);
|
|
850
|
+
expect(tabsNode.tabs[1].hasErrors).toBe(false);
|
|
851
|
+
|
|
852
|
+
// Fill only title, leave metaTitle empty
|
|
853
|
+
form.field("title").setValue("Hello");
|
|
854
|
+
await form.validate();
|
|
855
|
+
tabsNode = form.vm.layout[0];
|
|
856
|
+
expect(tabsNode.tabs[0].hasErrors).toBe(false);
|
|
857
|
+
expect(tabsNode.tabs[1].hasErrors).toBe(true);
|
|
858
|
+
});
|
|
859
|
+
it("should not warn about fields inside tabs as orphans", () => {
|
|
860
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
861
|
+
new FormModel({
|
|
862
|
+
fields: fields => ({
|
|
863
|
+
title: fields.text().label("Title"),
|
|
864
|
+
description: fields.text().label("Description")
|
|
865
|
+
}),
|
|
866
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
867
|
+
id: "settings",
|
|
868
|
+
tabs: [{
|
|
869
|
+
id: "general",
|
|
870
|
+
label: "General",
|
|
871
|
+
layout: [layout.row("description")]
|
|
872
|
+
}]
|
|
873
|
+
})]
|
|
874
|
+
});
|
|
875
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
876
|
+
warnSpy.mockRestore();
|
|
877
|
+
});
|
|
878
|
+
it("should return null for tabs with empty tabs array", () => {
|
|
879
|
+
const form = new FormModel({
|
|
880
|
+
fields: fields => ({
|
|
881
|
+
title: fields.text().label("Title")
|
|
882
|
+
}),
|
|
883
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
884
|
+
id: "empty",
|
|
885
|
+
tabs: []
|
|
886
|
+
})]
|
|
887
|
+
});
|
|
888
|
+
expect(form.vm.layout).toHaveLength(1);
|
|
889
|
+
expect(form.vm.layout[0].type).toBe("row");
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
describe("element", () => {
|
|
893
|
+
it("should include element nodes in the resolved layout", () => {
|
|
894
|
+
const form = new FormModel({
|
|
895
|
+
fields: fields => ({
|
|
896
|
+
title: fields.text().label("Title")
|
|
897
|
+
}),
|
|
898
|
+
layout: layout => [layout.row("title"), layout.element("usage-stats", {
|
|
899
|
+
plan: "enterprise"
|
|
900
|
+
})]
|
|
901
|
+
});
|
|
902
|
+
const vm = form.vm;
|
|
903
|
+
expect(vm.layout).toHaveLength(2);
|
|
904
|
+
expect(vm.layout[1].type).toBe("element");
|
|
905
|
+
const elementNode = vm.layout[1];
|
|
906
|
+
expect(elementNode.renderer).toBe("usage-stats");
|
|
907
|
+
expect(elementNode.props).toEqual({
|
|
908
|
+
plan: "enterprise"
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
it("should support element without props", () => {
|
|
912
|
+
const form = new FormModel({
|
|
913
|
+
fields: fields => ({
|
|
914
|
+
title: fields.text().label("Title")
|
|
915
|
+
}),
|
|
916
|
+
layout: layout => [layout.row("title"), layout.element("divider")]
|
|
917
|
+
});
|
|
918
|
+
const elementNode = form.vm.layout[1];
|
|
919
|
+
expect(elementNode.renderer).toBe("divider");
|
|
920
|
+
expect(elementNode.props).toBeUndefined();
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
describe("named layout node access — form.layout(nodeId)", () => {
|
|
924
|
+
function createFormWithTabs() {
|
|
925
|
+
return new FormModel({
|
|
926
|
+
fields: fields => ({
|
|
927
|
+
title: fields.text().label("Title"),
|
|
928
|
+
description: fields.text().label("Description"),
|
|
929
|
+
metaTitle: fields.text().label("Meta Title")
|
|
930
|
+
}),
|
|
931
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
932
|
+
id: "settings",
|
|
933
|
+
tabs: [{
|
|
934
|
+
id: "general",
|
|
935
|
+
label: "General",
|
|
936
|
+
layout: [layout.row("description")]
|
|
937
|
+
}, {
|
|
938
|
+
id: "seo",
|
|
939
|
+
label: "SEO",
|
|
940
|
+
layout: [layout.row("metaTitle")]
|
|
941
|
+
}]
|
|
942
|
+
})]
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
it("should access a tabs node by ID and add a new tab", () => {
|
|
946
|
+
const form = createFormWithTabs();
|
|
947
|
+
form.fields(fields => ({
|
|
948
|
+
trackingId: fields.text().label("Tracking ID")
|
|
949
|
+
}));
|
|
950
|
+
form.layout("settings").as("tabs").tab({
|
|
951
|
+
id: "analytics",
|
|
952
|
+
label: "Analytics",
|
|
953
|
+
layout: [{
|
|
954
|
+
type: "row",
|
|
955
|
+
fieldIds: ["trackingId"]
|
|
956
|
+
}]
|
|
957
|
+
}).after("seo");
|
|
958
|
+
const tabsNode = form.vm.layout[1];
|
|
959
|
+
expect(tabsNode.tabs).toHaveLength(3);
|
|
960
|
+
expect(tabsNode.tabs[2].id).toBe("analytics");
|
|
961
|
+
expect(tabsNode.tabs[2].label).toBe("Analytics");
|
|
962
|
+
});
|
|
963
|
+
it("should add a tab before an existing tab", () => {
|
|
964
|
+
const form = createFormWithTabs();
|
|
965
|
+
form.fields(fields => ({
|
|
966
|
+
trackingId: fields.text().label("Tracking ID")
|
|
967
|
+
}));
|
|
968
|
+
form.layout("settings").as("tabs").tab({
|
|
969
|
+
id: "analytics",
|
|
970
|
+
label: "Analytics",
|
|
971
|
+
layout: [{
|
|
972
|
+
type: "row",
|
|
973
|
+
fieldIds: ["trackingId"]
|
|
974
|
+
}]
|
|
975
|
+
}).before("seo");
|
|
976
|
+
const tabsNode = form.vm.layout[1];
|
|
977
|
+
expect(tabsNode.tabs).toHaveLength(3);
|
|
978
|
+
expect(tabsNode.tabs[0].id).toBe("general");
|
|
979
|
+
expect(tabsNode.tabs[1].id).toBe("analytics");
|
|
980
|
+
expect(tabsNode.tabs[2].id).toBe("seo");
|
|
981
|
+
});
|
|
982
|
+
it("should append to an existing tab's layout", () => {
|
|
983
|
+
const form = createFormWithTabs();
|
|
984
|
+
form.fields(fields => ({
|
|
985
|
+
ogImage: fields.text().label("OG Image")
|
|
986
|
+
}));
|
|
987
|
+
form.layout("settings").as("tabs").tab("seo").layout(layout => [layout.row("ogImage")]);
|
|
988
|
+
const tabsNode = form.vm.layout[1];
|
|
989
|
+
const seoTab = tabsNode.tabs.find(t => t.id === "seo");
|
|
990
|
+
expect(seoTab.layout).toHaveLength(2);
|
|
991
|
+
const lastRow = seoTab.layout[1];
|
|
992
|
+
expect(lastRow.fields[0].name).toBe("ogImage");
|
|
993
|
+
});
|
|
994
|
+
it("should throw when accessing a non-existent node ID", () => {
|
|
995
|
+
const form = createFormWithTabs();
|
|
996
|
+
expect(() => form.layout("nonexistent").as("tabs")).toThrow('Layout node "nonexistent" not found.');
|
|
997
|
+
});
|
|
998
|
+
it("should throw when accessing a non-existent tab ID", () => {
|
|
999
|
+
const form = createFormWithTabs();
|
|
1000
|
+
expect(() => form.layout("settings").as("tabs").tab("nonexistent")).toThrow('Tab "nonexistent" not found in tabs node "settings".');
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
describe("positional modifiers targeting tabs/element nodes", () => {
|
|
1004
|
+
it("should insert a row before a tabs node by ID", () => {
|
|
1005
|
+
const form = new FormModel({
|
|
1006
|
+
fields: fields => ({
|
|
1007
|
+
title: fields.text().label("Title"),
|
|
1008
|
+
subtitle: fields.text().label("Subtitle"),
|
|
1009
|
+
description: fields.text().label("Description")
|
|
1010
|
+
}),
|
|
1011
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
1012
|
+
id: "settings",
|
|
1013
|
+
tabs: [{
|
|
1014
|
+
id: "general",
|
|
1015
|
+
label: "General",
|
|
1016
|
+
layout: [layout.row("description")]
|
|
1017
|
+
}]
|
|
1018
|
+
})]
|
|
1019
|
+
});
|
|
1020
|
+
form.layout(layout => [layout.row("subtitle").before("settings")]);
|
|
1021
|
+
expect(form.vm.layout).toHaveLength(3);
|
|
1022
|
+
expect(form.vm.layout[0].type).toBe("row");
|
|
1023
|
+
expect(form.vm.layout[1].type).toBe("row");
|
|
1024
|
+
expect(form.vm.layout[2].type).toBe("tabs");
|
|
1025
|
+
const row = form.vm.layout[1];
|
|
1026
|
+
expect(row.fields[0].name).toBe("subtitle");
|
|
1027
|
+
});
|
|
1028
|
+
it("should remove a tabs node by ID", () => {
|
|
1029
|
+
const form = new FormModel({
|
|
1030
|
+
fields: fields => ({
|
|
1031
|
+
title: fields.text().label("Title"),
|
|
1032
|
+
description: fields.text().label("Description")
|
|
1033
|
+
}),
|
|
1034
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
1035
|
+
id: "settings",
|
|
1036
|
+
tabs: [{
|
|
1037
|
+
id: "general",
|
|
1038
|
+
label: "General",
|
|
1039
|
+
layout: [layout.row("description")]
|
|
1040
|
+
}]
|
|
1041
|
+
})]
|
|
1042
|
+
});
|
|
1043
|
+
form.layout(layout => {
|
|
1044
|
+
layout.remove("settings");
|
|
1045
|
+
return [];
|
|
1046
|
+
});
|
|
1047
|
+
expect(form.vm.layout).toHaveLength(1);
|
|
1048
|
+
expect(form.vm.layout[0].type).toBe("row");
|
|
1049
|
+
});
|
|
1050
|
+
it("should replace a tabs node by ID", () => {
|
|
1051
|
+
const form = new FormModel({
|
|
1052
|
+
fields: fields => ({
|
|
1053
|
+
title: fields.text().label("Title"),
|
|
1054
|
+
description: fields.text().label("Description"),
|
|
1055
|
+
metaTitle: fields.text().label("Meta Title")
|
|
1056
|
+
}),
|
|
1057
|
+
layout: layout => [layout.row("title"), layout.tabs({
|
|
1058
|
+
id: "settings",
|
|
1059
|
+
tabs: [{
|
|
1060
|
+
id: "general",
|
|
1061
|
+
label: "General",
|
|
1062
|
+
layout: [layout.row("description")]
|
|
1063
|
+
}]
|
|
1064
|
+
})]
|
|
1065
|
+
});
|
|
1066
|
+
form.layout(layout => [layout.row("metaTitle").replace("settings")]);
|
|
1067
|
+
expect(form.vm.layout).toHaveLength(2);
|
|
1068
|
+
expect(form.vm.layout[0].type).toBe("row");
|
|
1069
|
+
expect(form.vm.layout[1].type).toBe("row");
|
|
1070
|
+
const row = form.vm.layout[1];
|
|
1071
|
+
expect(row.fields[0].name).toBe("metaTitle");
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
describe("modifier integration with tabs", () => {
|
|
1075
|
+
it("should support a full modifier workflow: base form with tabs + modifier adds tab + modifier appends to existing tab", () => {
|
|
1076
|
+
const form = new FormModel({
|
|
1077
|
+
fields: fields => ({
|
|
1078
|
+
title: fields.text().label("Title"),
|
|
1079
|
+
description: fields.text().label("Description"),
|
|
1080
|
+
metaTitle: fields.text().label("Meta Title")
|
|
1081
|
+
}),
|
|
1082
|
+
layout: layout => [layout.row("title"), layout.separator(), layout.tabs({
|
|
1083
|
+
id: "settings",
|
|
1084
|
+
tabs: [{
|
|
1085
|
+
id: "general",
|
|
1086
|
+
label: "General",
|
|
1087
|
+
layout: [layout.row("description")]
|
|
1088
|
+
}, {
|
|
1089
|
+
id: "seo",
|
|
1090
|
+
label: "SEO",
|
|
1091
|
+
layout: [layout.row("metaTitle")]
|
|
1092
|
+
}]
|
|
1093
|
+
})]
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Modifier A: add analytics tab
|
|
1097
|
+
form.fields(fields => ({
|
|
1098
|
+
trackingId: fields.text().label("Tracking ID")
|
|
1099
|
+
}));
|
|
1100
|
+
form.layout("settings").as("tabs").tab({
|
|
1101
|
+
id: "analytics",
|
|
1102
|
+
label: "Analytics",
|
|
1103
|
+
layout: [{
|
|
1104
|
+
type: "row",
|
|
1105
|
+
fieldIds: ["trackingId"]
|
|
1106
|
+
}]
|
|
1107
|
+
}).after("seo");
|
|
1108
|
+
|
|
1109
|
+
// Modifier B: append OG Image to SEO tab
|
|
1110
|
+
form.fields(fields => ({
|
|
1111
|
+
ogImage: fields.text().label("OG Image")
|
|
1112
|
+
}));
|
|
1113
|
+
form.layout("settings").as("tabs").tab("seo").layout(layout => [layout.row("ogImage")]);
|
|
1114
|
+
|
|
1115
|
+
// Verify full layout
|
|
1116
|
+
const vm = form.vm;
|
|
1117
|
+
expect(vm.layout).toHaveLength(3); // row, separator, tabs
|
|
1118
|
+
expect(vm.layout[0].type).toBe("row");
|
|
1119
|
+
expect(vm.layout[1].type).toBe("separator");
|
|
1120
|
+
expect(vm.layout[2].type).toBe("tabs");
|
|
1121
|
+
const tabsNode = vm.layout[2];
|
|
1122
|
+
expect(tabsNode.tabs).toHaveLength(3);
|
|
1123
|
+
expect(tabsNode.tabs[0].id).toBe("general");
|
|
1124
|
+
expect(tabsNode.tabs[1].id).toBe("seo");
|
|
1125
|
+
expect(tabsNode.tabs[2].id).toBe("analytics");
|
|
1126
|
+
|
|
1127
|
+
// SEO tab now has metaTitle + ogImage
|
|
1128
|
+
const seoTab = tabsNode.tabs[1];
|
|
1129
|
+
expect(seoTab.layout).toHaveLength(2);
|
|
1130
|
+
|
|
1131
|
+
// Verify all fields are in getData
|
|
1132
|
+
const data = form.getData();
|
|
1133
|
+
expect(data).toHaveProperty("trackingId");
|
|
1134
|
+
expect(data).toHaveProperty("ogImage");
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
//# sourceMappingURL=FormModel.test.js.map
|