@zeno-lib/forms 0.7.0 → 0.8.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.mjs +16 -6
- package/dist/lib/schema-defaults.d.mts +3 -1
- package/dist/lib/schema-defaults.mjs +43 -1
- package/dist/lib/submit-action.mjs +7 -2
- package/package.json +2 -2
- package/src/create-zeno-form.tsx +29 -14
- package/src/form-dialog.test.tsx +187 -3
- package/src/form-dialog.tsx +46 -11
- package/src/form-error.test.tsx +47 -0
- package/src/lib/schema-defaults.test.ts +128 -1
- package/src/lib/schema-defaults.ts +74 -2
- package/src/lib/submit-action.ts +17 -2
- package/src/submit-action.test.tsx +45 -0
- package/src/use-form.test-d.ts +23 -0
- package/src/use-form.test.tsx +46 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
3
3
|
import { fieldContext, formContext } from "./lib/contexts.mjs";
|
|
4
4
|
import { withDefaultProps } from "./lib/default-props.mjs";
|
|
5
|
-
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults.mjs";
|
|
5
|
+
import { deepMergeDefaults, extractZodDefaults, pickSchemaShape } from "./lib/schema-defaults.mjs";
|
|
6
6
|
import { getRequiredPaths } from "./lib/schema-required.mjs";
|
|
7
7
|
import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
|
|
8
8
|
import { withEmptyStartValues } from "./lib/empty-value.mjs";
|
|
@@ -100,22 +100,32 @@ function createZenoForm(config) {
|
|
|
100
100
|
resolvedValidationLogic = mode === "blur-then-change" ? blurThenChangeLogic : void 0;
|
|
101
101
|
}
|
|
102
102
|
const wrappedOnSubmit = userOnSubmit ? (async (props) => {
|
|
103
|
-
const {
|
|
103
|
+
const { formApi } = props;
|
|
104
|
+
const { submissionAttempts } = formApi.state;
|
|
105
|
+
let superseded = false;
|
|
106
|
+
const watch = formApi.store.subscribe(() => {
|
|
107
|
+
if (formApi.state.submissionAttempts !== submissionAttempts) superseded = true;
|
|
108
|
+
});
|
|
104
109
|
try {
|
|
105
110
|
await userOnSubmit(props);
|
|
106
111
|
} catch (error) {
|
|
107
112
|
if (error instanceof ValidationError) {
|
|
108
|
-
applyValidationError(
|
|
113
|
+
applyValidationError(formApi, error);
|
|
109
114
|
return;
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
if (state.values === props.value && state.submissionAttempts === submissionAttempts) setSubmitError(props.formApi, error);
|
|
116
|
+
if (!superseded) setSubmitError(formApi, error);
|
|
113
117
|
throw error;
|
|
118
|
+
} finally {
|
|
119
|
+
watch.unsubscribe();
|
|
114
120
|
}
|
|
115
121
|
}) : void 0;
|
|
116
122
|
const requiredFields = useMemo(() => schema ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema]);
|
|
117
123
|
const schemaDefaults = useMemo(() => schema ? withEmptyStartValues(schema, extractZodDefaults(schema)) : void 0, [schema]);
|
|
118
|
-
const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, userDefaultValues) : userDefaultValues, [
|
|
124
|
+
const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, pickSchemaShape(schema, userDefaultValues)) : userDefaultValues, [
|
|
125
|
+
schema,
|
|
126
|
+
schemaDefaults,
|
|
127
|
+
userDefaultValues
|
|
128
|
+
]);
|
|
119
129
|
const rebased = useRebasedDefaultValues(mergedDefaultValues);
|
|
120
130
|
const listeners = withMountListener(rest.listeners, rebased.onMount);
|
|
121
131
|
const form = useAppForm({
|
|
@@ -8,6 +8,7 @@ type SchemaDef = {
|
|
|
8
8
|
readonly type?: string;
|
|
9
9
|
readonly innerType?: SchemaNode;
|
|
10
10
|
readonly shape?: Record<string, SchemaNode>;
|
|
11
|
+
readonly catchall?: SchemaNode;
|
|
11
12
|
readonly in?: SchemaNode;
|
|
12
13
|
readonly element?: SchemaNode;
|
|
13
14
|
readonly defaultValue?: unknown;
|
|
@@ -20,6 +21,7 @@ type ExtractResult = {
|
|
|
20
21
|
};
|
|
21
22
|
declare function extractZodDefaults(schema: SchemaNode): Record<string, unknown>;
|
|
22
23
|
declare function extractZodDefaultAt(schema: SchemaNode, keys: readonly PropertyKey[]): ExtractResult;
|
|
24
|
+
declare function pickSchemaShape<T>(schema: SchemaNode, value: T): T;
|
|
23
25
|
declare function deepMergeDefaults(schemaDefaults: Record<string, unknown>, userDefaults: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
24
26
|
//#endregion
|
|
25
|
-
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
|
|
27
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
|
|
@@ -92,6 +92,48 @@ function extractZodDefaultAt(schema, keys) {
|
|
|
92
92
|
function isPlainObject(value) {
|
|
93
93
|
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
94
94
|
}
|
|
95
|
+
function acceptsOnlyShape(def) {
|
|
96
|
+
return def.catchall === void 0 || getDef(def.catchall)?.type === "never";
|
|
97
|
+
}
|
|
98
|
+
function pickShape(node, value) {
|
|
99
|
+
let current = node;
|
|
100
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
101
|
+
const def = getDef(current);
|
|
102
|
+
switch (def?.type) {
|
|
103
|
+
case "object": {
|
|
104
|
+
const { shape } = def;
|
|
105
|
+
if (!(shape && isPlainObject(value) && acceptsOnlyShape(def))) return value;
|
|
106
|
+
const result = {};
|
|
107
|
+
for (const [key, field] of Object.entries(shape)) if (Object.hasOwn(value, key)) result[key] = pickShape(field, value[key]);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
case "array": {
|
|
111
|
+
const { element } = def;
|
|
112
|
+
return element && Array.isArray(value) ? value.map((item) => pickShape(element, item)) : value;
|
|
113
|
+
}
|
|
114
|
+
case "default":
|
|
115
|
+
case "nonoptional":
|
|
116
|
+
case "nullable":
|
|
117
|
+
case "optional":
|
|
118
|
+
case "prefault":
|
|
119
|
+
case "readonly":
|
|
120
|
+
current = def.innerType;
|
|
121
|
+
continue;
|
|
122
|
+
case "pipe":
|
|
123
|
+
current = def.in;
|
|
124
|
+
continue;
|
|
125
|
+
default: return value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
function pickSchemaShape(schema, value) {
|
|
131
|
+
try {
|
|
132
|
+
return pickShape(schema, value);
|
|
133
|
+
} catch {
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
95
137
|
function deepMergeDefaults(schemaDefaults, userDefaults) {
|
|
96
138
|
if (!userDefaults) return schemaDefaults;
|
|
97
139
|
const result = { ...schemaDefaults };
|
|
@@ -104,4 +146,4 @@ function deepMergeDefaults(schemaDefaults, userDefaults) {
|
|
|
104
146
|
return result;
|
|
105
147
|
}
|
|
106
148
|
//#endregion
|
|
107
|
-
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
|
|
149
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyValidationError } from "./apply-validation-error.mjs";
|
|
2
2
|
import { toActionError } from "./action-result.mjs";
|
|
3
|
+
import { pickSchemaShape } from "./schema-defaults.mjs";
|
|
3
4
|
import { getFormSchema } from "./validation-modes.mjs";
|
|
4
5
|
import { ValidationError } from "./validation-error.mjs";
|
|
5
6
|
//#region src/lib/submit-action.ts
|
|
@@ -36,10 +37,14 @@ async function submitAction(props, action, options = {}) {
|
|
|
36
37
|
return result;
|
|
37
38
|
}
|
|
38
39
|
const { reset } = options;
|
|
39
|
-
if (reset === true) formApi.reset(result.data);
|
|
40
|
+
if (reset === true) formApi.reset(toFormValues(formApi, result.data));
|
|
40
41
|
else if (reset === "values") formApi.reset(value);
|
|
41
|
-
else if (typeof reset === "function") formApi.reset(reset(result.data));
|
|
42
|
+
else if (typeof reset === "function") formApi.reset(toFormValues(formApi, reset(result.data)));
|
|
42
43
|
return result;
|
|
43
44
|
}
|
|
45
|
+
function toFormValues(formApi, data) {
|
|
46
|
+
const schema = getFormSchema(formApi);
|
|
47
|
+
return schema ? pickSchemaShape(schema, data) : data;
|
|
48
|
+
}
|
|
44
49
|
//#endregion
|
|
45
50
|
export { applyActionError, submitAction };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "https://github.com/zeno-lib/zeno.git",
|
|
6
6
|
"directory": "packages/forms"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@types/react": "19.3.0",
|
|
38
38
|
"@types/react-dom": "19.3.0",
|
|
39
39
|
"@zeno-lib/test": "^0.1.1",
|
|
40
|
-
"@zeno-lib/typescript": "^1.
|
|
40
|
+
"@zeno-lib/typescript": "^1.2.0",
|
|
41
41
|
"@zeno-lib/ui": "^0.0.1",
|
|
42
42
|
"lucide-react": "^1.52.0",
|
|
43
43
|
"tsdown": "^0.23.0",
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -16,7 +16,11 @@ import { fieldContext, formContext } from "./lib/contexts"
|
|
|
16
16
|
import { withDefaultProps } from "./lib/default-props"
|
|
17
17
|
import { withEmptyStartValues } from "./lib/empty-value"
|
|
18
18
|
import { useDisabledOption } from "./lib/form-disabled"
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
deepMergeDefaults,
|
|
21
|
+
extractZodDefaults,
|
|
22
|
+
pickSchemaShape,
|
|
23
|
+
} from "./lib/schema-defaults"
|
|
20
24
|
import {
|
|
21
25
|
getRequiredPaths,
|
|
22
26
|
type StandardSchemaLike,
|
|
@@ -531,7 +535,17 @@ export function createZenoForm<
|
|
|
531
535
|
|
|
532
536
|
const wrappedOnSubmit = userOnSubmit
|
|
533
537
|
? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
|
|
534
|
-
const {
|
|
538
|
+
const { formApi } = props
|
|
539
|
+
const { submissionAttempts } = formApi.state
|
|
540
|
+
// A reset (a dialog closed meanwhile) or a newer submit while this
|
|
541
|
+
// one runs makes its failure stale, even once a later submit brings
|
|
542
|
+
// the count back. An edit doesn't: the save still didn't happen.
|
|
543
|
+
let superseded = false
|
|
544
|
+
const watch = formApi.store.subscribe(() => {
|
|
545
|
+
if (formApi.state.submissionAttempts !== submissionAttempts) {
|
|
546
|
+
superseded = true
|
|
547
|
+
}
|
|
548
|
+
})
|
|
535
549
|
try {
|
|
536
550
|
await (
|
|
537
551
|
userOnSubmit as unknown as (
|
|
@@ -540,20 +554,15 @@ export function createZenoForm<
|
|
|
540
554
|
)(props)
|
|
541
555
|
} catch (error) {
|
|
542
556
|
if (error instanceof ValidationError) {
|
|
543
|
-
applyValidationError(
|
|
557
|
+
applyValidationError(formApi, error)
|
|
544
558
|
return
|
|
545
559
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// values no longer on screen.
|
|
549
|
-
const { state } = props.formApi
|
|
550
|
-
if (
|
|
551
|
-
state.values === props.value &&
|
|
552
|
-
state.submissionAttempts === submissionAttempts
|
|
553
|
-
) {
|
|
554
|
-
setSubmitError(props.formApi, error)
|
|
560
|
+
if (!superseded) {
|
|
561
|
+
setSubmitError(formApi, error)
|
|
555
562
|
}
|
|
556
563
|
throw error
|
|
564
|
+
} finally {
|
|
565
|
+
watch.unsubscribe()
|
|
557
566
|
}
|
|
558
567
|
}) as typeof userOnSubmit)
|
|
559
568
|
: undefined
|
|
@@ -579,15 +588,21 @@ export function createZenoForm<
|
|
|
579
588
|
[schema]
|
|
580
589
|
)
|
|
581
590
|
|
|
591
|
+
// The caller's values keep only the keys the schema describes, so a
|
|
592
|
+
// record as read (`id`, `createdAt`, …) doesn't fail a `.strict()` schema
|
|
593
|
+
// on submit. The schema's own defaults then fill the keys left out.
|
|
582
594
|
const mergedDefaultValues = useMemo(
|
|
583
595
|
() =>
|
|
584
596
|
schemaDefaults
|
|
585
597
|
? (deepMergeDefaults(
|
|
586
598
|
schemaDefaults,
|
|
587
|
-
|
|
599
|
+
pickSchemaShape(
|
|
600
|
+
schema as Parameters<typeof pickSchemaShape>[0],
|
|
601
|
+
userDefaultValues as Record<string, unknown> | undefined
|
|
602
|
+
)
|
|
588
603
|
) as typeof userDefaultValues)
|
|
589
604
|
: userDefaultValues,
|
|
590
|
-
[schemaDefaults, userDefaultValues]
|
|
605
|
+
[schema, schemaDefaults, userDefaultValues]
|
|
591
606
|
)
|
|
592
607
|
|
|
593
608
|
// Keep `formApi.reset(values)` from being undone by the next render; see
|
package/src/form-dialog.test.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "./form-dialog"
|
|
19
19
|
import type { ActionResult } from "./lib/action-result"
|
|
20
20
|
import { submitAction } from "./lib/submit-action"
|
|
21
|
-
import { useFormDialog } from "./lib/use-form-dialog"
|
|
21
|
+
import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
|
|
22
22
|
|
|
23
23
|
const NAME = /Name/
|
|
24
24
|
const EMAIL = /Email/
|
|
@@ -54,8 +54,10 @@ function Harness({
|
|
|
54
54
|
children,
|
|
55
55
|
onSubmit = vi.fn(),
|
|
56
56
|
closeOnSubmit,
|
|
57
|
+
controller,
|
|
57
58
|
disabled,
|
|
58
59
|
discardPrompt,
|
|
60
|
+
formDisabled,
|
|
59
61
|
saveFromPrompt,
|
|
60
62
|
submitErrorMessage,
|
|
61
63
|
submitLabel,
|
|
@@ -65,15 +67,23 @@ function Harness({
|
|
|
65
67
|
children?: ReactNode
|
|
66
68
|
onSubmit?: (values: Values) => unknown
|
|
67
69
|
closeOnSubmit?: boolean
|
|
70
|
+
/** Gets the dialog's controller, to close it from outside. */
|
|
71
|
+
controller?: { current?: FormDialogController<Values> }
|
|
68
72
|
disabled?: boolean
|
|
69
73
|
discardPrompt?: DiscardPromptText
|
|
74
|
+
/** Disable the form through `useForm({ disabled })`. */
|
|
75
|
+
formDisabled?: boolean
|
|
70
76
|
saveFromPrompt?: boolean
|
|
71
77
|
submitErrorMessage?: (error: unknown) => ReactNode
|
|
72
78
|
submitLabel?: ReactNode
|
|
73
79
|
}) {
|
|
74
80
|
const dialog = useFormDialog({ defaultValues: BLANK })
|
|
81
|
+
if (controller) {
|
|
82
|
+
controller.current = dialog
|
|
83
|
+
}
|
|
75
84
|
const form = useForm({
|
|
76
85
|
defaultValues: dialog.defaultValues,
|
|
86
|
+
disabled: formDisabled,
|
|
77
87
|
onSubmit: (submit) =>
|
|
78
88
|
action ? submitAction(submit, action) : onSubmit(submit.value),
|
|
79
89
|
schema,
|
|
@@ -119,6 +129,13 @@ function dropFocus() {
|
|
|
119
129
|
sink.remove()
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// Whether leaving the page now would make the browser ask first.
|
|
133
|
+
function warnsOnUnload(): boolean {
|
|
134
|
+
const event = new Event("beforeunload", { cancelable: true })
|
|
135
|
+
window.dispatchEvent(event)
|
|
136
|
+
return event.defaultPrevented
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
// The alerts added where assistive tech could see them. One added under an
|
|
123
140
|
// `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
|
|
124
141
|
// announced, not even once that ancestor is shown again.
|
|
@@ -161,6 +178,40 @@ describe("FormDialog", () => {
|
|
|
161
178
|
expect(name.value).toBe("Ada")
|
|
162
179
|
})
|
|
163
180
|
|
|
181
|
+
test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
|
|
182
|
+
const user = userEvent.setup()
|
|
183
|
+
const strictSchema = schema.strict()
|
|
184
|
+
const record = { ...ROW, createdAt: "2026-01-01", id: 7 }
|
|
185
|
+
const onSubmit = vi.fn()
|
|
186
|
+
function StrictHarness() {
|
|
187
|
+
const dialog = useFormDialog<Values>({ defaultValues: BLANK })
|
|
188
|
+
const form = useForm({
|
|
189
|
+
defaultValues: dialog.defaultValues,
|
|
190
|
+
onSubmit: ({ value }) => onSubmit(value),
|
|
191
|
+
schema: strictSchema,
|
|
192
|
+
})
|
|
193
|
+
const { InputField } = form
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => dialog.open({ defaultValues: record })}
|
|
198
|
+
type="button"
|
|
199
|
+
>
|
|
200
|
+
Edit
|
|
201
|
+
</button>
|
|
202
|
+
<FormDialog dialog={dialog} form={form} title="Person">
|
|
203
|
+
<InputField label="Name" name="name" />
|
|
204
|
+
</FormDialog>
|
|
205
|
+
</>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
render(<StrictHarness />)
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "Edit" }))
|
|
210
|
+
await screen.findByLabelText(NAME)
|
|
211
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
212
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ROW))
|
|
213
|
+
})
|
|
214
|
+
|
|
164
215
|
test("closes without asking when nothing changed", async () => {
|
|
165
216
|
const user = userEvent.setup()
|
|
166
217
|
render(<Harness />)
|
|
@@ -277,6 +328,110 @@ describe("FormDialog", () => {
|
|
|
277
328
|
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
278
329
|
})
|
|
279
330
|
|
|
331
|
+
for (const closeOnSubmit of [true, false]) {
|
|
332
|
+
test(`a leave asked for while a Save runs goes through once it saves (closeOnSubmit: ${closeOnSubmit})`, async () => {
|
|
333
|
+
const user = userEvent.setup()
|
|
334
|
+
let finishSave: () => void = () => undefined
|
|
335
|
+
const onSubmit = vi.fn(
|
|
336
|
+
() =>
|
|
337
|
+
new Promise<void>((resolve) => {
|
|
338
|
+
finishSave = resolve
|
|
339
|
+
})
|
|
340
|
+
)
|
|
341
|
+
render(<Harness closeOnSubmit={closeOnSubmit} onSubmit={onSubmit} />)
|
|
342
|
+
const name = await openEdit(user)
|
|
343
|
+
await user.type(name, "!")
|
|
344
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
345
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
346
|
+
await user.keyboard("{Escape}")
|
|
347
|
+
await screen.findByRole("alertdialog")
|
|
348
|
+
|
|
349
|
+
finishSave()
|
|
350
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
351
|
+
expect(screen.queryByRole("alertdialog")).toBeNull()
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
test("an edit made while a Save runs keeps the leave prompt asking once it saves", async () => {
|
|
356
|
+
const user = userEvent.setup()
|
|
357
|
+
let finishSave: () => void = () => undefined
|
|
358
|
+
const onSubmit = vi.fn(
|
|
359
|
+
() =>
|
|
360
|
+
new Promise<void>((resolve) => {
|
|
361
|
+
finishSave = resolve
|
|
362
|
+
})
|
|
363
|
+
)
|
|
364
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
365
|
+
const name = await openEdit(user)
|
|
366
|
+
await user.type(name, "!")
|
|
367
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
368
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
369
|
+
await user.type(name, "?")
|
|
370
|
+
await user.keyboard("{Escape}")
|
|
371
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
372
|
+
|
|
373
|
+
finishSave()
|
|
374
|
+
await waitFor(() =>
|
|
375
|
+
expect(screen.queryByRole("status", { hidden: true })).toBeNull()
|
|
376
|
+
)
|
|
377
|
+
expect(screen.getByRole("alertdialog")).toBe(prompt)
|
|
378
|
+
await user.click(
|
|
379
|
+
within(prompt).getByRole("button", { name: "Keep editing" })
|
|
380
|
+
)
|
|
381
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
382
|
+
expect(name.value).toBe("Ada!?")
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test("a Save that fails while the leave prompt asks focuses the invalid field once it closes", async () => {
|
|
386
|
+
const user = userEvent.setup()
|
|
387
|
+
let finishSave: (result: ActionResult<never>) => void = () => undefined
|
|
388
|
+
const action = vi.fn(
|
|
389
|
+
() =>
|
|
390
|
+
new Promise<ActionResult<never>>((resolve) => {
|
|
391
|
+
finishSave = resolve
|
|
392
|
+
})
|
|
393
|
+
)
|
|
394
|
+
render(<Harness action={action} />)
|
|
395
|
+
const name = await openEdit(user)
|
|
396
|
+
await user.type(name, "!")
|
|
397
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
398
|
+
await waitFor(() => expect(action).toHaveBeenCalled())
|
|
399
|
+
await user.keyboard("{Escape}")
|
|
400
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
401
|
+
await waitFor(() =>
|
|
402
|
+
expect(prompt.contains(document.activeElement)).toBe(true)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
finishSave({
|
|
406
|
+
error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
|
|
407
|
+
ok: false,
|
|
408
|
+
})
|
|
409
|
+
const email = screen.getByLabelText(EMAIL)
|
|
410
|
+
await waitFor(() => expect(email.getAttribute("aria-invalid")).toBe("true"))
|
|
411
|
+
expect(prompt.contains(document.activeElement)).toBe(true)
|
|
412
|
+
await user.click(
|
|
413
|
+
within(prompt).getByRole("button", { name: "Keep editing" })
|
|
414
|
+
)
|
|
415
|
+
await waitFor(() => expect(document.activeElement).toBe(email))
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test("closing the dialog from outside closes its leave prompt for good", async () => {
|
|
419
|
+
const user = userEvent.setup()
|
|
420
|
+
const controller: { current?: FormDialogController<Values> } = {}
|
|
421
|
+
render(<Harness controller={controller} />)
|
|
422
|
+
const name = await openEdit(user)
|
|
423
|
+
await user.type(name, "!")
|
|
424
|
+
await user.keyboard("{Escape}")
|
|
425
|
+
await screen.findByRole("alertdialog")
|
|
426
|
+
|
|
427
|
+
act(() => controller.current?.close())
|
|
428
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
429
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
430
|
+
await user.click(screen.getByRole("button", { name: "Edit" }))
|
|
431
|
+
await screen.findByRole("dialog")
|
|
432
|
+
expect(screen.queryByRole("alertdialog")).toBeNull()
|
|
433
|
+
})
|
|
434
|
+
|
|
280
435
|
test("a field error from the server focuses that field", async () => {
|
|
281
436
|
const user = userEvent.setup()
|
|
282
437
|
const action = vi.fn(() =>
|
|
@@ -583,17 +738,46 @@ describe("FormDialog", () => {
|
|
|
583
738
|
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
584
739
|
})
|
|
585
740
|
|
|
586
|
-
|
|
741
|
+
for (const via of ["disabled", "useForm({ disabled })"]) {
|
|
742
|
+
test(`with ${via}, a changed dialog still asks and warns, without offering Save`, async () => {
|
|
743
|
+
const user = userEvent.setup()
|
|
744
|
+
const { rerender } = render(<Harness saveFromPrompt />)
|
|
745
|
+
const name = await openEdit(user)
|
|
746
|
+
await user.type(name, "!")
|
|
747
|
+
|
|
748
|
+
rerender(
|
|
749
|
+
<Harness
|
|
750
|
+
disabled={via === "disabled"}
|
|
751
|
+
formDisabled={via !== "disabled"}
|
|
752
|
+
saveFromPrompt
|
|
753
|
+
/>
|
|
754
|
+
)
|
|
755
|
+
expect(warnsOnUnload()).toBe(true)
|
|
756
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
757
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
758
|
+
expect(within(prompt).getByText("Discard changes?")).toBeTruthy()
|
|
759
|
+
expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
|
|
760
|
+
await user.click(within(prompt).getByRole("button", { name: "Discard" }))
|
|
761
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
762
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
test("disabled while the leave prompt asks keeps it, with its Save off", async () => {
|
|
587
767
|
const user = userEvent.setup()
|
|
588
768
|
const onSubmit = vi.fn()
|
|
589
769
|
const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
590
770
|
const name = await openEdit(user)
|
|
591
771
|
await user.type(name, "!")
|
|
592
|
-
rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
|
|
593
772
|
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
594
773
|
const prompt = await screen.findByRole("alertdialog")
|
|
774
|
+
|
|
775
|
+
rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
|
|
595
776
|
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
596
777
|
expect(onSubmit).not.toHaveBeenCalled()
|
|
778
|
+
expect(screen.getByRole("alertdialog")).toBe(prompt)
|
|
779
|
+
await user.click(within(prompt).getByRole("button", { name: "Discard" }))
|
|
780
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
597
781
|
})
|
|
598
782
|
|
|
599
783
|
test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
|
package/src/form-dialog.tsx
CHANGED
|
@@ -110,7 +110,11 @@ type FormDialogProps<TValues> = {
|
|
|
110
110
|
cancelLabel?: ReactNode
|
|
111
111
|
/** Extra footer content, rendered before Cancel / Save. */
|
|
112
112
|
footer?: ReactNode
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Close after a successful submit. Defaults to `true`. A close the user
|
|
115
|
+
* asked for while the submit ran goes through once it succeeds either way,
|
|
116
|
+
* unless they edited a field since.
|
|
117
|
+
*/
|
|
114
118
|
closeOnSubmit?: boolean
|
|
115
119
|
/**
|
|
116
120
|
* Shown above the footer when the submit handler throws. Defaults to the
|
|
@@ -127,13 +131,15 @@ type FormDialogProps<TValues> = {
|
|
|
127
131
|
discardPrompt?: DiscardPromptText
|
|
128
132
|
/**
|
|
129
133
|
* Offer Save in the discard prompt, next to Discard: it submits, and closes
|
|
130
|
-
* the dialog if the submit succeeds.
|
|
134
|
+
* the dialog if the submit succeeds. Not in a prompt that opens while the
|
|
135
|
+
* dialog is disabled. Defaults to `false`.
|
|
131
136
|
*/
|
|
132
137
|
saveFromPrompt?: boolean
|
|
133
138
|
/**
|
|
134
139
|
* Disable every field, Save and ⌘/Ctrl+Enter, as `useForm({ disabled })`
|
|
135
140
|
* does. A `<fieldset disabled>` also disables native controls of your own.
|
|
136
|
-
*
|
|
141
|
+
* A discard prompt that opens while disabled doesn't offer Save. Defaults
|
|
142
|
+
* to `false`.
|
|
137
143
|
*/
|
|
138
144
|
disabled?: boolean
|
|
139
145
|
/** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
|
|
@@ -257,8 +263,8 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
257
263
|
useIsFormDisabled(form as unknown as AnyFormApi) || disabled
|
|
258
264
|
const popupRef = useRef<HTMLDivElement>(null)
|
|
259
265
|
const formRef = useRef<HTMLFormElement>(null)
|
|
260
|
-
// Set when a
|
|
261
|
-
// to the first invalid field as it closes.
|
|
266
|
+
// Set when a submit fails while the discard prompt is open, so the prompt
|
|
267
|
+
// hands focus to the first invalid field as it closes.
|
|
262
268
|
const focusInvalidOnPromptClose = useRef<boolean>(false)
|
|
263
269
|
// The open prompt hides the dialog from assistive tech, so a form-level
|
|
264
270
|
// error that shows up meanwhile (a Save from the prompt, or a footer Save
|
|
@@ -266,9 +272,13 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
266
272
|
// the prompt has closed, which announces it.
|
|
267
273
|
const announceOnPromptClosed = useRef<boolean>(false)
|
|
268
274
|
const [formErrorKey, setFormErrorKey] = useState(0)
|
|
275
|
+
// Whether the discard prompt offers Save, set as it opens: a disabled
|
|
276
|
+
// dialog can't save. Kept while it's open, so the prompt doesn't change
|
|
277
|
+
// under the user; one disabled meanwhile only turns its Save off.
|
|
278
|
+
const [promptOffersSave, setPromptOffersSave] = useState(false)
|
|
269
279
|
const { close, defaultValues, focus, isOpen, open, session } = dialog
|
|
270
280
|
const DialogFormError = form.FormError ?? FormError
|
|
271
|
-
const promptCopy = getPromptCopy(discardPrompt,
|
|
281
|
+
const promptCopy = getPromptCopy(discardPrompt, promptOffersSave)
|
|
272
282
|
// Read after a submit's await: the dialog may have closed, or reopened on
|
|
273
283
|
// another record, while a slow save was in flight.
|
|
274
284
|
const latest = useRef({ isOpen, session })
|
|
@@ -313,6 +323,12 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
313
323
|
}
|
|
314
324
|
}, [session])
|
|
315
325
|
|
|
326
|
+
// A held leave belongs to the session it was asked in. Drop it when that
|
|
327
|
+
// session ends some other way (`dialog.close()`, a submit that closes it,
|
|
328
|
+
// `dialog.open()` on another record), so the prompt never outlives it.
|
|
329
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: on every session change.
|
|
330
|
+
useLayoutEffect(() => cancelLeave, [isOpen, session])
|
|
331
|
+
|
|
316
332
|
// Every way to submit (footer Save, ⌘/Ctrl+Enter, the prompt's Save) checks
|
|
317
333
|
// this first. TanStack only refuses a second `handleSubmit` while one runs on
|
|
318
334
|
// the very first attempt, and `isOpen` is false through the exit animation,
|
|
@@ -342,10 +358,25 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
342
358
|
}
|
|
343
359
|
const node = event.currentTarget
|
|
344
360
|
const focused = node.ownerDocument.activeElement
|
|
361
|
+
// The fields stay editable while the submit runs. TanStack replaces
|
|
362
|
+
// `values` on every edit, so the same object means none came after.
|
|
363
|
+
const submitted = form.state.values
|
|
345
364
|
const saved = await submit()
|
|
346
365
|
if (!latest.current.isOpen || latest.current.session !== session) {
|
|
347
366
|
return // the session this submit belongs to is over
|
|
348
367
|
}
|
|
368
|
+
if (isConfirmingRef.current) {
|
|
369
|
+
// The user asked to leave while the submit ran. If it saved every
|
|
370
|
+
// change, that leave goes through, `closeOnSubmit` or not. After a
|
|
371
|
+
// failure or a later edit, the prompt waits for the user's answer, and
|
|
372
|
+
// it traps focus, so it moves focus to an invalid field as it closes.
|
|
373
|
+
if (saved && form.state.values === submitted) {
|
|
374
|
+
confirmLeave()
|
|
375
|
+
} else {
|
|
376
|
+
focusInvalidOnPromptClose.current = !saved
|
|
377
|
+
}
|
|
378
|
+
return
|
|
379
|
+
}
|
|
349
380
|
if (saved) {
|
|
350
381
|
if (closeOnSubmit) {
|
|
351
382
|
// Close directly, past the guard: the changes are saved. The form is
|
|
@@ -392,6 +423,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
392
423
|
if (next) {
|
|
393
424
|
open()
|
|
394
425
|
} else {
|
|
426
|
+
setPromptOffersSave(saveFromPrompt && !isDisabled)
|
|
395
427
|
requestLeave(close)
|
|
396
428
|
}
|
|
397
429
|
}}
|
|
@@ -480,21 +512,24 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
480
512
|
setFormErrorKey((key) => key + 1)
|
|
481
513
|
}
|
|
482
514
|
}}
|
|
483
|
-
|
|
515
|
+
// Closes in the same render as the dialog.
|
|
516
|
+
open={isConfirming && isOpen}
|
|
484
517
|
>
|
|
485
518
|
<AlertDialogContent
|
|
486
519
|
// The prompt traps focus while open, so a failed Save's invalid
|
|
487
520
|
// field gets focus as the prompt closes, not from `submit`.
|
|
488
521
|
finalFocus={() => {
|
|
489
|
-
|
|
522
|
+
const focusInvalid = focusInvalidOnPromptClose.current
|
|
523
|
+
focusInvalidOnPromptClose.current = false
|
|
524
|
+
// After Discard, the closing dialog hands focus back itself.
|
|
525
|
+
if (!(focusInvalid && latest.current.isOpen)) {
|
|
490
526
|
return true
|
|
491
527
|
}
|
|
492
|
-
focusInvalidOnPromptClose.current = false
|
|
493
528
|
// Base UI focuses what this returns too; focusing here first
|
|
494
529
|
// finds a field that takes focus, skipping disabled or hidden ones.
|
|
495
530
|
return focusFirstInvalid(formRef.current) ?? true
|
|
496
531
|
}}
|
|
497
|
-
size={
|
|
532
|
+
size={promptOffersSave ? "default" : "sm"}
|
|
498
533
|
>
|
|
499
534
|
<AlertDialogHeader>
|
|
500
535
|
<AlertDialogTitle>{promptCopy.title}</AlertDialogTitle>
|
|
@@ -513,7 +548,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
|
513
548
|
>
|
|
514
549
|
{discardPrompt?.confirmLabel ?? "Discard"}
|
|
515
550
|
</AlertDialogAction>
|
|
516
|
-
{
|
|
551
|
+
{promptOffersSave && (
|
|
517
552
|
<AlertDialogAction
|
|
518
553
|
disabled={isDisabled || isSaving || isSubmitting}
|
|
519
554
|
onClick={saveAndLeave}
|
package/src/form-error.test.tsx
CHANGED
|
@@ -136,6 +136,53 @@ describe("<FormError> with <Form>", () => {
|
|
|
136
136
|
expect(screen.queryByRole("alert")).toBeNull()
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
+
test("a submit that throws after an edit made while it ran still shows", async () => {
|
|
140
|
+
const user = userEvent.setup()
|
|
141
|
+
let rejectSubmit: (error: Error) => void = () => undefined
|
|
142
|
+
const onSubmit = vi.fn(
|
|
143
|
+
() =>
|
|
144
|
+
new Promise((_, reject) => {
|
|
145
|
+
rejectSubmit = reject
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
149
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
150
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
151
|
+
|
|
152
|
+
await user.type(screen.getByLabelText(NAME), "!")
|
|
153
|
+
rejectSubmit(new Error("offline"))
|
|
154
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
155
|
+
|
|
156
|
+
// The next edit clears it, as after any thrown submit.
|
|
157
|
+
await user.type(screen.getByLabelText(NAME), "?")
|
|
158
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("a reset while a submit runs drops its error, even after a new submit", async () => {
|
|
162
|
+
const user = userEvent.setup()
|
|
163
|
+
let rejectFirst: (error: Error) => void = () => undefined
|
|
164
|
+
const onSubmit = vi
|
|
165
|
+
.fn<(values: Values) => unknown>()
|
|
166
|
+
.mockImplementationOnce(
|
|
167
|
+
() =>
|
|
168
|
+
new Promise((_, reject) => {
|
|
169
|
+
rejectFirst = reject
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
.mockImplementationOnce(() => new Promise(() => undefined))
|
|
173
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
174
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
175
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
176
|
+
|
|
177
|
+
await user.click(screen.getByRole("button", { name: "Reset" }))
|
|
178
|
+
// Back to the first submit's attempt count, still running.
|
|
179
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
180
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
|
|
181
|
+
rejectFirst(new Error("offline"))
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
183
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
184
|
+
})
|
|
185
|
+
|
|
139
186
|
test("a reset drops a thrown submit's error, even when it restores the same values", async () => {
|
|
140
187
|
const user = userEvent.setup()
|
|
141
188
|
let resolveRetry: () => void = () => undefined
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
deepMergeDefaults,
|
|
5
|
+
extractZodDefaults,
|
|
6
|
+
pickSchemaShape,
|
|
7
|
+
} from "./schema-defaults"
|
|
4
8
|
|
|
5
9
|
describe("extractZodDefaults — primitives", () => {
|
|
6
10
|
test("z.string() → ''", () => {
|
|
@@ -192,3 +196,126 @@ describe("deepMergeDefaults", () => {
|
|
|
192
196
|
expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
|
|
193
197
|
})
|
|
194
198
|
})
|
|
199
|
+
|
|
200
|
+
describe("pickSchemaShape", () => {
|
|
201
|
+
test("keeps only the keys the schema describes, at every depth", () => {
|
|
202
|
+
const schema = z
|
|
203
|
+
.object({
|
|
204
|
+
address: z.object({ city: z.string() }).strict(),
|
|
205
|
+
name: z.string(),
|
|
206
|
+
})
|
|
207
|
+
.strict()
|
|
208
|
+
const record = {
|
|
209
|
+
address: { city: "Geneva", geocodedAt: "2026-01-01" },
|
|
210
|
+
createdAt: new Date(),
|
|
211
|
+
id: 7,
|
|
212
|
+
name: "Ada",
|
|
213
|
+
}
|
|
214
|
+
expect(pickSchemaShape(schema, record)).toEqual({
|
|
215
|
+
address: { city: "Geneva" },
|
|
216
|
+
name: "Ada",
|
|
217
|
+
})
|
|
218
|
+
// The record is copied, never trimmed in place.
|
|
219
|
+
expect(record).toHaveProperty("id", 7)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("maps an array's element over every row", () => {
|
|
223
|
+
const schema = z.object({
|
|
224
|
+
lines: z.array(z.object({ amount: z.number(), label: z.string() })),
|
|
225
|
+
})
|
|
226
|
+
expect(
|
|
227
|
+
pickSchemaShape(schema, {
|
|
228
|
+
lines: [
|
|
229
|
+
{ amount: 1, id: 10, label: "Design" },
|
|
230
|
+
{ amount: 2, id: 11, label: "Build" },
|
|
231
|
+
],
|
|
232
|
+
})
|
|
233
|
+
).toEqual({
|
|
234
|
+
lines: [
|
|
235
|
+
{ amount: 1, label: "Design" },
|
|
236
|
+
{ amount: 2, label: "Build" },
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("unwraps optional, nullable and default, keeping null and absent keys", () => {
|
|
242
|
+
const address = z.object({ city: z.string() })
|
|
243
|
+
const schema = z.object({
|
|
244
|
+
billing: address.nullable(),
|
|
245
|
+
shipping: address.optional(),
|
|
246
|
+
tags: z.array(z.object({ label: z.string() })).default([]),
|
|
247
|
+
work: address.nullable(),
|
|
248
|
+
})
|
|
249
|
+
const picked = pickSchemaShape(schema, {
|
|
250
|
+
billing: null,
|
|
251
|
+
tags: [{ id: 1, label: "vip" }],
|
|
252
|
+
work: { city: "Bern", id: 3 },
|
|
253
|
+
})
|
|
254
|
+
expect(picked).toEqual({
|
|
255
|
+
billing: null,
|
|
256
|
+
tags: [{ label: "vip" }],
|
|
257
|
+
work: { city: "Bern" },
|
|
258
|
+
})
|
|
259
|
+
expect(picked).not.toHaveProperty("shipping")
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test("reads a pipe or a transform through its input", () => {
|
|
263
|
+
const schema = z
|
|
264
|
+
.object({
|
|
265
|
+
amount: z.string().transform(Number),
|
|
266
|
+
period: z
|
|
267
|
+
.object({ from: z.string(), to: z.string() })
|
|
268
|
+
.transform(({ from, to }) => `${from}/${to}`),
|
|
269
|
+
})
|
|
270
|
+
.transform((invoice) => ({ ...invoice, total: invoice.amount }))
|
|
271
|
+
expect(
|
|
272
|
+
pickSchemaShape(schema, {
|
|
273
|
+
amount: "12",
|
|
274
|
+
id: 4,
|
|
275
|
+
period: { from: "2026-01", id: 9, to: "2026-02" },
|
|
276
|
+
})
|
|
277
|
+
).toEqual({ amount: "12", period: { from: "2026-01", to: "2026-02" } })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test("keeps as is what the schema doesn't describe for certain", () => {
|
|
281
|
+
const schema = z.object({
|
|
282
|
+
contact: z.union([
|
|
283
|
+
z.object({ email: z.string() }),
|
|
284
|
+
z.object({ phone: z.string() }),
|
|
285
|
+
]),
|
|
286
|
+
extra: z.object({ a: z.string() }).catchall(z.string()),
|
|
287
|
+
labels: z.record(z.string(), z.object({ text: z.string() })),
|
|
288
|
+
loose: z.looseObject({ a: z.string() }),
|
|
289
|
+
meta: z.unknown(),
|
|
290
|
+
profile: z.object({ nick: z.string() }),
|
|
291
|
+
})
|
|
292
|
+
const value = {
|
|
293
|
+
contact: { email: "ada@example.com", id: 1 },
|
|
294
|
+
extra: { a: "x", b: "y" },
|
|
295
|
+
labels: { en: { id: 2, text: "Hello" } },
|
|
296
|
+
loose: { a: "x", b: "y" },
|
|
297
|
+
meta: { anything: true },
|
|
298
|
+
profile: "not an object",
|
|
299
|
+
}
|
|
300
|
+
expect(pickSchemaShape(schema, value)).toEqual(value)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("leaves a value alone without a Zod schema", () => {
|
|
304
|
+
const value = { id: 1, name: "Ada" }
|
|
305
|
+
expect(
|
|
306
|
+
pickSchemaShape({} as Parameters<typeof pickSchemaShape>[0], value)
|
|
307
|
+
).toBe(value)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test("leaves a missing key absent, for the schema default to fill", () => {
|
|
311
|
+
const schema = z.object({
|
|
312
|
+
name: z.string(),
|
|
313
|
+
role: z.string().default("member"),
|
|
314
|
+
})
|
|
315
|
+
const picked = pickSchemaShape(schema, { id: 7, name: "Ada" })
|
|
316
|
+
expect(deepMergeDefaults(extractZodDefaults(schema), picked)).toEqual({
|
|
317
|
+
name: "Ada",
|
|
318
|
+
role: "member",
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Extract default values from a Zod schema for `useForm
|
|
1
|
+
// Extract default values from a Zod schema for `useForm`, and keep the values
|
|
2
|
+
// a caller passes to the schema's shape (`pickSchemaShape`, below).
|
|
2
3
|
//
|
|
3
4
|
// Tier A+ semantics:
|
|
4
5
|
// - `.default(...)` / `.prefault(...)` values flow through.
|
|
@@ -23,6 +24,7 @@ type SchemaDef = {
|
|
|
23
24
|
readonly type?: string
|
|
24
25
|
readonly innerType?: SchemaNode
|
|
25
26
|
readonly shape?: Record<string, SchemaNode>
|
|
27
|
+
readonly catchall?: SchemaNode
|
|
26
28
|
readonly in?: SchemaNode
|
|
27
29
|
readonly element?: SchemaNode
|
|
28
30
|
readonly defaultValue?: unknown
|
|
@@ -148,6 +150,71 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
148
150
|
)
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
// `.strict()` sets `catchall` to `z.never()`; a plain `z.object` leaves it
|
|
154
|
+
// unset. Either way the schema accepts no key outside its `shape`.
|
|
155
|
+
function acceptsOnlyShape(def: SchemaDef): boolean {
|
|
156
|
+
return def.catchall === undefined || getDef(def.catchall)?.type === "never"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pickShape(node: SchemaNode, value: unknown): unknown {
|
|
160
|
+
let current: SchemaNode | undefined = node
|
|
161
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
162
|
+
const def: SchemaDef | undefined = getDef(current)
|
|
163
|
+
switch (def?.type) {
|
|
164
|
+
case "object": {
|
|
165
|
+
const { shape } = def
|
|
166
|
+
if (!(shape && isPlainObject(value) && acceptsOnlyShape(def))) {
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
const result: Record<string, unknown> = {}
|
|
170
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
171
|
+
if (Object.hasOwn(value, key)) {
|
|
172
|
+
result[key] = pickShape(field, value[key])
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return result
|
|
176
|
+
}
|
|
177
|
+
case "array": {
|
|
178
|
+
const { element } = def
|
|
179
|
+
return element && Array.isArray(value)
|
|
180
|
+
? value.map((item) => pickShape(element, item))
|
|
181
|
+
: value
|
|
182
|
+
}
|
|
183
|
+
case "default":
|
|
184
|
+
case "nonoptional":
|
|
185
|
+
case "nullable":
|
|
186
|
+
case "optional":
|
|
187
|
+
case "prefault":
|
|
188
|
+
case "readonly":
|
|
189
|
+
current = def.innerType
|
|
190
|
+
continue
|
|
191
|
+
case "pipe":
|
|
192
|
+
current = def.in
|
|
193
|
+
continue
|
|
194
|
+
default:
|
|
195
|
+
return value
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return value
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `value` with only the keys `schema` describes, so a form can start from a
|
|
202
|
+
// record as read (`{ id, createdAt, … }`) without a `.strict()` schema
|
|
203
|
+
// rejecting the rest on every submit. An object keeps the keys in its
|
|
204
|
+
// `shape`, an array maps its `element` over every item, and the wrappers
|
|
205
|
+
// unwrap as in `extractDefaultForField`. Where the shape isn't certain (a
|
|
206
|
+
// union, a record, a loose or catchall object, `any`, a non-Zod schema) or the
|
|
207
|
+
// value doesn't fit it (`null`, a string where an object is expected), the
|
|
208
|
+
// value is kept as is. A key the value lacks stays absent: filling it is
|
|
209
|
+
// `extractZodDefaults`' job.
|
|
210
|
+
function pickSchemaShape<T>(schema: SchemaNode, value: T): T {
|
|
211
|
+
try {
|
|
212
|
+
return pickShape(schema, value) as T
|
|
213
|
+
} catch {
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
151
218
|
// User-supplied values always win on conflict. Plain objects on both sides
|
|
152
219
|
// recurse so nested `z.object(...)` defaults aren't clobbered by partial user
|
|
153
220
|
// objects. Arrays, dates, class instances are treated atomically (user value
|
|
@@ -173,4 +240,9 @@ function deepMergeDefaults(
|
|
|
173
240
|
return result
|
|
174
241
|
}
|
|
175
242
|
|
|
176
|
-
export {
|
|
243
|
+
export {
|
|
244
|
+
deepMergeDefaults,
|
|
245
|
+
extractZodDefaultAt,
|
|
246
|
+
extractZodDefaults,
|
|
247
|
+
pickSchemaShape,
|
|
248
|
+
}
|
package/src/lib/submit-action.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
toActionError,
|
|
8
8
|
} from "./action-result"
|
|
9
9
|
import { applyValidationError } from "./apply-validation-error"
|
|
10
|
+
import { pickSchemaShape } from "./schema-defaults"
|
|
10
11
|
import { ValidationError } from "./validation-error"
|
|
11
12
|
import { getFormSchema } from "./validation-modes"
|
|
12
13
|
|
|
@@ -179,14 +180,28 @@ async function submitAction(
|
|
|
179
180
|
|
|
180
181
|
const { reset } = options
|
|
181
182
|
if (reset === true) {
|
|
182
|
-
formApi.reset(result.data)
|
|
183
|
+
formApi.reset(toFormValues(formApi, result.data))
|
|
183
184
|
} else if (reset === "values") {
|
|
184
185
|
formApi.reset(value)
|
|
185
186
|
} else if (typeof reset === "function") {
|
|
186
|
-
formApi.reset(
|
|
187
|
+
formApi.reset(
|
|
188
|
+
toFormValues(formApi, (reset as (data: unknown) => unknown)(result.data))
|
|
189
|
+
)
|
|
187
190
|
}
|
|
188
191
|
return result
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
// What the action returned, kept to the keys the form's own schema describes
|
|
195
|
+
// (not `options.schema`, which is the action's), so a saved record's `id` or
|
|
196
|
+
// `updatedAt` doesn't become a form value that a `.strict()` schema rejects on
|
|
197
|
+
// the next submit. The submitted values need none of this: they already have
|
|
198
|
+
// the form's shape.
|
|
199
|
+
function toFormValues(formApi: FormApiLike, data: unknown): unknown {
|
|
200
|
+
const schema = getFormSchema(formApi as AnyFormApi)
|
|
201
|
+
return schema
|
|
202
|
+
? pickSchemaShape(schema as Parameters<typeof pickSchemaShape>[0], data)
|
|
203
|
+
: data
|
|
204
|
+
}
|
|
205
|
+
|
|
191
206
|
export type { FormApiLike, SubmitActionOptions, SubmitActionSchema }
|
|
192
207
|
export { applyActionError, submitAction }
|
|
@@ -508,6 +508,51 @@ describe("submitAction — success", () => {
|
|
|
508
508
|
expect(form.state.isDirty).toBe(false)
|
|
509
509
|
})
|
|
510
510
|
|
|
511
|
+
// An action that returns the saved row, with keys the form doesn't edit.
|
|
512
|
+
type SavedProfile = Profile & { id: number; updatedAt: string }
|
|
513
|
+
const resets = {
|
|
514
|
+
"a reset function": (saved: SavedProfile): Profile => saved,
|
|
515
|
+
"reset: true": true,
|
|
516
|
+
} as const
|
|
517
|
+
|
|
518
|
+
for (const [label, reset] of Object.entries(resets)) {
|
|
519
|
+
test(`${label} rebases on the saved row's form keys only, so the next save passes a .strict() schema`, async () => {
|
|
520
|
+
const user = userEvent.setup()
|
|
521
|
+
const save = vi.fn((input: Profile) =>
|
|
522
|
+
Promise.resolve<ActionResult<SavedProfile>>({
|
|
523
|
+
data: { ...input, id: 7, updatedAt: "2026-01-01" },
|
|
524
|
+
ok: true,
|
|
525
|
+
})
|
|
526
|
+
)
|
|
527
|
+
function Harness() {
|
|
528
|
+
const form = useForm({
|
|
529
|
+
defaultValues: { email: "ada@example.com", name: "Ada" },
|
|
530
|
+
onSubmit: (submit) => submitAction(submit, save, { reset }),
|
|
531
|
+
schema: profileSchema.strict(),
|
|
532
|
+
})
|
|
533
|
+
const { SubmitButton } = form
|
|
534
|
+
return (
|
|
535
|
+
<FormProvider form={form}>
|
|
536
|
+
<Form>
|
|
537
|
+
<SubmitButton>Submit</SubmitButton>
|
|
538
|
+
</Form>
|
|
539
|
+
</FormProvider>
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
render(<Harness />)
|
|
543
|
+
|
|
544
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
545
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1))
|
|
546
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
547
|
+
|
|
548
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
|
549
|
+
expect(save).toHaveBeenLastCalledWith({
|
|
550
|
+
email: "ada@example.com",
|
|
551
|
+
name: "Ada",
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
511
556
|
test('reset: "values" rebases the form on the submitted values', async () => {
|
|
512
557
|
const user = userEvent.setup()
|
|
513
558
|
const formRef: { current: unknown } = { current: undefined }
|
package/src/use-form.test-d.ts
CHANGED
|
@@ -48,6 +48,29 @@ test("schema path: defaultValues is PartialFormData (deep partial for plain obje
|
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
test("schema path: defaultValues takes a record with keys the schema lacks", () => {
|
|
52
|
+
// A row as read: `useForm` keeps the keys the schema describes at runtime,
|
|
53
|
+
// and the form stays typed from the schema.
|
|
54
|
+
const invoiceSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
lines: z.array(z.object({ label: z.string() }).strict()),
|
|
57
|
+
number: z.string(),
|
|
58
|
+
})
|
|
59
|
+
.strict()
|
|
60
|
+
const row = {
|
|
61
|
+
createdAt: new Date(),
|
|
62
|
+
id: 7,
|
|
63
|
+
lines: [{ id: 1, invoiceId: 7, label: "Design" }],
|
|
64
|
+
number: "2026-001",
|
|
65
|
+
}
|
|
66
|
+
const form = useForm({
|
|
67
|
+
defaultValues: row,
|
|
68
|
+
onSubmit: () => undefined,
|
|
69
|
+
schema: invoiceSchema,
|
|
70
|
+
})
|
|
71
|
+
expectTypeOf(form.state.values).toEqualTypeOf<z.input<typeof invoiceSchema>>()
|
|
72
|
+
})
|
|
73
|
+
|
|
51
74
|
test("schema path: validationLogic is forbidden (type never)", () => {
|
|
52
75
|
// Error lands on the overall call argument, not on `validationLogic`:
|
|
53
76
|
// `validationLogic: never` makes the whole options object incompatible.
|
package/src/use-form.test.tsx
CHANGED
|
@@ -62,6 +62,52 @@ describe("useForm — schema-derived defaults", () => {
|
|
|
62
62
|
})
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
describe("useForm — defaultValues from a record", () => {
|
|
66
|
+
test("an edit form opened with a record as read submits to a .strict() schema", async () => {
|
|
67
|
+
const user = userEvent.setup()
|
|
68
|
+
const schema = z
|
|
69
|
+
.object({
|
|
70
|
+
address: z.object({ city: z.string() }).strict(),
|
|
71
|
+
email: z.email(),
|
|
72
|
+
lines: z.array(z.object({ label: z.string() }).strict()),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
const record = {
|
|
76
|
+
address: { city: "Geneva", updatedAt: "2026-01-01" },
|
|
77
|
+
createdAt: new Date("2026-01-01"),
|
|
78
|
+
email: "ada@example.com",
|
|
79
|
+
id: 7,
|
|
80
|
+
lines: [{ id: 1, label: "Design" }],
|
|
81
|
+
}
|
|
82
|
+
const onSubmit = vi.fn()
|
|
83
|
+
function Harness() {
|
|
84
|
+
const form = useForm({
|
|
85
|
+
defaultValues: record,
|
|
86
|
+
onSubmit: ({ value }) => onSubmit(value),
|
|
87
|
+
schema,
|
|
88
|
+
})
|
|
89
|
+
const { EmailField, SubmitButton } = form
|
|
90
|
+
return (
|
|
91
|
+
<FormProvider form={form}>
|
|
92
|
+
<Form>
|
|
93
|
+
<EmailField label="Email" name="email" />
|
|
94
|
+
<SubmitButton>Submit</SubmitButton>
|
|
95
|
+
</Form>
|
|
96
|
+
</FormProvider>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
render(<Harness />)
|
|
100
|
+
await user.click(screen.getByRole("button", { name: "Submit" }))
|
|
101
|
+
await vi.waitFor(() =>
|
|
102
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
103
|
+
address: { city: "Geneva" },
|
|
104
|
+
email: "ada@example.com",
|
|
105
|
+
lines: [{ label: "Design" }],
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
65
111
|
describe("useForm — required indicator", () => {
|
|
66
112
|
test("`*` rendered next to required field labels (schema-derived)", () => {
|
|
67
113
|
const schema = z.object({
|