@zeno-lib/forms 0.7.1 → 0.9.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 +18 -9
- package/dist/create-zeno-form.mjs +68 -23
- 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/composition.test.tsx +140 -0
- package/src/create-zeno-form.tsx +237 -44
- package/src/default-props.test.tsx +25 -1
- 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
- package/src/with-form.test-d.ts +25 -0
- package/src/with-form.test.tsx +2 -11
|
@@ -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({
|
package/src/with-form.test-d.ts
CHANGED
|
@@ -37,6 +37,31 @@ const AddressGroup = withFieldGroup({
|
|
|
37
37
|
render: () => null,
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
// A section's `form` and a group's `group` carry the fields themselves.
|
|
41
|
+
withForm({
|
|
42
|
+
defaultValues: VALUES,
|
|
43
|
+
render: ({ form }) => {
|
|
44
|
+
form.InputField({ name: "address.city" })
|
|
45
|
+
// @ts-expect-error — not a path of the form
|
|
46
|
+
form.InputField({ name: "phone" })
|
|
47
|
+
return null
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
withFieldGroup({
|
|
52
|
+
defaultValues: VALUES.address,
|
|
53
|
+
props: { label: "" },
|
|
54
|
+
render: ({ group, label }) => {
|
|
55
|
+
expectTypeOf(label).toEqualTypeOf<string>()
|
|
56
|
+
expectTypeOf(group.state.values).toEqualTypeOf<Values["address"]>()
|
|
57
|
+
const { InputField } = group
|
|
58
|
+
InputField({ name: "street" })
|
|
59
|
+
// @ts-expect-error — a path of the form, not of the group
|
|
60
|
+
InputField({ name: "address.street" })
|
|
61
|
+
return null
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
40
65
|
test("withForm takes the form useForm returns, with a schema", () => {
|
|
41
66
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
42
67
|
EmailSection({ form, title: "Email" })
|
package/src/with-form.test.tsx
CHANGED
|
@@ -2,13 +2,7 @@ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
|
2
2
|
import userEvent from "@zeno-lib/test/user-event"
|
|
3
3
|
import { afterEach, expect, test, vi } from "vitest"
|
|
4
4
|
import { z } from "zod"
|
|
5
|
-
import {
|
|
6
|
-
Form,
|
|
7
|
-
FormProvider,
|
|
8
|
-
useAppFields,
|
|
9
|
-
useForm,
|
|
10
|
-
withForm,
|
|
11
|
-
} from "./create-form"
|
|
5
|
+
import { Form, FormProvider, useForm, withForm } from "./create-form"
|
|
12
6
|
|
|
13
7
|
const CITY = /City/
|
|
14
8
|
|
|
@@ -24,10 +18,7 @@ type Profile = z.infer<typeof schema>
|
|
|
24
18
|
|
|
25
19
|
const AddressSection = withForm({
|
|
26
20
|
defaultValues: {} as Profile,
|
|
27
|
-
render: ({ form }) =>
|
|
28
|
-
const { InputField } = useAppFields(form)
|
|
29
|
-
return <InputField label="City" name="address.city" />
|
|
30
|
-
},
|
|
21
|
+
render: ({ form }) => <form.InputField label="City" name="address.city" />,
|
|
31
22
|
})
|
|
32
23
|
|
|
33
24
|
test("a withForm section edits and validates the parent's form", async () => {
|