@akinon/pz-theme 2.0.94 → 2.0.95-beta.1
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 +16 -0
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import NewsletterFormBlock from '../blocks/newsletter-form-block';
|
|
6
|
+
import ThemeModalMount from '../overlays/theme-modal-mount';
|
|
7
|
+
import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
|
|
8
|
+
import {
|
|
9
|
+
__resetOverlayBusForTests,
|
|
10
|
+
getOpenModalIds,
|
|
11
|
+
isModalOpen,
|
|
12
|
+
openModal,
|
|
13
|
+
registerModalHost
|
|
14
|
+
} from '../overlays/overlay-bus';
|
|
15
|
+
import { clearAutoOpenRecord } from '../overlays/modal-frequency';
|
|
16
|
+
import { __resetModalPreviewForTests } from '../overlays/use-modal-preview';
|
|
17
|
+
import { Block } from '../theme-block';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A shopper who signs in inside the drawer is done with the drawer.
|
|
21
|
+
*
|
|
22
|
+
* It used to stay open over the page, and the reason was a default in the
|
|
23
|
+
* wrong place: the only close a form could perform was `closeAllModals()`
|
|
24
|
+
* behind an authored toggle, and that toggle is off by default — correctly,
|
|
25
|
+
* since "close every modal on the page" is not something a form on a plain
|
|
26
|
+
* page should do unasked. The live GANT drawer shows how that ends: its OTP
|
|
27
|
+
* form has the toggle on and its login and register forms do not, so signing
|
|
28
|
+
* in with an e-mail left the drawer covering the homepage.
|
|
29
|
+
*
|
|
30
|
+
* With the host's id in hand the two defaults stop fighting: a form closes THE
|
|
31
|
+
* MODAL IT IS IN, and a form that is not in one still closes nothing.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
|
|
35
|
+
|
|
36
|
+
jest.mock('next-auth/react', () => ({
|
|
37
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
38
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const push = jest.fn();
|
|
42
|
+
const refresh = jest.fn();
|
|
43
|
+
|
|
44
|
+
jest.mock('next/navigation', () => ({
|
|
45
|
+
useRouter: () => ({ push, refresh, replace: jest.fn() }),
|
|
46
|
+
useSearchParams: () => new URLSearchParams(),
|
|
47
|
+
usePathname: () => '/'
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
jest.mock('@akinon/next/hooks/use-localization', () => ({
|
|
51
|
+
useLocalization: () => ({ locale: 'en', currency: 'USD' })
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const subscribe = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
|
|
55
|
+
|
|
56
|
+
jest.mock('@akinon/next/data/client/misc', () => ({
|
|
57
|
+
useEmailSubscriptionMutation: () => [subscribe, { isLoading: false }]
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
61
|
+
({
|
|
62
|
+
id: 'blk',
|
|
63
|
+
type: 'text',
|
|
64
|
+
label: 'Block',
|
|
65
|
+
value: '',
|
|
66
|
+
order: 0,
|
|
67
|
+
hidden: false,
|
|
68
|
+
properties: {},
|
|
69
|
+
styles: {},
|
|
70
|
+
...overrides
|
|
71
|
+
}) as Block;
|
|
72
|
+
|
|
73
|
+
const loginForm = (properties: Record<string, unknown> = {}) => (
|
|
74
|
+
<AuthFormBlock
|
|
75
|
+
block={
|
|
76
|
+
block({
|
|
77
|
+
id: 'login-1',
|
|
78
|
+
type: 'auth-form',
|
|
79
|
+
label: 'Auth Form',
|
|
80
|
+
properties
|
|
81
|
+
}) as Block
|
|
82
|
+
}
|
|
83
|
+
placeholderId="placeholder-1"
|
|
84
|
+
sectionId="section-1"
|
|
85
|
+
currentBreakpoint="desktop"
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const mkModal = (id: string): ThemeModal =>
|
|
90
|
+
readThemeModals([{ id, name: 'Auth', presentation: 'drawer' }])[0];
|
|
91
|
+
|
|
92
|
+
/** The drawer, with the login form composed inside it — the GANT shape. */
|
|
93
|
+
const openDrawerWithLogin = (
|
|
94
|
+
properties: Record<string, unknown> = {},
|
|
95
|
+
modalId = 'auth-drawer'
|
|
96
|
+
) => {
|
|
97
|
+
const view = render(
|
|
98
|
+
<ThemeModalMount modal={mkModal(modalId)}>
|
|
99
|
+
{loginForm(properties)}
|
|
100
|
+
</ThemeModalMount>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
act(() => {
|
|
104
|
+
openModal(modalId);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return view;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const submit = (container: HTMLElement) => {
|
|
111
|
+
fireEvent.change(container.querySelector('input[name="email"]')!, {
|
|
112
|
+
target: { value: 'ada@example.com' }
|
|
113
|
+
});
|
|
114
|
+
fireEvent.change(container.querySelector('input[name="password"]')!, {
|
|
115
|
+
target: { value: 'hunter2' }
|
|
116
|
+
});
|
|
117
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
signIn.mockClear();
|
|
122
|
+
push.mockClear();
|
|
123
|
+
refresh.mockClear();
|
|
124
|
+
__resetOverlayBusForTests();
|
|
125
|
+
__resetModalPreviewForTests();
|
|
126
|
+
registerModalHost();
|
|
127
|
+
clearAutoOpenRecord('auth-drawer');
|
|
128
|
+
clearAutoOpenRecord('other');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('a login inside a drawer', () => {
|
|
132
|
+
it('closes the drawer it was in, with nothing authored', async () => {
|
|
133
|
+
const { container } = openDrawerWithLogin();
|
|
134
|
+
|
|
135
|
+
expect(isModalOpen('auth-drawer')).toBe(true);
|
|
136
|
+
|
|
137
|
+
submit(container);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => expect(isModalOpen('auth-drawer')).toBe(false));
|
|
140
|
+
// And the surface is gone with it, not merely marked closed.
|
|
141
|
+
expect(document.querySelector('[data-theme-modal]')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('stays on the page instead of pushing the shopper to the homepage', async () => {
|
|
145
|
+
const { container } = openDrawerWithLogin();
|
|
146
|
+
|
|
147
|
+
submit(container);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
|
150
|
+
// A sign-in inside a drawer is a detour from the page the shopper is on,
|
|
151
|
+
// so `stay` is the default THERE — the refresh is the whole navigation.
|
|
152
|
+
expect(push).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('still obeys a composition that asked to be sent somewhere', async () => {
|
|
156
|
+
const { container } = openDrawerWithLogin({
|
|
157
|
+
afterSignIn: 'fixed',
|
|
158
|
+
callbackUrl: '/account'
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
submit(container);
|
|
162
|
+
|
|
163
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
|
|
164
|
+
// Authored destination or not, the drawer it finished in still closes.
|
|
165
|
+
expect(isModalOpen('auth-drawer')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('leaves other modals alone unless asked to close everything', async () => {
|
|
169
|
+
const { container } = openDrawerWithLogin();
|
|
170
|
+
|
|
171
|
+
act(() => {
|
|
172
|
+
openModal('other');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
submit(container);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(isModalOpen('auth-drawer')).toBe(false));
|
|
178
|
+
// Closing the surface a form finished in is never a surprise; closing an
|
|
179
|
+
// unrelated overlay would be.
|
|
180
|
+
expect(isModalOpen('other')).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('closes every open modal when the composition authored that', async () => {
|
|
184
|
+
const { container } = openDrawerWithLogin({ closeModalsOnSuccess: true });
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
openModal('other');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
submit(container);
|
|
191
|
+
|
|
192
|
+
// The wider meaning the toggle has always had, for a form nested two
|
|
193
|
+
// overlays deep.
|
|
194
|
+
await waitFor(() => expect(getOpenModalIds()).toHaveLength(0));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('a login on a plain page', () => {
|
|
199
|
+
it('closes nothing, and keeps its historical destination', async () => {
|
|
200
|
+
const { container } = render(loginForm());
|
|
201
|
+
|
|
202
|
+
act(() => {
|
|
203
|
+
openModal('other');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
submit(container);
|
|
207
|
+
|
|
208
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
|
|
209
|
+
// No host to close, so a form on a page cannot reach an overlay it knows
|
|
210
|
+
// nothing about — which is why the toggle defaults to off.
|
|
211
|
+
expect(isModalOpen('other')).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The destination, decided on the VALUE rather than on a default.
|
|
217
|
+
*
|
|
218
|
+
* A host-aware DEFAULT would have been dead on arrival: the editor writes every
|
|
219
|
+
* select's default into the block it inserts, so `afterSignIn` arrives as
|
|
220
|
+
* `'fixed'` on every newly authored form and a host-aware default would only
|
|
221
|
+
* ever apply to compositions that predate the property. Reading the resolved
|
|
222
|
+
* PATH instead survives that, and it keeps an authored destination working.
|
|
223
|
+
*/
|
|
224
|
+
describe('where a drawer sign-in leaves the shopper', () => {
|
|
225
|
+
it('goes nowhere when the destination names nowhere in particular', async () => {
|
|
226
|
+
// `/` is the shipped default that every inserted form carries.
|
|
227
|
+
const { container } = openDrawerWithLogin({ callbackUrl: '/' });
|
|
228
|
+
|
|
229
|
+
submit(container);
|
|
230
|
+
|
|
231
|
+
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
|
232
|
+
expect(push).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('still honours a destination the brand actually chose', async () => {
|
|
236
|
+
const { container } = openDrawerWithLogin({ callbackUrl: '/account' });
|
|
237
|
+
|
|
238
|
+
submit(container);
|
|
239
|
+
|
|
240
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('keeps pushing on a plain page, where `/` is a real destination', async () => {
|
|
244
|
+
const { container } = render(loginForm({ callbackUrl: '/' }));
|
|
245
|
+
|
|
246
|
+
submit(container);
|
|
247
|
+
|
|
248
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The same rule, for everything else that finishes inside a modal.
|
|
254
|
+
*
|
|
255
|
+
* "Only login/register/OTP" was the wrong scope: a newsletter popup, a
|
|
256
|
+
* quick-view's add-to-cart and a change-password form all finish inside a
|
|
257
|
+
* modal too. What differs is not the mechanism but the SUCCESS STATE — a
|
|
258
|
+
* sign-in leaves nothing to read, while "thanks for subscribing" is the point
|
|
259
|
+
* — so every one of them carries the same switch and only the auth forms
|
|
260
|
+
* default it on.
|
|
261
|
+
*/
|
|
262
|
+
describe('any block that finishes inside a modal', () => {
|
|
263
|
+
const newsletterInDrawer = (properties: Record<string, unknown>) => {
|
|
264
|
+
const view = render(
|
|
265
|
+
<ThemeModalMount modal={mkModal('newsletter-modal')}>
|
|
266
|
+
<NewsletterFormBlock
|
|
267
|
+
block={
|
|
268
|
+
block({
|
|
269
|
+
id: 'news-1',
|
|
270
|
+
type: 'newsletter-form',
|
|
271
|
+
label: 'Newsletter',
|
|
272
|
+
properties
|
|
273
|
+
}) as Block
|
|
274
|
+
}
|
|
275
|
+
placeholderId="placeholder-1"
|
|
276
|
+
sectionId="section-1"
|
|
277
|
+
currentBreakpoint="desktop"
|
|
278
|
+
/>
|
|
279
|
+
</ThemeModalMount>
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
act(() => {
|
|
283
|
+
openModal('newsletter-modal');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return view;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const subscribeWith = (container: HTMLElement) => {
|
|
290
|
+
fireEvent.change(container.querySelector('input[type="email"]')!, {
|
|
291
|
+
target: { value: 'ada@example.com' }
|
|
292
|
+
});
|
|
293
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
subscribe.mockClear();
|
|
298
|
+
clearAutoOpenRecord('newsletter-modal');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('keeps the popup open by default, so the thank-you can be read', async () => {
|
|
302
|
+
const { container } = newsletterInDrawer({});
|
|
303
|
+
|
|
304
|
+
subscribeWith(container);
|
|
305
|
+
|
|
306
|
+
await waitFor(() => expect(subscribe).toHaveBeenCalled());
|
|
307
|
+
expect(isModalOpen('newsletter-modal')).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('closes it when the designer asked for that', async () => {
|
|
311
|
+
const { container } = newsletterInDrawer({ closeHostModalOnSuccess: true });
|
|
312
|
+
|
|
313
|
+
subscribeWith(container);
|
|
314
|
+
|
|
315
|
+
await waitFor(() => expect(isModalOpen('newsletter-modal')).toBe(false));
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('the sign-in trio, with the switch turned off', () => {
|
|
320
|
+
it('leaves the drawer open — the default is a default, not a rule', async () => {
|
|
321
|
+
const { container } = openDrawerWithLogin({
|
|
322
|
+
closeHostModalOnSuccess: false
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
submit(container);
|
|
326
|
+
|
|
327
|
+
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
|
328
|
+
expect(isModalOpen('auth-drawer')).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('what must NOT close the surface', () => {
|
|
333
|
+
it('a refused sign-in leaves the drawer up, with the message in it', async () => {
|
|
334
|
+
signIn.mockImplementationOnce(() =>
|
|
335
|
+
Promise.resolve({
|
|
336
|
+
error: 'CredentialsSignin',
|
|
337
|
+
code: JSON.stringify([
|
|
338
|
+
{ type: 'non_field_errors', data: ['E-mail or password is wrong.'] }
|
|
339
|
+
])
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const { container } = openDrawerWithLogin();
|
|
344
|
+
|
|
345
|
+
submit(container);
|
|
346
|
+
|
|
347
|
+
await waitFor(() =>
|
|
348
|
+
expect(
|
|
349
|
+
container.textContent?.includes('E-mail or password is wrong.')
|
|
350
|
+
).toBe(true)
|
|
351
|
+
);
|
|
352
|
+
// Closing here would take the error away with the form that has to be
|
|
353
|
+
// corrected — the one case where an auto-close is plainly wrong.
|
|
354
|
+
expect(isModalOpen('auth-drawer')).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('closes the host even when a legacy composition asked for all modals', async () => {
|
|
358
|
+
const { container } = openDrawerWithLogin({ closeModalsOnSuccess: true });
|
|
359
|
+
|
|
360
|
+
submit(container);
|
|
361
|
+
|
|
362
|
+
// Both run: the wide close is a superset, so an already-published drawer
|
|
363
|
+
// is not stuck on it. (The narrow one then finds nothing to do.)
|
|
364
|
+
await waitFor(() => expect(getOpenModalIds()).toHaveLength(0));
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
|
|
4
4
|
import RegisterFormBlock from '../blocks/register-form-block';
|
|
5
|
+
import ThemeModalMount from '../overlays/theme-modal-mount';
|
|
6
|
+
import { readThemeModals } from '../overlays/modal-registry';
|
|
7
|
+
import {
|
|
8
|
+
__resetOverlayBusForTests,
|
|
9
|
+
isModalOpen,
|
|
10
|
+
openModal,
|
|
11
|
+
registerModalHost
|
|
12
|
+
} from '../overlays/overlay-bus';
|
|
13
|
+
import { __resetModalPreviewForTests } from '../overlays/use-modal-preview';
|
|
5
14
|
import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
|
|
6
15
|
import {
|
|
7
16
|
__resetAuthFlowBusForTests,
|
|
@@ -36,6 +45,8 @@ const push = jest.fn();
|
|
|
36
45
|
const refresh = jest.fn();
|
|
37
46
|
|
|
38
47
|
jest.mock('next/navigation', () => ({
|
|
48
|
+
usePathname: () => '/',
|
|
49
|
+
useSearchParams: () => new URLSearchParams(),
|
|
39
50
|
useRouter: () => ({ push, refresh, replace: jest.fn() })
|
|
40
51
|
}));
|
|
41
52
|
|
|
@@ -121,6 +132,8 @@ beforeEach(() => {
|
|
|
121
132
|
push.mockClear();
|
|
122
133
|
refresh.mockClear();
|
|
123
134
|
__resetAuthFlowBusForTests();
|
|
135
|
+
__resetOverlayBusForTests();
|
|
136
|
+
__resetModalPreviewForTests();
|
|
124
137
|
});
|
|
125
138
|
|
|
126
139
|
describe('a composed register form', () => {
|
|
@@ -158,7 +171,10 @@ describe('a composed register form', () => {
|
|
|
158
171
|
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
159
172
|
|
|
160
173
|
expect(submitted()).toMatchObject({
|
|
161
|
-
|
|
174
|
+
// `register`, not `registration`: the credentials provider resolves the
|
|
175
|
+
// endpoint as `user[formType]` and that map has only `register`, so the
|
|
176
|
+
// old value POSTed to `${commerceUrl}undefined`.
|
|
177
|
+
formType: 'register',
|
|
162
178
|
first_name: 'Ada',
|
|
163
179
|
last_name: 'Lovelace',
|
|
164
180
|
email: 'ada@example.com',
|
|
@@ -337,7 +353,7 @@ describe('the SMS handoff from a composed registration', () => {
|
|
|
337
353
|
// the sign-up used — password included, which is why it never outlives the
|
|
338
354
|
// journey (see `registerAuthFlowMount`).
|
|
339
355
|
expect(pending.payload).toMatchObject({
|
|
340
|
-
formType: '
|
|
356
|
+
formType: 'register',
|
|
341
357
|
email: 'ada@example.com',
|
|
342
358
|
is_allowed: true
|
|
343
359
|
});
|
|
@@ -621,7 +637,7 @@ describe('the body a composition posts', () => {
|
|
|
621
637
|
fireEvent.submit(container.querySelector('form')!);
|
|
622
638
|
|
|
623
639
|
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
624
|
-
expect(submitted()).toMatchObject({ formType: '
|
|
640
|
+
expect(submitted()).toMatchObject({ formType: 'register' });
|
|
625
641
|
// False, not the string: a truthy `redirect` navigates away instead of
|
|
626
642
|
// answering, and the form would never see its own errors again.
|
|
627
643
|
expect(submitted().redirect).toBe(false);
|
|
@@ -737,3 +753,236 @@ describe('a field the design has taken off this breakpoint', () => {
|
|
|
737
753
|
expect(submitted()).not.toHaveProperty('phone');
|
|
738
754
|
});
|
|
739
755
|
});
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The four marketing channels commerce records separately.
|
|
759
|
+
*
|
|
760
|
+
* They used to be fanned out from the single marketing tick, which turned a
|
|
761
|
+
* composition that asks per channel into a lie: a shopper who ticked e-mail and
|
|
762
|
+
* left SMS alone was recorded as refusing all four. A consent record is a legal
|
|
763
|
+
* record, so what travels is what the shopper actually answered.
|
|
764
|
+
*/
|
|
765
|
+
describe('per-channel marketing permissions', () => {
|
|
766
|
+
const channel = (name: string, order: number): Block =>
|
|
767
|
+
block({
|
|
768
|
+
id: `consent-${name}`,
|
|
769
|
+
type: 'auth-consent',
|
|
770
|
+
label: name,
|
|
771
|
+
properties: { consentType: 'custom', name, tickedValue: 'true' },
|
|
772
|
+
order
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('sends each channel its own answer when the composition asks per channel', async () => {
|
|
776
|
+
const { container } = renderComposed([
|
|
777
|
+
field('email', 0),
|
|
778
|
+
field('password', 1),
|
|
779
|
+
consent('legal', 2),
|
|
780
|
+
channel('email_allowed', 3),
|
|
781
|
+
channel('sms_allowed', 4)
|
|
782
|
+
]);
|
|
783
|
+
|
|
784
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
785
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
786
|
+
tick(container, 'email_allowed');
|
|
787
|
+
|
|
788
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
789
|
+
|
|
790
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
791
|
+
|
|
792
|
+
const body = submitted();
|
|
793
|
+
|
|
794
|
+
expect(body.email_allowed).toBe(true);
|
|
795
|
+
// The refusal the shopper actually gave, not the one the blanket tick
|
|
796
|
+
// would have written over it.
|
|
797
|
+
expect(body.sms_allowed).toBe(false);
|
|
798
|
+
// Channels nobody asked about keep falling back to the blanket answer,
|
|
799
|
+
// which is what a fixed form and every published composition send.
|
|
800
|
+
expect(body.call_allowed).toBe(false);
|
|
801
|
+
expect(body.whatsapp_allowed).toBe(false);
|
|
802
|
+
// A flag re-expressed as a boolean must not also travel as `'true'`.
|
|
803
|
+
expect(body.email_allowed).not.toBe('true');
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('keeps the blanket tick for a composition that asks once', async () => {
|
|
807
|
+
const { container } = renderComposed(COMPOSED);
|
|
808
|
+
|
|
809
|
+
fill(container, {
|
|
810
|
+
first_name: 'Ada',
|
|
811
|
+
last_name: 'Lovelace',
|
|
812
|
+
email: 'ada@example.com',
|
|
813
|
+
password: 'hunter2',
|
|
814
|
+
phone: '5551234567'
|
|
815
|
+
});
|
|
816
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
817
|
+
tick(container, AUTH_CONSENT_NAMES.marketing);
|
|
818
|
+
|
|
819
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
820
|
+
|
|
821
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
822
|
+
|
|
823
|
+
expect(submitted()).toMatchObject({
|
|
824
|
+
email_allowed: true,
|
|
825
|
+
sms_allowed: true,
|
|
826
|
+
call_allowed: true,
|
|
827
|
+
whatsapp_allowed: true
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('lets a composition answer `confirm` on its own', async () => {
|
|
832
|
+
const { container } = renderComposed([
|
|
833
|
+
field('email', 0),
|
|
834
|
+
field('password', 1),
|
|
835
|
+
consent('legal', 2),
|
|
836
|
+
channel('confirm', 3)
|
|
837
|
+
]);
|
|
838
|
+
|
|
839
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
840
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
841
|
+
|
|
842
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
843
|
+
|
|
844
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
845
|
+
|
|
846
|
+
// The agreement was accepted; the separate confirmation was not, and that
|
|
847
|
+
// is the answer commerce is told — rather than a copy of `is_allowed`.
|
|
848
|
+
expect(submitted()).toMatchObject({ is_allowed: true, confirm: false });
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* A refusal that names a field belongs under that field.
|
|
854
|
+
*
|
|
855
|
+
* "This e-mail is already registered" used to reach the shopper as the raw
|
|
856
|
+
* `[{"type":"field_errors",…}]` above the form, because the reader kept a
|
|
857
|
+
* message only when it was a string and commerce sends `string[]`.
|
|
858
|
+
*/
|
|
859
|
+
describe('a refusal commerce hangs on a field', () => {
|
|
860
|
+
const refuse = (entries: { name: string; value: unknown }[]) =>
|
|
861
|
+
signIn.mockImplementationOnce(() =>
|
|
862
|
+
Promise.resolve({
|
|
863
|
+
error: 'CredentialsSignin',
|
|
864
|
+
code: JSON.stringify([{ type: 'field_errors', data: entries }])
|
|
865
|
+
})
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const fillValid = (container: HTMLElement) => {
|
|
869
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
870
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
871
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
it('reads under the field, and leaves the form-level slot empty', async () => {
|
|
875
|
+
refuse([{ name: 'email', value: ['A user with this e-mail exists.'] }]);
|
|
876
|
+
|
|
877
|
+
const { container } = renderComposed([
|
|
878
|
+
field('email', 0),
|
|
879
|
+
field('password', 1),
|
|
880
|
+
consent('legal', 2)
|
|
881
|
+
]);
|
|
882
|
+
|
|
883
|
+
fillValid(container);
|
|
884
|
+
|
|
885
|
+
await waitFor(() =>
|
|
886
|
+
expect(
|
|
887
|
+
screen.getByText('A user with this e-mail exists.')
|
|
888
|
+
).toBeInTheDocument()
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const emailInput = container.querySelector(
|
|
892
|
+
'input[name="email"]'
|
|
893
|
+
) as HTMLInputElement;
|
|
894
|
+
|
|
895
|
+
expect(emailInput.getAttribute('aria-invalid')).toBe('true');
|
|
896
|
+
// The authored fallback is for a refusal nobody could read, not for one
|
|
897
|
+
// whose every sentence already found a field.
|
|
898
|
+
expect(screen.queryByText('Registration failed.')).toBeNull();
|
|
899
|
+
// And the caret goes where the work is, as it does after client validation.
|
|
900
|
+
expect(document.activeElement).toBe(emailInput);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('lifts a message for a field this composition never authored', async () => {
|
|
904
|
+
refuse([{ name: 'tckn', value: ['This field is required.'] }]);
|
|
905
|
+
|
|
906
|
+
const { container } = renderComposed([
|
|
907
|
+
field('email', 0),
|
|
908
|
+
field('password', 1),
|
|
909
|
+
consent('legal', 2)
|
|
910
|
+
]);
|
|
911
|
+
|
|
912
|
+
fillValid(container);
|
|
913
|
+
|
|
914
|
+
// Not silently dropped into a provider nothing reads: the shopper is told,
|
|
915
|
+
// in the one slot that always exists.
|
|
916
|
+
await waitFor(() =>
|
|
917
|
+
expect(
|
|
918
|
+
screen.getByText('This field is required.')
|
|
919
|
+
).toBeInTheDocument()
|
|
920
|
+
);
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* A sign-up that commerce escalates to SMS must NOT close the drawer.
|
|
926
|
+
*
|
|
927
|
+
* The parked payload is what an `otp-form` finishes the registration with, so
|
|
928
|
+
* closing the surface would strand it: the shopper never reaches the code
|
|
929
|
+
* screen and the account is never created. Safe today by one `return` before
|
|
930
|
+
* the close, which is exactly the kind of ordering that a later edit breaks
|
|
931
|
+
* silently — hence this case.
|
|
932
|
+
*/
|
|
933
|
+
describe('an SMS escalation inside a drawer', () => {
|
|
934
|
+
it('moves to the verification step and leaves the drawer open', async () => {
|
|
935
|
+
signIn.mockImplementationOnce(() =>
|
|
936
|
+
Promise.resolve({
|
|
937
|
+
error: 'CredentialsSignin',
|
|
938
|
+
code: JSON.stringify([{ type: 'otp' }])
|
|
939
|
+
})
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
const modal = readThemeModals([
|
|
943
|
+
{ id: 'auth-drawer', name: 'Auth', presentation: 'drawer' }
|
|
944
|
+
])[0];
|
|
945
|
+
|
|
946
|
+
registerModalHost();
|
|
947
|
+
|
|
948
|
+
const { container } = render(
|
|
949
|
+
<ThemeModalMount modal={modal}>
|
|
950
|
+
<RegisterFormBlock
|
|
951
|
+
block={
|
|
952
|
+
block({
|
|
953
|
+
id: 'register-1',
|
|
954
|
+
type: 'register-form',
|
|
955
|
+
label: 'Register Form',
|
|
956
|
+
properties: { otpStepTarget: 'auth:verify' },
|
|
957
|
+
blocks: COMPOSED
|
|
958
|
+
}) as Block
|
|
959
|
+
}
|
|
960
|
+
placeholderId="placeholder-1"
|
|
961
|
+
sectionId="section-1"
|
|
962
|
+
currentBreakpoint="desktop"
|
|
963
|
+
/>
|
|
964
|
+
</ThemeModalMount>
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
act(() => {
|
|
968
|
+
openModal('auth-drawer');
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
fill(container, {
|
|
972
|
+
first_name: 'Ada',
|
|
973
|
+
last_name: 'Lovelace',
|
|
974
|
+
email: 'ada@example.com',
|
|
975
|
+
password: 'hunter2',
|
|
976
|
+
phone: '5551234567'
|
|
977
|
+
});
|
|
978
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
979
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
980
|
+
|
|
981
|
+
await waitFor(() =>
|
|
982
|
+
expect(getAuthFlowState('auth')?.active).toBe('verify')
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
expect(isModalOpen('auth-drawer')).toBe(true);
|
|
986
|
+
expect(getPendingOtp()?.kind).toBe('register');
|
|
987
|
+
});
|
|
988
|
+
});
|