@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.
@@ -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 { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
243
+ export {
244
+ deepMergeDefaults,
245
+ extractZodDefaultAt,
246
+ extractZodDefaults,
247
+ pickSchemaShape,
248
+ }
@@ -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((reset as (data: unknown) => unknown)(result.data))
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 }
@@ -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.
@@ -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({
@@ -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" })
@@ -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 () => {