@akinon/pz-theme 2.0.36-rc.0 → 2.0.36
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/CHANGELOG.md +8 -31
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- package/src/utils/visibility-rules.ts +346 -3
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { buildIteratorBlock, resolveBlockBindings } from '../iterator-utils';
|
|
4
|
+
import { generateThemeCSS, getCSSStyles } from '../index';
|
|
5
|
+
import TextBlock from '../../blocks/text-block';
|
|
6
|
+
import type { Block } from '../../theme-block';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-row appearance, nested repeats and interpolated bindings.
|
|
10
|
+
*
|
|
11
|
+
* The first block of tests is the important one: a composition that carries no
|
|
12
|
+
* binding token must come out of the iterator and out of the CSS generator
|
|
13
|
+
* exactly as it does today — same object, same bytes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
17
|
+
({
|
|
18
|
+
id: 'block',
|
|
19
|
+
type: 'text',
|
|
20
|
+
label: 'Block',
|
|
21
|
+
value: '',
|
|
22
|
+
order: 0,
|
|
23
|
+
hidden: false,
|
|
24
|
+
properties: {},
|
|
25
|
+
styles: {},
|
|
26
|
+
blocks: [],
|
|
27
|
+
...overrides
|
|
28
|
+
} as Block);
|
|
29
|
+
|
|
30
|
+
const iteratorOf = (template: Block, overrides: Partial<Block> = {}): Block =>
|
|
31
|
+
block({
|
|
32
|
+
id: 'iterator',
|
|
33
|
+
type: 'group',
|
|
34
|
+
label: 'Iterator',
|
|
35
|
+
isIterator: true,
|
|
36
|
+
blocks: [template],
|
|
37
|
+
...overrides
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('a composition with no bindings is untouched', () => {
|
|
41
|
+
const cardStyles = {
|
|
42
|
+
'background-color': '#e63946',
|
|
43
|
+
color: '#ffffff',
|
|
44
|
+
'background-opacity': '40',
|
|
45
|
+
padding: { desktop: '12px', mobile: '4px' },
|
|
46
|
+
'badge/border-color': '#00ff00'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const template = block({
|
|
50
|
+
id: 'card',
|
|
51
|
+
type: 'group',
|
|
52
|
+
label: 'Card',
|
|
53
|
+
styles: cardStyles,
|
|
54
|
+
blocks: [
|
|
55
|
+
block({
|
|
56
|
+
id: 'card-title',
|
|
57
|
+
properties: { dataBinding: 'item.name' },
|
|
58
|
+
styles: { 'font-size': 16 },
|
|
59
|
+
value: 'Fallback'
|
|
60
|
+
})
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const expanded = buildIteratorBlock({
|
|
65
|
+
iteratorBlock: iteratorOf(template),
|
|
66
|
+
items: [{ name: 'Sneaker' }, { name: 'Boot' }]
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('hands every clone the template style object itself, not a copy of it', () => {
|
|
70
|
+
// Reference identity is the strongest available proof that nothing about
|
|
71
|
+
// the style set changed: no re-serialization, no key reordering, no value
|
|
72
|
+
// rewritten — the clone points at exactly what the template holds.
|
|
73
|
+
(expanded.blocks || []).forEach((clone) => {
|
|
74
|
+
expect(clone.styles).toBe(cardStyles);
|
|
75
|
+
expect((clone.blocks || [])[0].styles).toBe(template.blocks?.[0].styles);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps the template style identity, so one rule still serves every row', () => {
|
|
80
|
+
(expanded.blocks || []).forEach((clone) => {
|
|
81
|
+
expect(clone.styleSourceId).toBe('card');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still resolves the row values', () => {
|
|
86
|
+
expect(
|
|
87
|
+
(expanded.blocks || []).map((clone) => clone.blocks?.[0].value)
|
|
88
|
+
).toEqual(['Sneaker', 'Boot']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('emits byte-identical CSS', () => {
|
|
92
|
+
// Frozen against the output of the committed generator, captured before
|
|
93
|
+
// this feature existed. Any drift in spacing, ordering, alpha maths or
|
|
94
|
+
// part handling breaks this line.
|
|
95
|
+
const section = [
|
|
96
|
+
{
|
|
97
|
+
id: 'sec-1',
|
|
98
|
+
styles: { 'background-color': '#101010' },
|
|
99
|
+
blocks: [{ id: 'card', styleSourceId: 'tpl', styles: cardStyles }]
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
expect(generateThemeCSS(section as any)).toBe(
|
|
104
|
+
'[data-section-id="sec-1"] {\n' +
|
|
105
|
+
' background-color: rgba(16, 16, 16, 1);\n' +
|
|
106
|
+
'}\n' +
|
|
107
|
+
'[data-block-id="tpl"] {\n' +
|
|
108
|
+
' background-color: rgba(230, 57, 70, 0.4);\n' +
|
|
109
|
+
' color: #ffffff;\n' +
|
|
110
|
+
' padding: 12px;\n' +
|
|
111
|
+
'}\n' +
|
|
112
|
+
'@media (max-width: 1023px) {\n' +
|
|
113
|
+
' [data-block-id="tpl"] {\n' +
|
|
114
|
+
' padding: 4px;\n' +
|
|
115
|
+
' }\n' +
|
|
116
|
+
'}\n' +
|
|
117
|
+
// The part rule carries an ownership guard so it cannot reach a part
|
|
118
|
+
// of the same name inside a nested block (utils/part-styles.ts). The
|
|
119
|
+
// guard sits in :where(), so the rule's specificity is unchanged.
|
|
120
|
+
'[data-block-id="tpl"] [data-part="badge"]' +
|
|
121
|
+
':not(:where([data-block-id="tpl"] [data-block-id] *)) {\n' +
|
|
122
|
+
' border-color: #00ff00 !important;\n' +
|
|
123
|
+
'}\n'
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(generateThemeCSS(section as any, 'mobile')).toBe(
|
|
127
|
+
'[data-section-id="sec-1"] {\n' +
|
|
128
|
+
' background-color: rgba(16, 16, 16, 1);\n' +
|
|
129
|
+
'}\n' +
|
|
130
|
+
'[data-block-id="tpl"] {\n' +
|
|
131
|
+
' background-color: rgba(230, 57, 70, 0.4);\n' +
|
|
132
|
+
' color: #ffffff;\n' +
|
|
133
|
+
' padding: 4px;\n' +
|
|
134
|
+
'}\n' +
|
|
135
|
+
// The part rule carries an ownership guard so it cannot reach a part
|
|
136
|
+
// of the same name inside a nested block (utils/part-styles.ts). The
|
|
137
|
+
// guard sits in :where(), so the rule's specificity is unchanged.
|
|
138
|
+
'[data-block-id="tpl"] [data-part="badge"]' +
|
|
139
|
+
':not(:where([data-block-id="tpl"] [data-block-id] *)) {\n' +
|
|
140
|
+
' border-color: #00ff00 !important;\n' +
|
|
141
|
+
'}\n'
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('a style value can carry a binding', () => {
|
|
147
|
+
const badgeStyles = {
|
|
148
|
+
'background-color': '{{item.badgeColor}}',
|
|
149
|
+
color: '{{item.badgeTextColor}}',
|
|
150
|
+
'background-opacity': '{{item.badgeOpacity}}',
|
|
151
|
+
'border-radius': '4px'
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const expandFor = (rows: Record<string, unknown>[]) =>
|
|
155
|
+
buildIteratorBlock({
|
|
156
|
+
iteratorBlock: iteratorOf(
|
|
157
|
+
block({
|
|
158
|
+
id: 'card',
|
|
159
|
+
type: 'group',
|
|
160
|
+
label: 'Card',
|
|
161
|
+
blocks: [block({ id: 'badge', label: 'Badge', styles: badgeStyles })]
|
|
162
|
+
})
|
|
163
|
+
),
|
|
164
|
+
items: rows
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const badgeOf = (expanded: Block, index: number) =>
|
|
168
|
+
(expanded.blocks || [])[index].blocks?.[0];
|
|
169
|
+
|
|
170
|
+
it('resolves the bound properties per clone', () => {
|
|
171
|
+
const expanded = expandFor([
|
|
172
|
+
{ badgeColor: '#00a86b', badgeTextColor: '#ffffff', badgeOpacity: 40 },
|
|
173
|
+
{
|
|
174
|
+
badgeColor: 'rgb(230 57 70)',
|
|
175
|
+
badgeTextColor: '#101010',
|
|
176
|
+
badgeOpacity: 100
|
|
177
|
+
}
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
expect(badgeOf(expanded, 0)?.styles).toEqual({
|
|
181
|
+
'background-color': '#00a86b',
|
|
182
|
+
color: '#ffffff',
|
|
183
|
+
'background-opacity': '40',
|
|
184
|
+
'border-radius': '4px'
|
|
185
|
+
});
|
|
186
|
+
expect(badgeOf(expanded, 1)?.styles).toEqual({
|
|
187
|
+
'background-color': 'rgb(230 57 70)',
|
|
188
|
+
color: '#101010',
|
|
189
|
+
'background-opacity': '100',
|
|
190
|
+
'border-radius': '4px'
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('leaves the unbound properties alone, so the shared rule still applies', () => {
|
|
195
|
+
const expanded = expandFor([{ badgeColor: '#00a86b' }]);
|
|
196
|
+
|
|
197
|
+
expect(badgeOf(expanded, 0)?.styles['border-radius']).toBe('4px');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('never writes back onto the template', () => {
|
|
201
|
+
expandFor([{ badgeColor: '#00a86b' }]);
|
|
202
|
+
|
|
203
|
+
expect(badgeStyles['background-color']).toBe('{{item.badgeColor}}');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('leaves the property unset when the binding resolves to nothing', () => {
|
|
207
|
+
const styles = badgeOf(expandFor([{ badgeTextColor: '#fff' }]), 0)?.styles;
|
|
208
|
+
|
|
209
|
+
expect('background-color' in styles).toBe(false);
|
|
210
|
+
expect(JSON.stringify(styles)).not.toContain('undefined');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('resolves per breakpoint and keeps the literal breakpoints', () => {
|
|
214
|
+
const expanded = buildIteratorBlock({
|
|
215
|
+
iteratorBlock: iteratorOf(
|
|
216
|
+
block({
|
|
217
|
+
id: 'card',
|
|
218
|
+
styles: {
|
|
219
|
+
'font-size': { desktop: '{{item.size}}', mobile: '12px' }
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
),
|
|
223
|
+
items: [{ size: '18px' }]
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect((expanded.blocks || [])[0].styles['font-size']).toEqual({
|
|
227
|
+
desktop: '18px',
|
|
228
|
+
mobile: '12px'
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('interpolates a binding inside a longer CSS value', () => {
|
|
233
|
+
const expanded = buildIteratorBlock({
|
|
234
|
+
iteratorBlock: iteratorOf(
|
|
235
|
+
block({
|
|
236
|
+
id: 'card',
|
|
237
|
+
styles: { padding: '{{item.gap}}px {{item.gap}}px' }
|
|
238
|
+
})
|
|
239
|
+
),
|
|
240
|
+
items: [{ gap: 8 }]
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect((expanded.blocks || [])[0].styles.padding).toBe('8px 8px');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('drops a value where only some tokens resolve', () => {
|
|
247
|
+
// Half a CSS value can change what the shorthand around it means.
|
|
248
|
+
const expanded = buildIteratorBlock({
|
|
249
|
+
iteratorBlock: iteratorOf(
|
|
250
|
+
block({ id: 'card', styles: { margin: '{{item.top}} {{item.side}}' } })
|
|
251
|
+
),
|
|
252
|
+
items: [{ top: '4px' }]
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect('margin' in (expanded.blocks || [])[0].styles).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('leaves the token in place on the designer canvas, where there is no row', () => {
|
|
259
|
+
const expanded = buildIteratorBlock({
|
|
260
|
+
iteratorBlock: iteratorOf(block({ id: 'card', styles: badgeStyles })),
|
|
261
|
+
items: [],
|
|
262
|
+
isDesigner: true
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect((expanded.blocks || [])[0].styles['background-color']).toBe(
|
|
266
|
+
'{{item.badgeColor}}'
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('and the resolved value reaches the element', () => {
|
|
271
|
+
const paintBadges = (rows: Record<string, unknown>[]) => {
|
|
272
|
+
const expanded = buildIteratorBlock({
|
|
273
|
+
iteratorBlock: iteratorOf(
|
|
274
|
+
block({
|
|
275
|
+
id: 'badge',
|
|
276
|
+
type: 'text',
|
|
277
|
+
value: 'SALE',
|
|
278
|
+
styles: {
|
|
279
|
+
'background-color': '{{item.badgeColor}}',
|
|
280
|
+
'background-opacity': '{{item.badgeOpacity}}',
|
|
281
|
+
'border-radius': '4px'
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
),
|
|
285
|
+
items: rows
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return (expanded.blocks || []).map(
|
|
289
|
+
(clone) =>
|
|
290
|
+
render(<TextBlock block={clone as any} />).container.querySelector(
|
|
291
|
+
'div'
|
|
292
|
+
) as HTMLDivElement
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
it('paints each row its own colour', () => {
|
|
297
|
+
const painted = paintBadges([
|
|
298
|
+
{ badgeColor: '#00a86b', badgeOpacity: 40 },
|
|
299
|
+
{ badgeColor: '#e63946', badgeOpacity: 100 }
|
|
300
|
+
]);
|
|
301
|
+
|
|
302
|
+
// The bound opacity feeds the same alpha maths a typed colour goes
|
|
303
|
+
// through, so the row gets a real rgba rather than a flat swatch.
|
|
304
|
+
expect(painted[0].style.backgroundColor).toBe('rgba(0, 168, 107, 0.4)');
|
|
305
|
+
expect(painted[1].style.backgroundColor).toMatch(/230, 57, 70/);
|
|
306
|
+
// The unbound property is still on every row.
|
|
307
|
+
expect(painted[0].style.borderRadius).toBe('4px');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('paints nothing where the row carries no colour', () => {
|
|
311
|
+
const [painted] = paintBadges([{ name: 'No badge' }]);
|
|
312
|
+
|
|
313
|
+
expect(painted.style.backgroundColor).toBe('');
|
|
314
|
+
expect(painted.getAttribute('style')).not.toContain('undefined');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('a bound style value is merchant data, so it is treated as untrusted', () => {
|
|
320
|
+
const resolveBadge = (badgeColor: unknown) =>
|
|
321
|
+
(buildIteratorBlock({
|
|
322
|
+
iteratorBlock: iteratorOf(
|
|
323
|
+
block({
|
|
324
|
+
id: 'card',
|
|
325
|
+
styles: { 'background-color': '{{item.badgeColor}}' }
|
|
326
|
+
})
|
|
327
|
+
),
|
|
328
|
+
items: [{ badgeColor }]
|
|
329
|
+
}).blocks || [])[0].styles;
|
|
330
|
+
|
|
331
|
+
it.each([
|
|
332
|
+
['a second declaration', '#fff; color: red'],
|
|
333
|
+
['a second rule', 'red} body { display: none'],
|
|
334
|
+
['a style tag breakout', '</style><script>alert(1)</script>'],
|
|
335
|
+
['an escaped breakout', '\\3c /style\\3e '],
|
|
336
|
+
['an important hijack', '#fff !important'],
|
|
337
|
+
['a comment', '#fff /* hidden'],
|
|
338
|
+
['a remote fetch', 'url(//tracker.test/p.gif)'],
|
|
339
|
+
['a fetch behind a gradient', 'image-set(//tracker.test/p.gif)'],
|
|
340
|
+
['a legacy script value', 'expression(alert(1))'],
|
|
341
|
+
['a quoted value', '"#fff"'],
|
|
342
|
+
['an at-rule', '@import "x"']
|
|
343
|
+
])('rejects %s', (_label, payload) => {
|
|
344
|
+
expect('background-color' in resolveBadge(payload)).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('accepts the values a colour actually needs', () => {
|
|
348
|
+
expect(resolveBadge('#00a86b')['background-color']).toBe('#00a86b');
|
|
349
|
+
expect(resolveBadge('rebeccapurple')['background-color']).toBe(
|
|
350
|
+
'rebeccapurple'
|
|
351
|
+
);
|
|
352
|
+
expect(resolveBadge('rgb(230, 57, 70)')['background-color']).toBe(
|
|
353
|
+
'rgb(230, 57, 70)'
|
|
354
|
+
);
|
|
355
|
+
expect(resolveBadge('rgb(230 57 70 / 40%)')['background-color']).toBe(
|
|
356
|
+
'rgb(230 57 70 / 40%)'
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('rejects a value long enough to be a payload rather than a colour', () => {
|
|
361
|
+
expect('background-color' in resolveBadge(`#${'a'.repeat(200)}`)).toBe(
|
|
362
|
+
false
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('never lets an unresolved token reach the stylesheet', () => {
|
|
367
|
+
const css = generateThemeCSS([
|
|
368
|
+
{
|
|
369
|
+
id: 'sec',
|
|
370
|
+
styles: {},
|
|
371
|
+
blocks: [
|
|
372
|
+
{
|
|
373
|
+
id: 'badge',
|
|
374
|
+
styles: {
|
|
375
|
+
'background-color': '{{item.badgeColor}}',
|
|
376
|
+
'badge/color': '{{item.badgeTextColor}}',
|
|
377
|
+
padding: { desktop: '{{item.pad}}', mobile: '4px' }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
] as any);
|
|
383
|
+
|
|
384
|
+
expect(css).not.toContain('{{');
|
|
385
|
+
expect(css).not.toContain('background-color');
|
|
386
|
+
expect(css).not.toContain('undefined');
|
|
387
|
+
// The breakpoint that is real CSS still lands.
|
|
388
|
+
expect(css).toContain('padding: 4px');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('a repeat inside a row iterates the row', () => {
|
|
393
|
+
const swatchStrip = block({
|
|
394
|
+
id: 'swatches',
|
|
395
|
+
type: 'group',
|
|
396
|
+
label: 'Swatches',
|
|
397
|
+
isIterator: true,
|
|
398
|
+
iteratorDataPath: 'item.colors',
|
|
399
|
+
properties: { iteratorDataPath: 'item.colors' },
|
|
400
|
+
blocks: [block({ id: 'swatch', properties: { dataBinding: 'item.label' } })]
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const card = block({
|
|
404
|
+
id: 'card',
|
|
405
|
+
type: 'group',
|
|
406
|
+
label: 'Card',
|
|
407
|
+
blocks: [swatchStrip]
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// What the section renderer does: expand the outer repeat, then walk into
|
|
411
|
+
// each clone and expand the repeats it finds there.
|
|
412
|
+
const expandNested = (rows: Record<string, unknown>[]) => {
|
|
413
|
+
const outer = buildIteratorBlock({
|
|
414
|
+
iteratorBlock: iteratorOf(card),
|
|
415
|
+
items: rows
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return (outer.blocks || []).map((clone) =>
|
|
419
|
+
buildIteratorBlock({ iteratorBlock: (clone.blocks || [])[0] })
|
|
420
|
+
);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
it('draws one element per entry of the row it lives in', () => {
|
|
424
|
+
const nested = expandNested([
|
|
425
|
+
{ colors: [{ label: 'Red' }, { label: 'Blue' }, { label: 'Black' }] },
|
|
426
|
+
{ colors: [{ label: 'Sand' }] }
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
expect(nested[0].blocks).toHaveLength(3);
|
|
430
|
+
expect(nested[1].blocks).toHaveLength(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('resolves the nested bindings against the nested entry', () => {
|
|
434
|
+
const nested = expandNested([
|
|
435
|
+
{ colors: [{ label: 'Red' }, { label: 'Blue' }] }
|
|
436
|
+
]);
|
|
437
|
+
|
|
438
|
+
expect((nested[0].blocks || []).map((clone) => clone.value)).toEqual([
|
|
439
|
+
'Red',
|
|
440
|
+
'Blue'
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('leaves a top-level repeat resolving exactly where it did', () => {
|
|
445
|
+
// No row to stand in: `item.colors` is not a section path, so this is the
|
|
446
|
+
// single placeholder clone it has always produced.
|
|
447
|
+
const topLevel = buildIteratorBlock({
|
|
448
|
+
iteratorBlock: iteratorOf(block({ id: 'swatch' }), {
|
|
449
|
+
iteratorDataPath: 'item.colors'
|
|
450
|
+
}),
|
|
451
|
+
sectionDataSource: {
|
|
452
|
+
type: 'page-context',
|
|
453
|
+
details: { pageContext: { pageType: 'product', rootPath: 'product' } }
|
|
454
|
+
},
|
|
455
|
+
pageContext: { colors: [{ label: 'Red' }, { label: 'Blue' }] }
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(topLevel.blocks).toHaveLength(1);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('leaves a nested repeat that points at a section path alone', () => {
|
|
462
|
+
const outer = buildIteratorBlock({
|
|
463
|
+
iteratorBlock: iteratorOf(
|
|
464
|
+
block({
|
|
465
|
+
id: 'card',
|
|
466
|
+
type: 'group',
|
|
467
|
+
blocks: [
|
|
468
|
+
block({
|
|
469
|
+
id: 'campaigns',
|
|
470
|
+
type: 'group',
|
|
471
|
+
isIterator: true,
|
|
472
|
+
iteratorDataPath: 'campaigns',
|
|
473
|
+
properties: { iteratorDataPath: 'campaigns' },
|
|
474
|
+
blocks: [block({ id: 'chip' })]
|
|
475
|
+
})
|
|
476
|
+
]
|
|
477
|
+
})
|
|
478
|
+
),
|
|
479
|
+
items: [{ campaigns: [{ label: 'A' }, { label: 'B' }] }]
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const nestedProperties = (outer.blocks || [])[0].blocks?.[0].properties;
|
|
483
|
+
expect(nestedProperties.iteratorScopedItems).toBeUndefined();
|
|
484
|
+
// Nothing from the row travels with it either.
|
|
485
|
+
expect(JSON.stringify(nestedProperties)).not.toContain('label');
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe('a colour strip inside a card', () => {
|
|
490
|
+
// The design case the whole feature exists for: a card repeats products, and
|
|
491
|
+
// inside each card a strip repeats THAT product's colours with each swatch
|
|
492
|
+
// painted its own hex. The strip's template is a descendant of the card, so
|
|
493
|
+
// the outer expansion walks straight through it — and a style token, unlike
|
|
494
|
+
// a `dataBinding`, is consumed the moment it is resolved: whatever the outer
|
|
495
|
+
// row makes of it is final. The nested repeat has to be the one that
|
|
496
|
+
// resolves it.
|
|
497
|
+
const swatch = block({
|
|
498
|
+
id: 'swatch',
|
|
499
|
+
label: 'Swatch',
|
|
500
|
+
properties: { dataBinding: 'item.label' },
|
|
501
|
+
styles: { 'background-color': '{{item.hex}}', 'border-radius': '2px' }
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const strip = block({
|
|
505
|
+
id: 'strip',
|
|
506
|
+
type: 'group',
|
|
507
|
+
label: 'Colours',
|
|
508
|
+
isIterator: true,
|
|
509
|
+
iteratorDataPath: 'item.colors',
|
|
510
|
+
properties: { iteratorDataPath: 'item.colors' },
|
|
511
|
+
styles: { 'background-color': '{{item.stripBg}}', gap: '4px' },
|
|
512
|
+
blocks: [swatch]
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const card = block({
|
|
516
|
+
id: 'card',
|
|
517
|
+
type: 'group',
|
|
518
|
+
label: 'Card',
|
|
519
|
+
blocks: [strip]
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// What the section renderer does: expand the outer repeat, then walk into
|
|
523
|
+
// each clone and expand the repeats it finds there.
|
|
524
|
+
const expandCards = (rows: Record<string, unknown>[]) => {
|
|
525
|
+
const outer = buildIteratorBlock({
|
|
526
|
+
iteratorBlock: iteratorOf(card),
|
|
527
|
+
items: rows
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return (outer.blocks || []).map((clone) => ({
|
|
531
|
+
unexpandedStrip: (clone.blocks || [])[0],
|
|
532
|
+
strip: buildIteratorBlock({ iteratorBlock: (clone.blocks || [])[0] })
|
|
533
|
+
}));
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const swatchColours = (expandedStrip: Block) =>
|
|
537
|
+
(expandedStrip.blocks || []).map(
|
|
538
|
+
(clone) => (clone.styles as Record<string, unknown>)['background-color']
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const colours = [
|
|
542
|
+
{ label: 'Red', hex: '#ff0000' },
|
|
543
|
+
{ label: 'Blue', hex: '#0000ff' }
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
it('paints every swatch the colour of its own entry', () => {
|
|
547
|
+
const [{ strip: expanded }] = expandCards([{ colors: colours }]);
|
|
548
|
+
|
|
549
|
+
expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('does not paint the strip with the product row that happens to carry the same field', () => {
|
|
553
|
+
// A dominant-colour attribute on the product is ordinary catalogue data.
|
|
554
|
+
// Resolved by the outer repeat it silently paints every swatch the same
|
|
555
|
+
// wrong colour, which is worse than painting none.
|
|
556
|
+
const [{ strip: expanded }] = expandCards([
|
|
557
|
+
{ hex: '#123456', colors: colours }
|
|
558
|
+
]);
|
|
559
|
+
|
|
560
|
+
expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
|
|
561
|
+
expect(JSON.stringify(expanded)).not.toContain('#123456');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('still resolves the strip container against the product row it is drawn in', () => {
|
|
565
|
+
// The strip itself is drawn once per product, so its own styles belong to
|
|
566
|
+
// the outer repeat. Only what is INSIDE it belongs to the nested one.
|
|
567
|
+
const [{ strip: expanded }] = expandCards([
|
|
568
|
+
{ stripBg: '#eeeeee', colors: colours }
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
expect((expanded.styles as Record<string, unknown>)['background-color']).toBe(
|
|
572
|
+
'#eeeeee'
|
|
573
|
+
);
|
|
574
|
+
expect((expanded.styles as Record<string, unknown>).gap).toBe('4px');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('keeps the per-swatch text the nested repeat already resolved correctly', () => {
|
|
578
|
+
const [{ strip: expanded }] = expandCards([{ colors: colours }]);
|
|
579
|
+
|
|
580
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
581
|
+
'Red',
|
|
582
|
+
'Blue'
|
|
583
|
+
]);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('leaves the unbound property on the shared rule', () => {
|
|
587
|
+
const [{ strip: expanded }] = expandCards([{ colors: colours }]);
|
|
588
|
+
|
|
589
|
+
(expanded.blocks || []).forEach((clone) => {
|
|
590
|
+
expect((clone.styles as Record<string, unknown>)['border-radius']).toBe(
|
|
591
|
+
'2px'
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('resolves a repeat inside a repeat inside a repeat by its own owner', () => {
|
|
597
|
+
const sizeChip = block({
|
|
598
|
+
id: 'size',
|
|
599
|
+
label: 'Size',
|
|
600
|
+
styles: { color: '{{item.tint}}' }
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const sizes = block({
|
|
604
|
+
id: 'sizes',
|
|
605
|
+
type: 'group',
|
|
606
|
+
label: 'Sizes',
|
|
607
|
+
isIterator: true,
|
|
608
|
+
iteratorDataPath: 'item.sizes',
|
|
609
|
+
properties: { iteratorDataPath: 'item.sizes' },
|
|
610
|
+
blocks: [sizeChip]
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const deepStrip = block({
|
|
614
|
+
id: 'strip',
|
|
615
|
+
type: 'group',
|
|
616
|
+
label: 'Colours',
|
|
617
|
+
isIterator: true,
|
|
618
|
+
iteratorDataPath: 'item.colors',
|
|
619
|
+
properties: { iteratorDataPath: 'item.colors' },
|
|
620
|
+
blocks: [
|
|
621
|
+
block({ id: 'swatch', type: 'group', label: 'Swatch', blocks: [sizes] })
|
|
622
|
+
]
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const outer = buildIteratorBlock({
|
|
626
|
+
iteratorBlock: iteratorOf(
|
|
627
|
+
block({ id: 'card', type: 'group', label: 'Card', blocks: [deepStrip] })
|
|
628
|
+
),
|
|
629
|
+
items: [
|
|
630
|
+
{
|
|
631
|
+
tint: '#123456',
|
|
632
|
+
colors: [
|
|
633
|
+
{ tint: '#654321', sizes: [{ tint: '#00a86b' }, { tint: '#e63946' }] }
|
|
634
|
+
]
|
|
635
|
+
}
|
|
636
|
+
]
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const expandedStrip = buildIteratorBlock({
|
|
640
|
+
iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
|
|
641
|
+
});
|
|
642
|
+
const expandedSizes = buildIteratorBlock({
|
|
643
|
+
iteratorBlock: ((expandedStrip.blocks || [])[0].blocks || [])[0]
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
expect(
|
|
647
|
+
(expandedSizes.blocks || []).map(
|
|
648
|
+
(clone) => (clone.styles as Record<string, unknown>).color
|
|
649
|
+
)
|
|
650
|
+
).toEqual(['#00a86b', '#e63946']);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('draws the row colours and carries nothing else from the row into the page', () => {
|
|
654
|
+
// Whatever the outer row hands the nested iterator rides along in the
|
|
655
|
+
// rendered tree until that iterator expands, and is serialized to the
|
|
656
|
+
// client with it. The strip must draw the colours and ship nothing the
|
|
657
|
+
// composition never displays — cost prices, warehouse notes, internal SKUs.
|
|
658
|
+
const [{ unexpandedStrip, strip: expanded }] = expandCards([
|
|
659
|
+
{
|
|
660
|
+
colors: colours,
|
|
661
|
+
sku: 'ABC-1',
|
|
662
|
+
cost_price: 42.5,
|
|
663
|
+
internal_note: 'do-not-ship'
|
|
664
|
+
}
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
|
|
668
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
669
|
+
'Red',
|
|
670
|
+
'Blue'
|
|
671
|
+
]);
|
|
672
|
+
|
|
673
|
+
const shipped = JSON.stringify(unexpandedStrip);
|
|
674
|
+
expect(shipped).not.toContain('cost_price');
|
|
675
|
+
expect(shipped).not.toContain('do-not-ship');
|
|
676
|
+
expect(shipped).not.toContain('ABC-1');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('never prints an outer-row field the swatch itself cannot answer', () => {
|
|
680
|
+
// `internal_note` is a product field, not a colour field. Resolved by the
|
|
681
|
+
// outer repeat it sticks: the nested repeat has nothing to overwrite it
|
|
682
|
+
// with, so a warehouse note the composition never meant to show is printed
|
|
683
|
+
// in every swatch.
|
|
684
|
+
const noteStrip = block({
|
|
685
|
+
id: 'strip',
|
|
686
|
+
type: 'group',
|
|
687
|
+
label: 'Colours',
|
|
688
|
+
isIterator: true,
|
|
689
|
+
iteratorDataPath: 'item.colors',
|
|
690
|
+
properties: { iteratorDataPath: 'item.colors' },
|
|
691
|
+
blocks: [
|
|
692
|
+
block({
|
|
693
|
+
id: 'swatch',
|
|
694
|
+
label: 'Swatch',
|
|
695
|
+
value: 'Colour',
|
|
696
|
+
properties: { dataBinding: 'item.internal_note' }
|
|
697
|
+
})
|
|
698
|
+
]
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const outer = buildIteratorBlock({
|
|
702
|
+
iteratorBlock: iteratorOf(
|
|
703
|
+
block({ id: 'card', type: 'group', label: 'Card', blocks: [noteStrip] })
|
|
704
|
+
),
|
|
705
|
+
items: [{ internal_note: 'do-not-ship', colors: colours }]
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const expanded = buildIteratorBlock({
|
|
709
|
+
iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
713
|
+
'Colour',
|
|
714
|
+
'Colour'
|
|
715
|
+
]);
|
|
716
|
+
expect(JSON.stringify(expanded)).not.toContain('do-not-ship');
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('draws no swatch for a product whose colour list is empty', () => {
|
|
720
|
+
const [{ strip: expanded }] = expandCards([{ colors: [] }]);
|
|
721
|
+
|
|
722
|
+
expect(expanded.blocks).toEqual([]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('draws no swatch for a product that has no colours at all', () => {
|
|
726
|
+
// Next to cards that do have colours, one empty swatch is a visible
|
|
727
|
+
// artefact — the shopper reads it as a colour.
|
|
728
|
+
const [{ strip: expanded }] = expandCards([{ sku: 'ABC-1' }]);
|
|
729
|
+
|
|
730
|
+
expect(expanded.blocks).toEqual([]);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('still draws the placeholder swatch on the canvas, where no row answered', () => {
|
|
734
|
+
// A card with no data behind it: the strip is inside a row that knows
|
|
735
|
+
// nothing, so nothing has said "this product has no colours" and the
|
|
736
|
+
// designer still needs a swatch to arrange.
|
|
737
|
+
const [{ strip: expanded }] = expandCards([{}]);
|
|
738
|
+
|
|
739
|
+
expect(expanded.blocks).toHaveLength(1);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('still draws the placeholder row where there is no row to ask', () => {
|
|
743
|
+
// The canvas and a page whose data has not landed both reach the iterator
|
|
744
|
+
// with an empty list and no row behind it. That is not "this product has
|
|
745
|
+
// no colours", and it still owes the designer something to arrange.
|
|
746
|
+
const canvas = buildIteratorBlock({
|
|
747
|
+
iteratorBlock: iteratorOf(block({ id: 'swatch' }), {
|
|
748
|
+
iteratorDataPath: 'item.colors'
|
|
749
|
+
}),
|
|
750
|
+
items: []
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
expect(canvas.blocks).toHaveLength(1);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('drops what it handed over once the nested repeat has expanded', () => {
|
|
757
|
+
const [{ strip: expanded }] = expandCards([{ colors: colours }]);
|
|
758
|
+
|
|
759
|
+
expect(JSON.stringify(expanded.properties)).not.toContain('#ff0000');
|
|
760
|
+
expect(expanded.properties.iteratorDataPath).toBe('item.colors');
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe('a repeat over a list of plain values', () => {
|
|
765
|
+
// `tags: ['new','sale']` is an ordinary catalogue shape. The repeat draws one
|
|
766
|
+
// clone per entry, so the entry has to be addressable — otherwise every clone
|
|
767
|
+
// prints the authored fallback and the surface is a dead end.
|
|
768
|
+
const expandChips = (
|
|
769
|
+
chip: Block,
|
|
770
|
+
row: Record<string, unknown>,
|
|
771
|
+
dataPath = 'item.tags'
|
|
772
|
+
) => {
|
|
773
|
+
const chipStrip = block({
|
|
774
|
+
id: 'chips',
|
|
775
|
+
type: 'group',
|
|
776
|
+
label: 'Chips',
|
|
777
|
+
isIterator: true,
|
|
778
|
+
iteratorDataPath: dataPath,
|
|
779
|
+
properties: { iteratorDataPath: dataPath },
|
|
780
|
+
blocks: [chip]
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const outer = buildIteratorBlock({
|
|
784
|
+
iteratorBlock: iteratorOf(
|
|
785
|
+
block({ id: 'card', type: 'group', label: 'Card', blocks: [chipStrip] })
|
|
786
|
+
),
|
|
787
|
+
items: [row]
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return buildIteratorBlock({
|
|
791
|
+
iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
|
|
792
|
+
});
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
it('addresses the entry itself with a bare item binding', () => {
|
|
796
|
+
const expanded = expandChips(
|
|
797
|
+
block({ id: 'chip', value: 'Tag', properties: { dataBinding: 'item' } }),
|
|
798
|
+
{ tags: ['new', 'sale'] }
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
802
|
+
'new',
|
|
803
|
+
'sale'
|
|
804
|
+
]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('reads the entry inside a longer string', () => {
|
|
808
|
+
const expanded = expandChips(
|
|
809
|
+
block({
|
|
810
|
+
id: 'chip',
|
|
811
|
+
value: 'Tag',
|
|
812
|
+
properties: { dataBinding: '#{{item}}' }
|
|
813
|
+
}),
|
|
814
|
+
{ tags: ['new', 'sale'] }
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
818
|
+
'#new',
|
|
819
|
+
'#sale'
|
|
820
|
+
]);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('reads a numeric entry, which carries no keys to count', () => {
|
|
824
|
+
const expanded = expandChips(
|
|
825
|
+
block({ id: 'chip', value: 'Size', properties: { dataBinding: 'item' } }),
|
|
826
|
+
{ sizes: [38, 40] },
|
|
827
|
+
'item.sizes'
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
|
|
831
|
+
'38',
|
|
832
|
+
'40'
|
|
833
|
+
]);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('paints the entry when it is the colour itself', () => {
|
|
837
|
+
const expanded = expandChips(
|
|
838
|
+
block({ id: 'chip', styles: { 'background-color': '{{item}}' } }),
|
|
839
|
+
{ swatches: ['#ff0000', '#0000ff'] },
|
|
840
|
+
'item.swatches'
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
expect(
|
|
844
|
+
(expanded.blocks || []).map(
|
|
845
|
+
(clone) => (clone.styles as Record<string, unknown>)['background-color']
|
|
846
|
+
)
|
|
847
|
+
).toEqual(['#ff0000', '#0000ff']);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe('an iterator keeps the shape it was given', () => {
|
|
852
|
+
it('does not grow a properties key it never had', () => {
|
|
853
|
+
const iteratorBlock = {
|
|
854
|
+
id: 'iterator',
|
|
855
|
+
type: 'group',
|
|
856
|
+
label: 'Iterator',
|
|
857
|
+
order: 0,
|
|
858
|
+
isIterator: true,
|
|
859
|
+
blocks: [{ id: 'row', type: 'text', label: 'Row', order: 0, value: 'x' }]
|
|
860
|
+
} as unknown as Block;
|
|
861
|
+
|
|
862
|
+
const expanded = buildIteratorBlock({ iteratorBlock, items: [{}] });
|
|
863
|
+
|
|
864
|
+
expect('properties' in expanded).toBe(false);
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe('a breakpoint never inherits another breakpoint row value', () => {
|
|
869
|
+
const expandBadge = (
|
|
870
|
+
responsiveValue: Record<string, unknown>,
|
|
871
|
+
row: Record<string, unknown>
|
|
872
|
+
) =>
|
|
873
|
+
(buildIteratorBlock({
|
|
874
|
+
iteratorBlock: iteratorOf(
|
|
875
|
+
block({ id: 'badge', styles: { 'background-color': responsiveValue } })
|
|
876
|
+
),
|
|
877
|
+
items: [row]
|
|
878
|
+
}).blocks || [])[0];
|
|
879
|
+
|
|
880
|
+
it('unsets the whole property when one breakpoint cannot resolve', () => {
|
|
881
|
+
// `getResponsiveValue` answers a missing breakpoint with the desktop
|
|
882
|
+
// value, so leaving desktop behind paints mobile with the desktop row's
|
|
883
|
+
// colour — the wrong colour, not the unset the author would expect.
|
|
884
|
+
const clone = expandBadge(
|
|
885
|
+
{
|
|
886
|
+
desktop: '{{item.badgeColor}}',
|
|
887
|
+
mobile: '{{item.badgeColorMobile}}'
|
|
888
|
+
},
|
|
889
|
+
{ badgeColor: '#00a86b' }
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
expect('background-color' in (clone.styles as Record<string, unknown>)).toBe(
|
|
893
|
+
false
|
|
894
|
+
);
|
|
895
|
+
expect(
|
|
896
|
+
getCSSStyles(clone.styles as Record<string, unknown>, undefined, 'mobile')
|
|
897
|
+
.backgroundColor
|
|
898
|
+
).toBeUndefined();
|
|
899
|
+
expect(
|
|
900
|
+
getCSSStyles(clone.styles as Record<string, unknown>, undefined, 'desktop')
|
|
901
|
+
.backgroundColor
|
|
902
|
+
).toBeUndefined();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('keeps a fully resolved responsive value', () => {
|
|
906
|
+
const clone = expandBadge(
|
|
907
|
+
{ desktop: '{{item.badgeColor}}', mobile: '{{item.badgeColorMobile}}' },
|
|
908
|
+
{ badgeColor: '#00a86b', badgeColorMobile: '#e63946' }
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
expect((clone.styles as Record<string, unknown>)['background-color']).toEqual(
|
|
912
|
+
{
|
|
913
|
+
desktop: '#00a86b',
|
|
914
|
+
mobile: '#e63946'
|
|
915
|
+
}
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
describe('a style token addresses the row only when it says so', () => {
|
|
921
|
+
it('leaves a token that does not address the row to the layer that owns it', () => {
|
|
922
|
+
// `{{brandColor}}` is a section-level spelling. Resolving it off the row
|
|
923
|
+
// means a field that happens to share the name decides a site-wide colour.
|
|
924
|
+
const styles = { 'background-color': '{{brandColor}}' };
|
|
925
|
+
const clone = (buildIteratorBlock({
|
|
926
|
+
iteratorBlock: iteratorOf(block({ id: 'card', styles })),
|
|
927
|
+
items: [{ brandColor: '#00a86b' }]
|
|
928
|
+
}).blocks || [])[0];
|
|
929
|
+
|
|
930
|
+
expect(clone.styles).toBe(styles);
|
|
931
|
+
expect((clone.styles as Record<string, unknown>)['background-color']).toBe(
|
|
932
|
+
'{{brandColor}}'
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('unsets a value that mixes a row token with one it cannot resolve', () => {
|
|
937
|
+
const clone = (buildIteratorBlock({
|
|
938
|
+
iteratorBlock: iteratorOf(
|
|
939
|
+
block({ id: 'card', styles: { padding: '{{item.gap}}px {{siteGap}}' } })
|
|
940
|
+
),
|
|
941
|
+
items: [{ gap: 8, siteGap: '12px' }]
|
|
942
|
+
}).blocks || [])[0];
|
|
943
|
+
|
|
944
|
+
expect('padding' in (clone.styles as Record<string, unknown>)).toBe(false);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe('a binding can sit inside a longer string', () => {
|
|
949
|
+
const expandText = (
|
|
950
|
+
dataBinding: string,
|
|
951
|
+
row: Record<string, unknown>,
|
|
952
|
+
overrides: Partial<Block> = {}
|
|
953
|
+
) =>
|
|
954
|
+
(buildIteratorBlock({
|
|
955
|
+
iteratorBlock: iteratorOf(
|
|
956
|
+
block({
|
|
957
|
+
id: 'label',
|
|
958
|
+
value: 'Authored',
|
|
959
|
+
properties: { dataBinding },
|
|
960
|
+
...overrides
|
|
961
|
+
})
|
|
962
|
+
),
|
|
963
|
+
items: [row]
|
|
964
|
+
}).blocks || [])[0];
|
|
965
|
+
|
|
966
|
+
it('keeps the literal text around each binding', () => {
|
|
967
|
+
expect(
|
|
968
|
+
expandText('Ürün Kodu {{item.base_code}}.{{item.color_code}}', {
|
|
969
|
+
base_code: 'ABC123',
|
|
970
|
+
color_code: '42'
|
|
971
|
+
}).value
|
|
972
|
+
).toBe('Ürün Kodu ABC123.42');
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('reads a binding followed by literal text', () => {
|
|
976
|
+
expect(
|
|
977
|
+
expandText('{{item.colorCount}} Renk', { colorCount: 5 }).value
|
|
978
|
+
).toBe('5 Renk');
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('writes an empty string for the token that has no data', () => {
|
|
982
|
+
expect(
|
|
983
|
+
expandText('{{item.brand}} {{item.model}}', { brand: 'Lacoste' }).value
|
|
984
|
+
).toBe('Lacoste ');
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('keeps the authored value when nothing at all resolves', () => {
|
|
988
|
+
expect(
|
|
989
|
+
expandText('{{item.brand}} {{item.model}}', { other: 1 }).value
|
|
990
|
+
).toBe('Authored');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it('interpolates into a link target too', () => {
|
|
994
|
+
const link = expandText(
|
|
995
|
+
'/p/{{item.slug}}-{{item.pk}}',
|
|
996
|
+
{
|
|
997
|
+
slug: 'sneaker',
|
|
998
|
+
pk: 42
|
|
999
|
+
},
|
|
1000
|
+
{ type: 'link' }
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
expect(link.properties.href).toBe('/p/sneaker-42');
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
describe('a value that is exactly one binding still returns the raw value', () => {
|
|
1008
|
+
const expandBoth = (
|
|
1009
|
+
path: string,
|
|
1010
|
+
row: Record<string, unknown>,
|
|
1011
|
+
type = 'text'
|
|
1012
|
+
) => {
|
|
1013
|
+
const run = (dataBinding: string) =>
|
|
1014
|
+
(buildIteratorBlock({
|
|
1015
|
+
iteratorBlock: iteratorOf(
|
|
1016
|
+
block({ id: 'target', type, properties: { dataBinding } })
|
|
1017
|
+
),
|
|
1018
|
+
items: [row]
|
|
1019
|
+
}).blocks || [])[0];
|
|
1020
|
+
|
|
1021
|
+
// The binding string itself is kept verbatim on the clone, so it is the
|
|
1022
|
+
// one thing that legitimately differs between the two spellings.
|
|
1023
|
+
const withoutBinding = (clone: Block) => ({
|
|
1024
|
+
...clone,
|
|
1025
|
+
properties: { ...clone.properties, dataBinding: undefined }
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
return {
|
|
1029
|
+
bare: withoutBinding(run(path)),
|
|
1030
|
+
wrapped: withoutBinding(run(`{{${path}}}`))
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
it.each([
|
|
1035
|
+
['a string', 'item.name', { name: 'Sneaker' }, 'text'],
|
|
1036
|
+
['a number', 'item.stock', { stock: 0 }, 'text'],
|
|
1037
|
+
['a boolean', 'item.inStock', { inStock: false }, 'text'],
|
|
1038
|
+
[
|
|
1039
|
+
'an image object',
|
|
1040
|
+
'item.image',
|
|
1041
|
+
{ image: { url: 'https://cdn.example.com/a.jpg' } },
|
|
1042
|
+
'image'
|
|
1043
|
+
],
|
|
1044
|
+
['a price', 'item.price', { price: 199.9, currency_type: 'TRY' }, 'text']
|
|
1045
|
+
])('%s behaves the same wrapped as bare', (_label, path, row, type) => {
|
|
1046
|
+
const { bare, wrapped } = expandBoth(
|
|
1047
|
+
path as string,
|
|
1048
|
+
row as Record<string, unknown>,
|
|
1049
|
+
type as string
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
expect(wrapped).toEqual(bare);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
describe('section-level bindings interpolate too', () => {
|
|
1057
|
+
const sectionDataSource = {
|
|
1058
|
+
type: 'page-context',
|
|
1059
|
+
details: { pageContext: { pageType: 'product', rootPath: 'product' } }
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
it('mixes literal text with page-context values', () => {
|
|
1063
|
+
const resolved = resolveBlockBindings({
|
|
1064
|
+
block: block({
|
|
1065
|
+
id: 'greeting',
|
|
1066
|
+
value: 'Fallback',
|
|
1067
|
+
properties: {
|
|
1068
|
+
dataBinding: '{{customer.firstName}} için {{product.title}}'
|
|
1069
|
+
}
|
|
1070
|
+
}),
|
|
1071
|
+
sectionDataSource,
|
|
1072
|
+
pageContext: {
|
|
1073
|
+
customer: { firstName: 'Esma' },
|
|
1074
|
+
product: { title: 'Sneaker' }
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
expect(resolved.value).toBe('Esma için Sneaker');
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it('leaves a row-scoped template to the iterator', () => {
|
|
1082
|
+
// The iterator has already written the row's value; re-resolving against
|
|
1083
|
+
// the section would blank it back out.
|
|
1084
|
+
const resolved = resolveBlockBindings({
|
|
1085
|
+
block: block({
|
|
1086
|
+
id: 'code',
|
|
1087
|
+
value: 'Ürün Kodu ABC123',
|
|
1088
|
+
properties: { dataBinding: 'Ürün Kodu {{item.base_code}}' }
|
|
1089
|
+
}),
|
|
1090
|
+
sectionDataSource,
|
|
1091
|
+
pageContext: { base_code: 'WRONG' }
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
expect(resolved.value).toBe('Ürün Kodu ABC123');
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
describe('a mistyped row-scoped list path is diagnosable', () => {
|
|
1099
|
+
const warnSpy = () => jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
1100
|
+
|
|
1101
|
+
const productWithScalarColour = {
|
|
1102
|
+
type: 'collection',
|
|
1103
|
+
details: { collection: { data: [{ title: 'Tee', colour: 'red' }] } }
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
const stripBoundTo = (path: string): Block =>
|
|
1107
|
+
iteratorOf(
|
|
1108
|
+
block({
|
|
1109
|
+
id: 'card',
|
|
1110
|
+
type: 'group',
|
|
1111
|
+
blocks: [
|
|
1112
|
+
block({
|
|
1113
|
+
id: 'strip',
|
|
1114
|
+
type: 'group',
|
|
1115
|
+
isIterator: true,
|
|
1116
|
+
iteratorDataPath: path,
|
|
1117
|
+
blocks: [block({ id: 'swatch', value: 'Colour' })]
|
|
1118
|
+
})
|
|
1119
|
+
]
|
|
1120
|
+
})
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
/** Same two-pass expansion theme-section performs. */
|
|
1124
|
+
const expand = (root: Block): Block => {
|
|
1125
|
+
const resolved = root.isIterator
|
|
1126
|
+
? buildIteratorBlock({
|
|
1127
|
+
iteratorBlock: root,
|
|
1128
|
+
sectionDataSource: productWithScalarColour
|
|
1129
|
+
})
|
|
1130
|
+
: root;
|
|
1131
|
+
if (!resolved.blocks?.length) return resolved;
|
|
1132
|
+
return { ...resolved, blocks: resolved.blocks.map(expand) };
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
it('warns when the row answers the path with something that is not a list', () => {
|
|
1136
|
+
// `item.colour` is a string, not `item.colours`. The repeat now draws no
|
|
1137
|
+
// rows at all, so without a warning the author sees an empty strip and no
|
|
1138
|
+
// clue that the path is the problem — the same typo against a SECTION path
|
|
1139
|
+
// has always been reported.
|
|
1140
|
+
const warn = warnSpy();
|
|
1141
|
+
try {
|
|
1142
|
+
expand(stripBoundTo('item.colour'));
|
|
1143
|
+
expect(warn).toHaveBeenCalledWith(
|
|
1144
|
+
expect.stringContaining('item.colour')
|
|
1145
|
+
);
|
|
1146
|
+
} finally {
|
|
1147
|
+
warn.mockRestore();
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('stays silent when the row simply has no such field yet', () => {
|
|
1152
|
+
// Absent is ambiguous with data that has not landed, exactly as the
|
|
1153
|
+
// section-scoped path treats an unresolved lookup.
|
|
1154
|
+
const warn = warnSpy();
|
|
1155
|
+
try {
|
|
1156
|
+
expand(stripBoundTo('item.not_here'));
|
|
1157
|
+
expect(warn).not.toHaveBeenCalled();
|
|
1158
|
+
} finally {
|
|
1159
|
+
warn.mockRestore();
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it('stays silent when the row answers with a real list', () => {
|
|
1164
|
+
const warn = warnSpy();
|
|
1165
|
+
try {
|
|
1166
|
+
buildIteratorBlock({
|
|
1167
|
+
iteratorBlock: stripBoundTo('item.colours'),
|
|
1168
|
+
sectionDataSource: {
|
|
1169
|
+
type: 'collection',
|
|
1170
|
+
details: { collection: { data: [{ colours: [{ hex: '#ff0000' }] }] } }
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
expect(warn).not.toHaveBeenCalled();
|
|
1174
|
+
} finally {
|
|
1175
|
+
warn.mockRestore();
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
});
|