@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,326 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import OtpFormBlock from '../blocks/otp-form-block';
|
|
6
|
+
import { containsAuthErrorBlock } from '../auth/form-error-context';
|
|
7
|
+
import { Block } from '../theme-block';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where a form-level message lands.
|
|
11
|
+
*
|
|
12
|
+
* The form used to append it after everything it drew, and in a composition
|
|
13
|
+
* that is wrong twice: the CTA is one of the children, so a failed login wrote
|
|
14
|
+
* "e-posta veya şifre hatalı" BELOW the button that had just been pressed, and
|
|
15
|
+
* the padding the design puts on its own wrapper never reaches a node appended
|
|
16
|
+
* outside that wrapper — so the line also sat flush against the drawer's edge
|
|
17
|
+
* while every other line was inset by 24px.
|
|
18
|
+
*
|
|
19
|
+
* The form cannot fix either from where it stands: it does not know which child
|
|
20
|
+
* is the CTA, nor which wrapper carries the padding. So it publishes the message
|
|
21
|
+
* and a Form Error block draws it where the design says. These cases hold both
|
|
22
|
+
* halves of that contract — the block draws it, and the form stands down — plus
|
|
23
|
+
* the fallback that keeps a composition which has not placed one from going
|
|
24
|
+
* silent.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const signIn = jest.fn(() =>
|
|
28
|
+
Promise.resolve({ error: 'CredentialsSignin', url: null })
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
jest.mock('next-auth/react', () => ({
|
|
32
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
33
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock('next/navigation', () => ({
|
|
37
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
41
|
+
({
|
|
42
|
+
id: 'blk',
|
|
43
|
+
type: 'text',
|
|
44
|
+
label: 'Block',
|
|
45
|
+
value: '',
|
|
46
|
+
order: 0,
|
|
47
|
+
hidden: false,
|
|
48
|
+
properties: {},
|
|
49
|
+
styles: {},
|
|
50
|
+
...overrides
|
|
51
|
+
}) as Block;
|
|
52
|
+
|
|
53
|
+
const emailField = block({
|
|
54
|
+
id: 'field-email',
|
|
55
|
+
type: 'auth-field',
|
|
56
|
+
properties: { fieldType: 'email', label: 'E-mail' },
|
|
57
|
+
order: 0
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const passwordField = block({
|
|
61
|
+
id: 'field-password',
|
|
62
|
+
type: 'auth-field',
|
|
63
|
+
properties: { fieldType: 'password', label: 'Password' },
|
|
64
|
+
order: 1
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const errorBlock = (properties: Record<string, unknown> = {}) =>
|
|
68
|
+
block({
|
|
69
|
+
id: 'error-row',
|
|
70
|
+
type: 'auth-error',
|
|
71
|
+
label: 'Form Error',
|
|
72
|
+
properties,
|
|
73
|
+
order: 2
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** The composed CTA, which is what the message used to end up underneath. */
|
|
77
|
+
const ctaBlock = block({
|
|
78
|
+
id: 'cta',
|
|
79
|
+
type: 'button',
|
|
80
|
+
label: 'CTA',
|
|
81
|
+
properties: { text: 'Devam Et', tag: 'button', buttonType: 'submit' },
|
|
82
|
+
order: 3
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const renderLoginForm = (children: Block[], isDesigner = false) =>
|
|
86
|
+
render(
|
|
87
|
+
<AuthFormBlock
|
|
88
|
+
block={
|
|
89
|
+
block({
|
|
90
|
+
id: 'auth-form-1',
|
|
91
|
+
type: 'auth-form',
|
|
92
|
+
label: 'Auth Form',
|
|
93
|
+
properties: { showSubmit: false },
|
|
94
|
+
blocks: children
|
|
95
|
+
}) as Block
|
|
96
|
+
}
|
|
97
|
+
placeholderId="placeholder-1"
|
|
98
|
+
sectionId="section-1"
|
|
99
|
+
currentBreakpoint="desktop"
|
|
100
|
+
isDesigner={isDesigner}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** Fails one submit, which is the only way to a real form-level message. */
|
|
105
|
+
const failASubmit = async (container: HTMLElement) => {
|
|
106
|
+
fireEvent.change(container.querySelector('input[name="email"]')!, {
|
|
107
|
+
target: { value: 'someone@example.com' }
|
|
108
|
+
});
|
|
109
|
+
fireEvent.change(container.querySelector('input[name="password"]')!, {
|
|
110
|
+
target: { value: 'hunter2' }
|
|
111
|
+
});
|
|
112
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
signIn.mockClear();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('the Form Error block', () => {
|
|
122
|
+
it('draws the message where the design put it, and the form stops appending its own', async () => {
|
|
123
|
+
const { container } = renderLoginForm([
|
|
124
|
+
emailField,
|
|
125
|
+
passwordField,
|
|
126
|
+
errorBlock(),
|
|
127
|
+
ctaBlock
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await failASubmit(container);
|
|
131
|
+
|
|
132
|
+
const authored = await waitFor(() =>
|
|
133
|
+
container.querySelector('[data-auth-error]')
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(authored).not.toBeNull();
|
|
137
|
+
expect(authored).toHaveAttribute('role', 'alert');
|
|
138
|
+
expect(authored).toHaveAttribute('data-auth-error-state', 'error');
|
|
139
|
+
// The form's own node is what used to land under the button; with the block
|
|
140
|
+
// placed there must be exactly one message on the page, not two.
|
|
141
|
+
expect(container.querySelector('[data-auth-form-error]')).toBeNull();
|
|
142
|
+
|
|
143
|
+
// And it is where it was authored: BEFORE the CTA, not after it.
|
|
144
|
+
const cta = screen.getByRole('button', { name: 'Devam Et' });
|
|
145
|
+
expect(
|
|
146
|
+
authored!.compareDocumentPosition(cta) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
147
|
+
).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls back to the form's own node for a composition that has not placed one", async () => {
|
|
151
|
+
const { container } = renderLoginForm([
|
|
152
|
+
emailField,
|
|
153
|
+
passwordField,
|
|
154
|
+
ctaBlock
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
await failASubmit(container);
|
|
158
|
+
|
|
159
|
+
const fallback = await waitFor(() =>
|
|
160
|
+
container.querySelector('[data-auth-form-error]')
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(fallback).not.toBeNull();
|
|
164
|
+
expect(container.querySelector('[data-auth-error]')).toBeNull();
|
|
165
|
+
// Documented rather than desired: appended after every child, which is
|
|
166
|
+
// below the CTA. That is the placement the block exists to replace.
|
|
167
|
+
const cta = screen.getByRole('button', { name: 'Devam Et' });
|
|
168
|
+
expect(
|
|
169
|
+
fallback!.compareDocumentPosition(cta) & Node.DOCUMENT_POSITION_PRECEDING
|
|
170
|
+
).toBeTruthy();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('shows nothing at all until the form has something to say', () => {
|
|
174
|
+
const { container } = renderLoginForm([
|
|
175
|
+
emailField,
|
|
176
|
+
passwordField,
|
|
177
|
+
errorBlock(),
|
|
178
|
+
ctaBlock
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
expect(container.querySelector('[data-auth-error]')).toBeNull();
|
|
182
|
+
expect(container.textContent).not.toContain('went wrong');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('draws a preview in the canvas so the state can be dressed without failing a login', () => {
|
|
186
|
+
const { container } = renderLoginForm(
|
|
187
|
+
[
|
|
188
|
+
emailField,
|
|
189
|
+
passwordField,
|
|
190
|
+
errorBlock({ previewMessage: 'Hatalı giriş.' }),
|
|
191
|
+
ctaBlock
|
|
192
|
+
],
|
|
193
|
+
true
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const preview = container.querySelector('[data-auth-error]');
|
|
197
|
+
|
|
198
|
+
expect(preview).not.toBeNull();
|
|
199
|
+
expect(preview).toHaveTextContent('Hatalı giriş.');
|
|
200
|
+
expect(preview).toHaveAttribute('data-auth-error-state', 'preview');
|
|
201
|
+
// A designer's screen reader must not announce a failure that never
|
|
202
|
+
// happened.
|
|
203
|
+
expect(preview).not.toHaveAttribute('role');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('an OTP form whose phases are composed separately', () => {
|
|
208
|
+
const phase = (key: string, children: Block[]) =>
|
|
209
|
+
block({
|
|
210
|
+
id: `phase-${key}`,
|
|
211
|
+
type: 'otp-phase',
|
|
212
|
+
label: key,
|
|
213
|
+
properties: { phaseKey: key },
|
|
214
|
+
blocks: children,
|
|
215
|
+
order: key === 'phone' ? 0 : 1
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const renderOtp = (children: Block[]) =>
|
|
219
|
+
render(
|
|
220
|
+
<OtpFormBlock
|
|
221
|
+
block={
|
|
222
|
+
block({
|
|
223
|
+
id: 'otp-form-1',
|
|
224
|
+
type: 'otp-form',
|
|
225
|
+
label: 'OTP Form',
|
|
226
|
+
properties: { collectPhone: true },
|
|
227
|
+
blocks: children
|
|
228
|
+
}) as Block
|
|
229
|
+
}
|
|
230
|
+
placeholderId="placeholder-1"
|
|
231
|
+
sectionId="section-1"
|
|
232
|
+
currentBreakpoint="desktop"
|
|
233
|
+
isDesigner
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it('does not draw an error block that belongs to the phase it is not showing', () => {
|
|
238
|
+
const { container } = renderOtp([
|
|
239
|
+
phase('phone', [
|
|
240
|
+
block({
|
|
241
|
+
id: 'phone-field',
|
|
242
|
+
type: 'auth-field',
|
|
243
|
+
properties: { fieldType: 'phone' }
|
|
244
|
+
})
|
|
245
|
+
]),
|
|
246
|
+
phase('code', [errorBlock({ previewMessage: 'Kod hatalı.' })])
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
expect(container.querySelector('[data-otp-form]')).not.toBeNull();
|
|
250
|
+
// The phone phase is up, so the code phase's block is not on screen — and a
|
|
251
|
+
// block that is not rendered cannot carry the message, which is why the
|
|
252
|
+
// form asks about the RENDERED children before standing down. The rule
|
|
253
|
+
// itself is pinned on `containsAuthErrorBlock` below.
|
|
254
|
+
expect(container.querySelector('[data-auth-error]')).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('stands down for an error block among the chrome both phases draw', () => {
|
|
258
|
+
const { container } = renderOtp([
|
|
259
|
+
errorBlock({ previewMessage: 'Kod hatalı.' }),
|
|
260
|
+
phase('phone', [
|
|
261
|
+
block({
|
|
262
|
+
id: 'phone-field',
|
|
263
|
+
type: 'auth-field',
|
|
264
|
+
properties: { fieldType: 'phone' }
|
|
265
|
+
})
|
|
266
|
+
])
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
// Shared chrome renders in both halves, so the block is on screen and the
|
|
270
|
+
// canvas preview proves it is the one that will carry the message.
|
|
271
|
+
expect(container.querySelector('[data-auth-error]')).toHaveTextContent(
|
|
272
|
+
'Kod hatalı.'
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('the rule the form uses to decide whether to stand down', () => {
|
|
278
|
+
const errorAt = (children: Block[]) => [
|
|
279
|
+
block({ id: 'wrapper', type: 'group', blocks: children })
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
it('finds an error block however deep the composition buried it', () => {
|
|
283
|
+
expect(containsAuthErrorBlock(errorAt(errorAt([errorBlock()])))).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('answers no for an error block the composition has hidden', () => {
|
|
287
|
+
// The forms filter their OWN children before asking, but the recursion did
|
|
288
|
+
// not: a Form Error tucked into a Group and then hidden counted as
|
|
289
|
+
// authored, silenced the form's built-in message, and drew nothing itself.
|
|
290
|
+
// The shopper was told nothing at all.
|
|
291
|
+
const hidden = { ...errorBlock(), hidden: true } as Block;
|
|
292
|
+
|
|
293
|
+
expect(containsAuthErrorBlock(errorAt([hidden]))).toBe(false);
|
|
294
|
+
expect(
|
|
295
|
+
containsAuthErrorBlock([
|
|
296
|
+
block({
|
|
297
|
+
id: 'wrapper',
|
|
298
|
+
type: 'group',
|
|
299
|
+
hidden: true,
|
|
300
|
+
blocks: [errorBlock()]
|
|
301
|
+
})
|
|
302
|
+
])
|
|
303
|
+
).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('answers no for a composition that has none', () => {
|
|
307
|
+
expect(containsAuthErrorBlock(errorAt([emailField, ctaBlock]))).toBe(false);
|
|
308
|
+
expect(containsAuthErrorBlock(undefined)).toBe(false);
|
|
309
|
+
expect(containsAuthErrorBlock([])).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('is asked of the rendered subset, so a hidden phase cannot silence the form', () => {
|
|
313
|
+
// What the OTP form passes while the phone phase is up: the shared chrome
|
|
314
|
+
// plus that phase. The code phase — and the error block inside it — is not
|
|
315
|
+
// in the list, so the answer is no and the form keeps its own message.
|
|
316
|
+
const sharedChrome = block({ id: 'logo', type: 'image' });
|
|
317
|
+
const phonePhase = block({
|
|
318
|
+
id: 'phase-phone',
|
|
319
|
+
type: 'otp-phase',
|
|
320
|
+
properties: { phaseKey: 'phone' },
|
|
321
|
+
blocks: [emailField]
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(containsAuthErrorBlock([sharedChrome, phonePhase])).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import { Block } from '../theme-block';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A composed auth form validates its own fields.
|
|
9
|
+
*
|
|
10
|
+
* It used to leave the job to the browser: `required` on the inputs and no
|
|
11
|
+
* `noValidate`, so an empty submit raised the native bubble — the browser's
|
|
12
|
+
* copy, over the field, gone on the next click, and nothing a brand can style.
|
|
13
|
+
* The storefront's own auth pages draw the message under the field instead, so
|
|
14
|
+
* these hold that shape: our message, in the DOM, per field, cleared on edit.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/' }));
|
|
18
|
+
|
|
19
|
+
jest.mock('next-auth/react', () => ({
|
|
20
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
21
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('next/navigation', () => ({
|
|
25
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const authField = (
|
|
29
|
+
id: string,
|
|
30
|
+
fieldType: string,
|
|
31
|
+
order: number,
|
|
32
|
+
properties: Record<string, unknown> = {}
|
|
33
|
+
): Block => ({
|
|
34
|
+
id,
|
|
35
|
+
type: 'auth-field',
|
|
36
|
+
label: id,
|
|
37
|
+
properties: { fieldType, ...properties },
|
|
38
|
+
styles: {},
|
|
39
|
+
order,
|
|
40
|
+
hidden: false
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const formBlock = (
|
|
44
|
+
children: Block[],
|
|
45
|
+
properties: Record<string, unknown> = {}
|
|
46
|
+
): Block => ({
|
|
47
|
+
id: 'auth-form-1',
|
|
48
|
+
type: 'auth-form',
|
|
49
|
+
label: 'Auth Form',
|
|
50
|
+
properties,
|
|
51
|
+
styles: {},
|
|
52
|
+
order: 0,
|
|
53
|
+
hidden: false,
|
|
54
|
+
blocks: children
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const renderForm = (
|
|
58
|
+
children: Block[],
|
|
59
|
+
properties: Record<string, unknown> = {}
|
|
60
|
+
) =>
|
|
61
|
+
render(
|
|
62
|
+
<AuthFormBlock
|
|
63
|
+
block={formBlock(children, properties)}
|
|
64
|
+
placeholderId="placeholder-1"
|
|
65
|
+
sectionId="section-1"
|
|
66
|
+
currentBreakpoint="desktop"
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const composedFields = [
|
|
71
|
+
authField('field-email', 'email', 0),
|
|
72
|
+
authField('field-password', 'password', 1)
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const submitForm = () => {
|
|
76
|
+
fireEvent.submit(document.querySelector('form') as HTMLFormElement);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe('composed auth form validation', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
signIn.mockClear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('turns the native bubble off', () => {
|
|
85
|
+
renderForm(composedFields);
|
|
86
|
+
|
|
87
|
+
expect((document.querySelector('form') as HTMLFormElement).noValidate).toBe(
|
|
88
|
+
true
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('fills the block wrapper it sits in', () => {
|
|
93
|
+
renderForm(composedFields);
|
|
94
|
+
|
|
95
|
+
// The wrapper is a flex row and this form is its single item, so without
|
|
96
|
+
// the class the whole form shrink-wraps to its longest line of text.
|
|
97
|
+
expect(
|
|
98
|
+
(document.querySelector('form') as HTMLFormElement).classList
|
|
99
|
+
).toContain('w-full');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('draws its own message under every empty required field', () => {
|
|
103
|
+
renderForm(composedFields);
|
|
104
|
+
|
|
105
|
+
submitForm();
|
|
106
|
+
|
|
107
|
+
expect(screen.getAllByText('This field is required.')).toHaveLength(2);
|
|
108
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('marks the offending input and focuses the first of them', () => {
|
|
112
|
+
renderForm(composedFields);
|
|
113
|
+
|
|
114
|
+
submitForm();
|
|
115
|
+
|
|
116
|
+
const email = document.querySelector(
|
|
117
|
+
'input[name="email"]'
|
|
118
|
+
) as HTMLInputElement;
|
|
119
|
+
|
|
120
|
+
expect(email.getAttribute('aria-invalid')).toBe('true');
|
|
121
|
+
expect(document.activeElement).toBe(email);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('takes the brand copy from the block', () => {
|
|
125
|
+
renderForm(composedFields, { requiredMessage: 'Bu alan gereklidir.' });
|
|
126
|
+
|
|
127
|
+
submitForm();
|
|
128
|
+
|
|
129
|
+
expect(screen.getAllByText('Bu alan gereklidir.')).toHaveLength(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('separates a wrong value from a missing one', () => {
|
|
133
|
+
renderForm(composedFields, {
|
|
134
|
+
requiredMessage: 'Required.',
|
|
135
|
+
invalidMessage: 'Enter a valid e-mail.'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
fireEvent.change(document.querySelector('input[name="email"]')!, {
|
|
139
|
+
target: { value: 'not-an-email' }
|
|
140
|
+
});
|
|
141
|
+
submitForm();
|
|
142
|
+
|
|
143
|
+
expect(screen.getByText('Enter a valid e-mail.')).toBeTruthy();
|
|
144
|
+
// The password is still empty, and still says so.
|
|
145
|
+
expect(screen.getByText('Required.')).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('drops a field message as soon as that field is edited', () => {
|
|
149
|
+
renderForm(composedFields);
|
|
150
|
+
|
|
151
|
+
submitForm();
|
|
152
|
+
expect(screen.getAllByText('This field is required.')).toHaveLength(2);
|
|
153
|
+
|
|
154
|
+
// `input`, not `change`: this is what a keystroke raises.
|
|
155
|
+
fireEvent.input(document.querySelector('input[name="email"]')!, {
|
|
156
|
+
target: { value: 'shopper@example.com' }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// The password's message stays: it is still empty.
|
|
160
|
+
expect(screen.getAllByText('This field is required.')).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('leaves a field the designer made optional alone', () => {
|
|
164
|
+
renderForm([
|
|
165
|
+
authField('field-email', 'email', 0),
|
|
166
|
+
authField('field-phone', 'phone', 1, { required: false })
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
submitForm();
|
|
170
|
+
|
|
171
|
+
expect(screen.getAllByText('This field is required.')).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('submits once every field is filled in', () => {
|
|
175
|
+
renderForm(composedFields);
|
|
176
|
+
|
|
177
|
+
fireEvent.change(document.querySelector('input[name="email"]')!, {
|
|
178
|
+
target: { value: 'shopper@example.com' }
|
|
179
|
+
});
|
|
180
|
+
fireEvent.change(document.querySelector('input[name="password"]')!, {
|
|
181
|
+
target: { value: 'hunter2' }
|
|
182
|
+
});
|
|
183
|
+
submitForm();
|
|
184
|
+
|
|
185
|
+
expect(signIn).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(screen.queryByText('This field is required.')).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('fixed-layout auth form validation', () => {
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
signIn.mockClear();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('draws the message under its own fields too', () => {
|
|
196
|
+
renderForm([], { requiredMessage: 'Bu alan gereklidir.' });
|
|
197
|
+
|
|
198
|
+
submitForm();
|
|
199
|
+
|
|
200
|
+
// The uncomposed form draws e-mail and password itself.
|
|
201
|
+
expect(screen.getAllByText('Bu alan gereklidir.')).toHaveLength(2);
|
|
202
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
});
|