@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-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 +33 -15
- package/package.json +4 -4
- package/src/__tests__/auth-commerce-events.test.tsx +1 -5
- package/src/__tests__/auth-form-error-block.test.tsx +0 -2
- package/src/__tests__/auth-form-validation.test.tsx +0 -2
- package/src/__tests__/auth-slot-coverage.test.ts +1 -9
- package/src/__tests__/auth-step-title.test.tsx +0 -2
- package/src/__tests__/detached-submit-state.test.tsx +2 -80
- package/src/__tests__/forgot-password-composed.test.tsx +0 -2
- package/src/__tests__/otp-form-composed.test.tsx +0 -2
- package/src/__tests__/password-reset-step.test.tsx +0 -2
- package/src/__tests__/register-form-composed.test.tsx +4 -253
- package/src/__tests__/registry-parity.test.ts +0 -12
- package/src/auth/auth-field-errors.tsx +0 -31
- package/src/blocks/account-button-block.tsx +0 -25
- package/src/blocks/add-to-cart-block.tsx +0 -15
- package/src/blocks/auth-field-block.tsx +3 -13
- package/src/blocks/auth-flow-block.tsx +3 -45
- package/src/blocks/auth-form-block.tsx +54 -337
- package/src/blocks/block-renderer-registry.tsx +0 -8
- package/src/blocks/button-block.tsx +13 -43
- package/src/blocks/change-password-form-block.tsx +0 -15
- package/src/blocks/forgot-password-block.tsx +9 -132
- package/src/blocks/newsletter-form-block.tsx +0 -16
- package/src/blocks/otp-form-block.tsx +67 -161
- package/src/blocks/profile-form-block.tsx +0 -13
- package/src/blocks/register-form-block.tsx +110 -362
- package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
- package/src/chrome/placeholder-slugs.ts +0 -3
- package/src/chrome/route-slug.ts +0 -11
- package/src/index.ts +0 -10
- package/src/overlays/__tests__/modal-registry.test.ts +1 -5
- package/src/overlays/modal-chrome-css.ts +1 -8
- package/src/overlays/modal-preview-host.tsx +2 -7
- package/src/overlays/modal-registry.ts +1 -38
- package/src/overlays/overlay-surface.tsx +0 -23
- package/src/overlays/overlay-tokens.ts +0 -12
- package/src/overlays/theme-modal-mount.tsx +6 -85
- package/src/utils/visibility-rules.ts +0 -114
- package/src/__tests__/auth-captcha.test.tsx +0 -261
- package/src/__tests__/auth-destination.test.tsx +0 -348
- package/src/__tests__/auth-error-reading.test.ts +0 -232
- package/src/__tests__/auth-flow-scope.test.tsx +0 -162
- package/src/__tests__/auth-form-success-block.test.tsx +0 -429
- package/src/__tests__/block-action-audience.test.ts +0 -154
- package/src/__tests__/form-state-composition.test.tsx +0 -484
- package/src/__tests__/modal-login-close.test.tsx +0 -366
- package/src/__tests__/reset-password-composed.test.tsx +0 -385
- package/src/auth/auth-captcha.tsx +0 -125
- package/src/auth/auth-destination.ts +0 -168
- package/src/auth/form-state-context.tsx +0 -170
- package/src/auth/form-success-context.tsx +0 -56
- package/src/auth/read-auth-error.ts +0 -332
- package/src/blocks/auth-success-block.tsx +0 -85
- package/src/blocks/captcha-block.tsx +0 -61
- package/src/blocks/form-state-block.tsx +0 -84
- package/src/blocks/reset-password-block.tsx +0 -717
- package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
- package/src/overlays/modal-host-context.tsx +0 -66
- package/src/overlays/overlay-loading.tsx +0 -144
package/CHANGELOG.md
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.95-
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- Updated dependencies [
|
|
16
|
-
|
|
17
|
-
|
|
3
|
+
## 2.0.95-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 [b1111d7b]
|
|
34
|
+
- @akinon/next@2.0.95-rc.0
|
|
35
|
+
- @akinon/theme-contract@2.0.95-rc.0
|
|
18
36
|
|
|
19
37
|
## 2.0.94
|
|
20
38
|
|
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.95-
|
|
4
|
+
"version": "2.0.95-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.95-
|
|
11
|
+
"@akinon/next": "2.0.95-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.95-
|
|
16
|
+
"@akinon/theme-contract": "2.0.95-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.95-
|
|
24
|
+
"@akinon/next": "2.0.95-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",
|
|
@@ -32,11 +32,7 @@ jest.mock('next/navigation', () => ({
|
|
|
32
32
|
refresh: jest.fn(),
|
|
33
33
|
replace: jest.fn()
|
|
34
34
|
}),
|
|
35
|
-
usePathname: () => '/account/orders'
|
|
36
|
-
// The login form reads the query too, since where a sign-in leaves the
|
|
37
|
-
// shopper can come from `?callbackUrl`/`?next` — a double without this
|
|
38
|
-
// throws before a single event can be measured.
|
|
39
|
-
useSearchParams: () => new URLSearchParams()
|
|
35
|
+
usePathname: () => '/account/orders'
|
|
40
36
|
}));
|
|
41
37
|
|
|
42
38
|
jest.mock('@akinon/next/data/client/user', () => ({
|
|
@@ -23,12 +23,7 @@ const APP_DIR = path.resolve(
|
|
|
23
23
|
/** Placeholder key -> the file (relative to `[pz]`) mounting it. */
|
|
24
24
|
const EXPECTED_MOUNTS: Record<string, string> = {
|
|
25
25
|
auth: 'auth/(login)/layout.tsx',
|
|
26
|
-
forgotPassword: 'users/password/reset/layout.tsx'
|
|
27
|
-
// The screen the reset MAIL lands on, which is a different page from the one
|
|
28
|
-
// that asks for the mail. It was the last auth slug the editor offered and
|
|
29
|
-
// nothing mounted: a designer could compose the screen, publish it, and the
|
|
30
|
-
// shopper coming out of their inbox still met the hand-written page.
|
|
31
|
-
resetPassword: 'users/reset/[[...id]]/layout.tsx'
|
|
26
|
+
forgotPassword: 'users/password/reset/layout.tsx'
|
|
32
27
|
};
|
|
33
28
|
|
|
34
29
|
/**
|
|
@@ -57,9 +52,6 @@ const AUTH_PICKER_PATHS = [
|
|
|
57
52
|
'/register',
|
|
58
53
|
'/auth/forgot-password',
|
|
59
54
|
'/users/password/reset',
|
|
60
|
-
// The mail's own link, token and all — a catch-all route, so the path has
|
|
61
|
-
// segments the rule has to see past.
|
|
62
|
-
'/users/reset/tok/abc123',
|
|
63
55
|
'/auth/guest',
|
|
64
56
|
'/users/auth/guest'
|
|
65
57
|
];
|
|
@@ -59,8 +59,6 @@ jest.mock('@akinon/next/data/client/user', () => {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
jest.mock('next/navigation', () => ({
|
|
62
|
-
usePathname: () => '/',
|
|
63
|
-
useSearchParams: () => new URLSearchParams(),
|
|
64
62
|
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
65
63
|
}));
|
|
66
64
|
|
|
@@ -74,15 +72,10 @@ const blockOf = (type: string, properties: Record<string, unknown>): Block => ({
|
|
|
74
72
|
hidden: false
|
|
75
73
|
});
|
|
76
74
|
|
|
77
|
-
const renderCta = (
|
|
75
|
+
const renderCta = (submitFormId: string) =>
|
|
78
76
|
render(
|
|
79
77
|
<ButtonBlock
|
|
80
|
-
block={blockOf(
|
|
81
|
-
'button',
|
|
82
|
-
typeof properties === 'string'
|
|
83
|
-
? { text: 'Continue', submitFormId: properties }
|
|
84
|
-
: { text: 'Continue', ...properties }
|
|
85
|
-
)}
|
|
78
|
+
block={blockOf('button', { text: 'Continue', submitFormId })}
|
|
86
79
|
placeholderId="placeholder-1"
|
|
87
80
|
sectionId="section-1"
|
|
88
81
|
currentBreakpoint="desktop"
|
|
@@ -221,74 +214,3 @@ describe('a detached submit CTA', () => {
|
|
|
221
214
|
expect(forgotPassword).not.toHaveBeenCalled();
|
|
222
215
|
});
|
|
223
216
|
});
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* What the shopper SEES while the request is in flight.
|
|
227
|
-
*
|
|
228
|
-
* `disabled` alone changed nothing visible: a Button carries an authored
|
|
229
|
-
* background and colour, and those override what a browser paints on a
|
|
230
|
-
* disabled button — so a drawer's bottom-bar CTA stayed bright and
|
|
231
|
-
* clickable-looking for the whole POST, which is what makes someone press it
|
|
232
|
-
* twice. The state now travels as part state (so a designer can dress it) and
|
|
233
|
-
* carries a default dimming (so it reads as busy with nothing authored).
|
|
234
|
-
*/
|
|
235
|
-
describe('a detached CTA while its form is working', () => {
|
|
236
|
-
it('says so in its part state and dims itself', () => {
|
|
237
|
-
const { container } = renderCta({ submitFormId: 'login-form' });
|
|
238
|
-
|
|
239
|
-
const button = container.querySelector('button') as HTMLButtonElement;
|
|
240
|
-
|
|
241
|
-
expect(button.getAttribute('data-state')).toBeNull();
|
|
242
|
-
|
|
243
|
-
act(() => {
|
|
244
|
-
publishFormSubmitState('login-form', true);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
expect(button.getAttribute('data-state')).toContain('loading');
|
|
248
|
-
expect(button.getAttribute('data-state')).toContain('disabled');
|
|
249
|
-
expect(button.style.opacity).toBe('0.7');
|
|
250
|
-
expect(button.style.cursor).toBe('not-allowed');
|
|
251
|
-
expect(button).toBeDisabled();
|
|
252
|
-
|
|
253
|
-
act(() => {
|
|
254
|
-
publishFormSubmitState('login-form', false);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
expect(button.getAttribute('data-state')).toBeNull();
|
|
258
|
-
expect(button.style.opacity).not.toBe('0.7');
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('swaps its caption only when the designer authored one', () => {
|
|
262
|
-
const { container } = renderCta({
|
|
263
|
-
submitFormId: 'login-form',
|
|
264
|
-
text: 'Devam Et',
|
|
265
|
-
busyText: 'Gönderiliyor…'
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
act(() => {
|
|
269
|
-
publishFormSubmitState('login-form', true);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
expect(container.textContent).toContain('Gönderiliyor…');
|
|
273
|
-
|
|
274
|
-
act(() => {
|
|
275
|
-
publishFormSubmitState('login-form', false);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
expect(container.textContent).toContain('Devam Et');
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('keeps the caption put when no busy text was authored', () => {
|
|
282
|
-
const { container } = renderCta({
|
|
283
|
-
submitFormId: 'login-form',
|
|
284
|
-
text: 'Devam Et'
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
act(() => {
|
|
288
|
-
publishFormSubmitState('login-form', true);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// Absence-preserving, exactly like the forms' own `busyLabel`.
|
|
292
|
-
expect(container.textContent).toContain('Devam Et');
|
|
293
|
-
});
|
|
294
|
-
});
|
|
@@ -67,8 +67,6 @@ jest.mock('@akinon/next/data/client/user', () => {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
jest.mock('next/navigation', () => ({
|
|
70
|
-
usePathname: () => '/',
|
|
71
|
-
useSearchParams: () => new URLSearchParams(),
|
|
72
70
|
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
73
71
|
}));
|
|
74
72
|
|
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { 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';
|
|
14
5
|
import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
|
|
15
6
|
import {
|
|
16
7
|
__resetAuthFlowBusForTests,
|
|
@@ -45,8 +36,6 @@ const push = jest.fn();
|
|
|
45
36
|
const refresh = jest.fn();
|
|
46
37
|
|
|
47
38
|
jest.mock('next/navigation', () => ({
|
|
48
|
-
usePathname: () => '/',
|
|
49
|
-
useSearchParams: () => new URLSearchParams(),
|
|
50
39
|
useRouter: () => ({ push, refresh, replace: jest.fn() })
|
|
51
40
|
}));
|
|
52
41
|
|
|
@@ -132,8 +121,6 @@ beforeEach(() => {
|
|
|
132
121
|
push.mockClear();
|
|
133
122
|
refresh.mockClear();
|
|
134
123
|
__resetAuthFlowBusForTests();
|
|
135
|
-
__resetOverlayBusForTests();
|
|
136
|
-
__resetModalPreviewForTests();
|
|
137
124
|
});
|
|
138
125
|
|
|
139
126
|
describe('a composed register form', () => {
|
|
@@ -171,10 +158,7 @@ describe('a composed register form', () => {
|
|
|
171
158
|
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
172
159
|
|
|
173
160
|
expect(submitted()).toMatchObject({
|
|
174
|
-
|
|
175
|
-
// endpoint as `user[formType]` and that map has only `register`, so the
|
|
176
|
-
// old value POSTed to `${commerceUrl}undefined`.
|
|
177
|
-
formType: 'register',
|
|
161
|
+
formType: 'registration',
|
|
178
162
|
first_name: 'Ada',
|
|
179
163
|
last_name: 'Lovelace',
|
|
180
164
|
email: 'ada@example.com',
|
|
@@ -353,7 +337,7 @@ describe('the SMS handoff from a composed registration', () => {
|
|
|
353
337
|
// the sign-up used — password included, which is why it never outlives the
|
|
354
338
|
// journey (see `registerAuthFlowMount`).
|
|
355
339
|
expect(pending.payload).toMatchObject({
|
|
356
|
-
formType: '
|
|
340
|
+
formType: 'registration',
|
|
357
341
|
email: 'ada@example.com',
|
|
358
342
|
is_allowed: true
|
|
359
343
|
});
|
|
@@ -637,7 +621,7 @@ describe('the body a composition posts', () => {
|
|
|
637
621
|
fireEvent.submit(container.querySelector('form')!);
|
|
638
622
|
|
|
639
623
|
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
640
|
-
expect(submitted()).toMatchObject({ formType: '
|
|
624
|
+
expect(submitted()).toMatchObject({ formType: 'registration' });
|
|
641
625
|
// False, not the string: a truthy `redirect` navigates away instead of
|
|
642
626
|
// answering, and the form would never see its own errors again.
|
|
643
627
|
expect(submitted().redirect).toBe(false);
|
|
@@ -753,236 +737,3 @@ describe('a field the design has taken off this breakpoint', () => {
|
|
|
753
737
|
expect(submitted()).not.toHaveProperty('phone');
|
|
754
738
|
});
|
|
755
739
|
});
|
|
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
|
-
});
|
|
@@ -39,17 +39,9 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
39
39
|
'auth-form',
|
|
40
40
|
'auth-step',
|
|
41
41
|
'auth-step-title',
|
|
42
|
-
// The confirmation as a placeable block, the success twin of 'auth-error':
|
|
43
|
-
// the two forms that have a success state publish their message and this
|
|
44
|
-
// draws it where the design put it. See `auth/form-success-context.tsx`.
|
|
45
|
-
'auth-success',
|
|
46
42
|
'basket-items',
|
|
47
43
|
'basket-summary',
|
|
48
44
|
'button',
|
|
49
|
-
// The security check commerce escalates to, placed inside an auth form.
|
|
50
|
-
'captcha',
|
|
51
|
-
// The screen the password-reset mail lands on.
|
|
52
|
-
'reset-password',
|
|
53
45
|
'change-password-form',
|
|
54
46
|
'checkout-flow-outlet',
|
|
55
47
|
'checkout-steps',
|
|
@@ -68,10 +60,6 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
68
60
|
'favorite-button',
|
|
69
61
|
'favourites-grid',
|
|
70
62
|
'forgot-password',
|
|
71
|
-
// One screen of a composed form — the confirmation, the refusal, the busy
|
|
72
|
-
// moment — as a container the form draws when it is on that screen. See
|
|
73
|
-
// `auth/form-state-context.tsx`.
|
|
74
|
-
'form-state',
|
|
75
63
|
'group',
|
|
76
64
|
'hamburger-menu',
|
|
77
65
|
'hotspot',
|
|
@@ -132,37 +132,6 @@ export const collectAuthFieldErrors = (
|
|
|
132
132
|
return { errors, firstInvalid };
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
-
/**
|
|
136
|
-
* Put the caret where the work is after a SERVER refusal.
|
|
137
|
-
*
|
|
138
|
-
* The client-side collector already does this for what it finds itself
|
|
139
|
-
* (`firstInvalid`), and a refusal that arrives from commerce deserves the same
|
|
140
|
-
* treatment: a duplicate e-mail three fields up is a message the shopper has
|
|
141
|
-
* to scroll for otherwise. Document order rather than the order commerce
|
|
142
|
-
* listed its errors in, because the shopper reads the form, not the response.
|
|
143
|
-
*
|
|
144
|
-
* A control the design took off this breakpoint is skipped for the same reason
|
|
145
|
-
* the collector skips it: focus cannot land on something nobody can see.
|
|
146
|
-
*/
|
|
147
|
-
export const focusFirstAuthFieldError = (
|
|
148
|
-
form: HTMLFormElement | null | undefined,
|
|
149
|
-
errors: AuthFieldErrors
|
|
150
|
-
): void => {
|
|
151
|
-
if (!form || Object.keys(errors).length === 0) return;
|
|
152
|
-
|
|
153
|
-
const controls = Array.from(
|
|
154
|
-
form.querySelectorAll<FormControl>(
|
|
155
|
-
'input[name], select[name], textarea[name]'
|
|
156
|
-
)
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
const first = controls.find(
|
|
160
|
-
(control) => Boolean(errors[control.name]) && isVisibleControl(control)
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
first?.focus();
|
|
164
|
-
};
|
|
165
|
-
|
|
166
135
|
/**
|
|
167
136
|
* The same map with one field's message dropped — what a form does the moment
|
|
168
137
|
* that field is edited, so a message never outlives the mistake it describes.
|