@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,702 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// The item-template module pulls in theme-block -> the whole renderer registry
|
|
5
|
+
// (next-auth, RTK, …). These tests are about the strip's own card path, so the
|
|
6
|
+
// custom-layout branch is stubbed off rather than exercised here.
|
|
7
|
+
let itemTemplatePresent = false;
|
|
8
|
+
jest.mock('../item-template', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
hasItemTemplate: () => itemTemplatePresent,
|
|
11
|
+
ItemTemplateList: () => <div data-testid="item-template-list" />
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
/** The collection payload behind the strip; undefined means "no collection". */
|
|
15
|
+
let mockWidget: Record<string, unknown> | undefined;
|
|
16
|
+
/** What `/product/{pk}/` answers the overlay with. */
|
|
17
|
+
let mockDetail: Record<string, unknown> | undefined;
|
|
18
|
+
/** Every payload that reached the basket endpoint, in order. */
|
|
19
|
+
const mockAdds: Record<string, unknown>[] = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every `@akinon/next/data/client/*` subpath is folded onto one mock module by
|
|
23
|
+
* the jest moduleNameMapper, so one factory has to serve the widget query, the
|
|
24
|
+
* overlay's product query and the add mutation. A hoisted function declaration
|
|
25
|
+
* rather than a const: ts-jest lifts `jest.mock` above the imports, and the
|
|
26
|
+
* factory runs while the block is being required.
|
|
27
|
+
*/
|
|
28
|
+
function mockDataClient() {
|
|
29
|
+
return {
|
|
30
|
+
__esModule: true,
|
|
31
|
+
useGetWidgetQuery: (_id: unknown, options?: { skip?: boolean }) => ({
|
|
32
|
+
data: options?.skip ? undefined : mockWidget,
|
|
33
|
+
isFetching: false
|
|
34
|
+
}),
|
|
35
|
+
useGetProductByPkQuery: (_pk: unknown, options?: { skip?: boolean }) => ({
|
|
36
|
+
data: options?.skip ? undefined : mockDetail,
|
|
37
|
+
isFetching: false,
|
|
38
|
+
isError: false,
|
|
39
|
+
refetch: () => undefined
|
|
40
|
+
}),
|
|
41
|
+
useAddProductMutation: () => [
|
|
42
|
+
(payload: Record<string, unknown>) => {
|
|
43
|
+
mockAdds.push(payload);
|
|
44
|
+
return { unwrap: () => Promise.resolve({}) };
|
|
45
|
+
},
|
|
46
|
+
{ isLoading: false }
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
jest.mock('@akinon/next/data/client/misc', mockDataClient);
|
|
52
|
+
jest.mock('@akinon/next/data/client/product', mockDataClient);
|
|
53
|
+
|
|
54
|
+
import ProductRecommendationsBlock from '../product-recommendations-block';
|
|
55
|
+
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
56
|
+
import {
|
|
57
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
58
|
+
readRecentlyViewed,
|
|
59
|
+
recordRecentlyViewed
|
|
60
|
+
} from '../recently-viewed-store';
|
|
61
|
+
|
|
62
|
+
const props = (properties: Record<string, unknown> = {}, over: Record<string, unknown> = {}) =>
|
|
63
|
+
({
|
|
64
|
+
block: { id: 'rec1', type: 'product-recommendations', label: 'rec', properties },
|
|
65
|
+
placeholderId: 'p',
|
|
66
|
+
sectionId: 's',
|
|
67
|
+
isDesigner: false,
|
|
68
|
+
currentBreakpoint: 'desktop',
|
|
69
|
+
...over
|
|
70
|
+
} as any);
|
|
71
|
+
|
|
72
|
+
const PRODUCT = {
|
|
73
|
+
pk: 41,
|
|
74
|
+
title: 'Viewed Shirt',
|
|
75
|
+
url: '/viewed-shirt',
|
|
76
|
+
image: 'https://cdn/viewed.jpg',
|
|
77
|
+
price: '199.90',
|
|
78
|
+
currency: 'TRY'
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const entry = (over: Record<string, unknown> = {}) => ({
|
|
82
|
+
pk: '7',
|
|
83
|
+
name: 'Stored Coat',
|
|
84
|
+
url: '/stored-coat',
|
|
85
|
+
image: 'https://cdn/coat.jpg',
|
|
86
|
+
price: '1299.00',
|
|
87
|
+
currency: 'USD',
|
|
88
|
+
viewedAt: Date.now(),
|
|
89
|
+
...over
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const renderBlock = (rendered: React.ReactElement, product: unknown = PRODUCT) =>
|
|
93
|
+
render(
|
|
94
|
+
<ThemePageContextProvider value={{ product } as Record<string, unknown>}>
|
|
95
|
+
{rendered}
|
|
96
|
+
</ThemePageContextProvider>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
// jsdom has no requestIdleCallback, so the deferred write lands on the
|
|
101
|
+
// setTimeout fallback — fake timers are what let a test observe it.
|
|
102
|
+
jest.useFakeTimers();
|
|
103
|
+
window.localStorage.clear();
|
|
104
|
+
jest.restoreAllMocks();
|
|
105
|
+
itemTemplatePresent = false;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
jest.useRealTimers();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('default source is unchanged', () => {
|
|
113
|
+
it('renders the collection empty state and touches no storage', () => {
|
|
114
|
+
const setItem = jest.spyOn(Storage.prototype, 'setItem');
|
|
115
|
+
const { container } = renderBlock(
|
|
116
|
+
<ProductRecommendationsBlock {...props({}, { isDesigner: true })} />
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(container.textContent).toContain('set a collection id');
|
|
120
|
+
expect(setItem).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders nothing on the storefront when the collection is empty', () => {
|
|
124
|
+
const { container } = renderBlock(
|
|
125
|
+
<ProductRecommendationsBlock {...props()} />
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(container).toBeEmptyDOMElement();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not record a view when the source was never switched', () => {
|
|
132
|
+
renderBlock(<ProductRecommendationsBlock {...props()} />);
|
|
133
|
+
|
|
134
|
+
expect(window.localStorage.getItem(DEFAULT_RECENTLY_VIEWED_KEY)).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('recently viewed source', () => {
|
|
139
|
+
it('renders nothing on a first visit', () => {
|
|
140
|
+
const { container } = renderBlock(
|
|
141
|
+
<ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(container).toBeEmptyDOMElement();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('records the ambient product, deduped and newest first', () => {
|
|
148
|
+
window.localStorage.setItem(
|
|
149
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
150
|
+
JSON.stringify([entry({ pk: '41', name: 'Old Name' }), entry({ pk: '9' })])
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
renderBlock(
|
|
154
|
+
<ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
|
|
155
|
+
);
|
|
156
|
+
act(() => {
|
|
157
|
+
jest.advanceTimersByTime(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const stored = readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY);
|
|
161
|
+
expect(stored.map((item) => item.pk)).toEqual(['41', '9']);
|
|
162
|
+
expect(stored[0].name).toBe('Viewed Shirt');
|
|
163
|
+
expect(stored[0].price).toBe('199.90');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('caps the stored history at the authored capacity', () => {
|
|
167
|
+
window.localStorage.setItem(
|
|
168
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
169
|
+
JSON.stringify([entry({ pk: '1' }), entry({ pk: '2' }), entry({ pk: '3' })])
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
renderBlock(
|
|
173
|
+
<ProductRecommendationsBlock
|
|
174
|
+
{...props({ source: 'recently-viewed', recentlyViewedCapacity: 2 })}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
act(() => {
|
|
178
|
+
jest.advanceTimersByTime(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY).map((item) => item.pk)
|
|
183
|
+
).toEqual(['41', '1']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('shows the stored products and excludes the one being viewed', () => {
|
|
187
|
+
window.localStorage.setItem(
|
|
188
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
189
|
+
JSON.stringify([
|
|
190
|
+
entry({ pk: '41', name: 'Viewed Shirt' }),
|
|
191
|
+
entry({ pk: '7', name: 'Stored Coat' })
|
|
192
|
+
])
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
renderBlock(
|
|
196
|
+
<ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(screen.getByText('Stored Coat')).toBeInTheDocument();
|
|
200
|
+
expect(screen.queryByText('Viewed Shirt')).not.toBeInTheDocument();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('formats the stored amount at read time instead of storing a string', () => {
|
|
204
|
+
window.localStorage.setItem(
|
|
205
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
206
|
+
JSON.stringify([entry({ pk: '7', price: '1299.00', currency: 'USD' })])
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
renderBlock(
|
|
210
|
+
<ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const price = document.querySelector('[data-product-card-price]');
|
|
214
|
+
expect(price?.textContent).toMatch(/1,299\.00/);
|
|
215
|
+
expect(price?.textContent).not.toBe('1299.00');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('drops entries older than the authored window', () => {
|
|
219
|
+
const old = Date.now() - 40 * 24 * 60 * 60 * 1000;
|
|
220
|
+
window.localStorage.setItem(
|
|
221
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
222
|
+
JSON.stringify([
|
|
223
|
+
entry({ pk: '7', name: 'Stale Coat', viewedAt: old }),
|
|
224
|
+
entry({ pk: '8', name: 'Fresh Coat' })
|
|
225
|
+
])
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
renderBlock(
|
|
229
|
+
<ProductRecommendationsBlock
|
|
230
|
+
{...props({ source: 'recently-viewed', recentlyViewedMaxAge: 30 })}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(screen.getByText('Fresh Coat')).toBeInTheDocument();
|
|
235
|
+
expect(screen.queryByText('Stale Coat')).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('honours a custom storage key', () => {
|
|
239
|
+
window.localStorage.setItem(
|
|
240
|
+
'brand-b',
|
|
241
|
+
JSON.stringify([entry({ pk: '7', name: 'Brand B Coat' })])
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
renderBlock(
|
|
245
|
+
<ProductRecommendationsBlock
|
|
246
|
+
{...props({ source: 'recently-viewed', recentlyViewedKey: 'brand-b' })}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
expect(screen.getByText('Brand B Coat')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('designer', () => {
|
|
255
|
+
it('draws placeholder rows instead of collapsing', () => {
|
|
256
|
+
const { container } = renderBlock(
|
|
257
|
+
<ProductRecommendationsBlock
|
|
258
|
+
{...props({ source: 'recently-viewed' }, { isDesigner: true })}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(
|
|
263
|
+
container.querySelectorAll('[data-product-card]').length
|
|
264
|
+
).toBeGreaterThan(0);
|
|
265
|
+
expect(screen.getAllByText('Recently viewed').length).toBeGreaterThan(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('uses the authored placeholder text', () => {
|
|
269
|
+
renderBlock(
|
|
270
|
+
<ProductRecommendationsBlock
|
|
271
|
+
{...props(
|
|
272
|
+
{
|
|
273
|
+
source: 'recently-viewed',
|
|
274
|
+
recentlyViewedEmptyLabel: 'Son gezdiklerin'
|
|
275
|
+
},
|
|
276
|
+
{ isDesigner: true }
|
|
277
|
+
)}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(screen.getAllByText('Son gezdiklerin').length).toBeGreaterThan(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('records nothing and keeps links inert', () => {
|
|
285
|
+
const setItem = jest.spyOn(Storage.prototype, 'setItem');
|
|
286
|
+
const { container } = renderBlock(
|
|
287
|
+
<ProductRecommendationsBlock
|
|
288
|
+
{...props({ source: 'recently-viewed' }, { isDesigner: true })}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
act(() => {
|
|
292
|
+
jest.advanceTimersByTime(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(setItem).not.toHaveBeenCalled();
|
|
296
|
+
container.querySelectorAll('a[data-product-card]').forEach((card) => {
|
|
297
|
+
expect(card.getAttribute('href')).toBe('#');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('storage failures never reach the shopper', () => {
|
|
303
|
+
it('survives a private window where localStorage throws on read', () => {
|
|
304
|
+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
305
|
+
throw new Error('SecurityError');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const { container } = renderBlock(
|
|
309
|
+
<ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(container).toBeEmptyDOMElement();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('survives a full quota on write', () => {
|
|
316
|
+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
317
|
+
throw new Error('QuotaExceededError');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(() =>
|
|
321
|
+
recordRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY, entry(), 20)
|
|
322
|
+
).not.toThrow();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('treats another app’s payload under the same key as no history', () => {
|
|
326
|
+
window.localStorage.setItem(DEFAULT_RECENTLY_VIEWED_KEY, '{not json');
|
|
327
|
+
|
|
328
|
+
expect(readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY)).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('drops entries with no pk, which cannot be deduped or excluded', () => {
|
|
332
|
+
window.localStorage.setItem(
|
|
333
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
334
|
+
JSON.stringify([{ name: 'No Pk' }, entry({ pk: '7' })])
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(
|
|
338
|
+
readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY).map((item) => item.pk)
|
|
339
|
+
).toEqual(['7']);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* A recommendation card carries ONE pk and a summary, while the overlay sells
|
|
345
|
+
* whatever LEAF the shopper picks. The two are different products the moment
|
|
346
|
+
* the card points at a size, which is the everyday shape of a group product.
|
|
347
|
+
*/
|
|
348
|
+
const CARD = {
|
|
349
|
+
pk: 57,
|
|
350
|
+
name: 'Runner',
|
|
351
|
+
absolute_url: '/runner',
|
|
352
|
+
price: '100.00',
|
|
353
|
+
currency_type: 'TRY'
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const detail = (
|
|
357
|
+
inStock: boolean,
|
|
358
|
+
options: Record<string, unknown>[] | null
|
|
359
|
+
) => ({
|
|
360
|
+
product: { pk: 57, name: 'Runner', absolute_url: '/runner', price: '100.00' },
|
|
361
|
+
in_stock: inStock,
|
|
362
|
+
selected_variant: null,
|
|
363
|
+
variants: options
|
|
364
|
+
? [{ attribute_key: 'size', attribute_name: 'Beden', options }]
|
|
365
|
+
: []
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const QUICK_VIEW = { collectionId: 'runners', showQuickView: true };
|
|
369
|
+
|
|
370
|
+
const trigger = (container: HTMLElement) =>
|
|
371
|
+
container.querySelector('[data-product-card-quick-view]') as HTMLButtonElement;
|
|
372
|
+
|
|
373
|
+
const size = (container: HTMLElement, label: string) =>
|
|
374
|
+
Array.from(
|
|
375
|
+
container.querySelectorAll<HTMLButtonElement>(
|
|
376
|
+
'[data-product-card-quick-view-size]'
|
|
377
|
+
)
|
|
378
|
+
).find((node) => node.textContent === label) as HTMLButtonElement;
|
|
379
|
+
|
|
380
|
+
const addButton = (container: HTMLElement) =>
|
|
381
|
+
container.querySelector(
|
|
382
|
+
'[data-product-card-quick-view-add]'
|
|
383
|
+
) as HTMLButtonElement;
|
|
384
|
+
|
|
385
|
+
describe('quick view stock follows the size the shopper picked', () => {
|
|
386
|
+
beforeEach(() => {
|
|
387
|
+
mockWidget = { products: [CARD] };
|
|
388
|
+
mockDetail = undefined;
|
|
389
|
+
mockAdds.length = 0;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
afterEach(() => {
|
|
393
|
+
mockWidget = undefined;
|
|
394
|
+
mockDetail = undefined;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The card points at S because S is what the merchandiser put in the
|
|
399
|
+
* collection; S has since sold through while M and L are still sellable.
|
|
400
|
+
*/
|
|
401
|
+
const CARD_SIZE_SOLD_OUT = [
|
|
402
|
+
{ label: 'S', value: 's', in_stock: false, is_selectable: false, product: { pk: 57, stock: 0 } },
|
|
403
|
+
{ label: 'M', value: 'm', in_stock: true, is_selectable: true, product: { pk: 58, stock: 4 } },
|
|
404
|
+
{ label: 'L', value: 'l', in_stock: true, is_selectable: true, product: { pk: 59, stock: 2 } }
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
it('offers the sizes that are left instead of declaring the product dead', () => {
|
|
408
|
+
mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
|
|
409
|
+
|
|
410
|
+
const { container } = renderBlock(
|
|
411
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
412
|
+
);
|
|
413
|
+
fireEvent.click(trigger(container));
|
|
414
|
+
|
|
415
|
+
expect(size(container, 'M')).not.toBeDisabled();
|
|
416
|
+
// Nothing is picked yet and two sizes are sellable, so "Out of stock" is a
|
|
417
|
+
// claim about a product the shop can still sell.
|
|
418
|
+
expect(addButton(container).textContent).toBe('Select a size');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('enables the add button once a sellable size is picked', () => {
|
|
422
|
+
mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
|
|
423
|
+
|
|
424
|
+
const { container } = renderBlock(
|
|
425
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
426
|
+
);
|
|
427
|
+
fireEvent.click(trigger(container));
|
|
428
|
+
fireEvent.click(size(container, 'M'));
|
|
429
|
+
|
|
430
|
+
const add = addButton(container);
|
|
431
|
+
expect(add.textContent).toBe('Add to basket');
|
|
432
|
+
expect(add).not.toBeDisabled();
|
|
433
|
+
expect(add.getAttribute('data-product-card-quick-view-add-state')).toBe(
|
|
434
|
+
'idle'
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('posts the picked leaf, not the pk the card carried', async () => {
|
|
439
|
+
mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
|
|
440
|
+
|
|
441
|
+
const { container } = renderBlock(
|
|
442
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
443
|
+
);
|
|
444
|
+
fireEvent.click(trigger(container));
|
|
445
|
+
fireEvent.click(size(container, 'M'));
|
|
446
|
+
await act(async () => {
|
|
447
|
+
fireEvent.click(addButton(container));
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(mockAdds).toEqual([{ product: 58, quantity: 1, attributes: {} }]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The other direction: the parent is in stock, so the overlay used to hand a
|
|
455
|
+
* sold-out leaf to the basket endpoint with everything enabled.
|
|
456
|
+
*/
|
|
457
|
+
const LEAF_SOLD_OUT = [
|
|
458
|
+
{ label: 'M', value: 'm', in_stock: true, is_selectable: true, product: { pk: 58, stock: 4 } },
|
|
459
|
+
{ label: 'L', value: 'l', in_stock: false, is_selectable: true, product: { pk: 57, stock: 0 } }
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
it('refuses a sold-out leaf commerce did not mark pickable', () => {
|
|
463
|
+
mockDetail = detail(true, LEAF_SOLD_OUT);
|
|
464
|
+
|
|
465
|
+
const { container } = renderBlock(
|
|
466
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
467
|
+
);
|
|
468
|
+
fireEvent.click(trigger(container));
|
|
469
|
+
|
|
470
|
+
const sold = size(container, 'L');
|
|
471
|
+
expect(sold).toBeDisabled();
|
|
472
|
+
expect(sold.getAttribute('data-state')).toContain('unavailable');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('lets a sold-out leaf be picked when commerce says it may, and still refuses to add it', async () => {
|
|
476
|
+
mockDetail = detail(true, [
|
|
477
|
+
LEAF_SOLD_OUT[0],
|
|
478
|
+
{ ...LEAF_SOLD_OUT[1], is_selectable_without_stock: true }
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
const { container } = renderBlock(
|
|
482
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
483
|
+
);
|
|
484
|
+
fireEvent.click(trigger(container));
|
|
485
|
+
|
|
486
|
+
const sold = size(container, 'L');
|
|
487
|
+
expect(sold).not.toBeDisabled();
|
|
488
|
+
fireEvent.click(sold);
|
|
489
|
+
|
|
490
|
+
const add = addButton(container);
|
|
491
|
+
expect(add.textContent).toBe('Out of stock');
|
|
492
|
+
expect(add).toBeDisabled();
|
|
493
|
+
|
|
494
|
+
await act(async () => {
|
|
495
|
+
fireEvent.click(add);
|
|
496
|
+
});
|
|
497
|
+
expect(mockAdds).toEqual([]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('still reads the product itself when it has no sizes', () => {
|
|
501
|
+
mockDetail = detail(false, null);
|
|
502
|
+
|
|
503
|
+
const { container } = renderBlock(
|
|
504
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
505
|
+
);
|
|
506
|
+
fireEvent.click(trigger(container));
|
|
507
|
+
|
|
508
|
+
const add = addButton(container);
|
|
509
|
+
expect(add.textContent).toBe('Out of stock');
|
|
510
|
+
expect(add).toBeDisabled();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('adds a product with no sizes at the pk the card carried', async () => {
|
|
514
|
+
mockDetail = detail(true, null);
|
|
515
|
+
|
|
516
|
+
const { container } = renderBlock(
|
|
517
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
518
|
+
);
|
|
519
|
+
fireEvent.click(trigger(container));
|
|
520
|
+
await act(async () => {
|
|
521
|
+
fireEvent.click(addButton(container));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(mockAdds).toEqual([{ product: 57, quantity: 1, attributes: {} }]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe('quick view overlay', () => {
|
|
529
|
+
beforeEach(() => {
|
|
530
|
+
mockWidget = { products: [CARD] };
|
|
531
|
+
mockDetail = detail(true, null);
|
|
532
|
+
mockAdds.length = 0;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
afterEach(() => {
|
|
536
|
+
mockWidget = undefined;
|
|
537
|
+
mockDetail = undefined;
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('moves focus into the panel and hands it back to the trigger on Escape', () => {
|
|
541
|
+
const { container } = renderBlock(
|
|
542
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
543
|
+
);
|
|
544
|
+
const opener = trigger(container);
|
|
545
|
+
fireEvent.click(opener);
|
|
546
|
+
|
|
547
|
+
const panel = container.querySelector(
|
|
548
|
+
'[data-product-card-quick-view-panel]'
|
|
549
|
+
) as HTMLElement;
|
|
550
|
+
expect(panel.contains(document.activeElement)).toBe(true);
|
|
551
|
+
expect(panel.getAttribute('aria-modal')).toBe('true');
|
|
552
|
+
|
|
553
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
554
|
+
|
|
555
|
+
expect(
|
|
556
|
+
container.querySelector('[data-product-card-quick-view-panel]')
|
|
557
|
+
).toBeNull();
|
|
558
|
+
expect(document.activeElement).toBe(opener);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('gives the page its own overflow back when the overlay closes', () => {
|
|
562
|
+
const { container } = renderBlock(
|
|
563
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
564
|
+
);
|
|
565
|
+
fireEvent.click(trigger(container));
|
|
566
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
567
|
+
|
|
568
|
+
fireEvent.click(
|
|
569
|
+
container.querySelector(
|
|
570
|
+
'[data-product-card-quick-view-close]'
|
|
571
|
+
) as HTMLElement
|
|
572
|
+
);
|
|
573
|
+
expect(document.body.style.overflow).toBe('');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('stays inert on the designer canvas', () => {
|
|
577
|
+
const { container } = renderBlock(
|
|
578
|
+
<ProductRecommendationsBlock
|
|
579
|
+
{...props(QUICK_VIEW, { isDesigner: true })}
|
|
580
|
+
/>
|
|
581
|
+
);
|
|
582
|
+
fireEvent.click(trigger(container));
|
|
583
|
+
|
|
584
|
+
const panel = container.querySelector(
|
|
585
|
+
'[data-product-card-quick-view-panel]'
|
|
586
|
+
) as HTMLElement;
|
|
587
|
+
expect(panel).not.toBeNull();
|
|
588
|
+
// No focus stolen into the canvas, no frozen page, no `aria-modal` claim.
|
|
589
|
+
expect(panel.contains(document.activeElement)).toBe(false);
|
|
590
|
+
expect(document.body.style.overflow).toBe('');
|
|
591
|
+
expect(panel.getAttribute('aria-modal')).toBeNull();
|
|
592
|
+
|
|
593
|
+
// An author styling a held-open panel must not have it collapse on Escape.
|
|
594
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
595
|
+
expect(
|
|
596
|
+
container.querySelector('[data-product-card-quick-view-panel]')
|
|
597
|
+
).not.toBeNull();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A leaf the payload gave no `value` to.
|
|
603
|
+
*
|
|
604
|
+
* `chosenOption` starts '' and `toText(undefined)` is also '', so an unguarded
|
|
605
|
+
* lookup matched such an option before the shopper had touched anything: the
|
|
606
|
+
* strip reported a size as chosen that no button ever showed as pressed, and
|
|
607
|
+
* the add button posted it.
|
|
608
|
+
*/
|
|
609
|
+
describe('quick view will not sell a size nobody picked', () => {
|
|
610
|
+
const NO_VALUE = [
|
|
611
|
+
{ label: 'M', product: { pk: 58, stock: 4 } },
|
|
612
|
+
{ label: 'L', value: 'l', product: { pk: 59, stock: 2 } }
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
beforeEach(() => {
|
|
616
|
+
mockWidget = { products: [CARD] };
|
|
617
|
+
mockDetail = detail(true, NO_VALUE);
|
|
618
|
+
mockAdds.length = 0;
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
afterEach(() => {
|
|
622
|
+
mockWidget = undefined;
|
|
623
|
+
mockDetail = undefined;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const open = () => {
|
|
627
|
+
const { container } = renderBlock(
|
|
628
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
629
|
+
);
|
|
630
|
+
fireEvent.click(trigger(container));
|
|
631
|
+
return container;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
it('still asks for a size when the first option carries no value', () => {
|
|
635
|
+
expect(addButton(open()).textContent).toBe('Select a size');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('posts nothing while no size has actually been picked', () => {
|
|
639
|
+
const container = open();
|
|
640
|
+
|
|
641
|
+
act(() => {
|
|
642
|
+
fireEvent.click(addButton(container));
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
expect(mockAdds).toEqual([]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('reports no size as pressed, matching what the row draws', () => {
|
|
649
|
+
const pressed = Array.from(
|
|
650
|
+
open().querySelectorAll('[data-product-card-quick-view-size]')
|
|
651
|
+
).filter((node) => node.getAttribute('aria-pressed') === 'true');
|
|
652
|
+
|
|
653
|
+
expect(pressed).toHaveLength(0);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('does not offer a leaf it has no way to name', () => {
|
|
657
|
+
const container = open();
|
|
658
|
+
// Clicking it could only ever set `chosenOption` back to '', so it would
|
|
659
|
+
// be a control whose click does nothing.
|
|
660
|
+
expect(size(container, 'M')).toBeDisabled();
|
|
661
|
+
expect(size(container, 'L')).not.toBeDisabled();
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe('the strip root is a real focus target only where it is needed', () => {
|
|
666
|
+
beforeEach(() => {
|
|
667
|
+
mockWidget = { products: [CARD] };
|
|
668
|
+
mockDetail = detail(true, null);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
afterEach(() => {
|
|
672
|
+
mockWidget = undefined;
|
|
673
|
+
mockDetail = undefined;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('adds no attribute to a strip without quick view', () => {
|
|
677
|
+
const { container } = renderBlock(
|
|
678
|
+
<ProductRecommendationsBlock {...props({ collectionId: 'runners' })} />
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
expect(
|
|
682
|
+
container
|
|
683
|
+
.querySelector('[data-product-recommendations]')
|
|
684
|
+
?.getAttribute('tabindex')
|
|
685
|
+
).toBeNull();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('is focusable, out of the tab order, once the overlay exists', () => {
|
|
689
|
+
const { container } = renderBlock(
|
|
690
|
+
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
691
|
+
);
|
|
692
|
+
const root = container.querySelector(
|
|
693
|
+
'[data-product-recommendations]'
|
|
694
|
+
) as HTMLElement;
|
|
695
|
+
|
|
696
|
+
// Without this the overlay's last-resort focus target silently refused
|
|
697
|
+
// focus and a shopper whose trigger had gone was dropped on <body>.
|
|
698
|
+
expect(root.getAttribute('tabindex')).toBe('-1');
|
|
699
|
+
root.focus();
|
|
700
|
+
expect(document.activeElement).toBe(root);
|
|
701
|
+
});
|
|
702
|
+
});
|