@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,817 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, fireEvent, act } from '@testing-library/react';
|
|
3
|
+
import VariantSelectorBlock from '../variant-selector-block';
|
|
4
|
+
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
5
|
+
import GOLDEN from './variant-selector-golden.json';
|
|
6
|
+
|
|
7
|
+
jest.mock('next/navigation', () => ({
|
|
8
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
9
|
+
usePathname: () => '/p/blue',
|
|
10
|
+
useSearchParams: () => new URLSearchParams('')
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
let sessionStatus: 'authenticated' | 'unauthenticated' = 'unauthenticated';
|
|
14
|
+
let sessionEmail = '';
|
|
15
|
+
|
|
16
|
+
jest.mock('next-auth/react', () => ({
|
|
17
|
+
useSession: () => ({
|
|
18
|
+
status: sessionStatus,
|
|
19
|
+
data:
|
|
20
|
+
sessionStatus === 'authenticated'
|
|
21
|
+
? { user: { email: sessionEmail } }
|
|
22
|
+
: null
|
|
23
|
+
})
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const addStockAlert = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
|
|
27
|
+
|
|
28
|
+
jest.mock('@akinon/next/data/client/wishlist', () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
useAddStockAlertMutation: () => [addStockAlert, { isLoading: false }]
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `L` is the sold-out size the whole feature is about. `XL` is sold out with no
|
|
35
|
+
* url — still alertable, because commerce sent a product id for it. `NOPK` is
|
|
36
|
+
* sold out with nothing to register against, and `NOURL` is IN STOCK but
|
|
37
|
+
* unpickable: neither may ever be offered an alert.
|
|
38
|
+
*/
|
|
39
|
+
const PRODUCT = {
|
|
40
|
+
pk: 10,
|
|
41
|
+
title: 'Oxford Shirt',
|
|
42
|
+
variants: [
|
|
43
|
+
{
|
|
44
|
+
attribute_key: 'color',
|
|
45
|
+
attribute_name: 'Color',
|
|
46
|
+
options: [
|
|
47
|
+
{
|
|
48
|
+
label: 'Blue',
|
|
49
|
+
value: 'blue',
|
|
50
|
+
is_selected: true,
|
|
51
|
+
is_selectable: true,
|
|
52
|
+
product: { pk: 11, absolute_url: '/p/blue', stock: 9 },
|
|
53
|
+
attributes: { color: '#0000ff' }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: 'Red',
|
|
57
|
+
value: 'red',
|
|
58
|
+
is_selectable: true,
|
|
59
|
+
product: { pk: 12, absolute_url: '/p/red', stock: 2 }
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
attribute_key: 'size',
|
|
65
|
+
attribute_name: 'Size',
|
|
66
|
+
options: [
|
|
67
|
+
{
|
|
68
|
+
label: 'M',
|
|
69
|
+
value: 'm',
|
|
70
|
+
is_selected: true,
|
|
71
|
+
is_selectable: true,
|
|
72
|
+
product: { pk: 22, absolute_url: '/p/m', stock: 1 }
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
label: 'L',
|
|
76
|
+
value: 'l',
|
|
77
|
+
is_selectable: false,
|
|
78
|
+
in_stock: false,
|
|
79
|
+
product: { pk: 23, absolute_url: '/p/l', stock: 0 }
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: 'XL',
|
|
83
|
+
value: 'xl',
|
|
84
|
+
is_selectable: false,
|
|
85
|
+
in_stock: false,
|
|
86
|
+
product: { pk: 24, stock: 0 }
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: 'NOPK',
|
|
90
|
+
value: 'nopk',
|
|
91
|
+
is_selectable: false,
|
|
92
|
+
in_stock: false,
|
|
93
|
+
product: { absolute_url: '/p/nopk', stock: 0 }
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
// In stock, but commerce sent no url — disabled, never sold out.
|
|
97
|
+
label: 'NOURL',
|
|
98
|
+
value: 'nourl',
|
|
99
|
+
is_selectable: true,
|
|
100
|
+
in_stock: true,
|
|
101
|
+
product: { pk: 26, stock: 7 }
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const renderBlock = (
|
|
109
|
+
properties: Record<string, unknown>,
|
|
110
|
+
isDesigner = false
|
|
111
|
+
) =>
|
|
112
|
+
render(
|
|
113
|
+
<ThemePageContextProvider value={{ product: PRODUCT } as never}>
|
|
114
|
+
<VariantSelectorBlock
|
|
115
|
+
{...({
|
|
116
|
+
block: {
|
|
117
|
+
id: 'vs',
|
|
118
|
+
type: 'variant-selector',
|
|
119
|
+
styles: {},
|
|
120
|
+
properties,
|
|
121
|
+
order: 0,
|
|
122
|
+
hidden: false
|
|
123
|
+
},
|
|
124
|
+
placeholderId: 'p',
|
|
125
|
+
sectionId: 's',
|
|
126
|
+
isDesigner,
|
|
127
|
+
currentBreakpoint: 'desktop'
|
|
128
|
+
} as never)}
|
|
129
|
+
/>
|
|
130
|
+
</ThemePageContextProvider>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const ON = { stockAlert: true };
|
|
134
|
+
|
|
135
|
+
/** The trigger for one sold-out label. */
|
|
136
|
+
const trigger = (container: HTMLElement, label: string) =>
|
|
137
|
+
Array.from(container.querySelectorAll('button[data-variant-option]')).find(
|
|
138
|
+
(node) => node.textContent === label
|
|
139
|
+
) as HTMLButtonElement | undefined;
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
addStockAlert.mockClear();
|
|
143
|
+
sessionStatus = 'unauthenticated';
|
|
144
|
+
sessionEmail = '';
|
|
145
|
+
document.body.style.overflow = '';
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
/* The default render, locked to a golden captured from HEAD. */
|
|
150
|
+
/* ------------------------------------------------------------------ */
|
|
151
|
+
|
|
152
|
+
describe('default render is untouched', () => {
|
|
153
|
+
/**
|
|
154
|
+
* The golden was captured by rendering the block AT HEAD, before the stock
|
|
155
|
+
* alert existed. Every case must still come back byte for byte, which is the
|
|
156
|
+
* only real proof that a composition nobody edited did not move.
|
|
157
|
+
*/
|
|
158
|
+
const GOLDEN_PRODUCT = {
|
|
159
|
+
pk: 10,
|
|
160
|
+
variants: [
|
|
161
|
+
{
|
|
162
|
+
attribute_key: 'color',
|
|
163
|
+
attribute_name: 'Color',
|
|
164
|
+
options: [
|
|
165
|
+
{
|
|
166
|
+
label: 'Blue',
|
|
167
|
+
value: 'blue',
|
|
168
|
+
is_selected: true,
|
|
169
|
+
is_selectable: true,
|
|
170
|
+
product: { pk: 11, absolute_url: '/p/blue', stock: 9 },
|
|
171
|
+
attributes: { color: '#0000ff' }
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
label: 'Red',
|
|
175
|
+
value: 'red',
|
|
176
|
+
is_selectable: true,
|
|
177
|
+
product: { pk: 12, absolute_url: '/p/red', stock: 2 }
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
attribute_key: 'size',
|
|
183
|
+
attribute_name: 'Size',
|
|
184
|
+
options: [
|
|
185
|
+
{
|
|
186
|
+
label: 'S',
|
|
187
|
+
value: 's',
|
|
188
|
+
is_selectable: true,
|
|
189
|
+
product: { pk: 21, absolute_url: '/p/s', stock: 5 }
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
label: 'M',
|
|
193
|
+
value: 'm',
|
|
194
|
+
is_selected: true,
|
|
195
|
+
is_selectable: true,
|
|
196
|
+
product: { pk: 22, absolute_url: '/p/m', stock: 1 }
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
label: 'L',
|
|
200
|
+
value: 'l',
|
|
201
|
+
is_selectable: false,
|
|
202
|
+
in_stock: false,
|
|
203
|
+
product: { pk: 23, absolute_url: '/p/l', stock: 0 }
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: 'XL',
|
|
207
|
+
value: 'xl',
|
|
208
|
+
is_selectable: false,
|
|
209
|
+
in_stock: false,
|
|
210
|
+
product: { pk: 24, stock: 0 }
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const renderGolden = (
|
|
218
|
+
properties: Record<string, unknown>,
|
|
219
|
+
isDesigner = false
|
|
220
|
+
) =>
|
|
221
|
+
render(
|
|
222
|
+
<ThemePageContextProvider value={{ product: GOLDEN_PRODUCT } as never}>
|
|
223
|
+
<VariantSelectorBlock
|
|
224
|
+
{...({
|
|
225
|
+
block: {
|
|
226
|
+
id: 'vs',
|
|
227
|
+
type: 'variant-selector',
|
|
228
|
+
styles: {},
|
|
229
|
+
properties,
|
|
230
|
+
order: 0,
|
|
231
|
+
hidden: false
|
|
232
|
+
},
|
|
233
|
+
placeholderId: 'p',
|
|
234
|
+
sectionId: 's',
|
|
235
|
+
isDesigner,
|
|
236
|
+
currentBreakpoint: 'desktop'
|
|
237
|
+
} as never)}
|
|
238
|
+
/>
|
|
239
|
+
</ThemePageContextProvider>
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const CASES: Array<[string, Record<string, unknown>, boolean]> = [
|
|
243
|
+
['defaults', {}, false],
|
|
244
|
+
['defaults-designer', {}, true],
|
|
245
|
+
['dropdown', { layout: 'dropdown' }, false],
|
|
246
|
+
['diagonal', { disabledStyle: 'diagonal' }, false],
|
|
247
|
+
['diagonal-w3', { disabledStyle: 'diagonal', strikeWidth: 3 }, false],
|
|
248
|
+
['nomark', { disabledStyle: 'none' }, false],
|
|
249
|
+
['lowstock', { lowStockThreshold: 3 }, false],
|
|
250
|
+
[
|
|
251
|
+
'lowstock-label',
|
|
252
|
+
{ lowStockThreshold: 3, lowStockLabel: 'Few left' },
|
|
253
|
+
false
|
|
254
|
+
],
|
|
255
|
+
['chip', { groupLayouts: 'color:chip' }, false],
|
|
256
|
+
[
|
|
257
|
+
'chip-image',
|
|
258
|
+
{ groupLayouts: 'color:chip', swatchContent: 'image' },
|
|
259
|
+
false
|
|
260
|
+
],
|
|
261
|
+
['text-layout', { groupLayouts: 'size:text' }, false],
|
|
262
|
+
['group-dropdown', { groupLayouts: 'size:dropdown' }, false],
|
|
263
|
+
['selected-value', { showSelectedValue: true }, false],
|
|
264
|
+
[
|
|
265
|
+
'selected-sep',
|
|
266
|
+
{ showSelectedValue: true, selectedValueSeparator: ' - ' },
|
|
267
|
+
false
|
|
268
|
+
],
|
|
269
|
+
['no-label', { showGroupLabel: false }, false],
|
|
270
|
+
['gaps', { gap: 4, optionGap: 20 }, false],
|
|
271
|
+
['swatchsize', { groupLayouts: 'color:chip', swatchSize: 48 }, false],
|
|
272
|
+
[
|
|
273
|
+
'combo',
|
|
274
|
+
{
|
|
275
|
+
layout: 'dropdown',
|
|
276
|
+
groupLayouts: 'size:box',
|
|
277
|
+
lowStockThreshold: 2,
|
|
278
|
+
disabledStyle: 'diagonal'
|
|
279
|
+
},
|
|
280
|
+
false
|
|
281
|
+
]
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
it.each(CASES)('%s matches the HEAD golden', (name, props, designer) => {
|
|
285
|
+
const { container } = renderGolden(
|
|
286
|
+
props as Record<string, unknown>,
|
|
287
|
+
designer as boolean
|
|
288
|
+
);
|
|
289
|
+
expect(container.innerHTML).toBe(
|
|
290
|
+
(GOLDEN as Record<string, string>)[name as string]
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('covers every captured case', () => {
|
|
295
|
+
expect(CASES.map(([n]) => n).sort()).toEqual(Object.keys(GOLDEN).sort());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('renders no alert markup at all while the switch is off', () => {
|
|
299
|
+
const { container } = renderBlock({});
|
|
300
|
+
expect(container.querySelector('[data-variant-alert]')).toBeNull();
|
|
301
|
+
expect(container.querySelectorAll('button[data-variant-option]')).toHaveLength(
|
|
302
|
+
0
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/* ------------------------------------------------------------------ */
|
|
308
|
+
/* Which options become actionable. */
|
|
309
|
+
/* ------------------------------------------------------------------ */
|
|
310
|
+
|
|
311
|
+
describe('only a sold-out option with an id becomes actionable', () => {
|
|
312
|
+
it('turns the sold-out sizes into real controls', () => {
|
|
313
|
+
const { container } = renderBlock(ON);
|
|
314
|
+
|
|
315
|
+
expect(trigger(container, 'L')).toBeTruthy();
|
|
316
|
+
// Sold out with no url still has a product id, so it can still be watched.
|
|
317
|
+
expect(trigger(container, 'XL')).toBeTruthy();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('leaves a sold-out option commerce sent no id for inert', () => {
|
|
321
|
+
const { container } = renderBlock(ON);
|
|
322
|
+
|
|
323
|
+
expect(trigger(container, 'NOPK')).toBeUndefined();
|
|
324
|
+
const inert = Array.from(
|
|
325
|
+
container.querySelectorAll('span[data-variant-option="disabled"]')
|
|
326
|
+
).find((node) => node.textContent === 'NOPK');
|
|
327
|
+
expect(inert).toBeTruthy();
|
|
328
|
+
expect(inert?.getAttribute('aria-disabled')).toBe('true');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('never offers an alert on an in-stock option that is merely unpickable', () => {
|
|
332
|
+
const { container } = renderBlock(ON);
|
|
333
|
+
|
|
334
|
+
expect(trigger(container, 'NOURL')).toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('keeps the disabled part state so existing swatch styling still lands', () => {
|
|
338
|
+
const { container } = renderBlock(ON);
|
|
339
|
+
const button = trigger(container, 'L');
|
|
340
|
+
|
|
341
|
+
expect(button?.getAttribute('data-part')).toBe('option');
|
|
342
|
+
expect(button?.getAttribute('data-state')).toBe('disabled');
|
|
343
|
+
expect(button?.getAttribute('data-variant-option')).toBe('disabled');
|
|
344
|
+
// A control that does something must not announce itself as disabled.
|
|
345
|
+
expect(button?.hasAttribute('aria-disabled')).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/* ------------------------------------------------------------------ */
|
|
350
|
+
/* The form is bound to the option, not the product. */
|
|
351
|
+
/* ------------------------------------------------------------------ */
|
|
352
|
+
|
|
353
|
+
describe('the form belongs to the option that was clicked', () => {
|
|
354
|
+
it('names that option in the description and sends that option id', async () => {
|
|
355
|
+
sessionStatus = 'authenticated';
|
|
356
|
+
sessionEmail = 'shopper@example.com';
|
|
357
|
+
|
|
358
|
+
const { container } = renderBlock({
|
|
359
|
+
...ON,
|
|
360
|
+
stockAlertDescription: 'Tell me when {attribute} {option} of {product} returns'
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
364
|
+
|
|
365
|
+
expect(
|
|
366
|
+
container.querySelector('[data-variant-alert-description]')?.textContent
|
|
367
|
+
).toBe('Tell me when Size L of Oxford Shirt returns');
|
|
368
|
+
|
|
369
|
+
await act(async () => {
|
|
370
|
+
fireEvent.submit(
|
|
371
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// 23 is L's own product id — not 10, the product's.
|
|
376
|
+
expect(addStockAlert).toHaveBeenCalledWith({
|
|
377
|
+
productPk: 23,
|
|
378
|
+
email: 'shopper@example.com'
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('opens against the second sold-out option independently', () => {
|
|
383
|
+
sessionStatus = 'authenticated';
|
|
384
|
+
sessionEmail = 'shopper@example.com';
|
|
385
|
+
|
|
386
|
+
const { container } = renderBlock({
|
|
387
|
+
...ON,
|
|
388
|
+
stockAlertDescription: '{option}'
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
fireEvent.click(trigger(container, 'XL') as HTMLButtonElement);
|
|
392
|
+
expect(
|
|
393
|
+
container.querySelector('[data-variant-alert-description]')?.textContent
|
|
394
|
+
).toBe('XL');
|
|
395
|
+
expect(container.querySelectorAll('[data-variant-alert]')).toHaveLength(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('clears a previous answer when another option is opened', async () => {
|
|
399
|
+
sessionStatus = 'authenticated';
|
|
400
|
+
sessionEmail = 'shopper@example.com';
|
|
401
|
+
|
|
402
|
+
const { container } = renderBlock(ON);
|
|
403
|
+
|
|
404
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
405
|
+
await act(async () => {
|
|
406
|
+
fireEvent.submit(
|
|
407
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
expect(container.querySelector('[data-variant-alert-success]')).toBeTruthy();
|
|
411
|
+
|
|
412
|
+
fireEvent.click(trigger(container, 'XL') as HTMLButtonElement);
|
|
413
|
+
// A success from L must never read as XL's answer.
|
|
414
|
+
expect(container.querySelector('[data-variant-alert-success]')).toBeNull();
|
|
415
|
+
expect(container.querySelector('[data-variant-alert-form]')).toBeTruthy();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('closes when the same option is clicked again', () => {
|
|
419
|
+
sessionStatus = 'authenticated';
|
|
420
|
+
const { container } = renderBlock(ON);
|
|
421
|
+
|
|
422
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
423
|
+
expect(container.querySelector('[data-variant-alert]')).toBeTruthy();
|
|
424
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
425
|
+
expect(container.querySelector('[data-variant-alert]')).toBeNull();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
/* ------------------------------------------------------------------ */
|
|
430
|
+
/* Honesty: a guest is told the truth, never given a fake waiting list.*/
|
|
431
|
+
/* ------------------------------------------------------------------ */
|
|
432
|
+
|
|
433
|
+
describe('a signed-out shopper is not pretended at', () => {
|
|
434
|
+
it('shows the authorable explanation and link instead of a form', () => {
|
|
435
|
+
const { container } = renderBlock({
|
|
436
|
+
...ON,
|
|
437
|
+
stockAlertSignedOutText: 'Sign in first',
|
|
438
|
+
stockAlertSignInLabel: 'Go to sign in',
|
|
439
|
+
stockAlertSignInUrl: '/users/login'
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
443
|
+
|
|
444
|
+
expect(container.querySelector('[data-variant-alert-form]')).toBeNull();
|
|
445
|
+
expect(
|
|
446
|
+
container.querySelector('[data-variant-alert-signed-out]')?.textContent
|
|
447
|
+
).toBe('Sign in first');
|
|
448
|
+
|
|
449
|
+
const link = container.querySelector('[data-variant-alert-signin]');
|
|
450
|
+
expect(link?.getAttribute('href')).toBe('/users/login');
|
|
451
|
+
expect(link?.textContent).toBe('Go to sign in');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('never reaches the alert endpoint', () => {
|
|
455
|
+
const { container } = renderBlock(ON);
|
|
456
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
457
|
+
expect(addStockAlert).not.toHaveBeenCalled();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('drops a script url rather than rendering it', () => {
|
|
461
|
+
const { container } = renderBlock({
|
|
462
|
+
...ON,
|
|
463
|
+
// eslint-disable-next-line no-script-url
|
|
464
|
+
stockAlertSignInUrl: 'java\tscript:alert(1)'
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
468
|
+
expect(container.querySelector('[data-variant-alert-signin]')).toBeNull();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/* ------------------------------------------------------------------ */
|
|
473
|
+
/* It validates before it submits, and says what happened either way. */
|
|
474
|
+
/* ------------------------------------------------------------------ */
|
|
475
|
+
|
|
476
|
+
describe('validation and outcome', () => {
|
|
477
|
+
beforeEach(() => {
|
|
478
|
+
sessionStatus = 'authenticated';
|
|
479
|
+
sessionEmail = 'shopper@example.com';
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('refuses an invalid address without calling commerce', async () => {
|
|
483
|
+
const { container } = renderBlock({
|
|
484
|
+
...ON,
|
|
485
|
+
stockAlertInvalidEmailLabel: 'Bad address'
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
489
|
+
fireEvent.change(
|
|
490
|
+
container.querySelector('[data-variant-alert-input]') as HTMLInputElement,
|
|
491
|
+
{ target: { value: 'not-an-email' } }
|
|
492
|
+
);
|
|
493
|
+
await act(async () => {
|
|
494
|
+
fireEvent.submit(
|
|
495
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(addStockAlert).not.toHaveBeenCalled();
|
|
500
|
+
const error = container.querySelector('[data-variant-alert-error]');
|
|
501
|
+
expect(error?.textContent).toBe('Bad address');
|
|
502
|
+
expect(error?.getAttribute('role')).toBe('alert');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('refuses to submit until required consent is given', async () => {
|
|
506
|
+
const { container } = renderBlock({
|
|
507
|
+
...ON,
|
|
508
|
+
stockAlertConsentText: 'I agree to be emailed',
|
|
509
|
+
stockAlertConsentRequired: true,
|
|
510
|
+
stockAlertConsentErrorLabel: 'Consent needed'
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
514
|
+
await act(async () => {
|
|
515
|
+
fireEvent.submit(
|
|
516
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(addStockAlert).not.toHaveBeenCalled();
|
|
521
|
+
expect(
|
|
522
|
+
container.querySelector('[data-variant-alert-error]')?.textContent
|
|
523
|
+
).toBe('Consent needed');
|
|
524
|
+
|
|
525
|
+
fireEvent.click(
|
|
526
|
+
container.querySelector(
|
|
527
|
+
'[data-variant-alert-consent] input[type="checkbox"]'
|
|
528
|
+
) as HTMLInputElement
|
|
529
|
+
);
|
|
530
|
+
await act(async () => {
|
|
531
|
+
fireEvent.submit(
|
|
532
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
expect(addStockAlert).toHaveBeenCalledTimes(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('shows consent copy without a checkbox when it is not required', () => {
|
|
539
|
+
const { container } = renderBlock({
|
|
540
|
+
...ON,
|
|
541
|
+
stockAlertConsentText: 'We only email you once.'
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
545
|
+
const consent = container.querySelector('[data-variant-alert-consent]');
|
|
546
|
+
expect(consent?.textContent).toBe('We only email you once.');
|
|
547
|
+
expect(consent?.querySelector('input')).toBeNull();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('says so when commerce rejects the alert', async () => {
|
|
551
|
+
addStockAlert.mockImplementationOnce(() => ({
|
|
552
|
+
unwrap: () => Promise.reject(new Error('401'))
|
|
553
|
+
}));
|
|
554
|
+
|
|
555
|
+
const { container } = renderBlock({
|
|
556
|
+
...ON,
|
|
557
|
+
stockAlertErrorLabel: 'Could not register'
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
561
|
+
await act(async () => {
|
|
562
|
+
fireEvent.submit(
|
|
563
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
expect(container.querySelector('[data-variant-alert-success]')).toBeNull();
|
|
568
|
+
expect(
|
|
569
|
+
container.querySelector('[data-variant-alert-error]')?.textContent
|
|
570
|
+
).toBe('Could not register');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('confirms in the brand words when it worked', async () => {
|
|
574
|
+
const { container } = renderBlock({
|
|
575
|
+
...ON,
|
|
576
|
+
stockAlertSuccessLabel: 'You are on the list for {option}'
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
580
|
+
await act(async () => {
|
|
581
|
+
fireEvent.submit(
|
|
582
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const success = container.querySelector('[data-variant-alert-success]');
|
|
587
|
+
expect(success?.textContent).toBe('You are on the list for L');
|
|
588
|
+
expect(success?.getAttribute('role')).toBe('status');
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/* ------------------------------------------------------------------ */
|
|
593
|
+
/* The designer is not a storefront. */
|
|
594
|
+
/* ------------------------------------------------------------------ */
|
|
595
|
+
|
|
596
|
+
describe('designer safety', () => {
|
|
597
|
+
it('never posts an alert from the canvas', async () => {
|
|
598
|
+
sessionStatus = 'authenticated';
|
|
599
|
+
sessionEmail = 'shopper@example.com';
|
|
600
|
+
|
|
601
|
+
const { container } = renderBlock(ON, true);
|
|
602
|
+
|
|
603
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
604
|
+
await act(async () => {
|
|
605
|
+
fireEvent.submit(
|
|
606
|
+
container.querySelector('[data-variant-alert-form]') as HTMLFormElement
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
expect(addStockAlert).not.toHaveBeenCalled();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('shows the author the form even though nobody is signed in', () => {
|
|
614
|
+
const { container } = renderBlock(ON, true);
|
|
615
|
+
|
|
616
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
617
|
+
expect(container.querySelector('[data-variant-alert-form]')).toBeTruthy();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('leaves the sign-in link inert', () => {
|
|
621
|
+
const { container } = renderBlock(
|
|
622
|
+
{ ...ON, stockAlertSignInUrl: '/users/login' },
|
|
623
|
+
true
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
627
|
+
// The designer branch shows the form, so no navigation is offered at all.
|
|
628
|
+
expect(container.querySelector('[data-variant-alert-signin]')).toBeNull();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('locks no scroll and claims no dialog on the canvas', () => {
|
|
632
|
+
const { container } = renderBlock(
|
|
633
|
+
{ ...ON, stockAlertPresentation: 'modal' },
|
|
634
|
+
true
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
638
|
+
|
|
639
|
+
expect(document.body.style.overflow).toBe('');
|
|
640
|
+
expect(
|
|
641
|
+
container.querySelector('[data-variant-alert]')?.hasAttribute('aria-modal')
|
|
642
|
+
).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
/* ------------------------------------------------------------------ */
|
|
647
|
+
/* The modal owes the full contract. */
|
|
648
|
+
/* ------------------------------------------------------------------ */
|
|
649
|
+
|
|
650
|
+
describe('modal contract', () => {
|
|
651
|
+
const MODAL = { ...ON, stockAlertPresentation: 'modal' };
|
|
652
|
+
|
|
653
|
+
beforeEach(() => {
|
|
654
|
+
sessionStatus = 'authenticated';
|
|
655
|
+
sessionEmail = 'shopper@example.com';
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('claims aria-modal, moves focus in and returns it on close', () => {
|
|
659
|
+
const { container } = renderBlock(MODAL);
|
|
660
|
+
const button = trigger(container, 'L') as HTMLButtonElement;
|
|
661
|
+
|
|
662
|
+
fireEvent.click(button);
|
|
663
|
+
|
|
664
|
+
const panel = container.querySelector('[data-variant-alert]');
|
|
665
|
+
expect(panel?.getAttribute('aria-modal')).toBe('true');
|
|
666
|
+
expect(panel?.getAttribute('role')).toBe('dialog');
|
|
667
|
+
expect(panel?.contains(document.activeElement)).toBe(true);
|
|
668
|
+
|
|
669
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
670
|
+
|
|
671
|
+
expect(container.querySelector('[data-variant-alert]')).toBeNull();
|
|
672
|
+
expect(document.activeElement).toBe(button);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('reference-counts the scroll lock across two open pickers', () => {
|
|
676
|
+
document.body.style.overflow = 'scroll';
|
|
677
|
+
|
|
678
|
+
const first = renderBlock(MODAL);
|
|
679
|
+
const second = renderBlock(MODAL);
|
|
680
|
+
|
|
681
|
+
fireEvent.click(trigger(first.container, 'L') as HTMLButtonElement);
|
|
682
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
683
|
+
|
|
684
|
+
fireEvent.click(trigger(second.container, 'L') as HTMLButtonElement);
|
|
685
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
686
|
+
|
|
687
|
+
// Closed one at a time: Escape is on the document, so it would dismiss
|
|
688
|
+
// both at once and never exercise the counting.
|
|
689
|
+
fireEvent.click(
|
|
690
|
+
first.container.querySelector(
|
|
691
|
+
'[data-variant-alert-close]'
|
|
692
|
+
) as HTMLButtonElement
|
|
693
|
+
);
|
|
694
|
+
// The first letting go must not unlock the page under the second.
|
|
695
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
696
|
+
|
|
697
|
+
fireEvent.click(
|
|
698
|
+
second.container.querySelector(
|
|
699
|
+
'[data-variant-alert-close]'
|
|
700
|
+
) as HTMLButtonElement
|
|
701
|
+
);
|
|
702
|
+
// Only the last holder restores what the page had before either opened.
|
|
703
|
+
expect(document.body.style.overflow).toBe('scroll');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('closes on the backdrop but not on a click inside the dialog', () => {
|
|
707
|
+
const { container } = renderBlock(MODAL);
|
|
708
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
709
|
+
|
|
710
|
+
fireEvent.click(
|
|
711
|
+
container.querySelector('[data-variant-alert-input]') as HTMLElement
|
|
712
|
+
);
|
|
713
|
+
expect(container.querySelector('[data-variant-alert]')).toBeTruthy();
|
|
714
|
+
|
|
715
|
+
fireEvent.click(
|
|
716
|
+
container.querySelector('[data-variant-alert-backdrop]') as HTMLElement
|
|
717
|
+
);
|
|
718
|
+
expect(container.querySelector('[data-variant-alert]')).toBeNull();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('keeps the backdrop out of the dialog subtree', () => {
|
|
722
|
+
const { container } = renderBlock(MODAL);
|
|
723
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
724
|
+
|
|
725
|
+
const backdrop = container.querySelector('[data-variant-alert-backdrop]');
|
|
726
|
+
const panel = container.querySelector('[data-variant-alert]');
|
|
727
|
+
// aria-hidden on an ancestor would hide the dialog it sits behind.
|
|
728
|
+
expect(backdrop?.contains(panel as Node)).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('claims no aria-modal for the inline panel', () => {
|
|
732
|
+
const { container } = renderBlock(ON);
|
|
733
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
734
|
+
|
|
735
|
+
const panel = container.querySelector('[data-variant-alert]');
|
|
736
|
+
expect(panel?.hasAttribute('aria-modal')).toBe(false);
|
|
737
|
+
expect(panel?.getAttribute('role')).toBe('group');
|
|
738
|
+
expect(document.body.style.overflow).toBe('');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('draws the inline panel inside the group that owns the option', () => {
|
|
742
|
+
const { container } = renderBlock(ON);
|
|
743
|
+
fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
|
|
744
|
+
|
|
745
|
+
const group = container.querySelector('[data-variant-group="size"]');
|
|
746
|
+
expect(group?.querySelector('[data-variant-alert]')).toBeTruthy();
|
|
747
|
+
expect(
|
|
748
|
+
container
|
|
749
|
+
.querySelector('[data-variant-group="color"]')
|
|
750
|
+
?.querySelector('[data-variant-alert]')
|
|
751
|
+
).toBeNull();
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
/* ------------------------------------------------------------------ */
|
|
756
|
+
/* Two dialogs on one PDP is an ordinary composition — a desktop */
|
|
757
|
+
/* picker and a mobile one — and it must not take the tab with it. */
|
|
758
|
+
/* ------------------------------------------------------------------ */
|
|
759
|
+
|
|
760
|
+
describe('two dialogs open at once', () => {
|
|
761
|
+
const MODAL = { ...ON, stockAlertPresentation: 'modal' };
|
|
762
|
+
|
|
763
|
+
beforeEach(() => {
|
|
764
|
+
sessionStatus = 'authenticated';
|
|
765
|
+
sessionEmail = 'shopper@example.com';
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const openBoth = () => {
|
|
769
|
+
const first = renderBlock(MODAL);
|
|
770
|
+
const second = renderBlock(MODAL);
|
|
771
|
+
|
|
772
|
+
fireEvent.click(trigger(first.container, 'L') as HTMLButtonElement);
|
|
773
|
+
fireEvent.click(trigger(second.container, 'L') as HTMLButtonElement);
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
firstPanel: first.container.querySelector(
|
|
777
|
+
'[data-variant-alert]'
|
|
778
|
+
) as HTMLElement,
|
|
779
|
+
secondPanel: second.container.querySelector(
|
|
780
|
+
'[data-variant-alert]'
|
|
781
|
+
) as HTMLElement
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
it('does not fight itself to death when focus lands outside both', () => {
|
|
786
|
+
const { secondPanel } = openBoth();
|
|
787
|
+
|
|
788
|
+
const outside = document.createElement('button');
|
|
789
|
+
document.body.appendChild(outside);
|
|
790
|
+
|
|
791
|
+
// Each panel used to read the other's focus as "outside" and pull it back
|
|
792
|
+
// into itself, forever: RangeError: Maximum call stack size exceeded.
|
|
793
|
+
let moves = 0;
|
|
794
|
+
const countMoves = () => {
|
|
795
|
+
moves += 1;
|
|
796
|
+
};
|
|
797
|
+
document.addEventListener('focusin', countMoves);
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
expect(() => outside.focus()).not.toThrow();
|
|
801
|
+
} finally {
|
|
802
|
+
document.removeEventListener('focusin', countMoves);
|
|
803
|
+
outside.remove();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// The move itself, plus the one correction from the dialog on top.
|
|
807
|
+
expect(moves).toBeLessThan(5);
|
|
808
|
+
expect(secondPanel.contains(document.activeElement)).toBe(true);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('lets only the dialog on top claim the rest of the page is inert', () => {
|
|
812
|
+
const { firstPanel, secondPanel } = openBoth();
|
|
813
|
+
|
|
814
|
+
expect(firstPanel.hasAttribute('aria-modal')).toBe(false);
|
|
815
|
+
expect(secondPanel.getAttribute('aria-modal')).toBe('true');
|
|
816
|
+
});
|
|
817
|
+
});
|