@zeno-lib/forms 0.7.1 → 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 +34 -0
- 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
|
@@ -178,6 +178,40 @@ describe("FormDialog", () => {
|
|
|
178
178
|
expect(name.value).toBe("Ada")
|
|
179
179
|
})
|
|
180
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
|
+
|
|
181
215
|
test("closes without asking when nothing changed", async () => {
|
|
182
216
|
const user = userEvent.setup()
|
|
183
217
|
render(<Harness />)
|
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({
|