@akinon/pz-theme 2.0.43-rc.0 → 2.0.44
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 +14 -31
- package/package.json +4 -4
- package/src/__tests__/checkout-pieces.test.tsx +420 -0
- package/src/__tests__/checkout-slot-coverage.test.ts +181 -0
- package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
- package/src/__tests__/code-owned-region.test.tsx +30 -6
- package/src/__tests__/composable-region.test.tsx +481 -0
- package/src/__tests__/part-styles.test.ts +24 -4
- package/src/__tests__/registry-parity.test.ts +3 -0
- package/src/__tests__/route-slug.test.ts +11 -0
- package/src/blocks/__tests__/checkout-hosted-parts.test.ts +203 -0
- package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
- package/src/blocks/block-renderer-registry.tsx +6 -0
- package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
- package/src/blocks/checkout-outlet-base.tsx +241 -0
- package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
- package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
- package/src/chrome/checkout-piece.tsx +145 -0
- package/src/chrome/checkout-pieces-provider.tsx +28 -0
- package/src/chrome/checkout-pieces.ts +173 -0
- package/src/chrome/chrome-fallback.tsx +8 -26
- package/src/chrome/chrome-slot.tsx +25 -26
- package/src/chrome/code-owned-region.tsx +21 -9
- package/src/chrome/composable-region-client.tsx +228 -0
- package/src/chrome/composable-region.tsx +58 -0
- package/src/chrome/composition-source.ts +34 -0
- package/src/chrome/composition-state.ts +49 -0
- package/src/chrome/live-checkout-page-context.tsx +3 -2
- package/src/chrome/placeholder-slugs.ts +14 -4
- package/src/chrome/route-slug.ts +6 -3
- package/src/hooks/use-designer-features.tsx +41 -10
- package/src/index.ts +6 -0
- package/src/utils/__tests__/checkout-safety.test.ts +374 -0
- package/src/utils/checkout-safety.ts +177 -0
- package/src/utils/index.ts +47 -8
- package/src/utils/part-styles.ts +4 -0
- package/src/utils/visibility-rules.ts +31 -8
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import CheckoutFlowOutletBlock from '../checkout-flow-outlet-block';
|
|
5
|
+
import CheckoutStepsOutletBlock from '../checkout-steps-outlet-block';
|
|
6
|
+
import CheckoutSummaryOutletBlock from '../checkout-summary-outlet-block';
|
|
7
|
+
import {
|
|
8
|
+
getCheckoutFlowVariantOverride,
|
|
9
|
+
getCheckoutOutletClaim,
|
|
10
|
+
registerCheckoutOutlet,
|
|
11
|
+
resetCheckoutPiecesForTests,
|
|
12
|
+
updateCheckoutOutletFlowVariant
|
|
13
|
+
} from '../../chrome/checkout-pieces';
|
|
14
|
+
import { postToEditor } from '../../editor-bridge';
|
|
15
|
+
|
|
16
|
+
jest.mock('../../editor-bridge', () => ({
|
|
17
|
+
postToEditor: jest.fn()
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const postToEditorMock = postToEditor as jest.Mock;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The outlet blocks are positioning targets for the checkout pieces bridge.
|
|
24
|
+
* These tests lock the three properties the feature stands on:
|
|
25
|
+
*
|
|
26
|
+
* - SEALED (I1): an outlet never emits anything interactive — no inputs, no
|
|
27
|
+
* buttons, no forms, no iframes. It cannot render payment surfaces and it
|
|
28
|
+
* cannot synthesize a place-order trigger.
|
|
29
|
+
* - STABLE TARGET: the portal target div exists unconditionally and its DOM
|
|
30
|
+
* identity never changes across re-renders, so iframes portaled into it by
|
|
31
|
+
* the flow piece are never remounted mid-payment.
|
|
32
|
+
* - ZONE GATE: an outlet only registers inside the `checkout-page-body`
|
|
33
|
+
* placeholder. Anywhere else (trailing checkout zones, other pages) it is
|
|
34
|
+
* inert on live AND on the canvas, and its designer band says so — the
|
|
35
|
+
* canvas must never teleport into a spot the storefront would ignore.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
39
|
+
({
|
|
40
|
+
block: {
|
|
41
|
+
id: 'outlet-1',
|
|
42
|
+
type: 'checkout-flow-outlet',
|
|
43
|
+
label: 'Checkout Flow Outlet',
|
|
44
|
+
value: '',
|
|
45
|
+
properties: {},
|
|
46
|
+
styles: {},
|
|
47
|
+
order: 0,
|
|
48
|
+
hidden: false
|
|
49
|
+
},
|
|
50
|
+
placeholderId: 'checkout-page-body',
|
|
51
|
+
sectionId: 's',
|
|
52
|
+
isDesigner: false,
|
|
53
|
+
...over
|
|
54
|
+
}) as any;
|
|
55
|
+
|
|
56
|
+
const flowProps = (
|
|
57
|
+
properties: Record<string, unknown>,
|
|
58
|
+
over: Record<string, unknown> = {}
|
|
59
|
+
) => props({ block: { ...props().block, properties }, ...over });
|
|
60
|
+
|
|
61
|
+
const detachedNodes: HTMLElement[] = [];
|
|
62
|
+
|
|
63
|
+
const createDetachedOutletNode = () => {
|
|
64
|
+
const node = document.createElement('div');
|
|
65
|
+
document.body.appendChild(node);
|
|
66
|
+
detachedNodes.push(node);
|
|
67
|
+
|
|
68
|
+
return node;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
resetCheckoutPiecesForTests();
|
|
73
|
+
postToEditorMock.mockClear();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
detachedNodes.forEach((node) => node.remove());
|
|
78
|
+
detachedNodes.length = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('checkout outlet blocks — sealed live output (I1)', () => {
|
|
82
|
+
it('renders exactly one empty target div on live, nothing else', () => {
|
|
83
|
+
const { container } = render(<CheckoutFlowOutletBlock {...props()} />);
|
|
84
|
+
|
|
85
|
+
expect(container.innerHTML).toBe('<div data-checkout-outlet="flow"></div>');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('never emits interactive elements, for any of the three outlets', () => {
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<>
|
|
91
|
+
<CheckoutFlowOutletBlock {...props()} />
|
|
92
|
+
<CheckoutStepsOutletBlock {...props()} />
|
|
93
|
+
<CheckoutSummaryOutletBlock {...props()} />
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
container.querySelector(
|
|
99
|
+
'input, button, form, iframe, select, textarea, [role="button"]'
|
|
100
|
+
)
|
|
101
|
+
).toBeNull();
|
|
102
|
+
expect(container.querySelectorAll('[data-checkout-outlet]')).toHaveLength(
|
|
103
|
+
3
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('checkout outlet blocks — registration lifecycle', () => {
|
|
109
|
+
it('registers its target on mount and unregisters on unmount', () => {
|
|
110
|
+
const { container, unmount } = render(
|
|
111
|
+
<CheckoutSummaryOutletBlock {...props()} />
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const claim = getCheckoutOutletClaim('summary');
|
|
115
|
+
expect(claim).not.toBeNull();
|
|
116
|
+
expect(claim!.node).toBe(
|
|
117
|
+
container.querySelector('[data-checkout-outlet="summary"]')
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
unmount();
|
|
121
|
+
|
|
122
|
+
expect(getCheckoutOutletClaim('summary')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps the target node identity stable across re-renders', () => {
|
|
126
|
+
const { rerender } = render(
|
|
127
|
+
<CheckoutStepsOutletBlock {...props({ isSelected: false })} />
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const before = getCheckoutOutletClaim('steps');
|
|
131
|
+
expect(before).not.toBeNull();
|
|
132
|
+
|
|
133
|
+
rerender(<CheckoutStepsOutletBlock {...props({ isSelected: true })} />);
|
|
134
|
+
|
|
135
|
+
const after = getCheckoutOutletClaim('steps');
|
|
136
|
+
expect(after).not.toBeNull();
|
|
137
|
+
expect(after!.node).toBe(before!.node);
|
|
138
|
+
expect(after!.id).toBe(before!.id);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('checkout outlet blocks — designer bands', () => {
|
|
143
|
+
it('shows the "renders here" band in the designer while the piece is absent', () => {
|
|
144
|
+
render(<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />);
|
|
145
|
+
|
|
146
|
+
expect(
|
|
147
|
+
screen.getByText('Checkout flow renders here on the storefront')
|
|
148
|
+
).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('shows no band on live', () => {
|
|
152
|
+
const { container } = render(<CheckoutFlowOutletBlock {...props()} />);
|
|
153
|
+
|
|
154
|
+
expect(
|
|
155
|
+
container.querySelector('[data-theme-block-placeholder]')
|
|
156
|
+
).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('marks the second outlet for the same piece as a duplicate (first-wins)', () => {
|
|
160
|
+
render(
|
|
161
|
+
<>
|
|
162
|
+
<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
|
|
163
|
+
<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
|
|
164
|
+
</>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(
|
|
168
|
+
screen.getAllByText(
|
|
169
|
+
'Checkout flow — already placed in another outlet on this page'
|
|
170
|
+
)
|
|
171
|
+
).toHaveLength(1);
|
|
172
|
+
expect(
|
|
173
|
+
screen.getAllByText('Checkout flow renders here on the storefront')
|
|
174
|
+
).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('checkout outlet blocks — zone gate', () => {
|
|
179
|
+
// The flow latches one-shot at page mount and only the body slot hydrates
|
|
180
|
+
// before that latch; a trailing-zone outlet would register too late and be
|
|
181
|
+
// silently dead on live while the canvas teleported into it. The gate makes
|
|
182
|
+
// canvas and storefront agree: outside checkout-page-body an outlet never
|
|
183
|
+
// registers, anywhere.
|
|
184
|
+
it.each([
|
|
185
|
+
['checkout-bottom-body'],
|
|
186
|
+
['checkout-top-body'],
|
|
187
|
+
['theme-homepage-body']
|
|
188
|
+
])('never registers from the %s placeholder', (placeholderId) => {
|
|
189
|
+
const { container } = render(
|
|
190
|
+
<CheckoutFlowOutletBlock {...props({ placeholderId })} />
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(getCheckoutOutletClaim('flow')).toBeNull();
|
|
194
|
+
// Live output stays the sealed empty div, nothing else.
|
|
195
|
+
expect(container.innerHTML).toBe('<div data-checkout-outlet="flow"></div>');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('applies the gate to all three outlets', () => {
|
|
199
|
+
render(
|
|
200
|
+
<>
|
|
201
|
+
<CheckoutFlowOutletBlock
|
|
202
|
+
{...props({ placeholderId: 'checkout-bottom-body' })}
|
|
203
|
+
/>
|
|
204
|
+
<CheckoutStepsOutletBlock
|
|
205
|
+
{...props({ placeholderId: 'checkout-bottom-body' })}
|
|
206
|
+
/>
|
|
207
|
+
<CheckoutSummaryOutletBlock
|
|
208
|
+
{...props({ placeholderId: 'checkout-bottom-body' })}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(getCheckoutOutletClaim('flow')).toBeNull();
|
|
214
|
+
expect(getCheckoutOutletClaim('steps')).toBeNull();
|
|
215
|
+
expect(getCheckoutOutletClaim('summary')).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('explains itself in the designer instead of promising a render', () => {
|
|
219
|
+
render(
|
|
220
|
+
<CheckoutFlowOutletBlock
|
|
221
|
+
{...props({ placeholderId: 'checkout-bottom-body', isDesigner: true })}
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(
|
|
226
|
+
screen.getByText(
|
|
227
|
+
'Checkout flow outlet only works inside the Checkout Body zone — the storefront ignores it here'
|
|
228
|
+
)
|
|
229
|
+
).toBeInTheDocument();
|
|
230
|
+
expect(
|
|
231
|
+
screen.queryByText('Checkout flow renders here on the storefront')
|
|
232
|
+
).toBeNull();
|
|
233
|
+
expect(
|
|
234
|
+
screen.queryByText(
|
|
235
|
+
'Checkout flow — already placed in another outlet on this page'
|
|
236
|
+
)
|
|
237
|
+
).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('checkout flow outlet — composed variant channel', () => {
|
|
242
|
+
it('registers a valid flowVariant with its claim', () => {
|
|
243
|
+
render(
|
|
244
|
+
<CheckoutFlowOutletBlock {...flowProps({ flowVariant: 'one-page' })} />
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(getCheckoutOutletClaim('flow')?.flowVariant).toBe('one-page');
|
|
248
|
+
expect(getCheckoutFlowVariantOverride()).toBe('one-page');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('is breakpoint-invariant: the desktop value is authoritative', () => {
|
|
252
|
+
render(
|
|
253
|
+
<CheckoutFlowOutletBlock
|
|
254
|
+
{...flowProps({
|
|
255
|
+
flowVariant: { desktop: 'step-by-step', mobile: 'one-page' }
|
|
256
|
+
})}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
expect(getCheckoutFlowVariantOverride()).toBe('step-by-step');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it.each([
|
|
264
|
+
['inherit'],
|
|
265
|
+
['accordion'],
|
|
266
|
+
['basket-and-checkout'],
|
|
267
|
+
[42],
|
|
268
|
+
[null],
|
|
269
|
+
[{ desktop: 'accordion' }],
|
|
270
|
+
[undefined]
|
|
271
|
+
])('treats %p as inherit and registers no preference', (value) => {
|
|
272
|
+
render(<CheckoutFlowOutletBlock {...flowProps({ flowVariant: value })} />);
|
|
273
|
+
|
|
274
|
+
expect(getCheckoutOutletClaim('flow')).not.toBeNull();
|
|
275
|
+
expect(getCheckoutOutletClaim('flow')?.flowVariant).toBeUndefined();
|
|
276
|
+
expect(getCheckoutFlowVariantOverride()).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('applies a designer property edit in place, never re-registering', () => {
|
|
280
|
+
const { rerender } = render(
|
|
281
|
+
<CheckoutFlowOutletBlock {...flowProps({ flowVariant: 'one-page' })} />
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const before = getCheckoutOutletClaim('flow');
|
|
285
|
+
expect(before?.flowVariant).toBe('one-page');
|
|
286
|
+
|
|
287
|
+
rerender(
|
|
288
|
+
<CheckoutFlowOutletBlock
|
|
289
|
+
{...flowProps({ flowVariant: 'step-by-step' })}
|
|
290
|
+
/>
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const after = getCheckoutOutletClaim('flow');
|
|
294
|
+
// Same registration — same queue slot, same target node identity.
|
|
295
|
+
expect(after?.id).toBe(before?.id);
|
|
296
|
+
expect(after?.node).toBe(before?.node);
|
|
297
|
+
expect(after?.flowVariant).toBe('step-by-step');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('checkout outlets — designer canvas click capture', () => {
|
|
302
|
+
const renderFlowInWrapper = (over: Record<string, unknown> = {}) => {
|
|
303
|
+
const rendered = render(
|
|
304
|
+
<div data-block-id="outlet-1">
|
|
305
|
+
<CheckoutFlowOutletBlock {...props(over)} />
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
const target = rendered.container.querySelector<HTMLElement>(
|
|
309
|
+
'[data-checkout-outlet="flow"]'
|
|
310
|
+
)!;
|
|
311
|
+
|
|
312
|
+
return { ...rendered, target };
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// The teleported markup is code-owned and full of live handlers; the tests
|
|
316
|
+
// simulate it by appending plain DOM under the portal target.
|
|
317
|
+
const appendPartButton = (target: HTMLElement, part: string) => {
|
|
318
|
+
const button = document.createElement('button');
|
|
319
|
+
button.setAttribute('data-part', part);
|
|
320
|
+
const liveHandler = jest.fn();
|
|
321
|
+
button.addEventListener('click', liveHandler);
|
|
322
|
+
target.appendChild(button);
|
|
323
|
+
|
|
324
|
+
return { button, liveHandler };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
it('selects the clicked part and never lets the live handler fire (designer)', () => {
|
|
328
|
+
const { target } = renderFlowInWrapper({ isDesigner: true });
|
|
329
|
+
const { button, liveHandler } = appendPartButton(target, 'payment-option');
|
|
330
|
+
|
|
331
|
+
const notPrevented = fireEvent.click(button);
|
|
332
|
+
|
|
333
|
+
expect(liveHandler).not.toHaveBeenCalled();
|
|
334
|
+
expect(notPrevented).toBe(false);
|
|
335
|
+
expect(postToEditorMock).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(postToEditorMock).toHaveBeenCalledWith({
|
|
337
|
+
type: 'SELECT_BLOCK',
|
|
338
|
+
data: {
|
|
339
|
+
placeholderId: 'checkout-page-body',
|
|
340
|
+
sectionId: 's',
|
|
341
|
+
blockId: 'outlet-1',
|
|
342
|
+
blockType: 'checkout-flow-outlet',
|
|
343
|
+
blockLabel: 'Checkout Flow Outlet',
|
|
344
|
+
partName: 'payment-option'
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('does not claim a part owned by a nested block', () => {
|
|
350
|
+
const { target } = renderFlowInWrapper({ isDesigner: true });
|
|
351
|
+
const nested = document.createElement('div');
|
|
352
|
+
nested.setAttribute('data-block-id', 'nested-block');
|
|
353
|
+
target.appendChild(nested);
|
|
354
|
+
const { button, liveHandler } = appendPartButton(nested, 'header');
|
|
355
|
+
|
|
356
|
+
fireEvent.click(button);
|
|
357
|
+
|
|
358
|
+
// Still neutralized (the click is inside checkout markup on the canvas),
|
|
359
|
+
// but the part belongs to the nested block — only the outlet is selected.
|
|
360
|
+
expect(liveHandler).not.toHaveBeenCalled();
|
|
361
|
+
expect(postToEditorMock).toHaveBeenCalledWith(
|
|
362
|
+
expect.objectContaining({
|
|
363
|
+
type: 'SELECT_BLOCK',
|
|
364
|
+
data: expect.objectContaining({
|
|
365
|
+
blockId: 'outlet-1',
|
|
366
|
+
partName: undefined
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('attaches nothing on live: clicks reach their own handlers untouched', () => {
|
|
373
|
+
const { target } = renderFlowInWrapper();
|
|
374
|
+
const { button, liveHandler } = appendPartButton(target, 'payment-option');
|
|
375
|
+
|
|
376
|
+
const notPrevented = fireEvent.click(button);
|
|
377
|
+
|
|
378
|
+
expect(liveHandler).toHaveBeenCalledTimes(1);
|
|
379
|
+
expect(notPrevented).toBe(true);
|
|
380
|
+
expect(postToEditorMock).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('checkout outlets — capture against a REAL React portal', () => {
|
|
385
|
+
/**
|
|
386
|
+
* The plain-DOM harness above proves the listener runs; it cannot prove
|
|
387
|
+
* anything about React's synthetic dispatch, which is what every real
|
|
388
|
+
* checkout handler uses. `createPortal` makes React attach its own
|
|
389
|
+
* delegated capture AND bubble listeners to the very node the outlet's
|
|
390
|
+
* capture listener sits on, so the ordering has to be verified with a real
|
|
391
|
+
* portal — including the case where `isDesigner` arrives late (it rides a
|
|
392
|
+
* postMessage) and the outlet re-registers AFTER the portal mounted.
|
|
393
|
+
*/
|
|
394
|
+
const Portaled = ({ children }: { children: React.ReactNode }) => {
|
|
395
|
+
const [node, setNode] = useState<HTMLElement | null>(null);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
setNode(
|
|
399
|
+
document.querySelector<HTMLElement>('[data-checkout-outlet="flow"]')
|
|
400
|
+
);
|
|
401
|
+
}, []);
|
|
402
|
+
|
|
403
|
+
return node ? createPortal(children, node) : null;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const renderWithPortal = (
|
|
407
|
+
isDesigner: boolean,
|
|
408
|
+
children: React.ReactNode
|
|
409
|
+
) => {
|
|
410
|
+
const tree = (designer: boolean) => (
|
|
411
|
+
<div data-block-id="outlet-1">
|
|
412
|
+
<CheckoutFlowOutletBlock {...props({ isDesigner: designer })} />
|
|
413
|
+
<Portaled>{children}</Portaled>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const rendered = render(tree(isDesigner));
|
|
418
|
+
|
|
419
|
+
return { ...rendered, rerenderAsDesigner: () => rendered.rerender(tree(true)) };
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
it('never lets a React onClick fire on the canvas', () => {
|
|
423
|
+
const onClick = jest.fn();
|
|
424
|
+
const { container } = renderWithPortal(
|
|
425
|
+
true,
|
|
426
|
+
<button data-part="payment-option" onClick={onClick}>
|
|
427
|
+
Credit Card
|
|
428
|
+
</button>
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
fireEvent.click(container.querySelector('[data-part]')!);
|
|
432
|
+
|
|
433
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
434
|
+
expect(postToEditorMock).toHaveBeenCalledWith(
|
|
435
|
+
expect.objectContaining({
|
|
436
|
+
data: expect.objectContaining({ partName: 'payment-option' })
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('still holds when isDesigner arrives AFTER the portal mounted', () => {
|
|
442
|
+
const onClick = jest.fn();
|
|
443
|
+
const { container, rerenderAsDesigner } = renderWithPortal(
|
|
444
|
+
false,
|
|
445
|
+
<button data-part="payment-option" onClick={onClick}>
|
|
446
|
+
Credit Card
|
|
447
|
+
</button>
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
rerenderAsDesigner();
|
|
451
|
+
fireEvent.click(container.querySelector('[data-part]')!);
|
|
452
|
+
|
|
453
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('blocks the submit a keystroke produces without any click', () => {
|
|
457
|
+
// Enter inside a card-number field triggers IMPLICIT form submission: the
|
|
458
|
+
// form gets a `submit` event and no click ever happens, so a click-only
|
|
459
|
+
// guard would let a canvas keystroke place a real order.
|
|
460
|
+
const onSubmit = jest.fn((event: React.FormEvent) =>
|
|
461
|
+
event.preventDefault()
|
|
462
|
+
);
|
|
463
|
+
const { container } = renderWithPortal(
|
|
464
|
+
true,
|
|
465
|
+
<form onSubmit={onSubmit}>
|
|
466
|
+
<input data-part="card-number" />
|
|
467
|
+
<button type="submit" data-part="place-order-button">
|
|
468
|
+
Pay
|
|
469
|
+
</button>
|
|
470
|
+
</form>
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
474
|
+
|
|
475
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('cancels the Enter default action, leaving propagation for editor shortcuts', () => {
|
|
479
|
+
const onKeyDown = jest.fn();
|
|
480
|
+
const { container } = renderWithPortal(
|
|
481
|
+
true,
|
|
482
|
+
<input data-part="card-number" onKeyDown={onKeyDown} />
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const notPrevented = fireEvent.keyDown(
|
|
486
|
+
container.querySelector('[data-part]')!,
|
|
487
|
+
{ key: 'Enter' }
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
expect(notPrevented).toBe(false);
|
|
491
|
+
// Propagation is deliberately NOT stopped — the editor still needs keys.
|
|
492
|
+
expect(onKeyDown).toHaveBeenCalledTimes(1);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('leaves the live page completely untouched', () => {
|
|
496
|
+
const onClick = jest.fn();
|
|
497
|
+
const onSubmit = jest.fn((event: React.FormEvent) =>
|
|
498
|
+
event.preventDefault()
|
|
499
|
+
);
|
|
500
|
+
const { container } = renderWithPortal(
|
|
501
|
+
false,
|
|
502
|
+
<form onSubmit={onSubmit}>
|
|
503
|
+
<button type="button" data-part="payment-option" onClick={onClick}>
|
|
504
|
+
Credit Card
|
|
505
|
+
</button>
|
|
506
|
+
</form>
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
fireEvent.click(container.querySelector('[data-part]')!);
|
|
510
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
511
|
+
|
|
512
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
513
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
514
|
+
expect(postToEditorMock).not.toHaveBeenCalled();
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe('checkout outlets — the hidden flag stops the claim', () => {
|
|
519
|
+
// Live drops a hidden block before it mounts; the designer deliberately
|
|
520
|
+
// keeps it mounted under a display:none wrapper. Without this gate the
|
|
521
|
+
// hidden outlet still won the claim on the canvas and the piece portaled
|
|
522
|
+
// the real flow into an invisible subtree — checkout vanished from the
|
|
523
|
+
// canvas while the storefront rendered it in its default position.
|
|
524
|
+
const hidden = (over: Record<string, unknown> = {}) =>
|
|
525
|
+
props({ block: { ...props().block, hidden: true }, ...over });
|
|
526
|
+
|
|
527
|
+
it('never registers, on the canvas or on live', () => {
|
|
528
|
+
render(<CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />);
|
|
529
|
+
expect(getCheckoutOutletClaim('flow')).toBeNull();
|
|
530
|
+
|
|
531
|
+
render(<CheckoutSummaryOutletBlock {...hidden()} />);
|
|
532
|
+
expect(getCheckoutOutletClaim('summary')).toBeNull();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('hands the claim to the next outlet instead of swallowing it', () => {
|
|
536
|
+
render(
|
|
537
|
+
<>
|
|
538
|
+
<CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />
|
|
539
|
+
<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />
|
|
540
|
+
</>
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
expect(getCheckoutOutletClaim('flow')).not.toBeNull();
|
|
544
|
+
expect(
|
|
545
|
+
screen.getByText('Checkout flow renders here on the storefront')
|
|
546
|
+
).toBeInTheDocument();
|
|
547
|
+
expect(
|
|
548
|
+
screen.queryByText(
|
|
549
|
+
'Checkout flow — already placed in another outlet on this page'
|
|
550
|
+
)
|
|
551
|
+
).toBeNull();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('registers again when the flag is cleared', () => {
|
|
555
|
+
const { rerender } = render(
|
|
556
|
+
<CheckoutFlowOutletBlock {...hidden({ isDesigner: true })} />
|
|
557
|
+
);
|
|
558
|
+
expect(getCheckoutOutletClaim('flow')).toBeNull();
|
|
559
|
+
|
|
560
|
+
rerender(<CheckoutFlowOutletBlock {...props({ isDesigner: true })} />);
|
|
561
|
+
expect(getCheckoutOutletClaim('flow')).not.toBeNull();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('checkout steps outlet — one-page honesty note', () => {
|
|
566
|
+
const NOTE =
|
|
567
|
+
'Step indicator only renders with the Step by step checkout — the One page checkout draws its own section numbers';
|
|
568
|
+
|
|
569
|
+
it('explains itself when the composition prefers the one-page checkout', () => {
|
|
570
|
+
registerCheckoutOutlet('flow', {
|
|
571
|
+
id: 'flow-outlet',
|
|
572
|
+
node: createDetachedOutletNode(),
|
|
573
|
+
flowVariant: 'one-page'
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
|
|
577
|
+
|
|
578
|
+
expect(screen.getByText(NOTE)).toBeInTheDocument();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('stays quiet for step-by-step and inherit compositions', () => {
|
|
582
|
+
registerCheckoutOutlet('flow', {
|
|
583
|
+
id: 'flow-outlet',
|
|
584
|
+
node: createDetachedOutletNode(),
|
|
585
|
+
flowVariant: 'step-by-step'
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
|
|
589
|
+
|
|
590
|
+
expect(screen.queryByText(NOTE)).toBeNull();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('reacts to a designer-time variant edit', () => {
|
|
594
|
+
registerCheckoutOutlet('flow', {
|
|
595
|
+
id: 'flow-outlet',
|
|
596
|
+
node: createDetachedOutletNode(),
|
|
597
|
+
flowVariant: 'step-by-step'
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
render(<CheckoutStepsOutletBlock {...props({ isDesigner: true })} />);
|
|
601
|
+
expect(screen.queryByText(NOTE)).toBeNull();
|
|
602
|
+
|
|
603
|
+
act(() => {
|
|
604
|
+
updateCheckoutOutletFlowVariant('flow', 'flow-outlet', 'one-page');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(screen.getByText(NOTE)).toBeInTheDocument();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('never renders the note on live', () => {
|
|
611
|
+
registerCheckoutOutlet('flow', {
|
|
612
|
+
id: 'flow-outlet',
|
|
613
|
+
node: createDetachedOutletNode(),
|
|
614
|
+
flowVariant: 'one-page'
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const { container } = render(<CheckoutStepsOutletBlock {...props()} />);
|
|
618
|
+
|
|
619
|
+
expect(container.innerHTML).toBe(
|
|
620
|
+
'<div data-checkout-outlet="steps"></div>'
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
@@ -58,6 +58,9 @@ import SocialLinksBlock from './social-links-block';
|
|
|
58
58
|
import PaymentBadgesBlock from './payment-badges-block';
|
|
59
59
|
import CheckoutSummaryBlock from './checkout-summary-block';
|
|
60
60
|
import CheckoutStepsBlock from './checkout-steps-block';
|
|
61
|
+
import CheckoutFlowOutletBlock from './checkout-flow-outlet-block';
|
|
62
|
+
import CheckoutStepsOutletBlock from './checkout-steps-outlet-block';
|
|
63
|
+
import CheckoutSummaryOutletBlock from './checkout-summary-outlet-block';
|
|
61
64
|
import OrderSuccessBlock from './order-success-block';
|
|
62
65
|
import OrderTrackingBlock from './order-tracking-block';
|
|
63
66
|
import ProductBreadcrumbBlock from './product-breadcrumb-block';
|
|
@@ -176,6 +179,9 @@ blockRendererRegistry.register('checkout-summary', CheckoutSummaryBlock);
|
|
|
176
179
|
blockRendererRegistry.register('checkout-steps', CheckoutStepsBlock);
|
|
177
180
|
blockRendererRegistry.register('order-success', OrderSuccessBlock);
|
|
178
181
|
blockRendererRegistry.register('order-tracking', OrderTrackingBlock);
|
|
182
|
+
blockRendererRegistry.register('checkout-flow-outlet', CheckoutFlowOutletBlock);
|
|
183
|
+
blockRendererRegistry.register('checkout-steps-outlet', CheckoutStepsOutletBlock);
|
|
184
|
+
blockRendererRegistry.register('checkout-summary-outlet', CheckoutSummaryOutletBlock);
|
|
179
185
|
|
|
180
186
|
// Catalog extras
|
|
181
187
|
blockRendererRegistry.register('product-breadcrumb', ProductBreadcrumbBlock);
|