@akinon/pz-theme 2.0.165-rc.0 → 2.0.165
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 +10 -51
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/profile-form-composed.test.tsx +795 -0
- package/src/auth/auth-field-errors.tsx +59 -13
- package/src/auth/form-field-defaults.tsx +74 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/auth-consent-block.tsx +78 -19
- package/src/blocks/auth-field-block.tsx +212 -50
- package/src/blocks/auth-form-block.tsx +3 -4
- package/src/blocks/birth-date-selects.tsx +206 -0
- package/src/blocks/forgot-password-block.tsx +3 -4
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/otp-form-block.tsx +3 -4
- package/src/blocks/profile-form-block.tsx +353 -10
- package/src/blocks/register-form-block.tsx +12 -6
- package/src/blocks/reset-password-block.tsx +3 -4
- package/src/blocks/text-block.tsx +2 -5
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/theme-placeholder-wrapper.tsx +0 -3
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import ProfileFormBlock, {
|
|
5
|
+
readProfilePayload
|
|
6
|
+
} from '../blocks/profile-form-block';
|
|
7
|
+
import RegisterFormBlock from '../blocks/register-form-block';
|
|
8
|
+
import {
|
|
9
|
+
daysInMonth,
|
|
10
|
+
joinIsoDate,
|
|
11
|
+
splitIsoDate
|
|
12
|
+
} from '../blocks/birth-date-selects';
|
|
13
|
+
import { AUTH_CONSENT_NAMES } from '../blocks/auth-consent-block';
|
|
14
|
+
import { Block } from '../theme-block';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A profile form whose rows are blocks.
|
|
18
|
+
*
|
|
19
|
+
* The fixed profile form draws name, e-mail, phone and a native date input and
|
|
20
|
+
* nothing else, so a brand that asks for gender, a day/month/year birth date or
|
|
21
|
+
* its contact consents had to hand-write HTML and wire it with its own script.
|
|
22
|
+
* Composed, the form draws `auth-field` and `auth-consent` children instead,
|
|
23
|
+
* starts each one from the stored profile and saves what the visible ones say.
|
|
24
|
+
*
|
|
25
|
+
* What these cases pin is the save: a consent record is a legal record, so a
|
|
26
|
+
* tick the shopper never saw must never be written, and one they did see must
|
|
27
|
+
* be written exactly as they left it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const PROFILE = {
|
|
31
|
+
first_name: 'Ada',
|
|
32
|
+
last_name: 'Lovelace',
|
|
33
|
+
email: 'ada@example.com',
|
|
34
|
+
phone: '5551234567',
|
|
35
|
+
date_of_birth: '1990-04-07',
|
|
36
|
+
gender: 'female',
|
|
37
|
+
email_allowed: true,
|
|
38
|
+
sms_allowed: false,
|
|
39
|
+
call_allowed: null,
|
|
40
|
+
whatsapp_allowed: true
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let profileResult: { data: unknown; isLoading: boolean; isError?: boolean } = {
|
|
44
|
+
data: PROFILE,
|
|
45
|
+
isLoading: false
|
|
46
|
+
};
|
|
47
|
+
const updateProfile = jest.fn((_body: Record<string, unknown>) => ({
|
|
48
|
+
unwrap: () => Promise.resolve({})
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
jest.mock('@akinon/next/data/client/account', () => ({
|
|
52
|
+
useGetProfileInfoQuery: () => profileResult,
|
|
53
|
+
useUpdateProfileMutation: () => [
|
|
54
|
+
(body: Record<string, unknown>) => updateProfile(body),
|
|
55
|
+
{ isLoading: false }
|
|
56
|
+
]
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
let sessionStatus = 'authenticated';
|
|
60
|
+
const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/' }));
|
|
61
|
+
|
|
62
|
+
jest.mock('next-auth/react', () => ({
|
|
63
|
+
signIn: (...args: unknown[]) => signIn(...(args as [])),
|
|
64
|
+
useSession: () => ({ data: null, status: sessionStatus })
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
jest.mock('next/navigation', () => ({
|
|
68
|
+
usePathname: () => '/',
|
|
69
|
+
useSearchParams: () => new URLSearchParams(),
|
|
70
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
74
|
+
({
|
|
75
|
+
id: 'blk',
|
|
76
|
+
type: 'text',
|
|
77
|
+
label: 'Block',
|
|
78
|
+
value: '',
|
|
79
|
+
order: 0,
|
|
80
|
+
hidden: false,
|
|
81
|
+
properties: {},
|
|
82
|
+
styles: {},
|
|
83
|
+
...overrides
|
|
84
|
+
}) as Block;
|
|
85
|
+
|
|
86
|
+
const field = (
|
|
87
|
+
fieldType: string,
|
|
88
|
+
order: number,
|
|
89
|
+
properties: Record<string, unknown> = {}
|
|
90
|
+
): Block =>
|
|
91
|
+
block({
|
|
92
|
+
id: `field-${fieldType}`,
|
|
93
|
+
type: 'auth-field',
|
|
94
|
+
label: fieldType,
|
|
95
|
+
properties: { fieldType, required: false, ...properties },
|
|
96
|
+
order
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const channel = (
|
|
100
|
+
consentType: string,
|
|
101
|
+
order: number,
|
|
102
|
+
overrides: Partial<Block> = {}
|
|
103
|
+
): Block =>
|
|
104
|
+
block({
|
|
105
|
+
id: `consent-${consentType}`,
|
|
106
|
+
type: 'auth-consent',
|
|
107
|
+
label: consentType,
|
|
108
|
+
properties: { consentType },
|
|
109
|
+
order,
|
|
110
|
+
...overrides
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const renderProfile = (
|
|
114
|
+
children: Block[],
|
|
115
|
+
{ isDesigner = false, properties = {} } = {}
|
|
116
|
+
) =>
|
|
117
|
+
render(
|
|
118
|
+
<ProfileFormBlock
|
|
119
|
+
block={
|
|
120
|
+
block({
|
|
121
|
+
id: 'profile-1',
|
|
122
|
+
type: 'profile-form',
|
|
123
|
+
label: 'Profile Form',
|
|
124
|
+
properties,
|
|
125
|
+
blocks: children
|
|
126
|
+
}) as Block
|
|
127
|
+
}
|
|
128
|
+
placeholderId="placeholder-1"
|
|
129
|
+
sectionId="section-1"
|
|
130
|
+
currentBreakpoint="desktop"
|
|
131
|
+
isDesigner={isDesigner}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const input = (container: HTMLElement, selector: string) =>
|
|
136
|
+
container.querySelector(selector) as HTMLInputElement;
|
|
137
|
+
|
|
138
|
+
const datePart = (container: HTMLElement, part: string) =>
|
|
139
|
+
container.querySelector(
|
|
140
|
+
`select[data-date-part="${part}"]`
|
|
141
|
+
) as HTMLSelectElement;
|
|
142
|
+
|
|
143
|
+
const submit = (container: HTMLElement) =>
|
|
144
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
145
|
+
|
|
146
|
+
const lastSaved = () =>
|
|
147
|
+
updateProfile.mock.calls[updateProfile.mock.calls.length - 1][0];
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
updateProfile.mockClear();
|
|
151
|
+
signIn.mockClear();
|
|
152
|
+
sessionStatus = 'authenticated';
|
|
153
|
+
profileResult = { data: PROFILE, isLoading: false };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('composed profile form', () => {
|
|
157
|
+
const COMPOSED = [
|
|
158
|
+
field('first_name', 0),
|
|
159
|
+
field('last_name', 1),
|
|
160
|
+
field('email', 2),
|
|
161
|
+
field('gender', 3, { choiceControl: 'radio' }),
|
|
162
|
+
field('date_of_birth', 4, { dateControl: 'selects' }),
|
|
163
|
+
channel('email', 5),
|
|
164
|
+
channel('sms', 6),
|
|
165
|
+
channel('call', 7)
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
it('starts every field from the stored profile', () => {
|
|
169
|
+
const { container } = renderProfile(COMPOSED);
|
|
170
|
+
|
|
171
|
+
expect(input(container, 'input[name="first_name"]').value).toBe('Ada');
|
|
172
|
+
expect(input(container, 'input[name="last_name"]').value).toBe('Lovelace');
|
|
173
|
+
expect(input(container, 'input[value="female"]').checked).toBe(true);
|
|
174
|
+
expect(input(container, 'input[value="male"]').checked).toBe(false);
|
|
175
|
+
expect(datePart(container, 'day').value).toBe('7');
|
|
176
|
+
expect(datePart(container, 'month').value).toBe('4');
|
|
177
|
+
expect(datePart(container, 'year').value).toBe('1990');
|
|
178
|
+
expect(input(container, 'input[name="date_of_birth"]').value).toBe(
|
|
179
|
+
'1990-04-07'
|
|
180
|
+
);
|
|
181
|
+
expect(input(container, 'input[name="email_allowed"]').checked).toBe(true);
|
|
182
|
+
expect(input(container, 'input[name="sms_allowed"]').checked).toBe(false);
|
|
183
|
+
// `null` is "never asked", which must not be drawn as an agreement.
|
|
184
|
+
expect(input(container, 'input[name="call_allowed"]').checked).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('shows the e-mail without letting it be edited or sent', async () => {
|
|
188
|
+
const { container } = renderProfile(COMPOSED);
|
|
189
|
+
const email = input(container, 'input[name="email"]');
|
|
190
|
+
|
|
191
|
+
expect(email.value).toBe('ada@example.com');
|
|
192
|
+
expect(email.disabled).toBe(true);
|
|
193
|
+
|
|
194
|
+
submit(container);
|
|
195
|
+
|
|
196
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
197
|
+
expect(lastSaved()).not.toHaveProperty('email');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('saves the visible answers, ticks as booleans', async () => {
|
|
201
|
+
const { container } = renderProfile(COMPOSED);
|
|
202
|
+
|
|
203
|
+
fireEvent.click(input(container, 'input[name="sms_allowed"]'));
|
|
204
|
+
fireEvent.click(input(container, 'input[name="email_allowed"]'));
|
|
205
|
+
fireEvent.click(input(container, 'input[value="male"]'));
|
|
206
|
+
fireEvent.change(datePart(container, 'year'), {
|
|
207
|
+
target: { value: '1991' }
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
submit(container);
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalledTimes(1));
|
|
213
|
+
expect(lastSaved()).toEqual({
|
|
214
|
+
first_name: 'Ada',
|
|
215
|
+
last_name: 'Lovelace',
|
|
216
|
+
gender: 'male',
|
|
217
|
+
date_of_birth: '1991-04-07',
|
|
218
|
+
email_allowed: false,
|
|
219
|
+
sms_allowed: true,
|
|
220
|
+
// Shown and left unticked: that is the shopper's answer now.
|
|
221
|
+
call_allowed: false
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('never writes a consent the composition does not show', async () => {
|
|
226
|
+
const { container } = renderProfile([
|
|
227
|
+
field('first_name', 0),
|
|
228
|
+
channel('email', 1),
|
|
229
|
+
// Switched off in the editor: not drawn, so not the shopper's answer.
|
|
230
|
+
channel('whatsapp', 2, { hidden: true })
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
container.querySelector('input[name="whatsapp_allowed"]')
|
|
235
|
+
).toBeNull();
|
|
236
|
+
|
|
237
|
+
submit(container);
|
|
238
|
+
|
|
239
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
240
|
+
const saved = lastSaved();
|
|
241
|
+
|
|
242
|
+
expect(saved).not.toHaveProperty('whatsapp_allowed');
|
|
243
|
+
expect(saved).not.toHaveProperty('sms_allowed');
|
|
244
|
+
expect(saved).not.toHaveProperty('call_allowed');
|
|
245
|
+
expect(saved).toEqual({ first_name: 'Ada', email_allowed: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('re-reads the stored answer when a tick changes type on the canvas', () => {
|
|
249
|
+
const consentBlock = (consentType: string) =>
|
|
250
|
+
block({
|
|
251
|
+
id: 'profile-1',
|
|
252
|
+
type: 'profile-form',
|
|
253
|
+
label: 'Profile Form',
|
|
254
|
+
blocks: [channel(consentType, 0, { id: 'consent-1' })]
|
|
255
|
+
}) as Block;
|
|
256
|
+
const props = {
|
|
257
|
+
placeholderId: 'placeholder-1',
|
|
258
|
+
sectionId: 'section-1',
|
|
259
|
+
currentBreakpoint: 'desktop',
|
|
260
|
+
isDesigner: true
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const { container, rerender } = render(
|
|
264
|
+
<ProfileFormBlock block={consentBlock('email')} {...props} />
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(input(container, 'input[type="checkbox"]').checked).toBe(true);
|
|
268
|
+
|
|
269
|
+
// The author switches the same row to Arama, which was never answered.
|
|
270
|
+
rerender(<ProfileFormBlock block={consentBlock('call')} {...props} />);
|
|
271
|
+
|
|
272
|
+
const tick = input(container, 'input[type="checkbox"]');
|
|
273
|
+
expect(tick.name).toBe('call_allowed');
|
|
274
|
+
expect(tick.checked).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('never makes a contact permission mandatory', async () => {
|
|
278
|
+
const { container } = renderProfile([
|
|
279
|
+
channel('sms', 0, {
|
|
280
|
+
// What the editor seeds on every new Consent Row.
|
|
281
|
+
properties: { consentType: 'sms', required: { desktop: true } }
|
|
282
|
+
})
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
expect(input(container, 'input[name="sms_allowed"]').required).toBe(false);
|
|
286
|
+
|
|
287
|
+
submit(container);
|
|
288
|
+
|
|
289
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
290
|
+
expect(lastSaved()).toEqual({ sms_allowed: false });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('leaves a question that was never answered out instead of blanking it', async () => {
|
|
294
|
+
// Nothing stored and nothing typed: no key is written for any of them.
|
|
295
|
+
profileResult = {
|
|
296
|
+
data: { ...PROFILE, gender: null, date_of_birth: null, phone: null },
|
|
297
|
+
isLoading: false
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const { container } = renderProfile([
|
|
301
|
+
field('gender', 0, { choiceControl: 'radio' }),
|
|
302
|
+
field('date_of_birth', 1, { dateControl: 'selects' }),
|
|
303
|
+
field('phone', 2)
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
submit(container);
|
|
307
|
+
|
|
308
|
+
await waitFor(() =>
|
|
309
|
+
expect(
|
|
310
|
+
container.querySelector('[data-profile-form-success]')
|
|
311
|
+
).not.toBeNull()
|
|
312
|
+
);
|
|
313
|
+
expect(updateProfile).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('asks for the rest of a half-picked birth date, under the field', async () => {
|
|
317
|
+
profileResult = {
|
|
318
|
+
data: { ...PROFILE, date_of_birth: null },
|
|
319
|
+
isLoading: false
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const { container } = renderProfile(
|
|
323
|
+
[field('date_of_birth', 0, { dateControl: 'selects' })],
|
|
324
|
+
{ properties: { requiredMessage: 'Zorunlu alan' } }
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
fireEvent.change(datePart(container, 'day'), { target: { value: '12' } });
|
|
328
|
+
submit(container);
|
|
329
|
+
|
|
330
|
+
await waitFor(() =>
|
|
331
|
+
expect(
|
|
332
|
+
container.querySelector('[data-auth-field-error="date_of_birth"]')
|
|
333
|
+
?.textContent
|
|
334
|
+
).toBe('Zorunlu alan')
|
|
335
|
+
);
|
|
336
|
+
expect(updateProfile).not.toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
// Editing a part clears the field's message, like any other field.
|
|
339
|
+
fireEvent.change(datePart(container, 'month'), { target: { value: '3' } });
|
|
340
|
+
|
|
341
|
+
expect(
|
|
342
|
+
container.querySelector('[data-auth-field-error="date_of_birth"]')
|
|
343
|
+
).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('reports a required gender left unanswered', async () => {
|
|
347
|
+
profileResult = { data: { ...PROFILE, gender: null }, isLoading: false };
|
|
348
|
+
|
|
349
|
+
const { container } = renderProfile([
|
|
350
|
+
field('gender', 0, { choiceControl: 'radio', required: true })
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
submit(container);
|
|
354
|
+
|
|
355
|
+
await waitFor(() =>
|
|
356
|
+
expect(
|
|
357
|
+
container.querySelector('[data-auth-field-error="gender"]')
|
|
358
|
+
).not.toBeNull()
|
|
359
|
+
);
|
|
360
|
+
expect(updateProfile).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('waits for the profile before drawing fields a save would write', () => {
|
|
364
|
+
profileResult = { data: undefined, isLoading: true };
|
|
365
|
+
|
|
366
|
+
const { container } = renderProfile(COMPOSED);
|
|
367
|
+
|
|
368
|
+
expect(container.querySelector('form')).toBeNull();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('draws the fields on the canvas without a profile and never saves there', async () => {
|
|
372
|
+
profileResult = { data: undefined, isLoading: false };
|
|
373
|
+
|
|
374
|
+
const { container } = renderProfile(COMPOSED, { isDesigner: true });
|
|
375
|
+
|
|
376
|
+
expect(container.querySelector('input[name="first_name"]')).not.toBeNull();
|
|
377
|
+
|
|
378
|
+
submit(container);
|
|
379
|
+
|
|
380
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
381
|
+
expect(updateProfile).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('shows the error line when commerce refuses the save', async () => {
|
|
385
|
+
updateProfile.mockImplementationOnce(() => ({
|
|
386
|
+
unwrap: () => Promise.reject(new Error('400'))
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
const { container } = renderProfile([field('first_name', 0)], {
|
|
390
|
+
properties: { errorMessage: 'Kaydedilemedi' }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
submit(container);
|
|
394
|
+
|
|
395
|
+
await waitFor(() =>
|
|
396
|
+
expect(
|
|
397
|
+
container.querySelector('[data-profile-form-error]')?.textContent
|
|
398
|
+
).toBe('Kaydedilemedi')
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('sends a box the shopper emptied, so a stored value can be cleared', async () => {
|
|
403
|
+
const { container } = renderProfile([
|
|
404
|
+
field('first_name', 0),
|
|
405
|
+
field('phone', 1)
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
fireEvent.change(input(container, 'input[name="phone"]'), {
|
|
409
|
+
target: { value: '' }
|
|
410
|
+
});
|
|
411
|
+
submit(container);
|
|
412
|
+
|
|
413
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
414
|
+
expect(lastSaved()).toEqual({ first_name: 'Ada', phone: '' });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('saves a tick whose native box the brand hid behind its own drawing', async () => {
|
|
418
|
+
const { container } = renderProfile([channel('sms', 0)]);
|
|
419
|
+
const box = input(container, 'input[name="sms_allowed"]');
|
|
420
|
+
|
|
421
|
+
// The usual custom-checkbox technique: the native input is hidden and a
|
|
422
|
+
// styled sibling is what the shopper sees and clicks.
|
|
423
|
+
box.style.display = 'none';
|
|
424
|
+
fireEvent.click(box);
|
|
425
|
+
submit(container);
|
|
426
|
+
|
|
427
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
428
|
+
expect(lastSaved()).toEqual({ sms_allowed: true });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('does not write a field whose whole row the design hid', async () => {
|
|
432
|
+
const { container } = renderProfile([
|
|
433
|
+
field('first_name', 0),
|
|
434
|
+
channel('sms', 1)
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
(
|
|
438
|
+
container.querySelector('[data-auth-consent]') as HTMLElement
|
|
439
|
+
).style.display = 'none';
|
|
440
|
+
submit(container);
|
|
441
|
+
|
|
442
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
443
|
+
expect(lastSaved()).toEqual({ first_name: 'Ada' });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('starts and saves a custom tick by its own value', async () => {
|
|
447
|
+
profileResult = {
|
|
448
|
+
data: { ...PROFILE, newsletter: 'yes' },
|
|
449
|
+
isLoading: false
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const { container } = renderProfile([
|
|
453
|
+
block({
|
|
454
|
+
id: 'consent-custom',
|
|
455
|
+
type: 'auth-consent',
|
|
456
|
+
properties: {
|
|
457
|
+
consentType: 'custom',
|
|
458
|
+
name: 'newsletter',
|
|
459
|
+
tickedValue: 'yes'
|
|
460
|
+
},
|
|
461
|
+
order: 0
|
|
462
|
+
})
|
|
463
|
+
]);
|
|
464
|
+
const tick = input(container, 'input[name="newsletter"]');
|
|
465
|
+
|
|
466
|
+
expect(tick.checked).toBe(true);
|
|
467
|
+
|
|
468
|
+
fireEvent.click(tick);
|
|
469
|
+
submit(container);
|
|
470
|
+
|
|
471
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
472
|
+
expect(lastSaved()).toEqual({ newsletter: '' });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('shows a Form State container only in the state it is scoped to', async () => {
|
|
476
|
+
const { container } = renderProfile([
|
|
477
|
+
field('first_name', 0),
|
|
478
|
+
block({
|
|
479
|
+
id: 'state-error',
|
|
480
|
+
type: 'form-state',
|
|
481
|
+
properties: { formState: 'error' },
|
|
482
|
+
order: 1,
|
|
483
|
+
blocks: [
|
|
484
|
+
block({
|
|
485
|
+
id: 'state-error-copy',
|
|
486
|
+
type: 'text',
|
|
487
|
+
value: 'Kaydedilemedi, tekrar deneyin',
|
|
488
|
+
order: 0
|
|
489
|
+
})
|
|
490
|
+
]
|
|
491
|
+
})
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
expect(container.textContent).not.toContain(
|
|
495
|
+
'Kaydedilemedi, tekrar deneyin'
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
updateProfile.mockImplementationOnce(() => ({
|
|
499
|
+
unwrap: () => Promise.reject(new Error('400'))
|
|
500
|
+
}));
|
|
501
|
+
submit(container);
|
|
502
|
+
|
|
503
|
+
await waitFor(() =>
|
|
504
|
+
expect(container.textContent).toContain('Kaydedilemedi, tekrar deneyin')
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('says the profile could not be loaded instead of drawing nothing', () => {
|
|
509
|
+
profileResult = { data: undefined, isLoading: false, isError: true };
|
|
510
|
+
|
|
511
|
+
const { container } = renderProfile([field('first_name', 0)], {
|
|
512
|
+
properties: { errorMessage: 'Bilgileriniz yüklenemedi.' }
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(
|
|
516
|
+
container.querySelector('[data-profile-form-state="unavailable"]')
|
|
517
|
+
?.textContent
|
|
518
|
+
).toBe('Bilgileriniz yüklenemedi.');
|
|
519
|
+
expect(container.querySelector('form')).toBeNull();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('keeps the fixed rows, unchanged, when the form has no children', async () => {
|
|
523
|
+
const { container } = renderProfile([], {
|
|
524
|
+
properties: { showName: false, showPhone: false, showEmail: false }
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(container.querySelector('[data-profile-form-composed]')).toBeNull();
|
|
528
|
+
expect(container.querySelector('input[name="first_name"]')).toBeNull();
|
|
529
|
+
expect(input(container, 'input[name="date_of_birth"]').value).toBe(
|
|
530
|
+
'1990-04-07'
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
submit(container);
|
|
534
|
+
|
|
535
|
+
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
|
|
536
|
+
// The fixed form's body is what published compositions send today.
|
|
537
|
+
expect(lastSaved()).toMatchObject({
|
|
538
|
+
first_name: 'Ada',
|
|
539
|
+
last_name: 'Lovelace',
|
|
540
|
+
date_of_birth: '1990-04-07',
|
|
541
|
+
gender: 'female',
|
|
542
|
+
sms_allowed: false,
|
|
543
|
+
email_allowed: true
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe('birth date selects', () => {
|
|
549
|
+
it('offers month captions when twelve are given', () => {
|
|
550
|
+
const months =
|
|
551
|
+
'Ocak,Şubat,Mart,Nisan,Mayıs,Haziran,Temmuz,Ağustos,Eylül,Ekim,Kasım,Aralık';
|
|
552
|
+
const { container } = renderProfile([
|
|
553
|
+
field('date_of_birth', 0, {
|
|
554
|
+
dateControl: 'selects',
|
|
555
|
+
monthLabels: months,
|
|
556
|
+
monthPlaceholder: 'Ay'
|
|
557
|
+
})
|
|
558
|
+
]);
|
|
559
|
+
const options = Array.from(datePart(container, 'month').options).map(
|
|
560
|
+
(option) => option.textContent
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
expect(options[0]).toBe('Ay');
|
|
564
|
+
expect(options[1]).toBe('Ocak');
|
|
565
|
+
expect(options[12]).toBe('Aralık');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('numbers the months otherwise, and bounds the years by age', () => {
|
|
569
|
+
const { container } = renderProfile([
|
|
570
|
+
field('date_of_birth', 0, {
|
|
571
|
+
dateControl: 'selects',
|
|
572
|
+
minAge: '18',
|
|
573
|
+
maxAge: '20'
|
|
574
|
+
})
|
|
575
|
+
]);
|
|
576
|
+
const thisYear = new Date().getFullYear();
|
|
577
|
+
const years = Array.from(datePart(container, 'year').options)
|
|
578
|
+
.slice(1)
|
|
579
|
+
.map((option) => option.value);
|
|
580
|
+
|
|
581
|
+
expect(datePart(container, 'month').options[1].textContent).toBe('01');
|
|
582
|
+
// The stored 1990 is kept selectable even though it is out of range.
|
|
583
|
+
expect(years).toEqual([
|
|
584
|
+
String(thisYear - 18),
|
|
585
|
+
String(thisYear - 19),
|
|
586
|
+
String(thisYear - 20),
|
|
587
|
+
'1990'
|
|
588
|
+
]);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('clears a day the newly picked month cannot hold', () => {
|
|
592
|
+
profileResult = {
|
|
593
|
+
data: { ...PROFILE, date_of_birth: '1990-01-31' },
|
|
594
|
+
isLoading: false
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const { container } = renderProfile([
|
|
598
|
+
field('date_of_birth', 0, { dateControl: 'selects' })
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
fireEvent.change(datePart(container, 'month'), { target: { value: '4' } });
|
|
602
|
+
|
|
603
|
+
expect(datePart(container, 'day').value).toBe('');
|
|
604
|
+
expect(input(container, 'input[name="date_of_birth"]').value).toBe('');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('splits, joins and sizes dates', () => {
|
|
608
|
+
expect(splitIsoDate('1990-04-07T00:00:00Z')).toEqual({
|
|
609
|
+
year: '1990',
|
|
610
|
+
month: '4',
|
|
611
|
+
day: '7'
|
|
612
|
+
});
|
|
613
|
+
expect(splitIsoDate('')).toEqual({ year: '', month: '', day: '' });
|
|
614
|
+
expect(joinIsoDate('7', '4', '1990')).toBe('1990-04-07');
|
|
615
|
+
expect(joinIsoDate('7', '', '1990')).toBe('');
|
|
616
|
+
expect(daysInMonth('', '2')).toBe(29);
|
|
617
|
+
expect(daysInMonth('2023', '2')).toBe(28);
|
|
618
|
+
expect(daysInMonth('', '')).toBe(31);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('readProfilePayload', () => {
|
|
623
|
+
it('reads the first answer per name and skips empty and disabled controls', () => {
|
|
624
|
+
const form = document.createElement('form');
|
|
625
|
+
|
|
626
|
+
form.innerHTML = `
|
|
627
|
+
<input name="first_name" value="Ada" />
|
|
628
|
+
<input name="first_name" value="Copy" />
|
|
629
|
+
<input name="last_name" value="" />
|
|
630
|
+
<input name="phone" value="555" disabled />
|
|
631
|
+
<input type="radio" name="gender" value="female" />
|
|
632
|
+
<input type="radio" name="gender" value="male" checked />
|
|
633
|
+
<input type="checkbox" name="sms_allowed" />
|
|
634
|
+
<div style="display: none"><input type="checkbox" name="call_allowed" checked /></div>
|
|
635
|
+
<input name="email" value="ada@example.com" />
|
|
636
|
+
`;
|
|
637
|
+
document.body.appendChild(form);
|
|
638
|
+
|
|
639
|
+
expect(readProfilePayload(form)).toEqual({
|
|
640
|
+
first_name: 'Ada',
|
|
641
|
+
gender: 'male',
|
|
642
|
+
sms_allowed: false
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
form.remove();
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('contact-channel ticks in a register form', () => {
|
|
650
|
+
it('lets a tick the design hid fall back to the blanket answer', async () => {
|
|
651
|
+
sessionStatus = 'unauthenticated';
|
|
652
|
+
|
|
653
|
+
const { container } = render(
|
|
654
|
+
<RegisterFormBlock
|
|
655
|
+
block={
|
|
656
|
+
block({
|
|
657
|
+
id: 'register-2',
|
|
658
|
+
type: 'register-form',
|
|
659
|
+
label: 'Register Form',
|
|
660
|
+
blocks: [
|
|
661
|
+
block({
|
|
662
|
+
id: 'f-email',
|
|
663
|
+
type: 'auth-field',
|
|
664
|
+
properties: { fieldType: 'email' },
|
|
665
|
+
order: 0
|
|
666
|
+
}),
|
|
667
|
+
block({
|
|
668
|
+
id: 'f-password',
|
|
669
|
+
type: 'auth-field',
|
|
670
|
+
properties: { fieldType: 'password' },
|
|
671
|
+
order: 1
|
|
672
|
+
}),
|
|
673
|
+
block({
|
|
674
|
+
id: 'c-legal',
|
|
675
|
+
type: 'auth-consent',
|
|
676
|
+
properties: { consentType: 'legal' },
|
|
677
|
+
order: 2
|
|
678
|
+
}),
|
|
679
|
+
block({
|
|
680
|
+
id: 'c-marketing',
|
|
681
|
+
type: 'auth-consent',
|
|
682
|
+
properties: { consentType: 'marketing' },
|
|
683
|
+
order: 3
|
|
684
|
+
}),
|
|
685
|
+
channel('sms', 4)
|
|
686
|
+
]
|
|
687
|
+
}) as Block
|
|
688
|
+
}
|
|
689
|
+
placeholderId="placeholder-1"
|
|
690
|
+
sectionId="section-1"
|
|
691
|
+
currentBreakpoint="mobile"
|
|
692
|
+
isDesigner={false}
|
|
693
|
+
/>
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// The per-channel row is the desktop design; on this breakpoint it is off.
|
|
697
|
+
(
|
|
698
|
+
container.querySelector('[data-auth-consent="sms"]') as HTMLElement
|
|
699
|
+
).style.display = 'none';
|
|
700
|
+
|
|
701
|
+
fireEvent.change(input(container, 'input[name="email"]'), {
|
|
702
|
+
target: { value: 'ada@example.com' }
|
|
703
|
+
});
|
|
704
|
+
fireEvent.change(input(container, 'input[name="password"]'), {
|
|
705
|
+
target: { value: 'hunter2' }
|
|
706
|
+
});
|
|
707
|
+
fireEvent.click(
|
|
708
|
+
input(container, `input[name="${AUTH_CONSENT_NAMES.legal}"]`)
|
|
709
|
+
);
|
|
710
|
+
fireEvent.click(
|
|
711
|
+
input(container, `input[name="${AUTH_CONSENT_NAMES.marketing}"]`)
|
|
712
|
+
);
|
|
713
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
714
|
+
|
|
715
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
716
|
+
|
|
717
|
+
const body = (signIn.mock.calls[0] as unknown[])[1] as Record<
|
|
718
|
+
string,
|
|
719
|
+
unknown
|
|
720
|
+
>;
|
|
721
|
+
|
|
722
|
+
// The shopper said yes to marketing; the hidden row does not say no.
|
|
723
|
+
expect(body.sms_allowed).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('answer their own channel as a boolean', async () => {
|
|
727
|
+
sessionStatus = 'unauthenticated';
|
|
728
|
+
|
|
729
|
+
const { container } = render(
|
|
730
|
+
<RegisterFormBlock
|
|
731
|
+
block={
|
|
732
|
+
block({
|
|
733
|
+
id: 'register-1',
|
|
734
|
+
type: 'register-form',
|
|
735
|
+
label: 'Register Form',
|
|
736
|
+
blocks: [
|
|
737
|
+
block({
|
|
738
|
+
id: 'f-email',
|
|
739
|
+
type: 'auth-field',
|
|
740
|
+
properties: { fieldType: 'email' },
|
|
741
|
+
order: 0
|
|
742
|
+
}),
|
|
743
|
+
block({
|
|
744
|
+
id: 'f-password',
|
|
745
|
+
type: 'auth-field',
|
|
746
|
+
properties: { fieldType: 'password' },
|
|
747
|
+
order: 1
|
|
748
|
+
}),
|
|
749
|
+
block({
|
|
750
|
+
id: 'c-legal',
|
|
751
|
+
type: 'auth-consent',
|
|
752
|
+
properties: { consentType: 'legal' },
|
|
753
|
+
order: 2
|
|
754
|
+
}),
|
|
755
|
+
channel('sms', 3),
|
|
756
|
+
channel('whatsapp', 4)
|
|
757
|
+
]
|
|
758
|
+
}) as Block
|
|
759
|
+
}
|
|
760
|
+
placeholderId="placeholder-1"
|
|
761
|
+
sectionId="section-1"
|
|
762
|
+
currentBreakpoint="desktop"
|
|
763
|
+
isDesigner={false}
|
|
764
|
+
/>
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
fireEvent.change(input(container, 'input[name="email"]'), {
|
|
768
|
+
target: { value: 'ada@example.com' }
|
|
769
|
+
});
|
|
770
|
+
fireEvent.change(input(container, 'input[name="password"]'), {
|
|
771
|
+
target: { value: 'hunter2' }
|
|
772
|
+
});
|
|
773
|
+
fireEvent.click(
|
|
774
|
+
input(container, `input[name="${AUTH_CONSENT_NAMES.legal}"]`)
|
|
775
|
+
);
|
|
776
|
+
fireEvent.click(input(container, 'input[name="sms_allowed"]'));
|
|
777
|
+
|
|
778
|
+
// Unticked in a sign-up, whatever any stored profile says.
|
|
779
|
+
expect(input(container, 'input[name="whatsapp_allowed"]').checked).toBe(
|
|
780
|
+
false
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
784
|
+
|
|
785
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
786
|
+
|
|
787
|
+
const body = (signIn.mock.calls[0] as unknown[])[1] as Record<
|
|
788
|
+
string,
|
|
789
|
+
unknown
|
|
790
|
+
>;
|
|
791
|
+
|
|
792
|
+
expect(body.sms_allowed).toBe(true);
|
|
793
|
+
expect(body.whatsapp_allowed).toBe(false);
|
|
794
|
+
});
|
|
795
|
+
});
|