@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.test.tsx
CHANGED
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
import {
|
|
2
|
+
act,
|
|
2
3
|
cleanup,
|
|
3
4
|
render,
|
|
4
5
|
screen,
|
|
5
6
|
waitFor,
|
|
7
|
+
within,
|
|
6
8
|
} from "@zeno-lib/test/testing-library"
|
|
7
9
|
import userEvent from "@zeno-lib/test/user-event"
|
|
8
|
-
import {
|
|
10
|
+
import { type ReactNode, useState } from "react"
|
|
11
|
+
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
|
|
9
12
|
import { z } from "zod"
|
|
10
13
|
import { useForm } from "./create-form"
|
|
11
14
|
import { FormDialog } from "./form-dialog"
|
|
15
|
+
import type { ActionResult } from "./lib/action-result"
|
|
16
|
+
import { submitAction } from "./lib/submit-action"
|
|
12
17
|
import { useFormDialog } from "./lib/use-form-dialog"
|
|
13
18
|
|
|
14
19
|
const NAME = /Name/
|
|
15
20
|
const EMAIL = /Email/
|
|
21
|
+
const INNER_NAME = /Inner name/
|
|
22
|
+
|
|
23
|
+
const animatable = HTMLElement.prototype as {
|
|
24
|
+
getAnimations?: () => unknown[]
|
|
25
|
+
}
|
|
16
26
|
|
|
17
27
|
afterEach(() => {
|
|
28
|
+
animatable.getAnimations = undefined
|
|
18
29
|
cleanup()
|
|
30
|
+
vi.unstubAllGlobals()
|
|
19
31
|
})
|
|
20
32
|
|
|
33
|
+
// jsdom runs no CSS animations, so Base UI unmounts a closing popup at once.
|
|
34
|
+
// Report an animation that never ends, as a browser does while the exit
|
|
35
|
+
// animation plays, so the popup stays mounted after it closes.
|
|
36
|
+
function holdExitAnimation() {
|
|
37
|
+
animatable.getAnimations = () => [{ finished: new Promise(() => undefined) }]
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
const schema = z.object({
|
|
22
41
|
email: z.string(),
|
|
23
42
|
name: z.string().min(1, "Required"),
|
|
@@ -27,16 +46,28 @@ const BLANK: Values = { email: "", name: "" }
|
|
|
27
46
|
const ROW: Values = { email: "ada@example.com", name: "Ada" }
|
|
28
47
|
|
|
29
48
|
function Harness({
|
|
49
|
+
action,
|
|
50
|
+
children,
|
|
30
51
|
onSubmit = vi.fn(),
|
|
31
52
|
closeOnSubmit,
|
|
53
|
+
disabled,
|
|
54
|
+
saveFromPrompt,
|
|
55
|
+
submitErrorMessage,
|
|
32
56
|
}: {
|
|
57
|
+
/** Submit through `submitAction` with this server action instead. */
|
|
58
|
+
action?: (values: Values) => Promise<ActionResult<unknown>>
|
|
59
|
+
children?: ReactNode
|
|
33
60
|
onSubmit?: (values: Values) => unknown
|
|
34
61
|
closeOnSubmit?: boolean
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
saveFromPrompt?: boolean
|
|
64
|
+
submitErrorMessage?: (error: unknown) => ReactNode
|
|
35
65
|
}) {
|
|
36
66
|
const dialog = useFormDialog({ defaultValues: BLANK })
|
|
37
67
|
const form = useForm({
|
|
38
68
|
defaultValues: dialog.defaultValues,
|
|
39
|
-
onSubmit: (
|
|
69
|
+
onSubmit: (submit) =>
|
|
70
|
+
action ? submitAction(submit, action) : onSubmit(submit.value),
|
|
40
71
|
schema,
|
|
41
72
|
})
|
|
42
73
|
const { InputField } = form
|
|
@@ -54,16 +85,55 @@ function Harness({
|
|
|
54
85
|
<FormDialog
|
|
55
86
|
closeOnSubmit={closeOnSubmit}
|
|
56
87
|
dialog={dialog}
|
|
88
|
+
disabled={disabled}
|
|
57
89
|
form={form}
|
|
90
|
+
saveFromPrompt={saveFromPrompt}
|
|
91
|
+
submitErrorMessage={submitErrorMessage}
|
|
58
92
|
title="Person"
|
|
59
93
|
>
|
|
60
94
|
<InputField label="Name" name="name" />
|
|
61
95
|
<InputField label="Email" name="email" />
|
|
96
|
+
{children}
|
|
62
97
|
</FormDialog>
|
|
63
98
|
</>
|
|
64
99
|
)
|
|
65
100
|
}
|
|
66
101
|
|
|
102
|
+
// Browsers drop focus to the body when the focused Save button is disabled
|
|
103
|
+
// for the submit; jsdom keeps it there (and can't blur a disabled button).
|
|
104
|
+
// Drop it as a browser would: through an element that is then removed.
|
|
105
|
+
function dropFocus() {
|
|
106
|
+
const sink = document.createElement("input")
|
|
107
|
+
document.body.append(sink)
|
|
108
|
+
sink.focus()
|
|
109
|
+
sink.remove()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The alerts added where assistive tech could see them. One added under an
|
|
113
|
+
// `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
|
|
114
|
+
// announced, not even once that ancestor is shown again.
|
|
115
|
+
function watchAnnouncedAlerts(): string[] {
|
|
116
|
+
const announced: string[] = []
|
|
117
|
+
const observer = new MutationObserver((records) => {
|
|
118
|
+
for (const node of records.flatMap((record) => [...record.addedNodes])) {
|
|
119
|
+
if (!(node instanceof Element)) {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
for (const alert of [node, ...node.querySelectorAll("*")]) {
|
|
123
|
+
if (
|
|
124
|
+
alert.getAttribute("role") === "alert" &&
|
|
125
|
+
!alert.closest('[aria-hidden="true"]')
|
|
126
|
+
) {
|
|
127
|
+
announced.push(alert.textContent ?? "")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
133
|
+
onTestFinished(() => observer.disconnect())
|
|
134
|
+
return announced
|
|
135
|
+
}
|
|
136
|
+
|
|
67
137
|
// Open the "Edit" session and wait for its initial focus (on Email) to land,
|
|
68
138
|
// so later typing isn't raced by the dialog's focus management.
|
|
69
139
|
async function openEdit(user: ReturnType<typeof userEvent.setup>) {
|
|
@@ -158,4 +228,425 @@ describe("FormDialog", () => {
|
|
|
158
228
|
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
159
229
|
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
160
230
|
})
|
|
231
|
+
|
|
232
|
+
test("a failed Save focuses the first invalid field", async () => {
|
|
233
|
+
const user = userEvent.setup()
|
|
234
|
+
render(<Harness />)
|
|
235
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
236
|
+
const name = await screen.findByLabelText(NAME)
|
|
237
|
+
// The dialog's initial focus also lands on Name; let it settle so only
|
|
238
|
+
// the failed Save can bring focus back from the Save button.
|
|
239
|
+
await waitFor(() => expect(document.activeElement).toBe(name))
|
|
240
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
241
|
+
expect(await screen.findByText("Required")).toBeTruthy()
|
|
242
|
+
await waitFor(() => expect(document.activeElement).toBe(name))
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test("a save that ends after the dialog reopened leaves the new session open", async () => {
|
|
246
|
+
const user = userEvent.setup()
|
|
247
|
+
let finishSave: () => void = () => undefined
|
|
248
|
+
const onSubmit = vi.fn(
|
|
249
|
+
() =>
|
|
250
|
+
new Promise<void>((resolve) => {
|
|
251
|
+
finishSave = resolve
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
255
|
+
await openEdit(user)
|
|
256
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
257
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
258
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
259
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
260
|
+
|
|
261
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
262
|
+
await screen.findByRole("dialog")
|
|
263
|
+
await act(async () => {
|
|
264
|
+
finishSave()
|
|
265
|
+
await Promise.resolve()
|
|
266
|
+
})
|
|
267
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("a field error from the server focuses that field", async () => {
|
|
271
|
+
const user = userEvent.setup()
|
|
272
|
+
const action = vi.fn(() =>
|
|
273
|
+
Promise.resolve<ActionResult<never>>({
|
|
274
|
+
error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
|
|
275
|
+
ok: false,
|
|
276
|
+
})
|
|
277
|
+
)
|
|
278
|
+
render(<Harness action={action} />)
|
|
279
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
280
|
+
await user.type(await screen.findByLabelText(NAME), "Bob")
|
|
281
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
282
|
+
expect(await screen.findByText("Already taken")).toBeTruthy()
|
|
283
|
+
await waitFor(() =>
|
|
284
|
+
expect(document.activeElement).toBe(screen.getByLabelText(EMAIL))
|
|
285
|
+
)
|
|
286
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("an action's form-level error shows in the dialog, focus back on Save", async () => {
|
|
290
|
+
const user = userEvent.setup()
|
|
291
|
+
const action = vi.fn(() => {
|
|
292
|
+
dropFocus()
|
|
293
|
+
return Promise.resolve<ActionResult<never>>({
|
|
294
|
+
error: { fieldErrors: {}, formErrors: ["Record is locked"] },
|
|
295
|
+
ok: false,
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
render(<Harness action={action} />)
|
|
299
|
+
const name = await openEdit(user)
|
|
300
|
+
const save = screen.getByRole("button", { name: "Save" })
|
|
301
|
+
await user.click(save)
|
|
302
|
+
|
|
303
|
+
const alert = await screen.findByRole("alert")
|
|
304
|
+
expect(alert.textContent).toBe("Record is locked")
|
|
305
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
306
|
+
await waitFor(() => expect(document.activeElement).toBe(save))
|
|
307
|
+
|
|
308
|
+
await user.type(name, "!")
|
|
309
|
+
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull())
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test("a thrown submit shows a generic message until an edit or a resubmit", async () => {
|
|
313
|
+
const user = userEvent.setup()
|
|
314
|
+
let rejectRetry: (error: Error) => void = () => undefined
|
|
315
|
+
const onSubmit = vi
|
|
316
|
+
.fn<(values: Values) => unknown>()
|
|
317
|
+
.mockImplementationOnce(() => {
|
|
318
|
+
dropFocus()
|
|
319
|
+
throw new Error("connect ECONNREFUSED 10.0.0.1:5432")
|
|
320
|
+
})
|
|
321
|
+
.mockImplementationOnce(() => {
|
|
322
|
+
throw new Error("offline")
|
|
323
|
+
})
|
|
324
|
+
.mockImplementationOnce(
|
|
325
|
+
() =>
|
|
326
|
+
new Promise((_, reject) => {
|
|
327
|
+
rejectRetry = reject
|
|
328
|
+
})
|
|
329
|
+
)
|
|
330
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
331
|
+
const name = await openEdit(user)
|
|
332
|
+
const save = screen.getByRole("button", { name: "Save" })
|
|
333
|
+
await user.click(save)
|
|
334
|
+
|
|
335
|
+
const alert = await screen.findByRole("alert")
|
|
336
|
+
expect(alert.textContent).toBe("Something went wrong. Try again.")
|
|
337
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
338
|
+
await waitFor(() => expect(document.activeElement).toBe(save))
|
|
339
|
+
|
|
340
|
+
await user.type(name, "!")
|
|
341
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
342
|
+
|
|
343
|
+
await user.click(save)
|
|
344
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
345
|
+
|
|
346
|
+
// The retry clears the message while it runs; it comes back if it fails.
|
|
347
|
+
await user.click(save)
|
|
348
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(3))
|
|
349
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
350
|
+
rejectRetry(new Error("offline"))
|
|
351
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test("submitErrorMessage words the thrown-submit message from the error", async () => {
|
|
355
|
+
const user = userEvent.setup()
|
|
356
|
+
render(
|
|
357
|
+
<Harness
|
|
358
|
+
onSubmit={() => {
|
|
359
|
+
throw new Error("Quota reached")
|
|
360
|
+
}}
|
|
361
|
+
submitErrorMessage={(error) => `Not saved: ${(error as Error).message}`}
|
|
362
|
+
/>
|
|
363
|
+
)
|
|
364
|
+
await openEdit(user)
|
|
365
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
366
|
+
expect((await screen.findByRole("alert")).textContent).toBe(
|
|
367
|
+
"Not saved: Quota reached"
|
|
368
|
+
)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test("reopening during the close animation mounts fresh fields", async () => {
|
|
372
|
+
const user = userEvent.setup()
|
|
373
|
+
function Details() {
|
|
374
|
+
const [shown, setShown] = useState(false)
|
|
375
|
+
return (
|
|
376
|
+
<button
|
|
377
|
+
aria-pressed={shown}
|
|
378
|
+
onClick={() => setShown(true)}
|
|
379
|
+
type="button"
|
|
380
|
+
>
|
|
381
|
+
Details
|
|
382
|
+
</button>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
render(
|
|
386
|
+
<Harness>
|
|
387
|
+
<Details />
|
|
388
|
+
</Harness>
|
|
389
|
+
)
|
|
390
|
+
await openEdit(user)
|
|
391
|
+
await user.click(screen.getByRole("button", { name: "Details" }))
|
|
392
|
+
expect(
|
|
393
|
+
screen
|
|
394
|
+
.getByRole("button", { name: "Details" })
|
|
395
|
+
.getAttribute("aria-pressed")
|
|
396
|
+
).toBe("true")
|
|
397
|
+
|
|
398
|
+
holdExitAnimation()
|
|
399
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
400
|
+
// Still mounted: the exit animation is running.
|
|
401
|
+
expect(
|
|
402
|
+
screen.queryByRole("button", { hidden: true, name: "Details" })
|
|
403
|
+
).not.toBeNull()
|
|
404
|
+
const name = await openEdit(user)
|
|
405
|
+
expect(name.value).toBe("Ada")
|
|
406
|
+
expect(
|
|
407
|
+
screen
|
|
408
|
+
.getByRole("button", { name: "Details" })
|
|
409
|
+
.getAttribute("aria-pressed")
|
|
410
|
+
).toBe("false")
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test("Save in the leave prompt saves, then closes", async () => {
|
|
414
|
+
const user = userEvent.setup()
|
|
415
|
+
const onSubmit = vi.fn()
|
|
416
|
+
render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
417
|
+
const name = await openEdit(user)
|
|
418
|
+
await user.type(name, "!")
|
|
419
|
+
await user.keyboard("{Escape}")
|
|
420
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
421
|
+
|
|
422
|
+
// The submit shortcut is off while the prompt asks.
|
|
423
|
+
await user.keyboard("{Control>}{Enter}{/Control}")
|
|
424
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
425
|
+
|
|
426
|
+
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
427
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
428
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
429
|
+
email: "ada@example.com",
|
|
430
|
+
name: "Ada!",
|
|
431
|
+
})
|
|
432
|
+
expect(screen.queryByRole("alertdialog")).toBeNull()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test("a failed Save in the leave prompt stays open and focuses the invalid field", async () => {
|
|
436
|
+
const user = userEvent.setup()
|
|
437
|
+
const onSubmit = vi.fn()
|
|
438
|
+
render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
439
|
+
const name = await openEdit(user)
|
|
440
|
+
await user.clear(name)
|
|
441
|
+
// Leave from another field, so focus returning there isn't a pass.
|
|
442
|
+
await user.click(screen.getByLabelText(EMAIL))
|
|
443
|
+
await user.keyboard("{Escape}")
|
|
444
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
445
|
+
|
|
446
|
+
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
447
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
448
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
449
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
450
|
+
await waitFor(() => expect(document.activeElement).toBe(name))
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test("a Save in the leave prompt that throws is announced once the prompt closes", async () => {
|
|
454
|
+
const user = userEvent.setup()
|
|
455
|
+
const onSubmit = vi.fn(() => {
|
|
456
|
+
throw new Error("offline")
|
|
457
|
+
})
|
|
458
|
+
render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
459
|
+
const name = await openEdit(user)
|
|
460
|
+
await user.type(name, "!")
|
|
461
|
+
await user.keyboard("{Escape}")
|
|
462
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
463
|
+
const announced = watchAnnouncedAlerts()
|
|
464
|
+
|
|
465
|
+
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
466
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
467
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
468
|
+
await waitFor(() =>
|
|
469
|
+
expect(announced).toContain("Something went wrong. Try again.")
|
|
470
|
+
)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test("a Save still running when the leave prompt opens is announced once it closes", async () => {
|
|
474
|
+
const user = userEvent.setup()
|
|
475
|
+
let rejectSave: (error: Error) => void = () => undefined
|
|
476
|
+
const onSubmit = vi.fn(
|
|
477
|
+
() =>
|
|
478
|
+
new Promise((_, reject) => {
|
|
479
|
+
rejectSave = reject
|
|
480
|
+
})
|
|
481
|
+
)
|
|
482
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
483
|
+
const name = await openEdit(user)
|
|
484
|
+
await user.type(name, "!")
|
|
485
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
486
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
487
|
+
await user.keyboard("{Escape}")
|
|
488
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
489
|
+
const announced = watchAnnouncedAlerts()
|
|
490
|
+
|
|
491
|
+
rejectSave(new Error("offline"))
|
|
492
|
+
await waitFor(() =>
|
|
493
|
+
expect(document.querySelector('[data-slot="form-error"]')).not.toBeNull()
|
|
494
|
+
)
|
|
495
|
+
await user.click(
|
|
496
|
+
within(prompt).getByRole("button", { name: "Keep editing" })
|
|
497
|
+
)
|
|
498
|
+
await waitFor(() =>
|
|
499
|
+
expect(announced).toContain("Something went wrong. Try again.")
|
|
500
|
+
)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test("a Save that fails after a discard doesn't show in the next session", async () => {
|
|
504
|
+
const user = userEvent.setup()
|
|
505
|
+
const report = vi.fn()
|
|
506
|
+
vi.stubGlobal("reportError", report)
|
|
507
|
+
let rejectSave: (error: Error) => void = () => undefined
|
|
508
|
+
const onSubmit = vi.fn(
|
|
509
|
+
() =>
|
|
510
|
+
new Promise((_, reject) => {
|
|
511
|
+
rejectSave = reject
|
|
512
|
+
})
|
|
513
|
+
)
|
|
514
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
515
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
516
|
+
await user.type(await screen.findByLabelText(NAME), "Ada")
|
|
517
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
518
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
519
|
+
await user.keyboard("{Escape}")
|
|
520
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
521
|
+
await user.click(within(prompt).getByRole("button", { name: "Discard" }))
|
|
522
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
523
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
524
|
+
await screen.findByRole("dialog")
|
|
525
|
+
|
|
526
|
+
rejectSave(new Error("offline"))
|
|
527
|
+
await waitFor(() => expect(report).toHaveBeenCalled())
|
|
528
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
for (const key of ["Control", "Meta"]) {
|
|
532
|
+
test(`${key}+Enter submits, from a textarea too; plain Enter there doesn't`, async () => {
|
|
533
|
+
const user = userEvent.setup()
|
|
534
|
+
const onSubmit = vi.fn()
|
|
535
|
+
render(
|
|
536
|
+
<Harness onSubmit={onSubmit}>
|
|
537
|
+
<textarea aria-label="Notes" />
|
|
538
|
+
</Harness>
|
|
539
|
+
)
|
|
540
|
+
const name = await openEdit(user)
|
|
541
|
+
await user.type(name, "!")
|
|
542
|
+
await user.type(screen.getByLabelText("Notes"), "line{Enter}")
|
|
543
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
544
|
+
|
|
545
|
+
await user.keyboard(`{${key}>}{Enter}{/${key}}`)
|
|
546
|
+
await waitFor(() =>
|
|
547
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
548
|
+
email: "ada@example.com",
|
|
549
|
+
name: "Ada!",
|
|
550
|
+
})
|
|
551
|
+
)
|
|
552
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
test("disabled turns off Save, the shortcut and the native fields", async () => {
|
|
557
|
+
const user = userEvent.setup()
|
|
558
|
+
const onSubmit = vi.fn()
|
|
559
|
+
render(<Harness disabled onSubmit={onSubmit} />)
|
|
560
|
+
await user.click(screen.getByRole("button", { name: "Edit" }))
|
|
561
|
+
const dialog = await screen.findByRole("dialog")
|
|
562
|
+
await waitFor(() =>
|
|
563
|
+
expect(dialog.contains(document.activeElement)).toBe(true)
|
|
564
|
+
)
|
|
565
|
+
expect(screen.getByLabelText(NAME).matches(":disabled")).toBe(true)
|
|
566
|
+
expect(
|
|
567
|
+
screen.getByRole("button", { name: "Save" }).matches(":disabled")
|
|
568
|
+
).toBe(true)
|
|
569
|
+
|
|
570
|
+
await user.keyboard("{Control>}{Enter}{/Control}")
|
|
571
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
572
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
573
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test("disabled also turns off Save in the leave prompt", async () => {
|
|
577
|
+
const user = userEvent.setup()
|
|
578
|
+
const onSubmit = vi.fn()
|
|
579
|
+
const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
580
|
+
const name = await openEdit(user)
|
|
581
|
+
await user.type(name, "!")
|
|
582
|
+
rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
|
|
583
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
584
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
585
|
+
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
586
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
|
|
590
|
+
const user = userEvent.setup()
|
|
591
|
+
const onSubmit = vi.fn(() => new Promise(() => undefined))
|
|
592
|
+
render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
593
|
+
const name = await openEdit(user)
|
|
594
|
+
await user.type(name, "!")
|
|
595
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
596
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
597
|
+
await user.keyboard("{Escape}")
|
|
598
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
599
|
+
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
600
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
test("Ctrl+Enter during the close animation doesn't submit again", async () => {
|
|
604
|
+
const user = userEvent.setup()
|
|
605
|
+
const onSubmit = vi.fn()
|
|
606
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
607
|
+
const name = await openEdit(user)
|
|
608
|
+
await user.type(name, "!")
|
|
609
|
+
holdExitAnimation()
|
|
610
|
+
await user.keyboard("{Control>}{Enter}{/Control}")
|
|
611
|
+
const dialog = screen.getByRole("dialog")
|
|
612
|
+
await waitFor(() => expect(dialog.hasAttribute("data-closed")).toBe(true))
|
|
613
|
+
await user.keyboard("{Control>}{Enter}{/Control}")
|
|
614
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test("Ctrl+Enter in a nested dialog submits only that dialog", async () => {
|
|
618
|
+
const user = userEvent.setup()
|
|
619
|
+
const outer = vi.fn()
|
|
620
|
+
const inner = vi.fn()
|
|
621
|
+
function Inner() {
|
|
622
|
+
const dialog = useFormDialog({ defaultValues: ROW })
|
|
623
|
+
const form = useForm({
|
|
624
|
+
defaultValues: dialog.defaultValues,
|
|
625
|
+
onSubmit: ({ value }) => inner(value),
|
|
626
|
+
schema,
|
|
627
|
+
})
|
|
628
|
+
return (
|
|
629
|
+
<FormDialog
|
|
630
|
+
dialog={dialog}
|
|
631
|
+
form={form}
|
|
632
|
+
title="Inner"
|
|
633
|
+
trigger={<button type="button">Open inner</button>}
|
|
634
|
+
>
|
|
635
|
+
<form.InputField label="Inner name" name="name" />
|
|
636
|
+
</FormDialog>
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
render(
|
|
640
|
+
<Harness onSubmit={outer}>
|
|
641
|
+
<Inner />
|
|
642
|
+
</Harness>
|
|
643
|
+
)
|
|
644
|
+
await openEdit(user)
|
|
645
|
+
await user.click(screen.getByRole("button", { name: "Open inner" }))
|
|
646
|
+
const innerName = await screen.findByLabelText(INNER_NAME)
|
|
647
|
+
await user.type(innerName, "!")
|
|
648
|
+
await user.keyboard("{Control>}{Enter}{/Control}")
|
|
649
|
+
await waitFor(() => expect(inner).toHaveBeenCalled())
|
|
650
|
+
expect(outer).not.toHaveBeenCalled()
|
|
651
|
+
})
|
|
161
652
|
})
|