@akinon/pz-theme 2.0.83-beta.0 → 2.0.83
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 +31 -7
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- package/src/utils/part-styles.ts +4 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import RegisterFormBlock from '../blocks/register-form-block';
|
|
5
|
+
import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
|
|
6
|
+
import {
|
|
7
|
+
__resetAuthFlowBusForTests,
|
|
8
|
+
getAuthFlowState,
|
|
9
|
+
getPendingOtp
|
|
10
|
+
} from '../auth/auth-flow-bus';
|
|
11
|
+
import { Block } from '../theme-block';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A registration whose rows are blocks.
|
|
15
|
+
*
|
|
16
|
+
* The login form composes: an `auth-field` per input, arranged and dressed like
|
|
17
|
+
* any other block. The register form did not, so a composition could rewrite
|
|
18
|
+
* its e-mail row but had to take the form's own consent row — the one row a
|
|
19
|
+
* legal team actually rewrites — and could not reorder, relabel or restyle any
|
|
20
|
+
* of it.
|
|
21
|
+
*
|
|
22
|
+
* What must NOT change is the body that reaches commerce: a composed form and a
|
|
23
|
+
* fixed one register the same shopper the same way, ticks turned into the flags
|
|
24
|
+
* commerce names individually. These cases pin exactly that, plus the two
|
|
25
|
+
* refusals (no agreement, no submit in the canvas) and the SMS handoff.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
|
|
29
|
+
|
|
30
|
+
jest.mock('next-auth/react', () => ({
|
|
31
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
32
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const push = jest.fn();
|
|
36
|
+
const refresh = jest.fn();
|
|
37
|
+
|
|
38
|
+
jest.mock('next/navigation', () => ({
|
|
39
|
+
useRouter: () => ({ push, refresh, replace: jest.fn() })
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
43
|
+
({
|
|
44
|
+
id: 'blk',
|
|
45
|
+
type: 'text',
|
|
46
|
+
label: 'Block',
|
|
47
|
+
value: '',
|
|
48
|
+
order: 0,
|
|
49
|
+
hidden: false,
|
|
50
|
+
properties: {},
|
|
51
|
+
styles: {},
|
|
52
|
+
...overrides
|
|
53
|
+
}) as Block;
|
|
54
|
+
|
|
55
|
+
const field = (fieldType: string, order: number): Block =>
|
|
56
|
+
block({
|
|
57
|
+
id: `field-${fieldType}`,
|
|
58
|
+
type: 'auth-field',
|
|
59
|
+
label: fieldType,
|
|
60
|
+
properties: { fieldType },
|
|
61
|
+
order
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const consent = (
|
|
65
|
+
consentType: 'legal' | 'marketing',
|
|
66
|
+
order: number,
|
|
67
|
+
properties: Record<string, unknown> = {}
|
|
68
|
+
): Block =>
|
|
69
|
+
block({
|
|
70
|
+
id: `consent-${consentType}`,
|
|
71
|
+
type: 'auth-consent',
|
|
72
|
+
label: consentType,
|
|
73
|
+
properties: { consentType, ...properties },
|
|
74
|
+
order
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const renderComposed = (children: Block[], properties = {}) =>
|
|
78
|
+
render(
|
|
79
|
+
<RegisterFormBlock
|
|
80
|
+
block={
|
|
81
|
+
block({
|
|
82
|
+
id: 'register-1',
|
|
83
|
+
type: 'register-form',
|
|
84
|
+
label: 'Register Form',
|
|
85
|
+
properties,
|
|
86
|
+
blocks: children
|
|
87
|
+
}) as Block
|
|
88
|
+
}
|
|
89
|
+
placeholderId="placeholder-1"
|
|
90
|
+
sectionId="section-1"
|
|
91
|
+
currentBreakpoint="desktop"
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const COMPOSED = [
|
|
96
|
+
field('first_name', 0),
|
|
97
|
+
field('last_name', 1),
|
|
98
|
+
field('email', 2),
|
|
99
|
+
field('password', 3),
|
|
100
|
+
field('phone', 4),
|
|
101
|
+
consent('legal', 5),
|
|
102
|
+
consent('marketing', 6)
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const fill = (container: HTMLElement, values: Record<string, string>) => {
|
|
106
|
+
Object.entries(values).forEach(([name, value]) => {
|
|
107
|
+
const input = container.querySelector(
|
|
108
|
+
`input[name="${name}"]`
|
|
109
|
+
) as HTMLInputElement;
|
|
110
|
+
fireEvent.change(input, { target: { value } });
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const tick = (container: HTMLElement, name: string) =>
|
|
115
|
+
fireEvent.click(container.querySelector(`input[name="${name}"]`)!);
|
|
116
|
+
|
|
117
|
+
const submitted = () => signIn.mock.calls[0][1] as Record<string, unknown>;
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
signIn.mockClear();
|
|
121
|
+
push.mockClear();
|
|
122
|
+
refresh.mockClear();
|
|
123
|
+
__resetAuthFlowBusForTests();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('a composed register form', () => {
|
|
127
|
+
it('draws its children instead of its own rows', () => {
|
|
128
|
+
const { container } = renderComposed(COMPOSED);
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
container.querySelector('[data-register-form-composed]')
|
|
132
|
+
).not.toBeNull();
|
|
133
|
+
// The fixed rows are the thing a composition replaces, so none of them may
|
|
134
|
+
// also be on the page — a shopper filling in two e-mail inputs is the bug
|
|
135
|
+
// this asserts against.
|
|
136
|
+
expect(
|
|
137
|
+
container.querySelectorAll('[data-register-form-field]')
|
|
138
|
+
).toHaveLength(0);
|
|
139
|
+
expect(container.querySelectorAll('input[name="email"]')).toHaveLength(1);
|
|
140
|
+
expect(container.querySelectorAll('[data-auth-consent]')).toHaveLength(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('posts the same body a fixed form posts, ticks turned into flags', async () => {
|
|
144
|
+
const { container } = renderComposed(COMPOSED);
|
|
145
|
+
|
|
146
|
+
fill(container, {
|
|
147
|
+
first_name: 'Ada',
|
|
148
|
+
last_name: 'Lovelace',
|
|
149
|
+
email: 'ada@example.com',
|
|
150
|
+
password: 'hunter2',
|
|
151
|
+
phone: '(555) 123 45 67'
|
|
152
|
+
});
|
|
153
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
154
|
+
tick(container, AUTH_CONSENT_NAMES.marketing);
|
|
155
|
+
|
|
156
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
157
|
+
|
|
158
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
159
|
+
|
|
160
|
+
expect(submitted()).toMatchObject({
|
|
161
|
+
formType: 'registration',
|
|
162
|
+
first_name: 'Ada',
|
|
163
|
+
last_name: 'Lovelace',
|
|
164
|
+
email: 'ada@example.com',
|
|
165
|
+
password: 'hunter2',
|
|
166
|
+
// Bare digits: the mask a brand renders around the input would fail
|
|
167
|
+
// commerce's length check.
|
|
168
|
+
phone: '5551234567',
|
|
169
|
+
is_allowed: true,
|
|
170
|
+
confirm: true,
|
|
171
|
+
email_allowed: true,
|
|
172
|
+
sms_allowed: true,
|
|
173
|
+
call_allowed: true,
|
|
174
|
+
whatsapp_allowed: true,
|
|
175
|
+
permissions: 'on'
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('sends the marketing flags off, and no permissions key at all, when that tick is left alone', async () => {
|
|
180
|
+
const { container } = renderComposed(COMPOSED);
|
|
181
|
+
|
|
182
|
+
fill(container, {
|
|
183
|
+
first_name: 'Ada',
|
|
184
|
+
last_name: 'Lovelace',
|
|
185
|
+
email: 'ada@example.com',
|
|
186
|
+
password: 'hunter2',
|
|
187
|
+
phone: '5551234567'
|
|
188
|
+
});
|
|
189
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
190
|
+
|
|
191
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
192
|
+
|
|
193
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
194
|
+
|
|
195
|
+
const body = submitted();
|
|
196
|
+
|
|
197
|
+
expect(body).toMatchObject({
|
|
198
|
+
is_allowed: true,
|
|
199
|
+
email_allowed: false,
|
|
200
|
+
sms_allowed: false,
|
|
201
|
+
call_allowed: false,
|
|
202
|
+
whatsapp_allowed: false
|
|
203
|
+
});
|
|
204
|
+
// Absent rather than falsy: the brand's own view deletes the key.
|
|
205
|
+
expect(body).not.toHaveProperty('permissions');
|
|
206
|
+
// And the raw checkbox entries must not travel — `is_allowed: 'on'` is not
|
|
207
|
+
// what commerce reads.
|
|
208
|
+
expect(body.is_allowed).not.toBe('on');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('refuses to register anyone who has not agreed, and says which sentence they missed', async () => {
|
|
212
|
+
const { container } = renderComposed(COMPOSED, {
|
|
213
|
+
consentRequiredLabel: 'Üyelik sözleşmesini kabul etmelisiniz.'
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
fill(container, {
|
|
217
|
+
first_name: 'Ada',
|
|
218
|
+
last_name: 'Lovelace',
|
|
219
|
+
email: 'ada@example.com',
|
|
220
|
+
password: 'hunter2'
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
224
|
+
|
|
225
|
+
await waitFor(() =>
|
|
226
|
+
expect(
|
|
227
|
+
screen.getByText('Üyelik sözleşmesini kabul etmelisiniz.')
|
|
228
|
+
).toBeInTheDocument()
|
|
229
|
+
);
|
|
230
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('never submits from the canvas, where it would create a real account', () => {
|
|
234
|
+
const { container } = render(
|
|
235
|
+
<RegisterFormBlock
|
|
236
|
+
block={
|
|
237
|
+
block({
|
|
238
|
+
id: 'register-1',
|
|
239
|
+
type: 'register-form',
|
|
240
|
+
label: 'Register Form',
|
|
241
|
+
blocks: COMPOSED
|
|
242
|
+
}) as Block
|
|
243
|
+
}
|
|
244
|
+
placeholderId="placeholder-1"
|
|
245
|
+
sectionId="section-1"
|
|
246
|
+
currentBreakpoint="desktop"
|
|
247
|
+
isDesigner
|
|
248
|
+
/>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
252
|
+
|
|
253
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('still renders its fixed rows for a composition that has no children', () => {
|
|
257
|
+
const { container } = renderComposed([]);
|
|
258
|
+
|
|
259
|
+
expect(container.querySelector('[data-register-form-composed]')).toBeNull();
|
|
260
|
+
expect(
|
|
261
|
+
container.querySelectorAll('[data-register-form-field]').length
|
|
262
|
+
).toBeGreaterThan(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('the consent block on its own terms', () => {
|
|
267
|
+
it('requires the legal tick and leaves the marketing one optional', () => {
|
|
268
|
+
const { container } = renderComposed([
|
|
269
|
+
consent('legal', 0),
|
|
270
|
+
consent('marketing', 1)
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
expect(
|
|
274
|
+
container.querySelector(`input[name="${AUTH_CONSENT_NAMES.legal}"]`)
|
|
275
|
+
).toBeRequired();
|
|
276
|
+
expect(
|
|
277
|
+
container.querySelector(`input[name="${AUTH_CONSENT_NAMES.marketing}"]`)
|
|
278
|
+
).not.toBeRequired();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('lets a brand whose agreement is not mandatory say so', () => {
|
|
282
|
+
const { container } = renderComposed([
|
|
283
|
+
consent('legal', 0, { required: false })
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
expect(
|
|
287
|
+
container.querySelector(`input[name="${AUTH_CONSENT_NAMES.legal}"]`)
|
|
288
|
+
).not.toBeRequired();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('carries the sentence the author wrote', () => {
|
|
292
|
+
const { container } = renderComposed([
|
|
293
|
+
consent('legal', 0, { text: 'Kuralları okudum ve kabul ediyorum.' })
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
expect(container).toHaveTextContent('Kuralları okudum ve kabul ediyorum.');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('the SMS handoff from a composed registration', () => {
|
|
301
|
+
/** Commerce accepts the sign-up and answers with an `otp` challenge. */
|
|
302
|
+
const challenge = () =>
|
|
303
|
+
signIn.mockImplementationOnce(() =>
|
|
304
|
+
Promise.resolve({
|
|
305
|
+
error: 'CredentialsSignin',
|
|
306
|
+
code: JSON.stringify([{ type: 'otp' }]),
|
|
307
|
+
url: null
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const completeAndSubmit = (container: HTMLElement) => {
|
|
312
|
+
fill(container, {
|
|
313
|
+
first_name: 'Ada',
|
|
314
|
+
last_name: 'Lovelace',
|
|
315
|
+
email: 'ada@example.com',
|
|
316
|
+
password: 'hunter2',
|
|
317
|
+
phone: '(555) 123 45 67'
|
|
318
|
+
});
|
|
319
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
320
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
it('parks the whole attempt on the bus so an OTP block can finish it', async () => {
|
|
324
|
+
challenge();
|
|
325
|
+
|
|
326
|
+
const { container } = renderComposed(COMPOSED);
|
|
327
|
+
completeAndSubmit(container);
|
|
328
|
+
|
|
329
|
+
await waitFor(() => expect(getPendingOtp()).not.toBeNull());
|
|
330
|
+
|
|
331
|
+
const pending = getPendingOtp()!;
|
|
332
|
+
|
|
333
|
+
expect(pending.kind).toBe('register');
|
|
334
|
+
// Bare digits, because this is the number the code is sent to.
|
|
335
|
+
expect(pending.phone).toBe('5551234567');
|
|
336
|
+
// The payload is re-submitted with the code, so it has to be the same body
|
|
337
|
+
// the sign-up used — password included, which is why it never outlives the
|
|
338
|
+
// journey (see `registerAuthFlowMount`).
|
|
339
|
+
expect(pending.payload).toMatchObject({
|
|
340
|
+
formType: 'registration',
|
|
341
|
+
email: 'ada@example.com',
|
|
342
|
+
is_allowed: true
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('moves the flow to the step holding the verification, when one was named', async () => {
|
|
347
|
+
challenge();
|
|
348
|
+
|
|
349
|
+
const { container } = renderComposed(COMPOSED, {
|
|
350
|
+
otpStepTarget: 'auth:verify'
|
|
351
|
+
});
|
|
352
|
+
completeAndSubmit(container);
|
|
353
|
+
|
|
354
|
+
await waitFor(() =>
|
|
355
|
+
expect(getAuthFlowState('auth')?.active).toBe('verify')
|
|
356
|
+
);
|
|
357
|
+
// The challenge is a handoff, not a failure: nothing is written into the
|
|
358
|
+
// form's own error line.
|
|
359
|
+
expect(container.querySelector('[data-register-form-error]')).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("the answers the brand's own design asks for", () => {
|
|
364
|
+
const DESIGNED = [
|
|
365
|
+
field('gender', 0),
|
|
366
|
+
field('email', 1),
|
|
367
|
+
field('first_name', 2),
|
|
368
|
+
field('last_name', 3),
|
|
369
|
+
field('phone', 4),
|
|
370
|
+
field('date_of_birth', 5),
|
|
371
|
+
field('password', 6),
|
|
372
|
+
consent('legal', 7)
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
it('collects a gender as one of the two values commerce stores', async () => {
|
|
376
|
+
const { container } = renderComposed(DESIGNED);
|
|
377
|
+
|
|
378
|
+
const select = container.querySelector(
|
|
379
|
+
'select[name="gender"]'
|
|
380
|
+
) as HTMLSelectElement;
|
|
381
|
+
|
|
382
|
+
// A select rather than a text box: the account page reads these strings
|
|
383
|
+
// back, so a composition must not be able to invent a third one.
|
|
384
|
+
expect(select).not.toBeNull();
|
|
385
|
+
expect(Array.from(select.options).map((option) => option.value)).toEqual([
|
|
386
|
+
'',
|
|
387
|
+
'female',
|
|
388
|
+
'male'
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
fireEvent.change(select, { target: { value: 'female' } });
|
|
392
|
+
fill(container, {
|
|
393
|
+
email: 'ada@example.com',
|
|
394
|
+
first_name: 'Ada',
|
|
395
|
+
last_name: 'Lovelace',
|
|
396
|
+
phone: '5551234567',
|
|
397
|
+
date_of_birth: '1815-12-10',
|
|
398
|
+
password: 'hunter2'
|
|
399
|
+
});
|
|
400
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
401
|
+
|
|
402
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
403
|
+
|
|
404
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
405
|
+
|
|
406
|
+
expect(submitted()).toMatchObject({
|
|
407
|
+
gender: 'female',
|
|
408
|
+
// The native control's own value format, which is already the one
|
|
409
|
+
// commerce keeps — nothing reformats it on the way out.
|
|
410
|
+
date_of_birth: '1815-12-10'
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('leaves the two optional answers optional', () => {
|
|
415
|
+
const { container } = renderComposed([
|
|
416
|
+
field('gender', 0),
|
|
417
|
+
field('date_of_birth', 1)
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
// Required is the field's own toggle and defaults to on; what matters here
|
|
421
|
+
// is that a shopper CAN be asked for these without being forced, which is
|
|
422
|
+
// what the seeded composition does.
|
|
423
|
+
expect(container.querySelector('select[name="gender"]')).not.toBeNull();
|
|
424
|
+
expect(
|
|
425
|
+
container.querySelector('input[name="date_of_birth"]')
|
|
426
|
+
).toHaveAttribute('type', 'date');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('lets a brand caption the two values in its own language', () => {
|
|
430
|
+
const { container } = renderComposed([
|
|
431
|
+
block({
|
|
432
|
+
id: 'field-gender',
|
|
433
|
+
type: 'auth-field',
|
|
434
|
+
properties: {
|
|
435
|
+
fieldType: 'gender',
|
|
436
|
+
femaleLabel: 'Kadın',
|
|
437
|
+
maleLabel: 'Erkek',
|
|
438
|
+
placeholder: 'Cinsiyet'
|
|
439
|
+
},
|
|
440
|
+
order: 0
|
|
441
|
+
})
|
|
442
|
+
]);
|
|
443
|
+
|
|
444
|
+
const options = Array.from(
|
|
445
|
+
container.querySelectorAll('select[name="gender"] option')
|
|
446
|
+
).map((option) => option.textContent);
|
|
447
|
+
|
|
448
|
+
expect(options).toEqual(['Cinsiyet', 'Kadın', 'Erkek']);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe('a field or a tick the list has never heard of', () => {
|
|
453
|
+
const customField = (
|
|
454
|
+
name: string,
|
|
455
|
+
order: number,
|
|
456
|
+
properties: Record<string, unknown> = {}
|
|
457
|
+
) =>
|
|
458
|
+
block({
|
|
459
|
+
id: `field-${name || 'unnamed'}`,
|
|
460
|
+
type: 'auth-field',
|
|
461
|
+
properties: { fieldType: 'custom', name, ...properties },
|
|
462
|
+
order
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const customConsent = (
|
|
466
|
+
name: string,
|
|
467
|
+
order: number,
|
|
468
|
+
properties: Record<string, unknown> = {}
|
|
469
|
+
) =>
|
|
470
|
+
block({
|
|
471
|
+
id: `consent-${name || 'unnamed'}`,
|
|
472
|
+
type: 'auth-consent',
|
|
473
|
+
properties: { consentType: 'custom', name, ...properties },
|
|
474
|
+
order
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('submits a custom field under the name the author typed', async () => {
|
|
478
|
+
const { container } = renderComposed([
|
|
479
|
+
field('email', 0),
|
|
480
|
+
field('password', 1),
|
|
481
|
+
customField('tckn', 2, { label: 'National ID', inputType: 'number' }),
|
|
482
|
+
consent('legal', 3)
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
const input = container.querySelector('input[name="tckn"]');
|
|
486
|
+
expect(input).toHaveAttribute('type', 'number');
|
|
487
|
+
|
|
488
|
+
fill(container, {
|
|
489
|
+
email: 'ada@example.com',
|
|
490
|
+
password: 'hunter2',
|
|
491
|
+
tckn: '12345678901'
|
|
492
|
+
});
|
|
493
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
494
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
495
|
+
|
|
496
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
497
|
+
// Straight through: the form forwards every named control it finds, which
|
|
498
|
+
// is what makes a new field an editor job instead of a release.
|
|
499
|
+
expect(submitted()).toMatchObject({ tckn: '12345678901' });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('draws nothing for a custom field nobody has named', () => {
|
|
503
|
+
const { container } = renderComposed([customField('', 0)]);
|
|
504
|
+
|
|
505
|
+
// An input with no name collects into the void; a shopper must not be given
|
|
506
|
+
// one to fill in.
|
|
507
|
+
expect(container.querySelectorAll('input')).toHaveLength(0);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('sends a custom tick as the value the author chose, and omits it when unticked', async () => {
|
|
511
|
+
const children = [
|
|
512
|
+
field('email', 0),
|
|
513
|
+
field('password', 1),
|
|
514
|
+
consent('legal', 2),
|
|
515
|
+
customConsent('kvkk_approved', 3, { tickedValue: 'on' })
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
const first = renderComposed(children);
|
|
519
|
+
fill(first.container, { email: 'a@b.com', password: 'hunter2' });
|
|
520
|
+
tick(first.container, AUTH_CONSENT_NAMES.legal);
|
|
521
|
+
fireEvent.submit(first.container.querySelector('form')!);
|
|
522
|
+
|
|
523
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
524
|
+
// Unticked: absent altogether, exactly like the marketing flag's key.
|
|
525
|
+
expect(submitted()).not.toHaveProperty('kvkk_approved');
|
|
526
|
+
|
|
527
|
+
signIn.mockClear();
|
|
528
|
+
first.unmount();
|
|
529
|
+
|
|
530
|
+
const second = renderComposed(children);
|
|
531
|
+
fill(second.container, { email: 'a@b.com', password: 'hunter2' });
|
|
532
|
+
tick(second.container, AUTH_CONSENT_NAMES.legal);
|
|
533
|
+
tick(second.container, 'kvkk_approved');
|
|
534
|
+
fireEvent.submit(second.container.querySelector('form')!);
|
|
535
|
+
|
|
536
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
537
|
+
expect(submitted()).toMatchObject({ kvkk_approved: 'on' });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('defaults a custom tick to true, which is what an agreement is stored as', () => {
|
|
541
|
+
const { container } = renderComposed([customConsent('kvkk_approved', 0)]);
|
|
542
|
+
|
|
543
|
+
expect(
|
|
544
|
+
container.querySelector('input[name="kvkk_approved"]')
|
|
545
|
+
).toHaveAttribute('value', 'true');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('draws nothing for a custom tick nobody has named', () => {
|
|
549
|
+
const { container } = renderComposed([customConsent('', 0)]);
|
|
550
|
+
|
|
551
|
+
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(
|
|
552
|
+
0
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe('the body a composition posts', () => {
|
|
558
|
+
const customField = (
|
|
559
|
+
name: string,
|
|
560
|
+
order: number,
|
|
561
|
+
properties: Record<string, unknown> = {}
|
|
562
|
+
) =>
|
|
563
|
+
block({
|
|
564
|
+
id: `field-custom-${name || 'unnamed'}`,
|
|
565
|
+
type: 'auth-field',
|
|
566
|
+
properties: { fieldType: 'custom', name, ...properties },
|
|
567
|
+
order
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('leaves an optional answer out rather than posting an empty one', async () => {
|
|
571
|
+
// The two answers the design frames a sign-up with are optional, so a
|
|
572
|
+
// shopper skipping them is the ordinary case — and a gender left on its
|
|
573
|
+
// empty option is not a choice commerce can store. The fixed rows never
|
|
574
|
+
// sent these keys, and the form already deletes `permissions` rather than
|
|
575
|
+
// send a falsy one; this is the same rule.
|
|
576
|
+
const optional = (fieldType: string, order: number) =>
|
|
577
|
+
block({
|
|
578
|
+
id: `field-${fieldType}`,
|
|
579
|
+
type: 'auth-field',
|
|
580
|
+
properties: { fieldType, required: false },
|
|
581
|
+
order
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const { container } = renderComposed([
|
|
585
|
+
optional('gender', 0),
|
|
586
|
+
field('email', 1),
|
|
587
|
+
field('password', 2),
|
|
588
|
+
optional('date_of_birth', 3),
|
|
589
|
+
consent('legal', 4)
|
|
590
|
+
]);
|
|
591
|
+
|
|
592
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
593
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
594
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
595
|
+
|
|
596
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
597
|
+
expect(submitted()).not.toHaveProperty('gender');
|
|
598
|
+
expect(submitted()).not.toHaveProperty('date_of_birth');
|
|
599
|
+
expect(submitted()).toMatchObject({ email: 'ada@example.com' });
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('refuses a field named after the protocol instead of letting it through', async () => {
|
|
603
|
+
// `formType` picks the commerce endpoint and `redirect` decides whether
|
|
604
|
+
// next-auth hands back a response at all — a field named after either is
|
|
605
|
+
// not an extra answer, it is a different request.
|
|
606
|
+
const { container } = renderComposed([
|
|
607
|
+
field('email', 0),
|
|
608
|
+
field('password', 1),
|
|
609
|
+
customField('formType', 2, { label: 'Form type' }),
|
|
610
|
+
customField('redirect', 3, { label: 'Redirect' }),
|
|
611
|
+
consent('legal', 4)
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
fill(container, {
|
|
615
|
+
email: 'ada@example.com',
|
|
616
|
+
password: 'hunter2',
|
|
617
|
+
formType: 'oops',
|
|
618
|
+
redirect: 'yes'
|
|
619
|
+
});
|
|
620
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
621
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
622
|
+
|
|
623
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
624
|
+
expect(submitted()).toMatchObject({ formType: 'registration' });
|
|
625
|
+
// False, not the string: a truthy `redirect` navigates away instead of
|
|
626
|
+
// answering, and the form would never see its own errors again.
|
|
627
|
+
expect(submitted().redirect).toBe(false);
|
|
628
|
+
expect(submitted().callbackUrl).toBe('/');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('keeps what the shopper typed when a field name is duplicated', async () => {
|
|
632
|
+
// A row copied with Duplicate, or a breakpoint twin: two controls answer to
|
|
633
|
+
// one name and only one of them is filled in. `collectAuthFieldErrors`
|
|
634
|
+
// already resolves that duplicate first-one-wins; the body used to take the
|
|
635
|
+
// LAST, which is typically the empty one.
|
|
636
|
+
const { container } = renderComposed([
|
|
637
|
+
field('email', 0),
|
|
638
|
+
field('password', 1),
|
|
639
|
+
customField('email', 2, { label: 'E-mail again', required: false }),
|
|
640
|
+
consent('legal', 3)
|
|
641
|
+
]);
|
|
642
|
+
|
|
643
|
+
const emails = container.querySelectorAll('input[name="email"]');
|
|
644
|
+
expect(emails).toHaveLength(2);
|
|
645
|
+
|
|
646
|
+
fireEvent.change(emails[0], { target: { value: 'ada@example.com' } });
|
|
647
|
+
fill(container, { password: 'hunter2' });
|
|
648
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
649
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
650
|
+
|
|
651
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
652
|
+
expect(submitted()).toMatchObject({ email: 'ada@example.com' });
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe('an agreement the brand does not insist on', () => {
|
|
657
|
+
it('registers without the tick when the row says it is optional', async () => {
|
|
658
|
+
// The editor offers "turn it off for an optional tick" and the block
|
|
659
|
+
// promises a brand in a market where the agreement is not mandatory can say
|
|
660
|
+
// so. The refusal used to run whatever the row said, so the toggle changed
|
|
661
|
+
// which message appeared and nothing else.
|
|
662
|
+
const { container } = renderComposed([
|
|
663
|
+
field('email', 0),
|
|
664
|
+
field('password', 1),
|
|
665
|
+
consent('legal', 2, { required: false })
|
|
666
|
+
]);
|
|
667
|
+
|
|
668
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
669
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
670
|
+
|
|
671
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
672
|
+
// The shopper's actual answer travels, rather than a yes nobody gave.
|
|
673
|
+
expect(submitted()).toMatchObject({ is_allowed: false, confirm: false });
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('still refuses when the row is the mandatory one it is by default', async () => {
|
|
677
|
+
const { container } = renderComposed(
|
|
678
|
+
[field('email', 0), field('password', 1), consent('legal', 2)],
|
|
679
|
+
{ consentRequiredLabel: 'Üyelik sözleşmesini kabul etmelisiniz.' }
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
683
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
684
|
+
|
|
685
|
+
// A required tick is caught as the row's own missing answer, so the
|
|
686
|
+
// sentence lands under it rather than at the foot of the form.
|
|
687
|
+
await waitFor(() =>
|
|
688
|
+
expect(
|
|
689
|
+
screen.getByText('Üyelik sözleşmesini kabul etmelisiniz.')
|
|
690
|
+
).toBeInTheDocument()
|
|
691
|
+
);
|
|
692
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('registers a composition that does not ask the question at all', async () => {
|
|
696
|
+
// No consent row means the brand is not asking — which is what a childless
|
|
697
|
+
// form does too, and it registers. The refusal used to fire anyway, telling
|
|
698
|
+
// the shopper to accept an agreement that was nowhere on screen and leaving
|
|
699
|
+
// them no way to ever submit.
|
|
700
|
+
const { container } = renderComposed([
|
|
701
|
+
field('email', 0),
|
|
702
|
+
field('password', 1)
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
706
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
707
|
+
|
|
708
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
709
|
+
expect(submitted()).toMatchObject({ is_allowed: true, confirm: true });
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe('a field the design has taken off this breakpoint', () => {
|
|
714
|
+
it('does not hold the form hostage to a control nobody can see', async () => {
|
|
715
|
+
// The editor's Visibility setting is CSS: it writes `display: none` onto
|
|
716
|
+
// the block wrapper and leaves the control in the form. `willValidate` does
|
|
717
|
+
// not care about CSS, so a required field hidden on mobile refused every
|
|
718
|
+
// submit there — with the explanation drawn inside the hidden wrapper, and
|
|
719
|
+
// `focus()` a no-op on an element that is not rendered.
|
|
720
|
+
const { container } = renderComposed([
|
|
721
|
+
field('email', 0),
|
|
722
|
+
field('password', 1),
|
|
723
|
+
field('phone', 2),
|
|
724
|
+
consent('legal', 3)
|
|
725
|
+
]);
|
|
726
|
+
|
|
727
|
+
const phone = container.querySelector(
|
|
728
|
+
'input[name="phone"]'
|
|
729
|
+
) as HTMLInputElement;
|
|
730
|
+
(phone.closest('label') as HTMLElement).style.display = 'none';
|
|
731
|
+
|
|
732
|
+
fill(container, { email: 'ada@example.com', password: 'hunter2' });
|
|
733
|
+
tick(container, AUTH_CONSENT_NAMES.legal);
|
|
734
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
735
|
+
|
|
736
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
737
|
+
expect(submitted()).not.toHaveProperty('phone');
|
|
738
|
+
});
|
|
739
|
+
});
|