@webiny/app-admin 6.3.0-beta.1 → 6.3.0-beta.3

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.
Files changed (234) hide show
  1. package/base/Base/DefaultFieldRenderers.js +69 -5
  2. package/base/Base/DefaultFieldRenderers.js.map +1 -1
  3. package/base/Base/DefaultLayoutRenderers.js +5 -1
  4. package/base/Base/DefaultLayoutRenderers.js.map +1 -1
  5. package/base/Base/FieldRenderers/CheckboxesRenderer.d.ts +13 -0
  6. package/base/Base/FieldRenderers/CheckboxesRenderer.js +28 -0
  7. package/base/Base/FieldRenderers/CheckboxesRenderer.js.map +1 -0
  8. package/base/Base/FieldRenderers/CodeEditorRenderer.d.ts +15 -0
  9. package/base/Base/FieldRenderers/CodeEditorRenderer.js +17 -0
  10. package/base/Base/FieldRenderers/CodeEditorRenderer.js.map +1 -0
  11. package/base/Base/FieldRenderers/DateTimeInputsRenderer.d.ts +17 -0
  12. package/base/Base/FieldRenderers/DateTimeInputsRenderer.js +66 -0
  13. package/base/Base/FieldRenderers/DateTimeInputsRenderer.js.map +1 -0
  14. package/base/Base/FieldRenderers/DateTimeRenderer.d.ts +21 -0
  15. package/base/Base/FieldRenderers/DateTimeRenderer.js +46 -0
  16. package/base/Base/FieldRenderers/DateTimeRenderer.js.map +1 -0
  17. package/base/Base/FieldRenderers/FilePickerRenderer.d.ts +12 -0
  18. package/base/Base/FieldRenderers/FilePickerRenderer.js +47 -0
  19. package/base/Base/FieldRenderers/FilePickerRenderer.js.map +1 -0
  20. package/base/Base/FieldRenderers/FileUrlPickerRenderer.d.ts +12 -0
  21. package/base/Base/FieldRenderers/FileUrlPickerRenderer.js +25 -0
  22. package/base/Base/FieldRenderers/FileUrlPickerRenderer.js.map +1 -0
  23. package/base/Base/FieldRenderers/HiddenRenderer.d.ts +12 -0
  24. package/base/Base/FieldRenderers/HiddenRenderer.js +5 -0
  25. package/base/Base/FieldRenderers/HiddenRenderer.js.map +1 -0
  26. package/base/Base/FieldRenderers/HorizontalTabsRenderer.d.ts +5 -0
  27. package/base/Base/FieldRenderers/HorizontalTabsRenderer.js +27 -0
  28. package/base/Base/FieldRenderers/HorizontalTabsRenderer.js.map +1 -0
  29. package/base/Base/FieldRenderers/InputRenderer.d.ts +4 -7
  30. package/base/Base/FieldRenderers/InputRenderer.js +2 -2
  31. package/base/Base/FieldRenderers/InputRenderer.js.map +1 -1
  32. package/base/Base/FieldRenderers/NumberInputRenderer.d.ts +12 -0
  33. package/base/Base/FieldRenderers/NumberInputRenderer.js +23 -0
  34. package/base/Base/FieldRenderers/NumberInputRenderer.js.map +1 -0
  35. package/base/Base/FieldRenderers/NumberInputsRenderer.d.ts +14 -0
  36. package/base/Base/FieldRenderers/NumberInputsRenderer.js +49 -0
  37. package/base/Base/FieldRenderers/NumberInputsRenderer.js.map +1 -0
  38. package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.d.ts +14 -0
  39. package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.js +20 -0
  40. package/base/Base/FieldRenderers/ObjectRenderer/DynamicZoneRenderer.js.map +1 -0
  41. package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.d.ts +14 -0
  42. package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.js +65 -0
  43. package/base/Base/FieldRenderers/ObjectRenderer/KeyValueTagsRenderer.js.map +1 -0
  44. package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.d.ts +10 -0
  45. package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.js +109 -0
  46. package/base/Base/FieldRenderers/ObjectRenderer/MultiValueDynamicZone.js.map +1 -0
  47. package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.d.ts +17 -0
  48. package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.js +55 -0
  49. package/base/Base/FieldRenderers/ObjectRenderer/ObjectAccordionMultipleRenderer.js.map +1 -0
  50. package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.d.ts +7 -3
  51. package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.js +15 -19
  52. package/base/Base/FieldRenderers/ObjectRenderer/ObjectFieldComponents.js.map +1 -1
  53. package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.d.ts +5 -8
  54. package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.js +7 -50
  55. package/base/Base/FieldRenderers/ObjectRenderer/ObjectRenderer.js.map +1 -1
  56. package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.d.ts +10 -0
  57. package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.js +64 -0
  58. package/base/Base/FieldRenderers/ObjectRenderer/SingleValueDynamicZone.js.map +1 -0
  59. package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.d.ts +10 -0
  60. package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.js +85 -0
  61. package/base/Base/FieldRenderers/ObjectRenderer/TemplatePicker.js.map +1 -0
  62. package/base/Base/FieldRenderers/PassthroughRenderer.d.ts +3 -6
  63. package/base/Base/FieldRenderers/PassthroughRenderer.js +9 -23
  64. package/base/Base/FieldRenderers/PassthroughRenderer.js.map +1 -1
  65. package/base/Base/FieldRenderers/RadioButtonsRenderer.d.ts +13 -0
  66. package/base/Base/FieldRenderers/RadioButtonsRenderer.js +27 -0
  67. package/base/Base/FieldRenderers/RadioButtonsRenderer.js.map +1 -0
  68. package/base/Base/FieldRenderers/SelectRenderer.d.ts +6 -8
  69. package/base/Base/FieldRenderers/SelectRenderer.js +8 -5
  70. package/base/Base/FieldRenderers/SelectRenderer.js.map +1 -1
  71. package/base/Base/FieldRenderers/SwitchRenderer.d.ts +12 -0
  72. package/base/Base/FieldRenderers/SwitchRenderer.js +19 -0
  73. package/base/Base/FieldRenderers/SwitchRenderer.js.map +1 -0
  74. package/base/Base/FieldRenderers/TagsRenderer.d.ts +12 -0
  75. package/base/Base/FieldRenderers/TagsRenderer.js +21 -0
  76. package/base/Base/FieldRenderers/TagsRenderer.js.map +1 -0
  77. package/base/Base/FieldRenderers/TextInputsRenderer.d.ts +14 -0
  78. package/base/Base/FieldRenderers/TextInputsRenderer.js +48 -0
  79. package/base/Base/FieldRenderers/TextInputsRenderer.js.map +1 -0
  80. package/base/Base/FieldRenderers/TextareaRenderer.d.ts +3 -6
  81. package/base/Base/FieldRenderers/TextareaRenderer.js +3 -4
  82. package/base/Base/FieldRenderers/TextareaRenderer.js.map +1 -1
  83. package/base/Base/FieldRenderers/TextareasRenderer.d.ts +14 -0
  84. package/base/Base/FieldRenderers/TextareasRenderer.js +51 -0
  85. package/base/Base/FieldRenderers/TextareasRenderer.js.map +1 -0
  86. package/base/Base/FieldRenderers/VerticalTabsRenderer.js +2 -2
  87. package/base/Base/FieldRenderers/VerticalTabsRenderer.js.map +1 -1
  88. package/base/Base/Menus.js +5 -64
  89. package/base/Base/Menus.js.map +1 -1
  90. package/base/Base/RoutesConfig.js +6 -0
  91. package/base/Base/RoutesConfig.js.map +1 -1
  92. package/exports/admin/build-params.d.ts +2 -0
  93. package/exports/admin/build-params.js +3 -0
  94. package/exports/admin/build-params.js.map +1 -1
  95. package/exports/admin/form.d.ts +5 -0
  96. package/exports/admin/form.js +8 -0
  97. package/exports/admin/form.js.map +1 -1
  98. package/exports/admin/ui.d.ts +1 -0
  99. package/exports/admin/ui.js +1 -0
  100. package/exports/admin/ui.js.map +1 -1
  101. package/exports/admin.d.ts +3 -1
  102. package/exports/admin.js +3 -1
  103. package/exports/admin.js.map +1 -1
  104. package/features/formModel/ConditionRuleEvaluator.d.ts +9 -0
  105. package/features/formModel/ConditionRuleEvaluator.js +56 -0
  106. package/features/formModel/ConditionRuleEvaluator.js.map +1 -0
  107. package/features/formModel/Field.d.ts +50 -4
  108. package/features/formModel/Field.js +254 -35
  109. package/features/formModel/Field.js.map +1 -1
  110. package/features/formModel/FieldBuilder.d.ts +17 -35
  111. package/features/formModel/FieldBuilder.js +63 -100
  112. package/features/formModel/FieldBuilder.js.map +1 -1
  113. package/features/formModel/FieldBuilder.test.js +127 -13
  114. package/features/formModel/FieldBuilder.test.js.map +1 -1
  115. package/features/formModel/FieldBuilderRegistry.d.ts +4 -0
  116. package/features/formModel/FieldBuilderRegistry.js +31 -0
  117. package/features/formModel/FieldBuilderRegistry.js.map +1 -0
  118. package/features/formModel/FocusManager.d.ts +14 -0
  119. package/features/formModel/FocusManager.js +109 -0
  120. package/features/formModel/FocusManager.js.map +1 -0
  121. package/features/formModel/FormModel.d.ts +27 -31
  122. package/features/formModel/FormModel.js +210 -403
  123. package/features/formModel/FormModel.js.map +1 -1
  124. package/features/formModel/FormModel.test.js +2044 -193
  125. package/features/formModel/FormModel.test.js.map +1 -1
  126. package/features/formModel/FormModelFactory.d.ts +4 -2
  127. package/features/formModel/FormModelFactory.js +13 -3
  128. package/features/formModel/FormModelFactory.js.map +1 -1
  129. package/features/formModel/FormView.d.ts +2 -0
  130. package/features/formModel/FormView.js +44 -37
  131. package/features/formModel/FormView.js.map +1 -1
  132. package/features/formModel/LayoutBuilderFactory.d.ts +61 -0
  133. package/features/formModel/LayoutBuilderFactory.js +386 -0
  134. package/features/formModel/LayoutBuilderFactory.js.map +1 -0
  135. package/features/formModel/LayoutMutator.d.ts +11 -0
  136. package/features/formModel/LayoutMutator.js +136 -0
  137. package/features/formModel/LayoutMutator.js.map +1 -0
  138. package/features/formModel/LayoutResolver.d.ts +26 -0
  139. package/features/formModel/LayoutResolver.js +239 -0
  140. package/features/formModel/LayoutResolver.js.map +1 -0
  141. package/features/formModel/ObjectField.d.ts +55 -4
  142. package/features/formModel/ObjectField.js +499 -82
  143. package/features/formModel/ObjectField.js.map +1 -1
  144. package/features/formModel/Rules.test.d.ts +1 -0
  145. package/features/formModel/Rules.test.js +289 -0
  146. package/features/formModel/Rules.test.js.map +1 -0
  147. package/features/formModel/abstractions.d.ts +402 -52
  148. package/features/formModel/abstractions.js +55 -0
  149. package/features/formModel/abstractions.js.map +1 -1
  150. package/features/formModel/createFieldRenderer.d.ts +20 -0
  151. package/features/formModel/createFieldRenderer.js +15 -0
  152. package/features/formModel/createFieldRenderer.js.map +1 -0
  153. package/features/formModel/demo/FieldRenderersDemoPresenter.d.ts +18 -0
  154. package/features/formModel/demo/FieldRenderersDemoPresenter.js +225 -0
  155. package/features/formModel/demo/FieldRenderersDemoPresenter.js.map +1 -0
  156. package/features/formModel/demo/FormModelDemo.d.ts +4 -0
  157. package/features/formModel/demo/FormModelDemo.js +230 -0
  158. package/features/formModel/demo/FormModelDemo.js.map +1 -0
  159. package/features/formModel/demo/FormModelDemoPresenter.d.ts +22 -0
  160. package/features/formModel/demo/FormModelDemoPresenter.js +121 -0
  161. package/features/formModel/demo/FormModelDemoPresenter.js.map +1 -0
  162. package/features/formModel/demo/FormModelPhase11Presenter.d.ts +25 -0
  163. package/features/formModel/demo/FormModelPhase11Presenter.js +104 -0
  164. package/features/formModel/demo/FormModelPhase11Presenter.js.map +1 -0
  165. package/features/formModel/demo/FormModelPhase8c1Presenter.d.ts +23 -0
  166. package/features/formModel/demo/FormModelPhase8c1Presenter.js +62 -0
  167. package/features/formModel/demo/FormModelPhase8c1Presenter.js.map +1 -0
  168. package/features/formModel/feature.js +12 -0
  169. package/features/formModel/feature.js.map +1 -1
  170. package/features/formModel/fieldTypes/BooleanFieldType.d.ts +19 -0
  171. package/features/formModel/fieldTypes/BooleanFieldType.js +23 -0
  172. package/features/formModel/fieldTypes/BooleanFieldType.js.map +1 -0
  173. package/features/formModel/fieldTypes/DateTimeFieldType.d.ts +173 -0
  174. package/features/formModel/fieldTypes/DateTimeFieldType.js +369 -0
  175. package/features/formModel/fieldTypes/DateTimeFieldType.js.map +1 -0
  176. package/features/formModel/fieldTypes/FileFieldType.d.ts +18 -0
  177. package/features/formModel/fieldTypes/FileFieldType.js +20 -0
  178. package/features/formModel/fieldTypes/FileFieldType.js.map +1 -0
  179. package/features/formModel/fieldTypes/FileUrlFieldType.d.ts +18 -0
  180. package/features/formModel/fieldTypes/FileUrlFieldType.js +20 -0
  181. package/features/formModel/fieldTypes/FileUrlFieldType.js.map +1 -0
  182. package/features/formModel/fieldTypes/NumberFieldType.d.ts +19 -0
  183. package/features/formModel/fieldTypes/NumberFieldType.js +27 -0
  184. package/features/formModel/fieldTypes/NumberFieldType.js.map +1 -0
  185. package/features/formModel/fieldTypes/ObjectFieldType.d.ts +34 -0
  186. package/features/formModel/fieldTypes/ObjectFieldType.js +109 -0
  187. package/features/formModel/fieldTypes/ObjectFieldType.js.map +1 -0
  188. package/features/formModel/fieldTypes/TextFieldType.d.ts +18 -0
  189. package/features/formModel/fieldTypes/TextFieldType.js +20 -0
  190. package/features/formModel/fieldTypes/TextFieldType.js.map +1 -0
  191. package/features/formModel/fieldTypes/index.d.ts +7 -0
  192. package/features/formModel/fieldTypes/index.js +9 -0
  193. package/features/formModel/fieldTypes/index.js.map +1 -0
  194. package/features/formModel/index.d.ts +13 -4
  195. package/features/formModel/index.js +21 -2
  196. package/features/formModel/index.js.map +1 -1
  197. package/features/formModel/renderers.d.ts +15 -1
  198. package/features/formModel/renderers.js +15 -1
  199. package/features/formModel/renderers.js.map +1 -1
  200. package/features/tools/LexicalContext/LexicalContext.d.ts +14 -0
  201. package/features/tools/LexicalContext/LexicalContext.js +22 -0
  202. package/features/tools/LexicalContext/LexicalContext.js.map +1 -0
  203. package/features/tools/LexicalContext/abstractions.d.ts +11 -0
  204. package/features/tools/LexicalContext/abstractions.js +4 -0
  205. package/features/tools/LexicalContext/abstractions.js.map +1 -0
  206. package/features/tools/LexicalContext/index.d.ts +2 -0
  207. package/features/tools/LexicalContext/index.js +3 -0
  208. package/features/tools/LexicalContext/index.js.map +1 -0
  209. package/features/tools/feature.js +2 -0
  210. package/features/tools/feature.js.map +1 -1
  211. package/features/tools/index.d.ts +1 -0
  212. package/features/tools/index.js +1 -0
  213. package/features/tools/index.js.map +1 -1
  214. package/index.d.ts +8 -1
  215. package/index.js +7 -0
  216. package/index.js.map +1 -1
  217. package/package.json +31 -25
  218. package/presentation/installation/components/SystemInstaller/steps/AdminUserStep/createPasswordValidator.js +1 -1
  219. package/presentation/installation/components/SystemInstaller/steps/AdminUserStep/createPasswordValidator.js.map +1 -1
  220. package/presentation/lexicalContext/useLexicalContext.d.ts +3 -0
  221. package/presentation/lexicalContext/useLexicalContext.js +14 -0
  222. package/presentation/lexicalContext/useLexicalContext.js.map +1 -0
  223. package/presentation/textToLexicalTool/TextToLexicalTool.d.ts +3 -0
  224. package/presentation/textToLexicalTool/TextToLexicalTool.js +6 -2
  225. package/presentation/textToLexicalTool/TextToLexicalTool.js.map +1 -1
  226. package/presentation/textToLexicalTool/textToLexicalState.d.ts +2 -1
  227. package/presentation/textToLexicalTool/textToLexicalState.js +15 -3
  228. package/presentation/textToLexicalTool/textToLexicalState.js.map +1 -1
  229. package/routes.d.ts +1 -0
  230. package/routes.js +4 -0
  231. package/routes.js.map +1 -1
  232. package/base/Base/FieldRenderers/ObjectRenderer/ObjectListFlatRenderer.d.ts +0 -21
  233. package/base/Base/FieldRenderers/ObjectRenderer/ObjectListFlatRenderer.js +0 -28
  234. 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 { FormModel } from "./FormModel.js";
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 new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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
- new FormModel({
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
- new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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("select field with options", () => {
427
+ describe("field with options", () => {
421
428
  it("should resolve static options in field VM", () => {
422
- const form = new FormModel({
429
+ const form = createForm({
423
430
  fields: fields => ({
424
- lang: fields.select().label("Language").options([{
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 = new FormModel({
450
+ const form = createForm({
444
451
  fields: fields => ({
445
- lang: fields.select().label("Language").options(() => {
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.select().label("Language").options([{
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("select");
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 = new FormModel({
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 = new FormModel({
565
+ const form = createForm({
559
566
  fields: fields => ({
560
- lang: fields.select().label("Language").options([{
567
+ lang: fields.text().label("Language").options([{
561
568
  label: "English",
562
569
  value: "en"
563
570
  }])
564
571
  })
565
572
  });
566
- const selectField = form.field("lang").as("select");
567
- expect(selectField).toBe(form.field("lang"));
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("select")).toThrow('Field "title" is type "text", not "select".');
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 = new FormModel({
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 = new FormModel({
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 new FormModel({
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.select().label("Language").options([])
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.select().label("Language").options([])
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.select().label("Language").options([])
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.select().label("Language").options([])
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 = new FormModel({
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.select().label("Language").options([{
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 = new FormModel({
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 = new FormModel({
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 new FormModel({
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
- id: "settings",
769
- tabs: [{
770
- id: "general",
771
- label: "General",
772
- layout: [layout.row("description")]
773
- }, {
774
- id: "seo",
775
- label: "SEO",
776
- description: "Optimize how this page appears in search",
777
- layout: [layout.row("metaTitle"), layout.row("metaDescription")]
778
- }]
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 = new FormModel({
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
- id: "settings",
835
- tabs: [{
836
- id: "general",
837
- label: "General",
838
- layout: [layout.row("title")]
839
- }, {
840
- id: "seo",
841
- label: "SEO",
842
- layout: [layout.row("metaTitle")]
843
- }]
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
- new FormModel({
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
- id: "settings",
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 = new FormModel({
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 = new FormModel({
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 = new FormModel({
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 new FormModel({
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
- id: "settings",
933
- tabs: [{
934
- id: "general",
935
- label: "General",
936
- layout: [layout.row("description")]
937
- }, {
938
- id: "seo",
939
- label: "SEO",
940
- layout: [layout.row("metaTitle")]
941
- }]
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
- id: "analytics",
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
- id: "analytics",
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 = new FormModel({
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
- id: "settings",
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 = new FormModel({
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
- id: "settings",
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 = new FormModel({
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
- id: "settings",
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 = new FormModel({
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
- id: "settings",
1084
- tabs: [{
1085
- id: "general",
1086
- label: "General",
1087
- layout: [layout.row("description")]
1088
- }, {
1089
- id: "seo",
1090
- label: "SEO",
1091
- layout: [layout.row("metaTitle")]
1092
- }]
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
- id: "analytics",
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 now has metaTitle + ogImage
1045
+ // SEO tab has metaTitle
1128
1046
  const seoTab = tabsNode.tabs[1];
1129
- expect(seoTab.layout).toHaveLength(2);
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 new FormModel({
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 = new FormModel({
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 new FormModel({
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 = new FormModel({
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 = new FormModel({
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
- id: "settings",
1502
- tabs: [{
1503
- id: "general",
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