@zeno-lib/forms 0.4.0 → 0.5.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.
@@ -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, submits from a
104
- * footer button outside the `<form>` (via `form="id"`), shows a spinner while
105
- * submitting, closes on success, and asks before discarding unsaved changes
106
- * (Cancel, ×, Escape, outside press) or leaving the page.
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 { cancelLeave, confirmLeave, isConfirming, requestLeave } =
133
- useLeaveGuard({ hasUnsavedChanges: guard && hasChanges })
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
- try {
152
- await form.handleSubmit()
153
- } catch {
154
- return // the submit handler threw; keep the dialog open
238
+ if (!canSubmit()) {
239
+ return
155
240
  }
156
- const { isSubmitSuccessful, isValid } = form.state
157
- if (closeOnSubmit && isSubmitSuccessful && isValid) {
158
- // Close directly, past the guard: the changes are saved. The form is
159
- // reset once the exit animation completes (see `onOpenChangeComplete`).
160
- close()
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
- const target = focus
187
- ? findFieldElement(popupRef.current, focus)
188
- : null
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
- className={formClassName}
200
- id={formId}
201
- noValidate
202
- onSubmit={handleSubmit}
203
- >
204
- {children}
205
- </form>
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 disabled={isSubmitting} form={formId} type="submit">
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
- if (!next) {
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 size="sm">
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 ?? "Discard changes?"}
399
+ {discardPrompt?.title ??
400
+ (saveFromPrompt ? "Save changes?" : "Discard changes?")}
232
401
  </AlertDialogTitle>
233
402
  <AlertDialogDescription>
234
403
  {discardPrompt?.description ??
235
- "Your unsaved changes will be lost."}
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 onClick={confirmLeave} variant="destructive">
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>
@@ -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
- await Promise.resolve(form.handleSubmit()).catch(() => undefined)
32
- if (!form.state.isValid) {
33
- // First invalid control in *this* form; a group root (radio group,
34
- // slider) hands focus to its first tabbable child.
35
- const invalid = node.querySelector<HTMLElement>(
36
- '[aria-invalid="true"]'
37
- )
38
- const targets = invalid
39
- ? [invalid, ...invalid.querySelectorAll<HTMLElement>("*")]
40
- : []
41
- targets.find((el) => el.tabIndex >= 0)?.focus()
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
+ })