@akinon/pz-theme 2.0.37-beta.0 → 2.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,570 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import ProductGridBlock, {
4
+ buildInsertionRules,
5
+ interleaveCells,
6
+ placeInsertions
7
+ } from '../product-grid-block';
8
+ import { ThemePageContextProvider } from '../../theme-page-context';
9
+ import type { Block } from '../../theme-block';
10
+
11
+ /**
12
+ * The grid gained two authorable compositions: a merchandiser tile that takes
13
+ * a cell of the grid, and the no-results layout that replaces the grid.
14
+ *
15
+ * The first block of tests is the one that matters most — with nothing
16
+ * authored the DOM has to be what it was before any of this existed. The
17
+ * golden below was captured by rendering the previous renderer, so it is a
18
+ * record of the old output rather than a description of the new one.
19
+ */
20
+
21
+ const CARD_A =
22
+ '<a href="/a" data-product-card="" data-part="card" style="display: flex; flex-direction: column; gap: 8px; text-decoration: none;">' +
23
+ '<img alt="A" loading="lazy" data-product-card-image="" data-part="image" style="width: 100%; height: auto; display: block; object-fit: cover;" src="/a.jpg">' +
24
+ '<span data-product-card-name="" data-part="name">A</span>' +
25
+ '<span data-product-card-price="" data-part="price" style="display: flex; align-items: center; gap: 8px;">' +
26
+ '<s data-product-card-retail-price="" data-part="list-price" style="opacity: 0.6; font-size: 0.9em;">$200.00</s>' +
27
+ '<span data-part="sale-price">$100.00</span></span></a>';
28
+
29
+ const CARD_B =
30
+ '<a href="/b" data-product-card="" data-part="card" style="display: flex; flex-direction: column; gap: 8px; text-decoration: none;">' +
31
+ '<span data-product-card-image="" data-part="image" data-state="empty" style="display: block; width: 100%; background: rgb(241, 245, 249);"></span>' +
32
+ '<span data-product-card-name="" data-part="name">B</span>' +
33
+ '<span data-product-card-price="" data-part="price" style="display: flex; align-items: center; gap: 8px;">' +
34
+ '<span data-part="sale-price">$50.00</span></span></a>';
35
+
36
+ const CARD_C =
37
+ '<a href="/c" data-product-card="" data-part="card" style="display: flex; flex-direction: column; gap: 8px; text-decoration: none;">' +
38
+ '<img alt="C" loading="lazy" data-product-card-image="" data-part="image" style="width: 100%; height: auto; display: block; object-fit: cover;" src="/c.jpg">' +
39
+ '<span data-product-card-name="" data-part="name">C</span>' +
40
+ '<span data-product-card-price="" data-part="price" style="display: flex; align-items: center; gap: 8px;">' +
41
+ '<span data-part="sale-price">$75.00</span></span></a>';
42
+
43
+ const DEFAULT_HTML =
44
+ '<div data-product-grid="" data-product-grid-id="grid-1" data-part="list" style="display: grid; gap: var(--product-grid-gap, 16px);">' +
45
+ '<style>[data-product-grid-id="grid-1"]{grid-template-columns:repeat(4,minmax(0,1fr));}' +
46
+ '@media (max-width:1023px){[data-product-grid-id="grid-1"]{grid-template-columns:repeat(2,minmax(0,1fr));}}</style>' +
47
+ CARD_A +
48
+ CARD_B +
49
+ CARD_C +
50
+ '</div>';
51
+
52
+ const DESIGNER_PLACEHOLDER_HTML =
53
+ '<div data-product-grid="" style="display: inline-block; padding: 8px 12px; ' +
54
+ 'border: 1px dashed #cbd5e1; border-radius: 4px; color: rgb(100, 116, 139); font-size: 12px;">' +
55
+ 'Product grid (no listing data)</div>';
56
+
57
+ const product = (n: number, over: Record<string, unknown> = {}) => ({
58
+ pk: n,
59
+ name: `P${n}`,
60
+ url: `/p${n}`,
61
+ image: `/p${n}.jpg`,
62
+ price: 10 * n,
63
+ ...over
64
+ });
65
+
66
+ const listingContext = (
67
+ products: Record<string, unknown>[],
68
+ pagination: Record<string, unknown> = { current_page: 1, count: 99 }
69
+ ) => ({
70
+ listing: {
71
+ title: 'Shoes',
72
+ path: '/shoes',
73
+ products,
74
+ facets: [{ key: 'colour' }],
75
+ sorters: [],
76
+ pagination
77
+ }
78
+ });
79
+
80
+ const THREE_PRODUCTS = listingContext([
81
+ { pk: 1, name: 'A', url: '/a', image: '/a.jpg', price: 100, retail_price: 200 },
82
+ { pk: 2, name: 'B', url: '/b', image: '', price: 50 },
83
+ { pk: 3, name: 'C', url: '/c', image: '/c.jpg', price: 75 }
84
+ ]);
85
+
86
+ const child = (
87
+ role: string,
88
+ properties: Record<string, unknown> = {},
89
+ over: Partial<Block> = {}
90
+ ): Block =>
91
+ ({
92
+ id: 'tile-1',
93
+ type: 'group',
94
+ label: 'Campaign',
95
+ order: 0,
96
+ hidden: false,
97
+ value: '',
98
+ properties: { role: { desktop: role }, ...properties },
99
+ styles: {},
100
+ blocks: [
101
+ {
102
+ id: 'tile-copy',
103
+ type: 'text',
104
+ label: 'Copy',
105
+ order: 0,
106
+ hidden: false,
107
+ value: 'Winter campaign',
108
+ properties: {},
109
+ styles: {},
110
+ blocks: []
111
+ }
112
+ ],
113
+ ...over
114
+ } as Block);
115
+
116
+ const gridBlock = (
117
+ properties: Record<string, unknown> = {},
118
+ blocks: Block[] = []
119
+ ): Block =>
120
+ ({
121
+ id: 'grid-1',
122
+ type: 'product-grid',
123
+ label: 'Grid',
124
+ order: 0,
125
+ hidden: false,
126
+ value: '',
127
+ properties,
128
+ styles: {},
129
+ blocks
130
+ } as Block);
131
+
132
+ const draw = (
133
+ block: Block,
134
+ context: unknown,
135
+ isDesigner = false,
136
+ currentBreakpoint = 'desktop'
137
+ ) =>
138
+ render(
139
+ <ThemePageContextProvider value={context as Record<string, unknown>}>
140
+ <ProductGridBlock
141
+ block={block}
142
+ placeholderId="p"
143
+ sectionId="s"
144
+ isDesigner={isDesigner}
145
+ currentBreakpoint={currentBreakpoint}
146
+ />
147
+ </ThemePageContextProvider>
148
+ ).container;
149
+
150
+ /** The grid's cells in DOM order, with the stylesheet dropped. */
151
+ const cellSequence = (container: HTMLElement): string[] =>
152
+ Array.from(
153
+ container.querySelector('[data-product-grid]')?.children ?? []
154
+ )
155
+ .filter((element) => element.tagName !== 'STYLE')
156
+ .map((element) =>
157
+ element.hasAttribute('data-product-grid-insert')
158
+ ? `tile:${element.getAttribute('data-product-grid-insert')}`
159
+ : element.getAttribute('href') || element.tagName.toLowerCase()
160
+ );
161
+
162
+ const sheet = (container: HTMLElement): string =>
163
+ container.querySelector('style')?.textContent ?? '';
164
+
165
+ /** Every cell-pinning rule the grid wrote, reduced to its bare selector. */
166
+ const pinSelectors = (css: string): string[] =>
167
+ Array.from(css.matchAll(/([^{}@]+)\{grid-column:[^{}]*\}/g)).map((match) =>
168
+ match[1].trim()
169
+ );
170
+
171
+ describe('nothing authored renders exactly what it rendered before', () => {
172
+ it('draws the grid byte for byte', () => {
173
+ expect(draw(gridBlock(), THREE_PRODUCTS).innerHTML).toBe(DEFAULT_HTML);
174
+ });
175
+
176
+ it('still renders nothing on a storefront listing that came back empty', () => {
177
+ expect(draw(gridBlock(), listingContext([])).innerHTML).toBe('');
178
+ });
179
+
180
+ it('still renders nothing on a page with no listing at all', () => {
181
+ expect(draw(gridBlock(), null).innerHTML).toBe('');
182
+ });
183
+
184
+ it('still shows only the designer notice when there is no data', () => {
185
+ expect(draw(gridBlock(), null, true).innerHTML).toBe(
186
+ DESIGNER_PLACEHOLDER_HTML
187
+ );
188
+ });
189
+
190
+ it('emits no tile or empty-state markup', () => {
191
+ const container = draw(gridBlock(), THREE_PRODUCTS);
192
+
193
+ expect(container.querySelector('[data-product-grid-insert]')).toBeNull();
194
+ expect(container.querySelector('[data-product-grid-empty]')).toBeNull();
195
+ });
196
+
197
+ it('leaves a child carrying no role unrendered, as it always was', () => {
198
+ const container = draw(
199
+ gridBlock({}, [child('')]),
200
+ THREE_PRODUCTS
201
+ );
202
+
203
+ expect(container.innerHTML).toBe(DEFAULT_HTML);
204
+ });
205
+ });
206
+
207
+ describe('a merchandiser tile in the grid', () => {
208
+ const eightProducts = listingContext(
209
+ Array.from({ length: 8 }, (_, index) => product(index + 1))
210
+ );
211
+
212
+ it('takes the authored cell and pushes the products along', () => {
213
+ const container = draw(
214
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
215
+ eightProducts
216
+ );
217
+
218
+ expect(cellSequence(container)).toEqual([
219
+ '/p1',
220
+ '/p2',
221
+ '/p3',
222
+ '/p4',
223
+ 'tile:0',
224
+ '/p5',
225
+ '/p6',
226
+ '/p7',
227
+ '/p8'
228
+ ]);
229
+ });
230
+
231
+ it('pins the cell for each column count the block emits', () => {
232
+ const css = sheet(
233
+ draw(
234
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
235
+ eightProducts
236
+ )
237
+ );
238
+
239
+ // Cell 5 is the start of row 2 across four columns…
240
+ expect(css).toContain(
241
+ '[data-product-grid-id="grid-1"] ' +
242
+ '[data-product-grid-page="1"][data-product-grid-insert="0"]' +
243
+ '{grid-column:1/span 1;grid-row:2/span 1;}'
244
+ );
245
+ // …and of row 3 across the two the same block drops to on a phone.
246
+ expect(css).toContain(
247
+ '[data-product-grid-id="grid-1"] ' +
248
+ '[data-product-grid-page="1"][data-product-grid-insert="0"]' +
249
+ '{grid-column:1/span 1;grid-row:3/span 1;}'
250
+ );
251
+ });
252
+
253
+ it('keeps the mobile rule inside the mobile query', () => {
254
+ const css = sheet(
255
+ draw(
256
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
257
+ eightProducts
258
+ )
259
+ );
260
+ const query = css.slice(css.indexOf('@media'));
261
+
262
+ expect(query).toContain('grid-row:3/span 1;');
263
+ expect(query.endsWith('}')).toBe(true);
264
+ });
265
+
266
+ it('spans two columns and two rows when asked', () => {
267
+ const css = sheet(
268
+ draw(
269
+ gridBlock({}, [
270
+ child('grid-insertion', {
271
+ insertPosition: 5,
272
+ insertColumnSpan: 2,
273
+ insertRowSpan: 2
274
+ })
275
+ ]),
276
+ eightProducts
277
+ )
278
+ );
279
+
280
+ expect(css).toContain('{grid-column:1/span 2;grid-row:2/span 2;}');
281
+ });
282
+
283
+ it('narrows a wide tile to the column count it is actually in', () => {
284
+ // Two columns wide is the whole row on a one-column phone; asking for two
285
+ // tracks there would push the tile off the edge of the grid.
286
+ const css = sheet(
287
+ draw(
288
+ gridBlock({ columnsMobile: 1 }, [
289
+ child('grid-insertion', {
290
+ insertPosition: 5,
291
+ insertColumnSpan: 2
292
+ })
293
+ ]),
294
+ eightProducts
295
+ )
296
+ );
297
+ const query = css.slice(css.indexOf('@media'));
298
+
299
+ expect(css.slice(0, css.indexOf('@media'))).toContain(
300
+ '{grid-column:1/span 2;grid-row:2/span 1;}'
301
+ );
302
+ expect(query).toContain('{grid-column:1/span 1;grid-row:5/span 1;}');
303
+ });
304
+
305
+ it('pulls a tile back inside the last track rather than overhanging it', () => {
306
+ const css = sheet(
307
+ draw(
308
+ gridBlock({}, [
309
+ child('grid-insertion', {
310
+ insertPosition: 4,
311
+ insertColumnSpan: 2
312
+ })
313
+ ]),
314
+ eightProducts
315
+ )
316
+ );
317
+
318
+ // Cell 4 is the last column of four; a two-column tile starts one earlier.
319
+ expect(css.slice(0, css.indexOf('@media'))).toContain(
320
+ '{grid-column:3/span 2;grid-row:1/span 1;}'
321
+ );
322
+ });
323
+
324
+ it('lands at the end when the filters returned fewer products than that', () => {
325
+ // Three results and a tile authored for cell 5: pinning it to cell 5 would
326
+ // leave cell 4 unreachable and tear a hole through the row.
327
+ const container = draw(
328
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
329
+ THREE_PRODUCTS
330
+ );
331
+
332
+ expect(cellSequence(container)).toEqual(['/a', '/b', '/c', 'tile:0']);
333
+ expect(sheet(container).slice(0, sheet(container).indexOf('@media'))).toContain(
334
+ '{grid-column:4/span 1;grid-row:1/span 1;}'
335
+ );
336
+ });
337
+
338
+ it('is the only cell left when the listing came back empty', () => {
339
+ const container = draw(
340
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
341
+ listingContext([])
342
+ );
343
+
344
+ expect(cellSequence(container)).toEqual(['tile:0']);
345
+ });
346
+
347
+ it('stays off a page it was not authored for', () => {
348
+ const secondPage = listingContext(
349
+ Array.from({ length: 8 }, (_, index) => product(index + 1)),
350
+ { current_page: 2, count: 99 }
351
+ );
352
+
353
+ expect(
354
+ draw(
355
+ gridBlock({}, [child('grid-insertion', { insertPosition: 5 })]),
356
+ secondPage
357
+ ).querySelector('[data-product-grid-insert]')
358
+ ).toBeNull();
359
+
360
+ expect(
361
+ draw(
362
+ gridBlock({}, [
363
+ child('grid-insertion', { insertPosition: 5, insertPages: 'all' })
364
+ ]),
365
+ secondPage
366
+ ).querySelector('[data-product-grid-insert]')
367
+ ).not.toBeNull();
368
+ });
369
+
370
+ it('pins only the page it was rendered for', () => {
371
+ // "Cell 5" means the fifth cell of the page being read, so the rule that
372
+ // expresses it is only true of that page. Load More can put several pages
373
+ // in one grid; a rule that reaches past its own page pins two tiles into
374
+ // one cell and buries whatever the shopper was already looking at.
375
+ const everyPage = child('grid-insertion', {
376
+ insertPosition: 5,
377
+ insertPages: 'all'
378
+ });
379
+ const products = Array.from({ length: 8 }, (_, index) => product(index + 1));
380
+
381
+ const pageOne = draw(gridBlock({}, [everyPage]), listingContext(products));
382
+ const pageTwo = draw(
383
+ gridBlock({}, [everyPage]),
384
+ listingContext(products, { current_page: 2, count: 99 })
385
+ );
386
+
387
+ const selectors = pinSelectors(sheet(pageOne));
388
+ expect(selectors.length).toBeGreaterThan(0);
389
+ selectors.forEach((selector) => {
390
+ expect(pageOne.querySelectorAll(selector)).toHaveLength(1);
391
+ expect(pageTwo.querySelectorAll(selector)).toHaveLength(0);
392
+ });
393
+ });
394
+
395
+ it('is gone from the storefront when the designer hid it', () => {
396
+ const hidden = child('grid-insertion', { insertPosition: 2 }, {
397
+ hidden: true
398
+ } as Partial<Block>);
399
+
400
+ expect(
401
+ draw(gridBlock({}, [hidden]), THREE_PRODUCTS).querySelector(
402
+ '[data-product-grid-insert]'
403
+ )
404
+ ).toBeNull();
405
+ // Still on the canvas, so it can be brought back — the rule the group
406
+ // block already applies to its own children.
407
+ expect(
408
+ draw(gridBlock({}, [hidden]), THREE_PRODUCTS, true).querySelector(
409
+ '[data-product-grid-insert]'
410
+ )
411
+ ).not.toBeNull();
412
+ });
413
+
414
+ it('declares the part it emits so the tile can be selected and styled', () => {
415
+ const container = draw(
416
+ gridBlock({}, [child('grid-insertion', { insertPosition: 2 })]),
417
+ THREE_PRODUCTS
418
+ );
419
+
420
+ expect(
421
+ container
422
+ .querySelector('[data-product-grid-insert]')
423
+ ?.getAttribute('data-part')
424
+ ).toBe('insert');
425
+ });
426
+
427
+ it('writes nothing to storage while the designer arranges it', () => {
428
+ const setItem = jest.spyOn(window.sessionStorage.__proto__, 'setItem');
429
+
430
+ draw(
431
+ gridBlock({}, [child('grid-insertion', { insertPosition: 2 })]),
432
+ THREE_PRODUCTS,
433
+ true
434
+ );
435
+
436
+ expect(setItem).not.toHaveBeenCalled();
437
+ setItem.mockRestore();
438
+ });
439
+ });
440
+
441
+ describe('the no-results page', () => {
442
+ const emptyState = () =>
443
+ child('listing-empty', {}, { id: 'empty-1' } as Partial<Block>);
444
+
445
+ it('replaces the grid when the filters return nothing', () => {
446
+ const container = draw(
447
+ gridBlock({}, [emptyState()]),
448
+ listingContext([])
449
+ );
450
+
451
+ expect(container.querySelector('[data-product-grid]')).toBeNull();
452
+ expect(container.querySelector('[data-product-grid-empty]')).not.toBeNull();
453
+ expect(container.textContent).toContain('Winter campaign');
454
+ });
455
+
456
+ it('declares the part it emits', () => {
457
+ const container = draw(gridBlock({}, [emptyState()]), listingContext([]));
458
+
459
+ expect(
460
+ container
461
+ .querySelector('[data-product-grid-empty]')
462
+ ?.getAttribute('data-part')
463
+ ).toBe('empty');
464
+ });
465
+
466
+ it('stays out of the way while the listing has results', () => {
467
+ const container = draw(gridBlock({}, [emptyState()]), THREE_PRODUCTS);
468
+
469
+ expect(container.querySelector('[data-product-grid-empty]')).toBeNull();
470
+ expect(container.querySelector('[data-product-grid]')).not.toBeNull();
471
+ });
472
+
473
+ it('never speaks for a page that has no listing at all', () => {
474
+ // A grid dropped on a landing page has not "returned no results"; saying
475
+ // so would announce an empty search on a page that never ran one.
476
+ expect(draw(gridBlock({}, [emptyState()]), null).innerHTML).toBe('');
477
+ });
478
+
479
+ it('can be previewed on the canvas without touching the storefront', () => {
480
+ const block = gridBlock({ previewEmptyState: true }, [emptyState()]);
481
+
482
+ expect(
483
+ draw(block, THREE_PRODUCTS, true).querySelector('[data-product-grid-empty]')
484
+ ).not.toBeNull();
485
+ // The same composition on the live site, where the listing has products.
486
+ expect(draw(block, THREE_PRODUCTS, false).innerHTML).toBe(DEFAULT_HTML);
487
+ });
488
+
489
+ it('is gone from the storefront when the designer hid it', () => {
490
+ const hidden = child('listing-empty', {}, {
491
+ id: 'empty-1',
492
+ hidden: true
493
+ } as Partial<Block>);
494
+
495
+ expect(draw(gridBlock({}, [hidden]), listingContext([])).innerHTML).toBe('');
496
+ });
497
+ });
498
+
499
+ describe('placing tiles', () => {
500
+ const tile = (position: number) => ({
501
+ block: { id: `t${position}` } as Block,
502
+ position,
503
+ columnSpan: 1,
504
+ rowSpan: 1,
505
+ everyPage: false
506
+ });
507
+
508
+ it('honours an authored cell that the results can reach', () => {
509
+ expect(placeInsertions([tile(5)], 20).map((t) => t.cell)).toEqual([5]);
510
+ });
511
+
512
+ it('pulls a tile back to the first cell past the products', () => {
513
+ expect(placeInsertions([tile(9)], 3).map((t) => t.cell)).toEqual([4]);
514
+ expect(placeInsertions([tile(9)], 0).map((t) => t.cell)).toEqual([1]);
515
+ });
516
+
517
+ it('keeps two tiles authored onto one cell from stacking', () => {
518
+ expect(placeInsertions([tile(5), tile(5)], 20).map((t) => t.cell)).toEqual([
519
+ 5, 6
520
+ ]);
521
+ });
522
+
523
+ it('sorts by cell whatever order the children are in', () => {
524
+ expect(placeInsertions([tile(9), tile(2)], 20).map((t) => t.cell)).toEqual([
525
+ 2, 9
526
+ ]);
527
+ });
528
+
529
+ it('gives every tile a cell even when none of them fit', () => {
530
+ expect(
531
+ placeInsertions([tile(40), tile(40), tile(40)], 0).map((t) => t.cell)
532
+ ).toEqual([1, 2, 3]);
533
+ });
534
+ });
535
+
536
+ describe('the interleave and the rules it agrees with', () => {
537
+ const placed = (...cells: number[]) =>
538
+ cells.map((cell) => ({
539
+ block: {} as Block,
540
+ position: cell,
541
+ columnSpan: 1,
542
+ rowSpan: 1,
543
+ everyPage: false,
544
+ cell
545
+ }));
546
+
547
+ it('returns the cards untouched when there is nothing to insert', () => {
548
+ const cards = ['a', 'b', 'c'];
549
+
550
+ expect(interleaveCells(cards, [], [])).toEqual(cards);
551
+ });
552
+
553
+ it('puts each tile in the cell the stylesheet pins it to', () => {
554
+ expect(
555
+ interleaveCells(['a', 'b', 'c', 'd'], ['T1', 'T2'], placed(1, 4))
556
+ ).toEqual(['T1', 'a', 'b', 'T2', 'c', 'd']);
557
+ });
558
+
559
+ it('emits no rules for a grid with no tiles', () => {
560
+ expect(buildInsertionRules('grid-1', [], 4, 1)).toBe('');
561
+ });
562
+
563
+ it('writes a rule that only its own page can satisfy', () => {
564
+ expect(buildInsertionRules('grid-1', placed(3), 4, 2)).toBe(
565
+ '[data-product-grid-id="grid-1"] ' +
566
+ '[data-product-grid-page="2"][data-product-grid-insert="0"]' +
567
+ '{grid-column:3/span 1;grid-row:1/span 1;}'
568
+ );
569
+ });
570
+ });