@webiny/app-admin 6.3.0-beta.2 → 6.3.0-beta.4
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/Base/DefaultFieldRenderers.js +69 -5
- package/base/Base/DefaultFieldRenderers.js.map +1 -1
- package/base/Base/DefaultLayoutRenderers.js +5 -1
- package/base/Base/DefaultLayoutRenderers.js.map +1 -1
- package/base/Base/FieldRenderers/CheckboxesRenderer.d.ts +13 -0
- package/base/Base/FieldRenderers/CheckboxesRenderer.js +28 -0
- package/base/Base/FieldRenderers/CheckboxesRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/CodeEditorRenderer.d.ts +15 -0
- package/base/Base/FieldRenderers/CodeEditorRenderer.js +17 -0
- package/base/Base/FieldRenderers/CodeEditorRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/DateTimeInputsRenderer.d.ts +17 -0
- package/base/Base/FieldRenderers/DateTimeInputsRenderer.js +66 -0
- package/base/Base/FieldRenderers/DateTimeInputsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/DateTimeRenderer.d.ts +21 -0
- package/base/Base/FieldRenderers/DateTimeRenderer.js +46 -0
- package/base/Base/FieldRenderers/DateTimeRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/FilePickerRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/FilePickerRenderer.js +47 -0
- package/base/Base/FieldRenderers/FilePickerRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/FileUrlPickerRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/FileUrlPickerRenderer.js +25 -0
- package/base/Base/FieldRenderers/FileUrlPickerRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/HiddenRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/HiddenRenderer.js +5 -0
- package/base/Base/FieldRenderers/HiddenRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/HorizontalTabsRenderer.d.ts +5 -0
- package/base/Base/FieldRenderers/HorizontalTabsRenderer.js +27 -0
- package/base/Base/FieldRenderers/HorizontalTabsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/InputRenderer.d.ts +4 -7
- package/base/Base/FieldRenderers/InputRenderer.js +2 -2
- package/base/Base/FieldRenderers/InputRenderer.js.map +1 -1
- package/base/Base/FieldRenderers/NumberInputRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/NumberInputRenderer.js +23 -0
- package/base/Base/FieldRenderers/NumberInputRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/NumberInputsRenderer.d.ts +14 -0
- package/base/Base/FieldRenderers/NumberInputsRenderer.js +49 -0
- package/base/Base/FieldRenderers/NumberInputsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.d.ts +14 -0
- package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.js +20 -0
- package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.d.ts +14 -0
- package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.js +65 -0
- package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.d.ts +10 -0
- package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.js +109 -0
- package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.d.ts +17 -0
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.js +55 -0
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.d.ts +7 -3
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.js +15 -19
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.js.map +1 -1
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.d.ts +5 -8
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.js +7 -50
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.js.map +1 -1
- package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.d.ts +10 -0
- package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.js +64 -0
- package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.js.map +1 -0
- package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.d.ts +10 -0
- package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.js +85 -0
- package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.js.map +1 -0
- package/base/Base/FieldRenderers/PassthroughRenderer.d.ts +3 -6
- package/base/Base/FieldRenderers/PassthroughRenderer.js +9 -23
- package/base/Base/FieldRenderers/PassthroughRenderer.js.map +1 -1
- package/base/Base/FieldRenderers/RadioButtonsRenderer.d.ts +13 -0
- package/base/Base/FieldRenderers/RadioButtonsRenderer.js +27 -0
- package/base/Base/FieldRenderers/RadioButtonsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/SelectRenderer.d.ts +6 -8
- package/base/Base/FieldRenderers/SelectRenderer.js +8 -5
- package/base/Base/FieldRenderers/SelectRenderer.js.map +1 -1
- package/base/Base/FieldRenderers/SwitchRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/SwitchRenderer.js +19 -0
- package/base/Base/FieldRenderers/SwitchRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/TagsRenderer.d.ts +12 -0
- package/base/Base/FieldRenderers/TagsRenderer.js +21 -0
- package/base/Base/FieldRenderers/TagsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/TextInputsRenderer.d.ts +14 -0
- package/base/Base/FieldRenderers/TextInputsRenderer.js +48 -0
- package/base/Base/FieldRenderers/TextInputsRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/TextareaRenderer.d.ts +3 -6
- package/base/Base/FieldRenderers/TextareaRenderer.js +3 -4
- package/base/Base/FieldRenderers/TextareaRenderer.js.map +1 -1
- package/base/Base/FieldRenderers/TextareasRenderer.d.ts +14 -0
- package/base/Base/FieldRenderers/TextareasRenderer.js +51 -0
- package/base/Base/FieldRenderers/TextareasRenderer.js.map +1 -0
- package/base/Base/FieldRenderers/VerticalTabsRenderer.js +2 -2
- package/base/Base/FieldRenderers/VerticalTabsRenderer.js.map +1 -1
- package/base/Base/Menus.js +5 -64
- package/base/Base/Menus.js.map +1 -1
- package/base/Base/RoutesConfig.js +6 -0
- package/base/Base/RoutesConfig.js.map +1 -1
- package/exports/admin/build-params.d.ts +2 -0
- package/exports/admin/build-params.js +3 -0
- package/exports/admin/build-params.js.map +1 -1
- package/exports/admin/form.d.ts +5 -0
- package/exports/admin/form.js +8 -0
- package/exports/admin/form.js.map +1 -1
- package/exports/admin/ui.d.ts +1 -0
- package/exports/admin/ui.js +1 -0
- package/exports/admin/ui.js.map +1 -1
- package/exports/admin.d.ts +3 -1
- package/exports/admin.js +3 -1
- package/exports/admin.js.map +1 -1
- package/features/formModel/ConditionRuleEvaluator.d.ts +9 -0
- package/features/formModel/ConditionRuleEvaluator.js +56 -0
- package/features/formModel/ConditionRuleEvaluator.js.map +1 -0
- package/features/formModel/Field.d.ts +50 -4
- package/features/formModel/Field.js +254 -35
- package/features/formModel/Field.js.map +1 -1
- package/features/formModel/FieldBuilder.d.ts +17 -35
- package/features/formModel/FieldBuilder.js +63 -100
- package/features/formModel/FieldBuilder.js.map +1 -1
- package/features/formModel/FieldBuilder.test.js +127 -13
- package/features/formModel/FieldBuilder.test.js.map +1 -1
- package/features/formModel/FieldBuilderRegistry.d.ts +4 -0
- package/features/formModel/FieldBuilderRegistry.js +31 -0
- package/features/formModel/FieldBuilderRegistry.js.map +1 -0
- package/features/formModel/FocusManager.d.ts +14 -0
- package/features/formModel/FocusManager.js +109 -0
- package/features/formModel/FocusManager.js.map +1 -0
- package/features/formModel/FormModel.d.ts +27 -31
- package/features/formModel/FormModel.js +210 -403
- package/features/formModel/FormModel.js.map +1 -1
- package/features/formModel/FormModel.test.js +2044 -193
- package/features/formModel/FormModel.test.js.map +1 -1
- package/features/formModel/FormModelFactory.d.ts +4 -2
- package/features/formModel/FormModelFactory.js +13 -3
- package/features/formModel/FormModelFactory.js.map +1 -1
- package/features/formModel/FormView.d.ts +2 -0
- package/features/formModel/FormView.js +44 -37
- package/features/formModel/FormView.js.map +1 -1
- package/features/formModel/LayoutBuilderFactory.d.ts +61 -0
- package/features/formModel/LayoutBuilderFactory.js +386 -0
- package/features/formModel/LayoutBuilderFactory.js.map +1 -0
- package/features/formModel/LayoutMutator.d.ts +11 -0
- package/features/formModel/LayoutMutator.js +136 -0
- package/features/formModel/LayoutMutator.js.map +1 -0
- package/features/formModel/LayoutResolver.d.ts +26 -0
- package/features/formModel/LayoutResolver.js +239 -0
- package/features/formModel/LayoutResolver.js.map +1 -0
- package/features/formModel/ObjectField.d.ts +55 -4
- package/features/formModel/ObjectField.js +499 -82
- package/features/formModel/ObjectField.js.map +1 -1
- package/features/formModel/Rules.test.d.ts +1 -0
- package/features/formModel/Rules.test.js +289 -0
- package/features/formModel/Rules.test.js.map +1 -0
- package/features/formModel/abstractions.d.ts +402 -52
- package/features/formModel/abstractions.js +55 -0
- package/features/formModel/abstractions.js.map +1 -1
- package/features/formModel/createFieldRenderer.d.ts +20 -0
- package/features/formModel/createFieldRenderer.js +15 -0
- package/features/formModel/createFieldRenderer.js.map +1 -0
- package/features/formModel/demo/FieldRenderersDemoPresenter.d.ts +18 -0
- package/features/formModel/demo/FieldRenderersDemoPresenter.js +225 -0
- package/features/formModel/demo/FieldRenderersDemoPresenter.js.map +1 -0
- package/features/formModel/demo/FormModelDemo.d.ts +4 -0
- package/features/formModel/demo/FormModelDemo.js +230 -0
- package/features/formModel/demo/FormModelDemo.js.map +1 -0
- package/features/formModel/demo/FormModelDemoPresenter.d.ts +22 -0
- package/features/formModel/demo/FormModelDemoPresenter.js +121 -0
- package/features/formModel/demo/FormModelDemoPresenter.js.map +1 -0
- package/features/formModel/demo/FormModelPhase11Presenter.d.ts +25 -0
- package/features/formModel/demo/FormModelPhase11Presenter.js +104 -0
- package/features/formModel/demo/FormModelPhase11Presenter.js.map +1 -0
- package/features/formModel/demo/FormModelPhase8c1Presenter.d.ts +23 -0
- package/features/formModel/demo/FormModelPhase8c1Presenter.js +62 -0
- package/features/formModel/demo/FormModelPhase8c1Presenter.js.map +1 -0
- package/features/formModel/feature.js +12 -0
- package/features/formModel/feature.js.map +1 -1
- package/features/formModel/fieldTypes/BooleanFieldType.d.ts +19 -0
- package/features/formModel/fieldTypes/BooleanFieldType.js +23 -0
- package/features/formModel/fieldTypes/BooleanFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/DateTimeFieldType.d.ts +173 -0
- package/features/formModel/fieldTypes/DateTimeFieldType.js +369 -0
- package/features/formModel/fieldTypes/DateTimeFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/FileFieldType.d.ts +18 -0
- package/features/formModel/fieldTypes/FileFieldType.js +20 -0
- package/features/formModel/fieldTypes/FileFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/FileUrlFieldType.d.ts +18 -0
- package/features/formModel/fieldTypes/FileUrlFieldType.js +20 -0
- package/features/formModel/fieldTypes/FileUrlFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/NumberFieldType.d.ts +19 -0
- package/features/formModel/fieldTypes/NumberFieldType.js +27 -0
- package/features/formModel/fieldTypes/NumberFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/ObjectFieldType.d.ts +34 -0
- package/features/formModel/fieldTypes/ObjectFieldType.js +109 -0
- package/features/formModel/fieldTypes/ObjectFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/TextFieldType.d.ts +18 -0
- package/features/formModel/fieldTypes/TextFieldType.js +20 -0
- package/features/formModel/fieldTypes/TextFieldType.js.map +1 -0
- package/features/formModel/fieldTypes/index.d.ts +7 -0
- package/features/formModel/fieldTypes/index.js +9 -0
- package/features/formModel/fieldTypes/index.js.map +1 -0
- package/features/formModel/index.d.ts +13 -4
- package/features/formModel/index.js +21 -2
- package/features/formModel/index.js.map +1 -1
- package/features/formModel/renderers.d.ts +15 -1
- package/features/formModel/renderers.js +15 -1
- package/features/formModel/renderers.js.map +1 -1
- package/features/tools/LexicalContext/LexicalContext.d.ts +14 -0
- package/features/tools/LexicalContext/LexicalContext.js +22 -0
- package/features/tools/LexicalContext/LexicalContext.js.map +1 -0
- package/features/tools/LexicalContext/abstractions.d.ts +11 -0
- package/features/tools/LexicalContext/abstractions.js +4 -0
- package/features/tools/LexicalContext/abstractions.js.map +1 -0
- package/features/tools/LexicalContext/index.d.ts +2 -0
- package/features/tools/LexicalContext/index.js +3 -0
- package/features/tools/LexicalContext/index.js.map +1 -0
- package/features/tools/feature.js +2 -0
- package/features/tools/feature.js.map +1 -1
- package/features/tools/index.d.ts +1 -0
- package/features/tools/index.js +1 -0
- package/features/tools/index.js.map +1 -1
- package/index.d.ts +8 -1
- package/index.js +7 -0
- package/index.js.map +1 -1
- package/package.json +30 -24
- package/presentation/installation/components/SystemInstaller/steps/AdminUserStep/createPasswordValidator.js +1 -1
- package/presentation/installation/components/SystemInstaller/steps/AdminUserStep/createPasswordValidator.js.map +1 -1
- package/presentation/lexicalContext/useLexicalContext.d.ts +3 -0
- package/presentation/lexicalContext/useLexicalContext.js +14 -0
- package/presentation/lexicalContext/useLexicalContext.js.map +1 -0
- package/presentation/textToLexicalTool/TextToLexicalTool.d.ts +3 -0
- package/presentation/textToLexicalTool/TextToLexicalTool.js +6 -2
- package/presentation/textToLexicalTool/TextToLexicalTool.js.map +1 -1
- package/presentation/textToLexicalTool/textToLexicalState.d.ts +2 -1
- package/presentation/textToLexicalTool/textToLexicalState.js +15 -3
- package/presentation/textToLexicalTool/textToLexicalState.js.map +1 -1
- package/routes.d.ts +1 -0
- package/routes.js +4 -0
- package/routes.js.map +1 -1
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectListFlatRenderer.d.ts +0 -21
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectListFlatRenderer.js +0 -28
- package/base/Base/FieldRenderers/ObjectRenderer/ObjectListFlatRenderer.js.map +0 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { Container } from "@webiny/di";
|
|
4
|
+
import { FormModelFeature } from "./feature.js";
|
|
5
|
+
import { FormModelFactory } from "./abstractions.js";
|
|
6
|
+
function createForm(config) {
|
|
7
|
+
const container = new Container();
|
|
8
|
+
FormModelFeature.register(container);
|
|
9
|
+
return container.resolve(FormModelFactory).create(config);
|
|
10
|
+
}
|
|
4
11
|
function asRow(node) {
|
|
5
12
|
if (node.type !== "row") {
|
|
6
13
|
throw new Error(`Expected row node, got "${node.type}"`);
|
|
@@ -8,7 +15,7 @@ function asRow(node) {
|
|
|
8
15
|
return node;
|
|
9
16
|
}
|
|
10
17
|
function createBasicForm() {
|
|
11
|
-
return
|
|
18
|
+
return createForm({
|
|
12
19
|
fields: fields => ({
|
|
13
20
|
title: fields.text().label("Title").required("Title is required"),
|
|
14
21
|
path: fields.text().label("Path").required("Path is required")
|
|
@@ -38,7 +45,7 @@ describe("FormModel", () => {
|
|
|
38
45
|
expect(form.field("title").getValue()).toBeNull();
|
|
39
46
|
});
|
|
40
47
|
it("should use defaultValue when provided", () => {
|
|
41
|
-
const form =
|
|
48
|
+
const form = createForm({
|
|
42
49
|
fields: fields => ({
|
|
43
50
|
status: fields.text().defaultValue("draft")
|
|
44
51
|
})
|
|
@@ -48,7 +55,7 @@ describe("FormModel", () => {
|
|
|
48
55
|
});
|
|
49
56
|
describe("getData / setData", () => {
|
|
50
57
|
it("should return all field values including hidden", () => {
|
|
51
|
-
const form =
|
|
58
|
+
const form = createForm({
|
|
52
59
|
fields: fields => ({
|
|
53
60
|
title: fields.text().label("Title"),
|
|
54
61
|
pageType: fields.text().hidden().defaultValue("static")
|
|
@@ -152,7 +159,7 @@ describe("FormModel", () => {
|
|
|
152
159
|
expect(form.errors).toHaveLength(0);
|
|
153
160
|
});
|
|
154
161
|
it("should validate zod schemas", async () => {
|
|
155
|
-
const form =
|
|
162
|
+
const form = createForm({
|
|
156
163
|
fields: fields => ({
|
|
157
164
|
email: fields.text().label("Email").schema(z.string().email("Invalid email"))
|
|
158
165
|
})
|
|
@@ -163,7 +170,7 @@ describe("FormModel", () => {
|
|
|
163
170
|
expect(form.errors[0].message).toBe("Invalid email");
|
|
164
171
|
});
|
|
165
172
|
it("should run required check before zod schema", async () => {
|
|
166
|
-
const form =
|
|
173
|
+
const form = createForm({
|
|
167
174
|
fields: fields => ({
|
|
168
175
|
email: fields.text().label("Email").required("Email is required").schema(z.string().email("Invalid email"))
|
|
169
176
|
})
|
|
@@ -211,7 +218,7 @@ describe("FormModel", () => {
|
|
|
211
218
|
expect(asRow(vm.layout[1]).fields[0].name).toBe("path");
|
|
212
219
|
});
|
|
213
220
|
it("should exclude hidden fields from layout", () => {
|
|
214
|
-
const form =
|
|
221
|
+
const form = createForm({
|
|
215
222
|
fields: fields => ({
|
|
216
223
|
title: fields.text().label("Title"),
|
|
217
224
|
pageType: fields.text().hidden().defaultValue("static")
|
|
@@ -239,7 +246,7 @@ describe("FormModel", () => {
|
|
|
239
246
|
expect(form.vm.layout).toHaveLength(2);
|
|
240
247
|
});
|
|
241
248
|
it("should use explicit layout when provided", () => {
|
|
242
|
-
const form =
|
|
249
|
+
const form = createForm({
|
|
243
250
|
fields: fields => ({
|
|
244
251
|
title: fields.text().label("Title"),
|
|
245
252
|
path: fields.text().label("Path")
|
|
@@ -251,7 +258,7 @@ describe("FormModel", () => {
|
|
|
251
258
|
});
|
|
252
259
|
it("should warn about orphan fields in explicit layout", () => {
|
|
253
260
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
254
|
-
|
|
261
|
+
createForm({
|
|
255
262
|
fields: fields => ({
|
|
256
263
|
title: fields.text().label("Title"),
|
|
257
264
|
path: fields.text().label("Path")
|
|
@@ -263,7 +270,7 @@ describe("FormModel", () => {
|
|
|
263
270
|
});
|
|
264
271
|
it("should not warn about hidden orphan fields", () => {
|
|
265
272
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
266
|
-
|
|
273
|
+
createForm({
|
|
267
274
|
fields: fields => ({
|
|
268
275
|
title: fields.text().label("Title"),
|
|
269
276
|
pageType: fields.text().hidden().defaultValue("static")
|
|
@@ -276,7 +283,7 @@ describe("FormModel", () => {
|
|
|
276
283
|
});
|
|
277
284
|
describe("beforeChange / afterChange", () => {
|
|
278
285
|
it("should run beforeChange pipeline in order, transforming value", () => {
|
|
279
|
-
const form =
|
|
286
|
+
const form = createForm({
|
|
280
287
|
fields: fields => ({
|
|
281
288
|
path: fields.text().label("Path").beforeChange(value => String(value).trim()).beforeChange(value => String(value).toLowerCase())
|
|
282
289
|
})
|
|
@@ -286,7 +293,7 @@ describe("FormModel", () => {
|
|
|
286
293
|
});
|
|
287
294
|
it("should run afterChange after value is stored", () => {
|
|
288
295
|
const received = [];
|
|
289
|
-
const form =
|
|
296
|
+
const form = createForm({
|
|
290
297
|
fields: fields => ({
|
|
291
298
|
title: fields.text().label("Title").afterChange(value => {
|
|
292
299
|
received.push(value);
|
|
@@ -298,7 +305,7 @@ describe("FormModel", () => {
|
|
|
298
305
|
});
|
|
299
306
|
it("should pass transformed value to afterChange", () => {
|
|
300
307
|
const received = [];
|
|
301
|
-
const form =
|
|
308
|
+
const form = createForm({
|
|
302
309
|
fields: fields => ({
|
|
303
310
|
path: fields.text().label("Path").beforeChange(value => String(value).toLowerCase()).afterChange(value => {
|
|
304
311
|
received.push(value);
|
|
@@ -311,7 +318,7 @@ describe("FormModel", () => {
|
|
|
311
318
|
});
|
|
312
319
|
it("should not fire afterChange when value does not change (recursion guard)", () => {
|
|
313
320
|
const calls = [];
|
|
314
|
-
const form =
|
|
321
|
+
const form = createForm({
|
|
315
322
|
fields: fields => ({
|
|
316
323
|
title: fields.text().label("Title").beforeChange(() => "constant").afterChange(() => {
|
|
317
324
|
calls.push("afterChange");
|
|
@@ -329,7 +336,7 @@ describe("FormModel", () => {
|
|
|
329
336
|
});
|
|
330
337
|
it("should not trigger beforeChange or afterChange on setData", () => {
|
|
331
338
|
const calls = [];
|
|
332
|
-
const form =
|
|
339
|
+
const form = createForm({
|
|
333
340
|
fields: fields => ({
|
|
334
341
|
title: fields.text().label("Title").beforeChange(value => {
|
|
335
342
|
calls.push("before");
|
|
@@ -346,7 +353,7 @@ describe("FormModel", () => {
|
|
|
346
353
|
expect(form.field("title").getValue()).toBe("Loaded");
|
|
347
354
|
});
|
|
348
355
|
it("should support cross-field afterChange triggering target field pipeline", () => {
|
|
349
|
-
const form =
|
|
356
|
+
const form = createForm({
|
|
350
357
|
fields: fields => ({
|
|
351
358
|
title: fields.text().label("Title").afterChange((value, f) => {
|
|
352
359
|
// Auto-generate path from title
|
|
@@ -364,7 +371,7 @@ describe("FormModel", () => {
|
|
|
364
371
|
expect(form.field("path").getValue()).toBe("/hello-world");
|
|
365
372
|
});
|
|
366
373
|
it("should allow appending callbacks to existing fields at runtime", () => {
|
|
367
|
-
const form =
|
|
374
|
+
const form = createForm({
|
|
368
375
|
fields: fields => ({
|
|
369
376
|
title: fields.text().label("Title")
|
|
370
377
|
})
|
|
@@ -375,7 +382,7 @@ describe("FormModel", () => {
|
|
|
375
382
|
expect(field.getValue()).toBe("HELLO");
|
|
376
383
|
});
|
|
377
384
|
it("should chain builder callbacks with runtime-appended callbacks", () => {
|
|
378
|
-
const form =
|
|
385
|
+
const form = createForm({
|
|
379
386
|
fields: fields => ({
|
|
380
387
|
path: fields.text().label("Path").beforeChange(value => String(value).trim())
|
|
381
388
|
})
|
|
@@ -387,7 +394,7 @@ describe("FormModel", () => {
|
|
|
387
394
|
expect(form.field("path").getValue()).toBe("hello");
|
|
388
395
|
});
|
|
389
396
|
it("should demonstrate title→path with path-dirty tracking", () => {
|
|
390
|
-
const form =
|
|
397
|
+
const form = createForm({
|
|
391
398
|
fields: fields => ({
|
|
392
399
|
title: fields.text().label("Title").required("Title is required").afterChange((value, f) => {
|
|
393
400
|
// Only auto-generate if path is empty
|
|
@@ -417,11 +424,11 @@ describe("FormModel", () => {
|
|
|
417
424
|
expect(form.field("path").getValue()).toBe("/custom-path");
|
|
418
425
|
});
|
|
419
426
|
});
|
|
420
|
-
describe("
|
|
427
|
+
describe("field with options", () => {
|
|
421
428
|
it("should resolve static options in field VM", () => {
|
|
422
|
-
const form =
|
|
429
|
+
const form = createForm({
|
|
423
430
|
fields: fields => ({
|
|
424
|
-
lang: fields.
|
|
431
|
+
lang: fields.text().label("Language").options([{
|
|
425
432
|
label: "English",
|
|
426
433
|
value: "en"
|
|
427
434
|
}, {
|
|
@@ -440,9 +447,9 @@ describe("FormModel", () => {
|
|
|
440
447
|
}]);
|
|
441
448
|
});
|
|
442
449
|
it("should resolve reactive options function in field VM", () => {
|
|
443
|
-
const form =
|
|
450
|
+
const form = createForm({
|
|
444
451
|
fields: fields => ({
|
|
445
|
-
lang: fields.
|
|
452
|
+
lang: fields.text().label("Language").options(() => {
|
|
446
453
|
// Dynamic options based on form state
|
|
447
454
|
return [{
|
|
448
455
|
label: "Dynamic",
|
|
@@ -463,7 +470,7 @@ describe("FormModel", () => {
|
|
|
463
470
|
it("should add a new field via form.fields()", () => {
|
|
464
471
|
const form = createBasicForm();
|
|
465
472
|
form.fields(fields => ({
|
|
466
|
-
language: fields.
|
|
473
|
+
language: fields.text().label("Language").options([{
|
|
467
474
|
label: "English",
|
|
468
475
|
value: "en"
|
|
469
476
|
}, {
|
|
@@ -472,11 +479,11 @@ describe("FormModel", () => {
|
|
|
472
479
|
}])
|
|
473
480
|
}));
|
|
474
481
|
expect(form.field("language")).toBeDefined();
|
|
475
|
-
expect(form.field("language").type).toBe("
|
|
482
|
+
expect(form.field("language").type).toBe("text");
|
|
476
483
|
expect(form.getData()).toHaveProperty("language");
|
|
477
484
|
});
|
|
478
485
|
it("should add a field that appears in getData but not layout until positioned", () => {
|
|
479
|
-
const form =
|
|
486
|
+
const form = createForm({
|
|
480
487
|
fields: fields => ({
|
|
481
488
|
title: fields.text().label("Title")
|
|
482
489
|
})
|
|
@@ -555,25 +562,25 @@ describe("FormModel", () => {
|
|
|
555
562
|
});
|
|
556
563
|
describe("form.field().as() — type narrowing", () => {
|
|
557
564
|
it("should return the field when type matches", () => {
|
|
558
|
-
const form =
|
|
565
|
+
const form = createForm({
|
|
559
566
|
fields: fields => ({
|
|
560
|
-
lang: fields.
|
|
567
|
+
lang: fields.text().label("Language").options([{
|
|
561
568
|
label: "English",
|
|
562
569
|
value: "en"
|
|
563
570
|
}])
|
|
564
571
|
})
|
|
565
572
|
});
|
|
566
|
-
const
|
|
567
|
-
expect(
|
|
573
|
+
const textField = form.field("lang").as("text");
|
|
574
|
+
expect(textField).toBe(form.field("lang"));
|
|
568
575
|
});
|
|
569
576
|
it("should throw when type does not match", () => {
|
|
570
577
|
const form = createBasicForm();
|
|
571
|
-
expect(() => form.field("title").as("
|
|
578
|
+
expect(() => form.field("title").as("boolean")).toThrow('Field "title" is type "text", not "boolean".');
|
|
572
579
|
});
|
|
573
580
|
});
|
|
574
581
|
describe("modifier appends callbacks to existing fields", () => {
|
|
575
582
|
it("should append beforeChange to an existing field", () => {
|
|
576
|
-
const form =
|
|
583
|
+
const form = createForm({
|
|
577
584
|
fields: fields => ({
|
|
578
585
|
path: fields.text().label("Path").beforeChange(value => String(value).trim())
|
|
579
586
|
})
|
|
@@ -586,7 +593,7 @@ describe("FormModel", () => {
|
|
|
586
593
|
});
|
|
587
594
|
it("should append afterChange to an existing field", () => {
|
|
588
595
|
const received = [];
|
|
589
|
-
const form =
|
|
596
|
+
const form = createForm({
|
|
590
597
|
fields: fields => ({
|
|
591
598
|
title: fields.text().label("Title"),
|
|
592
599
|
path: fields.text().label("Path")
|
|
@@ -605,7 +612,7 @@ describe("FormModel", () => {
|
|
|
605
612
|
});
|
|
606
613
|
describe("layout positional modifiers", () => {
|
|
607
614
|
function createFormWithLayout() {
|
|
608
|
-
return
|
|
615
|
+
return createForm({
|
|
609
616
|
fields: fields => ({
|
|
610
617
|
title: fields.text().label("Title"),
|
|
611
618
|
path: fields.text().label("Path"),
|
|
@@ -617,7 +624,7 @@ describe("FormModel", () => {
|
|
|
617
624
|
it("should insert a row before a target", () => {
|
|
618
625
|
const form = createFormWithLayout();
|
|
619
626
|
form.fields(fields => ({
|
|
620
|
-
language: fields.
|
|
627
|
+
language: fields.text().label("Language").options([])
|
|
621
628
|
}));
|
|
622
629
|
form.layout(layout => [layout.row("language").before("path")]);
|
|
623
630
|
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
@@ -626,7 +633,7 @@ describe("FormModel", () => {
|
|
|
626
633
|
it("should insert a row after a target", () => {
|
|
627
634
|
const form = createFormWithLayout();
|
|
628
635
|
form.fields(fields => ({
|
|
629
|
-
language: fields.
|
|
636
|
+
language: fields.text().label("Language").options([])
|
|
630
637
|
}));
|
|
631
638
|
form.layout(layout => [layout.row("language").after("path")]);
|
|
632
639
|
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
@@ -653,7 +660,7 @@ describe("FormModel", () => {
|
|
|
653
660
|
it("should append when no position is specified", () => {
|
|
654
661
|
const form = createFormWithLayout();
|
|
655
662
|
form.fields(fields => ({
|
|
656
|
-
language: fields.
|
|
663
|
+
language: fields.text().label("Language").options([])
|
|
657
664
|
}));
|
|
658
665
|
form.layout(layout => [layout.row("language")]);
|
|
659
666
|
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
@@ -662,7 +669,7 @@ describe("FormModel", () => {
|
|
|
662
669
|
it("should append when target is not found", () => {
|
|
663
670
|
const form = createFormWithLayout();
|
|
664
671
|
form.fields(fields => ({
|
|
665
|
-
language: fields.
|
|
672
|
+
language: fields.text().label("Language").options([])
|
|
666
673
|
}));
|
|
667
674
|
form.layout(layout => [layout.row("language").after("nonexistent")]);
|
|
668
675
|
const names = form.vm.layout.map(row => asRow(row).fields[0].name);
|
|
@@ -671,7 +678,7 @@ describe("FormModel", () => {
|
|
|
671
678
|
});
|
|
672
679
|
describe("IFormModifier integration", () => {
|
|
673
680
|
it("should support a full modifier workflow: add field + position in layout + append callbacks", () => {
|
|
674
|
-
const form =
|
|
681
|
+
const form = createForm({
|
|
675
682
|
fields: fields => ({
|
|
676
683
|
title: fields.text().label("Title").required("Title is required"),
|
|
677
684
|
path: fields.text().label("Path").required("Path is required").beforeChange(value => {
|
|
@@ -687,7 +694,7 @@ describe("FormModel", () => {
|
|
|
687
694
|
modify(form) {
|
|
688
695
|
// Add language field
|
|
689
696
|
form.fields(fields => ({
|
|
690
|
-
language: fields.
|
|
697
|
+
language: fields.text().label("Language").options([{
|
|
691
698
|
label: "English",
|
|
692
699
|
value: "en"
|
|
693
700
|
}, {
|
|
@@ -728,7 +735,7 @@ describe("FormModel", () => {
|
|
|
728
735
|
describe("layout system expansion (Phase 5)", () => {
|
|
729
736
|
describe("separator", () => {
|
|
730
737
|
it("should include separator nodes in the resolved layout", () => {
|
|
731
|
-
const form =
|
|
738
|
+
const form = createForm({
|
|
732
739
|
fields: fields => ({
|
|
733
740
|
title: fields.text().label("Title"),
|
|
734
741
|
description: fields.text().label("Description")
|
|
@@ -742,7 +749,7 @@ describe("FormModel", () => {
|
|
|
742
749
|
expect(vm.layout[2].type).toBe("row");
|
|
743
750
|
});
|
|
744
751
|
it("should support separator via modifier layout API", () => {
|
|
745
|
-
const form =
|
|
752
|
+
const form = createForm({
|
|
746
753
|
fields: fields => ({
|
|
747
754
|
title: fields.text().label("Title"),
|
|
748
755
|
description: fields.text().label("Description")
|
|
@@ -756,7 +763,7 @@ describe("FormModel", () => {
|
|
|
756
763
|
});
|
|
757
764
|
describe("tabs", () => {
|
|
758
765
|
function createFormWithTabs() {
|
|
759
|
-
return
|
|
766
|
+
return createForm({
|
|
760
767
|
fields: fields => ({
|
|
761
768
|
title: fields.text().label("Title"),
|
|
762
769
|
slug: fields.text().label("Slug"),
|
|
@@ -764,18 +771,10 @@ describe("FormModel", () => {
|
|
|
764
771
|
metaTitle: fields.text().label("Meta Title"),
|
|
765
772
|
metaDescription: fields.text().label("Meta Description")
|
|
766
773
|
}),
|
|
767
|
-
layout: layout => [layout.row("title", "slug"), layout.tabs({
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
}]
|
|
774
|
+
layout: layout => [layout.row("title", "slug"), layout.tabs("settings").tab("general", tab => {
|
|
775
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
776
|
+
}).tab("seo", tab => {
|
|
777
|
+
tab.label("SEO").description("Optimize how this page appears in search").layout(layout => [layout.row("metaTitle"), layout.row("metaDescription")]);
|
|
779
778
|
})]
|
|
780
779
|
});
|
|
781
780
|
}
|
|
@@ -825,22 +824,15 @@ describe("FormModel", () => {
|
|
|
825
824
|
expect(tabsNode.activeTabId).toBe("general");
|
|
826
825
|
});
|
|
827
826
|
it("should compute hasErrors for tabs based on referenced fields", async () => {
|
|
828
|
-
const form =
|
|
827
|
+
const form = createForm({
|
|
829
828
|
fields: fields => ({
|
|
830
829
|
title: fields.text().label("Title").required("Title is required"),
|
|
831
830
|
metaTitle: fields.text().label("Meta Title").required("Required")
|
|
832
831
|
}),
|
|
833
|
-
layout: layout => [layout.tabs({
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
label: "General",
|
|
838
|
-
layout: [layout.row("title")]
|
|
839
|
-
}, {
|
|
840
|
-
id: "seo",
|
|
841
|
-
label: "SEO",
|
|
842
|
-
layout: [layout.row("metaTitle")]
|
|
843
|
-
}]
|
|
832
|
+
layout: layout => [layout.tabs("settings").tab("general", tab => {
|
|
833
|
+
tab.label("General").layout(layout => [layout.row("title")]);
|
|
834
|
+
}).tab("seo", tab => {
|
|
835
|
+
tab.label("SEO").layout(layout => [layout.row("metaTitle")]);
|
|
844
836
|
})]
|
|
845
837
|
});
|
|
846
838
|
|
|
@@ -858,32 +850,24 @@ describe("FormModel", () => {
|
|
|
858
850
|
});
|
|
859
851
|
it("should not warn about fields inside tabs as orphans", () => {
|
|
860
852
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
861
|
-
|
|
853
|
+
createForm({
|
|
862
854
|
fields: fields => ({
|
|
863
855
|
title: fields.text().label("Title"),
|
|
864
856
|
description: fields.text().label("Description")
|
|
865
857
|
}),
|
|
866
|
-
layout: layout => [layout.row("title"), layout.tabs({
|
|
867
|
-
|
|
868
|
-
tabs: [{
|
|
869
|
-
id: "general",
|
|
870
|
-
label: "General",
|
|
871
|
-
layout: [layout.row("description")]
|
|
872
|
-
}]
|
|
858
|
+
layout: layout => [layout.row("title"), layout.tabs("settings").tab("general", tab => {
|
|
859
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
873
860
|
})]
|
|
874
861
|
});
|
|
875
862
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
876
863
|
warnSpy.mockRestore();
|
|
877
864
|
});
|
|
878
865
|
it("should return null for tabs with empty tabs array", () => {
|
|
879
|
-
const form =
|
|
866
|
+
const form = createForm({
|
|
880
867
|
fields: fields => ({
|
|
881
868
|
title: fields.text().label("Title")
|
|
882
869
|
}),
|
|
883
|
-
layout: layout => [layout.row("title"), layout.tabs(
|
|
884
|
-
id: "empty",
|
|
885
|
-
tabs: []
|
|
886
|
-
})]
|
|
870
|
+
layout: layout => [layout.row("title"), layout.tabs("empty")]
|
|
887
871
|
});
|
|
888
872
|
expect(form.vm.layout).toHaveLength(1);
|
|
889
873
|
expect(form.vm.layout[0].type).toBe("row");
|
|
@@ -891,7 +875,7 @@ describe("FormModel", () => {
|
|
|
891
875
|
});
|
|
892
876
|
describe("element", () => {
|
|
893
877
|
it("should include element nodes in the resolved layout", () => {
|
|
894
|
-
const form =
|
|
878
|
+
const form = createForm({
|
|
895
879
|
fields: fields => ({
|
|
896
880
|
title: fields.text().label("Title")
|
|
897
881
|
}),
|
|
@@ -909,7 +893,7 @@ describe("FormModel", () => {
|
|
|
909
893
|
});
|
|
910
894
|
});
|
|
911
895
|
it("should support element without props", () => {
|
|
912
|
-
const form =
|
|
896
|
+
const form = createForm({
|
|
913
897
|
fields: fields => ({
|
|
914
898
|
title: fields.text().label("Title")
|
|
915
899
|
}),
|
|
@@ -922,23 +906,16 @@ describe("FormModel", () => {
|
|
|
922
906
|
});
|
|
923
907
|
describe("named layout node access — form.layout(nodeId)", () => {
|
|
924
908
|
function createFormWithTabs() {
|
|
925
|
-
return
|
|
909
|
+
return createForm({
|
|
926
910
|
fields: fields => ({
|
|
927
911
|
title: fields.text().label("Title"),
|
|
928
912
|
description: fields.text().label("Description"),
|
|
929
913
|
metaTitle: fields.text().label("Meta Title")
|
|
930
914
|
}),
|
|
931
|
-
layout: layout => [layout.row("title"), layout.tabs({
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
label: "General",
|
|
936
|
-
layout: [layout.row("description")]
|
|
937
|
-
}, {
|
|
938
|
-
id: "seo",
|
|
939
|
-
label: "SEO",
|
|
940
|
-
layout: [layout.row("metaTitle")]
|
|
941
|
-
}]
|
|
915
|
+
layout: layout => [layout.row("title"), layout.tabs("settings").tab("general", tab => {
|
|
916
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
917
|
+
}).tab("seo", tab => {
|
|
918
|
+
tab.label("SEO").layout(layout => [layout.row("metaTitle")]);
|
|
942
919
|
})]
|
|
943
920
|
});
|
|
944
921
|
}
|
|
@@ -947,13 +924,8 @@ describe("FormModel", () => {
|
|
|
947
924
|
form.fields(fields => ({
|
|
948
925
|
trackingId: fields.text().label("Tracking ID")
|
|
949
926
|
}));
|
|
950
|
-
form.layout("settings").as("tabs").tab({
|
|
951
|
-
|
|
952
|
-
label: "Analytics",
|
|
953
|
-
layout: [{
|
|
954
|
-
type: "row",
|
|
955
|
-
fieldIds: ["trackingId"]
|
|
956
|
-
}]
|
|
927
|
+
form.layout("settings").as("tabs").tab("analytics", tab => {
|
|
928
|
+
tab.label("Analytics").layout(layout => [layout.row("trackingId")]);
|
|
957
929
|
}).after("seo");
|
|
958
930
|
const tabsNode = form.vm.layout[1];
|
|
959
931
|
expect(tabsNode.tabs).toHaveLength(3);
|
|
@@ -965,13 +937,8 @@ describe("FormModel", () => {
|
|
|
965
937
|
form.fields(fields => ({
|
|
966
938
|
trackingId: fields.text().label("Tracking ID")
|
|
967
939
|
}));
|
|
968
|
-
form.layout("settings").as("tabs").tab({
|
|
969
|
-
|
|
970
|
-
label: "Analytics",
|
|
971
|
-
layout: [{
|
|
972
|
-
type: "row",
|
|
973
|
-
fieldIds: ["trackingId"]
|
|
974
|
-
}]
|
|
940
|
+
form.layout("settings").as("tabs").tab("analytics", tab => {
|
|
941
|
+
tab.label("Analytics").layout(layout => [layout.row("trackingId")]);
|
|
975
942
|
}).before("seo");
|
|
976
943
|
const tabsNode = form.vm.layout[1];
|
|
977
944
|
expect(tabsNode.tabs).toHaveLength(3);
|
|
@@ -979,42 +946,21 @@ describe("FormModel", () => {
|
|
|
979
946
|
expect(tabsNode.tabs[1].id).toBe("analytics");
|
|
980
947
|
expect(tabsNode.tabs[2].id).toBe("seo");
|
|
981
948
|
});
|
|
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
949
|
it("should throw when accessing a non-existent node ID", () => {
|
|
995
950
|
const form = createFormWithTabs();
|
|
996
951
|
expect(() => form.layout("nonexistent").as("tabs")).toThrow('Layout node "nonexistent" not found.');
|
|
997
952
|
});
|
|
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
953
|
});
|
|
1003
954
|
describe("positional modifiers targeting tabs/element nodes", () => {
|
|
1004
955
|
it("should insert a row before a tabs node by ID", () => {
|
|
1005
|
-
const form =
|
|
956
|
+
const form = createForm({
|
|
1006
957
|
fields: fields => ({
|
|
1007
958
|
title: fields.text().label("Title"),
|
|
1008
959
|
subtitle: fields.text().label("Subtitle"),
|
|
1009
960
|
description: fields.text().label("Description")
|
|
1010
961
|
}),
|
|
1011
|
-
layout: layout => [layout.row("title"), layout.tabs({
|
|
1012
|
-
|
|
1013
|
-
tabs: [{
|
|
1014
|
-
id: "general",
|
|
1015
|
-
label: "General",
|
|
1016
|
-
layout: [layout.row("description")]
|
|
1017
|
-
}]
|
|
962
|
+
layout: layout => [layout.row("title"), layout.tabs("settings").tab("general", tab => {
|
|
963
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
1018
964
|
})]
|
|
1019
965
|
});
|
|
1020
966
|
form.layout(layout => [layout.row("subtitle").before("settings")]);
|
|
@@ -1026,18 +972,13 @@ describe("FormModel", () => {
|
|
|
1026
972
|
expect(row.fields[0].name).toBe("subtitle");
|
|
1027
973
|
});
|
|
1028
974
|
it("should remove a tabs node by ID", () => {
|
|
1029
|
-
const form =
|
|
975
|
+
const form = createForm({
|
|
1030
976
|
fields: fields => ({
|
|
1031
977
|
title: fields.text().label("Title"),
|
|
1032
978
|
description: fields.text().label("Description")
|
|
1033
979
|
}),
|
|
1034
|
-
layout: layout => [layout.row("title"), layout.tabs({
|
|
1035
|
-
|
|
1036
|
-
tabs: [{
|
|
1037
|
-
id: "general",
|
|
1038
|
-
label: "General",
|
|
1039
|
-
layout: [layout.row("description")]
|
|
1040
|
-
}]
|
|
980
|
+
layout: layout => [layout.row("title"), layout.tabs("settings").tab("general", tab => {
|
|
981
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
1041
982
|
})]
|
|
1042
983
|
});
|
|
1043
984
|
form.layout(layout => {
|
|
@@ -1048,19 +989,14 @@ describe("FormModel", () => {
|
|
|
1048
989
|
expect(form.vm.layout[0].type).toBe("row");
|
|
1049
990
|
});
|
|
1050
991
|
it("should replace a tabs node by ID", () => {
|
|
1051
|
-
const form =
|
|
992
|
+
const form = createForm({
|
|
1052
993
|
fields: fields => ({
|
|
1053
994
|
title: fields.text().label("Title"),
|
|
1054
995
|
description: fields.text().label("Description"),
|
|
1055
996
|
metaTitle: fields.text().label("Meta Title")
|
|
1056
997
|
}),
|
|
1057
|
-
layout: layout => [layout.row("title"), layout.tabs({
|
|
1058
|
-
|
|
1059
|
-
tabs: [{
|
|
1060
|
-
id: "general",
|
|
1061
|
-
label: "General",
|
|
1062
|
-
layout: [layout.row("description")]
|
|
1063
|
-
}]
|
|
998
|
+
layout: layout => [layout.row("title"), layout.tabs("settings").tab("general", tab => {
|
|
999
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
1064
1000
|
})]
|
|
1065
1001
|
});
|
|
1066
1002
|
form.layout(layout => [layout.row("metaTitle").replace("settings")]);
|
|
@@ -1073,23 +1009,16 @@ describe("FormModel", () => {
|
|
|
1073
1009
|
});
|
|
1074
1010
|
describe("modifier integration with tabs", () => {
|
|
1075
1011
|
it("should support a full modifier workflow: base form with tabs + modifier adds tab + modifier appends to existing tab", () => {
|
|
1076
|
-
const form =
|
|
1012
|
+
const form = createForm({
|
|
1077
1013
|
fields: fields => ({
|
|
1078
1014
|
title: fields.text().label("Title"),
|
|
1079
1015
|
description: fields.text().label("Description"),
|
|
1080
1016
|
metaTitle: fields.text().label("Meta Title")
|
|
1081
1017
|
}),
|
|
1082
|
-
layout: layout => [layout.row("title"), layout.separator(), layout.tabs({
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
label: "General",
|
|
1087
|
-
layout: [layout.row("description")]
|
|
1088
|
-
}, {
|
|
1089
|
-
id: "seo",
|
|
1090
|
-
label: "SEO",
|
|
1091
|
-
layout: [layout.row("metaTitle")]
|
|
1092
|
-
}]
|
|
1018
|
+
layout: layout => [layout.row("title"), layout.separator(), layout.tabs("settings").tab("general", tab => {
|
|
1019
|
+
tab.label("General").layout(layout => [layout.row("description")]);
|
|
1020
|
+
}).tab("seo", tab => {
|
|
1021
|
+
tab.label("SEO").layout(layout => [layout.row("metaTitle")]);
|
|
1093
1022
|
})]
|
|
1094
1023
|
});
|
|
1095
1024
|
|
|
@@ -1097,21 +1026,10 @@ describe("FormModel", () => {
|
|
|
1097
1026
|
form.fields(fields => ({
|
|
1098
1027
|
trackingId: fields.text().label("Tracking ID")
|
|
1099
1028
|
}));
|
|
1100
|
-
form.layout("settings").as("tabs").tab({
|
|
1101
|
-
|
|
1102
|
-
label: "Analytics",
|
|
1103
|
-
layout: [{
|
|
1104
|
-
type: "row",
|
|
1105
|
-
fieldIds: ["trackingId"]
|
|
1106
|
-
}]
|
|
1029
|
+
form.layout("settings").as("tabs").tab("analytics", tab => {
|
|
1030
|
+
tab.label("Analytics").layout(layout => [layout.row("trackingId")]);
|
|
1107
1031
|
}).after("seo");
|
|
1108
1032
|
|
|
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
1033
|
// Verify full layout
|
|
1116
1034
|
const vm = form.vm;
|
|
1117
1035
|
expect(vm.layout).toHaveLength(3); // row, separator, tabs
|
|
@@ -1124,21 +1042,20 @@ describe("FormModel", () => {
|
|
|
1124
1042
|
expect(tabsNode.tabs[1].id).toBe("seo");
|
|
1125
1043
|
expect(tabsNode.tabs[2].id).toBe("analytics");
|
|
1126
1044
|
|
|
1127
|
-
// SEO tab
|
|
1045
|
+
// SEO tab has metaTitle
|
|
1128
1046
|
const seoTab = tabsNode.tabs[1];
|
|
1129
|
-
expect(seoTab.layout).toHaveLength(
|
|
1047
|
+
expect(seoTab.layout).toHaveLength(1);
|
|
1130
1048
|
|
|
1131
1049
|
// Verify all fields are in getData
|
|
1132
1050
|
const data = form.getData();
|
|
1133
1051
|
expect(data).toHaveProperty("trackingId");
|
|
1134
|
-
expect(data).toHaveProperty("ogImage");
|
|
1135
1052
|
});
|
|
1136
1053
|
});
|
|
1137
1054
|
});
|
|
1138
1055
|
describe("object fields (Phase 6)", () => {
|
|
1139
1056
|
describe("non-list object field", () => {
|
|
1140
1057
|
function createFormWithObject() {
|
|
1141
|
-
return
|
|
1058
|
+
return createForm({
|
|
1142
1059
|
fields: fields => ({
|
|
1143
1060
|
title: fields.text().label("Title"),
|
|
1144
1061
|
address: fields.object().label("Address").fields(f => ({
|
|
@@ -1240,7 +1157,7 @@ describe("FormModel", () => {
|
|
|
1240
1157
|
expect(vm.items).toHaveLength(0);
|
|
1241
1158
|
});
|
|
1242
1159
|
it("should render object field in a row via default layout", () => {
|
|
1243
|
-
const form =
|
|
1160
|
+
const form = createForm({
|
|
1244
1161
|
fields: fields => ({
|
|
1245
1162
|
address: fields.object().label("Address").fields(f => ({
|
|
1246
1163
|
street: f.text().label("Street"),
|
|
@@ -1287,7 +1204,7 @@ describe("FormModel", () => {
|
|
|
1287
1204
|
});
|
|
1288
1205
|
describe("list object field", () => {
|
|
1289
1206
|
function createFormWithList() {
|
|
1290
|
-
return
|
|
1207
|
+
return createForm({
|
|
1291
1208
|
fields: fields => ({
|
|
1292
1209
|
presets: fields.object().label("Presets").fields(f => ({
|
|
1293
1210
|
name: f.text().label("Name").required("Name is required"),
|
|
@@ -1424,7 +1341,7 @@ describe("FormModel", () => {
|
|
|
1424
1341
|
expect(valid).toBe(true);
|
|
1425
1342
|
});
|
|
1426
1343
|
it("should validate with listSchema", async () => {
|
|
1427
|
-
const form =
|
|
1344
|
+
const form = createForm({
|
|
1428
1345
|
fields: fields => ({
|
|
1429
1346
|
presets: fields.object().label("Presets").fields(f => ({
|
|
1430
1347
|
name: f.text().label("Name")
|
|
@@ -1490,24 +1407,17 @@ describe("FormModel", () => {
|
|
|
1490
1407
|
});
|
|
1491
1408
|
describe("hasErrors rollup through tabs", () => {
|
|
1492
1409
|
it("should report hasErrors in tabs containing object fields", async () => {
|
|
1493
|
-
const form =
|
|
1410
|
+
const form = createForm({
|
|
1494
1411
|
fields: fields => ({
|
|
1495
1412
|
title: fields.text().label("Title"),
|
|
1496
1413
|
address: fields.object().label("Address").fields(f => ({
|
|
1497
1414
|
street: f.text().label("Street").required("Required")
|
|
1498
1415
|
}))
|
|
1499
1416
|
}),
|
|
1500
|
-
layout: layout => [layout.tabs({
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
label: "General",
|
|
1505
|
-
layout: [layout.row("title")]
|
|
1506
|
-
}, {
|
|
1507
|
-
id: "details",
|
|
1508
|
-
label: "Details",
|
|
1509
|
-
layout: [layout.row("address")]
|
|
1510
|
-
}]
|
|
1417
|
+
layout: layout => [layout.tabs("settings").tab("general", tab => {
|
|
1418
|
+
tab.label("General").layout(layout => [layout.row("title")]);
|
|
1419
|
+
}).tab("details", tab => {
|
|
1420
|
+
tab.label("Details").layout(layout => [layout.row("address")]);
|
|
1511
1421
|
})]
|
|
1512
1422
|
});
|
|
1513
1423
|
form.field("title").setValue("Hello");
|
|
@@ -1518,6 +1428,1947 @@ describe("FormModel", () => {
|
|
|
1518
1428
|
});
|
|
1519
1429
|
});
|
|
1520
1430
|
});
|
|
1431
|
+
describe("templated object fields (Phase 8a)", () => {
|
|
1432
|
+
function createFormWithTemplatedObject() {
|
|
1433
|
+
return createForm({
|
|
1434
|
+
fields: fields => ({
|
|
1435
|
+
content: fields.object().label("Content").template("hero", t => {
|
|
1436
|
+
t.label("Hero Banner").fields(f => ({
|
|
1437
|
+
heading: f.text().label("Heading").required("Required"),
|
|
1438
|
+
image: f.text().label("Image")
|
|
1439
|
+
}));
|
|
1440
|
+
}).template("text", t => {
|
|
1441
|
+
t.label("Rich Text").fields(f => ({
|
|
1442
|
+
body: f.text().label("Body").required("Required")
|
|
1443
|
+
}));
|
|
1444
|
+
})
|
|
1445
|
+
})
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
describe("shape", () => {
|
|
1449
|
+
it("starts with no active template and empty children", () => {
|
|
1450
|
+
const form = createFormWithTemplatedObject();
|
|
1451
|
+
const field = form.field("content");
|
|
1452
|
+
expect(field.isTemplated).toBe(true);
|
|
1453
|
+
expect(field.activeTemplateId).toBeNull();
|
|
1454
|
+
expect(field.children.size).toBe(0);
|
|
1455
|
+
});
|
|
1456
|
+
it("exposes available templates via VM", () => {
|
|
1457
|
+
const form = createFormWithTemplatedObject();
|
|
1458
|
+
const vm = form.field("content").vm;
|
|
1459
|
+
expect(vm.isTemplated).toBe(true);
|
|
1460
|
+
expect(vm.availableTemplates).toEqual([{
|
|
1461
|
+
id: "hero",
|
|
1462
|
+
label: "Hero Banner"
|
|
1463
|
+
}, {
|
|
1464
|
+
id: "text",
|
|
1465
|
+
label: "Rich Text"
|
|
1466
|
+
}]);
|
|
1467
|
+
expect(vm.activeTemplateId).toBeNull();
|
|
1468
|
+
expect(vm.fields).toEqual([]);
|
|
1469
|
+
});
|
|
1470
|
+
it("rejects .fields() alongside .template() at build time", () => {
|
|
1471
|
+
expect(() => createForm({
|
|
1472
|
+
fields: fields => ({
|
|
1473
|
+
content: fields.object().fields(f => ({
|
|
1474
|
+
x: f.text()
|
|
1475
|
+
})).template("a", t => {
|
|
1476
|
+
t.label("A").fields(f => ({
|
|
1477
|
+
y: f.text()
|
|
1478
|
+
}));
|
|
1479
|
+
})
|
|
1480
|
+
})
|
|
1481
|
+
})).toThrow(/both .fields\(\) and .template\(\)/);
|
|
1482
|
+
});
|
|
1483
|
+
it("rejects duplicate template ids", () => {
|
|
1484
|
+
expect(() => createForm({
|
|
1485
|
+
fields: fields => ({
|
|
1486
|
+
content: fields.object().template("a", t => {
|
|
1487
|
+
t.label("A1").fields(f => ({
|
|
1488
|
+
x: f.text()
|
|
1489
|
+
}));
|
|
1490
|
+
}).template("a", t => {
|
|
1491
|
+
t.label("A2").fields(f => ({
|
|
1492
|
+
y: f.text()
|
|
1493
|
+
}));
|
|
1494
|
+
})
|
|
1495
|
+
})
|
|
1496
|
+
})).toThrow(/Duplicate template id "a"/);
|
|
1497
|
+
});
|
|
1498
|
+
it("rejects reserved _templateId as template id", () => {
|
|
1499
|
+
expect(() => createForm({
|
|
1500
|
+
fields: fields => ({
|
|
1501
|
+
content: fields.object().template("_templateId", t => {
|
|
1502
|
+
t.label("X").fields(f => ({
|
|
1503
|
+
x: f.text()
|
|
1504
|
+
}));
|
|
1505
|
+
})
|
|
1506
|
+
})
|
|
1507
|
+
})).toThrow(/reserved/);
|
|
1508
|
+
});
|
|
1509
|
+
it("rejects _templateId as a child field name in a template", () => {
|
|
1510
|
+
expect(() => createForm({
|
|
1511
|
+
fields: fields => ({
|
|
1512
|
+
content: fields.object().template("hero", t => {
|
|
1513
|
+
t.label("Hero").fields(f => ({
|
|
1514
|
+
_templateId: f.text()
|
|
1515
|
+
}));
|
|
1516
|
+
})
|
|
1517
|
+
})
|
|
1518
|
+
})).toThrow(/reserved field "_templateId"/);
|
|
1519
|
+
});
|
|
1520
|
+
it("allows combining .list() with .template() (Phase 8b)", () => {
|
|
1521
|
+
expect(() => createForm({
|
|
1522
|
+
fields: fields => ({
|
|
1523
|
+
content: fields.object().list().template("a", t => {
|
|
1524
|
+
t.label("A").fields(f => ({
|
|
1525
|
+
x: f.text()
|
|
1526
|
+
}));
|
|
1527
|
+
})
|
|
1528
|
+
})
|
|
1529
|
+
})).not.toThrow();
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
describe("setTemplate / switching", () => {
|
|
1533
|
+
it("builds children when a template is selected", () => {
|
|
1534
|
+
const form = createFormWithTemplatedObject();
|
|
1535
|
+
const field = form.field("content");
|
|
1536
|
+
field.setTemplate("hero");
|
|
1537
|
+
expect(field.activeTemplateId).toBe("hero");
|
|
1538
|
+
expect(field.children.size).toBe(2);
|
|
1539
|
+
expect(form.field("content.heading").type).toBe("text");
|
|
1540
|
+
expect(form.field("content.image").type).toBe("text");
|
|
1541
|
+
});
|
|
1542
|
+
it("discards data when switching to a different template", () => {
|
|
1543
|
+
const form = createFormWithTemplatedObject();
|
|
1544
|
+
const field = form.field("content");
|
|
1545
|
+
field.setTemplate("hero");
|
|
1546
|
+
form.field("content.heading").setValue("Hello");
|
|
1547
|
+
field.setTemplate("text");
|
|
1548
|
+
expect(field.activeTemplateId).toBe("text");
|
|
1549
|
+
expect(form.field("content.body")).toBeDefined();
|
|
1550
|
+
expect(() => form.field("content.heading")).toThrow();
|
|
1551
|
+
});
|
|
1552
|
+
it("is a no-op when setting the currently active template", () => {
|
|
1553
|
+
const form = createFormWithTemplatedObject();
|
|
1554
|
+
const field = form.field("content");
|
|
1555
|
+
field.setTemplate("hero");
|
|
1556
|
+
form.field("content.heading").setValue("Preserved");
|
|
1557
|
+
field.setTemplate("hero");
|
|
1558
|
+
expect(form.field("content.heading").getValue()).toBe("Preserved");
|
|
1559
|
+
});
|
|
1560
|
+
it("throws when setting an unknown template id", () => {
|
|
1561
|
+
const form = createFormWithTemplatedObject();
|
|
1562
|
+
const field = form.field("content");
|
|
1563
|
+
expect(() => field.setTemplate("missing")).toThrow(/Template "missing" not found/);
|
|
1564
|
+
});
|
|
1565
|
+
it("wires the form reference on newly created children", () => {
|
|
1566
|
+
const form = createFormWithTemplatedObject();
|
|
1567
|
+
const field = form.field("content");
|
|
1568
|
+
field.setTemplate("hero");
|
|
1569
|
+
// Setting a value on a child requires _form to be wired for pipelines.
|
|
1570
|
+
expect(() => form.field("content.heading").setValue("OK")).not.toThrow();
|
|
1571
|
+
});
|
|
1572
|
+
});
|
|
1573
|
+
describe("getData / setData", () => {
|
|
1574
|
+
it("returns null when no template is active", () => {
|
|
1575
|
+
const form = createFormWithTemplatedObject();
|
|
1576
|
+
expect(form.getData().content).toBeNull();
|
|
1577
|
+
});
|
|
1578
|
+
it("includes _templateId and child values when active", () => {
|
|
1579
|
+
const form = createFormWithTemplatedObject();
|
|
1580
|
+
const field = form.field("content");
|
|
1581
|
+
field.setTemplate("hero");
|
|
1582
|
+
form.field("content.heading").setValue("Welcome");
|
|
1583
|
+
form.field("content.image").setValue("cover.jpg");
|
|
1584
|
+
expect(form.getData().content).toEqual({
|
|
1585
|
+
_templateId: "hero",
|
|
1586
|
+
heading: "Welcome",
|
|
1587
|
+
image: "cover.jpg"
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
it("hydrates via setData by reading _templateId", () => {
|
|
1591
|
+
const form = createFormWithTemplatedObject();
|
|
1592
|
+
form.setData({
|
|
1593
|
+
content: {
|
|
1594
|
+
_templateId: "text",
|
|
1595
|
+
body: "Lorem ipsum"
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
const field = form.field("content");
|
|
1599
|
+
expect(field.activeTemplateId).toBe("text");
|
|
1600
|
+
expect(form.field("content.body").getValue()).toBe("Lorem ipsum");
|
|
1601
|
+
});
|
|
1602
|
+
it("setData with null clears the active template", () => {
|
|
1603
|
+
const form = createFormWithTemplatedObject();
|
|
1604
|
+
const field = form.field("content");
|
|
1605
|
+
field.setTemplate("hero");
|
|
1606
|
+
form.field("content.heading").setValue("X");
|
|
1607
|
+
form.setData({
|
|
1608
|
+
content: null
|
|
1609
|
+
});
|
|
1610
|
+
expect(field.activeTemplateId).toBeNull();
|
|
1611
|
+
expect(field.children.size).toBe(0);
|
|
1612
|
+
});
|
|
1613
|
+
it("setData ignores unknown template id silently", () => {
|
|
1614
|
+
const form = createFormWithTemplatedObject();
|
|
1615
|
+
form.setData({
|
|
1616
|
+
content: {
|
|
1617
|
+
_templateId: "nope",
|
|
1618
|
+
foo: "bar"
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
const field = form.field("content");
|
|
1622
|
+
expect(field.activeTemplateId).toBeNull();
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
describe("validation", () => {
|
|
1626
|
+
it("required templated object fails validation when no template active", async () => {
|
|
1627
|
+
const form = createForm({
|
|
1628
|
+
fields: fields => ({
|
|
1629
|
+
content: fields.object().required("Pick a template").template("hero", t => {
|
|
1630
|
+
t.label("Hero").fields(f => ({
|
|
1631
|
+
heading: f.text()
|
|
1632
|
+
}));
|
|
1633
|
+
})
|
|
1634
|
+
})
|
|
1635
|
+
});
|
|
1636
|
+
const valid = await form.validate();
|
|
1637
|
+
expect(valid).toBe(false);
|
|
1638
|
+
expect(form.errors.some(e => e.path === "content")).toBe(true);
|
|
1639
|
+
});
|
|
1640
|
+
it("required templated object passes when template active with valid children", async () => {
|
|
1641
|
+
const form = createForm({
|
|
1642
|
+
fields: fields => ({
|
|
1643
|
+
content: fields.object().required("Pick a template").template("hero", t => {
|
|
1644
|
+
t.label("Hero").fields(f => ({
|
|
1645
|
+
heading: f.text().required("Required")
|
|
1646
|
+
}));
|
|
1647
|
+
})
|
|
1648
|
+
})
|
|
1649
|
+
});
|
|
1650
|
+
const field = form.field("content");
|
|
1651
|
+
field.setTemplate("hero");
|
|
1652
|
+
form.field("content.heading").setValue("Hi");
|
|
1653
|
+
const valid = await form.validate();
|
|
1654
|
+
expect(valid).toBe(true);
|
|
1655
|
+
});
|
|
1656
|
+
it("validates child fields inside active template", async () => {
|
|
1657
|
+
const form = createFormWithTemplatedObject();
|
|
1658
|
+
const field = form.field("content");
|
|
1659
|
+
field.setTemplate("hero");
|
|
1660
|
+
// heading is required; no value set
|
|
1661
|
+
const valid = await form.validate();
|
|
1662
|
+
expect(valid).toBe(false);
|
|
1663
|
+
});
|
|
1664
|
+
it("passes validation when object is optional and no template selected", async () => {
|
|
1665
|
+
const form = createFormWithTemplatedObject();
|
|
1666
|
+
const valid = await form.validate();
|
|
1667
|
+
expect(valid).toBe(true);
|
|
1668
|
+
});
|
|
1669
|
+
});
|
|
1670
|
+
describe("template visibility", () => {
|
|
1671
|
+
it("filters availableTemplates by reactive visible callback", () => {
|
|
1672
|
+
const form = createForm({
|
|
1673
|
+
fields: fields => ({
|
|
1674
|
+
plan: fields.text().defaultValue("free"),
|
|
1675
|
+
content: fields.object().template("basic", t => {
|
|
1676
|
+
t.label("Basic").fields(f => ({
|
|
1677
|
+
x: f.text()
|
|
1678
|
+
}));
|
|
1679
|
+
}).template("premium", t => {
|
|
1680
|
+
t.label("Premium").visible(f => f.field("plan").getValue() === "enterprise").fields(f => ({
|
|
1681
|
+
y: f.text()
|
|
1682
|
+
}));
|
|
1683
|
+
})
|
|
1684
|
+
})
|
|
1685
|
+
});
|
|
1686
|
+
const vm1 = form.field("content").vm;
|
|
1687
|
+
expect(vm1.availableTemplates.map(t => t.id)).toEqual(["basic"]);
|
|
1688
|
+
form.field("plan").setValue("enterprise");
|
|
1689
|
+
const vm2 = form.field("content").vm;
|
|
1690
|
+
expect(vm2.availableTemplates.map(t => t.id)).toEqual(["basic", "premium"]);
|
|
1691
|
+
});
|
|
1692
|
+
it("hiding a template does not clear an already-active selection", () => {
|
|
1693
|
+
const form = createForm({
|
|
1694
|
+
fields: fields => ({
|
|
1695
|
+
plan: fields.text().defaultValue("enterprise"),
|
|
1696
|
+
content: fields.object().template("premium", t => {
|
|
1697
|
+
t.label("Premium").visible(f => f.field("plan").getValue() === "enterprise").fields(f => ({
|
|
1698
|
+
y: f.text()
|
|
1699
|
+
}));
|
|
1700
|
+
})
|
|
1701
|
+
})
|
|
1702
|
+
});
|
|
1703
|
+
const field = form.field("content");
|
|
1704
|
+
field.setTemplate("premium");
|
|
1705
|
+
form.field("content.y").setValue("set");
|
|
1706
|
+
form.field("plan").setValue("free");
|
|
1707
|
+
|
|
1708
|
+
// Template no longer in picker, but active selection + data preserved.
|
|
1709
|
+
const vm = form.field("content").vm;
|
|
1710
|
+
expect(vm.availableTemplates).toEqual([]);
|
|
1711
|
+
expect(vm.activeTemplateId).toBe("premium");
|
|
1712
|
+
expect(form.field("content.y").getValue()).toBe("set");
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
describe("isDirty", () => {
|
|
1716
|
+
it("is not dirty after setData with template", () => {
|
|
1717
|
+
const form = createFormWithTemplatedObject();
|
|
1718
|
+
form.setData({
|
|
1719
|
+
content: {
|
|
1720
|
+
_templateId: "hero",
|
|
1721
|
+
heading: "Hello",
|
|
1722
|
+
image: ""
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
expect(form.isDirty).toBe(false);
|
|
1726
|
+
});
|
|
1727
|
+
it("becomes dirty after switching template", () => {
|
|
1728
|
+
const form = createFormWithTemplatedObject();
|
|
1729
|
+
form.setData({
|
|
1730
|
+
content: {
|
|
1731
|
+
_templateId: "hero",
|
|
1732
|
+
heading: "Hello",
|
|
1733
|
+
image: ""
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
form.field("content").setTemplate("text");
|
|
1737
|
+
expect(form.isDirty).toBe(true);
|
|
1738
|
+
});
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
describe("templated list fields (Phase 8b)", () => {
|
|
1742
|
+
function createFormWithTemplatedList() {
|
|
1743
|
+
return createForm({
|
|
1744
|
+
fields: fields => ({
|
|
1745
|
+
sections: fields.object().label("Sections").list().template("hero", t => {
|
|
1746
|
+
t.label("Hero").fields(f => ({
|
|
1747
|
+
heading: f.text().required("Required"),
|
|
1748
|
+
image: f.text()
|
|
1749
|
+
}));
|
|
1750
|
+
}).template("text", t => {
|
|
1751
|
+
t.label("Text").fields(f => ({
|
|
1752
|
+
body: f.text().required("Required")
|
|
1753
|
+
}));
|
|
1754
|
+
})
|
|
1755
|
+
})
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
describe("addItem / templateId", () => {
|
|
1759
|
+
it("requires a template id when adding to a templated list", () => {
|
|
1760
|
+
const form = createFormWithTemplatedList();
|
|
1761
|
+
const field = form.field("sections");
|
|
1762
|
+
expect(() => field.addItem()).toThrow(/require a template id/);
|
|
1763
|
+
});
|
|
1764
|
+
it("rejects unknown template ids", () => {
|
|
1765
|
+
const form = createFormWithTemplatedList();
|
|
1766
|
+
const field = form.field("sections");
|
|
1767
|
+
expect(() => field.addItem("missing")).toThrow(/Template "missing" not found/);
|
|
1768
|
+
});
|
|
1769
|
+
it("adds an item with the picked template's children", () => {
|
|
1770
|
+
const form = createFormWithTemplatedList();
|
|
1771
|
+
const field = form.field("sections");
|
|
1772
|
+
field.addItem("hero");
|
|
1773
|
+
expect(field.items.length).toBe(1);
|
|
1774
|
+
expect(field.items[0].templateId).toBe("hero");
|
|
1775
|
+
expect(field.items[0].children.has("heading")).toBe(true);
|
|
1776
|
+
expect(field.items[0].children.has("image")).toBe(true);
|
|
1777
|
+
});
|
|
1778
|
+
it("allows mixing different templates across items", () => {
|
|
1779
|
+
const form = createFormWithTemplatedList();
|
|
1780
|
+
const field = form.field("sections");
|
|
1781
|
+
field.addItem("hero");
|
|
1782
|
+
field.addItem("text");
|
|
1783
|
+
field.addItem("hero");
|
|
1784
|
+
expect(field.items.map(i => i.templateId)).toEqual(["hero", "text", "hero"]);
|
|
1785
|
+
});
|
|
1786
|
+
});
|
|
1787
|
+
describe("getData", () => {
|
|
1788
|
+
it("includes _templateId per item", () => {
|
|
1789
|
+
const form = createFormWithTemplatedList();
|
|
1790
|
+
const field = form.field("sections");
|
|
1791
|
+
field.addItem("hero");
|
|
1792
|
+
field.items[0].children.get("heading").setValue("Welcome");
|
|
1793
|
+
field.addItem("text");
|
|
1794
|
+
field.items[1].children.get("body").setValue("Lorem");
|
|
1795
|
+
expect(form.getData().sections).toEqual([{
|
|
1796
|
+
_templateId: "hero",
|
|
1797
|
+
heading: "Welcome",
|
|
1798
|
+
image: null
|
|
1799
|
+
}, {
|
|
1800
|
+
_templateId: "text",
|
|
1801
|
+
body: "Lorem"
|
|
1802
|
+
}]);
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
describe("setData", () => {
|
|
1806
|
+
it("hydrates items by reading each item's _templateId", () => {
|
|
1807
|
+
const form = createFormWithTemplatedList();
|
|
1808
|
+
form.setData({
|
|
1809
|
+
sections: [{
|
|
1810
|
+
_templateId: "hero",
|
|
1811
|
+
heading: "H1",
|
|
1812
|
+
image: "img.jpg"
|
|
1813
|
+
}, {
|
|
1814
|
+
_templateId: "text",
|
|
1815
|
+
body: "Body copy"
|
|
1816
|
+
}]
|
|
1817
|
+
});
|
|
1818
|
+
const field = form.field("sections");
|
|
1819
|
+
expect(field.items.length).toBe(2);
|
|
1820
|
+
expect(field.items[0].templateId).toBe("hero");
|
|
1821
|
+
expect(field.items[0].children.get("heading").getValue()).toBe("H1");
|
|
1822
|
+
expect(field.items[1].templateId).toBe("text");
|
|
1823
|
+
expect(field.items[1].children.get("body").getValue()).toBe("Body copy");
|
|
1824
|
+
});
|
|
1825
|
+
it("silently drops items with invalid or missing _templateId", () => {
|
|
1826
|
+
const form = createFormWithTemplatedList();
|
|
1827
|
+
form.setData({
|
|
1828
|
+
sections: [{
|
|
1829
|
+
_templateId: "hero",
|
|
1830
|
+
heading: "Keep"
|
|
1831
|
+
}, {
|
|
1832
|
+
_templateId: "nope",
|
|
1833
|
+
x: 1
|
|
1834
|
+
}, {
|
|
1835
|
+
heading: "no-id"
|
|
1836
|
+
}, null, {
|
|
1837
|
+
_templateId: "text",
|
|
1838
|
+
body: "Also keep"
|
|
1839
|
+
}]
|
|
1840
|
+
});
|
|
1841
|
+
const field = form.field("sections");
|
|
1842
|
+
expect(field.items.length).toBe(2);
|
|
1843
|
+
expect(field.items.map(i => i.templateId)).toEqual(["hero", "text"]);
|
|
1844
|
+
});
|
|
1845
|
+
});
|
|
1846
|
+
describe("duplicate / move / remove", () => {
|
|
1847
|
+
it("duplicates an item preserving templateId and values", () => {
|
|
1848
|
+
const form = createFormWithTemplatedList();
|
|
1849
|
+
const field = form.field("sections");
|
|
1850
|
+
field.addItem("hero");
|
|
1851
|
+
field.items[0].children.get("heading").setValue("Original");
|
|
1852
|
+
field.items[0].children.get("image").setValue("pic.jpg");
|
|
1853
|
+
field.duplicateItem(0);
|
|
1854
|
+
expect(field.items.length).toBe(2);
|
|
1855
|
+
expect(field.items[1].templateId).toBe("hero");
|
|
1856
|
+
expect(field.items[1].children.get("heading").getValue()).toBe("Original");
|
|
1857
|
+
expect(field.items[1].children.get("image").getValue()).toBe("pic.jpg");
|
|
1858
|
+
expect(field.items[0].key).not.toBe(field.items[1].key);
|
|
1859
|
+
});
|
|
1860
|
+
it("moves items while preserving templateId", () => {
|
|
1861
|
+
const form = createFormWithTemplatedList();
|
|
1862
|
+
const field = form.field("sections");
|
|
1863
|
+
field.addItem("hero");
|
|
1864
|
+
field.addItem("text");
|
|
1865
|
+
field.moveItem(0, 1);
|
|
1866
|
+
expect(field.items.map(i => i.templateId)).toEqual(["text", "hero"]);
|
|
1867
|
+
});
|
|
1868
|
+
it("removes items by index", () => {
|
|
1869
|
+
const form = createFormWithTemplatedList();
|
|
1870
|
+
const field = form.field("sections");
|
|
1871
|
+
field.addItem("hero");
|
|
1872
|
+
field.addItem("text");
|
|
1873
|
+
field.removeItem(0);
|
|
1874
|
+
expect(field.items.length).toBe(1);
|
|
1875
|
+
expect(field.items[0].templateId).toBe("text");
|
|
1876
|
+
});
|
|
1877
|
+
});
|
|
1878
|
+
describe("VM", () => {
|
|
1879
|
+
it("exposes templateId and duplicate on each item VM", () => {
|
|
1880
|
+
const form = createFormWithTemplatedList();
|
|
1881
|
+
const field = form.field("sections");
|
|
1882
|
+
field.addItem("hero");
|
|
1883
|
+
const vm = form.field("sections").vm;
|
|
1884
|
+
expect(vm.items.length).toBe(1);
|
|
1885
|
+
expect(vm.items[0].templateId).toBe("hero");
|
|
1886
|
+
expect(typeof vm.items[0].duplicate).toBe("function");
|
|
1887
|
+
});
|
|
1888
|
+
it("VM addItem(templateId) appends an item", () => {
|
|
1889
|
+
const form = createFormWithTemplatedList();
|
|
1890
|
+
const vm = form.field("sections").vm;
|
|
1891
|
+
vm.addItem("hero");
|
|
1892
|
+
vm.addItem("text");
|
|
1893
|
+
const field = form.field("sections");
|
|
1894
|
+
expect(field.items.map(i => i.templateId)).toEqual(["hero", "text"]);
|
|
1895
|
+
});
|
|
1896
|
+
it("availableTemplates respects reactive visible() on templated lists", () => {
|
|
1897
|
+
const form = createForm({
|
|
1898
|
+
fields: fields => ({
|
|
1899
|
+
plan: fields.text().defaultValue("free"),
|
|
1900
|
+
sections: fields.object().list().template("basic", t => {
|
|
1901
|
+
t.label("Basic").fields(f => ({
|
|
1902
|
+
x: f.text()
|
|
1903
|
+
}));
|
|
1904
|
+
}).template("premium", t => {
|
|
1905
|
+
t.label("Premium").visible(f => f.field("plan").getValue() === "enterprise").fields(f => ({
|
|
1906
|
+
y: f.text()
|
|
1907
|
+
}));
|
|
1908
|
+
})
|
|
1909
|
+
})
|
|
1910
|
+
});
|
|
1911
|
+
const vm1 = form.field("sections").vm;
|
|
1912
|
+
expect(vm1.availableTemplates.map(t => t.id)).toEqual(["basic"]);
|
|
1913
|
+
form.field("plan").setValue("enterprise");
|
|
1914
|
+
const vm2 = form.field("sections").vm;
|
|
1915
|
+
expect(vm2.availableTemplates.map(t => t.id)).toEqual(["basic", "premium"]);
|
|
1916
|
+
});
|
|
1917
|
+
});
|
|
1918
|
+
describe("validation", () => {
|
|
1919
|
+
it("validates each item's children under its template", async () => {
|
|
1920
|
+
const form = createFormWithTemplatedList();
|
|
1921
|
+
const field = form.field("sections");
|
|
1922
|
+
field.addItem("hero"); // heading is required, not set
|
|
1923
|
+
field.addItem("text");
|
|
1924
|
+
field.items[1].children.get("body").setValue("ok");
|
|
1925
|
+
const valid = await form.validate();
|
|
1926
|
+
expect(valid).toBe(false);
|
|
1927
|
+
expect(form.errors.some(e => e.path.startsWith("sections"))).toBe(true);
|
|
1928
|
+
});
|
|
1929
|
+
it("passes when every item's required fields are filled", async () => {
|
|
1930
|
+
const form = createFormWithTemplatedList();
|
|
1931
|
+
const field = form.field("sections");
|
|
1932
|
+
field.addItem("hero");
|
|
1933
|
+
field.items[0].children.get("heading").setValue("H");
|
|
1934
|
+
field.addItem("text");
|
|
1935
|
+
field.items[1].children.get("body").setValue("B");
|
|
1936
|
+
const valid = await form.validate();
|
|
1937
|
+
expect(valid).toBe(true);
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
describe("isDirty", () => {
|
|
1941
|
+
it("is not dirty after setData with templated list", () => {
|
|
1942
|
+
const form = createFormWithTemplatedList();
|
|
1943
|
+
form.setData({
|
|
1944
|
+
sections: [{
|
|
1945
|
+
_templateId: "hero",
|
|
1946
|
+
heading: "H",
|
|
1947
|
+
image: ""
|
|
1948
|
+
}, {
|
|
1949
|
+
_templateId: "text",
|
|
1950
|
+
body: "B"
|
|
1951
|
+
}]
|
|
1952
|
+
});
|
|
1953
|
+
expect(form.isDirty).toBe(false);
|
|
1954
|
+
});
|
|
1955
|
+
it("becomes dirty after adding an item", () => {
|
|
1956
|
+
const form = createFormWithTemplatedList();
|
|
1957
|
+
form.setData({
|
|
1958
|
+
sections: [{
|
|
1959
|
+
_templateId: "hero",
|
|
1960
|
+
heading: "H",
|
|
1961
|
+
image: ""
|
|
1962
|
+
}]
|
|
1963
|
+
});
|
|
1964
|
+
form.field("sections").addItem("text");
|
|
1965
|
+
expect(form.isDirty).toBe(true);
|
|
1966
|
+
});
|
|
1967
|
+
});
|
|
1968
|
+
});
|
|
1969
|
+
describe("per-template / inner object layouts (Phase 8c)", () => {
|
|
1970
|
+
describe("non-templated single object", () => {
|
|
1971
|
+
it("defaults to one row per visible child when no layout.object() is registered", () => {
|
|
1972
|
+
const form = createForm({
|
|
1973
|
+
fields: fields => ({
|
|
1974
|
+
meta: fields.object().fields(f => ({
|
|
1975
|
+
a: f.text().label("A"),
|
|
1976
|
+
b: f.text().label("B")
|
|
1977
|
+
}))
|
|
1978
|
+
})
|
|
1979
|
+
});
|
|
1980
|
+
const vm = form.field("meta").vm;
|
|
1981
|
+
expect(vm.layout.length).toBe(2);
|
|
1982
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["a"]);
|
|
1983
|
+
expect(asRow(vm.layout[1]).fields.map(f => f.name)).toEqual(["b"]);
|
|
1984
|
+
});
|
|
1985
|
+
it("resolves the registered inner layout against the children", () => {
|
|
1986
|
+
const form = createForm({
|
|
1987
|
+
fields: fields => ({
|
|
1988
|
+
meta: fields.object().fields(f => ({
|
|
1989
|
+
a: f.text().label("A"),
|
|
1990
|
+
b: f.text().label("B")
|
|
1991
|
+
}))
|
|
1992
|
+
}),
|
|
1993
|
+
layout: layout => [layout.object("meta", l => [l.row("a", "b")])]
|
|
1994
|
+
});
|
|
1995
|
+
const vm = form.field("meta").vm;
|
|
1996
|
+
expect(vm.layout.length).toBe(1);
|
|
1997
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["a", "b"]);
|
|
1998
|
+
});
|
|
1999
|
+
it("throws when a per-template map is passed to a non-templated field", () => {
|
|
2000
|
+
expect(() => createForm({
|
|
2001
|
+
fields: fields => ({
|
|
2002
|
+
meta: fields.object().fields(f => ({
|
|
2003
|
+
a: f.text()
|
|
2004
|
+
}))
|
|
2005
|
+
}),
|
|
2006
|
+
layout: layout => [layout.object("meta", {
|
|
2007
|
+
tplA: l => [l.row("a")]
|
|
2008
|
+
})]
|
|
2009
|
+
})).toThrow(/not templated/);
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
describe("non-templated list", () => {
|
|
2013
|
+
it("applies the inner layout to every list item", () => {
|
|
2014
|
+
const form = createForm({
|
|
2015
|
+
fields: fields => ({
|
|
2016
|
+
rows: fields.object().list().fields(f => ({
|
|
2017
|
+
a: f.text(),
|
|
2018
|
+
b: f.text()
|
|
2019
|
+
}))
|
|
2020
|
+
}),
|
|
2021
|
+
layout: layout => [layout.object("rows", l => [l.row("a", "b")])]
|
|
2022
|
+
});
|
|
2023
|
+
const field = form.field("rows");
|
|
2024
|
+
field.addItem();
|
|
2025
|
+
field.addItem();
|
|
2026
|
+
const vm = field.vm;
|
|
2027
|
+
expect(vm.items.length).toBe(2);
|
|
2028
|
+
for (const item of vm.items) {
|
|
2029
|
+
expect(item.layout.length).toBe(1);
|
|
2030
|
+
expect(asRow(item.layout[0]).fields.map(f => f.name)).toEqual(["a", "b"]);
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
});
|
|
2034
|
+
describe("templated single object", () => {
|
|
2035
|
+
function buildTemplatedForm(layoutFactory) {
|
|
2036
|
+
return createForm({
|
|
2037
|
+
fields: fields => ({
|
|
2038
|
+
content: fields.object().template("hero", t => {
|
|
2039
|
+
t.label("Hero").fields(f => ({
|
|
2040
|
+
heading: f.text().label("Heading"),
|
|
2041
|
+
subheading: f.text().label("Subheading")
|
|
2042
|
+
}));
|
|
2043
|
+
}).template("cta", t => {
|
|
2044
|
+
t.label("CTA").fields(f => ({
|
|
2045
|
+
text: f.text().label("Text"),
|
|
2046
|
+
url: f.text().label("URL")
|
|
2047
|
+
}));
|
|
2048
|
+
})
|
|
2049
|
+
}),
|
|
2050
|
+
layout: layoutFactory
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
it("uses the active template's per-template layout", () => {
|
|
2054
|
+
const form = buildTemplatedForm(layout => [layout.object("content", {
|
|
2055
|
+
hero: l => [l.row("heading", "subheading")],
|
|
2056
|
+
cta: l => [l.row("text"), l.row("url")]
|
|
2057
|
+
})]);
|
|
2058
|
+
const field = form.field("content");
|
|
2059
|
+
field.setTemplate("hero");
|
|
2060
|
+
let vm = form.field("content").vm;
|
|
2061
|
+
expect(vm.layout.length).toBe(1);
|
|
2062
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["heading", "subheading"]);
|
|
2063
|
+
field.setTemplate("cta");
|
|
2064
|
+
vm = form.field("content").vm;
|
|
2065
|
+
expect(vm.layout.length).toBe(2);
|
|
2066
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["text"]);
|
|
2067
|
+
expect(asRow(vm.layout[1]).fields.map(f => f.name)).toEqual(["url"]);
|
|
2068
|
+
});
|
|
2069
|
+
it("falls back to default one-row-per-child when active template has no entry", () => {
|
|
2070
|
+
const form = buildTemplatedForm(layout => [layout.object("content", {
|
|
2071
|
+
hero: l => [l.row("heading", "subheading")]
|
|
2072
|
+
// no entry for "cta"
|
|
2073
|
+
})]);
|
|
2074
|
+
const field = form.field("content");
|
|
2075
|
+
field.setTemplate("cta");
|
|
2076
|
+
const vm = form.field("content").vm;
|
|
2077
|
+
expect(vm.layout.length).toBe(2);
|
|
2078
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["text"]);
|
|
2079
|
+
expect(asRow(vm.layout[1]).fields.map(f => f.name)).toEqual(["url"]);
|
|
2080
|
+
});
|
|
2081
|
+
it("returns an empty layout when no template is active", () => {
|
|
2082
|
+
const form = buildTemplatedForm(layout => [layout.object("content", {
|
|
2083
|
+
hero: l => [l.row("heading", "subheading")]
|
|
2084
|
+
})]);
|
|
2085
|
+
const vm = form.field("content").vm;
|
|
2086
|
+
expect(vm.activeTemplateId).toBeNull();
|
|
2087
|
+
expect(vm.layout).toEqual([]);
|
|
2088
|
+
});
|
|
2089
|
+
it("silently ignores an unknown template id in the layout map", () => {
|
|
2090
|
+
const form = buildTemplatedForm(layout => [layout.object("content", {
|
|
2091
|
+
hero: l => [l.row("heading", "subheading")],
|
|
2092
|
+
unknown: l => [l.row("xxx")]
|
|
2093
|
+
})]);
|
|
2094
|
+
// Should not throw at build time; the unknown entry is dead until referenced.
|
|
2095
|
+
const field = form.field("content");
|
|
2096
|
+
field.setTemplate("hero");
|
|
2097
|
+
const vm = form.field("content").vm;
|
|
2098
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["heading", "subheading"]);
|
|
2099
|
+
});
|
|
2100
|
+
it("throws when a single LayoutNode[] is passed to a templated field", () => {
|
|
2101
|
+
expect(() => buildTemplatedForm(layout => [layout.object("content", l => [l.row("x")])])).toThrow(/is templated/);
|
|
2102
|
+
});
|
|
2103
|
+
it("falls back to default when no layout.object() is registered", () => {
|
|
2104
|
+
const form = buildTemplatedForm();
|
|
2105
|
+
const field = form.field("content");
|
|
2106
|
+
field.setTemplate("hero");
|
|
2107
|
+
const vm = form.field("content").vm;
|
|
2108
|
+
expect(vm.layout.length).toBe(2);
|
|
2109
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["heading"]);
|
|
2110
|
+
expect(asRow(vm.layout[1]).fields.map(f => f.name)).toEqual(["subheading"]);
|
|
2111
|
+
});
|
|
2112
|
+
});
|
|
2113
|
+
describe("templated list", () => {
|
|
2114
|
+
function buildTemplatedListForm() {
|
|
2115
|
+
return createForm({
|
|
2116
|
+
fields: fields => ({
|
|
2117
|
+
sections: fields.object().list().template("hero", t => {
|
|
2118
|
+
t.label("Hero").fields(f => ({
|
|
2119
|
+
heading: f.text().label("Heading"),
|
|
2120
|
+
subheading: f.text().label("Subheading")
|
|
2121
|
+
}));
|
|
2122
|
+
}).template("cta", t => {
|
|
2123
|
+
t.label("CTA").fields(f => ({
|
|
2124
|
+
text: f.text().label("Text"),
|
|
2125
|
+
url: f.text().label("URL")
|
|
2126
|
+
}));
|
|
2127
|
+
})
|
|
2128
|
+
}),
|
|
2129
|
+
layout: layout => [layout.object("sections", {
|
|
2130
|
+
hero: l => [l.row("heading", "subheading")],
|
|
2131
|
+
cta: l => [l.row("text"), l.row("url")]
|
|
2132
|
+
})]
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
it("each item resolves layout against its own template", () => {
|
|
2136
|
+
const form = buildTemplatedListForm();
|
|
2137
|
+
const field = form.field("sections");
|
|
2138
|
+
field.addItem("hero");
|
|
2139
|
+
field.addItem("cta");
|
|
2140
|
+
const vm = form.field("sections").vm;
|
|
2141
|
+
expect(vm.items.length).toBe(2);
|
|
2142
|
+
expect(asRow(vm.items[0].layout[0]).fields.map(f => f.name)).toEqual(["heading", "subheading"]);
|
|
2143
|
+
expect(asRow(vm.items[1].layout[0]).fields.map(f => f.name)).toEqual(["text"]);
|
|
2144
|
+
expect(asRow(vm.items[1].layout[1]).fields.map(f => f.name)).toEqual(["url"]);
|
|
2145
|
+
});
|
|
2146
|
+
it("hides hidden child fields from the resolved layout row", () => {
|
|
2147
|
+
const form = createForm({
|
|
2148
|
+
fields: fields => ({
|
|
2149
|
+
content: fields.object().template("hero", t => {
|
|
2150
|
+
t.label("Hero").fields(f => ({
|
|
2151
|
+
heading: f.text(),
|
|
2152
|
+
secret: f.text().hidden()
|
|
2153
|
+
}));
|
|
2154
|
+
})
|
|
2155
|
+
}),
|
|
2156
|
+
layout: layout => [layout.object("content", {
|
|
2157
|
+
hero: l => [l.row("heading", "secret")]
|
|
2158
|
+
})]
|
|
2159
|
+
});
|
|
2160
|
+
const field = form.field("content");
|
|
2161
|
+
field.setTemplate("hero");
|
|
2162
|
+
const vm = form.field("content").vm;
|
|
2163
|
+
expect(asRow(vm.layout[0]).fields.map(f => f.name)).toEqual(["heading"]);
|
|
2164
|
+
});
|
|
2165
|
+
});
|
|
2166
|
+
describe("interaction with form.vm.layout", () => {
|
|
2167
|
+
it("the form layout exposes a single-field row for the object", () => {
|
|
2168
|
+
const form = createForm({
|
|
2169
|
+
fields: fields => ({
|
|
2170
|
+
content: fields.object().template("hero", t => {
|
|
2171
|
+
t.label("Hero").fields(f => ({
|
|
2172
|
+
heading: f.text()
|
|
2173
|
+
}));
|
|
2174
|
+
})
|
|
2175
|
+
}),
|
|
2176
|
+
layout: layout => [layout.object("content", {
|
|
2177
|
+
hero: l => [l.row("heading")]
|
|
2178
|
+
})]
|
|
2179
|
+
});
|
|
2180
|
+
const layout = form.vm.layout;
|
|
2181
|
+
expect(layout.length).toBe(1);
|
|
2182
|
+
const row = asRow(layout[0]);
|
|
2183
|
+
expect(row.fields.map(f => f.name)).toEqual(["content"]);
|
|
2184
|
+
});
|
|
2185
|
+
it("hides the object node from form.vm.layout when the field is not visible", () => {
|
|
2186
|
+
const form = createForm({
|
|
2187
|
+
fields: fields => ({
|
|
2188
|
+
content: fields.object().hidden().template("hero", t => {
|
|
2189
|
+
t.label("Hero").fields(f => ({
|
|
2190
|
+
heading: f.text()
|
|
2191
|
+
}));
|
|
2192
|
+
})
|
|
2193
|
+
}),
|
|
2194
|
+
layout: layout => [layout.object("content", {
|
|
2195
|
+
hero: l => [l.row("heading")]
|
|
2196
|
+
})]
|
|
2197
|
+
});
|
|
2198
|
+
expect(form.vm.layout).toEqual([]);
|
|
2199
|
+
});
|
|
2200
|
+
});
|
|
2201
|
+
});
|
|
2202
|
+
describe("nested object layouts (Phase 8c.1)", () => {
|
|
2203
|
+
it("registers layout.object() nested inside another object's inner layout (non-templated)", () => {
|
|
2204
|
+
const form = createForm({
|
|
2205
|
+
fields: fields => ({
|
|
2206
|
+
page: fields.object().fields(f => ({
|
|
2207
|
+
title: f.text(),
|
|
2208
|
+
seo: f.object().fields(g => ({
|
|
2209
|
+
metaTitle: g.text(),
|
|
2210
|
+
metaDescription: g.text()
|
|
2211
|
+
}))
|
|
2212
|
+
}))
|
|
2213
|
+
}),
|
|
2214
|
+
layout: layout => [layout.object("page", l => [l.row("title"), l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])])]
|
|
2215
|
+
});
|
|
2216
|
+
const pageVm = form.field("page").vm;
|
|
2217
|
+
const seoRow = asRow(pageVm.layout[1]);
|
|
2218
|
+
const seoVm = seoRow.fields[0];
|
|
2219
|
+
expect(seoVm.layout.length).toBe(1);
|
|
2220
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2221
|
+
});
|
|
2222
|
+
it("supports three levels of nesting", () => {
|
|
2223
|
+
const form = createForm({
|
|
2224
|
+
fields: fields => ({
|
|
2225
|
+
a: fields.object().fields(f => ({
|
|
2226
|
+
b: f.object().fields(g => ({
|
|
2227
|
+
c: g.object().fields(h => ({
|
|
2228
|
+
x: h.text(),
|
|
2229
|
+
y: h.text()
|
|
2230
|
+
}))
|
|
2231
|
+
}))
|
|
2232
|
+
}))
|
|
2233
|
+
}),
|
|
2234
|
+
layout: layout => [layout.object("a", l1 => [l1.object("b", l2 => [l2.object("c", l3 => [l3.row("x", "y")])])])]
|
|
2235
|
+
});
|
|
2236
|
+
const aVm = form.field("a").vm;
|
|
2237
|
+
const bVm = asRow(aVm.layout[0]).fields[0];
|
|
2238
|
+
const cVm = asRow(bVm.layout[0]).fields[0];
|
|
2239
|
+
expect(cVm.layout.length).toBe(1);
|
|
2240
|
+
expect(asRow(cVm.layout[0]).fields.map(f => f.name)).toEqual(["x", "y"]);
|
|
2241
|
+
});
|
|
2242
|
+
it("registers nested layouts on a templated single object when its template activates", () => {
|
|
2243
|
+
const form = createForm({
|
|
2244
|
+
fields: fields => ({
|
|
2245
|
+
block: fields.object().template("hero", t => {
|
|
2246
|
+
t.label("Hero").fields(f => ({
|
|
2247
|
+
heading: f.text(),
|
|
2248
|
+
seo: f.object().fields(g => ({
|
|
2249
|
+
metaTitle: g.text(),
|
|
2250
|
+
metaDescription: g.text()
|
|
2251
|
+
}))
|
|
2252
|
+
}));
|
|
2253
|
+
})
|
|
2254
|
+
}),
|
|
2255
|
+
layout: layout => [layout.object("block", {
|
|
2256
|
+
hero: l => [l.row("heading"), l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])]
|
|
2257
|
+
})]
|
|
2258
|
+
});
|
|
2259
|
+
const field = form.field("block");
|
|
2260
|
+
field.setTemplate("hero");
|
|
2261
|
+
const vm = form.field("block").vm;
|
|
2262
|
+
const seoRow = asRow(vm.layout[1]);
|
|
2263
|
+
const seoVm = seoRow.fields[0];
|
|
2264
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2265
|
+
});
|
|
2266
|
+
it("registers nested layouts on a templated list — each item gets its own", () => {
|
|
2267
|
+
const form = createForm({
|
|
2268
|
+
fields: fields => ({
|
|
2269
|
+
sections: fields.object().list().template("hero", t => {
|
|
2270
|
+
t.label("Hero").fields(f => ({
|
|
2271
|
+
heading: f.text(),
|
|
2272
|
+
seo: f.object().fields(g => ({
|
|
2273
|
+
metaTitle: g.text(),
|
|
2274
|
+
metaDescription: g.text()
|
|
2275
|
+
}))
|
|
2276
|
+
}));
|
|
2277
|
+
})
|
|
2278
|
+
}),
|
|
2279
|
+
layout: layout => [layout.object("sections", {
|
|
2280
|
+
hero: l => [l.row("heading"), l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])]
|
|
2281
|
+
})]
|
|
2282
|
+
});
|
|
2283
|
+
const field = form.field("sections");
|
|
2284
|
+
field.addItem("hero");
|
|
2285
|
+
field.addItem("hero");
|
|
2286
|
+
const vm = form.field("sections").vm;
|
|
2287
|
+
expect(vm.items.length).toBe(2);
|
|
2288
|
+
for (const item of vm.items) {
|
|
2289
|
+
const seoVm = asRow(item.layout[1]).fields[0];
|
|
2290
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
it("registers nested layouts on a non-templated list item upon creation", () => {
|
|
2294
|
+
const form = createForm({
|
|
2295
|
+
fields: fields => ({
|
|
2296
|
+
rows: fields.object().list().fields(f => ({
|
|
2297
|
+
label: f.text(),
|
|
2298
|
+
seo: f.object().fields(g => ({
|
|
2299
|
+
metaTitle: g.text(),
|
|
2300
|
+
metaDescription: g.text()
|
|
2301
|
+
}))
|
|
2302
|
+
}))
|
|
2303
|
+
}),
|
|
2304
|
+
layout: layout => [layout.object("rows", l => [l.row("label"), l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])])]
|
|
2305
|
+
});
|
|
2306
|
+
const field = form.field("rows");
|
|
2307
|
+
field.addItem();
|
|
2308
|
+
const vm = form.field("rows").vm;
|
|
2309
|
+
const seoVm = asRow(vm.items[0].layout[1]).fields[0];
|
|
2310
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2311
|
+
});
|
|
2312
|
+
it("registers nested layouts on children added via field.as('object').fields() at runtime", () => {
|
|
2313
|
+
const form = createForm({
|
|
2314
|
+
fields: fields => ({
|
|
2315
|
+
page: fields.object().fields(f => ({
|
|
2316
|
+
title: f.text()
|
|
2317
|
+
}))
|
|
2318
|
+
}),
|
|
2319
|
+
layout: layout => [layout.object("page", l => [l.row("title"), l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])])]
|
|
2320
|
+
});
|
|
2321
|
+
// seo doesn't exist yet; the layout entry is a no-op until we add it.
|
|
2322
|
+
form.field("page").as("object").fields(f => ({
|
|
2323
|
+
seo: f.object().fields(g => ({
|
|
2324
|
+
metaTitle: g.text(),
|
|
2325
|
+
metaDescription: g.text()
|
|
2326
|
+
}))
|
|
2327
|
+
}));
|
|
2328
|
+
const pageVm = form.field("page").vm;
|
|
2329
|
+
const seoRow = asRow(pageVm.layout[1]);
|
|
2330
|
+
const seoVm = seoRow.fields[0];
|
|
2331
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2332
|
+
});
|
|
2333
|
+
it("recurses through tabs nested inside an inner layout", () => {
|
|
2334
|
+
const form = createForm({
|
|
2335
|
+
fields: fields => ({
|
|
2336
|
+
page: fields.object().fields(f => ({
|
|
2337
|
+
title: f.text(),
|
|
2338
|
+
seo: f.object().fields(g => ({
|
|
2339
|
+
metaTitle: g.text(),
|
|
2340
|
+
metaDescription: g.text()
|
|
2341
|
+
}))
|
|
2342
|
+
}))
|
|
2343
|
+
}),
|
|
2344
|
+
layout: layout => [layout.object("page", l => [l.tabs().tab("main", tab => {
|
|
2345
|
+
tab.label("Main").layout(l => [l.row("title")]);
|
|
2346
|
+
}).tab("seoTab", tab => {
|
|
2347
|
+
tab.label("SEO").layout(l => [l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])]);
|
|
2348
|
+
})])]
|
|
2349
|
+
});
|
|
2350
|
+
const seoVm = form.field("page.seo").vm;
|
|
2351
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2352
|
+
});
|
|
2353
|
+
it("resolves tabs inside an inner layout against the children scope", () => {
|
|
2354
|
+
const form = createForm({
|
|
2355
|
+
fields: fields => ({
|
|
2356
|
+
page: fields.object().fields(f => ({
|
|
2357
|
+
title: f.text(),
|
|
2358
|
+
body: f.text(),
|
|
2359
|
+
seo: f.object().fields(g => ({
|
|
2360
|
+
metaTitle: g.text(),
|
|
2361
|
+
metaDescription: g.text()
|
|
2362
|
+
}))
|
|
2363
|
+
}))
|
|
2364
|
+
}),
|
|
2365
|
+
layout: layout => [layout.object("page", l => [l.tabs("pageTabs").tab("general", tab => {
|
|
2366
|
+
tab.label("General").layout(l => [l.row("title"), l.row("body")]);
|
|
2367
|
+
}).tab("seo", tab => {
|
|
2368
|
+
tab.label("SEO").layout(l => [l.object("seo", inner => [inner.row("metaTitle", "metaDescription")])]);
|
|
2369
|
+
})])]
|
|
2370
|
+
});
|
|
2371
|
+
const pageVm = form.field("page").vm;
|
|
2372
|
+
expect(pageVm.layout.length).toBe(1);
|
|
2373
|
+
const tabs = pageVm.layout[0];
|
|
2374
|
+
expect(tabs.type).toBe("tabs");
|
|
2375
|
+
expect(tabs.tabs.map(t => t.id)).toEqual(["general", "seo"]);
|
|
2376
|
+
const generalTab = tabs.tabs[0];
|
|
2377
|
+
expect(asRow(generalTab.layout[0]).fields.map(f => f.name)).toEqual(["title"]);
|
|
2378
|
+
expect(asRow(generalTab.layout[1]).fields.map(f => f.name)).toEqual(["body"]);
|
|
2379
|
+
const seoTab = tabs.tabs[1];
|
|
2380
|
+
const seoVm = asRow(seoTab.layout[0]).fields[0];
|
|
2381
|
+
expect(asRow(seoVm.layout[0]).fields.map(f => f.name)).toEqual(["metaTitle", "metaDescription"]);
|
|
2382
|
+
});
|
|
2383
|
+
});
|
|
2384
|
+
describe("runtime template modification (Phase 8d)", () => {
|
|
2385
|
+
function createSingleTemplatedForm() {
|
|
2386
|
+
return createForm({
|
|
2387
|
+
fields: fields => ({
|
|
2388
|
+
content: fields.object().template("hero", t => {
|
|
2389
|
+
t.label("Hero").fields(f => ({
|
|
2390
|
+
heading: f.text()
|
|
2391
|
+
}));
|
|
2392
|
+
})
|
|
2393
|
+
})
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
function createListTemplatedForm() {
|
|
2397
|
+
return createForm({
|
|
2398
|
+
fields: fields => ({
|
|
2399
|
+
blocks: fields.object().list().template("hero", t => {
|
|
2400
|
+
t.label("Hero").fields(f => ({
|
|
2401
|
+
heading: f.text()
|
|
2402
|
+
}));
|
|
2403
|
+
}).template("text", t => {
|
|
2404
|
+
t.label("Text").fields(f => ({
|
|
2405
|
+
body: f.text()
|
|
2406
|
+
}));
|
|
2407
|
+
})
|
|
2408
|
+
})
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
it("templates.add appends a template and the picker lists it", () => {
|
|
2412
|
+
const form = createSingleTemplatedForm();
|
|
2413
|
+
const field = form.field("content").as("object");
|
|
2414
|
+
field.templates.add("text", t => {
|
|
2415
|
+
t.label("Text").fields(f => ({
|
|
2416
|
+
body: f.text()
|
|
2417
|
+
}));
|
|
2418
|
+
});
|
|
2419
|
+
const vm = form.field("content").vm;
|
|
2420
|
+
expect(vm.availableTemplates.map(t => t.id)).toEqual(["hero", "text"]);
|
|
2421
|
+
});
|
|
2422
|
+
it("templates.add throws on duplicate id", () => {
|
|
2423
|
+
const form = createSingleTemplatedForm();
|
|
2424
|
+
const field = form.field("content").as("object");
|
|
2425
|
+
expect(() => field.templates.add("hero", t => {
|
|
2426
|
+
t.label("Other").fields(f => ({
|
|
2427
|
+
x: f.text()
|
|
2428
|
+
}));
|
|
2429
|
+
})).toThrow(/Duplicate template id "hero"/);
|
|
2430
|
+
});
|
|
2431
|
+
it("templates.add throws on reserved _templateId", () => {
|
|
2432
|
+
const form = createSingleTemplatedForm();
|
|
2433
|
+
const field = form.field("content").as("object");
|
|
2434
|
+
expect(() => field.templates.add("_templateId", t => {
|
|
2435
|
+
t.label("Reserved").fields(f => ({
|
|
2436
|
+
x: f.text()
|
|
2437
|
+
}));
|
|
2438
|
+
})).toThrow(/reserved/);
|
|
2439
|
+
});
|
|
2440
|
+
it("templates.add throws when the template defines a reserved _templateId field", () => {
|
|
2441
|
+
const form = createSingleTemplatedForm();
|
|
2442
|
+
const field = form.field("content").as("object");
|
|
2443
|
+
expect(() => field.templates.add("bad", t => {
|
|
2444
|
+
t.label("Bad").fields(f => ({
|
|
2445
|
+
_templateId: f.text()
|
|
2446
|
+
}));
|
|
2447
|
+
})).toThrow(/reserved field "_templateId"/);
|
|
2448
|
+
});
|
|
2449
|
+
it("templates.remove removes the template from the picker", () => {
|
|
2450
|
+
const form = createListTemplatedForm();
|
|
2451
|
+
const field = form.field("blocks").as("object");
|
|
2452
|
+
field.templates.remove("text");
|
|
2453
|
+
const vm = form.field("blocks").vm;
|
|
2454
|
+
expect(vm.availableTemplates.map(t => t.id)).toEqual(["hero"]);
|
|
2455
|
+
});
|
|
2456
|
+
it("templates.remove silently no-ops on unknown id", () => {
|
|
2457
|
+
const form = createListTemplatedForm();
|
|
2458
|
+
const field = form.field("blocks").as("object");
|
|
2459
|
+
const before = form.field("blocks").vm.availableTemplates.length;
|
|
2460
|
+
expect(() => field.templates.remove("nonExistent")).not.toThrow();
|
|
2461
|
+
const after = form.field("blocks").vm.availableTemplates.length;
|
|
2462
|
+
expect(after).toBe(before);
|
|
2463
|
+
});
|
|
2464
|
+
it("templates.remove clears active template on a single-object field via onChange(null) semantics", () => {
|
|
2465
|
+
const form = createSingleTemplatedForm();
|
|
2466
|
+
const field = form.field("content").as("object");
|
|
2467
|
+
field.setTemplate("hero");
|
|
2468
|
+
expect(field.activeTemplateId).toBe("hero");
|
|
2469
|
+
field.templates.remove("hero");
|
|
2470
|
+
expect(field.activeTemplateId).toBeNull();
|
|
2471
|
+
expect(field.children.size).toBe(0);
|
|
2472
|
+
expect(form.field("content").getValue()).toBeNull();
|
|
2473
|
+
});
|
|
2474
|
+
it("templates.remove drops list items whose _templateId matches", () => {
|
|
2475
|
+
const form = createListTemplatedForm();
|
|
2476
|
+
const field = form.field("blocks").as("object");
|
|
2477
|
+
field.addItem("hero", {
|
|
2478
|
+
heading: "H1"
|
|
2479
|
+
});
|
|
2480
|
+
field.addItem("text", {
|
|
2481
|
+
body: "B1"
|
|
2482
|
+
});
|
|
2483
|
+
field.addItem("hero", {
|
|
2484
|
+
heading: "H2"
|
|
2485
|
+
});
|
|
2486
|
+
expect(field.items.length).toBe(3);
|
|
2487
|
+
field.templates.remove("hero");
|
|
2488
|
+
expect(field.items.length).toBe(1);
|
|
2489
|
+
expect(field.items[0].templateId).toBe("text");
|
|
2490
|
+
});
|
|
2491
|
+
it("templates.add throws when called on a non-templated object field", () => {
|
|
2492
|
+
const form = createForm({
|
|
2493
|
+
fields: fields => ({
|
|
2494
|
+
plain: fields.object().fields(f => ({
|
|
2495
|
+
x: f.text()
|
|
2496
|
+
}))
|
|
2497
|
+
})
|
|
2498
|
+
});
|
|
2499
|
+
const field = form.field("plain").as("object");
|
|
2500
|
+
expect(() => field.templates.add("x", t => {
|
|
2501
|
+
t.label("X").fields(f => ({
|
|
2502
|
+
y: f.text()
|
|
2503
|
+
}));
|
|
2504
|
+
})).toThrow(/not templated/);
|
|
2505
|
+
});
|
|
2506
|
+
it("templates.remove throws when called on a non-templated object field", () => {
|
|
2507
|
+
const form = createForm({
|
|
2508
|
+
fields: fields => ({
|
|
2509
|
+
plain: fields.object().fields(f => ({
|
|
2510
|
+
x: f.text()
|
|
2511
|
+
}))
|
|
2512
|
+
})
|
|
2513
|
+
});
|
|
2514
|
+
const field = form.field("plain").as("object");
|
|
2515
|
+
expect(() => field.templates.remove("anything")).toThrow(/not templated/);
|
|
2516
|
+
});
|
|
2517
|
+
it("orphan layout entry persists silently and is reused when the same template id is re-added", () => {
|
|
2518
|
+
const form = createForm({
|
|
2519
|
+
fields: fields => ({
|
|
2520
|
+
content: fields.object().template("hero", t => {
|
|
2521
|
+
t.label("Hero").fields(f => ({
|
|
2522
|
+
heading: f.text(),
|
|
2523
|
+
subheading: f.text()
|
|
2524
|
+
}));
|
|
2525
|
+
})
|
|
2526
|
+
}),
|
|
2527
|
+
layout: layout => [layout.object("content", {
|
|
2528
|
+
hero: l => [l.row("heading", "subheading")]
|
|
2529
|
+
})]
|
|
2530
|
+
});
|
|
2531
|
+
const field = form.field("content").as("object");
|
|
2532
|
+
field.setTemplate("hero");
|
|
2533
|
+
const vmBefore = form.field("content").vm;
|
|
2534
|
+
const rowBefore = asRow(vmBefore.layout[0]);
|
|
2535
|
+
expect(rowBefore.fields.map(f => f.name)).toEqual(["heading", "subheading"]);
|
|
2536
|
+
field.templates.remove("hero");
|
|
2537
|
+
field.templates.add("hero", t => {
|
|
2538
|
+
t.label("Hero v2").fields(f => ({
|
|
2539
|
+
heading: f.text(),
|
|
2540
|
+
subheading: f.text()
|
|
2541
|
+
}));
|
|
2542
|
+
});
|
|
2543
|
+
field.setTemplate("hero");
|
|
2544
|
+
const vmAfter = form.field("content").vm;
|
|
2545
|
+
const rowAfter = asRow(vmAfter.layout[0]);
|
|
2546
|
+
expect(rowAfter.fields.map(f => f.name)).toEqual(["heading", "subheading"]);
|
|
2547
|
+
});
|
|
2548
|
+
it("removing all templates does not emit dev warnings (orphan suppression)", () => {
|
|
2549
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2550
|
+
const form = createSingleTemplatedForm();
|
|
2551
|
+
const field = form.field("content").as("object");
|
|
2552
|
+
field.templates.remove("hero");
|
|
2553
|
+
const vm = form.field("content").vm;
|
|
2554
|
+
expect(vm.availableTemplates).toEqual([]);
|
|
2555
|
+
expect(warn).not.toHaveBeenCalled();
|
|
2556
|
+
warn.mockRestore();
|
|
2557
|
+
});
|
|
2558
|
+
});
|
|
2559
|
+
describe("requiredWhen (Phase 11)", () => {
|
|
2560
|
+
it("makes a field required when the callback returns true", async () => {
|
|
2561
|
+
const form = createForm({
|
|
2562
|
+
fields: fields => ({
|
|
2563
|
+
plan: fields.text().defaultValue("free"),
|
|
2564
|
+
seats: fields.text().requiredWhen(f => f.field("plan").getValue() === "pro", "Seats required")
|
|
2565
|
+
})
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
// plan = free → not required → validation passes
|
|
2569
|
+
expect(form.field("seats").vm.required).toBe(false);
|
|
2570
|
+
expect(await form.validate()).toBe(true);
|
|
2571
|
+
|
|
2572
|
+
// plan = pro → becomes required → empty value fails validation
|
|
2573
|
+
form.field("plan").setValue("pro");
|
|
2574
|
+
expect(form.field("seats").vm.required).toBe(true);
|
|
2575
|
+
expect(await form.validate()).toBe(false);
|
|
2576
|
+
expect(form.field("seats").vm.validation.message).toBe("Seats required");
|
|
2577
|
+
});
|
|
2578
|
+
it("chains requiredWhen callbacks — first truthy wins", async () => {
|
|
2579
|
+
const form = createForm({
|
|
2580
|
+
fields: fields => ({
|
|
2581
|
+
plan: fields.text().defaultValue("free"),
|
|
2582
|
+
flag: fields.text().defaultValue("off"),
|
|
2583
|
+
seats: fields.text().requiredWhen(f => f.field("plan").getValue() === "pro", "Pro requires it").requiredWhen(f => f.field("flag").getValue() === "on", "Flag requires it")
|
|
2584
|
+
})
|
|
2585
|
+
});
|
|
2586
|
+
form.field("flag").setValue("on");
|
|
2587
|
+
await form.validate();
|
|
2588
|
+
expect(form.field("seats").vm.validation.message).toBe("Flag requires it");
|
|
2589
|
+
|
|
2590
|
+
// First-truthy-wins: enable plan too, plan callback runs first.
|
|
2591
|
+
form.field("plan").setValue("pro");
|
|
2592
|
+
await form.validate();
|
|
2593
|
+
expect(form.field("seats").vm.validation.message).toBe("Pro requires it");
|
|
2594
|
+
});
|
|
2595
|
+
it("hard .required() always wins over requiredWhen messages", async () => {
|
|
2596
|
+
const form = createForm({
|
|
2597
|
+
fields: fields => ({
|
|
2598
|
+
seats: fields.text().required("Always required").requiredWhen(() => true, "Conditional message")
|
|
2599
|
+
})
|
|
2600
|
+
});
|
|
2601
|
+
await form.validate();
|
|
2602
|
+
expect(form.field("seats").vm.required).toBe(true);
|
|
2603
|
+
expect(form.field("seats").vm.validation.message).toBe("Always required");
|
|
2604
|
+
});
|
|
2605
|
+
it("modifier-added requiredWhen chains with builder-defined ones", async () => {
|
|
2606
|
+
const form = createForm({
|
|
2607
|
+
fields: fields => ({
|
|
2608
|
+
plan: fields.text().defaultValue("free"),
|
|
2609
|
+
other: fields.text().defaultValue("off"),
|
|
2610
|
+
seats: fields.text().requiredWhen(f => f.field("plan").getValue() === "pro", "Pro required")
|
|
2611
|
+
})
|
|
2612
|
+
});
|
|
2613
|
+
form.field("seats").addRequiredWhen(f => f.field("other").getValue() === "on", "Other required");
|
|
2614
|
+
|
|
2615
|
+
// Neither truthy → not required.
|
|
2616
|
+
expect(form.field("seats").vm.required).toBe(false);
|
|
2617
|
+
|
|
2618
|
+
// Modifier callback truthy.
|
|
2619
|
+
form.field("other").setValue("on");
|
|
2620
|
+
await form.validate();
|
|
2621
|
+
expect(form.field("seats").vm.validation.message).toBe("Other required");
|
|
2622
|
+
});
|
|
2623
|
+
});
|
|
2624
|
+
describe("computed / computedUntilDirty (Phase 11)", () => {
|
|
2625
|
+
it("computed field exposes derived value reactively", () => {
|
|
2626
|
+
const form = createForm({
|
|
2627
|
+
fields: fields => ({
|
|
2628
|
+
first: fields.text().defaultValue("Ada"),
|
|
2629
|
+
last: fields.text().defaultValue("Lovelace"),
|
|
2630
|
+
full: fields.text().computed(f => `${f.field("first").getValue()} ${f.field("last").getValue()}`)
|
|
2631
|
+
})
|
|
2632
|
+
});
|
|
2633
|
+
expect(form.field("full").getValue()).toBe("Ada Lovelace");
|
|
2634
|
+
form.field("first").setValue("Grace");
|
|
2635
|
+
expect(form.field("full").getValue()).toBe("Grace Lovelace");
|
|
2636
|
+
});
|
|
2637
|
+
it("computed field stays editable but value remains derived", () => {
|
|
2638
|
+
const form = createForm({
|
|
2639
|
+
fields: fields => ({
|
|
2640
|
+
src: fields.text().defaultValue("A"),
|
|
2641
|
+
derived: fields.text().computed(f => f.field("src").getValue())
|
|
2642
|
+
})
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// Not auto-disabled.
|
|
2646
|
+
expect(form.field("derived").vm.disabled).toBe(false);
|
|
2647
|
+
|
|
2648
|
+
// User edit doesn't override the computed value.
|
|
2649
|
+
form.field("derived").vm.onChange("manual override");
|
|
2650
|
+
expect(form.field("derived").getValue()).toBe("A");
|
|
2651
|
+
});
|
|
2652
|
+
it("computedUntilDirty switches to manual after first UI edit", () => {
|
|
2653
|
+
const form = createForm({
|
|
2654
|
+
fields: fields => ({
|
|
2655
|
+
src: fields.text().defaultValue("A"),
|
|
2656
|
+
derived: fields.text().computedUntilDirty(f => `derived-${f.field("src").getValue()}`)
|
|
2657
|
+
})
|
|
2658
|
+
});
|
|
2659
|
+
expect(form.field("derived").getValue()).toBe("derived-A");
|
|
2660
|
+
form.field("derived").vm.onChange("manual");
|
|
2661
|
+
expect(form.field("derived").getValue()).toBe("manual");
|
|
2662
|
+
|
|
2663
|
+
// Source changes no longer overwrite manual edit.
|
|
2664
|
+
form.field("src").setValue("B");
|
|
2665
|
+
expect(form.field("derived").getValue()).toBe("manual");
|
|
2666
|
+
});
|
|
2667
|
+
it("computed field still participates in validation", async () => {
|
|
2668
|
+
const form = createForm({
|
|
2669
|
+
fields: fields => ({
|
|
2670
|
+
src: fields.text().defaultValue(""),
|
|
2671
|
+
derived: fields.text().required("Derived must not be empty").computed(f => f.field("src").getValue())
|
|
2672
|
+
})
|
|
2673
|
+
});
|
|
2674
|
+
const valid = await form.validate();
|
|
2675
|
+
expect(valid).toBe(false);
|
|
2676
|
+
expect(form.field("derived").vm.validation.message).toBe("Derived must not be empty");
|
|
2677
|
+
form.field("src").setValue("hello");
|
|
2678
|
+
expect(await form.validate()).toBe(true);
|
|
2679
|
+
});
|
|
2680
|
+
it("modifier setComputed converts a regular field into a computed one", () => {
|
|
2681
|
+
const form = createForm({
|
|
2682
|
+
fields: fields => ({
|
|
2683
|
+
src: fields.text().defaultValue("X"),
|
|
2684
|
+
derived: fields.text().defaultValue("initial")
|
|
2685
|
+
})
|
|
2686
|
+
});
|
|
2687
|
+
form.field("derived").setComputed(f => `from-${f.field("src").getValue()}`);
|
|
2688
|
+
expect(form.field("derived").getValue()).toBe("from-X");
|
|
2689
|
+
form.field("src").setValue("Y");
|
|
2690
|
+
expect(form.field("derived").getValue()).toBe("from-Y");
|
|
2691
|
+
});
|
|
2692
|
+
});
|
|
2693
|
+
describe('field("...").as("object").fields() (Phase 11)', () => {
|
|
2694
|
+
it("adds new children to an existing object field at runtime", () => {
|
|
2695
|
+
const form = createForm({
|
|
2696
|
+
fields: fields => ({
|
|
2697
|
+
profile: fields.object().fields(f => ({
|
|
2698
|
+
firstName: f.text().label("First")
|
|
2699
|
+
}))
|
|
2700
|
+
})
|
|
2701
|
+
});
|
|
2702
|
+
form.field("profile").as("object").fields(f => ({
|
|
2703
|
+
lastName: f.text().label("Last")
|
|
2704
|
+
}));
|
|
2705
|
+
form.field("profile.firstName").setValue("Ada");
|
|
2706
|
+
form.field("profile.lastName").setValue("Lovelace");
|
|
2707
|
+
expect(form.getData()).toEqual({
|
|
2708
|
+
profile: {
|
|
2709
|
+
firstName: "Ada",
|
|
2710
|
+
lastName: "Lovelace"
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
});
|
|
2714
|
+
it("replaces existing children when keys collide", () => {
|
|
2715
|
+
const form = createForm({
|
|
2716
|
+
fields: fields => ({
|
|
2717
|
+
profile: fields.object().fields(f => ({
|
|
2718
|
+
firstName: f.text().label("Old")
|
|
2719
|
+
}))
|
|
2720
|
+
})
|
|
2721
|
+
});
|
|
2722
|
+
form.field("profile").as("object").fields(f => ({
|
|
2723
|
+
firstName: f.text().label("New")
|
|
2724
|
+
}));
|
|
2725
|
+
const profile = form.field("profile").as("object");
|
|
2726
|
+
expect(profile.children.get("firstName")?.config.label).toBe("New");
|
|
2727
|
+
});
|
|
2728
|
+
it("removes children when factory returns undefined", () => {
|
|
2729
|
+
const form = createForm({
|
|
2730
|
+
fields: fields => ({
|
|
2731
|
+
profile: fields.object().fields(f => ({
|
|
2732
|
+
firstName: f.text(),
|
|
2733
|
+
lastName: f.text()
|
|
2734
|
+
}))
|
|
2735
|
+
})
|
|
2736
|
+
});
|
|
2737
|
+
form.field("profile").as("object").fields(() => ({
|
|
2738
|
+
lastName: undefined
|
|
2739
|
+
}));
|
|
2740
|
+
const profile = form.field("profile").as("object");
|
|
2741
|
+
expect(profile.children.has("lastName")).toBe(false);
|
|
2742
|
+
expect(profile.children.has("firstName")).toBe(true);
|
|
2743
|
+
});
|
|
2744
|
+
it("propagates added children to existing list items", () => {
|
|
2745
|
+
const form = createForm({
|
|
2746
|
+
fields: fields => ({
|
|
2747
|
+
contacts: fields.object().list().fields(f => ({
|
|
2748
|
+
name: f.text()
|
|
2749
|
+
}))
|
|
2750
|
+
})
|
|
2751
|
+
});
|
|
2752
|
+
const contacts = form.field("contacts").as("object");
|
|
2753
|
+
contacts.addItem({
|
|
2754
|
+
name: "Ada"
|
|
2755
|
+
});
|
|
2756
|
+
contacts.addItem({
|
|
2757
|
+
name: "Grace"
|
|
2758
|
+
});
|
|
2759
|
+
contacts.fields(f => ({
|
|
2760
|
+
email: f.text()
|
|
2761
|
+
}));
|
|
2762
|
+
|
|
2763
|
+
// Existing items now have the new child.
|
|
2764
|
+
expect(contacts.items[0].children.has("email")).toBe(true);
|
|
2765
|
+
expect(contacts.items[1].children.has("email")).toBe(true);
|
|
2766
|
+
|
|
2767
|
+
// Newly added items pick up the child too.
|
|
2768
|
+
contacts.addItem({
|
|
2769
|
+
name: "Linus",
|
|
2770
|
+
email: "linus@example.com"
|
|
2771
|
+
});
|
|
2772
|
+
expect(contacts.items[2].children.get("email")?.getValue()).toBe("linus@example.com");
|
|
2773
|
+
});
|
|
2774
|
+
it("throws when called on a templated object field", () => {
|
|
2775
|
+
const form = createForm({
|
|
2776
|
+
fields: fields => ({
|
|
2777
|
+
block: fields.object().template("a", t => {
|
|
2778
|
+
t.label("A").fields(f => ({
|
|
2779
|
+
x: f.text()
|
|
2780
|
+
}));
|
|
2781
|
+
})
|
|
2782
|
+
})
|
|
2783
|
+
});
|
|
2784
|
+
expect(() => {
|
|
2785
|
+
form.field("block").as("object").fields(f => ({
|
|
2786
|
+
y: f.text()
|
|
2787
|
+
}));
|
|
2788
|
+
}).toThrow(/templated/);
|
|
2789
|
+
});
|
|
2790
|
+
});
|
|
2791
|
+
describe("form.addRule() (Phase 11)", () => {
|
|
2792
|
+
it("runs a Zod schema against getData() and surfaces issues", async () => {
|
|
2793
|
+
const form = createForm({
|
|
2794
|
+
fields: fields => ({
|
|
2795
|
+
password: fields.text().defaultValue("a"),
|
|
2796
|
+
confirm: fields.text().defaultValue("b")
|
|
2797
|
+
})
|
|
2798
|
+
});
|
|
2799
|
+
form.addRule(z.object({
|
|
2800
|
+
password: z.string(),
|
|
2801
|
+
confirm: z.string()
|
|
2802
|
+
}).refine(d => d.password === d.confirm, {
|
|
2803
|
+
message: "Passwords must match",
|
|
2804
|
+
path: ["confirm"]
|
|
2805
|
+
}));
|
|
2806
|
+
const valid = await form.validate();
|
|
2807
|
+
expect(valid).toBe(false);
|
|
2808
|
+
expect(form.errors.some(e => e.message === "Passwords must match")).toBe(true);
|
|
2809
|
+
// Error surfaced on per-field validation when path matches.
|
|
2810
|
+
expect(form.field("confirm").vm.validation.isValid).toBe(false);
|
|
2811
|
+
expect(form.field("confirm").vm.validation.message).toBe("Passwords must match");
|
|
2812
|
+
});
|
|
2813
|
+
it("runs an imperative function and merges returned errors", async () => {
|
|
2814
|
+
const form = createForm({
|
|
2815
|
+
fields: fields => ({
|
|
2816
|
+
age: fields.text().defaultValue("17")
|
|
2817
|
+
})
|
|
2818
|
+
});
|
|
2819
|
+
form.addRule(f => {
|
|
2820
|
+
if (Number(f.field("age").getValue()) < 18) {
|
|
2821
|
+
return [{
|
|
2822
|
+
path: "age",
|
|
2823
|
+
message: "Must be 18+"
|
|
2824
|
+
}];
|
|
2825
|
+
}
|
|
2826
|
+
return [];
|
|
2827
|
+
});
|
|
2828
|
+
expect(await form.validate()).toBe(false);
|
|
2829
|
+
expect(form.field("age").vm.validation.message).toBe("Must be 18+");
|
|
2830
|
+
form.field("age").setValue("21");
|
|
2831
|
+
expect(await form.validate()).toBe(true);
|
|
2832
|
+
});
|
|
2833
|
+
it("supports async imperative rules", async () => {
|
|
2834
|
+
const form = createForm({
|
|
2835
|
+
fields: fields => ({
|
|
2836
|
+
name: fields.text().defaultValue("taken")
|
|
2837
|
+
})
|
|
2838
|
+
});
|
|
2839
|
+
form.addRule(async f => {
|
|
2840
|
+
await Promise.resolve();
|
|
2841
|
+
if (f.field("name").getValue() === "taken") {
|
|
2842
|
+
return [{
|
|
2843
|
+
path: "name",
|
|
2844
|
+
message: "Already taken"
|
|
2845
|
+
}];
|
|
2846
|
+
}
|
|
2847
|
+
return [];
|
|
2848
|
+
});
|
|
2849
|
+
expect(await form.validate()).toBe(false);
|
|
2850
|
+
expect(form.field("name").vm.validation.message).toBe("Already taken");
|
|
2851
|
+
});
|
|
2852
|
+
});
|
|
2853
|
+
describe("form.setLayout() (Phase 11)", () => {
|
|
2854
|
+
it("replaces the layout entirely", () => {
|
|
2855
|
+
const form = createForm({
|
|
2856
|
+
fields: fields => ({
|
|
2857
|
+
a: fields.text(),
|
|
2858
|
+
b: fields.text(),
|
|
2859
|
+
c: fields.text()
|
|
2860
|
+
}),
|
|
2861
|
+
layout: layout => [layout.row("a"), layout.row("b"), layout.row("c")]
|
|
2862
|
+
});
|
|
2863
|
+
form.setLayout(layout => [layout.row("c", "a")]);
|
|
2864
|
+
const layout = form.vm.layout;
|
|
2865
|
+
expect(layout).toHaveLength(1);
|
|
2866
|
+
const row = asRow(layout[0]);
|
|
2867
|
+
expect(row.fields.map(f => f.name)).toEqual(["c", "a"]);
|
|
2868
|
+
});
|
|
2869
|
+
it("emits orphan warnings for fields not in the new layout", () => {
|
|
2870
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2871
|
+
const form = createForm({
|
|
2872
|
+
fields: fields => ({
|
|
2873
|
+
a: fields.text(),
|
|
2874
|
+
b: fields.text()
|
|
2875
|
+
})
|
|
2876
|
+
});
|
|
2877
|
+
warn.mockClear();
|
|
2878
|
+
form.setLayout(layout => [layout.row("a")]);
|
|
2879
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Field "b" is not in the layout'));
|
|
2880
|
+
warn.mockRestore();
|
|
2881
|
+
});
|
|
2882
|
+
});
|
|
2883
|
+
describe("Phase 10: Advanced Validation", () => {
|
|
2884
|
+
describe("field.vm.validating", () => {
|
|
2885
|
+
it("should be false initially", () => {
|
|
2886
|
+
const form = createBasicForm();
|
|
2887
|
+
expect(form.field("title").vm.validating).toBe(false);
|
|
2888
|
+
});
|
|
2889
|
+
it("should be true while async schema validates", async () => {
|
|
2890
|
+
let resolveValidation;
|
|
2891
|
+
const form = createForm({
|
|
2892
|
+
fields: fields => ({
|
|
2893
|
+
email: fields.text().schema(z.string().refine(async () => {
|
|
2894
|
+
await new Promise(r => {
|
|
2895
|
+
resolveValidation = r;
|
|
2896
|
+
});
|
|
2897
|
+
return true;
|
|
2898
|
+
}))
|
|
2899
|
+
})
|
|
2900
|
+
});
|
|
2901
|
+
form.field("email").setValue("test@test.com");
|
|
2902
|
+
const promise = form.validate();
|
|
2903
|
+
// Allow microtask to enter the async refine
|
|
2904
|
+
await new Promise(r => setTimeout(r, 0));
|
|
2905
|
+
expect(form.field("email").vm.validating).toBe(true);
|
|
2906
|
+
resolveValidation();
|
|
2907
|
+
await promise;
|
|
2908
|
+
expect(form.field("email").vm.validating).toBe(false);
|
|
2909
|
+
});
|
|
2910
|
+
it("should be false after sync-only validation", async () => {
|
|
2911
|
+
const form = createForm({
|
|
2912
|
+
fields: fields => ({
|
|
2913
|
+
name: fields.text().required()
|
|
2914
|
+
})
|
|
2915
|
+
});
|
|
2916
|
+
form.field("name").setValue("hello");
|
|
2917
|
+
await form.validate();
|
|
2918
|
+
expect(form.field("name").vm.validating).toBe(false);
|
|
2919
|
+
});
|
|
2920
|
+
});
|
|
2921
|
+
describe("form.submitted", () => {
|
|
2922
|
+
it("should be false initially", () => {
|
|
2923
|
+
const form = createBasicForm();
|
|
2924
|
+
expect(form.submitted).toBe(false);
|
|
2925
|
+
});
|
|
2926
|
+
it("should be true after validate()", async () => {
|
|
2927
|
+
const form = createBasicForm();
|
|
2928
|
+
form.field("title").setValue("t");
|
|
2929
|
+
form.field("path").setValue("/p");
|
|
2930
|
+
await form.validate();
|
|
2931
|
+
expect(form.submitted).toBe(true);
|
|
2932
|
+
});
|
|
2933
|
+
it("should be true after failed validate()", async () => {
|
|
2934
|
+
const form = createBasicForm();
|
|
2935
|
+
await form.validate();
|
|
2936
|
+
expect(form.submitted).toBe(true);
|
|
2937
|
+
});
|
|
2938
|
+
it("should reset to false on setData()", async () => {
|
|
2939
|
+
const form = createBasicForm();
|
|
2940
|
+
form.field("title").setValue("t");
|
|
2941
|
+
form.field("path").setValue("/p");
|
|
2942
|
+
await form.validate();
|
|
2943
|
+
expect(form.submitted).toBe(true);
|
|
2944
|
+
form.setData({
|
|
2945
|
+
title: "new",
|
|
2946
|
+
path: "/new"
|
|
2947
|
+
});
|
|
2948
|
+
expect(form.submitted).toBe(false);
|
|
2949
|
+
});
|
|
2950
|
+
it("should reset to false on reset()", async () => {
|
|
2951
|
+
const form = createBasicForm();
|
|
2952
|
+
form.field("title").setValue("t");
|
|
2953
|
+
form.field("path").setValue("/p");
|
|
2954
|
+
await form.validate();
|
|
2955
|
+
expect(form.submitted).toBe(true);
|
|
2956
|
+
form.reset();
|
|
2957
|
+
expect(form.submitted).toBe(false);
|
|
2958
|
+
});
|
|
2959
|
+
});
|
|
2960
|
+
describe("validate-on-blur after submit", () => {
|
|
2961
|
+
it("should not validate on blur before first submit", async () => {
|
|
2962
|
+
const form = createForm({
|
|
2963
|
+
fields: fields => ({
|
|
2964
|
+
email: fields.text().required("Required")
|
|
2965
|
+
})
|
|
2966
|
+
});
|
|
2967
|
+
form.field("email").vm.onBlur();
|
|
2968
|
+
await new Promise(r => setTimeout(r, 0));
|
|
2969
|
+
expect(form.field("email").vm.validation.isValid).toBeNull();
|
|
2970
|
+
});
|
|
2971
|
+
it("should validate on blur after first submit", async () => {
|
|
2972
|
+
const form = createForm({
|
|
2973
|
+
fields: fields => ({
|
|
2974
|
+
email: fields.text().required("Required").schema(z.string().email("Invalid email"))
|
|
2975
|
+
})
|
|
2976
|
+
});
|
|
2977
|
+
form.field("email").setValue("bad");
|
|
2978
|
+
await form.submit();
|
|
2979
|
+
expect(form.field("email").vm.validation.isValid).toBe(false);
|
|
2980
|
+
|
|
2981
|
+
// Fix the value and blur — should re-validate
|
|
2982
|
+
form.field("email").setValue("valid@email.com");
|
|
2983
|
+
form.field("email").vm.onBlur();
|
|
2984
|
+
await new Promise(r => setTimeout(r, 0));
|
|
2985
|
+
expect(form.field("email").vm.validation.isValid).toBe(true);
|
|
2986
|
+
});
|
|
2987
|
+
it("should show error on blur for invalid value after submit", async () => {
|
|
2988
|
+
const form = createForm({
|
|
2989
|
+
fields: fields => ({
|
|
2990
|
+
name: fields.text().required("Name is required")
|
|
2991
|
+
})
|
|
2992
|
+
});
|
|
2993
|
+
form.field("name").setValue("hello");
|
|
2994
|
+
await form.submit();
|
|
2995
|
+
expect(form.field("name").vm.validation.isValid).toBe(true);
|
|
2996
|
+
|
|
2997
|
+
// Clear the value and blur — should fail required check
|
|
2998
|
+
form.field("name").setValue("");
|
|
2999
|
+
form.field("name").vm.onBlur();
|
|
3000
|
+
await new Promise(r => setTimeout(r, 0));
|
|
3001
|
+
expect(form.field("name").vm.validation.isValid).toBe(false);
|
|
3002
|
+
expect(form.field("name").vm.validation.message).toBe("Name is required");
|
|
3003
|
+
});
|
|
3004
|
+
});
|
|
3005
|
+
describe("validation memoization", () => {
|
|
3006
|
+
it("should not re-run schema on blur when value unchanged", async () => {
|
|
3007
|
+
const schemaSpy = vi.fn().mockReturnValue(true);
|
|
3008
|
+
const form = createForm({
|
|
3009
|
+
fields: fields => ({
|
|
3010
|
+
slug: fields.text().schema(z.string().refine(schemaSpy, "fail"))
|
|
3011
|
+
})
|
|
3012
|
+
});
|
|
3013
|
+
form.field("slug").setValue("hello");
|
|
3014
|
+
await form.submit();
|
|
3015
|
+
expect(schemaSpy).toHaveBeenCalledTimes(1);
|
|
3016
|
+
|
|
3017
|
+
// Blur with same value — should use cache
|
|
3018
|
+
form.field("slug").vm.onBlur();
|
|
3019
|
+
await new Promise(r => setTimeout(r, 0));
|
|
3020
|
+
expect(schemaSpy).toHaveBeenCalledTimes(1);
|
|
3021
|
+
});
|
|
3022
|
+
it("should re-run schema on blur when value changed", async () => {
|
|
3023
|
+
const schemaSpy = vi.fn().mockReturnValue(true);
|
|
3024
|
+
const form = createForm({
|
|
3025
|
+
fields: fields => ({
|
|
3026
|
+
slug: fields.text().schema(z.string().refine(schemaSpy, "fail"))
|
|
3027
|
+
})
|
|
3028
|
+
});
|
|
3029
|
+
form.field("slug").setValue("hello");
|
|
3030
|
+
await form.submit();
|
|
3031
|
+
expect(schemaSpy).toHaveBeenCalledTimes(1);
|
|
3032
|
+
|
|
3033
|
+
// Change value and blur — should re-validate
|
|
3034
|
+
form.field("slug").setValue("world");
|
|
3035
|
+
form.field("slug").vm.onBlur();
|
|
3036
|
+
await new Promise(r => setTimeout(r, 0));
|
|
3037
|
+
expect(schemaSpy).toHaveBeenCalledTimes(2);
|
|
3038
|
+
});
|
|
3039
|
+
it("should always re-run schema on form.validate()", async () => {
|
|
3040
|
+
const schemaSpy = vi.fn().mockReturnValue(true);
|
|
3041
|
+
const form = createForm({
|
|
3042
|
+
fields: fields => ({
|
|
3043
|
+
slug: fields.text().schema(z.string().refine(schemaSpy, "fail"))
|
|
3044
|
+
})
|
|
3045
|
+
});
|
|
3046
|
+
form.field("slug").setValue("hello");
|
|
3047
|
+
await form.validate();
|
|
3048
|
+
expect(schemaSpy).toHaveBeenCalledTimes(1);
|
|
3049
|
+
|
|
3050
|
+
// Same value, but form.validate() forces re-validation
|
|
3051
|
+
await form.validate();
|
|
3052
|
+
expect(schemaSpy).toHaveBeenCalledTimes(2);
|
|
3053
|
+
});
|
|
3054
|
+
it("should clear cache on resetValidation()", async () => {
|
|
3055
|
+
const schemaSpy = vi.fn().mockReturnValue(true);
|
|
3056
|
+
const form = createForm({
|
|
3057
|
+
fields: fields => ({
|
|
3058
|
+
slug: fields.text().schema(z.string().refine(schemaSpy, "fail"))
|
|
3059
|
+
})
|
|
3060
|
+
});
|
|
3061
|
+
form.field("slug").setValue("hello");
|
|
3062
|
+
await form.submit();
|
|
3063
|
+
expect(schemaSpy).toHaveBeenCalledTimes(1);
|
|
3064
|
+
|
|
3065
|
+
// Reset validation — clears cache
|
|
3066
|
+
form.field("slug").resetValidation();
|
|
3067
|
+
|
|
3068
|
+
// Blur with same value — should re-validate (cache cleared)
|
|
3069
|
+
form.field("slug").vm.onBlur();
|
|
3070
|
+
await new Promise(r => setTimeout(r, 0));
|
|
3071
|
+
expect(schemaSpy).toHaveBeenCalledTimes(2);
|
|
3072
|
+
});
|
|
3073
|
+
});
|
|
3074
|
+
describe("async validation with z.refine", () => {
|
|
3075
|
+
it("should validate async refine on submit", async () => {
|
|
3076
|
+
const form = createForm({
|
|
3077
|
+
fields: fields => ({
|
|
3078
|
+
slug: fields.text().schema(z.string().refine(async value => {
|
|
3079
|
+
return value !== "taken";
|
|
3080
|
+
}, "This slug is already taken"))
|
|
3081
|
+
})
|
|
3082
|
+
});
|
|
3083
|
+
form.field("slug").setValue("taken");
|
|
3084
|
+
const result = await form.submit();
|
|
3085
|
+
expect(result).toBe(false);
|
|
3086
|
+
expect(form.field("slug").vm.validation.isValid).toBe(false);
|
|
3087
|
+
expect(form.field("slug").vm.validation.message).toBe("This slug is already taken");
|
|
3088
|
+
});
|
|
3089
|
+
it("should pass async refine with valid value", async () => {
|
|
3090
|
+
const form = createForm({
|
|
3091
|
+
fields: fields => ({
|
|
3092
|
+
slug: fields.text().schema(z.string().refine(async value => {
|
|
3093
|
+
return value !== "taken";
|
|
3094
|
+
}, "This slug is already taken"))
|
|
3095
|
+
})
|
|
3096
|
+
});
|
|
3097
|
+
form.field("slug").setValue("available");
|
|
3098
|
+
const result = await form.submit();
|
|
3099
|
+
expect(result).toEqual({
|
|
3100
|
+
slug: "available"
|
|
3101
|
+
});
|
|
3102
|
+
expect(form.field("slug").vm.validation.isValid).toBe(true);
|
|
3103
|
+
});
|
|
3104
|
+
});
|
|
3105
|
+
});
|
|
3106
|
+
describe("normalizeValue", () => {
|
|
3107
|
+
it("should coerce string to number on setValue for number fields", () => {
|
|
3108
|
+
const form = createForm({
|
|
3109
|
+
fields: fields => ({
|
|
3110
|
+
count: fields.number().label("Count")
|
|
3111
|
+
})
|
|
3112
|
+
});
|
|
3113
|
+
form.field("count").setValue("42");
|
|
3114
|
+
expect(form.field("count").getValue()).toBe(42);
|
|
3115
|
+
});
|
|
3116
|
+
it("should normalize invalid values to null for number fields", () => {
|
|
3117
|
+
const form = createForm({
|
|
3118
|
+
fields: fields => ({
|
|
3119
|
+
count: fields.number().label("Count")
|
|
3120
|
+
})
|
|
3121
|
+
});
|
|
3122
|
+
form.field("count").setValue("");
|
|
3123
|
+
expect(form.field("count").getValue()).toBe(null);
|
|
3124
|
+
form.field("count").setValue(null);
|
|
3125
|
+
expect(form.field("count").getValue()).toBe(null);
|
|
3126
|
+
form.field("count").setValue("abc");
|
|
3127
|
+
expect(form.field("count").getValue()).toBe(null);
|
|
3128
|
+
});
|
|
3129
|
+
it("should store number for number field with options", () => {
|
|
3130
|
+
const form = createForm({
|
|
3131
|
+
fields: fields => ({
|
|
3132
|
+
tier: fields.number().label("Tier").options([{
|
|
3133
|
+
label: "Tier 1",
|
|
3134
|
+
value: 100
|
|
3135
|
+
}, {
|
|
3136
|
+
label: "Tier 2",
|
|
3137
|
+
value: 200
|
|
3138
|
+
}])
|
|
3139
|
+
})
|
|
3140
|
+
});
|
|
3141
|
+
form.field("tier").setValue("100");
|
|
3142
|
+
expect(form.field("tier").getValue()).toBe(100);
|
|
3143
|
+
expect(typeof form.field("tier").getValue()).toBe("number");
|
|
3144
|
+
});
|
|
3145
|
+
it("should coerce to boolean on setValue for boolean fields", () => {
|
|
3146
|
+
const form = createForm({
|
|
3147
|
+
fields: fields => ({
|
|
3148
|
+
active: fields.boolean().label("Active")
|
|
3149
|
+
})
|
|
3150
|
+
});
|
|
3151
|
+
form.field("active").setValue(1);
|
|
3152
|
+
expect(form.field("active").getValue()).toBe(true);
|
|
3153
|
+
form.field("active").setValue(0);
|
|
3154
|
+
expect(form.field("active").getValue()).toBe(false);
|
|
3155
|
+
});
|
|
3156
|
+
it("should apply normalizeValue on setData (via setValueSilent)", () => {
|
|
3157
|
+
const form = createForm({
|
|
3158
|
+
fields: fields => ({
|
|
3159
|
+
count: fields.number().label("Count")
|
|
3160
|
+
})
|
|
3161
|
+
});
|
|
3162
|
+
form.setData({
|
|
3163
|
+
count: "42"
|
|
3164
|
+
});
|
|
3165
|
+
expect(form.field("count").getValue()).toBe(42);
|
|
3166
|
+
expect(typeof form.field("count").getValue()).toBe("number");
|
|
3167
|
+
});
|
|
3168
|
+
it("should not alter text field values", () => {
|
|
3169
|
+
const form = createForm({
|
|
3170
|
+
fields: fields => ({
|
|
3171
|
+
name: fields.text().label("Name")
|
|
3172
|
+
})
|
|
3173
|
+
});
|
|
3174
|
+
form.field("name").setValue("hello");
|
|
3175
|
+
expect(form.field("name").getValue()).toBe("hello");
|
|
3176
|
+
form.field("name").setValue(42);
|
|
3177
|
+
expect(form.field("name").getValue()).toBe(42);
|
|
3178
|
+
});
|
|
3179
|
+
it("should run normalizeValue before beforeChange", () => {
|
|
3180
|
+
const log = [];
|
|
3181
|
+
const form = createForm({
|
|
3182
|
+
fields: fields => ({
|
|
3183
|
+
count: fields.number().label("Count").beforeChange(value => {
|
|
3184
|
+
log.push(value);
|
|
3185
|
+
return value;
|
|
3186
|
+
})
|
|
3187
|
+
})
|
|
3188
|
+
});
|
|
3189
|
+
form.field("count").setValue("7");
|
|
3190
|
+
expect(log).toEqual([7]);
|
|
3191
|
+
});
|
|
3192
|
+
});
|
|
3193
|
+
describe("focusField", () => {
|
|
3194
|
+
it("should set focusRequested on the target field", () => {
|
|
3195
|
+
const form = createForm({
|
|
3196
|
+
fields: fields => ({
|
|
3197
|
+
title: fields.text().label("Title"),
|
|
3198
|
+
path: fields.text().label("Path")
|
|
3199
|
+
})
|
|
3200
|
+
});
|
|
3201
|
+
form.focusField("title");
|
|
3202
|
+
expect(form.field("title").vm.focusRequested).toBe(true);
|
|
3203
|
+
expect(form.field("path").vm.focusRequested).toBe(false);
|
|
3204
|
+
});
|
|
3205
|
+
it("should activate the correct tab", () => {
|
|
3206
|
+
const form = createForm({
|
|
3207
|
+
fields: fields => ({
|
|
3208
|
+
title: fields.text().label("Title"),
|
|
3209
|
+
slug: fields.text().label("Slug")
|
|
3210
|
+
}),
|
|
3211
|
+
layout: layout => [layout.tabs("mainTabs").tab("general", tab => {
|
|
3212
|
+
tab.label("General").layout(l => [l.row("title")]);
|
|
3213
|
+
}).tab("seo", tab => {
|
|
3214
|
+
tab.label("SEO").layout(l => [l.row("slug")]);
|
|
3215
|
+
})]
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
// Initially the first tab is active
|
|
3219
|
+
expect(form.vm.layout[0].activeTabId).toBe("general");
|
|
3220
|
+
form.focusField("slug");
|
|
3221
|
+
expect(form.field("slug").vm.focusRequested).toBe(true);
|
|
3222
|
+
expect(form.vm.layout[0].activeTabId).toBe("seo");
|
|
3223
|
+
});
|
|
3224
|
+
it("should activate nested tabs inside object fields", () => {
|
|
3225
|
+
const form = createForm({
|
|
3226
|
+
fields: fields => ({
|
|
3227
|
+
page: fields.object().fields(f => ({
|
|
3228
|
+
title: f.text().label("Title"),
|
|
3229
|
+
metaTitle: f.text().label("Meta Title")
|
|
3230
|
+
}))
|
|
3231
|
+
}),
|
|
3232
|
+
layout: layout => [layout.object("page", l => [l.tabs("pageTabs").tab("general", tab => {
|
|
3233
|
+
tab.label("General").layout(l => [l.row("title")]);
|
|
3234
|
+
}).tab("seo", tab => {
|
|
3235
|
+
tab.label("SEO").layout(l => [l.row("metaTitle")]);
|
|
3236
|
+
})])]
|
|
3237
|
+
});
|
|
3238
|
+
form.focusField("page.metaTitle");
|
|
3239
|
+
expect(form.field("page.metaTitle").vm.focusRequested).toBe(true);
|
|
3240
|
+
});
|
|
3241
|
+
it("should clear previous focus when focusing a new field", () => {
|
|
3242
|
+
const form = createForm({
|
|
3243
|
+
fields: fields => ({
|
|
3244
|
+
title: fields.text().label("Title"),
|
|
3245
|
+
path: fields.text().label("Path")
|
|
3246
|
+
})
|
|
3247
|
+
});
|
|
3248
|
+
form.focusField("title");
|
|
3249
|
+
expect(form.field("title").vm.focusRequested).toBe(true);
|
|
3250
|
+
form.focusField("path");
|
|
3251
|
+
expect(form.field("title").vm.focusRequested).toBe(false);
|
|
3252
|
+
expect(form.field("path").vm.focusRequested).toBe(true);
|
|
3253
|
+
});
|
|
3254
|
+
it("should allow renderer to clear focus via clearFocusRequest", () => {
|
|
3255
|
+
const form = createForm({
|
|
3256
|
+
fields: fields => ({
|
|
3257
|
+
title: fields.text().label("Title")
|
|
3258
|
+
})
|
|
3259
|
+
});
|
|
3260
|
+
form.focusField("title");
|
|
3261
|
+
expect(form.field("title").vm.focusRequested).toBe(true);
|
|
3262
|
+
form.field("title").vm.clearFocusRequest();
|
|
3263
|
+
expect(form.field("title").vm.focusRequested).toBe(false);
|
|
3264
|
+
});
|
|
3265
|
+
it("should not throw on unknown field", () => {
|
|
3266
|
+
const form = createForm({
|
|
3267
|
+
fields: fields => ({
|
|
3268
|
+
title: fields.text().label("Title")
|
|
3269
|
+
})
|
|
3270
|
+
});
|
|
3271
|
+
expect(() => form.focusField("nonexistent")).not.toThrow();
|
|
3272
|
+
});
|
|
3273
|
+
it("should propagate qualifiedName through nested objects", () => {
|
|
3274
|
+
const form = createForm({
|
|
3275
|
+
fields: fields => ({
|
|
3276
|
+
page: fields.object().fields(f => ({
|
|
3277
|
+
seo: f.object().fields(g => ({
|
|
3278
|
+
metaTitle: g.text().label("Meta Title")
|
|
3279
|
+
}))
|
|
3280
|
+
}))
|
|
3281
|
+
})
|
|
3282
|
+
});
|
|
3283
|
+
const meta = form.field("page.seo.metaTitle");
|
|
3284
|
+
expect(meta.qualifiedName).toBe("page.seo.metaTitle");
|
|
3285
|
+
});
|
|
3286
|
+
it("field.focus() should delegate to form.focusField", () => {
|
|
3287
|
+
const form = createForm({
|
|
3288
|
+
fields: fields => ({
|
|
3289
|
+
title: fields.text().label("Title"),
|
|
3290
|
+
path: fields.text().label("Path")
|
|
3291
|
+
}),
|
|
3292
|
+
layout: layout => [layout.tabs("tabs").tab("t1", tab => {
|
|
3293
|
+
tab.label("T1").layout(l => [l.row("title")]);
|
|
3294
|
+
}).tab("t2", tab => {
|
|
3295
|
+
tab.label("T2").layout(l => [l.row("path")]);
|
|
3296
|
+
})]
|
|
3297
|
+
});
|
|
3298
|
+
form.field("path").focus();
|
|
3299
|
+
expect(form.field("path").vm.focusRequested).toBe(true);
|
|
3300
|
+
expect(form.vm.layout[0].activeTabId).toBe("t2");
|
|
3301
|
+
});
|
|
3302
|
+
});
|
|
3303
|
+
describe("primitive list addItem / removeItem", () => {
|
|
3304
|
+
it("addItem appends to empty list", () => {
|
|
3305
|
+
const form = createForm({
|
|
3306
|
+
fields: f => ({
|
|
3307
|
+
tags: f.text().list()
|
|
3308
|
+
}),
|
|
3309
|
+
layout: l => [l.row("tags")]
|
|
3310
|
+
});
|
|
3311
|
+
const vm = form.field("tags").vm;
|
|
3312
|
+
expect(vm.value).toEqual([]);
|
|
3313
|
+
vm.addItem("hello");
|
|
3314
|
+
expect(form.field("tags").vm.value).toEqual(["hello"]);
|
|
3315
|
+
});
|
|
3316
|
+
it("addItem appends with default null when no value given", () => {
|
|
3317
|
+
const form = createForm({
|
|
3318
|
+
fields: f => ({
|
|
3319
|
+
tags: f.text().list()
|
|
3320
|
+
}),
|
|
3321
|
+
layout: l => [l.row("tags")]
|
|
3322
|
+
});
|
|
3323
|
+
form.field("tags").vm.addItem();
|
|
3324
|
+
expect(form.field("tags").vm.value).toEqual([null]);
|
|
3325
|
+
});
|
|
3326
|
+
it("addItem appends to existing values", () => {
|
|
3327
|
+
const form = createForm({
|
|
3328
|
+
fields: f => ({
|
|
3329
|
+
tags: f.text().list().defaultValue(["a", "b"])
|
|
3330
|
+
}),
|
|
3331
|
+
layout: l => [l.row("tags")]
|
|
3332
|
+
});
|
|
3333
|
+
form.field("tags").vm.addItem("c");
|
|
3334
|
+
expect(form.field("tags").vm.value).toEqual(["a", "b", "c"]);
|
|
3335
|
+
});
|
|
3336
|
+
it("removeItem removes at index", () => {
|
|
3337
|
+
const form = createForm({
|
|
3338
|
+
fields: f => ({
|
|
3339
|
+
tags: f.text().list().defaultValue(["a", "b", "c"])
|
|
3340
|
+
}),
|
|
3341
|
+
layout: l => [l.row("tags")]
|
|
3342
|
+
});
|
|
3343
|
+
form.field("tags").vm.removeItem(1);
|
|
3344
|
+
expect(form.field("tags").vm.value).toEqual(["a", "c"]);
|
|
3345
|
+
});
|
|
3346
|
+
it("removeItem from beginning preserves order", () => {
|
|
3347
|
+
const form = createForm({
|
|
3348
|
+
fields: f => ({
|
|
3349
|
+
tags: f.text().list().defaultValue(["a", "b", "c"])
|
|
3350
|
+
}),
|
|
3351
|
+
layout: l => [l.row("tags")]
|
|
3352
|
+
});
|
|
3353
|
+
form.field("tags").vm.removeItem(0);
|
|
3354
|
+
expect(form.field("tags").vm.value).toEqual(["b", "c"]);
|
|
3355
|
+
});
|
|
3356
|
+
it("operations go through beforeChange pipeline", () => {
|
|
3357
|
+
const log = [];
|
|
3358
|
+
const form = createForm({
|
|
3359
|
+
fields: f => ({
|
|
3360
|
+
tags: f.text().list().defaultValue(["a"]).beforeChange((value, _form) => {
|
|
3361
|
+
log.push(value);
|
|
3362
|
+
return value;
|
|
3363
|
+
})
|
|
3364
|
+
}),
|
|
3365
|
+
layout: l => [l.row("tags")]
|
|
3366
|
+
});
|
|
3367
|
+
form.field("tags").vm.addItem("b");
|
|
3368
|
+
form.field("tags").vm.removeItem(0);
|
|
3369
|
+
expect(log).toEqual([["a", "b"], ["b"]]);
|
|
3370
|
+
});
|
|
3371
|
+
});
|
|
1521
3372
|
});
|
|
1522
3373
|
|
|
1523
3374
|
//# sourceMappingURL=FormModel.test.js.map
|