@zeno-lib/forms 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-zeno-form.d.mts +8 -2
- package/dist/create-zeno-form.mjs +4 -0
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-field-control.d.mts +76 -0
- package/dist/lib/use-field-control.mjs +67 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +5 -0
- package/src/create-zeno-form.tsx +32 -1
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/field-shell.tsx +73 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +3 -0
- package/src/fields/input-field.tsx +14 -48
- package/src/fields/textarea-field.tsx +14 -48
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +6 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-field-control.ts +181 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/use-form.focus-invalid.test.tsx +34 -0
package/src/form-dialog.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { FormProvider } from "@zeno-lib/forms"
|
|
4
|
+
import {
|
|
5
|
+
focusFirstInvalid,
|
|
6
|
+
restoreFocus,
|
|
7
|
+
} from "@zeno-lib/forms/lib/focus-first-invalid"
|
|
8
|
+
import { reportSubmitError } from "@zeno-lib/forms/lib/submit-error"
|
|
4
9
|
import {
|
|
5
10
|
type FormDialogController,
|
|
6
11
|
findFieldElement,
|
|
@@ -10,11 +15,13 @@ import { useUnsavedChangesWarning } from "@zeno-lib/forms/lib/use-unsaved-change
|
|
|
10
15
|
import { type AnyFormApi, useSelector } from "@zeno-lib/forms/tanstack"
|
|
11
16
|
import {
|
|
12
17
|
type FormEvent,
|
|
18
|
+
type KeyboardEvent,
|
|
13
19
|
type ReactElement,
|
|
14
20
|
type ReactNode,
|
|
15
21
|
useId,
|
|
16
22
|
useLayoutEffect,
|
|
17
23
|
useRef,
|
|
24
|
+
useState,
|
|
18
25
|
} from "react"
|
|
19
26
|
import {
|
|
20
27
|
AlertDialog,
|
|
@@ -38,12 +45,15 @@ import {
|
|
|
38
45
|
DialogTrigger,
|
|
39
46
|
} from "@/components/ui/dialog"
|
|
40
47
|
import { Spinner } from "@/components/ui/spinner"
|
|
48
|
+
import { FormError } from "./fields/form-error"
|
|
41
49
|
|
|
42
50
|
type DiscardPromptText = {
|
|
43
51
|
title?: ReactNode
|
|
44
52
|
description?: ReactNode
|
|
45
53
|
confirmLabel?: ReactNode
|
|
46
54
|
cancelLabel?: ReactNode
|
|
55
|
+
/** The Save action's label (`saveFromPrompt`). Defaults to `submitLabel`. */
|
|
56
|
+
saveLabel?: ReactNode
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
// The slice of the `useForm()` API the dialog uses. Structural, so any Zeno /
|
|
@@ -53,6 +63,8 @@ type DialogFormState = {
|
|
|
53
63
|
isSubmitSuccessful: boolean
|
|
54
64
|
isSubmitting: boolean
|
|
55
65
|
isValid: boolean
|
|
66
|
+
submissionAttempts: number
|
|
67
|
+
values: unknown
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
type DialogForm<TValues> = {
|
|
@@ -86,6 +98,12 @@ type FormDialogProps<TValues> = {
|
|
|
86
98
|
footer?: ReactNode
|
|
87
99
|
/** Close after a successful submit. Defaults to `true`. */
|
|
88
100
|
closeOnSubmit?: boolean
|
|
101
|
+
/**
|
|
102
|
+
* Shown above the footer when the submit handler throws. Defaults to
|
|
103
|
+
* `FormError`'s `"Something went wrong. Try again."`. A function gets what
|
|
104
|
+
* was thrown.
|
|
105
|
+
*/
|
|
106
|
+
submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
|
|
89
107
|
/**
|
|
90
108
|
* Ask before closing with unsaved changes, and warn on page unload while
|
|
91
109
|
* open. Defaults to `true`.
|
|
@@ -93,6 +111,16 @@ type FormDialogProps<TValues> = {
|
|
|
93
111
|
guard?: boolean
|
|
94
112
|
/** Copy for the discard prompt. */
|
|
95
113
|
discardPrompt?: DiscardPromptText
|
|
114
|
+
/**
|
|
115
|
+
* Offer Save in the discard prompt, next to Discard: it submits, and closes
|
|
116
|
+
* the dialog if the submit succeeds. Defaults to `false`.
|
|
117
|
+
*/
|
|
118
|
+
saveFromPrompt?: boolean
|
|
119
|
+
/**
|
|
120
|
+
* Disable Save, ⌘/Ctrl+Enter, and the native controls in the form (through
|
|
121
|
+
* a `<fieldset disabled>`). Defaults to `false`.
|
|
122
|
+
*/
|
|
123
|
+
disabled?: boolean
|
|
96
124
|
/** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
|
|
97
125
|
className?: string
|
|
98
126
|
/** Class for the `<form>` element. */
|
|
@@ -100,10 +128,12 @@ type FormDialogProps<TValues> = {
|
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
/**
|
|
103
|
-
* A dialog-hosted form: opens with fresh values per session,
|
|
104
|
-
* footer button outside the `<form>` (via `form="id"`), shows a
|
|
105
|
-
* submitting,
|
|
106
|
-
*
|
|
131
|
+
* A dialog-hosted form: opens with fresh values and fields per session,
|
|
132
|
+
* submits from a footer button outside the `<form>` (via `form="id"`), shows a
|
|
133
|
+
* spinner while submitting, also submits on ⌘/Ctrl+Enter, focuses the first
|
|
134
|
+
* invalid field when the submit fails, shows a form-level error (or a thrown
|
|
135
|
+
* submit) above the footer, closes on success, and asks before discarding
|
|
136
|
+
* unsaved changes (Cancel, ×, Escape, outside press) or leaving the page.
|
|
107
137
|
*/
|
|
108
138
|
function FormDialog<TValues>({
|
|
109
139
|
cancelLabel = "Cancel",
|
|
@@ -112,25 +142,61 @@ function FormDialog<TValues>({
|
|
|
112
142
|
closeOnSubmit = true,
|
|
113
143
|
description,
|
|
114
144
|
dialog,
|
|
145
|
+
disabled = false,
|
|
115
146
|
discardPrompt,
|
|
116
147
|
footer,
|
|
117
148
|
form,
|
|
118
149
|
formClassName,
|
|
119
150
|
guard = true,
|
|
151
|
+
saveFromPrompt = false,
|
|
152
|
+
submitErrorMessage,
|
|
120
153
|
submitLabel = "Save",
|
|
121
154
|
title,
|
|
122
155
|
trigger,
|
|
123
156
|
}: FormDialogProps<TValues>) {
|
|
124
157
|
const formId = useId()
|
|
125
158
|
const popupRef = useRef<HTMLDivElement>(null)
|
|
159
|
+
const formRef = useRef<HTMLFormElement>(null)
|
|
160
|
+
// Set when a Save from the discard prompt fails, so the prompt hands focus
|
|
161
|
+
// to the first invalid field as it closes.
|
|
162
|
+
const focusInvalidOnPromptClose = useRef<boolean>(false)
|
|
163
|
+
// The open prompt hides the dialog from assistive tech, so a form-level
|
|
164
|
+
// error that shows up meanwhile (a Save from the prompt, or a footer Save
|
|
165
|
+
// still running when it opened) is never announced. Remount the error once
|
|
166
|
+
// the prompt has closed, which announces it.
|
|
167
|
+
const announceOnPromptClosed = useRef<boolean>(false)
|
|
168
|
+
const [formErrorKey, setFormErrorKey] = useState(0)
|
|
126
169
|
const { close, defaultValues, focus, isOpen, open, session } = dialog
|
|
170
|
+
// Read after a submit's await: the dialog may have closed, or reopened on
|
|
171
|
+
// another record, while a slow save was in flight.
|
|
172
|
+
const latest = useRef({ isOpen, session })
|
|
173
|
+
latest.current = { isOpen, session }
|
|
127
174
|
|
|
128
175
|
// `isDefaultValue`, not `isDirty`: `isDirty` stays true after the user
|
|
129
176
|
// reverts an edit, `isDefaultValue` compares the values themselves.
|
|
130
177
|
const hasChanges = useSelector(form.store, (state) => !state.isDefaultValue)
|
|
131
178
|
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
|
|
132
|
-
const {
|
|
133
|
-
|
|
179
|
+
const {
|
|
180
|
+
cancelLeave,
|
|
181
|
+
confirmLeave,
|
|
182
|
+
isConfirming,
|
|
183
|
+
isSaving,
|
|
184
|
+
requestLeave,
|
|
185
|
+
saveAndLeave,
|
|
186
|
+
} = useLeaveGuard({
|
|
187
|
+
hasUnsavedChanges: guard && hasChanges,
|
|
188
|
+
onSave: async () => {
|
|
189
|
+
if (!canSubmit()) {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
const saved = await submit()
|
|
193
|
+
focusInvalidOnPromptClose.current = !saved
|
|
194
|
+
return saved
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
// Read once a submit settles, so it sees a prompt that opened meanwhile.
|
|
198
|
+
const isConfirmingRef = useRef(isConfirming)
|
|
199
|
+
isConfirmingRef.current = isConfirming
|
|
134
200
|
useUnsavedChangesWarning(
|
|
135
201
|
form as unknown as AnyFormApi,
|
|
136
202
|
guard && isOpen ? "if-changed" : false
|
|
@@ -145,19 +211,76 @@ function FormDialog<TValues>({
|
|
|
145
211
|
}
|
|
146
212
|
}, [session])
|
|
147
213
|
|
|
214
|
+
// Every way to submit (footer Save, ⌘/Ctrl+Enter, the prompt's Save) checks
|
|
215
|
+
// this first. TanStack only refuses a second `handleSubmit` while one runs on
|
|
216
|
+
// the very first attempt, and `isOpen` is false through the exit animation,
|
|
217
|
+
// while the fields still hold the values just saved.
|
|
218
|
+
function canSubmit(): boolean {
|
|
219
|
+
return isOpen && !(disabled || form.state.isSubmitting)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Resolves whether the form saved. A submit handler that throws leaves
|
|
223
|
+
// `isSubmitSuccessful` false, so the dialog stays open, and `useForm` keeps
|
|
224
|
+
// the error for `FormError`.
|
|
225
|
+
async function submit(): Promise<boolean> {
|
|
226
|
+
await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
|
|
227
|
+
const { isSubmitSuccessful, isValid } = form.state
|
|
228
|
+
const saved = isValid && isSubmitSuccessful
|
|
229
|
+
if (!saved && isConfirmingRef.current) {
|
|
230
|
+
announceOnPromptClosed.current = true
|
|
231
|
+
}
|
|
232
|
+
return saved
|
|
233
|
+
}
|
|
234
|
+
|
|
148
235
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
149
236
|
event.preventDefault()
|
|
150
237
|
event.stopPropagation()
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
} catch {
|
|
154
|
-
return // the submit handler threw; keep the dialog open
|
|
238
|
+
if (!canSubmit()) {
|
|
239
|
+
return
|
|
155
240
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
241
|
+
const node = event.currentTarget
|
|
242
|
+
const focused = node.ownerDocument.activeElement
|
|
243
|
+
const saved = await submit()
|
|
244
|
+
if (!latest.current.isOpen || latest.current.session !== session) {
|
|
245
|
+
return // the session this submit belongs to is over
|
|
246
|
+
}
|
|
247
|
+
if (saved) {
|
|
248
|
+
if (closeOnSubmit) {
|
|
249
|
+
// Close directly, past the guard: the changes are saved. The form is
|
|
250
|
+
// reset once the exit animation completes (see `onOpenChangeComplete`).
|
|
251
|
+
close()
|
|
252
|
+
}
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
// A schema error, or a field error the server returned (`submitAction`).
|
|
256
|
+
if (!form.state.isValid && focusFirstInvalid(node)) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
// A form-level error has no field to fix, so focus stays where it was:
|
|
260
|
+
// on Save, or the field ⌘/Ctrl+Enter was pressed in. Disabling Save while
|
|
261
|
+
// submitting drops its focus to the body, so give it back.
|
|
262
|
+
restoreFocus(focused)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ⌘/Ctrl+Enter submits from anywhere in the popup, a textarea included
|
|
266
|
+
// (plain Enter there inserts a line break). The discard prompt is outside
|
|
267
|
+
// the popup, so its keys never get here.
|
|
268
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
269
|
+
if (
|
|
270
|
+
event.key !== "Enter" ||
|
|
271
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
272
|
+
event.nativeEvent.isComposing ||
|
|
273
|
+
event.defaultPrevented
|
|
274
|
+
) {
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
// Claimed even when it won't submit: it shouldn't click the focused
|
|
278
|
+
// button, nor reach a dialog this one is nested in (React events bubble
|
|
279
|
+
// through portals). `handleSubmit` refuses a disabled or busy dialog.
|
|
280
|
+
event.preventDefault()
|
|
281
|
+
event.stopPropagation()
|
|
282
|
+
if (!(event.repeat || isConfirming)) {
|
|
283
|
+
formRef.current?.requestSubmit()
|
|
161
284
|
}
|
|
162
285
|
}
|
|
163
286
|
|
|
@@ -183,11 +306,14 @@ function FormDialog<TValues>({
|
|
|
183
306
|
<DialogContent
|
|
184
307
|
className={className}
|
|
185
308
|
initialFocus={() => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
309
|
+
// A disabled control can't take focus.
|
|
310
|
+
const target =
|
|
311
|
+
focus && !disabled
|
|
312
|
+
? findFieldElement(popupRef.current, focus)
|
|
313
|
+
: null
|
|
189
314
|
return target ?? true
|
|
190
315
|
}}
|
|
316
|
+
onKeyDown={handleKeyDown}
|
|
191
317
|
ref={popupRef}
|
|
192
318
|
>
|
|
193
319
|
<DialogHeader>
|
|
@@ -195,21 +321,43 @@ function FormDialog<TValues>({
|
|
|
195
321
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
196
322
|
</DialogHeader>
|
|
197
323
|
<FormProvider form={form}>
|
|
198
|
-
<form
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
324
|
+
{/* Around the <form>, not inside it, so `formClassName` child
|
|
325
|
+
selectors (`space-y-4`) still reach the fields. `min-w-0` is
|
|
326
|
+
for a browser that ignores `contents` on a fieldset. */}
|
|
327
|
+
<fieldset className="contents min-w-0" disabled={disabled}>
|
|
328
|
+
{/* Keyed on the session so every open mounts fresh fields. The
|
|
329
|
+
popup stays mounted through its exit animation, so reopening
|
|
330
|
+
before it ends would keep each field's local state (and any
|
|
331
|
+
custom child's) from the previous session. */}
|
|
332
|
+
<form
|
|
333
|
+
className={formClassName}
|
|
334
|
+
id={formId}
|
|
335
|
+
key={session}
|
|
336
|
+
noValidate
|
|
337
|
+
onSubmit={handleSubmit}
|
|
338
|
+
ref={formRef}
|
|
339
|
+
>
|
|
340
|
+
{children}
|
|
341
|
+
</form>
|
|
342
|
+
</fieldset>
|
|
343
|
+
{/* Next to the Save button that failed, and outside the <form>, so
|
|
344
|
+
it stays in view when the fields scroll and adds no child for
|
|
345
|
+
`formClassName` selectors to match. */}
|
|
346
|
+
<FormError
|
|
347
|
+
key={formErrorKey}
|
|
348
|
+
submitErrorMessage={submitErrorMessage}
|
|
349
|
+
/>
|
|
206
350
|
</FormProvider>
|
|
207
351
|
<DialogFooter>
|
|
208
352
|
{footer}
|
|
209
353
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
210
354
|
{cancelLabel}
|
|
211
355
|
</DialogClose>
|
|
212
|
-
<Button
|
|
356
|
+
<Button
|
|
357
|
+
disabled={disabled || isSubmitting}
|
|
358
|
+
form={formId}
|
|
359
|
+
type="submit"
|
|
360
|
+
>
|
|
213
361
|
{isSubmitting && <Spinner />}
|
|
214
362
|
{submitLabel}
|
|
215
363
|
</Button>
|
|
@@ -219,29 +367,65 @@ function FormDialog<TValues>({
|
|
|
219
367
|
popup (so it can finish its own exit animation). */}
|
|
220
368
|
<AlertDialog
|
|
221
369
|
onOpenChange={(next) => {
|
|
222
|
-
|
|
370
|
+
// Stay open while a Save from the prompt is in flight.
|
|
371
|
+
if (!(next || isSaving)) {
|
|
223
372
|
cancelLeave()
|
|
224
373
|
}
|
|
225
374
|
}}
|
|
375
|
+
onOpenChangeComplete={(opened) => {
|
|
376
|
+
if (!opened && announceOnPromptClosed.current) {
|
|
377
|
+
announceOnPromptClosed.current = false
|
|
378
|
+
setFormErrorKey((key) => key + 1)
|
|
379
|
+
}
|
|
380
|
+
}}
|
|
226
381
|
open={isConfirming}
|
|
227
382
|
>
|
|
228
|
-
<AlertDialogContent
|
|
383
|
+
<AlertDialogContent
|
|
384
|
+
// The prompt traps focus while open, so a failed Save's invalid
|
|
385
|
+
// field gets focus as the prompt closes, not from `submit`.
|
|
386
|
+
finalFocus={() => {
|
|
387
|
+
if (!focusInvalidOnPromptClose.current) {
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
focusInvalidOnPromptClose.current = false
|
|
391
|
+
// Base UI focuses what this returns too; focusing here first
|
|
392
|
+
// finds a field that takes focus, skipping disabled or hidden ones.
|
|
393
|
+
return focusFirstInvalid(formRef.current) ?? true
|
|
394
|
+
}}
|
|
395
|
+
size={saveFromPrompt ? "default" : "sm"}
|
|
396
|
+
>
|
|
229
397
|
<AlertDialogHeader>
|
|
230
398
|
<AlertDialogTitle>
|
|
231
|
-
{discardPrompt?.title ??
|
|
399
|
+
{discardPrompt?.title ??
|
|
400
|
+
(saveFromPrompt ? "Save changes?" : "Discard changes?")}
|
|
232
401
|
</AlertDialogTitle>
|
|
233
402
|
<AlertDialogDescription>
|
|
234
403
|
{discardPrompt?.description ??
|
|
235
|
-
|
|
404
|
+
(saveFromPrompt
|
|
405
|
+
? "Your unsaved changes will be lost unless you save them."
|
|
406
|
+
: "Your unsaved changes will be lost.")}
|
|
236
407
|
</AlertDialogDescription>
|
|
237
408
|
</AlertDialogHeader>
|
|
238
409
|
<AlertDialogFooter>
|
|
239
|
-
<AlertDialogCancel>
|
|
410
|
+
<AlertDialogCancel disabled={isSaving}>
|
|
240
411
|
{discardPrompt?.cancelLabel ?? "Keep editing"}
|
|
241
412
|
</AlertDialogCancel>
|
|
242
|
-
<AlertDialogAction
|
|
413
|
+
<AlertDialogAction
|
|
414
|
+
disabled={isSaving}
|
|
415
|
+
onClick={confirmLeave}
|
|
416
|
+
variant="destructive"
|
|
417
|
+
>
|
|
243
418
|
{discardPrompt?.confirmLabel ?? "Discard"}
|
|
244
419
|
</AlertDialogAction>
|
|
420
|
+
{saveFromPrompt && (
|
|
421
|
+
<AlertDialogAction
|
|
422
|
+
disabled={disabled || isSaving || isSubmitting}
|
|
423
|
+
onClick={saveAndLeave}
|
|
424
|
+
>
|
|
425
|
+
{isSaving && <Spinner />}
|
|
426
|
+
{discardPrompt?.saveLabel ?? submitLabel}
|
|
427
|
+
</AlertDialogAction>
|
|
428
|
+
)}
|
|
245
429
|
</AlertDialogFooter>
|
|
246
430
|
</AlertDialogContent>
|
|
247
431
|
</AlertDialog>
|
package/src/form-element.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import type { AnyFormApi } from "@tanstack/react-form"
|
|
|
4
4
|
import type { ComponentProps, FormEvent, ReactNode } from "react"
|
|
5
5
|
|
|
6
6
|
import { FormProvider as RawFormProvider, useFormContext } from "./lib/contexts"
|
|
7
|
+
import { focusFirstInvalid, restoreFocus } from "./lib/focus-first-invalid"
|
|
8
|
+
import { reportSubmitError } from "./lib/submit-error"
|
|
7
9
|
|
|
8
10
|
type FormProviderProps = {
|
|
9
11
|
children: ReactNode
|
|
@@ -28,17 +30,17 @@ function Form({ children, className, ...props }: FormProps) {
|
|
|
28
30
|
event.preventDefault()
|
|
29
31
|
event.stopPropagation()
|
|
30
32
|
const node = event.currentTarget
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
const focused = node.ownerDocument.activeElement
|
|
34
|
+
// Caught so the user can retry; `useForm` keeps it for `FormError`.
|
|
35
|
+
await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
|
|
36
|
+
const { isSubmitSuccessful, isValid } = form.state
|
|
37
|
+
if (!isValid && focusFirstInvalid(node)) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
// A form-level failure has no field to fix, so focus stays where it
|
|
41
|
+
// was, or goes back there if the submit button's disabling dropped it.
|
|
42
|
+
if (!(isValid && isSubmitSuccessful)) {
|
|
43
|
+
restoreFocus(focused)
|
|
42
44
|
}
|
|
43
45
|
}}
|
|
44
46
|
{...props}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from "@zeno-lib/test/testing-library"
|
|
8
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
9
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
10
|
+
import { z } from "zod"
|
|
11
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
12
|
+
import type { ActionResult } from "./lib/action-result"
|
|
13
|
+
import { submitAction } from "./lib/submit-action"
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup()
|
|
17
|
+
vi.unstubAllGlobals()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const NAME = /Name/
|
|
21
|
+
const SUBMIT = { name: "Submit" }
|
|
22
|
+
|
|
23
|
+
const schema = z.object({ name: z.string().min(1, "Required") })
|
|
24
|
+
type Values = z.infer<typeof schema>
|
|
25
|
+
const ADA: Values = { name: "Ada" }
|
|
26
|
+
|
|
27
|
+
// Browsers drop focus to the body when the focused submit button is disabled
|
|
28
|
+
// for the submit; jsdom keeps it there (and can't blur a disabled button).
|
|
29
|
+
// Drop it as a browser would: through an element that is then removed.
|
|
30
|
+
function dropFocus() {
|
|
31
|
+
const sink = document.createElement("input")
|
|
32
|
+
document.body.append(sink)
|
|
33
|
+
sink.focus()
|
|
34
|
+
sink.remove()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Profile({ onSubmit }: { onSubmit: (values: Values) => unknown }) {
|
|
38
|
+
const form = useForm({
|
|
39
|
+
defaultValues: ADA,
|
|
40
|
+
onSubmit: ({ value }) => onSubmit(value),
|
|
41
|
+
schema,
|
|
42
|
+
})
|
|
43
|
+
const { FormError, InputField, ResetButton, SubmitButton } = form
|
|
44
|
+
return (
|
|
45
|
+
<FormProvider form={form}>
|
|
46
|
+
<Form id="profile">
|
|
47
|
+
<InputField label="Name" name="name" />
|
|
48
|
+
</Form>
|
|
49
|
+
{/* Outside the <form>: FormError finds the form through context. */}
|
|
50
|
+
<FormError />
|
|
51
|
+
<ResetButton />
|
|
52
|
+
<SubmitButton form="profile">Submit</SubmitButton>
|
|
53
|
+
</FormProvider>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("<FormError> with <Form>", () => {
|
|
58
|
+
test("shows an action's form-level messages, one per line, until an edit", async () => {
|
|
59
|
+
const user = userEvent.setup()
|
|
60
|
+
const action = () =>
|
|
61
|
+
Promise.resolve<ActionResult<never>>({
|
|
62
|
+
error: {
|
|
63
|
+
fieldErrors: { "owners[3].name": ["Unknown owner"] },
|
|
64
|
+
formErrors: ["Record is locked"],
|
|
65
|
+
},
|
|
66
|
+
ok: false,
|
|
67
|
+
})
|
|
68
|
+
function Harness() {
|
|
69
|
+
const form = useForm({
|
|
70
|
+
defaultValues: { name: "Ada" },
|
|
71
|
+
onSubmit: (submit) => submitAction(submit, action),
|
|
72
|
+
schema,
|
|
73
|
+
})
|
|
74
|
+
const { FormError, InputField, SubmitButton } = form
|
|
75
|
+
return (
|
|
76
|
+
<FormProvider form={form}>
|
|
77
|
+
<Form>
|
|
78
|
+
<InputField label="Name" name="name" />
|
|
79
|
+
<FormError />
|
|
80
|
+
<SubmitButton>Submit</SubmitButton>
|
|
81
|
+
</Form>
|
|
82
|
+
</FormProvider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
render(<Harness />)
|
|
86
|
+
|
|
87
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
88
|
+
const alert = await screen.findByRole("alert")
|
|
89
|
+
expect(
|
|
90
|
+
within(alert)
|
|
91
|
+
.getAllByRole("listitem")
|
|
92
|
+
.map((item) => item.textContent)
|
|
93
|
+
).toEqual(["Record is locked", "Unknown owner"])
|
|
94
|
+
|
|
95
|
+
await user.type(screen.getByLabelText(NAME), "!")
|
|
96
|
+
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull())
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("a thrown submit shows a generic message and leaves the submit button usable", async () => {
|
|
100
|
+
const user = userEvent.setup()
|
|
101
|
+
const report = vi.fn()
|
|
102
|
+
vi.stubGlobal("reportError", report)
|
|
103
|
+
const refused = new Error("connect ECONNREFUSED 10.0.0.1:5432")
|
|
104
|
+
let rejectRetry: (error: Error) => void = () => undefined
|
|
105
|
+
const onSubmit = vi
|
|
106
|
+
.fn<(values: Values) => unknown>()
|
|
107
|
+
.mockImplementationOnce(() => {
|
|
108
|
+
dropFocus()
|
|
109
|
+
throw refused
|
|
110
|
+
})
|
|
111
|
+
.mockImplementationOnce(
|
|
112
|
+
() =>
|
|
113
|
+
new Promise((_, reject) => {
|
|
114
|
+
rejectRetry = reject
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
118
|
+
const submit = screen.getByRole("button", SUBMIT)
|
|
119
|
+
await user.click(submit)
|
|
120
|
+
|
|
121
|
+
const alert = await screen.findByRole("alert")
|
|
122
|
+
expect(alert.textContent).toBe("Something went wrong. Try again.")
|
|
123
|
+
expect(submit.matches(":disabled")).toBe(false)
|
|
124
|
+
await waitFor(() => expect(document.activeElement).toBe(submit))
|
|
125
|
+
// Caught for the retry, but still logged and seen by error monitoring.
|
|
126
|
+
expect(report).toHaveBeenCalledWith(refused)
|
|
127
|
+
|
|
128
|
+
// A retry clears the message while it runs; it comes back if it fails.
|
|
129
|
+
await user.click(submit)
|
|
130
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
|
|
131
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
132
|
+
rejectRetry(new Error("offline"))
|
|
133
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
134
|
+
|
|
135
|
+
await user.type(screen.getByLabelText(NAME), "!")
|
|
136
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("a reset drops a thrown submit's error, even when it restores the same values", async () => {
|
|
140
|
+
const user = userEvent.setup()
|
|
141
|
+
let resolveRetry: () => void = () => undefined
|
|
142
|
+
const onSubmit = vi
|
|
143
|
+
.fn<(values: Values) => unknown>()
|
|
144
|
+
.mockImplementationOnce(() => {
|
|
145
|
+
throw new Error("offline")
|
|
146
|
+
})
|
|
147
|
+
.mockImplementationOnce(
|
|
148
|
+
() =>
|
|
149
|
+
new Promise<void>((resolve) => {
|
|
150
|
+
resolveRetry = resolve
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
154
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
155
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
156
|
+
|
|
157
|
+
await user.click(screen.getByRole("button", { name: "Reset" }))
|
|
158
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
159
|
+
// Back to the failed submit's values and attempt count.
|
|
160
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
161
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
|
|
162
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
163
|
+
resolveRetry()
|
|
164
|
+
await waitFor(() =>
|
|
165
|
+
expect(screen.getByRole("button", SUBMIT).matches(":disabled")).toBe(
|
|
166
|
+
false
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("a Next.js redirect from onSubmit is no error", async () => {
|
|
173
|
+
const user = userEvent.setup()
|
|
174
|
+
const report = vi.fn()
|
|
175
|
+
vi.stubGlobal("reportError", report)
|
|
176
|
+
const onSubmit = vi.fn(() => {
|
|
177
|
+
throw Object.assign(new Error("NEXT_REDIRECT"), {
|
|
178
|
+
digest: "NEXT_REDIRECT;push;/people/1;307;",
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
182
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
183
|
+
|
|
184
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
185
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
186
|
+
expect(report).not.toHaveBeenCalled()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("a form-level message wins over a thrown submit", async () => {
|
|
190
|
+
const user = userEvent.setup()
|
|
191
|
+
const action = () =>
|
|
192
|
+
Promise.resolve<ActionResult<never>>({
|
|
193
|
+
error: { fieldErrors: {}, formErrors: ["Record is locked"] },
|
|
194
|
+
ok: false,
|
|
195
|
+
})
|
|
196
|
+
function Harness() {
|
|
197
|
+
const form = useForm({
|
|
198
|
+
defaultValues: ADA,
|
|
199
|
+
onSubmit: async (submit) => {
|
|
200
|
+
await submitAction(submit, action)
|
|
201
|
+
throw new Error("Not saved")
|
|
202
|
+
},
|
|
203
|
+
schema,
|
|
204
|
+
})
|
|
205
|
+
const { FormError, SubmitButton } = form
|
|
206
|
+
return (
|
|
207
|
+
<FormProvider form={form}>
|
|
208
|
+
<Form>
|
|
209
|
+
<FormError />
|
|
210
|
+
<SubmitButton>Submit</SubmitButton>
|
|
211
|
+
</Form>
|
|
212
|
+
</FormProvider>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
render(<Harness />)
|
|
216
|
+
|
|
217
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
218
|
+
expect((await screen.findByRole("alert")).textContent).toBe(
|
|
219
|
+
"Record is locked"
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe("<FormError> with a <form> of your own", () => {
|
|
225
|
+
test("shows a thrown submit too", async () => {
|
|
226
|
+
const user = userEvent.setup()
|
|
227
|
+
function Harness() {
|
|
228
|
+
const form = useForm({
|
|
229
|
+
defaultValues: ADA,
|
|
230
|
+
onSubmit: () => {
|
|
231
|
+
throw new Error("offline")
|
|
232
|
+
},
|
|
233
|
+
schema,
|
|
234
|
+
})
|
|
235
|
+
const { FormError } = form
|
|
236
|
+
return (
|
|
237
|
+
<FormProvider form={form}>
|
|
238
|
+
<form
|
|
239
|
+
onSubmit={(event) => {
|
|
240
|
+
event.preventDefault()
|
|
241
|
+
form.handleSubmit().catch(() => undefined)
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<FormError />
|
|
245
|
+
<button type="submit">Submit</button>
|
|
246
|
+
</form>
|
|
247
|
+
</FormProvider>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
render(<Harness />)
|
|
251
|
+
|
|
252
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
253
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
254
|
+
})
|
|
255
|
+
})
|