@webiny/website-builder-sdk 6.0.0 → 6.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BindingsResolver.js +1 -4
- package/BindingsResolver.js.map +1 -1
- package/ComponentRegistry.js +30 -0
- package/ComponentRegistry.js.map +1 -1
- package/ConstraintEvaluator.d.ts +36 -0
- package/ConstraintEvaluator.js +395 -0
- package/ConstraintEvaluator.js.map +1 -0
- package/ConstraintEvaluator.test.d.ts +1 -0
- package/ConstraintEvaluator.test.js +1456 -0
- package/ConstraintEvaluator.test.js.map +1 -0
- package/ElementFactory.d.ts +2 -3
- package/ElementFactory.js +21 -12
- package/ElementFactory.js.map +1 -1
- package/ElementFactory.test.d.ts +1 -0
- package/ElementFactory.test.js +280 -0
- package/ElementFactory.test.js.map +1 -0
- package/InputBindingsProcessor.js +24 -6
- package/InputBindingsProcessor.js.map +1 -1
- package/InputsBindingsProcessor.test.js +1 -0
- package/InputsBindingsProcessor.test.js.map +1 -1
- package/documentOperations/$addElementReferenceToParent.d.ts +1 -1
- package/documentOperations/$addElementReferenceToParent.js +9 -2
- package/documentOperations/$addElementReferenceToParent.js.map +1 -1
- package/documentOperations/AddToParent.d.ts +2 -2
- package/documentOperations/AddToParent.js.map +1 -1
- package/documentOperations/RemoveElement.js +9 -0
- package/documentOperations/RemoveElement.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/messenger/Messenger.js +1 -3
- package/messenger/Messenger.js.map +1 -1
- package/package.json +8 -8
- package/types.d.ts +120 -7
- package/types.js.map +1 -1
|
@@ -0,0 +1,1456 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { evaluateConstraints, evaluateDeleteConstraint } from "./ConstraintEvaluator.js";
|
|
3
|
+
function makeElement(id, componentName, parent) {
|
|
4
|
+
return {
|
|
5
|
+
type: "Webiny/Element",
|
|
6
|
+
id,
|
|
7
|
+
component: {
|
|
8
|
+
name: componentName
|
|
9
|
+
},
|
|
10
|
+
parent
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function makeManifest(name, overrides) {
|
|
14
|
+
return {
|
|
15
|
+
name,
|
|
16
|
+
label: name,
|
|
17
|
+
inputs: [],
|
|
18
|
+
tags: [],
|
|
19
|
+
...overrides
|
|
20
|
+
};
|
|
21
|
+
}
|
|
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
|
+
})
|
|
120
|
+
};
|
|
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"
|
|
970
|
+
}
|
|
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"]
|
|
1020
|
+
}
|
|
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()
|
|
1035
|
+
};
|
|
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);
|
|
1078
|
+
});
|
|
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");
|
|
1089
|
+
}
|
|
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");
|
|
1118
|
+
}
|
|
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");
|
|
1146
|
+
}
|
|
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
|
|
1451
|
+
});
|
|
1452
|
+
expect(result.allowed).toBe(true);
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
//# sourceMappingURL=ConstraintEvaluator.test.js.map
|