@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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 +34 -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,429 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import ForgotPasswordBlock from '../blocks/forgot-password-block';
|
|
5
|
+
import ResetPasswordBlock from '../blocks/reset-password-block';
|
|
6
|
+
import { containsAuthSuccessBlock } from '../auth/form-success-context';
|
|
7
|
+
import { __resetAuthFlowBusForTests } from '../auth/auth-flow-bus';
|
|
8
|
+
import { Block } from '../theme-block';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where the CONFIRMATION lands.
|
|
12
|
+
*
|
|
13
|
+
* The error half of this was solved a while ago: the form publishes its message
|
|
14
|
+
* and a Form Error block draws it where the design says. The success half was
|
|
15
|
+
* still a node the form drew itself — and in these two blocks that node
|
|
16
|
+
* REPLACES the whole form, so a brand could recolour "a reset link is on its
|
|
17
|
+
* way" through a style part and nothing else: no icon beside it, no heading
|
|
18
|
+
* above it, no second line, no way to move it.
|
|
19
|
+
*
|
|
20
|
+
* These cases hold the three halves of the contract that fixes it: the block
|
|
21
|
+
* draws the message, the built-in copy stands down, and — the part that has no
|
|
22
|
+
* counterpart on the error side — the composition STAYS on screen, because a
|
|
23
|
+
* block inside a form that has been swapped out is unmounted and would draw
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
let forgotState = { isLoading: false, isSuccess: false };
|
|
28
|
+
const forgotPassword = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
|
|
29
|
+
|
|
30
|
+
const passwordReset = jest.fn();
|
|
31
|
+
const unwrap = jest.fn();
|
|
32
|
+
let resetState = { isLoading: false, isSuccess: false };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ONE mock for both forms, because `jest.config`'s moduleNameMapper collapses
|
|
36
|
+
* every `@akinon/next/data/client/*` onto the same file: a second `jest.mock`
|
|
37
|
+
* for `account` would replace this one rather than sit beside it, and whichever
|
|
38
|
+
* form lost would call an undefined hook.
|
|
39
|
+
*/
|
|
40
|
+
jest.mock('@akinon/next/data/client/user', () => {
|
|
41
|
+
const handler = {
|
|
42
|
+
get(_target: unknown, prop: string) {
|
|
43
|
+
if (prop === '__esModule') return true;
|
|
44
|
+
if (typeof prop !== 'string') return undefined;
|
|
45
|
+
if (prop === 'useForgotPasswordMutation') {
|
|
46
|
+
return () => [forgotPassword, forgotState];
|
|
47
|
+
}
|
|
48
|
+
if (prop === 'usePasswordResetMutation') {
|
|
49
|
+
return () => [
|
|
50
|
+
(...args: unknown[]) => {
|
|
51
|
+
passwordReset(...args);
|
|
52
|
+
|
|
53
|
+
return { unwrap };
|
|
54
|
+
},
|
|
55
|
+
resetState
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (prop === 'useGetValidatePasswordResetTokenQuery') {
|
|
59
|
+
return () => ({
|
|
60
|
+
data: { validlink: true },
|
|
61
|
+
isLoading: false,
|
|
62
|
+
isError: false
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (prop.endsWith('Mutation')) {
|
|
66
|
+
return () => [
|
|
67
|
+
jest.fn(() => ({ unwrap: () => Promise.resolve({}) })),
|
|
68
|
+
{ isLoading: false, isSuccess: false, isError: false }
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
if (prop.endsWith('Query')) {
|
|
72
|
+
return () => ({
|
|
73
|
+
data: undefined,
|
|
74
|
+
isLoading: false,
|
|
75
|
+
isSuccess: true,
|
|
76
|
+
refetch: jest.fn()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return () => undefined;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return new Proxy({}, handler);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
jest.mock('next/navigation', () => ({
|
|
87
|
+
useParams: () => ({ id: ['tok', 'abc123'] }),
|
|
88
|
+
usePathname: () => '/users/reset/tok/abc123',
|
|
89
|
+
useSearchParams: () => new URLSearchParams(),
|
|
90
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
94
|
+
({
|
|
95
|
+
id: 'blk',
|
|
96
|
+
type: 'text',
|
|
97
|
+
label: 'Block',
|
|
98
|
+
value: '',
|
|
99
|
+
order: 0,
|
|
100
|
+
hidden: false,
|
|
101
|
+
properties: {},
|
|
102
|
+
styles: {},
|
|
103
|
+
...overrides
|
|
104
|
+
}) as Block;
|
|
105
|
+
|
|
106
|
+
const successBlock = (
|
|
107
|
+
properties: Record<string, unknown> = {},
|
|
108
|
+
overrides: Partial<Block> = {}
|
|
109
|
+
) =>
|
|
110
|
+
block({
|
|
111
|
+
id: 'success-row',
|
|
112
|
+
type: 'auth-success',
|
|
113
|
+
label: 'Form Success',
|
|
114
|
+
properties,
|
|
115
|
+
order: 1,
|
|
116
|
+
...overrides
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const emailField = block({
|
|
120
|
+
id: 'field-email',
|
|
121
|
+
type: 'auth-field',
|
|
122
|
+
label: 'E-mail Field',
|
|
123
|
+
properties: { fieldType: 'email', label: 'E-mail' },
|
|
124
|
+
order: 0
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const cta = block({
|
|
128
|
+
id: 'cta',
|
|
129
|
+
type: 'button',
|
|
130
|
+
label: 'Submit Button',
|
|
131
|
+
properties: { text: 'Send reset link', tag: 'button', type: 'submit' },
|
|
132
|
+
order: 2
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const renderForgot = (
|
|
136
|
+
children: Block[],
|
|
137
|
+
properties: Record<string, unknown> = {},
|
|
138
|
+
isDesigner = false
|
|
139
|
+
) =>
|
|
140
|
+
render(
|
|
141
|
+
<ForgotPasswordBlock
|
|
142
|
+
block={
|
|
143
|
+
block({
|
|
144
|
+
id: 'forgot-1',
|
|
145
|
+
type: 'forgot-password',
|
|
146
|
+
label: 'Forgot Password',
|
|
147
|
+
properties: { showSubmit: false, ...properties },
|
|
148
|
+
blocks: children
|
|
149
|
+
}) as Block
|
|
150
|
+
}
|
|
151
|
+
placeholderId="placeholder-1"
|
|
152
|
+
sectionId="section-1"
|
|
153
|
+
currentBreakpoint="desktop"
|
|
154
|
+
isDesigner={isDesigner}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const passwordFields = [
|
|
159
|
+
block({
|
|
160
|
+
id: 'field-password',
|
|
161
|
+
type: 'auth-field',
|
|
162
|
+
label: 'Password',
|
|
163
|
+
properties: { fieldType: 'password', label: 'New password' },
|
|
164
|
+
order: 0
|
|
165
|
+
}),
|
|
166
|
+
block({
|
|
167
|
+
id: 'field-repeat',
|
|
168
|
+
type: 'auth-field',
|
|
169
|
+
label: 'Repeat',
|
|
170
|
+
properties: {
|
|
171
|
+
fieldType: 'custom',
|
|
172
|
+
name: 'repeatPassword',
|
|
173
|
+
inputType: 'password',
|
|
174
|
+
label: 'New password again'
|
|
175
|
+
},
|
|
176
|
+
order: 1
|
|
177
|
+
})
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const renderReset = (
|
|
181
|
+
children: Block[],
|
|
182
|
+
properties: Record<string, unknown> = {}
|
|
183
|
+
) =>
|
|
184
|
+
render(
|
|
185
|
+
<ResetPasswordBlock
|
|
186
|
+
block={
|
|
187
|
+
block({
|
|
188
|
+
id: 'reset-1',
|
|
189
|
+
type: 'reset-password',
|
|
190
|
+
label: 'Reset Password',
|
|
191
|
+
properties,
|
|
192
|
+
blocks: children
|
|
193
|
+
}) as Block
|
|
194
|
+
}
|
|
195
|
+
placeholderId="placeholder-1"
|
|
196
|
+
sectionId="section-1"
|
|
197
|
+
currentBreakpoint="desktop"
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
__resetAuthFlowBusForTests();
|
|
203
|
+
forgotState = { isLoading: false, isSuccess: false };
|
|
204
|
+
resetState = { isLoading: false, isSuccess: false };
|
|
205
|
+
forgotPassword.mockClear();
|
|
206
|
+
passwordReset.mockClear();
|
|
207
|
+
unwrap.mockReset();
|
|
208
|
+
unwrap.mockResolvedValue(undefined);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('finding the block that draws the confirmation', () => {
|
|
212
|
+
it('finds one placed directly in the form', () => {
|
|
213
|
+
expect(containsAuthSuccessBlock([emailField, successBlock()])).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('finds one tucked inside a group, however deep', () => {
|
|
217
|
+
expect(
|
|
218
|
+
containsAuthSuccessBlock([
|
|
219
|
+
block({
|
|
220
|
+
id: 'group',
|
|
221
|
+
type: 'group',
|
|
222
|
+
blocks: [block({ id: 'inner', type: 'group', blocks: [successBlock()] })]
|
|
223
|
+
})
|
|
224
|
+
])
|
|
225
|
+
).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('ignores a hidden one, which draws nothing wherever it sits', () => {
|
|
229
|
+
expect(
|
|
230
|
+
containsAuthSuccessBlock([successBlock({}, { hidden: true })])
|
|
231
|
+
).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('says no for a composition with none, and for none at all', () => {
|
|
235
|
+
expect(containsAuthSuccessBlock([emailField])).toBe(false);
|
|
236
|
+
expect(containsAuthSuccessBlock(undefined)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('a password-reset request that draws its own confirmation', () => {
|
|
241
|
+
it('shows the message in the block, and keeps the form it was placed in', () => {
|
|
242
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
243
|
+
|
|
244
|
+
const { container } = renderForgot(
|
|
245
|
+
[emailField, successBlock(), cta],
|
|
246
|
+
{ successLabel: 'Bir bağlantı yolda.' }
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
|
|
250
|
+
// Inside the block the design placed — not in a node appended by the form.
|
|
251
|
+
expect(
|
|
252
|
+
container.querySelector('[data-auth-success]')?.textContent
|
|
253
|
+
).toContain('Bir bağlantı yolda.');
|
|
254
|
+
// And the composition is still standing, which is what makes the block
|
|
255
|
+
// reachable at all: replaced, it would have been unmounted.
|
|
256
|
+
expect(container.querySelector('form')).not.toBeNull();
|
|
257
|
+
expect(container.querySelector('input[name="email"]')).not.toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('draws the message where the design put it, above the CTA', () => {
|
|
261
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
262
|
+
|
|
263
|
+
const { container } = renderForgot([emailField, successBlock(), cta], {
|
|
264
|
+
successLabel: 'Bir bağlantı yolda.'
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const message = container.querySelector('[data-auth-success]')!;
|
|
268
|
+
const button = screen.getByText('Send reset link');
|
|
269
|
+
|
|
270
|
+
expect(
|
|
271
|
+
message.compareDocumentPosition(button) &
|
|
272
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
273
|
+
).toBeTruthy();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('marks the form as finished so the state can be dressed', () => {
|
|
277
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
278
|
+
|
|
279
|
+
const { container } = renderForgot([emailField, successBlock(), cta]);
|
|
280
|
+
|
|
281
|
+
expect(
|
|
282
|
+
container
|
|
283
|
+
.querySelector('[data-forgot-password-composed]')
|
|
284
|
+
?.getAttribute('data-forgot-password-state')
|
|
285
|
+
).toBe('success');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps its send button, because pressing it again is a resend', () => {
|
|
289
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
290
|
+
|
|
291
|
+
const { container } = renderForgot([emailField, successBlock()], {
|
|
292
|
+
showSubmit: true
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Unlike the reset form below: the address is still valid, so a second
|
|
296
|
+
// submit is the "didn't arrive?" affordance rather than a guaranteed error.
|
|
297
|
+
expect(
|
|
298
|
+
container.querySelector('[data-forgot-password-submit]')
|
|
299
|
+
).not.toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('draws nothing at all before the mail is away', () => {
|
|
303
|
+
const { container } = renderForgot([emailField, successBlock(), cta], {
|
|
304
|
+
successLabel: 'Bir bağlantı yolda.'
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(container.querySelector('[data-auth-success]')).toBeNull();
|
|
308
|
+
expect(screen.queryByText('Bir bağlantı yolda.')).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('still requests the reset the way it always did', async () => {
|
|
312
|
+
const { container } = renderForgot([emailField, successBlock(), cta]);
|
|
313
|
+
|
|
314
|
+
fireEvent.change(container.querySelector('input[name="email"]')!, {
|
|
315
|
+
target: { value: 'ada@example.com' }
|
|
316
|
+
});
|
|
317
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
318
|
+
|
|
319
|
+
await waitFor(() => expect(forgotPassword).toHaveBeenCalled());
|
|
320
|
+
expect(forgotPassword).toHaveBeenCalledWith({ email: 'ada@example.com' });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('a composition that placed no such block', () => {
|
|
325
|
+
it('is replaced by the built-in copy, exactly as before', () => {
|
|
326
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
327
|
+
|
|
328
|
+
const { container } = renderForgot([emailField, cta], {
|
|
329
|
+
successLabel: 'Bir bağlantı yolda.'
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
|
|
333
|
+
// The fence: nothing about this change may alter a published composition.
|
|
334
|
+
expect(container.querySelector('form')).toBeNull();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('counts a HIDDEN block as none, so the shopper is still told', () => {
|
|
338
|
+
forgotState = { isLoading: false, isSuccess: true };
|
|
339
|
+
|
|
340
|
+
const { container } = renderForgot(
|
|
341
|
+
[emailField, successBlock({}, { hidden: true }), cta],
|
|
342
|
+
{ successLabel: 'Bir bağlantı yolda.' }
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(screen.getByText('Bir bağlantı yolda.')).toBeInTheDocument();
|
|
346
|
+
expect(container.querySelector('form')).toBeNull();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('the canvas', () => {
|
|
351
|
+
it('shows the real copy through the block on the preview toggle', () => {
|
|
352
|
+
const { container } = renderForgot(
|
|
353
|
+
[emailField, successBlock(), cta],
|
|
354
|
+
{ successLabel: 'Bir bağlantı yolda.', previewSuccess: true },
|
|
355
|
+
true
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// The state a designer is dressing, in the place they put it, with the
|
|
359
|
+
// form still around it — which is what they are dressing it against.
|
|
360
|
+
expect(
|
|
361
|
+
container.querySelector('[data-auth-success]')?.textContent
|
|
362
|
+
).toContain('Bir bağlantı yolda.');
|
|
363
|
+
expect(container.querySelector('form')).not.toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('shows the block preview copy while the form is in its normal state', () => {
|
|
367
|
+
renderForgot(
|
|
368
|
+
[emailField, successBlock({ previewMessage: 'Kutunuzu kontrol edin.' }), cta],
|
|
369
|
+
{},
|
|
370
|
+
true
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// Selectable and dressable without reaching the state for real, and
|
|
374
|
+
// announced to nobody: the confirmation has not happened.
|
|
375
|
+
const message = document.querySelector('[data-auth-success]')!;
|
|
376
|
+
|
|
377
|
+
expect(message.textContent).toContain('Kutunuzu kontrol edin.');
|
|
378
|
+
expect(message.getAttribute('role')).toBeNull();
|
|
379
|
+
expect(message.getAttribute('data-auth-success-state')).toBe('preview');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('the screen the mail lands on', () => {
|
|
384
|
+
it('keeps its composition and shows the message in the block', () => {
|
|
385
|
+
resetState = { isLoading: false, isSuccess: true };
|
|
386
|
+
|
|
387
|
+
const { container } = renderReset([...passwordFields, successBlock()], {
|
|
388
|
+
successLabel: 'Şifreniz değiştirildi.'
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(
|
|
392
|
+
container.querySelector('[data-auth-success]')?.textContent
|
|
393
|
+
).toContain('Şifreniz değiştirildi.');
|
|
394
|
+
expect(container.querySelector('form')).not.toBeNull();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('drops the submit, because the token is spent', () => {
|
|
398
|
+
resetState = { isLoading: false, isSuccess: true };
|
|
399
|
+
|
|
400
|
+
const { container } = renderReset([...passwordFields, successBlock()]);
|
|
401
|
+
|
|
402
|
+
expect(container.querySelector('[data-reset-password-submit]')).toBeNull();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('refuses a second submit, which is all a detached CTA could reach', () => {
|
|
406
|
+
resetState = { isLoading: false, isSuccess: true };
|
|
407
|
+
|
|
408
|
+
const { container } = renderReset([...passwordFields, successBlock()], {
|
|
409
|
+
formHtmlId: 'reset-form'
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
413
|
+
|
|
414
|
+
// A Button carrying the form's id sits outside this subtree, so dropping
|
|
415
|
+
// the built-in button cannot stop it — the handler has to.
|
|
416
|
+
expect(passwordReset).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('is still replaced outright where no block was placed', () => {
|
|
420
|
+
resetState = { isLoading: false, isSuccess: true };
|
|
421
|
+
|
|
422
|
+
const { container } = renderReset(passwordFields, {
|
|
423
|
+
successLabel: 'Şifreniz değiştirildi.'
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(screen.getByText('Şifreniz değiştirildi.')).toBeInTheDocument();
|
|
427
|
+
expect(container.querySelector('form')).toBeNull();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -23,7 +23,12 @@ 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'
|
|
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'
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -52,6 +57,9 @@ const AUTH_PICKER_PATHS = [
|
|
|
52
57
|
'/register',
|
|
53
58
|
'/auth/forgot-password',
|
|
54
59
|
'/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',
|
|
55
63
|
'/auth/guest',
|
|
56
64
|
'/users/auth/guest'
|
|
57
65
|
];
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BLOCK_ACTION_AUDIENCES,
|
|
3
|
+
applyVisibilityRulesToSections
|
|
4
|
+
} from '../utils/visibility-rules';
|
|
5
|
+
import { Block } from '../theme-block';
|
|
6
|
+
import { Section } from '../theme-section';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who a block's ACTION is for.
|
|
10
|
+
*
|
|
11
|
+
* The bug this exists for: the account button renders its own link — to the
|
|
12
|
+
* account for a member, to the login page for a guest — while the designer
|
|
13
|
+
* wires "open the login drawer" onto the block itself. Both fired on one
|
|
14
|
+
* click, so a signed-in shopper landed on their own account page with the
|
|
15
|
+
* login drawer open over it.
|
|
16
|
+
*
|
|
17
|
+
* Resolved in the pass that already knows who the shopper is, so an action the
|
|
18
|
+
* shopper is not the audience for is REMOVED rather than ignored at click
|
|
19
|
+
* time — the wrapper then never claims to be a dialog trigger either.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const context = (authState: 'authenticated' | 'guest' | 'loading') => ({
|
|
23
|
+
authState,
|
|
24
|
+
breakpoint: 'desktop',
|
|
25
|
+
locale: 'tr',
|
|
26
|
+
currency: 'TRY',
|
|
27
|
+
pathname: '/account',
|
|
28
|
+
searchParams: null
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const sectionWith = (block: Block): Section =>
|
|
32
|
+
({
|
|
33
|
+
id: 'section-1',
|
|
34
|
+
type: 'custom',
|
|
35
|
+
label: 'Header',
|
|
36
|
+
order: 0,
|
|
37
|
+
hidden: false,
|
|
38
|
+
properties: {},
|
|
39
|
+
styles: {},
|
|
40
|
+
blocks: [block]
|
|
41
|
+
}) as unknown as Section;
|
|
42
|
+
|
|
43
|
+
const blockOf = (
|
|
44
|
+
type: string,
|
|
45
|
+
properties: Record<string, unknown>
|
|
46
|
+
): Block =>
|
|
47
|
+
({
|
|
48
|
+
id: 'block-1',
|
|
49
|
+
type,
|
|
50
|
+
label: type,
|
|
51
|
+
value: '',
|
|
52
|
+
order: 0,
|
|
53
|
+
hidden: false,
|
|
54
|
+
properties,
|
|
55
|
+
styles: {}
|
|
56
|
+
}) as unknown as Block;
|
|
57
|
+
|
|
58
|
+
const actionTypeAfter = (
|
|
59
|
+
block: Block,
|
|
60
|
+
authState: 'authenticated' | 'guest' | 'loading'
|
|
61
|
+
): unknown =>
|
|
62
|
+
applyVisibilityRulesToSections([sectionWith(block)], context(authState))[0]
|
|
63
|
+
.blocks[0].properties?.actionType;
|
|
64
|
+
|
|
65
|
+
describe('an account button that opens a modal', () => {
|
|
66
|
+
const accountButton = blockOf('account-button', {
|
|
67
|
+
actionType: 'open-modal',
|
|
68
|
+
actionTarget: 'm-login'
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps the drawer for a guest', () => {
|
|
72
|
+
expect(actionTypeAfter(accountButton, 'guest')).toBe('open-modal');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops it for a member, who is already being taken to their account', () => {
|
|
76
|
+
expect(actionTypeAfter(accountButton, 'authenticated')).toBe('none');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps it while the session is unknown', () => {
|
|
80
|
+
// Withholding the drawer through the first client render would break the
|
|
81
|
+
// common case to fix the rare one.
|
|
82
|
+
expect(actionTypeAfter(accountButton, 'loading')).toBe('open-modal');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('obeys a brand that really wants it for everyone', () => {
|
|
86
|
+
const authored = blockOf('account-button', {
|
|
87
|
+
actionType: 'open-modal',
|
|
88
|
+
actionTarget: 'm-login',
|
|
89
|
+
actionAudience: 'all'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(actionTypeAfter(authored, 'authenticated')).toBe('open-modal');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves its navigation alone — only the wired action is scoped', () => {
|
|
96
|
+
const navigating = blockOf('account-button', { actionType: 'none' });
|
|
97
|
+
|
|
98
|
+
expect(actionTypeAfter(navigating, 'authenticated')).toBe('none');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('any other block', () => {
|
|
103
|
+
it('is unscoped unless the designer says otherwise', () => {
|
|
104
|
+
const button = blockOf('button', {
|
|
105
|
+
actionType: 'open-modal',
|
|
106
|
+
actionTarget: 'm-newsletter'
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(actionTypeAfter(button, 'authenticated')).toBe('open-modal');
|
|
110
|
+
expect(actionTypeAfter(button, 'guest')).toBe('open-modal');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('can be scoped to guests — a "sign in" CTA in a campaign band', () => {
|
|
114
|
+
const cta = blockOf('button', {
|
|
115
|
+
actionType: 'open-modal',
|
|
116
|
+
actionTarget: 'm-login',
|
|
117
|
+
actionAudience: 'guest'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(actionTypeAfter(cta, 'guest')).toBe('open-modal');
|
|
121
|
+
expect(actionTypeAfter(cta, 'authenticated')).toBe('none');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('can be scoped to members — a "my coupons" drawer', () => {
|
|
125
|
+
const memberOnly = blockOf('button', {
|
|
126
|
+
actionType: 'open-modal',
|
|
127
|
+
actionTarget: 'm-coupons',
|
|
128
|
+
actionAudience: 'authenticated'
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(actionTypeAfter(memberOnly, 'authenticated')).toBe('open-modal');
|
|
132
|
+
expect(actionTypeAfter(memberOnly, 'guest')).toBe('none');
|
|
133
|
+
// Unknown is not a member, so the action stays off until it is known.
|
|
134
|
+
expect(actionTypeAfter(memberOnly, 'loading')).toBe('none');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reads the answer for the breakpoint being rendered', () => {
|
|
138
|
+
const perBreakpoint = blockOf('button', {
|
|
139
|
+
actionType: { desktop: 'open-modal' },
|
|
140
|
+
actionTarget: { desktop: 'm-login' },
|
|
141
|
+
actionAudience: { desktop: 'authenticated' }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(actionTypeAfter(perBreakpoint, 'guest')).toBe('none');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('offers exactly three audiences', () => {
|
|
148
|
+
expect([...BLOCK_ACTION_AUDIENCES]).toEqual([
|
|
149
|
+
'all',
|
|
150
|
+
'guest',
|
|
151
|
+
'authenticated'
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
});
|