@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.
- package/CHANGELOG.md +4 -4
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
const push = jest.fn();
|
|
5
|
+
const searchParams = new URLSearchParams('');
|
|
6
|
+
|
|
7
|
+
jest.mock('next/navigation', () => ({
|
|
8
|
+
__esModule: true,
|
|
9
|
+
usePathname: () => '/shoes',
|
|
10
|
+
useSearchParams: () => searchParams,
|
|
11
|
+
useRouter: () => ({ push, replace: jest.fn(), refresh: jest.fn() })
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import ListingPaginationBlock from '../blocks/listing-pagination-block';
|
|
15
|
+
import ProductGridBlock from '../blocks/product-grid-block';
|
|
16
|
+
import SortSelectBlock from '../blocks/sort-select-block';
|
|
17
|
+
import { ThemePageContextProvider } from '../theme-page-context';
|
|
18
|
+
|
|
19
|
+
type Props = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
const listingContext = (over: Record<string, unknown> = {}) => ({
|
|
22
|
+
listing: {
|
|
23
|
+
title: 'Shoes',
|
|
24
|
+
path: '/shoes',
|
|
25
|
+
products: [
|
|
26
|
+
{ name: 'One', url: '/p/1', price: '10', pk: 1 },
|
|
27
|
+
{ name: 'Two', url: '/p/2', price: '20', pk: 2 }
|
|
28
|
+
],
|
|
29
|
+
resultCount: 6,
|
|
30
|
+
facets: [],
|
|
31
|
+
sorters: [],
|
|
32
|
+
pagination: { current_page: 1, num_pages: 3, page_size: 2, count: 6 },
|
|
33
|
+
breadcrumbs: [],
|
|
34
|
+
...over
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const blockProps = (properties: Props = {}, over: Props = {}) =>
|
|
39
|
+
({
|
|
40
|
+
block: {
|
|
41
|
+
id: 'blk-1',
|
|
42
|
+
type: 'listing-pagination',
|
|
43
|
+
label: 'Pagination',
|
|
44
|
+
order: 0,
|
|
45
|
+
hidden: false,
|
|
46
|
+
value: '',
|
|
47
|
+
properties,
|
|
48
|
+
styles: {},
|
|
49
|
+
blocks: []
|
|
50
|
+
},
|
|
51
|
+
placeholderId: 'ph',
|
|
52
|
+
sectionId: 'sec',
|
|
53
|
+
isDesigner: false,
|
|
54
|
+
...over
|
|
55
|
+
} as never);
|
|
56
|
+
|
|
57
|
+
const renderPagination = (
|
|
58
|
+
properties: Props = {},
|
|
59
|
+
over: Props = {},
|
|
60
|
+
context = listingContext()
|
|
61
|
+
) =>
|
|
62
|
+
render(
|
|
63
|
+
<ThemePageContextProvider value={context}>
|
|
64
|
+
<div>
|
|
65
|
+
<div data-product-grid="">
|
|
66
|
+
<a href="/p/1" data-product-card="">
|
|
67
|
+
One
|
|
68
|
+
</a>
|
|
69
|
+
<a href="/p/2" data-product-card="">
|
|
70
|
+
Two
|
|
71
|
+
</a>
|
|
72
|
+
</div>
|
|
73
|
+
<ListingPaginationBlock {...blockProps(properties, over)} />
|
|
74
|
+
</div>
|
|
75
|
+
</ThemePageContextProvider>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** The next page exactly as the server renders it — style element included. */
|
|
79
|
+
const pageHtml = (ids: number[]) =>
|
|
80
|
+
`<!doctype html><html><body><main><div data-product-grid="" data-product-grid-id="g">` +
|
|
81
|
+
`<style>[data-product-grid-id="g"]{grid-template-columns:repeat(9,minmax(0,1fr));}</style>` +
|
|
82
|
+
ids
|
|
83
|
+
.map((id) => `<a href="/p/${id}" data-product-card="">Item ${id}</a>`)
|
|
84
|
+
.join('') +
|
|
85
|
+
`</div></main></body></html>`;
|
|
86
|
+
|
|
87
|
+
const okResponse = (html: string) =>
|
|
88
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(html) });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clicks a real anchor and reports whether the block intercepted it.
|
|
92
|
+
*
|
|
93
|
+
* The document listener runs after React's (React delegates at the root
|
|
94
|
+
* container, which is inside the body), so it reads the verdict and only then
|
|
95
|
+
* stops jsdom from acting on an href it cannot navigate to — the pending
|
|
96
|
+
* navigation timer it would otherwise leave behind outlives the test.
|
|
97
|
+
*/
|
|
98
|
+
const clickLink = (element: HTMLElement): boolean => {
|
|
99
|
+
let intercepted = false;
|
|
100
|
+
|
|
101
|
+
const inspect = (event: Event) => {
|
|
102
|
+
intercepted = event.defaultPrevented;
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
document.addEventListener('click', inspect);
|
|
107
|
+
fireEvent.click(element);
|
|
108
|
+
document.removeEventListener('click', inspect);
|
|
109
|
+
|
|
110
|
+
return intercepted;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
class MockObserver {
|
|
114
|
+
static instances: MockObserver[] = [];
|
|
115
|
+
callback: IntersectionObserverCallback;
|
|
116
|
+
options: IntersectionObserverInit | undefined;
|
|
117
|
+
elements: Element[] = [];
|
|
118
|
+
disconnected = false;
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
callback: IntersectionObserverCallback,
|
|
122
|
+
options?: IntersectionObserverInit
|
|
123
|
+
) {
|
|
124
|
+
this.callback = callback;
|
|
125
|
+
this.options = options;
|
|
126
|
+
MockObserver.instances.push(this);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
observe(element: Element) {
|
|
130
|
+
this.elements.push(element);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
unobserve() {}
|
|
134
|
+
|
|
135
|
+
disconnect() {
|
|
136
|
+
this.disconnected = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
takeRecords() {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fire() {
|
|
144
|
+
this.callback(
|
|
145
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
146
|
+
this as unknown as IntersectionObserver
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static get live() {
|
|
151
|
+
return MockObserver.instances.filter((instance) => !instance.disconnected);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const originalObserver = window.IntersectionObserver;
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
push.mockClear();
|
|
159
|
+
MockObserver.instances = [];
|
|
160
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
161
|
+
MockObserver;
|
|
162
|
+
(global as unknown as Record<string, unknown>).fetch = jest.fn(() =>
|
|
163
|
+
okResponse(pageHtml([3, 4]))
|
|
164
|
+
);
|
|
165
|
+
window.history.replaceState(null, '', '/shoes');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
170
|
+
originalObserver;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Byte-identity goldens.
|
|
175
|
+
*
|
|
176
|
+
* Append, infinite scroll and the count readout all arrive switched off, so a
|
|
177
|
+
* composition that never asks for them has to produce the exact same bytes it
|
|
178
|
+
* produced before they existed. These strings are that promise written down:
|
|
179
|
+
* any change to the default markup — an attribute, an element, an inline
|
|
180
|
+
* style, even the order of two declarations — fails here rather than on a
|
|
181
|
+
* brand's storefront.
|
|
182
|
+
*
|
|
183
|
+
* They are the SERVER render, not jsdom's `innerHTML`: jsdom re-serialises CSS
|
|
184
|
+
* (lowercasing `currentColor`, dropping declarations it cannot parse), which
|
|
185
|
+
* would hide exactly the kind of drift this is meant to catch. These are the
|
|
186
|
+
* bytes a crawler and a cold visit receive.
|
|
187
|
+
*/
|
|
188
|
+
const ssr = (element: React.ReactElement): string => {
|
|
189
|
+
// react-dom's browser server build reaches for MessageChannel and
|
|
190
|
+
// TextEncoder at import time; jsdom's global has neither, so Node's own are
|
|
191
|
+
// handed over. The require has to be lazy for them to land first.
|
|
192
|
+
const scope = globalThis as unknown as Record<string, unknown>;
|
|
193
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
194
|
+
if (typeof scope.MessageChannel === 'undefined') {
|
|
195
|
+
// Deliberately inert rather than Node's own: a real MessagePort refs the
|
|
196
|
+
// event loop and the whole run hangs at teardown. renderToStaticMarkup is
|
|
197
|
+
// synchronous and never posts through it.
|
|
198
|
+
scope.MessageChannel = class {
|
|
199
|
+
port1 = { onmessage: null, postMessage() {}, close() {} };
|
|
200
|
+
port2 = { onmessage: null, postMessage() {}, close() {} };
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (typeof scope.TextEncoder === 'undefined') {
|
|
204
|
+
scope.TextEncoder = require('util').TextEncoder;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { renderToStaticMarkup } = require('react-dom/server');
|
|
208
|
+
/* eslint-enable @typescript-eslint/no-var-requires */
|
|
209
|
+
return renderToStaticMarkup(element) as string;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const GOLDEN_PAGES =
|
|
213
|
+
'<nav data-listing-pagination="" data-pagination-mode="pages" aria-label="Pagination" data-part="list" style="display:flex;align-items:center;gap:var(--listing-pagination-gap, 8px);flex-wrap:wrap"><a href="/shoes" data-pagination-page="1" aria-current="page" data-part="page" data-state="selected" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:1;font-weight:600">1</a><a href="/shoes?page=2" data-pagination-page="2" data-part="page" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">2</a><a href="/shoes?page=3" data-pagination-page="3" data-part="page" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">3</a><a href="/shoes?page=2" rel="next" data-pagination-next="" data-part="next" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">Next</a></nav>';
|
|
214
|
+
|
|
215
|
+
const GOLDEN_LOADMORE =
|
|
216
|
+
'<nav data-listing-pagination="" data-pagination-mode="loadmore" data-part="list"><a href="/shoes?page=2" rel="next" data-listing-load-more="" data-part="load-more" style="display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:4px 8px;color:inherit;text-decoration:none;border:1px solid currentColor;border-radius:inherit;opacity:0.7;font-weight:400">Load more</a></nav>';
|
|
217
|
+
|
|
218
|
+
const ssrPagination = (properties: Props = {}) =>
|
|
219
|
+
ssr(
|
|
220
|
+
<ThemePageContextProvider value={listingContext()}>
|
|
221
|
+
<ListingPaginationBlock {...blockProps(properties)} />
|
|
222
|
+
</ThemePageContextProvider>
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
describe('pagination — the default render is untouched', () => {
|
|
226
|
+
it('renders page links exactly as it always has', () => {
|
|
227
|
+
expect(ssrPagination()).toBe(GOLDEN_PAGES);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('renders load more as a plain link to the next page', () => {
|
|
231
|
+
expect(ssrPagination({ mode: 'loadmore' })).toBe(GOLDEN_LOADMORE);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('renders load more identically whether or not append exists', () => {
|
|
235
|
+
// The enhancement is invisible to the server: same bytes, so the crawl
|
|
236
|
+
// path and the no-javascript path are exactly what they were.
|
|
237
|
+
expect(ssrPagination({ mode: 'loadmore', append: false })).toBe(
|
|
238
|
+
GOLDEN_LOADMORE
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('lets that link navigate — no fetch, no intercepted click', () => {
|
|
243
|
+
renderPagination({ mode: 'loadmore' });
|
|
244
|
+
|
|
245
|
+
expect(clickLink(screen.getByText('Load more'))).toBe(false);
|
|
246
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('arms no observer and touches no history until append is asked for', () => {
|
|
250
|
+
const replaceSpy = jest.spyOn(window.history, 'replaceState');
|
|
251
|
+
renderPagination({ mode: 'loadmore' });
|
|
252
|
+
|
|
253
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
254
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
255
|
+
replaceSpy.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('pagination — append', () => {
|
|
260
|
+
const appendProps = {
|
|
261
|
+
mode: 'loadmore',
|
|
262
|
+
append: true
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
it("grafts the next page's own server markup into the live list", async () => {
|
|
266
|
+
const { container } = renderPagination(appendProps);
|
|
267
|
+
|
|
268
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
269
|
+
|
|
270
|
+
await waitFor(() =>
|
|
271
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
275
|
+
'/shoes?page=2',
|
|
276
|
+
expect.objectContaining({ credentials: 'same-origin' })
|
|
277
|
+
);
|
|
278
|
+
// The rows the server rendered, not rows re-derived here: identical
|
|
279
|
+
// markup, so a designer-authored card layout survives the append.
|
|
280
|
+
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
|
281
|
+
expect(screen.getByText('Item 4')).toBeInTheDocument();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('never copies the list stylesheet, which would outrank the live one', async () => {
|
|
285
|
+
const { container } = renderPagination(appendProps);
|
|
286
|
+
|
|
287
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
288
|
+
|
|
289
|
+
await waitFor(() =>
|
|
290
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
291
|
+
);
|
|
292
|
+
expect(container.querySelectorAll('style')).toHaveLength(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('keeps the cursor honest with replaceState, never pushState', async () => {
|
|
296
|
+
const replaceSpy = jest.spyOn(window.history, 'replaceState');
|
|
297
|
+
const pushSpy = jest.spyOn(window.history, 'pushState');
|
|
298
|
+
|
|
299
|
+
renderPagination(appendProps);
|
|
300
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
301
|
+
|
|
302
|
+
await waitFor(() =>
|
|
303
|
+
expect(replaceSpy).toHaveBeenCalledWith(null, '', '/shoes?page=2')
|
|
304
|
+
);
|
|
305
|
+
// One entry per append would turn the back button into a walk through
|
|
306
|
+
// every batch instead of a way off the listing.
|
|
307
|
+
expect(pushSpy).not.toHaveBeenCalled();
|
|
308
|
+
|
|
309
|
+
replaceSpy.mockRestore();
|
|
310
|
+
pushSpy.mockRestore();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('records which page a row came from and restores it on click', async () => {
|
|
314
|
+
const { container } = renderPagination(appendProps);
|
|
315
|
+
|
|
316
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
317
|
+
await waitFor(() =>
|
|
318
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const replaceSpy = jest.spyOn(window.history, 'replaceState');
|
|
322
|
+
|
|
323
|
+
clickLink(screen.getByText('Item 3'));
|
|
324
|
+
expect(replaceSpy).toHaveBeenCalledWith(null, '', '/shoes?page=2');
|
|
325
|
+
|
|
326
|
+
// A row from the server-rendered first page takes the shopper back there,
|
|
327
|
+
// not to the deepest page that happens to be loaded.
|
|
328
|
+
clickLink(screen.getByText('One'));
|
|
329
|
+
expect(replaceSpy).toHaveBeenLastCalledWith(null, '', '/shoes');
|
|
330
|
+
|
|
331
|
+
replaceSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('walks to the last page and then stops asking', async () => {
|
|
335
|
+
const { container } = renderPagination(appendProps);
|
|
336
|
+
|
|
337
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
338
|
+
await waitFor(() =>
|
|
339
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
(global.fetch as jest.Mock).mockImplementationOnce(() =>
|
|
343
|
+
okResponse(pageHtml([5, 6]))
|
|
344
|
+
);
|
|
345
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
346
|
+
await waitFor(() =>
|
|
347
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(6)
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// Page 3 of 3: the affordance is gone, so there is nothing left to press.
|
|
351
|
+
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('offers the shopper a way out of a failed page', async () => {
|
|
355
|
+
(global.fetch as jest.Mock).mockImplementationOnce(() =>
|
|
356
|
+
Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('') })
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const { container } = renderPagination({
|
|
360
|
+
...appendProps,
|
|
361
|
+
errorLabel: 'Nope',
|
|
362
|
+
retryLabel: 'Retry'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
366
|
+
await screen.findByText('Nope');
|
|
367
|
+
|
|
368
|
+
fireEvent.click(screen.getByText('Retry'));
|
|
369
|
+
await waitFor(() =>
|
|
370
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
371
|
+
);
|
|
372
|
+
expect(screen.queryByText('Nope')).not.toBeInTheDocument();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('is completely inert in the designer', async () => {
|
|
376
|
+
renderPagination(
|
|
377
|
+
{ ...appendProps, appendTrigger: 'scroll' },
|
|
378
|
+
{ isDesigner: true }
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// No sentinel is watched, and the link is inert rather than appending.
|
|
382
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
383
|
+
|
|
384
|
+
expect(clickLink(screen.getByText('Load more'))).toBe(true);
|
|
385
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('pagination — the infinite scroll sentinel', () => {
|
|
390
|
+
const scrollProps = {
|
|
391
|
+
mode: 'loadmore',
|
|
392
|
+
append: true,
|
|
393
|
+
appendTrigger: 'scroll',
|
|
394
|
+
prefetchOffset: 400
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
it('watches a sentinel with the designer-chosen lead', () => {
|
|
398
|
+
renderPagination(scrollProps);
|
|
399
|
+
|
|
400
|
+
expect(MockObserver.live).toHaveLength(1);
|
|
401
|
+
expect(MockObserver.live[0].options).toEqual({ rootMargin: '400px 0px' });
|
|
402
|
+
expect(MockObserver.live[0].elements[0]).toHaveAttribute(
|
|
403
|
+
'data-listing-load-sentinel'
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('loads one page per crossing and never two at once', async () => {
|
|
408
|
+
const { container } = renderPagination(scrollProps);
|
|
409
|
+
|
|
410
|
+
await act(async () => {
|
|
411
|
+
MockObserver.live[0].fire();
|
|
412
|
+
// A second crossing while the first request is still open must not
|
|
413
|
+
// queue a duplicate page.
|
|
414
|
+
MockObserver.instances[0].fire();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await waitFor(() =>
|
|
418
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
419
|
+
);
|
|
420
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('stands down at the end of the list rather than firing forever', async () => {
|
|
424
|
+
const { container } = renderPagination(scrollProps);
|
|
425
|
+
|
|
426
|
+
await act(async () => {
|
|
427
|
+
MockObserver.live[0].fire();
|
|
428
|
+
});
|
|
429
|
+
await waitFor(() =>
|
|
430
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
(global.fetch as jest.Mock).mockImplementationOnce(() =>
|
|
434
|
+
okResponse(pageHtml([5, 6]))
|
|
435
|
+
);
|
|
436
|
+
await act(async () => {
|
|
437
|
+
MockObserver.live[0].fire();
|
|
438
|
+
});
|
|
439
|
+
await waitFor(() =>
|
|
440
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(6)
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
expect(MockObserver.live).toHaveLength(0);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('stands down after a failure until the shopper retries', async () => {
|
|
447
|
+
(global.fetch as jest.Mock).mockImplementationOnce(() =>
|
|
448
|
+
Promise.reject(new Error('offline'))
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
renderPagination({ ...scrollProps, retryLabel: 'Retry' });
|
|
452
|
+
|
|
453
|
+
await act(async () => {
|
|
454
|
+
MockObserver.live[0].fire();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await screen.findByText('Retry');
|
|
458
|
+
expect(MockObserver.live).toHaveLength(0);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe('pagination — the count readout', () => {
|
|
463
|
+
it('publishes the numbers as well as the sentence', () => {
|
|
464
|
+
renderPagination({ showStatus: true });
|
|
465
|
+
|
|
466
|
+
const status = document.querySelector('[data-part="status"]') as HTMLElement;
|
|
467
|
+
expect(status).toHaveTextContent('Showing 2 of 6');
|
|
468
|
+
expect(status).toHaveAttribute('data-listing-shown', '2');
|
|
469
|
+
expect(status).toHaveAttribute('data-listing-total', '6');
|
|
470
|
+
expect(status).toHaveAttribute('aria-live', 'polite');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('counts what is on screen after an append', async () => {
|
|
474
|
+
const { container } = renderPagination({
|
|
475
|
+
mode: 'loadmore',
|
|
476
|
+
append: true,
|
|
477
|
+
showStatus: true
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
481
|
+
await waitFor(() =>
|
|
482
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
|
|
486
|
+
'Showing 4 of 6'
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('states a range from the page the shopper actually landed on', () => {
|
|
491
|
+
renderPagination(
|
|
492
|
+
{ showStatus: true, statusTemplate: '{from}–{to} of {total}' },
|
|
493
|
+
{},
|
|
494
|
+
listingContext({
|
|
495
|
+
pagination: { current_page: 2, num_pages: 3, page_size: 2, count: 6 }
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
|
|
500
|
+
'3–4 of 6'
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/* -------------------------------------------------------------------------- */
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Append against the REAL grid rather than a hand-written list.
|
|
509
|
+
*
|
|
510
|
+
* Both switches the grid and the pagination own are first-class options, and
|
|
511
|
+
* the failures below only exist where they meet: a grid that pins a
|
|
512
|
+
* merchandising tile by index, and a pagination block that grafts another
|
|
513
|
+
* page's cells into it. A fixture list cannot reproduce either, because the
|
|
514
|
+
* fixture has no tile and writes no rules.
|
|
515
|
+
*/
|
|
516
|
+
|
|
517
|
+
/** A campaign tile the merchandiser asked to repeat on every page. */
|
|
518
|
+
const everyPageTile = {
|
|
519
|
+
id: 'tile-1',
|
|
520
|
+
type: 'group',
|
|
521
|
+
label: 'Campaign',
|
|
522
|
+
order: 0,
|
|
523
|
+
hidden: false,
|
|
524
|
+
value: '',
|
|
525
|
+
properties: {
|
|
526
|
+
role: { desktop: 'grid-insertion' },
|
|
527
|
+
insertPosition: { desktop: 2 },
|
|
528
|
+
insertPages: { desktop: 'all' }
|
|
529
|
+
},
|
|
530
|
+
styles: {},
|
|
531
|
+
blocks: []
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const gridProps = (blocks: unknown[] = [everyPageTile]) =>
|
|
535
|
+
({
|
|
536
|
+
block: {
|
|
537
|
+
id: 'grid-1',
|
|
538
|
+
type: 'product-grid',
|
|
539
|
+
label: 'Grid',
|
|
540
|
+
order: 0,
|
|
541
|
+
hidden: false,
|
|
542
|
+
value: '',
|
|
543
|
+
properties: {},
|
|
544
|
+
styles: {},
|
|
545
|
+
blocks
|
|
546
|
+
},
|
|
547
|
+
placeholderId: 'ph',
|
|
548
|
+
sectionId: 'sec',
|
|
549
|
+
isDesigner: false
|
|
550
|
+
} as never);
|
|
551
|
+
|
|
552
|
+
const gridListing = (page: number, ids: number[]) => ({
|
|
553
|
+
listing: {
|
|
554
|
+
title: 'Shoes',
|
|
555
|
+
path: '/shoes',
|
|
556
|
+
products: ids.map((id) => ({
|
|
557
|
+
name: `Item ${id}`,
|
|
558
|
+
url: `/p/${id}`,
|
|
559
|
+
price: String(id * 10),
|
|
560
|
+
pk: id
|
|
561
|
+
})),
|
|
562
|
+
facets: [],
|
|
563
|
+
sorters: [],
|
|
564
|
+
pagination: { current_page: page, num_pages: 3, page_size: 2, count: 6 }
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
/** Page N exactly as the server renders it — the real grid, tile and all. */
|
|
569
|
+
const gridPageHtml = (page: number, ids: number[]) =>
|
|
570
|
+
'<!doctype html><html><body><main>' +
|
|
571
|
+
ssr(
|
|
572
|
+
<ThemePageContextProvider value={gridListing(page, ids)}>
|
|
573
|
+
<ProductGridBlock {...gridProps()} />
|
|
574
|
+
</ThemePageContextProvider>
|
|
575
|
+
) +
|
|
576
|
+
'</main></body></html>';
|
|
577
|
+
|
|
578
|
+
const renderGrid = (properties: Props) => {
|
|
579
|
+
const context = gridListing(1, [1, 2]);
|
|
580
|
+
|
|
581
|
+
return render(
|
|
582
|
+
<ThemePageContextProvider value={context}>
|
|
583
|
+
<div>
|
|
584
|
+
<ProductGridBlock {...gridProps()} />
|
|
585
|
+
<ListingPaginationBlock {...blockProps(properties)} />
|
|
586
|
+
</div>
|
|
587
|
+
</ThemePageContextProvider>
|
|
588
|
+
);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/** The grid's own stylesheet, which is the only one on the page. */
|
|
592
|
+
const gridSheet = (container: HTMLElement): string =>
|
|
593
|
+
container.querySelector('[data-product-grid] > style')?.textContent ?? '';
|
|
594
|
+
|
|
595
|
+
/** Every cell-pinning rule the grid wrote, reduced to its bare selector. */
|
|
596
|
+
const pinSelectors = (css: string): string[] =>
|
|
597
|
+
Array.from(css.matchAll(/([^{}@]+)\{grid-column:[^{}]*\}/g)).map((match) =>
|
|
598
|
+
match[1].trim()
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
describe('pagination — appending into the real product grid', () => {
|
|
602
|
+
const appendProps = { mode: 'loadmore', append: true };
|
|
603
|
+
|
|
604
|
+
const appendPage = async (
|
|
605
|
+
container: HTMLElement,
|
|
606
|
+
page: number,
|
|
607
|
+
ids: number[],
|
|
608
|
+
expectedCards: number
|
|
609
|
+
) => {
|
|
610
|
+
(global.fetch as jest.Mock).mockImplementationOnce(() =>
|
|
611
|
+
okResponse(gridPageHtml(page, ids))
|
|
612
|
+
);
|
|
613
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
614
|
+
await waitFor(() =>
|
|
615
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(
|
|
616
|
+
expectedCards
|
|
617
|
+
)
|
|
618
|
+
);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
it('never lets one pinning rule place two cells', async () => {
|
|
622
|
+
const { container } = renderGrid(appendProps);
|
|
623
|
+
await appendPage(container, 2, [3, 4], 4);
|
|
624
|
+
|
|
625
|
+
// Page two's tile is a cell of the same grid now. A rule page one wrote
|
|
626
|
+
// for "the tile at index 0" that also reaches page two's puts both in one
|
|
627
|
+
// cell, stacked on each other and over whatever was there.
|
|
628
|
+
const selectors = pinSelectors(gridSheet(container));
|
|
629
|
+
expect(selectors.length).toBeGreaterThan(0);
|
|
630
|
+
selectors.forEach((selector) => {
|
|
631
|
+
expect(container.querySelectorAll(selector)).toHaveLength(1);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('leaves every cell the shopper already saw exactly where it was', async () => {
|
|
636
|
+
const { container } = renderGrid(appendProps);
|
|
637
|
+
const before = Array.from(
|
|
638
|
+
container.querySelector('[data-product-grid]')?.children ?? []
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
await appendPage(container, 2, [3, 4], 4);
|
|
642
|
+
|
|
643
|
+
const after = Array.from(
|
|
644
|
+
container.querySelector('[data-product-grid]')?.children ?? []
|
|
645
|
+
);
|
|
646
|
+
// Same nodes, same order, nothing inserted among them: an appended page
|
|
647
|
+
// is additive or it is a regression.
|
|
648
|
+
expect(after.slice(0, before.length)).toEqual(before);
|
|
649
|
+
expect(after.length).toBeGreaterThan(before.length);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('counts the products it appended, not the cells', async () => {
|
|
653
|
+
const { container } = renderGrid({ ...appendProps, showStatus: true });
|
|
654
|
+
await appendPage(container, 2, [3, 4], 4);
|
|
655
|
+
|
|
656
|
+
// Four cards on screen; the tile that came with them is merchandising,
|
|
657
|
+
// not a product the listing is counting down.
|
|
658
|
+
expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
|
|
659
|
+
'Showing 4 of 6'
|
|
660
|
+
);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('never states a range that runs past the end of the listing', async () => {
|
|
664
|
+
const { container } = renderGrid({
|
|
665
|
+
...appendProps,
|
|
666
|
+
showStatus: true,
|
|
667
|
+
statusTemplate: '{from}-{to} of {total}'
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
await appendPage(container, 2, [3, 4], 4);
|
|
671
|
+
await appendPage(container, 3, [5, 6], 6);
|
|
672
|
+
|
|
673
|
+
expect(document.querySelector('[data-part="status"]')).toHaveTextContent(
|
|
674
|
+
'1-6 of 6'
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
describe('pagination — the fetched page is untrusted', () => {
|
|
680
|
+
const appendProps = { mode: 'loadmore', append: true };
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* A card with a nested block inside it: a lottie, an announcement bar and a
|
|
684
|
+
* recommendation strip all emit their own <style>, and any of them can sit
|
|
685
|
+
* inside a card. The script is what a compromised or mistaken response would
|
|
686
|
+
* carry — cloned out of a DOMParser document it has never started, so it
|
|
687
|
+
* runs the moment it is inserted.
|
|
688
|
+
*/
|
|
689
|
+
const hostileHtml = () =>
|
|
690
|
+
'<!doctype html><html><body><main>' +
|
|
691
|
+
'<div data-product-grid="" data-product-grid-id="g">' +
|
|
692
|
+
'<style>[data-product-grid-id="g"]{grid-template-columns:repeat(9,minmax(0,1fr));}</style>' +
|
|
693
|
+
'<a href="/p/3" data-product-card="">Item 3' +
|
|
694
|
+
'<span data-lottie="">' +
|
|
695
|
+
'<style>[data-lottie=""]{display:none;}</style>' +
|
|
696
|
+
'<script>window.__graftEscaped = true;</script>' +
|
|
697
|
+
'<img src="/x.jpg" onerror="window.__graftEscaped = true">' +
|
|
698
|
+
'</span></a>' +
|
|
699
|
+
'<a href="javascript:window.__graftEscaped = true" data-product-card="">' +
|
|
700
|
+
'Item 4</a>' +
|
|
701
|
+
'</div></main></body></html>';
|
|
702
|
+
|
|
703
|
+
beforeEach(() => {
|
|
704
|
+
delete (window as unknown as Record<string, unknown>).__graftEscaped;
|
|
705
|
+
(global.fetch as jest.Mock).mockImplementation(() =>
|
|
706
|
+
okResponse(hostileHtml())
|
|
707
|
+
);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const append = async (container: HTMLElement) => {
|
|
711
|
+
fireEvent.click(screen.getByText('Load more'));
|
|
712
|
+
await waitFor(() =>
|
|
713
|
+
expect(container.querySelectorAll('[data-product-card]')).toHaveLength(4)
|
|
714
|
+
);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
it('lets no script cross into the live document', async () => {
|
|
718
|
+
const { container } = renderPagination(appendProps);
|
|
719
|
+
await append(container);
|
|
720
|
+
|
|
721
|
+
// The element's presence IS the defect: a script cloned out of a
|
|
722
|
+
// DOMParser document has never started, so a browser runs it on insertion.
|
|
723
|
+
// jsdom does not, which is why this asserts the DOM and not a side effect.
|
|
724
|
+
expect(container.querySelectorAll('script')).toHaveLength(0);
|
|
725
|
+
expect(
|
|
726
|
+
(window as unknown as Record<string, unknown>).__graftEscaped
|
|
727
|
+
).toBeUndefined();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('copies no stylesheet at any depth, not just the top one', async () => {
|
|
731
|
+
const { container } = renderPagination(appendProps);
|
|
732
|
+
await append(container);
|
|
733
|
+
|
|
734
|
+
// A nested <style> outranks the live one exactly as a top-level one does;
|
|
735
|
+
// depth was never what made it safe.
|
|
736
|
+
expect(container.querySelectorAll('style')).toHaveLength(0);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('strips the handlers and the script urls off what it does copy', async () => {
|
|
740
|
+
const { container } = renderPagination(appendProps);
|
|
741
|
+
await append(container);
|
|
742
|
+
|
|
743
|
+
const image = container.querySelector('img[src="/x.jpg"]');
|
|
744
|
+
expect(image).not.toBeNull();
|
|
745
|
+
expect(image?.hasAttribute('onerror')).toBe(false);
|
|
746
|
+
expect(
|
|
747
|
+
container.querySelector('a[href^="javascript:"]')
|
|
748
|
+
).toBeNull();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('keeps text a shopper reads, even when it reads like a scheme', async () => {
|
|
752
|
+
// "JavaScript: The Good Parts" is a product a bookshop really sells, and
|
|
753
|
+
// it normalises to a script scheme. An attribute the browser never
|
|
754
|
+
// resolves cannot execute one, so scrubbing it costs the card its only
|
|
755
|
+
// description — and costs it on the appended page only, so the same
|
|
756
|
+
// product reads differently above and below the fold.
|
|
757
|
+
(global.fetch as jest.Mock).mockImplementation(() =>
|
|
758
|
+
okResponse(
|
|
759
|
+
'<!doctype html><html><body><main>' +
|
|
760
|
+
'<div data-product-grid="" data-product-grid-id="g">' +
|
|
761
|
+
'<a href="/p/3" data-product-card="" ' +
|
|
762
|
+
'title="JavaScript: The Good Parts">' +
|
|
763
|
+
'<img src="/b.jpg" alt="JavaScript: The Good Parts">Item 3</a>' +
|
|
764
|
+
'<a href="/p/4" data-product-card="">Item 4</a>' +
|
|
765
|
+
'</div></main></body></html>'
|
|
766
|
+
)
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const { container } = renderPagination(appendProps);
|
|
770
|
+
await append(container);
|
|
771
|
+
|
|
772
|
+
expect(
|
|
773
|
+
container.querySelector('img[src="/b.jpg"]')?.getAttribute('alt')
|
|
774
|
+
).toBe('JavaScript: The Good Parts');
|
|
775
|
+
expect(
|
|
776
|
+
container.querySelector('a[href="/p/3"]')?.getAttribute('title')
|
|
777
|
+
).toBe('JavaScript: The Good Parts');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('still grafts the products themselves', async () => {
|
|
781
|
+
const { container } = renderPagination(appendProps);
|
|
782
|
+
await append(container);
|
|
783
|
+
|
|
784
|
+
expect(screen.getByText(/Item 3/)).toBeInTheDocument();
|
|
785
|
+
expect(screen.getByText(/Item 4/)).toBeInTheDocument();
|
|
786
|
+
// The card's own content survives the scrub — only the executable parts
|
|
787
|
+
// are dropped, not the markup around them.
|
|
788
|
+
expect(container.querySelector('[data-lottie]')).not.toBeNull();
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
/* -------------------------------------------------------------------------- */
|
|
793
|
+
|
|
794
|
+
const sortBlockProps = (properties: Props = {}, over: Props = {}) =>
|
|
795
|
+
({
|
|
796
|
+
block: {
|
|
797
|
+
id: 'sort-1',
|
|
798
|
+
type: 'sort-select',
|
|
799
|
+
label: 'Sort',
|
|
800
|
+
order: 0,
|
|
801
|
+
hidden: false,
|
|
802
|
+
value: '',
|
|
803
|
+
properties,
|
|
804
|
+
styles: {},
|
|
805
|
+
blocks: []
|
|
806
|
+
},
|
|
807
|
+
placeholderId: 'ph',
|
|
808
|
+
sectionId: 'sec',
|
|
809
|
+
isDesigner: false,
|
|
810
|
+
...over
|
|
811
|
+
} as never);
|
|
812
|
+
|
|
813
|
+
const sorters = [
|
|
814
|
+
{ label: 'Newest', value: 'newest', is_selected: false },
|
|
815
|
+
{ label: 'Price low to high', value: 'price_asc', is_selected: true },
|
|
816
|
+
{ label: 'Price high to low', value: 'price_desc', is_selected: false }
|
|
817
|
+
];
|
|
818
|
+
|
|
819
|
+
const renderSort = (properties: Props = {}, over: Props = {}) =>
|
|
820
|
+
render(
|
|
821
|
+
<ThemePageContextProvider value={listingContext({ sorters })}>
|
|
822
|
+
<SortSelectBlock {...sortBlockProps(properties, over)} />
|
|
823
|
+
</ThemePageContextProvider>
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const GOLDEN_SORT_NATIVE =
|
|
827
|
+
'<label data-sort-select="" data-part="group" style="display:inline-flex;align-items:center;gap:8px;color:inherit"><span data-sort-select-label="" data-part="label">Sort by</span><select data-sort-select-input="" data-part="dropdown" aria-label="Sort by" style="border:1px solid currentColor;border-radius:inherit;background:transparent;color:inherit;font:inherit;padding:6px 8px;cursor:pointer"><option value="newest" data-part="option">Newest</option><option value="price_asc" data-part="option" data-state="selected" selected="">Price low to high</option><option value="price_desc" data-part="option">Price high to low</option></select></label>';
|
|
828
|
+
|
|
829
|
+
describe('sort — the native select is untouched', () => {
|
|
830
|
+
it('renders exactly as it always has', () => {
|
|
831
|
+
expect(
|
|
832
|
+
ssr(
|
|
833
|
+
<ThemePageContextProvider value={listingContext({ sorters })}>
|
|
834
|
+
<SortSelectBlock {...sortBlockProps()} />
|
|
835
|
+
</ThemePageContextProvider>
|
|
836
|
+
)
|
|
837
|
+
).toBe(GOLDEN_SORT_NATIVE);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('drops the page cursor when the sort changes', () => {
|
|
841
|
+
const { container } = renderSort();
|
|
842
|
+
|
|
843
|
+
fireEvent.change(container.querySelector('select') as HTMLSelectElement, {
|
|
844
|
+
target: { value: 'price_desc' }
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
expect(push).toHaveBeenCalledWith('/shoes?sorter=price_desc');
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe('sort — the option panel', () => {
|
|
852
|
+
const panelProps = { variant: 'panel' };
|
|
853
|
+
|
|
854
|
+
it('names the applied sort on the trigger', () => {
|
|
855
|
+
renderSort(panelProps);
|
|
856
|
+
|
|
857
|
+
const trigger = screen.getByRole('button');
|
|
858
|
+
expect(trigger).toHaveTextContent('Price low to high');
|
|
859
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
860
|
+
expect(trigger).toHaveAttribute('aria-haspopup', 'listbox');
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('composes the trigger text a designer asked for', () => {
|
|
864
|
+
renderSort({ ...panelProps, triggerTemplate: 'Sort: {value}' });
|
|
865
|
+
expect(screen.getByRole('button')).toHaveTextContent(
|
|
866
|
+
'Sort: Price low to high'
|
|
867
|
+
);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('opens onto the applied option and marks it', () => {
|
|
871
|
+
renderSort(panelProps);
|
|
872
|
+
fireEvent.click(screen.getByRole('button'));
|
|
873
|
+
|
|
874
|
+
const options = screen.getAllByRole('option');
|
|
875
|
+
expect(options).toHaveLength(3);
|
|
876
|
+
expect(options[1]).toHaveAttribute('aria-selected', 'true');
|
|
877
|
+
expect(options[0]).toHaveAttribute('aria-selected', 'false');
|
|
878
|
+
// Roving tab focus: exactly one row is reachable with Tab, and it is the
|
|
879
|
+
// one the arrow keys are on.
|
|
880
|
+
expect(options[1]).toHaveAttribute('tabindex', '0');
|
|
881
|
+
expect(options[0]).toHaveAttribute('tabindex', '-1');
|
|
882
|
+
expect(options[1]).toHaveFocus();
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it('moves through the options with the arrow keys, Home and End', () => {
|
|
886
|
+
renderSort(panelProps);
|
|
887
|
+
fireEvent.click(screen.getByRole('button'));
|
|
888
|
+
|
|
889
|
+
const panel = screen.getByRole('listbox');
|
|
890
|
+
fireEvent.keyDown(panel, { key: 'ArrowDown' });
|
|
891
|
+
expect(screen.getAllByRole('option')[2]).toHaveFocus();
|
|
892
|
+
|
|
893
|
+
// Clamped, not wrapped — the same as a native select.
|
|
894
|
+
fireEvent.keyDown(panel, { key: 'ArrowDown' });
|
|
895
|
+
expect(screen.getAllByRole('option')[2]).toHaveFocus();
|
|
896
|
+
|
|
897
|
+
fireEvent.keyDown(panel, { key: 'Home' });
|
|
898
|
+
expect(screen.getAllByRole('option')[0]).toHaveFocus();
|
|
899
|
+
|
|
900
|
+
fireEvent.keyDown(panel, { key: 'End' });
|
|
901
|
+
expect(screen.getAllByRole('option')[2]).toHaveFocus();
|
|
902
|
+
|
|
903
|
+
fireEvent.keyDown(panel, { key: 'ArrowUp' });
|
|
904
|
+
expect(screen.getAllByRole('option')[1]).toHaveFocus();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('applies a sort with Enter and invalidates the page cursor', () => {
|
|
908
|
+
renderSort(panelProps);
|
|
909
|
+
fireEvent.click(screen.getByRole('button'));
|
|
910
|
+
|
|
911
|
+
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'ArrowDown' });
|
|
912
|
+
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Enter' });
|
|
913
|
+
|
|
914
|
+
expect(push).toHaveBeenCalledWith('/shoes?sorter=price_desc');
|
|
915
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('applies a sort with a pointer', () => {
|
|
919
|
+
renderSort(panelProps);
|
|
920
|
+
fireEvent.click(screen.getByRole('button'));
|
|
921
|
+
fireEvent.click(screen.getByText('Newest'));
|
|
922
|
+
|
|
923
|
+
expect(push).toHaveBeenCalledWith('/shoes?sorter=newest');
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('closes on Escape and hands focus back to the trigger', () => {
|
|
927
|
+
renderSort(panelProps);
|
|
928
|
+
const trigger = screen.getByRole('button');
|
|
929
|
+
|
|
930
|
+
fireEvent.click(trigger);
|
|
931
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
932
|
+
|
|
933
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
934
|
+
expect(trigger).toHaveFocus();
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('closes on an outside pointer', () => {
|
|
938
|
+
renderSort(panelProps);
|
|
939
|
+
fireEvent.click(screen.getByRole('button'));
|
|
940
|
+
|
|
941
|
+
fireEvent.mouseDown(document.body);
|
|
942
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('stays open when the pointer lands inside it', () => {
|
|
946
|
+
renderSort(panelProps);
|
|
947
|
+
fireEvent.click(screen.getByRole('button'));
|
|
948
|
+
|
|
949
|
+
fireEvent.mouseDown(screen.getByRole('listbox'));
|
|
950
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('navigates nowhere in the designer', () => {
|
|
954
|
+
renderSort(panelProps, { isDesigner: true });
|
|
955
|
+
|
|
956
|
+
fireEvent.click(screen.getByRole('button'));
|
|
957
|
+
fireEvent.click(screen.getByText('Newest'));
|
|
958
|
+
|
|
959
|
+
expect(push).not.toHaveBeenCalled();
|
|
960
|
+
});
|
|
961
|
+
});
|