@akinon/pz-theme 2.0.104 → 2.0.105-rc.0
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 +37 -1
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +49 -1
- package/src/action-capabilities.ts +38 -0
- package/src/blocks/__tests__/add-to-cart-action.test.tsx +377 -0
- package/src/blocks/add-to-cart-block.tsx +29 -1
- package/src/components/__tests__/with-designer-features-keydown.test.tsx +176 -0
- package/src/components/with-designer-features.tsx +25 -3
- package/src/hooks/__tests__/use-block-action.test.tsx +199 -0
- package/src/hooks/use-block-action.ts +99 -57
- package/src/placeholder-registry.ts +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,46 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
+
## 2.0.105-rc.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [86187b1c]
|
|
8
|
+
- Updated dependencies [0cf9ea239]
|
|
9
|
+
- Updated dependencies [324f97d55]
|
|
10
|
+
- Updated dependencies [51ea06888]
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- Updated dependencies [2fea41430]
|
|
13
|
+
- Updated dependencies [b55acb768]
|
|
14
|
+
- Updated dependencies [760258c1c]
|
|
15
|
+
- Updated dependencies [143be2b9d]
|
|
16
|
+
- Updated dependencies [7889b08fe]
|
|
17
|
+
- Updated dependencies [9f8cd3bc5]
|
|
18
|
+
- Updated dependencies [bfafa3f49]
|
|
19
|
+
- Updated dependencies [57d7eb305]
|
|
20
|
+
- Updated dependencies [d99a6a7d5]
|
|
21
|
+
- Updated dependencies [9db81a714]
|
|
22
|
+
- Updated dependencies [591e345e1]
|
|
23
|
+
- Updated dependencies [4de5303c5]
|
|
24
|
+
- Updated dependencies [95b139dc1]
|
|
25
|
+
- Updated dependencies [1d00f2d06]
|
|
26
|
+
- Updated dependencies [4ac7b2a1e]
|
|
27
|
+
- Updated dependencies [e9598c71d]
|
|
28
|
+
- Updated dependencies [4998a9631]
|
|
29
|
+
- Updated dependencies [804d2bd6c]
|
|
30
|
+
- Updated dependencies [3909d3224]
|
|
31
|
+
- Updated dependencies [6a3d8a631]
|
|
32
|
+
- Updated dependencies [e18836b20]
|
|
33
|
+
- Updated dependencies [b1111d7b1]
|
|
34
|
+
- Updated dependencies [1595d45]
|
|
35
|
+
- Updated dependencies [dca1281]
|
|
36
|
+
- @akinon/next@2.0.105-rc.0
|
|
37
|
+
- @akinon/theme-contract@2.0.105-rc.0
|
|
38
|
+
|
|
3
39
|
## 2.0.104
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
|
6
42
|
|
|
7
|
-
-
|
|
43
|
+
- 31d4df3: ZERO-5066: Run the add-to-cart block's action only after the product is added to the basket
|
|
8
44
|
- @akinon/next@2.0.104
|
|
9
45
|
- @akinon/theme-contract@2.0.104
|
|
10
46
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.105-rc.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.105-rc.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.105-rc.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.
|
|
24
|
+
"@akinon/next": "2.0.105-rc.0",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* `getCustomCatalog()` had no caller: a brand could register a custom block
|
|
5
5
|
* and the editor could never learn the type existed. The announce is the only
|
|
6
6
|
* channel, and it is shared with the placeholder slugs — which is what makes
|
|
7
|
-
* the gating interesting.
|
|
7
|
+
* the gating interesting. Four answers have to hold at once:
|
|
8
8
|
*
|
|
9
9
|
* - no placeholders, no frame (the editor's subscriber reloads the theme
|
|
10
10
|
* against whatever slugs a frame carries, so an empty one is destructive)
|
|
11
11
|
* - no proven designer, no catalog (a storefront is framed in production by
|
|
12
12
|
* payment providers, and the catalog is brand IP)
|
|
13
13
|
* - every frame carries the catalog whole, so the receiver can replace
|
|
14
|
+
* - every frame, gated or not, carries the action capabilities (a fact about
|
|
15
|
+
* the theme version, and for most builds the gated frame is the only one)
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
const EDITOR_ORIGIN = 'http://editor.example';
|
|
@@ -318,6 +320,52 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
318
320
|
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
319
321
|
});
|
|
320
322
|
|
|
323
|
+
it('announces the add-to-cart action capability before the editor proves itself', () => {
|
|
324
|
+
const { registry } = load();
|
|
325
|
+
|
|
326
|
+
registry.registerPlaceholder('header');
|
|
327
|
+
jest.advanceTimersByTime(100);
|
|
328
|
+
|
|
329
|
+
// On the gated frame, not only the designer one. A build with no custom
|
|
330
|
+
// catalog never sends a second frame (see the test above), so for it this
|
|
331
|
+
// is the only frame the editor ever gets — and the editor reads a frame
|
|
332
|
+
// without the field as a renderer that still runs the action on the click,
|
|
333
|
+
// keeping "On Click" and every option on a block that no longer does.
|
|
334
|
+
//
|
|
335
|
+
// The literal string rather than the constant: the editor matches it by
|
|
336
|
+
// name and drops a name it does not know, so this pins the wire value a
|
|
337
|
+
// rename would silently break.
|
|
338
|
+
expect(frames()[0].actionCapabilities).toEqual(['add-to-cart-after-add']);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('keeps the action capability on the designer re-announce too', () => {
|
|
342
|
+
const { registry, register } = load();
|
|
343
|
+
|
|
344
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
345
|
+
registry.registerPlaceholder('header');
|
|
346
|
+
jest.advanceTimersByTime(100);
|
|
347
|
+
|
|
348
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
349
|
+
true;
|
|
350
|
+
window.dispatchEvent(
|
|
351
|
+
new MessageEvent('message', {
|
|
352
|
+
origin: EDITOR_ORIGIN,
|
|
353
|
+
source: window.parent as Window,
|
|
354
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
jest.advanceTimersByTime(100);
|
|
358
|
+
|
|
359
|
+
// Every frame, not just the first. The editor replaces its capability set
|
|
360
|
+
// with each announce, so a later frame without the field would turn the
|
|
361
|
+
// panel back to "On Click" halfway through a session.
|
|
362
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
363
|
+
expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
|
|
364
|
+
['add-to-cart-after-add'],
|
|
365
|
+
['add-to-cart-after-add']
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
|
|
321
369
|
it('sends the whole catalog every time, so a re-announce replaces', () => {
|
|
322
370
|
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
323
371
|
true;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this build's block renderer does with an authored action beyond running
|
|
3
|
+
* it on the click, announced to the editor.
|
|
4
|
+
*
|
|
5
|
+
* The editor words a block's action panel after what the storefront in front
|
|
6
|
+
* of it will actually do, so it has to be told — one Omnitron serves channels
|
|
7
|
+
* whose storefronts pin their own theme version, and a v1 brand cannot build
|
|
8
|
+
* past the line it is on, so "the repo has it" is not a fact the editor may
|
|
9
|
+
* assume.
|
|
10
|
+
*
|
|
11
|
+
* - `add-to-cart-after-add` the Add to Cart block runs its stored action
|
|
12
|
+
* itself once the add succeeds, rather than leaving
|
|
13
|
+
* it to the wrapper to run on the click (see
|
|
14
|
+
* `NATIVELY_INTERACTIVE_BLOCK_TYPES`). The editor
|
|
15
|
+
* reads it to say "After Adding to Cart" instead of
|
|
16
|
+
* "On Click", to trim the option list, and to stop
|
|
17
|
+
* offering Focus and Pressed root chips on a
|
|
18
|
+
* wrapper that is no longer a control.
|
|
19
|
+
*
|
|
20
|
+
* Names rather than a version number, for the reason the state pins are: the
|
|
21
|
+
* editor must not have to know which release added what. A build that does not
|
|
22
|
+
* send the list is read as doing none of the above, which is exactly what
|
|
23
|
+
* every renderer shipped before this one did.
|
|
24
|
+
*
|
|
25
|
+
* The editor matches these strings exactly and silently drops a name it does
|
|
26
|
+
* not know, so a rename here narrows the panel back to the baseline instead of
|
|
27
|
+
* failing anywhere. Change one only together with `StorefrontActionCapability`
|
|
28
|
+
* in pz-dynamic-widget.
|
|
29
|
+
*
|
|
30
|
+
* Its own module rather than a constant beside the block that implements it:
|
|
31
|
+
* the placeholder registry announces this, and it must not have to pull a
|
|
32
|
+
* client component in to say one list of strings.
|
|
33
|
+
*/
|
|
34
|
+
export type ActionCapability = 'add-to-cart-after-add';
|
|
35
|
+
|
|
36
|
+
export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
|
|
37
|
+
'add-to-cart-after-add'
|
|
38
|
+
];
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import AddToCartBlock from '../add-to-cart-block';
|
|
5
|
+
import { WithDesignerFeatures } from '../../components/with-designer-features';
|
|
6
|
+
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
7
|
+
import { ModalHostIdProvider } from '../../overlays/modal-host-context';
|
|
8
|
+
import {
|
|
9
|
+
__resetOverlayBusForTests,
|
|
10
|
+
getOpenModalIds,
|
|
11
|
+
isModalOpen,
|
|
12
|
+
openModal,
|
|
13
|
+
registerModalHost,
|
|
14
|
+
subscribeToModals
|
|
15
|
+
} from '../../overlays/overlay-bus';
|
|
16
|
+
import { applyVisibilityRulesToSections } from '../../utils/visibility-rules';
|
|
17
|
+
import { Block } from '../../theme-block';
|
|
18
|
+
import { Section } from '../../theme-section';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* "On Click: Open a modal / Modal: Added to basket" on the PDP add-to-cart.
|
|
22
|
+
*
|
|
23
|
+
* The designer means "once the product is in the basket". The wrapper every
|
|
24
|
+
* block renders inside used to run the action on the same click, before the
|
|
25
|
+
* add request had returned, so the modal opened on a failed add, the quantity
|
|
26
|
+
* stepper's +/- opened it too, and Enter on the real button opened it while
|
|
27
|
+
* the wrapper's keydown cancel swallowed the add itself.
|
|
28
|
+
*
|
|
29
|
+
* The block now runs the SAME saved action (no new property) itself, after the
|
|
30
|
+
* add succeeds — so every composition already published is fixed by the
|
|
31
|
+
* upgrade alone.
|
|
32
|
+
*
|
|
33
|
+
* Rendered the way a storefront renders it: the wrapper from `ThemeBlock`
|
|
34
|
+
* around the renderer, the product from the PDP's page context, and an add
|
|
35
|
+
* request the test holds open so "while pending" is a state it can look at.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const mockAddProduct = jest.fn();
|
|
39
|
+
|
|
40
|
+
// Every `@akinon/next/data/client/*` subpath maps to the same mock file, so
|
|
41
|
+
// one factory has to serve the product client (the mutation) and the basket
|
|
42
|
+
// client (`basketApi`) alike.
|
|
43
|
+
jest.mock('@akinon/next/data/client/product', () => ({
|
|
44
|
+
__esModule: true,
|
|
45
|
+
useAddProductMutation: () => [
|
|
46
|
+
(payload: unknown) => mockAddProduct(payload),
|
|
47
|
+
{ isLoading: false }
|
|
48
|
+
],
|
|
49
|
+
basketApi: {
|
|
50
|
+
util: { invalidateTags: () => ({ type: 'basket/invalidateTags' }) }
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock('@akinon/next/redux/hooks', () => ({
|
|
55
|
+
__esModule: true,
|
|
56
|
+
useAppSelector: () => undefined,
|
|
57
|
+
useAppDispatch: () => () => undefined
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const MODAL = 'added-to-basket';
|
|
61
|
+
const PAGE = { product: { pk: 101, name: 'Polo', in_stock: true } };
|
|
62
|
+
|
|
63
|
+
const addToCartBlock = (properties: Record<string, unknown> = {}): Block =>
|
|
64
|
+
({
|
|
65
|
+
id: 'atc-1',
|
|
66
|
+
type: 'add-to-cart',
|
|
67
|
+
label: 'Add to Cart',
|
|
68
|
+
value: '',
|
|
69
|
+
order: 0,
|
|
70
|
+
hidden: false,
|
|
71
|
+
properties: {
|
|
72
|
+
actionType: 'open-modal',
|
|
73
|
+
actionTarget: MODAL,
|
|
74
|
+
actionAudience: 'all',
|
|
75
|
+
...properties
|
|
76
|
+
},
|
|
77
|
+
styles: {}
|
|
78
|
+
} as unknown as Block);
|
|
79
|
+
|
|
80
|
+
const renderAddToCart = (
|
|
81
|
+
block: Block,
|
|
82
|
+
{ isDesigner = false, hostModalId = '' } = {}
|
|
83
|
+
) => {
|
|
84
|
+
const tree = (
|
|
85
|
+
<ThemePageContextProvider value={PAGE}>
|
|
86
|
+
<WithDesignerFeatures
|
|
87
|
+
block={block}
|
|
88
|
+
placeholderId="p"
|
|
89
|
+
sectionId="s"
|
|
90
|
+
isDesigner={isDesigner}
|
|
91
|
+
>
|
|
92
|
+
<AddToCartBlock
|
|
93
|
+
block={block}
|
|
94
|
+
placeholderId="p"
|
|
95
|
+
sectionId="s"
|
|
96
|
+
isDesigner={isDesigner}
|
|
97
|
+
/>
|
|
98
|
+
</WithDesignerFeatures>
|
|
99
|
+
</ThemePageContextProvider>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const view = render(
|
|
103
|
+
hostModalId ? (
|
|
104
|
+
<ModalHostIdProvider value={hostModalId}>{tree}</ModalHostIdProvider>
|
|
105
|
+
) : (
|
|
106
|
+
tree
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
...view,
|
|
112
|
+
button: view.container.querySelector(
|
|
113
|
+
'[data-add-to-cart]'
|
|
114
|
+
) as HTMLButtonElement,
|
|
115
|
+
wrapper: view.container.querySelector(
|
|
116
|
+
'[data-block-id="atc-1"]'
|
|
117
|
+
) as HTMLElement
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** The add request, held open until the test settles it. */
|
|
122
|
+
const pendingAdd = () => {
|
|
123
|
+
let resolve!: (value: unknown) => void;
|
|
124
|
+
let reject!: (reason: unknown) => void;
|
|
125
|
+
const promise = new Promise((res, rej) => {
|
|
126
|
+
resolve = res;
|
|
127
|
+
reject = rej;
|
|
128
|
+
});
|
|
129
|
+
// The handler awaits it; this only keeps an unawaited rejection quiet.
|
|
130
|
+
promise.catch(() => undefined);
|
|
131
|
+
|
|
132
|
+
mockAddProduct.mockImplementation(() => ({ unwrap: () => promise }));
|
|
133
|
+
|
|
134
|
+
return { resolve, reject };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Settle the request and let the click handler run to its end. */
|
|
138
|
+
const settle = async (finish: () => void) => {
|
|
139
|
+
await act(async () => {
|
|
140
|
+
finish();
|
|
141
|
+
await new Promise((done) => setTimeout(done, 0));
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
__resetOverlayBusForTests();
|
|
147
|
+
registerModalHost();
|
|
148
|
+
mockAddProduct.mockReset();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('add-to-cart with an "open modal" action', () => {
|
|
152
|
+
it('opens the modal only once the add has succeeded', async () => {
|
|
153
|
+
const request = pendingAdd();
|
|
154
|
+
const { button } = renderAddToCart(addToCartBlock());
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
fireEvent.click(button);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(mockAddProduct).toHaveBeenCalledTimes(1);
|
|
161
|
+
// The request is in flight: nothing is in the basket yet, so nothing may
|
|
162
|
+
// say it is.
|
|
163
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
164
|
+
|
|
165
|
+
await settle(() => request.resolve({}));
|
|
166
|
+
|
|
167
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
168
|
+
expect(button).toHaveAttribute('data-add-to-cart-state', 'added');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('never opens it for an add that failed, and shows the error instead', async () => {
|
|
172
|
+
const request = pendingAdd();
|
|
173
|
+
const { button } = renderAddToCart(addToCartBlock());
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
fireEvent.click(button);
|
|
177
|
+
});
|
|
178
|
+
await settle(() => request.reject(new Error('out of stock')));
|
|
179
|
+
|
|
180
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
181
|
+
expect(getOpenModalIds()).toEqual([]);
|
|
182
|
+
expect(button).toHaveAttribute('data-add-to-cart-state', 'error');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('treats the quantity stepper as the stepper: no add, no modal', () => {
|
|
186
|
+
pendingAdd();
|
|
187
|
+
renderAddToCart(addToCartBlock({ showQuantitySelector: true }));
|
|
188
|
+
|
|
189
|
+
act(() => {
|
|
190
|
+
fireEvent.click(screen.getByLabelText('Increase quantity'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The click did land — the amount moved — it just is not an add.
|
|
194
|
+
expect(
|
|
195
|
+
document.querySelector('[data-part="quantity-input"]')
|
|
196
|
+
).toHaveTextContent('2');
|
|
197
|
+
expect(mockAddProduct).not.toHaveBeenCalled();
|
|
198
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('leaves Enter on the real button to the button', async () => {
|
|
202
|
+
const request = pendingAdd();
|
|
203
|
+
const { button } = renderAddToCart(addToCartBlock());
|
|
204
|
+
|
|
205
|
+
button.focus();
|
|
206
|
+
const notPrevented = fireEvent.keyDown(button, { key: 'Enter' });
|
|
207
|
+
|
|
208
|
+
// Cancelling this keydown is what used to swallow the add: the browser
|
|
209
|
+
// turns an uncancelled Enter on a <button> into its click.
|
|
210
|
+
expect(notPrevented).toBe(true);
|
|
211
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
212
|
+
|
|
213
|
+
// jsdom does not synthesise that click, so play the browser's part.
|
|
214
|
+
act(() => {
|
|
215
|
+
fireEvent.click(button);
|
|
216
|
+
});
|
|
217
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
218
|
+
|
|
219
|
+
await settle(() => request.resolve({}));
|
|
220
|
+
|
|
221
|
+
expect(mockAddProduct).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('does not turn the wrapper into a second control around the button', () => {
|
|
226
|
+
const { wrapper, button } = renderAddToCart(addToCartBlock());
|
|
227
|
+
|
|
228
|
+
expect(wrapper).not.toHaveAttribute('role');
|
|
229
|
+
expect(wrapper).not.toHaveAttribute('tabindex');
|
|
230
|
+
expect(wrapper).not.toHaveAttribute('aria-haspopup');
|
|
231
|
+
expect(wrapper).not.toHaveAttribute('aria-expanded');
|
|
232
|
+
// Nor does the button claim to open a dialog: pressing it adds a product,
|
|
233
|
+
// and only a successful add opens one.
|
|
234
|
+
expect(button).not.toHaveAttribute('aria-haspopup');
|
|
235
|
+
expect(button).not.toHaveAttribute('aria-expanded');
|
|
236
|
+
expect(screen.getAllByRole('button')).toEqual([button]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('neither adds nor opens in the designer, where a click selects', () => {
|
|
240
|
+
pendingAdd();
|
|
241
|
+
const { button } = renderAddToCart(addToCartBlock(), { isDesigner: true });
|
|
242
|
+
|
|
243
|
+
act(() => {
|
|
244
|
+
fireEvent.click(button);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(mockAddProduct).not.toHaveBeenCalled();
|
|
248
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('who the action runs for', () => {
|
|
253
|
+
const shopperContext = (authState: 'authenticated' | 'guest') => ({
|
|
254
|
+
authState,
|
|
255
|
+
breakpoint: 'desktop',
|
|
256
|
+
locale: 'tr',
|
|
257
|
+
currency: 'TRY',
|
|
258
|
+
pathname: '/product/101/',
|
|
259
|
+
searchParams: null
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** The block as the placeholder hands it to the renderer for this shopper. */
|
|
263
|
+
const resolvedFor = (block: Block, authState: 'authenticated' | 'guest') =>
|
|
264
|
+
applyVisibilityRulesToSections(
|
|
265
|
+
[
|
|
266
|
+
{
|
|
267
|
+
id: 's',
|
|
268
|
+
type: 'custom',
|
|
269
|
+
label: 'PDP',
|
|
270
|
+
order: 0,
|
|
271
|
+
hidden: false,
|
|
272
|
+
properties: {},
|
|
273
|
+
styles: {},
|
|
274
|
+
blocks: [block]
|
|
275
|
+
} as unknown as Section
|
|
276
|
+
],
|
|
277
|
+
shopperContext(authState)
|
|
278
|
+
)[0].blocks[0];
|
|
279
|
+
|
|
280
|
+
const membersOnly = addToCartBlock({ actionAudience: 'authenticated' });
|
|
281
|
+
|
|
282
|
+
it('adds for a guest outside the audience, without opening the modal', async () => {
|
|
283
|
+
const request = pendingAdd();
|
|
284
|
+
const { button } = renderAddToCart(resolvedFor(membersOnly, 'guest'));
|
|
285
|
+
|
|
286
|
+
act(() => {
|
|
287
|
+
fireEvent.click(button);
|
|
288
|
+
});
|
|
289
|
+
await settle(() => request.resolve({}));
|
|
290
|
+
|
|
291
|
+
expect(mockAddProduct).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(button).toHaveAttribute('data-add-to-cart-state', 'added');
|
|
293
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('opens it for a member, who is the audience', async () => {
|
|
297
|
+
const request = pendingAdd();
|
|
298
|
+
const { button } = renderAddToCart(
|
|
299
|
+
resolvedFor(membersOnly, 'authenticated')
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
act(() => {
|
|
303
|
+
fireEvent.click(button);
|
|
304
|
+
});
|
|
305
|
+
await settle(() => request.resolve({}));
|
|
306
|
+
|
|
307
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('an add-to-cart inside a quick-view modal', () => {
|
|
312
|
+
it('closes the quick view first, then opens the confirmation', async () => {
|
|
313
|
+
const request = pendingAdd();
|
|
314
|
+
const { button } = renderAddToCart(
|
|
315
|
+
addToCartBlock({ closeHostModalOnSuccess: true }),
|
|
316
|
+
{ hostModalId: 'quick-view' }
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
act(() => {
|
|
320
|
+
openModal('quick-view');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const seen: string[][] = [];
|
|
324
|
+
subscribeToModals(() => seen.push([...getOpenModalIds()]));
|
|
325
|
+
|
|
326
|
+
act(() => {
|
|
327
|
+
fireEvent.click(button);
|
|
328
|
+
});
|
|
329
|
+
expect(isModalOpen('quick-view')).toBe(true);
|
|
330
|
+
|
|
331
|
+
await settle(() => request.resolve({}));
|
|
332
|
+
|
|
333
|
+
// One modal handing over to the next, never both at once.
|
|
334
|
+
expect(seen).toEqual([[], [MODAL]]);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('an add-to-cart inside a product card that is one big link', () => {
|
|
339
|
+
it.each([
|
|
340
|
+
['with an action', addToCartBlock()],
|
|
341
|
+
['without one', addToCartBlock({ actionType: 'none' })]
|
|
342
|
+
])('keeps the shopper on the page while the add runs, %s', (_name, block) => {
|
|
343
|
+
pendingAdd();
|
|
344
|
+
const { container } = render(
|
|
345
|
+
<ThemePageContextProvider value={PAGE}>
|
|
346
|
+
<a href="/product/101/">
|
|
347
|
+
<WithDesignerFeatures
|
|
348
|
+
block={block}
|
|
349
|
+
placeholderId="p"
|
|
350
|
+
sectionId="s"
|
|
351
|
+
isDesigner={false}
|
|
352
|
+
>
|
|
353
|
+
<AddToCartBlock
|
|
354
|
+
block={block}
|
|
355
|
+
placeholderId="p"
|
|
356
|
+
sectionId="s"
|
|
357
|
+
isDesigner={false}
|
|
358
|
+
/>
|
|
359
|
+
</WithDesignerFeatures>
|
|
360
|
+
</a>
|
|
361
|
+
</ThemePageContextProvider>
|
|
362
|
+
);
|
|
363
|
+
const button = container.querySelector(
|
|
364
|
+
'[data-add-to-cart]'
|
|
365
|
+
) as HTMLButtonElement;
|
|
366
|
+
|
|
367
|
+
let notPrevented = true;
|
|
368
|
+
act(() => {
|
|
369
|
+
notPrevented = fireEvent.click(button);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// A cancelled click is what stops both a plain <a> and NextLink, which
|
|
373
|
+
// checks `defaultPrevented` before routing.
|
|
374
|
+
expect(notPrevented).toBe(false);
|
|
375
|
+
expect(mockAddProduct).toHaveBeenCalledTimes(1);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
readItemsCurrency
|
|
12
12
|
} from '../utils/analytics';
|
|
13
13
|
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
14
|
+
import { useBlockAction } from '../hooks/use-block-action';
|
|
14
15
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
15
16
|
import {
|
|
16
17
|
DesignerPlaceholder,
|
|
@@ -85,6 +86,10 @@ const readAvailableStock = (product: ThemeProduct | null): number | null => {
|
|
|
85
86
|
* added, the optional icon, the width and — through named parts — the styling
|
|
86
87
|
* of the button in every state.
|
|
87
88
|
*
|
|
89
|
+
* The shared action (`actionType` / `actionTarget`, e.g. "open the Added to
|
|
90
|
+
* basket modal") runs after a SUCCESSFUL add rather than on the click: a
|
|
91
|
+
* failed add, the quantity stepper and the designer canvas never run it.
|
|
92
|
+
*
|
|
88
93
|
* Parts: button, label, icon, row, quantity, quantity-button, quantity-input.
|
|
89
94
|
* Properties: label, addingLabel, addedLabel, errorLabel, outOfStockLabel,
|
|
90
95
|
* fullWidth, quantity, showIcon, icon, iconSize, iconGap, iconPosition,
|
|
@@ -131,6 +136,14 @@ const AddToCartBlock = ({
|
|
|
131
136
|
);
|
|
132
137
|
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
133
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The authored action ("open the Added to basket modal"), which this block
|
|
141
|
+
* runs itself once the add succeeds — see `NATIVELY_INTERACTIVE_BLOCK_TYPES`.
|
|
142
|
+
* Only `run` is used: nothing is spread onto the button, because pressing it
|
|
143
|
+
* does not open a dialog; a successful add does. Called above the early
|
|
144
|
+
* returns below, as every hook here must be.
|
|
145
|
+
*/
|
|
146
|
+
const action = useBlockAction(block, { currentBreakpoint, isDesigner });
|
|
134
147
|
|
|
135
148
|
const label = prop('label', 'Add to Cart');
|
|
136
149
|
const addingLabel = prop('addingLabel', 'Adding…');
|
|
@@ -285,10 +298,18 @@ const AddToCartBlock = ({
|
|
|
285
298
|
);
|
|
286
299
|
};
|
|
287
300
|
|
|
288
|
-
const handleClick = async () => {
|
|
301
|
+
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
302
|
+
// A product card is often one big link with this button inside it. The
|
|
303
|
+
// button has no default of its own (`type="button"`), so cancelling the
|
|
304
|
+
// click only stops the card from navigating away while the add is in
|
|
305
|
+
// flight — the wrapper used to do this by accident when it ran the action.
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
|
|
289
308
|
// A click inside the designer must never touch the shopper's real basket.
|
|
290
309
|
if (isDesigner || disabled) return;
|
|
291
310
|
|
|
311
|
+
let added = false;
|
|
312
|
+
|
|
292
313
|
try {
|
|
293
314
|
await addProduct({
|
|
294
315
|
product: productPk,
|
|
@@ -305,11 +326,18 @@ const AddToCartBlock = ({
|
|
|
305
326
|
currency: readItemsCurrency(product),
|
|
306
327
|
items: [buildCommerceItem(product, { quantity })]
|
|
307
328
|
});
|
|
329
|
+
added = true;
|
|
308
330
|
} catch {
|
|
309
331
|
setFeedback('error');
|
|
310
332
|
}
|
|
311
333
|
|
|
312
334
|
scheduleReset();
|
|
335
|
+
|
|
336
|
+
// The authored action confirms the add, so a failed add never runs it. It
|
|
337
|
+
// runs after the host modal has closed, so a quick view can hand over to
|
|
338
|
+
// the "Added to basket" modal, and outside the try so an action that
|
|
339
|
+
// throws cannot relabel a product that IS in the basket as an error.
|
|
340
|
+
if (added) action.run();
|
|
313
341
|
};
|
|
314
342
|
|
|
315
343
|
const iconNode = showIcon ? (
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { WithDesignerFeatures } from '../with-designer-features';
|
|
5
|
+
import {
|
|
6
|
+
__resetOverlayBusForTests,
|
|
7
|
+
isModalOpen,
|
|
8
|
+
registerModalHost
|
|
9
|
+
} from '../../overlays/overlay-bus';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The keyboard half of "an authored action makes any block a trigger".
|
|
13
|
+
*
|
|
14
|
+
* The wrapper claims `role="button"`, so Enter and Space on it must act — and
|
|
15
|
+
* it cancels them, or Space would scroll the page under the modal it opened.
|
|
16
|
+
* But keydown BUBBLES: a key pressed on a control inside the block reached the
|
|
17
|
+
* same handler and was cancelled too. Enter on a real button then opened the
|
|
18
|
+
* wrapper's modal instead of pressing the button, and a space could not be
|
|
19
|
+
* typed into a field. A key pressed on a native control inside the block is
|
|
20
|
+
* that control's; its own click still reaches the wrapper, exactly as a mouse
|
|
21
|
+
* click on it does.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MODAL = 'campaign';
|
|
25
|
+
|
|
26
|
+
const blockOf = (type: string) => ({
|
|
27
|
+
id: 'blk-1',
|
|
28
|
+
type,
|
|
29
|
+
label: type,
|
|
30
|
+
properties: { actionType: 'open-modal', actionTarget: MODAL },
|
|
31
|
+
styles: {},
|
|
32
|
+
order: 0,
|
|
33
|
+
hidden: false
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const renderBlock = (type: string, children: React.ReactNode) => {
|
|
37
|
+
const view = render(
|
|
38
|
+
<WithDesignerFeatures
|
|
39
|
+
block={blockOf(type)}
|
|
40
|
+
placeholderId="p"
|
|
41
|
+
sectionId="s"
|
|
42
|
+
isDesigner={false}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</WithDesignerFeatures>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...view,
|
|
50
|
+
wrapper: view.container.querySelector(
|
|
51
|
+
'[data-block-id="blk-1"]'
|
|
52
|
+
) as HTMLElement
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
__resetOverlayBusForTests();
|
|
58
|
+
registerModalHost();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('a key pressed on the wrapper itself', () => {
|
|
62
|
+
it.each(['Enter', ' '])(
|
|
63
|
+
'runs the action on %p and cancels the key',
|
|
64
|
+
(key) => {
|
|
65
|
+
const { wrapper } = renderBlock(
|
|
66
|
+
'image',
|
|
67
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
68
|
+
<img src="/banner.jpg" alt="Sale" />
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(wrapper).toHaveAttribute('role', 'button');
|
|
72
|
+
expect(wrapper).toHaveAttribute('tabindex', '0');
|
|
73
|
+
|
|
74
|
+
wrapper.focus();
|
|
75
|
+
let notPrevented = true;
|
|
76
|
+
act(() => {
|
|
77
|
+
notPrevented = fireEvent.keyDown(wrapper, { key });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(notPrevented).toBe(false);
|
|
81
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it('ignores every other key', () => {
|
|
86
|
+
const { wrapper } = renderBlock('text', <p>Free shipping</p>);
|
|
87
|
+
|
|
88
|
+
wrapper.focus();
|
|
89
|
+
const notPrevented = fireEvent.keyDown(wrapper, { key: 'Tab' });
|
|
90
|
+
|
|
91
|
+
expect(notPrevented).toBe(true);
|
|
92
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('a key pressed on a control inside the block', () => {
|
|
97
|
+
const card = (
|
|
98
|
+
<div>
|
|
99
|
+
<span>Summer sale</span>
|
|
100
|
+
<a href="#details">Details</a>
|
|
101
|
+
<button type="button">Notify me</button>
|
|
102
|
+
<input aria-label="E-mail" />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
['link', 'a'],
|
|
108
|
+
['button', 'button']
|
|
109
|
+
])('leaves Enter on a nested %s to that control', (_name, selector) => {
|
|
110
|
+
const { container } = renderBlock('group', card);
|
|
111
|
+
const control = container.querySelector(selector) as HTMLElement;
|
|
112
|
+
|
|
113
|
+
control.focus();
|
|
114
|
+
const notPrevented = fireEvent.keyDown(control, { key: 'Enter' });
|
|
115
|
+
|
|
116
|
+
// Uncancelled, the browser turns it into the control's own click, which
|
|
117
|
+
// then bubbles to the wrapper like a mouse click on the control would.
|
|
118
|
+
// The keydown alone runs nothing.
|
|
119
|
+
expect(notPrevented).toBe(true);
|
|
120
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('lets a space be typed into a nested field', () => {
|
|
124
|
+
const { container } = renderBlock('group', card);
|
|
125
|
+
const field = container.querySelector('input') as HTMLInputElement;
|
|
126
|
+
|
|
127
|
+
field.focus();
|
|
128
|
+
const notPrevented = fireEvent.keyDown(field, { key: ' ' });
|
|
129
|
+
|
|
130
|
+
expect(notPrevented).toBe(true);
|
|
131
|
+
expect(isModalOpen(MODAL)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('still answers a mouse click on the content, as it always has', () => {
|
|
135
|
+
const { getByText } = renderBlock('group', card);
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
fireEvent.click(getByText('Summer sale'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('a trigger inside a trigger', () => {
|
|
146
|
+
it('answers Enter on the inner one the way it answers a click: both run', () => {
|
|
147
|
+
const inner = {
|
|
148
|
+
...blockOf('image'),
|
|
149
|
+
id: 'blk-inner',
|
|
150
|
+
properties: { actionType: 'open-modal', actionTarget: 'size-guide' }
|
|
151
|
+
};
|
|
152
|
+
const { container } = renderBlock(
|
|
153
|
+
'group',
|
|
154
|
+
<WithDesignerFeatures
|
|
155
|
+
block={inner}
|
|
156
|
+
placeholderId="p"
|
|
157
|
+
sectionId="s"
|
|
158
|
+
isDesigner={false}
|
|
159
|
+
>
|
|
160
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
161
|
+
<img src="/chart.jpg" alt="Size chart" />
|
|
162
|
+
</WithDesignerFeatures>
|
|
163
|
+
);
|
|
164
|
+
const innerWrapper = container.querySelector(
|
|
165
|
+
'[data-block-id="blk-inner"]'
|
|
166
|
+
) as HTMLElement;
|
|
167
|
+
|
|
168
|
+
innerWrapper.focus();
|
|
169
|
+
act(() => {
|
|
170
|
+
fireEvent.keyDown(innerWrapper, { key: 'Enter' });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(isModalOpen('size-guide')).toBe(true);
|
|
174
|
+
expect(isModalOpen(MODAL)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
HOVER_SCOPE_ATTRIBUTE,
|
|
16
16
|
REVEAL_ON_HOVER_ATTRIBUTE
|
|
17
17
|
} from '../utils/property-switches';
|
|
18
|
+
|
|
19
|
+
/** Elements that already act on Enter/Space themselves. */
|
|
20
|
+
const NATIVE_KEYBOARD_CONTROLS =
|
|
21
|
+
'a[href], button, input, select, textarea, summary, [contenteditable]:not([contenteditable="false"])';
|
|
22
|
+
|
|
18
23
|
interface WithDesignerFeaturesProps {
|
|
19
24
|
block: any;
|
|
20
25
|
placeholderId: string;
|
|
@@ -94,9 +99,11 @@ export function WithDesignerFeatures({
|
|
|
94
99
|
* only element in the chain that already exists, so a block that becomes
|
|
95
100
|
* clickable gains no extra box and its layout does not move.
|
|
96
101
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
102
|
+
* The `NATIVELY_INTERACTIVE_BLOCK_TYPES` are excluded because they run the
|
|
103
|
+
* action themselves: `button` and `link` wire it onto their real
|
|
104
|
+
* `<button>`/`<a>` (handling it here too would nest a control inside a
|
|
105
|
+
* control, which is both invalid and double-fires), and `add-to-cart` runs
|
|
106
|
+
* it once the add succeeds, which a click on this wrapper cannot know.
|
|
100
107
|
*/
|
|
101
108
|
const action = useBlockAction(block, { currentBreakpoint, isDesigner });
|
|
102
109
|
const ownsAction =
|
|
@@ -115,6 +122,21 @@ export function WithDesignerFeatures({
|
|
|
115
122
|
*/
|
|
116
123
|
const handleRootKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
117
124
|
if (!ownsAction) return;
|
|
125
|
+
// A key pressed on a native control inside the block — a real button,
|
|
126
|
+
// link or field — bubbles here too but belongs to that control: cancelling
|
|
127
|
+
// it swallowed the control's own activation (Enter on add-to-cart opened
|
|
128
|
+
// the modal and added nothing) and made a space impossible to type into a
|
|
129
|
+
// field. Left alone, the control's own click still bubbles up to
|
|
130
|
+
// `handleRootClick`, so the keyboard now does what the mouse does. A nested
|
|
131
|
+
// trigger wrapper is not a native control, so a key on it still reaches
|
|
132
|
+
// this one as well — again, as a click would.
|
|
133
|
+
if (
|
|
134
|
+
event.target !== event.currentTarget &&
|
|
135
|
+
event.target instanceof Element &&
|
|
136
|
+
event.target.matches(NATIVE_KEYBOARD_CONTROLS)
|
|
137
|
+
) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
118
140
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
119
141
|
// Space would scroll the page underneath the modal it just opened.
|
|
120
142
|
event.preventDefault();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
renderHook,
|
|
7
|
+
screen
|
|
8
|
+
} from '@testing-library/react';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
__resetOverlayBusForTests,
|
|
12
|
+
getOpenModalIds,
|
|
13
|
+
isModalOpen,
|
|
14
|
+
openModal,
|
|
15
|
+
registerModalHost
|
|
16
|
+
} from '../../overlays/overlay-bus';
|
|
17
|
+
import {
|
|
18
|
+
BlockActionType,
|
|
19
|
+
NATIVELY_INTERACTIVE_BLOCK_TYPES,
|
|
20
|
+
useBlockAction
|
|
21
|
+
} from '../use-block-action';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `run()`: the action without the click.
|
|
25
|
+
*
|
|
26
|
+
* The add-to-cart block's action confirms an add, so it runs when the request
|
|
27
|
+
* succeeds — long after the click event is gone. `run` is that path, and it
|
|
28
|
+
* must be exactly as inert as `onClick` where `onClick` is inert: in the
|
|
29
|
+
* designer, and on a block with no action (which is also what an audience the
|
|
30
|
+
* shopper is outside of turns an action into).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const mkBlock = (properties: Record<string, unknown>) => ({ properties });
|
|
34
|
+
|
|
35
|
+
const openAction = {
|
|
36
|
+
actionType: 'open-modal',
|
|
37
|
+
actionTarget: 'added-to-basket'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
__resetOverlayBusForTests();
|
|
42
|
+
registerModalHost();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('run()', () => {
|
|
46
|
+
it('performs the action with no event', () => {
|
|
47
|
+
const { result } = renderHook(() => useBlockAction(mkBlock(openAction)));
|
|
48
|
+
|
|
49
|
+
act(() => {
|
|
50
|
+
result.current.run();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(isModalOpen('added-to-basket')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('performs the targetless ones too', () => {
|
|
57
|
+
openModal('a');
|
|
58
|
+
openModal('b');
|
|
59
|
+
|
|
60
|
+
const { result } = renderHook(() =>
|
|
61
|
+
useBlockAction(mkBlock({ actionType: 'close-top-modal' }))
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.run();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(getOpenModalIds()).toEqual(['a']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does nothing in the designer', () => {
|
|
72
|
+
const { result } = renderHook(() =>
|
|
73
|
+
useBlockAction(mkBlock(openAction), { isDesigner: true })
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.run();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(isModalOpen('added-to-basket')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is present and does nothing when there is no action', () => {
|
|
84
|
+
const cases = [
|
|
85
|
+
{},
|
|
86
|
+
{ actionType: 'none', actionTarget: 'added-to-basket' },
|
|
87
|
+
// An action with nowhere to go is no action.
|
|
88
|
+
{ actionType: 'open-modal' },
|
|
89
|
+
{ actionType: 'link', actionTarget: '/cart' }
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
cases.forEach((properties) => {
|
|
93
|
+
const { result } = renderHook(() => useBlockAction(mkBlock(properties)));
|
|
94
|
+
|
|
95
|
+
expect(result.current.isActive).toBe(false);
|
|
96
|
+
expect(typeof result.current.run).toBe('function');
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.run();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(getOpenModalIds()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps its identity across renders, so it can sit in a dependency list', () => {
|
|
107
|
+
const block = mkBlock(openAction);
|
|
108
|
+
const { result, rerender } = renderHook(() => useBlockAction(block));
|
|
109
|
+
const first = result.current.run;
|
|
110
|
+
|
|
111
|
+
rerender();
|
|
112
|
+
|
|
113
|
+
expect(result.current.run).toBe(first);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('onClick, now that it shares the performer', () => {
|
|
118
|
+
const Trigger = ({
|
|
119
|
+
properties,
|
|
120
|
+
isDesigner = false
|
|
121
|
+
}: {
|
|
122
|
+
properties: Record<string, unknown>;
|
|
123
|
+
isDesigner?: boolean;
|
|
124
|
+
}) => {
|
|
125
|
+
const action = useBlockAction(mkBlock(properties), { isDesigner });
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<a href="#elsewhere" onClick={action.onClick}>
|
|
129
|
+
trigger
|
|
130
|
+
</a>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** false when the click's default (the navigation) was cancelled. */
|
|
135
|
+
const clickIsLetThrough = () => fireEvent.click(screen.getByText('trigger'));
|
|
136
|
+
|
|
137
|
+
const interactive: Array<[BlockActionType, string]> = [
|
|
138
|
+
['open-modal', 'm'],
|
|
139
|
+
['close-modal', 'm'],
|
|
140
|
+
['toggle-modal', 'm'],
|
|
141
|
+
['close-top-modal', ''],
|
|
142
|
+
['scroll-to', 'reviews'],
|
|
143
|
+
['auth-step', 'login'],
|
|
144
|
+
['auth-back', ''],
|
|
145
|
+
['otp-resend', ''],
|
|
146
|
+
['otp-change-number', '']
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
it.each(interactive)(
|
|
150
|
+
'cancels the default for %s, as before',
|
|
151
|
+
(actionType, actionTarget) => {
|
|
152
|
+
render(<Trigger properties={{ actionType, actionTarget }} />);
|
|
153
|
+
|
|
154
|
+
// A trigger that also navigated would race what it just started.
|
|
155
|
+
expect(clickIsLetThrough()).toBe(false);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
it.each([
|
|
160
|
+
['none', ''],
|
|
161
|
+
['link', '/elsewhere']
|
|
162
|
+
])('lets the navigation through for %s', (actionType, actionTarget) => {
|
|
163
|
+
render(<Trigger properties={{ actionType, actionTarget }} />);
|
|
164
|
+
|
|
165
|
+
expect(clickIsLetThrough()).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('cancels nothing in the designer, where the wrapper owns the click', () => {
|
|
169
|
+
render(
|
|
170
|
+
<Trigger
|
|
171
|
+
properties={{ actionType: 'open-modal', actionTarget: 'm' }}
|
|
172
|
+
isDesigner
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(clickIsLetThrough()).toBe(true);
|
|
177
|
+
expect(isModalOpen('m')).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('still performs the action it cancelled the default for', () => {
|
|
181
|
+
render(
|
|
182
|
+
<Trigger properties={{ actionType: 'open-modal', actionTarget: 'm' }} />
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
clickIsLetThrough();
|
|
186
|
+
|
|
187
|
+
expect(isModalOpen('m')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('NATIVELY_INTERACTIVE_BLOCK_TYPES', () => {
|
|
192
|
+
it('holds the blocks that run their own action, add-to-cart included', () => {
|
|
193
|
+
expect(Array.from(NATIVELY_INTERACTIVE_BLOCK_TYPES).sort()).toEqual([
|
|
194
|
+
'add-to-cart',
|
|
195
|
+
'button',
|
|
196
|
+
'link'
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
resolveAuthStepTarget,
|
|
18
18
|
setAuthStep
|
|
19
19
|
} from '../auth/auth-flow-bus';
|
|
20
|
-
import { useOtpForm } from '../auth/otp-form-context';
|
|
20
|
+
import { OtpFormApi, useOtpForm } from '../auth/otp-form-context';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* What a block does when it is clicked.
|
|
@@ -67,17 +67,23 @@ const MODAL_ACTIONS: BlockActionType[] = [
|
|
|
67
67
|
];
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Blocks that
|
|
71
|
-
*
|
|
70
|
+
* Blocks that run their authored action themselves, so the generic path in
|
|
71
|
+
* `WithDesignerFeatures` must leave it alone. Two reasons put a block here:
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
* claiming `role="button"` around a
|
|
75
|
-
* another, which is invalid markup,
|
|
76
|
-
* twice on one click.
|
|
73
|
+
* - It renders a real `<button>` or `<a>` and wires the action onto that
|
|
74
|
+
* element (`button`, `link`). A wrapper claiming `role="button"` around a
|
|
75
|
+
* real button nests one control inside another, which is invalid markup,
|
|
76
|
+
* announces twice, and fires the action twice on one click.
|
|
77
|
+
* - Its action is the CONFIRMATION of something asynchronous it does itself
|
|
78
|
+
* (`add-to-cart`). "Open the Added to basket modal" means "once the product
|
|
79
|
+
* is in the basket", so it runs when the add succeeds, not on the click. Run
|
|
80
|
+
* by the wrapper it fired before the request returned — the modal opened on
|
|
81
|
+
* a failed add, and on the quantity stepper's own +/- buttons too.
|
|
77
82
|
*/
|
|
78
83
|
export const NATIVELY_INTERACTIVE_BLOCK_TYPES: ReadonlySet<string> = new Set([
|
|
79
84
|
'button',
|
|
80
|
-
'link'
|
|
85
|
+
'link',
|
|
86
|
+
'add-to-cart'
|
|
81
87
|
]);
|
|
82
88
|
|
|
83
89
|
export interface BlockActionSource {
|
|
@@ -119,6 +125,14 @@ export interface UseBlockActionResult {
|
|
|
119
125
|
isActive: boolean;
|
|
120
126
|
/** Attach to the clickable element. `undefined` when there is no action. */
|
|
121
127
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
128
|
+
/**
|
|
129
|
+
* Perform the action with no event in hand, for a block whose action
|
|
130
|
+
* confirms something it did itself rather than answering a click — the
|
|
131
|
+
* add-to-cart block runs it once the add has succeeded. Always present and
|
|
132
|
+
* inert by the same rule as `onClick` (in the designer, or when the block has
|
|
133
|
+
* no action), so a caller can fire it unconditionally.
|
|
134
|
+
*/
|
|
135
|
+
run: () => void;
|
|
122
136
|
/**
|
|
123
137
|
* Spread onto the same element. Announces a dialog trigger as one, which is
|
|
124
138
|
* what turns a styled div into something a screen reader user can act on
|
|
@@ -143,6 +157,65 @@ const scrollToAnchor = (target: string) => {
|
|
|
143
157
|
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
144
158
|
};
|
|
145
159
|
|
|
160
|
+
/** What an action reaches that only the rendering tree can supply. */
|
|
161
|
+
export interface BlockActionScope {
|
|
162
|
+
/** The auth flow a trigger inside an `auth-flow` block belongs to. */
|
|
163
|
+
contextFlowId?: string;
|
|
164
|
+
/** The OTP form a trigger stands inside, for resend / change number. */
|
|
165
|
+
otpForm?: OtpFormApi | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Perform an authored action — the part of a click that is not the click.
|
|
170
|
+
*
|
|
171
|
+
* Split from the handler because not every action answers a click: the
|
|
172
|
+
* add-to-cart block's action is the confirmation of an add, so it runs when
|
|
173
|
+
* the request succeeds, long after the click event is gone. Both paths call
|
|
174
|
+
* this, so an action behaves the same whichever way it was reached.
|
|
175
|
+
*/
|
|
176
|
+
export const performBlockAction = (
|
|
177
|
+
{ type, target }: BlockAction,
|
|
178
|
+
{ contextFlowId = '', otpForm = null }: BlockActionScope = {}
|
|
179
|
+
): void => {
|
|
180
|
+
switch (type) {
|
|
181
|
+
case 'open-modal':
|
|
182
|
+
openModal(target);
|
|
183
|
+
break;
|
|
184
|
+
case 'close-modal':
|
|
185
|
+
closeModal(target);
|
|
186
|
+
break;
|
|
187
|
+
case 'toggle-modal':
|
|
188
|
+
toggleModal(target);
|
|
189
|
+
break;
|
|
190
|
+
case 'close-top-modal':
|
|
191
|
+
closeTopModal();
|
|
192
|
+
break;
|
|
193
|
+
case 'scroll-to':
|
|
194
|
+
scrollToAnchor(target);
|
|
195
|
+
break;
|
|
196
|
+
case 'auth-step': {
|
|
197
|
+
const resolved = resolveAuthStepTarget(target, contextFlowId);
|
|
198
|
+
if (resolved) setAuthStep(resolved.flowId, resolved.stepKey);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'otp-resend':
|
|
202
|
+
// No-ops while the countdown runs — the form owns that rule, so a
|
|
203
|
+
// resend placed anywhere obeys the same one.
|
|
204
|
+
otpForm?.resend();
|
|
205
|
+
break;
|
|
206
|
+
case 'otp-change-number':
|
|
207
|
+
otpForm?.changeNumber();
|
|
208
|
+
break;
|
|
209
|
+
case 'auth-back':
|
|
210
|
+
// The optional target names a flow; without one the nearest (or
|
|
211
|
+
// default) flow is walked back.
|
|
212
|
+
goBackAuthStep(target || contextFlowId || DEFAULT_AUTH_FLOW_ID);
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
146
219
|
/**
|
|
147
220
|
* Wire a block's authored action to the overlay bus.
|
|
148
221
|
*
|
|
@@ -183,54 +256,12 @@ export const useBlockAction = (
|
|
|
183
256
|
(event: React.MouseEvent<HTMLElement>) => {
|
|
184
257
|
if (isDesigner) return;
|
|
185
258
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
case 'close-modal':
|
|
193
|
-
event.preventDefault();
|
|
194
|
-
closeModal(target);
|
|
195
|
-
break;
|
|
196
|
-
case 'toggle-modal':
|
|
197
|
-
event.preventDefault();
|
|
198
|
-
toggleModal(target);
|
|
199
|
-
break;
|
|
200
|
-
case 'close-top-modal':
|
|
201
|
-
event.preventDefault();
|
|
202
|
-
closeTopModal();
|
|
203
|
-
break;
|
|
204
|
-
case 'scroll-to':
|
|
205
|
-
event.preventDefault();
|
|
206
|
-
scrollToAnchor(target);
|
|
207
|
-
break;
|
|
208
|
-
case 'auth-step': {
|
|
209
|
-
// A trigger that also navigates would race the step it opens.
|
|
210
|
-
event.preventDefault();
|
|
211
|
-
const resolved = resolveAuthStepTarget(target, contextFlowId);
|
|
212
|
-
if (resolved) setAuthStep(resolved.flowId, resolved.stepKey);
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
case 'otp-resend':
|
|
216
|
-
event.preventDefault();
|
|
217
|
-
// No-ops while the countdown runs — the form owns that rule, so a
|
|
218
|
-
// resend placed anywhere obeys the same one.
|
|
219
|
-
otpForm?.resend();
|
|
220
|
-
break;
|
|
221
|
-
case 'otp-change-number':
|
|
222
|
-
event.preventDefault();
|
|
223
|
-
otpForm?.changeNumber();
|
|
224
|
-
break;
|
|
225
|
-
case 'auth-back':
|
|
226
|
-
event.preventDefault();
|
|
227
|
-
// The optional target names a flow; without one the nearest (or
|
|
228
|
-
// default) flow is walked back.
|
|
229
|
-
goBackAuthStep(target || contextFlowId || DEFAULT_AUTH_FLOW_ID);
|
|
230
|
-
break;
|
|
231
|
-
default:
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
259
|
+
// Every action this hook performs cancels the element's default, and
|
|
260
|
+
// `none`/`link` keep it: a trigger that also navigates would race the
|
|
261
|
+
// modal, step or scroll it just started.
|
|
262
|
+
if (isInteractiveAction(type)) event.preventDefault();
|
|
263
|
+
|
|
264
|
+
performBlockAction({ type, target }, { contextFlowId, otpForm });
|
|
234
265
|
},
|
|
235
266
|
[isDesigner, type, target, contextFlowId, otpForm]
|
|
236
267
|
);
|
|
@@ -247,8 +278,17 @@ export const useBlockAction = (
|
|
|
247
278
|
isInteractiveAction(type) &&
|
|
248
279
|
(TARGETLESS_ACTIONS.includes(type) || Boolean(target));
|
|
249
280
|
|
|
281
|
+
// Called before the early return below — a hook cannot sit on a branch — and
|
|
282
|
+
// guarded by the same two conditions `onClick` is, so the designer canvas and
|
|
283
|
+
// a block with no action stay inert whichever way the action is reached.
|
|
284
|
+
const run = useCallback(() => {
|
|
285
|
+
if (!isActive || isDesigner) return;
|
|
286
|
+
|
|
287
|
+
performBlockAction({ type, target }, { contextFlowId, otpForm });
|
|
288
|
+
}, [isActive, isDesigner, type, target, contextFlowId, otpForm]);
|
|
289
|
+
|
|
250
290
|
if (!isActive) {
|
|
251
|
-
return { isActive: false, ariaProps: {} };
|
|
291
|
+
return { isActive: false, run, ariaProps: {} };
|
|
252
292
|
}
|
|
253
293
|
|
|
254
294
|
const opensDialog = type === 'open-modal' || type === 'toggle-modal';
|
|
@@ -269,6 +309,7 @@ export const useBlockAction = (
|
|
|
269
309
|
return {
|
|
270
310
|
isActive: true,
|
|
271
311
|
onClick: handleClick,
|
|
312
|
+
run,
|
|
272
313
|
ariaProps: { 'aria-disabled': true }
|
|
273
314
|
};
|
|
274
315
|
}
|
|
@@ -276,6 +317,7 @@ export const useBlockAction = (
|
|
|
276
317
|
return {
|
|
277
318
|
isActive: true,
|
|
278
319
|
onClick: handleClick,
|
|
320
|
+
run,
|
|
279
321
|
ariaProps: opensDialog
|
|
280
322
|
? {
|
|
281
323
|
'aria-haspopup': 'dialog',
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
|
|
2
2
|
import { getCustomSectionCatalog } from './custom/section-catalog';
|
|
3
|
+
import {
|
|
4
|
+
ACTION_CAPABILITIES,
|
|
5
|
+
type ActionCapability
|
|
6
|
+
} from './action-capabilities';
|
|
3
7
|
import {
|
|
4
8
|
STATE_PIN_CAPABILITIES,
|
|
5
9
|
type StatePinCapability
|
|
@@ -72,6 +76,22 @@ interface PlaceholdersAvailablePayload {
|
|
|
72
76
|
* listing it would make an old storefront look LESS capable than it is.
|
|
73
77
|
*/
|
|
74
78
|
statePins?: readonly StatePinCapability[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What THIS build's block renderer does with an authored action beyond
|
|
82
|
+
* running it on the click, so the editor can word the action panel after
|
|
83
|
+
* the storefront in front of it (see `action-capabilities.ts`).
|
|
84
|
+
*
|
|
85
|
+
* Additive and by name, on the same terms as `statePins`, and read the same
|
|
86
|
+
* way: a build that does not send it is taken to do none of it, which is the
|
|
87
|
+
* truth for every renderer shipped before the field existed.
|
|
88
|
+
*
|
|
89
|
+
* Unlike `statePins` it rides EVERY frame. The editor replaces what it holds
|
|
90
|
+
* with each frame, so a frame without it would put the panel back to "On
|
|
91
|
+
* Click" — and the only frame some documents ever send is the one that went
|
|
92
|
+
* out before the designer flag arrived (see `handleEditorMessage`).
|
|
93
|
+
*/
|
|
94
|
+
actionCapabilities?: readonly ActionCapability[];
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
const isFramed = (): boolean =>
|
|
@@ -105,7 +125,14 @@ const flush = () => {
|
|
|
105
125
|
if (!isFramed()) return;
|
|
106
126
|
|
|
107
127
|
const data: PlaceholdersAvailablePayload = {
|
|
108
|
-
placeholders: Array.from(placeholderRegistry)
|
|
128
|
+
placeholders: Array.from(placeholderRegistry),
|
|
129
|
+
// Outside the designer gate below, on purpose. It says what this theme
|
|
130
|
+
// version does, not what this brand built — nothing a framing payment
|
|
131
|
+
// provider could not read off the bundle — so there is nothing to
|
|
132
|
+
// withhold. Gated, it would be lost for the life of the document on a slow
|
|
133
|
+
// handshake: the designer re-announce only fires for a build that
|
|
134
|
+
// registers a custom catalog, and most brands register none.
|
|
135
|
+
actionCapabilities: ACTION_CAPABILITIES
|
|
109
136
|
};
|
|
110
137
|
|
|
111
138
|
// Being framed is NOT enough to hand the catalog over. A storefront is
|