@webiny/website-builder-sdk 6.3.0 → 6.4.0-beta.1

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 (154) hide show
  1. package/BindingsApi.js +29 -36
  2. package/BindingsApi.js.map +1 -1
  3. package/BindingsProcessor.js +34 -43
  4. package/BindingsProcessor.js.map +1 -1
  5. package/BindingsProcessor.test.js +82 -78
  6. package/BindingsProcessor.test.js.map +1 -1
  7. package/BindingsResolver.js +92 -119
  8. package/BindingsResolver.js.map +1 -1
  9. package/BindingsResolver.test.js +399 -363
  10. package/BindingsResolver.test.js.map +1 -1
  11. package/ComponentInputTraverser.js +28 -49
  12. package/ComponentInputTraverser.js.map +1 -1
  13. package/ComponentManifestToAstConverter.js +20 -21
  14. package/ComponentManifestToAstConverter.js.map +1 -1
  15. package/ComponentRegistry.js +26 -45
  16. package/ComponentRegistry.js.map +1 -1
  17. package/ComponentResolver.js +25 -29
  18. package/ComponentResolver.js.map +1 -1
  19. package/ConstraintEvaluator.js +246 -353
  20. package/ConstraintEvaluator.js.map +1 -1
  21. package/ConstraintEvaluator.test.js +1616 -1438
  22. package/ConstraintEvaluator.test.js.map +1 -1
  23. package/ContentSdk.js +83 -90
  24. package/ContentSdk.js.map +1 -1
  25. package/DocumentStore.js +47 -59
  26. package/DocumentStore.js.map +1 -1
  27. package/DocumentStoreManager.js +17 -16
  28. package/DocumentStoreManager.js.map +1 -1
  29. package/EditingSdk.js +87 -121
  30. package/EditingSdk.js.map +1 -1
  31. package/ElementFactory.js +126 -174
  32. package/ElementFactory.js.map +1 -1
  33. package/ElementFactory.test.js +234 -263
  34. package/ElementFactory.test.js.map +1 -1
  35. package/Environment.js +18 -19
  36. package/Environment.js.map +1 -1
  37. package/FunctionConverter.js +8 -7
  38. package/FunctionConverter.js.map +1 -1
  39. package/HashObject.js +11 -26
  40. package/HashObject.js.map +1 -1
  41. package/HotkeyManager.js +40 -47
  42. package/HotkeyManager.js.map +1 -1
  43. package/IBindingsUpdater.js +0 -3
  44. package/IRedirects.js +0 -3
  45. package/InheritanceProcessor.js +99 -139
  46. package/InheritanceProcessor.js.map +1 -1
  47. package/InheritanceProcessor.test.js +178 -179
  48. package/InheritanceProcessor.test.js.map +1 -1
  49. package/InheritedValueResolver.js +15 -20
  50. package/InheritedValueResolver.js.map +1 -1
  51. package/InputBindingsProcessor.js +187 -307
  52. package/InputBindingsProcessor.js.map +1 -1
  53. package/InputsBindingsProcessor.test.js +334 -315
  54. package/InputsBindingsProcessor.test.js.map +1 -1
  55. package/InputsUpdater.js +23 -26
  56. package/InputsUpdater.js.map +1 -1
  57. package/LiveSdk.js +12 -13
  58. package/LiveSdk.js.map +1 -1
  59. package/Logger.js +9 -8
  60. package/Logger.js.map +1 -1
  61. package/MouseTracker.js +77 -83
  62. package/MouseTracker.js.map +1 -1
  63. package/NullSdk.js +22 -21
  64. package/NullSdk.js.map +1 -1
  65. package/PreviewDocument.js +27 -30
  66. package/PreviewDocument.js.map +1 -1
  67. package/PreviewSdk.js +16 -17
  68. package/PreviewSdk.js.map +1 -1
  69. package/PreviewViewport.js +51 -63
  70. package/PreviewViewport.js.map +1 -1
  71. package/ResizeObserver.js +24 -31
  72. package/ResizeObserver.js.map +1 -1
  73. package/StylesBindingsProcessor.js +40 -79
  74. package/StylesBindingsProcessor.js.map +1 -1
  75. package/StylesUpdater.js +20 -25
  76. package/StylesUpdater.js.map +1 -1
  77. package/Theme.js +28 -25
  78. package/Theme.js.map +1 -1
  79. package/ViewportManager.js +89 -101
  80. package/ViewportManager.js.map +1 -1
  81. package/constants.js +7 -6
  82. package/constants.js.map +1 -1
  83. package/createElement.js +5 -6
  84. package/createElement.js.map +1 -1
  85. package/createInput.js +85 -143
  86. package/createInput.js.map +1 -1
  87. package/createTheme.js +2 -3
  88. package/createTheme.js.map +1 -1
  89. package/dataProviders/ApiClient.js +40 -49
  90. package/dataProviders/ApiClient.js.map +1 -1
  91. package/dataProviders/DefaultDataProvider.js +56 -58
  92. package/dataProviders/DefaultDataProvider.js.map +1 -1
  93. package/dataProviders/GET_PAGE_BY_ID.js +2 -1
  94. package/dataProviders/GET_PAGE_BY_ID.js.map +1 -1
  95. package/dataProviders/GET_PAGE_BY_PATH.js +2 -1
  96. package/dataProviders/GET_PAGE_BY_PATH.js.map +1 -1
  97. package/dataProviders/LIST_PUBLISHED_PAGES.js +2 -1
  98. package/dataProviders/LIST_PUBLISHED_PAGES.js.map +1 -1
  99. package/dataProviders/NullDataProvider.js +21 -20
  100. package/dataProviders/NullDataProvider.js.map +1 -1
  101. package/dataProviders/RedirectsProvider.js +24 -27
  102. package/dataProviders/RedirectsProvider.js.map +1 -1
  103. package/defaultBreakpoints.js +23 -22
  104. package/defaultBreakpoints.js.map +1 -1
  105. package/documentOperations/$addElementReferenceToParent.js +29 -32
  106. package/documentOperations/$addElementReferenceToParent.js.map +1 -1
  107. package/documentOperations/AddElement.js +8 -7
  108. package/documentOperations/AddElement.js.map +1 -1
  109. package/documentOperations/AddToParent.js +14 -13
  110. package/documentOperations/AddToParent.js.map +1 -1
  111. package/documentOperations/IDocumentOperation.js +0 -3
  112. package/documentOperations/RemoveElement.js +9 -15
  113. package/documentOperations/RemoveElement.js.map +1 -1
  114. package/documentOperations/SetGlobalInputBinding.js +23 -22
  115. package/documentOperations/SetGlobalInputBinding.js.map +1 -1
  116. package/documentOperations/SetGlobalStyleBinding.js +23 -23
  117. package/documentOperations/SetGlobalStyleBinding.js.map +1 -1
  118. package/documentOperations/SetInputBindingOverride.js +30 -29
  119. package/documentOperations/SetInputBindingOverride.js.map +1 -1
  120. package/documentOperations/SetStyleBindingOverride.js +30 -31
  121. package/documentOperations/SetStyleBindingOverride.js.map +1 -1
  122. package/documentOperations/index.js +9 -8
  123. package/documentOperations/index.js.map +1 -1
  124. package/findMatchingAstNode.js +11 -13
  125. package/findMatchingAstNode.js.map +1 -1
  126. package/generateElementId.js +2 -1
  127. package/generateElementId.js.map +1 -1
  128. package/headersProvider.js +4 -3
  129. package/headersProvider.js.map +1 -1
  130. package/index.js +0 -2
  131. package/jsonPatch.js +5 -9
  132. package/jsonPatch.js.map +1 -1
  133. package/messages.js +12 -11
  134. package/messages.js.map +1 -1
  135. package/messenger/MessageOrigin.js +12 -11
  136. package/messenger/MessageOrigin.js.map +1 -1
  137. package/messenger/Messenger.js +58 -69
  138. package/messenger/Messenger.js.map +1 -1
  139. package/messenger/index.js +0 -2
  140. package/package.json +5 -5
  141. package/registerComponentGroup.js +5 -6
  142. package/registerComponentGroup.js.map +1 -1
  143. package/types/ShorthandCssProperties.js +0 -3
  144. package/types/WebsiteBuilderTheme.js +0 -3
  145. package/types.d.ts +3 -0
  146. package/types.js +0 -3
  147. package/IBindingsUpdater.js.map +0 -1
  148. package/IRedirects.js.map +0 -1
  149. package/documentOperations/IDocumentOperation.js.map +0 -1
  150. package/index.js.map +0 -1
  151. package/messenger/index.js.map +0 -1
  152. package/types/ShorthandCssProperties.js.map +0 -1
  153. package/types/WebsiteBuilderTheme.js.map +0 -1
  154. package/types.js.map +0 -1
@@ -1,1456 +1,1634 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
  import { evaluateConstraints, evaluateDeleteConstraint } from "./ConstraintEvaluator.js";
3
3
  function makeElement(id, componentName, parent) {
4
- return {
5
- type: "Webiny/Element",
6
- id,
7
- component: {
8
- name: componentName
9
- },
10
- parent
11
- };
4
+ return {
5
+ type: "Webiny/Element",
6
+ id,
7
+ component: {
8
+ name: componentName
9
+ },
10
+ parent
11
+ };
12
12
  }
13
13
  function makeManifest(name, overrides) {
14
- return {
15
- name,
16
- label: name,
17
- inputs: [],
18
- tags: [],
19
- ...overrides
20
- };
14
+ return {
15
+ name,
16
+ label: name,
17
+ inputs: [],
18
+ tags: [],
19
+ ...overrides
20
+ };
21
21
  }
22
22
  function makeDocument(elements, bindings = {}) {
23
- const elementMap = {};
24
- for (const el of elements) {
25
- elementMap[el.id] = el;
26
- }
27
- return {
28
- id: "doc-1",
29
- state: {},
30
- version: 1,
31
- properties: {},
32
- metadata: {},
33
- extensions: {},
34
- bindings,
35
- elements: elementMap
36
- };
37
- }
38
- describe("evaluateConstraints", () => {
39
- it("should allow placement when there are no constraints", () => {
40
- const parent = makeElement("parent-1", "Container");
41
- const doc = makeDocument([parent]);
42
- const components = {
43
- Container: makeManifest("Container"),
44
- Button: makeManifest("Button")
45
- };
46
- const result = evaluateConstraints({
47
- componentName: "Button",
48
- parentId: "parent-1",
49
- slot: "children",
50
- document: doc,
51
- components
52
- });
53
- expect(result.allowed).toBe(true);
54
- expect(result.violation).toBeUndefined();
55
- });
56
- it("should block when component's parent constraint fails", () => {
57
- const parent = makeElement("parent-1", "Container");
58
- const doc = makeDocument([parent]);
59
- const components = {
60
- Container: makeManifest("Container"),
61
- TabPanel: makeManifest("TabPanel", {
62
- constraints: [ctx => {
63
- if (ctx.parent.name !== "Tabs") {
64
- return ctx.block("TabPanel must be inside Tabs");
65
- }
66
- }]
67
- })
68
- };
69
- const result = evaluateConstraints({
70
- componentName: "TabPanel",
71
- parentId: "parent-1",
72
- slot: "children",
73
- document: doc,
74
- components
75
- });
76
- expect(result.allowed).toBe(false);
77
- expect(result.violation).toBeDefined();
78
- expect(result.violation.message).toBe("TabPanel must be inside Tabs");
79
- });
80
- it("should allow when parent constraint passes", () => {
81
- const parent = makeElement("parent-1", "Tabs");
82
- const doc = makeDocument([parent]);
83
- const components = {
84
- Tabs: makeManifest("Tabs"),
85
- TabPanel: makeManifest("TabPanel", {
86
- constraints: [ctx => {
87
- if (ctx.parent.name !== "Tabs") {
88
- return ctx.block("TabPanel must be inside Tabs");
89
- }
90
- }]
91
- })
92
- };
93
- const result = evaluateConstraints({
94
- componentName: "TabPanel",
95
- parentId: "parent-1",
96
- slot: "children",
97
- document: doc,
98
- components
99
- });
100
- expect(result.allowed).toBe(true);
101
- expect(result.violation).toBeUndefined();
102
- });
103
- it("should block when ancestor constraint fails", () => {
104
- const root = makeElement("root", "Page");
105
- const section = makeElement("section-1", "Section", {
106
- id: "root",
107
- slot: "children"
108
- });
109
- const doc = makeDocument([root, section]);
110
- const components = {
111
- Page: makeManifest("Page"),
112
- Section: makeManifest("Section"),
113
- ProductPrice: makeManifest("ProductPrice", {
114
- constraints: [ctx => {
115
- if (!ctx.isDescendantOf("ProductBox")) {
116
- return ctx.block("ProductPrice must be inside a ProductBox");
117
- }
118
- }]
119
- })
23
+ const elementMap = {};
24
+ for (const el of elements)elementMap[el.id] = el;
25
+ return {
26
+ id: "doc-1",
27
+ state: {},
28
+ version: 1,
29
+ properties: {},
30
+ metadata: {},
31
+ extensions: {},
32
+ bindings,
33
+ elements: elementMap
120
34
  };
121
- const result = evaluateConstraints({
122
- componentName: "ProductPrice",
123
- parentId: "section-1",
124
- slot: "children",
125
- document: doc,
126
- components
127
- });
128
- expect(result.allowed).toBe(false);
129
- expect(result.violation.message).toBe("ProductPrice must be inside a ProductBox");
130
- });
131
- it("should allow when grandparent matches ancestor constraint", () => {
132
- const root = makeElement("root", "ProductBox");
133
- const inner = makeElement("inner-1", "Container", {
134
- id: "root",
135
- slot: "children"
136
- });
137
- const doc = makeDocument([root, inner]);
138
- const components = {
139
- ProductBox: makeManifest("ProductBox"),
140
- Container: makeManifest("Container"),
141
- ProductPrice: makeManifest("ProductPrice", {
142
- constraints: [ctx => {
143
- if (!ctx.isDescendantOf("ProductBox")) {
144
- return ctx.block("ProductPrice must be inside a ProductBox");
145
- }
146
- }]
147
- })
148
- };
149
- const result = evaluateConstraints({
150
- componentName: "ProductPrice",
151
- parentId: "inner-1",
152
- slot: "children",
153
- document: doc,
154
- components
155
- });
156
- expect(result.allowed).toBe(true);
157
- });
158
- it("should block via slot children limit constraint using slotChildCount", () => {
159
- const parent = makeElement("parent-1", "Grid");
160
- const child1 = makeElement("child-1", "Cell", {
161
- id: "parent-1",
162
- slot: "children"
163
- });
164
- const child2 = makeElement("child-2", "Cell", {
165
- id: "parent-1",
166
- slot: "children"
167
- });
168
- const doc = makeDocument([parent, child1, child2], {
169
- "parent-1": {
170
- inputs: {
171
- children: {
172
- id: "inp-1",
173
- type: "slot",
174
- static: ["child-1", "child-2"]
175
- }
176
- }
177
- }
178
- });
179
- const components = {
180
- Grid: makeManifest("Grid"),
181
- Cell: makeManifest("Cell", {
182
- constraints: [ctx => {
183
- if (!(ctx.slotChildCount() < 2)) {
184
- return ctx.block("Maximum 2 children allowed");
185
- }
186
- }]
187
- })
188
- };
189
- const result = evaluateConstraints({
190
- componentName: "Cell",
191
- parentId: "parent-1",
192
- slot: "children",
193
- document: doc,
194
- components
195
- });
196
- expect(result.allowed).toBe(false);
197
- expect(result.violation.message).toBe("Maximum 2 children allowed");
198
- });
199
- it("should block via countInstances when max instances reached", () => {
200
- const root = makeElement("root", "Page");
201
- const hero = makeElement("hero-1", "Hero", {
202
- id: "root",
203
- slot: "children"
204
- });
205
- const doc = makeDocument([root, hero]);
206
- const components = {
207
- Page: makeManifest("Page"),
208
- Hero: makeManifest("Hero", {
209
- constraints: [ctx => {
210
- if (!(ctx.countInstances("Hero") < 1)) {
211
- return ctx.block("Only one Hero per page");
212
- }
213
- }]
214
- })
215
- };
216
- const result = evaluateConstraints({
217
- componentName: "Hero",
218
- parentId: "root",
219
- slot: "children",
220
- document: doc,
221
- components
222
- });
223
- expect(result.allowed).toBe(false);
224
- expect(result.violation.message).toBe("Only one Hero per page");
225
- });
226
- it("should not evaluate parent's constraints against the placed component", () => {
227
- const parent = makeElement("parent-1", "Tabs");
228
- const doc = makeDocument([parent]);
229
- const components = {
230
- Tabs: makeManifest("Tabs", {
231
- constraints: [ctx => ctx.block("This should not run for children")]
232
- }),
233
- Button: makeManifest("Button")
234
- };
235
-
236
- // Parent's constraints should not affect children being dropped into it
237
- const result = evaluateConstraints({
238
- componentName: "Button",
239
- parentId: "parent-1",
240
- slot: "children",
241
- document: doc,
242
- components
243
- });
244
- expect(result.allowed).toBe(true);
245
- });
246
- it("should short-circuit on the first failed constraint", () => {
247
- const parent = makeElement("parent-1", "Container");
248
- const doc = makeDocument([parent]);
249
- const components = {
250
- Container: makeManifest("Container"),
251
- Widget: makeManifest("Widget", {
252
- constraints: [() => {}, ctx => ctx.block("Always fails"), ctx => ctx.block("Also fails")]
253
- })
254
- };
255
- const result = evaluateConstraints({
256
- componentName: "Widget",
257
- parentId: "parent-1",
258
- slot: "children",
259
- document: doc,
260
- components
261
- });
262
- expect(result.allowed).toBe(false);
263
- expect(result.violation.message).toBe("Always fails");
264
- });
265
- it("should allow placement when component manifest is not found", () => {
266
- const parent = makeElement("parent-1", "Container");
267
- const doc = makeDocument([parent]);
268
- const components = {
269
- Container: makeManifest("Container")
270
- };
271
- const result = evaluateConstraints({
272
- componentName: "Unknown",
273
- parentId: "parent-1",
274
- slot: "children",
275
- document: doc,
276
- components
277
- });
278
- expect(result.allowed).toBe(true);
279
- });
280
- it("should allow placement when parent element is not found", () => {
281
- const doc = makeDocument([]);
282
- const components = {
283
- Button: makeManifest("Button")
284
- };
285
- const result = evaluateConstraints({
286
- componentName: "Button",
287
- parentId: "nonexistent",
288
- slot: "children",
289
- document: doc,
290
- components
291
- });
292
- expect(result.allowed).toBe(true);
293
- });
294
- it("should handle multi-slot parent constraint checking specific slot", () => {
295
- const parent = makeElement("parent-1", "TwoColumn");
296
- const child = makeElement("child-1", "Widget", {
297
- id: "parent-1",
298
- slot: "leftColumn"
299
- });
300
- const doc = makeDocument([parent, child], {
301
- "parent-1": {
302
- inputs: {
303
- leftColumn: {
304
- id: "inp-left",
305
- type: "slot",
306
- static: ["child-1"]
307
- },
308
- rightColumn: {
309
- id: "inp-right",
310
- type: "slot",
311
- static: []
312
- }
313
- }
314
- }
315
- });
316
- const components = {
317
- TwoColumn: makeManifest("TwoColumn"),
318
- Widget: makeManifest("Widget", {
319
- constraints: [ctx => {
320
- if (!(ctx.slotChildCount() < 1)) {
321
- return ctx.block("Slot is full");
322
- }
323
- }]
324
- })
325
- };
326
-
327
- // leftColumn is full
328
- const leftResult = evaluateConstraints({
329
- componentName: "Widget",
330
- parentId: "parent-1",
331
- slot: "leftColumn",
332
- document: doc,
333
- components
334
- });
335
- expect(leftResult.allowed).toBe(false);
336
-
337
- // rightColumn is empty
338
- const rightResult = evaluateConstraints({
339
- componentName: "Widget",
340
- parentId: "parent-1",
341
- slot: "rightColumn",
342
- document: doc,
343
- components
344
- });
345
- expect(rightResult.allowed).toBe(true);
346
- });
347
- it("should provide a default message when constraint has no message", () => {
348
- const parent = makeElement("parent-1", "Container");
349
- const doc = makeDocument([parent]);
350
- const components = {
351
- Container: makeManifest("Container"),
352
- Widget: makeManifest("Widget", {
353
- constraints: [ctx => ctx.block("Blocked")]
354
- })
355
- };
356
- const result = evaluateConstraints({
357
- componentName: "Widget",
358
- parentId: "parent-1",
359
- slot: "children",
360
- document: doc,
361
- components
362
- });
363
- expect(result.violation.message).toBe("Blocked");
364
- });
365
- it("should use thrown error message as the violation message", () => {
366
- const parent = makeElement("parent-1", "Container");
367
- const doc = makeDocument([parent]);
368
- const components = {
369
- Container: makeManifest("Container"),
370
- Widget: makeManifest("Widget", {
371
- constraints: [ctx => {
372
- throw new Error(`Widget cannot be placed inside ${ctx.parent.name}`);
373
- }]
374
- })
375
- };
376
- const result = evaluateConstraints({
377
- componentName: "Widget",
378
- parentId: "parent-1",
379
- slot: "children",
380
- document: doc,
381
- components
382
- });
383
- expect(result.allowed).toBe(false);
384
- expect(result.violation.message).toBe("Widget cannot be placed inside Container");
385
- });
386
- it("should fall back to static message when a non-Error is thrown", () => {
387
- const parent = makeElement("parent-1", "Container");
388
- const doc = makeDocument([parent]);
389
- const components = {
390
- Container: makeManifest("Container"),
391
- Widget: makeManifest("Widget", {
392
- constraints: [() => {
393
- throw "not an error object";
394
- }]
395
- })
396
- };
397
- const result = evaluateConstraints({
398
- componentName: "Widget",
399
- parentId: "parent-1",
400
- slot: "children",
401
- document: doc,
402
- components
403
- });
404
- expect(result.allowed).toBe(false);
405
- expect(result.violation.message).toBe("Cannot place Widget here.");
406
- });
407
- it("should fall back to default message when a non-Error is thrown and no static message", () => {
408
- const parent = makeElement("parent-1", "Container");
409
- const doc = makeDocument([parent]);
410
- const components = {
411
- Container: makeManifest("Container"),
412
- Widget: makeManifest("Widget", {
413
- constraints: [() => {
414
- throw null;
415
- }]
416
- })
417
- };
418
- const result = evaluateConstraints({
419
- componentName: "Widget",
420
- parentId: "parent-1",
421
- slot: "children",
422
- document: doc,
423
- components
424
- });
425
- expect(result.allowed).toBe(false);
426
- expect(result.violation.message).toBe("Cannot place Widget here.");
427
- });
428
- describe("ctx.isChildOf", () => {
429
- it("should return true when direct parent matches", () => {
430
- const parent = makeElement("parent-1", "Tabs");
431
- const doc = makeDocument([parent]);
432
- const components = {
433
- Tabs: makeManifest("Tabs"),
434
- TabPanel: makeManifest("TabPanel", {
435
- constraints: [ctx => {
436
- if (!ctx.isChildOf("Tabs")) {
437
- return ctx.block("TabPanel must be a direct child of Tabs");
438
- }
439
- }]
440
- })
441
- };
442
- const result = evaluateConstraints({
443
- componentName: "TabPanel",
444
- parentId: "parent-1",
445
- slot: "children",
446
- document: doc,
447
- components
448
- });
449
- expect(result.allowed).toBe(true);
450
- });
451
- it("should return false when direct parent does not match", () => {
452
- const parent = makeElement("parent-1", "Container");
453
- const doc = makeDocument([parent]);
454
- const components = {
455
- Container: makeManifest("Container"),
456
- TabPanel: makeManifest("TabPanel", {
457
- constraints: [ctx => {
458
- if (!ctx.isChildOf("Tabs")) {
459
- return ctx.block("TabPanel must be a direct child of Tabs");
460
- }
461
- }]
462
- })
463
- };
464
- const result = evaluateConstraints({
465
- componentName: "TabPanel",
466
- parentId: "parent-1",
467
- slot: "children",
468
- document: doc,
469
- components
470
- });
471
- expect(result.allowed).toBe(false);
472
- });
473
- });
474
- describe("ctx.isDescendantOf", () => {
475
- it("should return true when direct parent matches", () => {
476
- const parent = makeElement("parent-1", "ProductBox");
477
- const doc = makeDocument([parent]);
478
- const components = {
479
- ProductBox: makeManifest("ProductBox"),
480
- ProductPrice: makeManifest("ProductPrice", {
481
- constraints: [ctx => {
482
- if (!ctx.isDescendantOf("ProductBox")) {
483
- return ctx.block("Blocked");
484
- }
485
- }]
486
- })
487
- };
488
- const result = evaluateConstraints({
489
- componentName: "ProductPrice",
490
- parentId: "parent-1",
491
- slot: "children",
492
- document: doc,
493
- components
494
- });
495
- expect(result.allowed).toBe(true);
496
- });
497
- it("should return true when grandparent matches", () => {
498
- const root = makeElement("root", "ProductBox");
499
- const inner = makeElement("inner", "Container", {
500
- id: "root",
501
- slot: "children"
502
- });
503
- const doc = makeDocument([root, inner]);
504
- const components = {
505
- ProductBox: makeManifest("ProductBox"),
506
- Container: makeManifest("Container"),
507
- ProductPrice: makeManifest("ProductPrice", {
508
- constraints: [ctx => {
509
- if (!ctx.isDescendantOf("ProductBox")) {
510
- return ctx.block("Blocked");
511
- }
512
- }]
513
- })
514
- };
515
- const result = evaluateConstraints({
516
- componentName: "ProductPrice",
517
- parentId: "inner",
518
- slot: "children",
519
- document: doc,
520
- components
521
- });
522
- expect(result.allowed).toBe(true);
523
- });
524
- it("should return false when no ancestor matches", () => {
525
- const root = makeElement("root", "Page");
526
- const inner = makeElement("inner", "Section", {
527
- id: "root",
528
- slot: "children"
529
- });
530
- const doc = makeDocument([root, inner]);
531
- const components = {
532
- Page: makeManifest("Page"),
533
- Section: makeManifest("Section"),
534
- ProductPrice: makeManifest("ProductPrice", {
535
- constraints: [ctx => {
536
- if (!ctx.isDescendantOf("ProductBox")) {
537
- return ctx.block("Blocked");
538
- }
539
- }]
540
- })
541
- };
542
- const result = evaluateConstraints({
543
- componentName: "ProductPrice",
544
- parentId: "inner",
545
- slot: "children",
546
- document: doc,
547
- components
548
- });
549
- expect(result.allowed).toBe(false);
550
- });
551
- });
552
- describe("ctx.slotChildCount", () => {
553
- it("should return the number of children in the target slot", () => {
554
- const parent = makeElement("parent-1", "Grid");
555
- const doc = makeDocument([parent], {
556
- "parent-1": {
557
- inputs: {
558
- children: {
559
- id: "inp-1",
560
- type: "slot",
561
- static: ["child-1", "child-2", "child-3"]
562
- }
563
- }
564
- }
565
- });
566
- const components = {
567
- Grid: makeManifest("Grid"),
568
- Cell: makeManifest("Cell", {
569
- constraints: [ctx => {
570
- if (!(ctx.slotChildCount() < 3)) {
571
- return ctx.block("Max 3 children");
572
- }
573
- }]
574
- })
575
- };
576
- const result = evaluateConstraints({
577
- componentName: "Cell",
578
- parentId: "parent-1",
579
- slot: "children",
580
- document: doc,
581
- components
582
- });
583
- expect(result.allowed).toBe(false);
584
- });
585
- it("should return 0 when slot has no bindings", () => {
586
- const parent = makeElement("parent-1", "Container");
587
- const doc = makeDocument([parent]);
588
- const components = {
589
- Container: makeManifest("Container"),
590
- Widget: makeManifest("Widget", {
591
- constraints: [ctx => {
592
- if (!(ctx.slotChildCount() < 5)) {
593
- return ctx.block("Blocked");
594
- }
595
- }]
596
- })
597
- };
598
- const result = evaluateConstraints({
599
- componentName: "Widget",
600
- parentId: "parent-1",
601
- slot: "children",
602
- document: doc,
603
- components
604
- });
605
- expect(result.allowed).toBe(true);
606
- });
607
- });
608
- describe("ctx.hasTag", () => {
609
- it("should return true when the placed component has the tag", () => {
610
- const parent = makeElement("parent-1", "Container");
611
- const doc = makeDocument([parent]);
612
- const components = {
613
- Container: makeManifest("Container"),
614
- FunnelField: makeManifest("FunnelField", {
615
- tags: ["funnel-field", "input"],
616
- constraints: [ctx => {
617
- if (ctx.hasTag("funnel-field")) {
618
- return ctx.block("Funnel fields not allowed here");
619
- }
620
- }]
621
- })
622
- };
623
- const result = evaluateConstraints({
624
- componentName: "FunnelField",
625
- parentId: "parent-1",
626
- slot: "children",
627
- document: doc,
628
- components
629
- });
630
- expect(result.allowed).toBe(false);
631
- });
632
- it("should return false when the placed component does not have the tag", () => {
633
- const parent = makeElement("parent-1", "Container");
634
- const doc = makeDocument([parent]);
635
- const components = {
636
- Container: makeManifest("Container"),
637
- Button: makeManifest("Button", {
638
- constraints: [ctx => {
639
- if (ctx.hasTag("funnel-field")) {
640
- return ctx.block("Blocked");
641
- }
642
- }]
643
- })
644
- };
645
- const result = evaluateConstraints({
646
- componentName: "Button",
647
- parentId: "parent-1",
648
- slot: "children",
649
- document: doc,
650
- components
651
- });
652
- expect(result.allowed).toBe(true);
653
- });
654
- });
655
- describe("ctx.getAncestor", () => {
656
- it("should return the matching ancestor element context", () => {
657
- const root = makeElement("root", "ProductBox");
658
- const inner = makeElement("inner", "Container", {
659
- id: "root",
660
- slot: "children"
661
- });
662
- const doc = makeDocument([root, inner]);
663
- const components = {
664
- ProductBox: makeManifest("ProductBox", {
665
- tags: ["product"]
666
- }),
667
- Container: makeManifest("Container"),
668
- ProductPrice: makeManifest("ProductPrice", {
669
- constraints: [ctx => {
670
- const ancestor = ctx.getAncestor("ProductBox");
671
- if (!(ancestor !== undefined && ancestor.name === "ProductBox")) {
672
- return ctx.block("Blocked");
673
- }
674
- }]
675
- })
676
- };
677
- const result = evaluateConstraints({
678
- componentName: "ProductPrice",
679
- parentId: "inner",
680
- slot: "children",
681
- document: doc,
682
- components
683
- });
684
- expect(result.allowed).toBe(true);
685
- });
686
- it("should return undefined when no ancestor matches", () => {
687
- const root = makeElement("root", "Page");
688
- const doc = makeDocument([root]);
689
- const components = {
690
- Page: makeManifest("Page"),
691
- Widget: makeManifest("Widget", {
692
- constraints: [ctx => {
693
- if (ctx.getAncestor("ProductBox") === undefined) {
694
- return ctx.block("Blocked");
695
- }
696
- }]
697
- })
698
- };
699
- const result = evaluateConstraints({
700
- componentName: "Widget",
701
- parentId: "root",
702
- slot: "children",
703
- document: doc,
704
- components
705
- });
706
- expect(result.allowed).toBe(false);
707
- });
708
- });
709
- describe("ctx.countInstances", () => {
710
- it("should count instances of a component in the document", () => {
711
- const root = makeElement("root", "Page");
712
- const hero1 = makeElement("hero-1", "Hero", {
713
- id: "root",
714
- slot: "children"
715
- });
716
- const hero2 = makeElement("hero-2", "Hero", {
717
- id: "root",
718
- slot: "children"
719
- });
720
- const doc = makeDocument([root, hero1, hero2]);
721
- const components = {
722
- Page: makeManifest("Page"),
723
- Hero: makeManifest("Hero", {
724
- constraints: [ctx => {
725
- if (!(ctx.countInstances("Hero") < 2)) {
726
- return ctx.block("Max 2 Heroes");
727
- }
728
- }]
729
- })
730
- };
731
- const result = evaluateConstraints({
732
- componentName: "Hero",
733
- parentId: "root",
734
- slot: "children",
735
- document: doc,
736
- components
737
- });
738
- expect(result.allowed).toBe(false);
739
- });
740
- });
741
- describe("ctx.component", () => {
742
- it("should expose the placed component's name and tags", () => {
743
- const parent = makeElement("parent-1", "Container");
744
- const doc = makeDocument([parent]);
745
- let capturedComponent;
746
- const components = {
747
- Container: makeManifest("Container"),
748
- Widget: makeManifest("Widget", {
749
- tags: ["interactive", "form"],
750
- constraints: [ctx => {
751
- capturedComponent = ctx.component;
752
- }]
753
- })
754
- };
755
- evaluateConstraints({
756
- componentName: "Widget",
757
- parentId: "parent-1",
758
- slot: "children",
759
- document: doc,
760
- components
761
- });
762
- expect(capturedComponent).toBeDefined();
763
- expect(capturedComponent.name).toBe("Widget");
764
- expect(capturedComponent.tags).toEqual(["interactive", "form"]);
765
- });
766
- });
767
- describe("parent.getParent", () => {
768
- it("should return the grandparent element context", () => {
769
- const root = makeElement("root", "Page");
770
- const section = makeElement("section", "Section", {
771
- id: "root",
772
- slot: "children"
773
- });
774
- const doc = makeDocument([root, section]);
775
- let grandparentName;
776
- const components = {
777
- Page: makeManifest("Page"),
778
- Section: makeManifest("Section"),
779
- Widget: makeManifest("Widget", {
780
- constraints: [ctx => {
781
- const grandparent = ctx.parent.getParent();
782
- grandparentName = grandparent?.name;
783
- }]
784
- })
785
- };
786
- evaluateConstraints({
787
- componentName: "Widget",
788
- parentId: "section",
789
- slot: "children",
790
- document: doc,
791
- components
792
- });
793
- expect(grandparentName).toBe("Page");
794
- });
795
- it("should return undefined for root element", () => {
796
- const root = makeElement("root", "Page");
797
- const doc = makeDocument([root]);
798
- let parentResult = "not-called";
799
- const components = {
800
- Page: makeManifest("Page"),
801
- Widget: makeManifest("Widget", {
802
- constraints: [ctx => {
803
- parentResult = ctx.parent.getParent();
804
- }]
805
- })
806
- };
807
- evaluateConstraints({
808
- componentName: "Widget",
809
- parentId: "root",
810
- slot: "children",
811
- document: doc,
812
- components
813
- });
814
- expect(parentResult).toBeUndefined();
815
- });
816
- });
817
- describe("parent.childIndex / childCount / isFirstChild / isLastChild", () => {
818
- it("should return -1 for elements not in a list slot", () => {
819
- const root = makeElement("root", "Page");
820
- const doc = makeDocument([root]);
821
- let capturedIndex;
822
- let capturedCount;
823
- const components = {
824
- Page: makeManifest("Page"),
825
- Widget: makeManifest("Widget", {
826
- constraints: [ctx => {
827
- capturedIndex = ctx.parent.childIndex();
828
- capturedCount = ctx.parent.childCount();
829
- }]
830
- })
831
- };
832
- evaluateConstraints({
833
- componentName: "Widget",
834
- parentId: "root",
835
- slot: "children",
836
- document: doc,
837
- components
838
- });
839
- expect(capturedIndex).toBe(-1);
840
- expect(capturedCount).toBe(-1);
841
- });
842
- it("should parse child index from list slot path", () => {
843
- const root = makeElement("root", "FunnelBuilder");
844
- const step = makeElement("step-1", "Step", {
845
- id: "root",
846
- slot: "steps/2/step"
847
- });
848
- const doc = makeDocument([root, step], {
849
- root: {
850
- inputs: {
851
- "steps/0/step": {
852
- id: "s0",
853
- type: "slot",
854
- static: ["step-0"]
855
- },
856
- "steps/1/step": {
857
- id: "s1",
858
- type: "slot",
859
- static: ["step-other"]
860
- },
861
- "steps/2/step": {
862
- id: "s2",
863
- type: "slot",
864
- static: ["step-1"]
865
- }
866
- }
867
- }
868
- });
869
- let capturedIndex;
870
- let capturedCount;
871
- let capturedIsLast;
872
- let capturedIsFirst;
873
- const components = {
874
- FunnelBuilder: makeManifest("FunnelBuilder"),
875
- Step: makeManifest("Step"),
876
- Widget: makeManifest("Widget", {
877
- constraints: [ctx => {
878
- capturedIndex = ctx.parent.childIndex();
879
- capturedCount = ctx.parent.childCount();
880
- capturedIsLast = ctx.parent.isLastChild();
881
- capturedIsFirst = ctx.parent.isFirstChild();
882
- }]
883
- })
884
- };
885
- evaluateConstraints({
886
- componentName: "Widget",
887
- parentId: "step-1",
888
- slot: "children",
889
- document: doc,
890
- components
891
- });
892
- expect(capturedIndex).toBe(2);
893
- expect(capturedCount).toBe(3);
894
- expect(capturedIsLast).toBe(true);
895
- expect(capturedIsFirst).toBe(false);
896
- });
897
- it("should identify the first child correctly", () => {
898
- const root = makeElement("root", "FunnelBuilder");
899
- const step = makeElement("step-0", "Step", {
900
- id: "root",
901
- slot: "steps/0/step"
902
- });
903
- const doc = makeDocument([root, step], {
904
- root: {
905
- inputs: {
906
- "steps/0/step": {
907
- id: "s0",
908
- type: "slot",
909
- static: ["step-0"]
910
- },
911
- "steps/1/step": {
912
- id: "s1",
913
- type: "slot",
914
- static: ["step-1"]
915
- }
916
- }
917
- }
918
- });
919
- let capturedIsFirst;
920
- let capturedIsLast;
921
- const components = {
922
- FunnelBuilder: makeManifest("FunnelBuilder"),
923
- Step: makeManifest("Step"),
924
- Widget: makeManifest("Widget", {
925
- constraints: [ctx => {
926
- capturedIsFirst = ctx.parent.isFirstChild();
927
- capturedIsLast = ctx.parent.isLastChild();
928
- }]
929
- })
930
- };
931
- evaluateConstraints({
932
- componentName: "Widget",
933
- parentId: "step-0",
934
- slot: "children",
935
- document: doc,
936
- components
937
- });
938
- expect(capturedIsFirst).toBe(true);
939
- expect(capturedIsLast).toBe(false);
940
- });
941
- it("should resolve position from bindings even when element.parent.slot is stale", () => {
942
- const root = makeElement("root", "FunnelBuilder");
943
- // Element says slot "steps/1/step" but bindings put it at steps/3/step
944
- const step = makeElement("step-final", "Step", {
945
- id: "root",
946
- slot: "steps/1/step"
947
- });
948
- const doc = makeDocument([root, step], {
949
- root: {
950
- inputs: {
951
- "steps/0/step": {
952
- id: "s0",
953
- type: "slot",
954
- static: "step-0"
955
- },
956
- "steps/1/step": {
957
- id: "s1",
958
- type: "slot",
959
- static: "step-1"
960
- },
961
- "steps/2/step": {
962
- id: "s2",
963
- type: "slot",
964
- static: "step-2"
965
- },
966
- "steps/3/step": {
967
- id: "s3",
968
- type: "slot",
969
- static: "step-final"
35
+ }
36
+ describe("evaluateConstraints", ()=>{
37
+ it("should allow placement when there are no constraints", ()=>{
38
+ const parent = makeElement("parent-1", "Container");
39
+ const doc = makeDocument([
40
+ parent
41
+ ]);
42
+ const components = {
43
+ Container: makeManifest("Container"),
44
+ Button: makeManifest("Button")
45
+ };
46
+ const result = evaluateConstraints({
47
+ componentName: "Button",
48
+ parentId: "parent-1",
49
+ slot: "children",
50
+ document: doc,
51
+ components
52
+ });
53
+ expect(result.allowed).toBe(true);
54
+ expect(result.violation).toBeUndefined();
55
+ });
56
+ it("should block when component's parent constraint fails", ()=>{
57
+ const parent = makeElement("parent-1", "Container");
58
+ const doc = makeDocument([
59
+ parent
60
+ ]);
61
+ const components = {
62
+ Container: makeManifest("Container"),
63
+ TabPanel: makeManifest("TabPanel", {
64
+ constraints: [
65
+ (ctx)=>{
66
+ if ("Tabs" !== ctx.parent.name) return ctx.block("TabPanel must be inside Tabs");
67
+ }
68
+ ]
69
+ })
70
+ };
71
+ const result = evaluateConstraints({
72
+ componentName: "TabPanel",
73
+ parentId: "parent-1",
74
+ slot: "children",
75
+ document: doc,
76
+ components
77
+ });
78
+ expect(result.allowed).toBe(false);
79
+ expect(result.violation).toBeDefined();
80
+ expect(result.violation.message).toBe("TabPanel must be inside Tabs");
81
+ });
82
+ it("should allow when parent constraint passes", ()=>{
83
+ const parent = makeElement("parent-1", "Tabs");
84
+ const doc = makeDocument([
85
+ parent
86
+ ]);
87
+ const components = {
88
+ Tabs: makeManifest("Tabs"),
89
+ TabPanel: makeManifest("TabPanel", {
90
+ constraints: [
91
+ (ctx)=>{
92
+ if ("Tabs" !== ctx.parent.name) return ctx.block("TabPanel must be inside Tabs");
93
+ }
94
+ ]
95
+ })
96
+ };
97
+ const result = evaluateConstraints({
98
+ componentName: "TabPanel",
99
+ parentId: "parent-1",
100
+ slot: "children",
101
+ document: doc,
102
+ components
103
+ });
104
+ expect(result.allowed).toBe(true);
105
+ expect(result.violation).toBeUndefined();
106
+ });
107
+ it("should block when ancestor constraint fails", ()=>{
108
+ const root = makeElement("root", "Page");
109
+ const section = makeElement("section-1", "Section", {
110
+ id: "root",
111
+ slot: "children"
112
+ });
113
+ const doc = makeDocument([
114
+ root,
115
+ section
116
+ ]);
117
+ const components = {
118
+ Page: makeManifest("Page"),
119
+ Section: makeManifest("Section"),
120
+ ProductPrice: makeManifest("ProductPrice", {
121
+ constraints: [
122
+ (ctx)=>{
123
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("ProductPrice must be inside a ProductBox");
124
+ }
125
+ ]
126
+ })
127
+ };
128
+ const result = evaluateConstraints({
129
+ componentName: "ProductPrice",
130
+ parentId: "section-1",
131
+ slot: "children",
132
+ document: doc,
133
+ components
134
+ });
135
+ expect(result.allowed).toBe(false);
136
+ expect(result.violation.message).toBe("ProductPrice must be inside a ProductBox");
137
+ });
138
+ it("should allow when grandparent matches ancestor constraint", ()=>{
139
+ const root = makeElement("root", "ProductBox");
140
+ const inner = makeElement("inner-1", "Container", {
141
+ id: "root",
142
+ slot: "children"
143
+ });
144
+ const doc = makeDocument([
145
+ root,
146
+ inner
147
+ ]);
148
+ const components = {
149
+ ProductBox: makeManifest("ProductBox"),
150
+ Container: makeManifest("Container"),
151
+ ProductPrice: makeManifest("ProductPrice", {
152
+ constraints: [
153
+ (ctx)=>{
154
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("ProductPrice must be inside a ProductBox");
155
+ }
156
+ ]
157
+ })
158
+ };
159
+ const result = evaluateConstraints({
160
+ componentName: "ProductPrice",
161
+ parentId: "inner-1",
162
+ slot: "children",
163
+ document: doc,
164
+ components
165
+ });
166
+ expect(result.allowed).toBe(true);
167
+ });
168
+ it("should block via slot children limit constraint using slotChildCount", ()=>{
169
+ const parent = makeElement("parent-1", "Grid");
170
+ const child1 = makeElement("child-1", "Cell", {
171
+ id: "parent-1",
172
+ slot: "children"
173
+ });
174
+ const child2 = makeElement("child-2", "Cell", {
175
+ id: "parent-1",
176
+ slot: "children"
177
+ });
178
+ const doc = makeDocument([
179
+ parent,
180
+ child1,
181
+ child2
182
+ ], {
183
+ "parent-1": {
184
+ inputs: {
185
+ children: {
186
+ id: "inp-1",
187
+ type: "slot",
188
+ static: [
189
+ "child-1",
190
+ "child-2"
191
+ ]
192
+ }
193
+ }
970
194
  }
971
- }
972
- }
973
- });
974
- let capturedIndex;
975
- let capturedIsLast;
976
- const components = {
977
- FunnelBuilder: makeManifest("FunnelBuilder"),
978
- Step: makeManifest("Step"),
979
- Widget: makeManifest("Widget", {
980
- constraints: [ctx => {
981
- capturedIndex = ctx.parent.childIndex();
982
- capturedIsLast = ctx.parent.isLastChild();
983
- }]
984
- })
985
- };
986
- evaluateConstraints({
987
- componentName: "Widget",
988
- parentId: "step-final",
989
- slot: "children",
990
- document: doc,
991
- components
992
- });
993
-
994
- // Should find actual position (index 3, last) not stale slot (index 1)
995
- expect(capturedIndex).toBe(3);
996
- expect(capturedIsLast).toBe(true);
997
- });
998
- it("should resolve position from direct list slot binding (static is an array of IDs)", () => {
999
- const root = makeElement("root", "FunnelBuilder");
1000
- const step0 = makeElement("step-0", "Step", {
1001
- id: "root",
1002
- slot: "steps"
1003
- });
1004
- const step1 = makeElement("step-1", "Step", {
1005
- id: "root",
1006
- slot: "steps"
1007
- });
1008
- const step2 = makeElement("step-2", "Step", {
1009
- id: "root",
1010
- slot: "steps"
1011
- });
1012
- const doc = makeDocument([root, step0, step1, step2], {
1013
- root: {
1014
- inputs: {
1015
- steps: {
1016
- id: "s",
1017
- type: "slot",
1018
- list: true,
1019
- static: ["step-0", "step-1", "step-2"]
195
+ });
196
+ const components = {
197
+ Grid: makeManifest("Grid"),
198
+ Cell: makeManifest("Cell", {
199
+ constraints: [
200
+ (ctx)=>{
201
+ if (!(ctx.slotChildCount() < 2)) return ctx.block("Maximum 2 children allowed");
202
+ }
203
+ ]
204
+ })
205
+ };
206
+ const result = evaluateConstraints({
207
+ componentName: "Cell",
208
+ parentId: "parent-1",
209
+ slot: "children",
210
+ document: doc,
211
+ components
212
+ });
213
+ expect(result.allowed).toBe(false);
214
+ expect(result.violation.message).toBe("Maximum 2 children allowed");
215
+ });
216
+ it("should block via countInstances when max instances reached", ()=>{
217
+ const root = makeElement("root", "Page");
218
+ const hero = makeElement("hero-1", "Hero", {
219
+ id: "root",
220
+ slot: "children"
221
+ });
222
+ const doc = makeDocument([
223
+ root,
224
+ hero
225
+ ]);
226
+ const components = {
227
+ Page: makeManifest("Page"),
228
+ Hero: makeManifest("Hero", {
229
+ constraints: [
230
+ (ctx)=>{
231
+ if (!(ctx.countInstances("Hero") < 1)) return ctx.block("Only one Hero per page");
232
+ }
233
+ ]
234
+ })
235
+ };
236
+ const result = evaluateConstraints({
237
+ componentName: "Hero",
238
+ parentId: "root",
239
+ slot: "children",
240
+ document: doc,
241
+ components
242
+ });
243
+ expect(result.allowed).toBe(false);
244
+ expect(result.violation.message).toBe("Only one Hero per page");
245
+ });
246
+ it("should not evaluate parent's constraints against the placed component", ()=>{
247
+ const parent = makeElement("parent-1", "Tabs");
248
+ const doc = makeDocument([
249
+ parent
250
+ ]);
251
+ const components = {
252
+ Tabs: makeManifest("Tabs", {
253
+ constraints: [
254
+ (ctx)=>ctx.block("This should not run for children")
255
+ ]
256
+ }),
257
+ Button: makeManifest("Button")
258
+ };
259
+ const result = evaluateConstraints({
260
+ componentName: "Button",
261
+ parentId: "parent-1",
262
+ slot: "children",
263
+ document: doc,
264
+ components
265
+ });
266
+ expect(result.allowed).toBe(true);
267
+ });
268
+ it("should short-circuit on the first failed constraint", ()=>{
269
+ const parent = makeElement("parent-1", "Container");
270
+ const doc = makeDocument([
271
+ parent
272
+ ]);
273
+ const components = {
274
+ Container: makeManifest("Container"),
275
+ Widget: makeManifest("Widget", {
276
+ constraints: [
277
+ ()=>{},
278
+ (ctx)=>ctx.block("Always fails"),
279
+ (ctx)=>ctx.block("Also fails")
280
+ ]
281
+ })
282
+ };
283
+ const result = evaluateConstraints({
284
+ componentName: "Widget",
285
+ parentId: "parent-1",
286
+ slot: "children",
287
+ document: doc,
288
+ components
289
+ });
290
+ expect(result.allowed).toBe(false);
291
+ expect(result.violation.message).toBe("Always fails");
292
+ });
293
+ it("should allow placement when component manifest is not found", ()=>{
294
+ const parent = makeElement("parent-1", "Container");
295
+ const doc = makeDocument([
296
+ parent
297
+ ]);
298
+ const components = {
299
+ Container: makeManifest("Container")
300
+ };
301
+ const result = evaluateConstraints({
302
+ componentName: "Unknown",
303
+ parentId: "parent-1",
304
+ slot: "children",
305
+ document: doc,
306
+ components
307
+ });
308
+ expect(result.allowed).toBe(true);
309
+ });
310
+ it("should allow placement when parent element is not found", ()=>{
311
+ const doc = makeDocument([]);
312
+ const components = {
313
+ Button: makeManifest("Button")
314
+ };
315
+ const result = evaluateConstraints({
316
+ componentName: "Button",
317
+ parentId: "nonexistent",
318
+ slot: "children",
319
+ document: doc,
320
+ components
321
+ });
322
+ expect(result.allowed).toBe(true);
323
+ });
324
+ it("should handle multi-slot parent constraint checking specific slot", ()=>{
325
+ const parent = makeElement("parent-1", "TwoColumn");
326
+ const child = makeElement("child-1", "Widget", {
327
+ id: "parent-1",
328
+ slot: "leftColumn"
329
+ });
330
+ const doc = makeDocument([
331
+ parent,
332
+ child
333
+ ], {
334
+ "parent-1": {
335
+ inputs: {
336
+ leftColumn: {
337
+ id: "inp-left",
338
+ type: "slot",
339
+ static: [
340
+ "child-1"
341
+ ]
342
+ },
343
+ rightColumn: {
344
+ id: "inp-right",
345
+ type: "slot",
346
+ static: []
347
+ }
348
+ }
1020
349
  }
1021
- }
1022
- }
1023
- });
1024
- let captured = {};
1025
- const comps = {
1026
- FunnelBuilder: makeManifest("FunnelBuilder"),
1027
- Step: makeManifest("Step"),
1028
- Widget: makeManifest("Widget", {
1029
- constraints: [ctx => {
1030
- captured = {
1031
- index: ctx.parent.childIndex(),
1032
- count: ctx.parent.childCount(),
1033
- isFirst: ctx.parent.isFirstChild(),
1034
- isLast: ctx.parent.isLastChild()
350
+ });
351
+ const components = {
352
+ TwoColumn: makeManifest("TwoColumn"),
353
+ Widget: makeManifest("Widget", {
354
+ constraints: [
355
+ (ctx)=>{
356
+ if (!(ctx.slotChildCount() < 1)) return ctx.block("Slot is full");
357
+ }
358
+ ]
359
+ })
360
+ };
361
+ const leftResult = evaluateConstraints({
362
+ componentName: "Widget",
363
+ parentId: "parent-1",
364
+ slot: "leftColumn",
365
+ document: doc,
366
+ components
367
+ });
368
+ expect(leftResult.allowed).toBe(false);
369
+ const rightResult = evaluateConstraints({
370
+ componentName: "Widget",
371
+ parentId: "parent-1",
372
+ slot: "rightColumn",
373
+ document: doc,
374
+ components
375
+ });
376
+ expect(rightResult.allowed).toBe(true);
377
+ });
378
+ it("should provide a default message when constraint has no message", ()=>{
379
+ const parent = makeElement("parent-1", "Container");
380
+ const doc = makeDocument([
381
+ parent
382
+ ]);
383
+ const components = {
384
+ Container: makeManifest("Container"),
385
+ Widget: makeManifest("Widget", {
386
+ constraints: [
387
+ (ctx)=>ctx.block("Blocked")
388
+ ]
389
+ })
390
+ };
391
+ const result = evaluateConstraints({
392
+ componentName: "Widget",
393
+ parentId: "parent-1",
394
+ slot: "children",
395
+ document: doc,
396
+ components
397
+ });
398
+ expect(result.violation.message).toBe("Blocked");
399
+ });
400
+ it("should use thrown error message as the violation message", ()=>{
401
+ const parent = makeElement("parent-1", "Container");
402
+ const doc = makeDocument([
403
+ parent
404
+ ]);
405
+ const components = {
406
+ Container: makeManifest("Container"),
407
+ Widget: makeManifest("Widget", {
408
+ constraints: [
409
+ (ctx)=>{
410
+ throw new Error(`Widget cannot be placed inside ${ctx.parent.name}`);
411
+ }
412
+ ]
413
+ })
414
+ };
415
+ const result = evaluateConstraints({
416
+ componentName: "Widget",
417
+ parentId: "parent-1",
418
+ slot: "children",
419
+ document: doc,
420
+ components
421
+ });
422
+ expect(result.allowed).toBe(false);
423
+ expect(result.violation.message).toBe("Widget cannot be placed inside Container");
424
+ });
425
+ it("should fall back to static message when a non-Error is thrown", ()=>{
426
+ const parent = makeElement("parent-1", "Container");
427
+ const doc = makeDocument([
428
+ parent
429
+ ]);
430
+ const components = {
431
+ Container: makeManifest("Container"),
432
+ Widget: makeManifest("Widget", {
433
+ constraints: [
434
+ ()=>{
435
+ throw "not an error object";
436
+ }
437
+ ]
438
+ })
439
+ };
440
+ const result = evaluateConstraints({
441
+ componentName: "Widget",
442
+ parentId: "parent-1",
443
+ slot: "children",
444
+ document: doc,
445
+ components
446
+ });
447
+ expect(result.allowed).toBe(false);
448
+ expect(result.violation.message).toBe("Cannot place Widget here.");
449
+ });
450
+ it("should fall back to default message when a non-Error is thrown and no static message", ()=>{
451
+ const parent = makeElement("parent-1", "Container");
452
+ const doc = makeDocument([
453
+ parent
454
+ ]);
455
+ const components = {
456
+ Container: makeManifest("Container"),
457
+ Widget: makeManifest("Widget", {
458
+ constraints: [
459
+ ()=>{
460
+ throw null;
461
+ }
462
+ ]
463
+ })
464
+ };
465
+ const result = evaluateConstraints({
466
+ componentName: "Widget",
467
+ parentId: "parent-1",
468
+ slot: "children",
469
+ document: doc,
470
+ components
471
+ });
472
+ expect(result.allowed).toBe(false);
473
+ expect(result.violation.message).toBe("Cannot place Widget here.");
474
+ });
475
+ describe("ctx.isChildOf", ()=>{
476
+ it("should return true when direct parent matches", ()=>{
477
+ const parent = makeElement("parent-1", "Tabs");
478
+ const doc = makeDocument([
479
+ parent
480
+ ]);
481
+ const components = {
482
+ Tabs: makeManifest("Tabs"),
483
+ TabPanel: makeManifest("TabPanel", {
484
+ constraints: [
485
+ (ctx)=>{
486
+ if (!ctx.isChildOf("Tabs")) return ctx.block("TabPanel must be a direct child of Tabs");
487
+ }
488
+ ]
489
+ })
1035
490
  };
1036
- }]
1037
- })
1038
- };
1039
-
1040
- // Place into first step
1041
- evaluateConstraints({
1042
- componentName: "Widget",
1043
- parentId: "step-0",
1044
- slot: "children",
1045
- document: doc,
1046
- components: comps
1047
- });
1048
- expect(captured.index).toBe(0);
1049
- expect(captured.count).toBe(3);
1050
- expect(captured.isFirst).toBe(true);
1051
- expect(captured.isLast).toBe(false);
1052
-
1053
- // Place into middle step
1054
- evaluateConstraints({
1055
- componentName: "Widget",
1056
- parentId: "step-1",
1057
- slot: "children",
1058
- document: doc,
1059
- components: comps
1060
- });
1061
- expect(captured.index).toBe(1);
1062
- expect(captured.count).toBe(3);
1063
- expect(captured.isFirst).toBe(false);
1064
- expect(captured.isLast).toBe(false);
1065
-
1066
- // Place into last step
1067
- evaluateConstraints({
1068
- componentName: "Widget",
1069
- parentId: "step-2",
1070
- slot: "children",
1071
- document: doc,
1072
- components: comps
1073
- });
1074
- expect(captured.index).toBe(2);
1075
- expect(captured.count).toBe(3);
1076
- expect(captured.isFirst).toBe(false);
1077
- expect(captured.isLast).toBe(true);
491
+ const result = evaluateConstraints({
492
+ componentName: "TabPanel",
493
+ parentId: "parent-1",
494
+ slot: "children",
495
+ document: doc,
496
+ components
497
+ });
498
+ expect(result.allowed).toBe(true);
499
+ });
500
+ it("should return false when direct parent does not match", ()=>{
501
+ const parent = makeElement("parent-1", "Container");
502
+ const doc = makeDocument([
503
+ parent
504
+ ]);
505
+ const components = {
506
+ Container: makeManifest("Container"),
507
+ TabPanel: makeManifest("TabPanel", {
508
+ constraints: [
509
+ (ctx)=>{
510
+ if (!ctx.isChildOf("Tabs")) return ctx.block("TabPanel must be a direct child of Tabs");
511
+ }
512
+ ]
513
+ })
514
+ };
515
+ const result = evaluateConstraints({
516
+ componentName: "TabPanel",
517
+ parentId: "parent-1",
518
+ slot: "children",
519
+ document: doc,
520
+ components
521
+ });
522
+ expect(result.allowed).toBe(false);
523
+ });
524
+ });
525
+ describe("ctx.isDescendantOf", ()=>{
526
+ it("should return true when direct parent matches", ()=>{
527
+ const parent = makeElement("parent-1", "ProductBox");
528
+ const doc = makeDocument([
529
+ parent
530
+ ]);
531
+ const components = {
532
+ ProductBox: makeManifest("ProductBox"),
533
+ ProductPrice: makeManifest("ProductPrice", {
534
+ constraints: [
535
+ (ctx)=>{
536
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
537
+ }
538
+ ]
539
+ })
540
+ };
541
+ const result = evaluateConstraints({
542
+ componentName: "ProductPrice",
543
+ parentId: "parent-1",
544
+ slot: "children",
545
+ document: doc,
546
+ components
547
+ });
548
+ expect(result.allowed).toBe(true);
549
+ });
550
+ it("should return true when grandparent matches", ()=>{
551
+ const root = makeElement("root", "ProductBox");
552
+ const inner = makeElement("inner", "Container", {
553
+ id: "root",
554
+ slot: "children"
555
+ });
556
+ const doc = makeDocument([
557
+ root,
558
+ inner
559
+ ]);
560
+ const components = {
561
+ ProductBox: makeManifest("ProductBox"),
562
+ Container: makeManifest("Container"),
563
+ ProductPrice: makeManifest("ProductPrice", {
564
+ constraints: [
565
+ (ctx)=>{
566
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
567
+ }
568
+ ]
569
+ })
570
+ };
571
+ const result = evaluateConstraints({
572
+ componentName: "ProductPrice",
573
+ parentId: "inner",
574
+ slot: "children",
575
+ document: doc,
576
+ components
577
+ });
578
+ expect(result.allowed).toBe(true);
579
+ });
580
+ it("should return false when no ancestor matches", ()=>{
581
+ const root = makeElement("root", "Page");
582
+ const inner = makeElement("inner", "Section", {
583
+ id: "root",
584
+ slot: "children"
585
+ });
586
+ const doc = makeDocument([
587
+ root,
588
+ inner
589
+ ]);
590
+ const components = {
591
+ Page: makeManifest("Page"),
592
+ Section: makeManifest("Section"),
593
+ ProductPrice: makeManifest("ProductPrice", {
594
+ constraints: [
595
+ (ctx)=>{
596
+ if (!ctx.isDescendantOf("ProductBox")) return ctx.block("Blocked");
597
+ }
598
+ ]
599
+ })
600
+ };
601
+ const result = evaluateConstraints({
602
+ componentName: "ProductPrice",
603
+ parentId: "inner",
604
+ slot: "children",
605
+ document: doc,
606
+ components
607
+ });
608
+ expect(result.allowed).toBe(false);
609
+ });
610
+ });
611
+ describe("ctx.slotChildCount", ()=>{
612
+ it("should return the number of children in the target slot", ()=>{
613
+ const parent = makeElement("parent-1", "Grid");
614
+ const doc = makeDocument([
615
+ parent
616
+ ], {
617
+ "parent-1": {
618
+ inputs: {
619
+ children: {
620
+ id: "inp-1",
621
+ type: "slot",
622
+ static: [
623
+ "child-1",
624
+ "child-2",
625
+ "child-3"
626
+ ]
627
+ }
628
+ }
629
+ }
630
+ });
631
+ const components = {
632
+ Grid: makeManifest("Grid"),
633
+ Cell: makeManifest("Cell", {
634
+ constraints: [
635
+ (ctx)=>{
636
+ if (!(ctx.slotChildCount() < 3)) return ctx.block("Max 3 children");
637
+ }
638
+ ]
639
+ })
640
+ };
641
+ const result = evaluateConstraints({
642
+ componentName: "Cell",
643
+ parentId: "parent-1",
644
+ slot: "children",
645
+ document: doc,
646
+ components
647
+ });
648
+ expect(result.allowed).toBe(false);
649
+ });
650
+ it("should return 0 when slot has no bindings", ()=>{
651
+ const parent = makeElement("parent-1", "Container");
652
+ const doc = makeDocument([
653
+ parent
654
+ ]);
655
+ const components = {
656
+ Container: makeManifest("Container"),
657
+ Widget: makeManifest("Widget", {
658
+ constraints: [
659
+ (ctx)=>{
660
+ if (!(ctx.slotChildCount() < 5)) return ctx.block("Blocked");
661
+ }
662
+ ]
663
+ })
664
+ };
665
+ const result = evaluateConstraints({
666
+ componentName: "Widget",
667
+ parentId: "parent-1",
668
+ slot: "children",
669
+ document: doc,
670
+ components
671
+ });
672
+ expect(result.allowed).toBe(true);
673
+ });
674
+ });
675
+ describe("ctx.hasTag", ()=>{
676
+ it("should return true when the placed component has the tag", ()=>{
677
+ const parent = makeElement("parent-1", "Container");
678
+ const doc = makeDocument([
679
+ parent
680
+ ]);
681
+ const components = {
682
+ Container: makeManifest("Container"),
683
+ FunnelField: makeManifest("FunnelField", {
684
+ tags: [
685
+ "funnel-field",
686
+ "input"
687
+ ],
688
+ constraints: [
689
+ (ctx)=>{
690
+ if (ctx.hasTag("funnel-field")) return ctx.block("Funnel fields not allowed here");
691
+ }
692
+ ]
693
+ })
694
+ };
695
+ const result = evaluateConstraints({
696
+ componentName: "FunnelField",
697
+ parentId: "parent-1",
698
+ slot: "children",
699
+ document: doc,
700
+ components
701
+ });
702
+ expect(result.allowed).toBe(false);
703
+ });
704
+ it("should return false when the placed component does not have the tag", ()=>{
705
+ const parent = makeElement("parent-1", "Container");
706
+ const doc = makeDocument([
707
+ parent
708
+ ]);
709
+ const components = {
710
+ Container: makeManifest("Container"),
711
+ Button: makeManifest("Button", {
712
+ constraints: [
713
+ (ctx)=>{
714
+ if (ctx.hasTag("funnel-field")) return ctx.block("Blocked");
715
+ }
716
+ ]
717
+ })
718
+ };
719
+ const result = evaluateConstraints({
720
+ componentName: "Button",
721
+ parentId: "parent-1",
722
+ slot: "children",
723
+ document: doc,
724
+ components
725
+ });
726
+ expect(result.allowed).toBe(true);
727
+ });
728
+ });
729
+ describe("ctx.getAncestor", ()=>{
730
+ it("should return the matching ancestor element context", ()=>{
731
+ const root = makeElement("root", "ProductBox");
732
+ const inner = makeElement("inner", "Container", {
733
+ id: "root",
734
+ slot: "children"
735
+ });
736
+ const doc = makeDocument([
737
+ root,
738
+ inner
739
+ ]);
740
+ const components = {
741
+ ProductBox: makeManifest("ProductBox", {
742
+ tags: [
743
+ "product"
744
+ ]
745
+ }),
746
+ Container: makeManifest("Container"),
747
+ ProductPrice: makeManifest("ProductPrice", {
748
+ constraints: [
749
+ (ctx)=>{
750
+ const ancestor = ctx.getAncestor("ProductBox");
751
+ if (!(void 0 !== ancestor && "ProductBox" === ancestor.name)) return ctx.block("Blocked");
752
+ }
753
+ ]
754
+ })
755
+ };
756
+ const result = evaluateConstraints({
757
+ componentName: "ProductPrice",
758
+ parentId: "inner",
759
+ slot: "children",
760
+ document: doc,
761
+ components
762
+ });
763
+ expect(result.allowed).toBe(true);
764
+ });
765
+ it("should return undefined when no ancestor matches", ()=>{
766
+ const root = makeElement("root", "Page");
767
+ const doc = makeDocument([
768
+ root
769
+ ]);
770
+ const components = {
771
+ Page: makeManifest("Page"),
772
+ Widget: makeManifest("Widget", {
773
+ constraints: [
774
+ (ctx)=>{
775
+ if (void 0 === ctx.getAncestor("ProductBox")) return ctx.block("Blocked");
776
+ }
777
+ ]
778
+ })
779
+ };
780
+ const result = evaluateConstraints({
781
+ componentName: "Widget",
782
+ parentId: "root",
783
+ slot: "children",
784
+ document: doc,
785
+ components
786
+ });
787
+ expect(result.allowed).toBe(false);
788
+ });
789
+ });
790
+ describe("ctx.countInstances", ()=>{
791
+ it("should count instances of a component in the document", ()=>{
792
+ const root = makeElement("root", "Page");
793
+ const hero1 = makeElement("hero-1", "Hero", {
794
+ id: "root",
795
+ slot: "children"
796
+ });
797
+ const hero2 = makeElement("hero-2", "Hero", {
798
+ id: "root",
799
+ slot: "children"
800
+ });
801
+ const doc = makeDocument([
802
+ root,
803
+ hero1,
804
+ hero2
805
+ ]);
806
+ const components = {
807
+ Page: makeManifest("Page"),
808
+ Hero: makeManifest("Hero", {
809
+ constraints: [
810
+ (ctx)=>{
811
+ if (!(ctx.countInstances("Hero") < 2)) return ctx.block("Max 2 Heroes");
812
+ }
813
+ ]
814
+ })
815
+ };
816
+ const result = evaluateConstraints({
817
+ componentName: "Hero",
818
+ parentId: "root",
819
+ slot: "children",
820
+ document: doc,
821
+ components
822
+ });
823
+ expect(result.allowed).toBe(false);
824
+ });
825
+ });
826
+ describe("ctx.component", ()=>{
827
+ it("should expose the placed component's name and tags", ()=>{
828
+ const parent = makeElement("parent-1", "Container");
829
+ const doc = makeDocument([
830
+ parent
831
+ ]);
832
+ let capturedComponent;
833
+ const components = {
834
+ Container: makeManifest("Container"),
835
+ Widget: makeManifest("Widget", {
836
+ tags: [
837
+ "interactive",
838
+ "form"
839
+ ],
840
+ constraints: [
841
+ (ctx)=>{
842
+ capturedComponent = ctx.component;
843
+ }
844
+ ]
845
+ })
846
+ };
847
+ evaluateConstraints({
848
+ componentName: "Widget",
849
+ parentId: "parent-1",
850
+ slot: "children",
851
+ document: doc,
852
+ components
853
+ });
854
+ expect(capturedComponent).toBeDefined();
855
+ expect(capturedComponent.name).toBe("Widget");
856
+ expect(capturedComponent.tags).toEqual([
857
+ "interactive",
858
+ "form"
859
+ ]);
860
+ });
861
+ });
862
+ describe("parent.getParent", ()=>{
863
+ it("should return the grandparent element context", ()=>{
864
+ const root = makeElement("root", "Page");
865
+ const section = makeElement("section", "Section", {
866
+ id: "root",
867
+ slot: "children"
868
+ });
869
+ const doc = makeDocument([
870
+ root,
871
+ section
872
+ ]);
873
+ let grandparentName;
874
+ const components = {
875
+ Page: makeManifest("Page"),
876
+ Section: makeManifest("Section"),
877
+ Widget: makeManifest("Widget", {
878
+ constraints: [
879
+ (ctx)=>{
880
+ const grandparent = ctx.parent.getParent();
881
+ grandparentName = grandparent?.name;
882
+ }
883
+ ]
884
+ })
885
+ };
886
+ evaluateConstraints({
887
+ componentName: "Widget",
888
+ parentId: "section",
889
+ slot: "children",
890
+ document: doc,
891
+ components
892
+ });
893
+ expect(grandparentName).toBe("Page");
894
+ });
895
+ it("should return undefined for root element", ()=>{
896
+ const root = makeElement("root", "Page");
897
+ const doc = makeDocument([
898
+ root
899
+ ]);
900
+ let parentResult = "not-called";
901
+ const components = {
902
+ Page: makeManifest("Page"),
903
+ Widget: makeManifest("Widget", {
904
+ constraints: [
905
+ (ctx)=>{
906
+ parentResult = ctx.parent.getParent();
907
+ }
908
+ ]
909
+ })
910
+ };
911
+ evaluateConstraints({
912
+ componentName: "Widget",
913
+ parentId: "root",
914
+ slot: "children",
915
+ document: doc,
916
+ components
917
+ });
918
+ expect(parentResult).toBeUndefined();
919
+ });
920
+ });
921
+ describe("parent.childIndex / childCount / isFirstChild / isLastChild", ()=>{
922
+ it("should return -1 for elements not in a list slot", ()=>{
923
+ const root = makeElement("root", "Page");
924
+ const doc = makeDocument([
925
+ root
926
+ ]);
927
+ let capturedIndex;
928
+ let capturedCount;
929
+ const components = {
930
+ Page: makeManifest("Page"),
931
+ Widget: makeManifest("Widget", {
932
+ constraints: [
933
+ (ctx)=>{
934
+ capturedIndex = ctx.parent.childIndex();
935
+ capturedCount = ctx.parent.childCount();
936
+ }
937
+ ]
938
+ })
939
+ };
940
+ evaluateConstraints({
941
+ componentName: "Widget",
942
+ parentId: "root",
943
+ slot: "children",
944
+ document: doc,
945
+ components
946
+ });
947
+ expect(capturedIndex).toBe(-1);
948
+ expect(capturedCount).toBe(-1);
949
+ });
950
+ it("should parse child index from list slot path", ()=>{
951
+ const root = makeElement("root", "FunnelBuilder");
952
+ const step = makeElement("step-1", "Step", {
953
+ id: "root",
954
+ slot: "steps/2/step"
955
+ });
956
+ const doc = makeDocument([
957
+ root,
958
+ step
959
+ ], {
960
+ root: {
961
+ inputs: {
962
+ "steps/0/step": {
963
+ id: "s0",
964
+ type: "slot",
965
+ static: [
966
+ "step-0"
967
+ ]
968
+ },
969
+ "steps/1/step": {
970
+ id: "s1",
971
+ type: "slot",
972
+ static: [
973
+ "step-other"
974
+ ]
975
+ },
976
+ "steps/2/step": {
977
+ id: "s2",
978
+ type: "slot",
979
+ static: [
980
+ "step-1"
981
+ ]
982
+ }
983
+ }
984
+ }
985
+ });
986
+ let capturedIndex;
987
+ let capturedCount;
988
+ let capturedIsLast;
989
+ let capturedIsFirst;
990
+ const components = {
991
+ FunnelBuilder: makeManifest("FunnelBuilder"),
992
+ Step: makeManifest("Step"),
993
+ Widget: makeManifest("Widget", {
994
+ constraints: [
995
+ (ctx)=>{
996
+ capturedIndex = ctx.parent.childIndex();
997
+ capturedCount = ctx.parent.childCount();
998
+ capturedIsLast = ctx.parent.isLastChild();
999
+ capturedIsFirst = ctx.parent.isFirstChild();
1000
+ }
1001
+ ]
1002
+ })
1003
+ };
1004
+ evaluateConstraints({
1005
+ componentName: "Widget",
1006
+ parentId: "step-1",
1007
+ slot: "children",
1008
+ document: doc,
1009
+ components
1010
+ });
1011
+ expect(capturedIndex).toBe(2);
1012
+ expect(capturedCount).toBe(3);
1013
+ expect(capturedIsLast).toBe(true);
1014
+ expect(capturedIsFirst).toBe(false);
1015
+ });
1016
+ it("should identify the first child correctly", ()=>{
1017
+ const root = makeElement("root", "FunnelBuilder");
1018
+ const step = makeElement("step-0", "Step", {
1019
+ id: "root",
1020
+ slot: "steps/0/step"
1021
+ });
1022
+ const doc = makeDocument([
1023
+ root,
1024
+ step
1025
+ ], {
1026
+ root: {
1027
+ inputs: {
1028
+ "steps/0/step": {
1029
+ id: "s0",
1030
+ type: "slot",
1031
+ static: [
1032
+ "step-0"
1033
+ ]
1034
+ },
1035
+ "steps/1/step": {
1036
+ id: "s1",
1037
+ type: "slot",
1038
+ static: [
1039
+ "step-1"
1040
+ ]
1041
+ }
1042
+ }
1043
+ }
1044
+ });
1045
+ let capturedIsFirst;
1046
+ let capturedIsLast;
1047
+ const components = {
1048
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1049
+ Step: makeManifest("Step"),
1050
+ Widget: makeManifest("Widget", {
1051
+ constraints: [
1052
+ (ctx)=>{
1053
+ capturedIsFirst = ctx.parent.isFirstChild();
1054
+ capturedIsLast = ctx.parent.isLastChild();
1055
+ }
1056
+ ]
1057
+ })
1058
+ };
1059
+ evaluateConstraints({
1060
+ componentName: "Widget",
1061
+ parentId: "step-0",
1062
+ slot: "children",
1063
+ document: doc,
1064
+ components
1065
+ });
1066
+ expect(capturedIsFirst).toBe(true);
1067
+ expect(capturedIsLast).toBe(false);
1068
+ });
1069
+ it("should resolve position from bindings even when element.parent.slot is stale", ()=>{
1070
+ const root = makeElement("root", "FunnelBuilder");
1071
+ const step = makeElement("step-final", "Step", {
1072
+ id: "root",
1073
+ slot: "steps/1/step"
1074
+ });
1075
+ const doc = makeDocument([
1076
+ root,
1077
+ step
1078
+ ], {
1079
+ root: {
1080
+ inputs: {
1081
+ "steps/0/step": {
1082
+ id: "s0",
1083
+ type: "slot",
1084
+ static: "step-0"
1085
+ },
1086
+ "steps/1/step": {
1087
+ id: "s1",
1088
+ type: "slot",
1089
+ static: "step-1"
1090
+ },
1091
+ "steps/2/step": {
1092
+ id: "s2",
1093
+ type: "slot",
1094
+ static: "step-2"
1095
+ },
1096
+ "steps/3/step": {
1097
+ id: "s3",
1098
+ type: "slot",
1099
+ static: "step-final"
1100
+ }
1101
+ }
1102
+ }
1103
+ });
1104
+ let capturedIndex;
1105
+ let capturedIsLast;
1106
+ const components = {
1107
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1108
+ Step: makeManifest("Step"),
1109
+ Widget: makeManifest("Widget", {
1110
+ constraints: [
1111
+ (ctx)=>{
1112
+ capturedIndex = ctx.parent.childIndex();
1113
+ capturedIsLast = ctx.parent.isLastChild();
1114
+ }
1115
+ ]
1116
+ })
1117
+ };
1118
+ evaluateConstraints({
1119
+ componentName: "Widget",
1120
+ parentId: "step-final",
1121
+ slot: "children",
1122
+ document: doc,
1123
+ components
1124
+ });
1125
+ expect(capturedIndex).toBe(3);
1126
+ expect(capturedIsLast).toBe(true);
1127
+ });
1128
+ it("should resolve position from direct list slot binding (static is an array of IDs)", ()=>{
1129
+ const root = makeElement("root", "FunnelBuilder");
1130
+ const step0 = makeElement("step-0", "Step", {
1131
+ id: "root",
1132
+ slot: "steps"
1133
+ });
1134
+ const step1 = makeElement("step-1", "Step", {
1135
+ id: "root",
1136
+ slot: "steps"
1137
+ });
1138
+ const step2 = makeElement("step-2", "Step", {
1139
+ id: "root",
1140
+ slot: "steps"
1141
+ });
1142
+ const doc = makeDocument([
1143
+ root,
1144
+ step0,
1145
+ step1,
1146
+ step2
1147
+ ], {
1148
+ root: {
1149
+ inputs: {
1150
+ steps: {
1151
+ id: "s",
1152
+ type: "slot",
1153
+ list: true,
1154
+ static: [
1155
+ "step-0",
1156
+ "step-1",
1157
+ "step-2"
1158
+ ]
1159
+ }
1160
+ }
1161
+ }
1162
+ });
1163
+ let captured = {};
1164
+ const comps = {
1165
+ FunnelBuilder: makeManifest("FunnelBuilder"),
1166
+ Step: makeManifest("Step"),
1167
+ Widget: makeManifest("Widget", {
1168
+ constraints: [
1169
+ (ctx)=>{
1170
+ captured = {
1171
+ index: ctx.parent.childIndex(),
1172
+ count: ctx.parent.childCount(),
1173
+ isFirst: ctx.parent.isFirstChild(),
1174
+ isLast: ctx.parent.isLastChild()
1175
+ };
1176
+ }
1177
+ ]
1178
+ })
1179
+ };
1180
+ evaluateConstraints({
1181
+ componentName: "Widget",
1182
+ parentId: "step-0",
1183
+ slot: "children",
1184
+ document: doc,
1185
+ components: comps
1186
+ });
1187
+ expect(captured.index).toBe(0);
1188
+ expect(captured.count).toBe(3);
1189
+ expect(captured.isFirst).toBe(true);
1190
+ expect(captured.isLast).toBe(false);
1191
+ evaluateConstraints({
1192
+ componentName: "Widget",
1193
+ parentId: "step-1",
1194
+ slot: "children",
1195
+ document: doc,
1196
+ components: comps
1197
+ });
1198
+ expect(captured.index).toBe(1);
1199
+ expect(captured.count).toBe(3);
1200
+ expect(captured.isFirst).toBe(false);
1201
+ expect(captured.isLast).toBe(false);
1202
+ evaluateConstraints({
1203
+ componentName: "Widget",
1204
+ parentId: "step-2",
1205
+ slot: "children",
1206
+ document: doc,
1207
+ components: comps
1208
+ });
1209
+ expect(captured.index).toBe(2);
1210
+ expect(captured.count).toBe(3);
1211
+ expect(captured.isFirst).toBe(false);
1212
+ expect(captured.isLast).toBe(true);
1213
+ });
1214
+ });
1215
+ describe("descendantConstraints", ()=>{
1216
+ it("should block a direct child via parent's descendantConstraints", ()=>{
1217
+ const parent = makeElement("parent-1", "Step");
1218
+ const doc = makeDocument([
1219
+ parent
1220
+ ]);
1221
+ const components = {
1222
+ Step: makeManifest("Step", {
1223
+ descendantConstraints: [
1224
+ (ctx)=>{
1225
+ if (ctx.hasTag("funnel-field")) return ctx.block("No funnel fields allowed");
1226
+ }
1227
+ ]
1228
+ }),
1229
+ TextField: makeManifest("TextField", {
1230
+ tags: [
1231
+ "funnel-field"
1232
+ ]
1233
+ })
1234
+ };
1235
+ const result = evaluateConstraints({
1236
+ componentName: "TextField",
1237
+ parentId: "parent-1",
1238
+ slot: "children",
1239
+ document: doc,
1240
+ components
1241
+ });
1242
+ expect(result.allowed).toBe(false);
1243
+ expect(result.violation.message).toBe("No funnel fields allowed");
1244
+ });
1245
+ it("should block a nested descendant via ancestor's descendantConstraints", ()=>{
1246
+ const step = makeElement("step-1", "Step");
1247
+ const container = makeElement("container-1", "Container", {
1248
+ id: "step-1",
1249
+ slot: "children"
1250
+ });
1251
+ const doc = makeDocument([
1252
+ step,
1253
+ container
1254
+ ]);
1255
+ const components = {
1256
+ Step: makeManifest("Step", {
1257
+ descendantConstraints: [
1258
+ (ctx)=>{
1259
+ if (ctx.hasTag("funnel-field")) return ctx.block("No funnel fields in this step");
1260
+ }
1261
+ ]
1262
+ }),
1263
+ Container: makeManifest("Container"),
1264
+ TextField: makeManifest("TextField", {
1265
+ tags: [
1266
+ "funnel-field"
1267
+ ]
1268
+ })
1269
+ };
1270
+ const result = evaluateConstraints({
1271
+ componentName: "TextField",
1272
+ parentId: "container-1",
1273
+ slot: "children",
1274
+ document: doc,
1275
+ components
1276
+ });
1277
+ expect(result.allowed).toBe(false);
1278
+ expect(result.violation.message).toBe("No funnel fields in this step");
1279
+ });
1280
+ it("should allow when descendantConstraints pass", ()=>{
1281
+ const step = makeElement("step-1", "Step");
1282
+ const doc = makeDocument([
1283
+ step
1284
+ ]);
1285
+ const components = {
1286
+ Step: makeManifest("Step", {
1287
+ descendantConstraints: [
1288
+ (ctx)=>{
1289
+ if (ctx.hasTag("funnel-field")) return ctx.block("Blocked");
1290
+ }
1291
+ ]
1292
+ }),
1293
+ Button: makeManifest("Button")
1294
+ };
1295
+ const result = evaluateConstraints({
1296
+ componentName: "Button",
1297
+ parentId: "step-1",
1298
+ slot: "children",
1299
+ document: doc,
1300
+ components
1301
+ });
1302
+ expect(result.allowed).toBe(true);
1303
+ });
1304
+ it("should not run parent's descendantConstraints for unrelated ancestors", ()=>{
1305
+ const page = makeElement("page", "Page");
1306
+ const step = makeElement("step-1", "Step", {
1307
+ id: "page",
1308
+ slot: "children"
1309
+ });
1310
+ const doc = makeDocument([
1311
+ page,
1312
+ step
1313
+ ]);
1314
+ const components = {
1315
+ Page: makeManifest("Page", {
1316
+ descendantConstraints: [
1317
+ (ctx)=>ctx.block("Page blocks everything")
1318
+ ]
1319
+ }),
1320
+ Step: makeManifest("Step"),
1321
+ Button: makeManifest("Button")
1322
+ };
1323
+ const result = evaluateConstraints({
1324
+ componentName: "Button",
1325
+ parentId: "step-1",
1326
+ slot: "children",
1327
+ document: doc,
1328
+ components
1329
+ });
1330
+ expect(result.allowed).toBe(false);
1331
+ expect(result.violation.message).toBe("Page blocks everything");
1332
+ });
1333
+ it("should evaluate component constraints before descendantConstraints", ()=>{
1334
+ const parent = makeElement("parent-1", "Step");
1335
+ const doc = makeDocument([
1336
+ parent
1337
+ ]);
1338
+ const components = {
1339
+ Step: makeManifest("Step", {
1340
+ descendantConstraints: [
1341
+ (ctx)=>ctx.block("descendant constraint")
1342
+ ]
1343
+ }),
1344
+ Widget: makeManifest("Widget", {
1345
+ constraints: [
1346
+ (ctx)=>ctx.block("component constraint")
1347
+ ]
1348
+ })
1349
+ };
1350
+ const result = evaluateConstraints({
1351
+ componentName: "Widget",
1352
+ parentId: "parent-1",
1353
+ slot: "children",
1354
+ document: doc,
1355
+ components
1356
+ });
1357
+ expect(result.violation.message).toBe("component constraint");
1358
+ });
1078
1359
  });
1079
- });
1080
- describe("descendantConstraints", () => {
1081
- it("should block a direct child via parent's descendantConstraints", () => {
1082
- const parent = makeElement("parent-1", "Step");
1083
- const doc = makeDocument([parent]);
1084
- const components = {
1085
- Step: makeManifest("Step", {
1086
- descendantConstraints: [ctx => {
1087
- if (ctx.hasTag("funnel-field")) {
1088
- return ctx.block("No funnel fields allowed");
1360
+ });
1361
+ describe("evaluateDeleteConstraint", ()=>{
1362
+ it("should allow when canDelete is undefined", ()=>{
1363
+ const el = makeElement("el-1", "Widget", {
1364
+ id: "root",
1365
+ slot: "children"
1366
+ });
1367
+ const root = makeElement("root", "Page");
1368
+ const doc = makeDocument([
1369
+ root,
1370
+ el
1371
+ ]);
1372
+ const components = {
1373
+ Page: makeManifest("Page"),
1374
+ Widget: makeManifest("Widget")
1375
+ };
1376
+ const result = evaluateDeleteConstraint({
1377
+ elementId: "el-1",
1378
+ document: doc,
1379
+ components
1380
+ });
1381
+ expect(result.allowed).toBe(true);
1382
+ });
1383
+ it("should allow when canDelete is true", ()=>{
1384
+ const el = makeElement("el-1", "Widget", {
1385
+ id: "root",
1386
+ slot: "children"
1387
+ });
1388
+ const root = makeElement("root", "Page");
1389
+ const doc = makeDocument([
1390
+ root,
1391
+ el
1392
+ ]);
1393
+ const components = {
1394
+ Page: makeManifest("Page"),
1395
+ Widget: makeManifest("Widget", {
1396
+ canDelete: true
1397
+ })
1398
+ };
1399
+ const result = evaluateDeleteConstraint({
1400
+ elementId: "el-1",
1401
+ document: doc,
1402
+ components
1403
+ });
1404
+ expect(result.allowed).toBe(true);
1405
+ });
1406
+ it("should block when canDelete is false", ()=>{
1407
+ const el = makeElement("el-1", "GridColumn", {
1408
+ id: "root",
1409
+ slot: "children"
1410
+ });
1411
+ const root = makeElement("root", "Grid");
1412
+ const doc = makeDocument([
1413
+ root,
1414
+ el
1415
+ ]);
1416
+ const components = {
1417
+ Grid: makeManifest("Grid"),
1418
+ GridColumn: makeManifest("GridColumn", {
1419
+ canDelete: false
1420
+ })
1421
+ };
1422
+ const result = evaluateDeleteConstraint({
1423
+ elementId: "el-1",
1424
+ document: doc,
1425
+ components
1426
+ });
1427
+ expect(result.allowed).toBe(false);
1428
+ expect(result.violation.message).toBe("GridColumn cannot be deleted.");
1429
+ });
1430
+ it("should allow when canDelete check returns true", ()=>{
1431
+ const funnel = makeElement("funnel", "Funnel");
1432
+ const step1 = makeElement("step-1", "Step", {
1433
+ id: "funnel",
1434
+ slot: "steps/0/step"
1435
+ });
1436
+ const step2 = makeElement("step-2", "Step", {
1437
+ id: "funnel",
1438
+ slot: "steps/1/step"
1439
+ });
1440
+ const step3 = makeElement("step-3", "Step", {
1441
+ id: "funnel",
1442
+ slot: "steps/2/step"
1443
+ });
1444
+ const doc = makeDocument([
1445
+ funnel,
1446
+ step1,
1447
+ step2,
1448
+ step3
1449
+ ], {
1450
+ funnel: {
1451
+ inputs: {
1452
+ "steps/0/step": {
1453
+ id: "s0",
1454
+ type: "slot",
1455
+ static: "step-1"
1456
+ },
1457
+ "steps/1/step": {
1458
+ id: "s1",
1459
+ type: "slot",
1460
+ static: "step-2"
1461
+ },
1462
+ "steps/2/step": {
1463
+ id: "s2",
1464
+ type: "slot",
1465
+ static: "step-3"
1466
+ }
1467
+ }
1089
1468
  }
1090
- }]
1091
- }),
1092
- TextField: makeManifest("TextField", {
1093
- tags: ["funnel-field"]
1094
- })
1095
- };
1096
- const result = evaluateConstraints({
1097
- componentName: "TextField",
1098
- parentId: "parent-1",
1099
- slot: "children",
1100
- document: doc,
1101
- components
1102
- });
1103
- expect(result.allowed).toBe(false);
1104
- expect(result.violation.message).toBe("No funnel fields allowed");
1105
- });
1106
- it("should block a nested descendant via ancestor's descendantConstraints", () => {
1107
- const step = makeElement("step-1", "Step");
1108
- const container = makeElement("container-1", "Container", {
1109
- id: "step-1",
1110
- slot: "children"
1111
- });
1112
- const doc = makeDocument([step, container]);
1113
- const components = {
1114
- Step: makeManifest("Step", {
1115
- descendantConstraints: [ctx => {
1116
- if (ctx.hasTag("funnel-field")) {
1117
- return ctx.block("No funnel fields in this step");
1469
+ });
1470
+ const components = {
1471
+ Funnel: makeManifest("Funnel"),
1472
+ Step: makeManifest("Step", {
1473
+ canDelete: (ctx)=>{
1474
+ if (!(ctx.countInstances("Step") > 2)) return ctx.block("Blocked");
1475
+ }
1476
+ })
1477
+ };
1478
+ const result = evaluateDeleteConstraint({
1479
+ elementId: "step-2",
1480
+ document: doc,
1481
+ components
1482
+ });
1483
+ expect(result.allowed).toBe(true);
1484
+ });
1485
+ it("should block when canDelete check returns false", ()=>{
1486
+ const funnel = makeElement("funnel", "Funnel");
1487
+ const step1 = makeElement("step-1", "Step", {
1488
+ id: "funnel",
1489
+ slot: "steps/0/step"
1490
+ });
1491
+ const step2 = makeElement("step-2", "Step", {
1492
+ id: "funnel",
1493
+ slot: "steps/1/step"
1494
+ });
1495
+ const doc = makeDocument([
1496
+ funnel,
1497
+ step1,
1498
+ step2
1499
+ ], {
1500
+ funnel: {
1501
+ inputs: {
1502
+ "steps/0/step": {
1503
+ id: "s0",
1504
+ type: "slot",
1505
+ static: "step-1"
1506
+ },
1507
+ "steps/1/step": {
1508
+ id: "s1",
1509
+ type: "slot",
1510
+ static: "step-2"
1511
+ }
1512
+ }
1118
1513
  }
1119
- }]
1120
- }),
1121
- Container: makeManifest("Container"),
1122
- TextField: makeManifest("TextField", {
1123
- tags: ["funnel-field"]
1124
- })
1125
- };
1126
-
1127
- // TextField dropped into Container, which is inside Step
1128
- const result = evaluateConstraints({
1129
- componentName: "TextField",
1130
- parentId: "container-1",
1131
- slot: "children",
1132
- document: doc,
1133
- components
1134
- });
1135
- expect(result.allowed).toBe(false);
1136
- expect(result.violation.message).toBe("No funnel fields in this step");
1137
- });
1138
- it("should allow when descendantConstraints pass", () => {
1139
- const step = makeElement("step-1", "Step");
1140
- const doc = makeDocument([step]);
1141
- const components = {
1142
- Step: makeManifest("Step", {
1143
- descendantConstraints: [ctx => {
1144
- if (ctx.hasTag("funnel-field")) {
1145
- return ctx.block("Blocked");
1514
+ });
1515
+ const components = {
1516
+ Funnel: makeManifest("Funnel"),
1517
+ Step: makeManifest("Step", {
1518
+ canDelete: (ctx)=>{
1519
+ if (!(ctx.countInstances("Step") > 2)) return ctx.block("Need at least 2 steps");
1520
+ }
1521
+ })
1522
+ };
1523
+ const result = evaluateDeleteConstraint({
1524
+ elementId: "step-1",
1525
+ document: doc,
1526
+ components
1527
+ });
1528
+ expect(result.allowed).toBe(false);
1529
+ expect(result.violation.message).toBe("Need at least 2 steps");
1530
+ });
1531
+ it("should use thrown error message as violation message", ()=>{
1532
+ const root = makeElement("root", "Page");
1533
+ const el = makeElement("el-1", "Widget", {
1534
+ id: "root",
1535
+ slot: "children"
1536
+ });
1537
+ const doc = makeDocument([
1538
+ root,
1539
+ el
1540
+ ]);
1541
+ const components = {
1542
+ Page: makeManifest("Page"),
1543
+ Widget: makeManifest("Widget", {
1544
+ canDelete: ()=>{
1545
+ throw new Error("Cannot delete this widget right now");
1546
+ }
1547
+ })
1548
+ };
1549
+ const result = evaluateDeleteConstraint({
1550
+ elementId: "el-1",
1551
+ document: doc,
1552
+ components
1553
+ });
1554
+ expect(result.allowed).toBe(false);
1555
+ expect(result.violation.message).toBe("Cannot delete this widget right now");
1556
+ });
1557
+ it("should provide correct context to the check function", ()=>{
1558
+ const root = makeElement("root", "Funnel");
1559
+ const step = makeElement("step-1", "Step", {
1560
+ id: "root",
1561
+ slot: "steps/0/step"
1562
+ });
1563
+ const doc = makeDocument([
1564
+ root,
1565
+ step
1566
+ ], {
1567
+ root: {
1568
+ inputs: {
1569
+ "steps/0/step": {
1570
+ id: "s0",
1571
+ type: "slot",
1572
+ static: "step-1"
1573
+ },
1574
+ "steps/1/step": {
1575
+ id: "s1",
1576
+ type: "slot",
1577
+ static: "step-2"
1578
+ }
1579
+ }
1146
1580
  }
1147
- }]
1148
- }),
1149
- Button: makeManifest("Button")
1150
- };
1151
- const result = evaluateConstraints({
1152
- componentName: "Button",
1153
- parentId: "step-1",
1154
- slot: "children",
1155
- document: doc,
1156
- components
1157
- });
1158
- expect(result.allowed).toBe(true);
1159
- });
1160
- it("should not run parent's descendantConstraints for unrelated ancestors", () => {
1161
- const page = makeElement("page", "Page");
1162
- const step = makeElement("step-1", "Step", {
1163
- id: "page",
1164
- slot: "children"
1165
- });
1166
- const doc = makeDocument([page, step]);
1167
- const components = {
1168
- Page: makeManifest("Page", {
1169
- descendantConstraints: [ctx => ctx.block("Page blocks everything")]
1170
- }),
1171
- Step: makeManifest("Step"),
1172
- Button: makeManifest("Button")
1173
- };
1174
-
1175
- // Button into Step Page's descendantConstraints should still fire
1176
- const result = evaluateConstraints({
1177
- componentName: "Button",
1178
- parentId: "step-1",
1179
- slot: "children",
1180
- document: doc,
1181
- components
1182
- });
1183
- expect(result.allowed).toBe(false);
1184
- expect(result.violation.message).toBe("Page blocks everything");
1185
- });
1186
- it("should evaluate component constraints before descendantConstraints", () => {
1187
- const parent = makeElement("parent-1", "Step");
1188
- const doc = makeDocument([parent]);
1189
- const components = {
1190
- Step: makeManifest("Step", {
1191
- descendantConstraints: [ctx => ctx.block("descendant constraint")]
1192
- }),
1193
- Widget: makeManifest("Widget", {
1194
- constraints: [ctx => ctx.block("component constraint")]
1195
- })
1196
- };
1197
- const result = evaluateConstraints({
1198
- componentName: "Widget",
1199
- parentId: "parent-1",
1200
- slot: "children",
1201
- document: doc,
1202
- components
1203
- });
1204
- // Component's own constraints should short-circuit first
1205
- expect(result.violation.message).toBe("component constraint");
1206
- });
1207
- });
1208
- });
1209
- describe("evaluateDeleteConstraint", () => {
1210
- it("should allow when canDelete is undefined", () => {
1211
- const el = makeElement("el-1", "Widget", {
1212
- id: "root",
1213
- slot: "children"
1214
- });
1215
- const root = makeElement("root", "Page");
1216
- const doc = makeDocument([root, el]);
1217
- const components = {
1218
- Page: makeManifest("Page"),
1219
- Widget: makeManifest("Widget")
1220
- };
1221
- const result = evaluateDeleteConstraint({
1222
- elementId: "el-1",
1223
- document: doc,
1224
- components
1225
- });
1226
- expect(result.allowed).toBe(true);
1227
- });
1228
- it("should allow when canDelete is true", () => {
1229
- const el = makeElement("el-1", "Widget", {
1230
- id: "root",
1231
- slot: "children"
1232
- });
1233
- const root = makeElement("root", "Page");
1234
- const doc = makeDocument([root, el]);
1235
- const components = {
1236
- Page: makeManifest("Page"),
1237
- Widget: makeManifest("Widget", {
1238
- canDelete: true
1239
- })
1240
- };
1241
- const result = evaluateDeleteConstraint({
1242
- elementId: "el-1",
1243
- document: doc,
1244
- components
1245
- });
1246
- expect(result.allowed).toBe(true);
1247
- });
1248
- it("should block when canDelete is false", () => {
1249
- const el = makeElement("el-1", "GridColumn", {
1250
- id: "root",
1251
- slot: "children"
1252
- });
1253
- const root = makeElement("root", "Grid");
1254
- const doc = makeDocument([root, el]);
1255
- const components = {
1256
- Grid: makeManifest("Grid"),
1257
- GridColumn: makeManifest("GridColumn", {
1258
- canDelete: false
1259
- })
1260
- };
1261
- const result = evaluateDeleteConstraint({
1262
- elementId: "el-1",
1263
- document: doc,
1264
- components
1265
- });
1266
- expect(result.allowed).toBe(false);
1267
- expect(result.violation.message).toBe("GridColumn cannot be deleted.");
1268
- });
1269
- it("should allow when canDelete check returns true", () => {
1270
- const funnel = makeElement("funnel", "Funnel");
1271
- const step1 = makeElement("step-1", "Step", {
1272
- id: "funnel",
1273
- slot: "steps/0/step"
1274
- });
1275
- const step2 = makeElement("step-2", "Step", {
1276
- id: "funnel",
1277
- slot: "steps/1/step"
1278
- });
1279
- const step3 = makeElement("step-3", "Step", {
1280
- id: "funnel",
1281
- slot: "steps/2/step"
1282
- });
1283
- const doc = makeDocument([funnel, step1, step2, step3], {
1284
- funnel: {
1285
- inputs: {
1286
- "steps/0/step": {
1287
- id: "s0",
1288
- type: "slot",
1289
- static: "step-1"
1290
- },
1291
- "steps/1/step": {
1292
- id: "s1",
1293
- type: "slot",
1294
- static: "step-2"
1295
- },
1296
- "steps/2/step": {
1297
- id: "s2",
1298
- type: "slot",
1299
- static: "step-3"
1300
- }
1301
- }
1302
- }
1303
- });
1304
- const components = {
1305
- Funnel: makeManifest("Funnel"),
1306
- Step: makeManifest("Step", {
1307
- canDelete: ctx => {
1308
- if (!(ctx.countInstances("Step") > 2)) {
1309
- return ctx.block("Blocked");
1310
- }
1311
- }
1312
- })
1313
- };
1314
- const result = evaluateDeleteConstraint({
1315
- elementId: "step-2",
1316
- document: doc,
1317
- components
1318
- });
1319
- expect(result.allowed).toBe(true);
1320
- });
1321
- it("should block when canDelete check returns false", () => {
1322
- const funnel = makeElement("funnel", "Funnel");
1323
- const step1 = makeElement("step-1", "Step", {
1324
- id: "funnel",
1325
- slot: "steps/0/step"
1326
- });
1327
- const step2 = makeElement("step-2", "Step", {
1328
- id: "funnel",
1329
- slot: "steps/1/step"
1330
- });
1331
- const doc = makeDocument([funnel, step1, step2], {
1332
- funnel: {
1333
- inputs: {
1334
- "steps/0/step": {
1335
- id: "s0",
1336
- type: "slot",
1337
- static: "step-1"
1338
- },
1339
- "steps/1/step": {
1340
- id: "s1",
1341
- type: "slot",
1342
- static: "step-2"
1343
- }
1344
- }
1345
- }
1346
- });
1347
- const components = {
1348
- Funnel: makeManifest("Funnel"),
1349
- Step: makeManifest("Step", {
1350
- canDelete: ctx => {
1351
- if (!(ctx.countInstances("Step") > 2)) {
1352
- return ctx.block("Need at least 2 steps");
1353
- }
1354
- }
1355
- })
1356
- };
1357
- const result = evaluateDeleteConstraint({
1358
- elementId: "step-1",
1359
- document: doc,
1360
- components
1361
- });
1362
- expect(result.allowed).toBe(false);
1363
- expect(result.violation.message).toBe("Need at least 2 steps");
1364
- });
1365
- it("should use thrown error message as violation message", () => {
1366
- const root = makeElement("root", "Page");
1367
- const el = makeElement("el-1", "Widget", {
1368
- id: "root",
1369
- slot: "children"
1370
- });
1371
- const doc = makeDocument([root, el]);
1372
- const components = {
1373
- Page: makeManifest("Page"),
1374
- Widget: makeManifest("Widget", {
1375
- canDelete: () => {
1376
- throw new Error("Cannot delete this widget right now");
1377
- }
1378
- })
1379
- };
1380
- const result = evaluateDeleteConstraint({
1381
- elementId: "el-1",
1382
- document: doc,
1383
- components
1384
- });
1385
- expect(result.allowed).toBe(false);
1386
- expect(result.violation.message).toBe("Cannot delete this widget right now");
1387
- });
1388
- it("should provide correct context to the check function", () => {
1389
- const root = makeElement("root", "Funnel");
1390
- const step = makeElement("step-1", "Step", {
1391
- id: "root",
1392
- slot: "steps/0/step"
1393
- });
1394
- const doc = makeDocument([root, step], {
1395
- root: {
1396
- inputs: {
1397
- "steps/0/step": {
1398
- id: "s0",
1399
- type: "slot",
1400
- static: "step-1"
1401
- },
1402
- "steps/1/step": {
1403
- id: "s1",
1404
- type: "slot",
1405
- static: "step-2"
1406
- }
1407
- }
1408
- }
1409
- });
1410
- let capturedCtx;
1411
- const components = {
1412
- Funnel: makeManifest("Funnel"),
1413
- Step: makeManifest("Step", {
1414
- tags: ["funnel-step"],
1415
- canDelete: ctx => {
1416
- capturedCtx = ctx;
1417
- }
1418
- })
1419
- };
1420
- evaluateDeleteConstraint({
1421
- elementId: "step-1",
1422
- document: doc,
1423
- components
1424
- });
1425
- expect(capturedCtx).toBeDefined();
1426
- expect(capturedCtx.component.name).toBe("Step");
1427
- expect(capturedCtx.component.tags).toEqual(["funnel-step"]);
1428
- expect(capturedCtx.parent.name).toBe("Funnel");
1429
- expect(capturedCtx.slot).toBe("steps/0/step");
1430
- expect(capturedCtx.isChildOf("Funnel")).toBe(true);
1431
- expect(capturedCtx.hasTag("funnel-step")).toBe(true);
1432
- });
1433
- it("should allow when element is not found", () => {
1434
- const doc = makeDocument([]);
1435
- const components = {};
1436
- const result = evaluateDeleteConstraint({
1437
- elementId: "nonexistent",
1438
- document: doc,
1439
- components
1440
- });
1441
- expect(result.allowed).toBe(true);
1442
- });
1443
- it("should allow when manifest is not found", () => {
1444
- const el = makeElement("el-1", "Unknown");
1445
- const doc = makeDocument([el]);
1446
- const components = {};
1447
- const result = evaluateDeleteConstraint({
1448
- elementId: "el-1",
1449
- document: doc,
1450
- components
1581
+ });
1582
+ let capturedCtx;
1583
+ const components = {
1584
+ Funnel: makeManifest("Funnel"),
1585
+ Step: makeManifest("Step", {
1586
+ tags: [
1587
+ "funnel-step"
1588
+ ],
1589
+ canDelete: (ctx)=>{
1590
+ capturedCtx = ctx;
1591
+ }
1592
+ })
1593
+ };
1594
+ evaluateDeleteConstraint({
1595
+ elementId: "step-1",
1596
+ document: doc,
1597
+ components
1598
+ });
1599
+ expect(capturedCtx).toBeDefined();
1600
+ expect(capturedCtx.component.name).toBe("Step");
1601
+ expect(capturedCtx.component.tags).toEqual([
1602
+ "funnel-step"
1603
+ ]);
1604
+ expect(capturedCtx.parent.name).toBe("Funnel");
1605
+ expect(capturedCtx.slot).toBe("steps/0/step");
1606
+ expect(capturedCtx.isChildOf("Funnel")).toBe(true);
1607
+ expect(capturedCtx.hasTag("funnel-step")).toBe(true);
1608
+ });
1609
+ it("should allow when element is not found", ()=>{
1610
+ const doc = makeDocument([]);
1611
+ const components = {};
1612
+ const result = evaluateDeleteConstraint({
1613
+ elementId: "nonexistent",
1614
+ document: doc,
1615
+ components
1616
+ });
1617
+ expect(result.allowed).toBe(true);
1618
+ });
1619
+ it("should allow when manifest is not found", ()=>{
1620
+ const el = makeElement("el-1", "Unknown");
1621
+ const doc = makeDocument([
1622
+ el
1623
+ ]);
1624
+ const components = {};
1625
+ const result = evaluateDeleteConstraint({
1626
+ elementId: "el-1",
1627
+ document: doc,
1628
+ components
1629
+ });
1630
+ expect(result.allowed).toBe(true);
1451
1631
  });
1452
- expect(result.allowed).toBe(true);
1453
- });
1454
1632
  });
1455
1633
 
1456
1634
  //# sourceMappingURL=ConstraintEvaluator.test.js.map