@zeno-lib/forms 0.2.2 → 0.4.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 +5 -3
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +4 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +67 -18
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +5 -3
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +14 -6
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +35 -17
- package/src/fields/date-picker-field.tsx +42 -13
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +12 -7
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +14 -6
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +14 -6
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +14 -6
- package/src/fields/select-field.tsx +31 -7
- package/src/fields/slider-field.tsx +6 -5
- package/src/fields/switch-field.tsx +14 -6
- package/src/fields/textarea-field.tsx +14 -6
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +2 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +45 -3
- package/src/lib/schema-required.ts +113 -29
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +341 -4
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldContent,
|
|
@@ -43,8 +43,9 @@ function SliderField({
|
|
|
43
43
|
...props
|
|
44
44
|
}: SliderFieldProps) {
|
|
45
45
|
const field = useFieldContext<SliderValue>()
|
|
46
|
-
const
|
|
47
|
-
const
|
|
46
|
+
const id = useId()
|
|
47
|
+
const errorId = `${id}-error`
|
|
48
|
+
const descriptionId = `${id}-description`
|
|
48
49
|
const isInvalid = useIsInvalid(field)
|
|
49
50
|
const hideErrors = useHideFieldErrors(field)
|
|
50
51
|
const showError = isInvalid && !hideErrors
|
|
@@ -61,7 +62,7 @@ function SliderField({
|
|
|
61
62
|
{(label || hasReadout) && (
|
|
62
63
|
<FieldContent className="flex-row items-center justify-between">
|
|
63
64
|
{label && (
|
|
64
|
-
<FieldLabel htmlFor={
|
|
65
|
+
<FieldLabel htmlFor={id}>
|
|
65
66
|
{label}
|
|
66
67
|
{isRequired && <RequiredIndicator />}
|
|
67
68
|
</FieldLabel>
|
|
@@ -79,7 +80,7 @@ function SliderField({
|
|
|
79
80
|
[showError, errorId]
|
|
80
81
|
)}
|
|
81
82
|
aria-invalid={isInvalid || undefined}
|
|
82
|
-
id={
|
|
83
|
+
id={id}
|
|
83
84
|
name={field.name}
|
|
84
85
|
onBlur={field.handleBlur}
|
|
85
86
|
onValueChange={(next) => {
|
|
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
11
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
12
|
import {
|
|
12
13
|
Field,
|
|
13
14
|
FieldContent,
|
|
@@ -24,7 +25,10 @@ type SwitchFieldProps = Omit<
|
|
|
24
25
|
> & {
|
|
25
26
|
description?: ReactNode
|
|
26
27
|
label?: ReactNode
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
30
|
+
* `aria-required`.
|
|
31
|
+
*/
|
|
28
32
|
required?: boolean
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -35,13 +39,16 @@ function SwitchField({
|
|
|
35
39
|
...props
|
|
36
40
|
}: SwitchFieldProps) {
|
|
37
41
|
const field = useFieldContext<boolean>()
|
|
38
|
-
const
|
|
39
|
-
const
|
|
42
|
+
const id = useId()
|
|
43
|
+
const errorId = `${id}-error`
|
|
44
|
+
const descriptionId = `${id}-description`
|
|
40
45
|
const isInvalid = useIsInvalid(field)
|
|
41
46
|
const hideErrors = useHideFieldErrors(field)
|
|
42
47
|
const showError = isInvalid && !hideErrors
|
|
43
48
|
const schemaRequired = useIsFieldRequired(field)
|
|
44
49
|
const isRequired = required ?? schemaRequired
|
|
50
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
51
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
45
52
|
|
|
46
53
|
return (
|
|
47
54
|
<Field
|
|
@@ -51,7 +58,7 @@ function SwitchField({
|
|
|
51
58
|
>
|
|
52
59
|
<FieldContent>
|
|
53
60
|
{label && (
|
|
54
|
-
<FieldLabel htmlFor={
|
|
61
|
+
<FieldLabel htmlFor={id}>
|
|
55
62
|
{label}
|
|
56
63
|
{isRequired && <RequiredIndicator />}
|
|
57
64
|
</FieldLabel>
|
|
@@ -69,8 +76,9 @@ function SwitchField({
|
|
|
69
76
|
[showError, errorId]
|
|
70
77
|
)}
|
|
71
78
|
aria-invalid={isInvalid || undefined}
|
|
79
|
+
aria-required={requiresValue || undefined}
|
|
72
80
|
checked={field.state.value ?? false}
|
|
73
|
-
id={
|
|
81
|
+
id={id}
|
|
74
82
|
name={field.name}
|
|
75
83
|
onBlur={field.handleBlur}
|
|
76
84
|
onCheckedChange={(value) => field.handleChange(value)}
|
|
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
11
|
+
import { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
11
12
|
import {
|
|
12
13
|
Field,
|
|
13
14
|
FieldDescription,
|
|
@@ -25,7 +26,10 @@ type TextAreaFieldProps = Omit<
|
|
|
25
26
|
children?: ReactNode
|
|
26
27
|
description?: ReactNode
|
|
27
28
|
label?: ReactNode
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
31
|
+
* `aria-required`.
|
|
32
|
+
*/
|
|
29
33
|
required?: boolean
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -37,14 +41,17 @@ function TextAreaField({
|
|
|
37
41
|
...props
|
|
38
42
|
}: TextAreaFieldProps) {
|
|
39
43
|
const field = useFieldContext<string>()
|
|
40
|
-
const
|
|
41
|
-
const
|
|
44
|
+
const id = useId()
|
|
45
|
+
const errorId = `${id}-error`
|
|
46
|
+
const descriptionId = `${id}-description`
|
|
42
47
|
const isInvalid = useIsInvalid(field)
|
|
43
48
|
const hideErrors = useHideFieldErrors(field)
|
|
44
49
|
const showError = isInvalid && !hideErrors
|
|
45
50
|
const hasAddons = Children.count(children) > 0
|
|
46
51
|
const schemaRequired = useIsFieldRequired(field)
|
|
47
52
|
const isRequired = required ?? schemaRequired
|
|
53
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
54
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
48
55
|
|
|
49
56
|
const textareaProps = {
|
|
50
57
|
"aria-describedby": describedBy(
|
|
@@ -52,7 +59,8 @@ function TextAreaField({
|
|
|
52
59
|
[showError, errorId]
|
|
53
60
|
),
|
|
54
61
|
"aria-invalid": isInvalid || undefined,
|
|
55
|
-
|
|
62
|
+
"aria-required": requiresValue || undefined,
|
|
63
|
+
id,
|
|
56
64
|
name: field.name,
|
|
57
65
|
onBlur: field.handleBlur,
|
|
58
66
|
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
@@ -64,7 +72,7 @@ function TextAreaField({
|
|
|
64
72
|
return (
|
|
65
73
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
66
74
|
{label && (
|
|
67
|
-
<FieldLabel htmlFor={
|
|
75
|
+
<FieldLabel htmlFor={id}>
|
|
68
76
|
{label}
|
|
69
77
|
{isRequired && <RequiredIndicator />}
|
|
70
78
|
</FieldLabel>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
6
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
6
7
|
|
|
@@ -36,7 +37,8 @@ function YearField({ max, min, ...props }: YearFieldProps) {
|
|
|
36
37
|
maximumFractionDigits: 0,
|
|
37
38
|
min,
|
|
38
39
|
onBlur: field.handleBlur,
|
|
39
|
-
onValueChange: (next) =>
|
|
40
|
+
onValueChange: (next) =>
|
|
41
|
+
field.handleChange(next ?? getEmptyValue(field, null)),
|
|
40
42
|
useGrouping: false,
|
|
41
43
|
value: field.state.value,
|
|
42
44
|
})
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* biome-ignore-all lint/performance/noBarrelFile: single npm entry for the headless form API. */
|
|
2
2
|
|
|
3
|
+
export { useSelector } from "@tanstack/react-form"
|
|
3
4
|
export { createZenoForm } from "./create-zeno-form"
|
|
4
5
|
export { Form, FormProvider } from "./form-element"
|
|
5
6
|
export {
|
|
@@ -20,6 +21,7 @@ export {
|
|
|
20
21
|
export {
|
|
21
22
|
useHideFieldErrors,
|
|
22
23
|
useIsFieldRequired,
|
|
24
|
+
useIsFieldRequiredBySchema,
|
|
23
25
|
useIsInvalid,
|
|
24
26
|
} from "./lib/use-is-invalid"
|
|
25
27
|
export { type FieldMessage, ValidationError } from "./lib/validation-error"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { emptyValueFor, withEmptyStartValues } from "./empty-value"
|
|
5
|
+
import { extractZodDefaults } from "./schema-defaults"
|
|
6
|
+
|
|
7
|
+
const FALLBACK = Symbol("fallback")
|
|
8
|
+
|
|
9
|
+
function cleared(field: z.ZodType, name = "field", values: unknown = {}) {
|
|
10
|
+
return emptyValueFor(z.object({ field }), values, name, FALLBACK)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("emptyValueFor", () => {
|
|
14
|
+
test("null, then undefined, then the type's own empty value", () => {
|
|
15
|
+
expect(cleared(z.string().nullable())).toBeNull()
|
|
16
|
+
expect(cleared(z.string().optional())).toBeUndefined()
|
|
17
|
+
expect(cleared(z.string())).toBe("")
|
|
18
|
+
expect(cleared(z.array(z.string()))).toEqual([])
|
|
19
|
+
expect(cleared(z.uuid().nullable())).toBeNull()
|
|
20
|
+
expect(cleared(z.enum(["a", "b"]).optional())).toBeUndefined()
|
|
21
|
+
expect(cleared(z.number().nullable())).toBeNull()
|
|
22
|
+
expect(cleared(z.date().nullish())).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("a field that accepts no empty value keeps the field's own fallback", () => {
|
|
26
|
+
expect(cleared(z.string().min(1))).toBe(FALLBACK)
|
|
27
|
+
expect(cleared(z.number())).toBe(FALLBACK)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("array rows follow the row schema", () => {
|
|
31
|
+
const schema = z.object({
|
|
32
|
+
members: z.array(
|
|
33
|
+
z.object({
|
|
34
|
+
id: z.uuid().nullable(),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
note: z.string().optional(),
|
|
37
|
+
})
|
|
38
|
+
),
|
|
39
|
+
})
|
|
40
|
+
const values = { members: [{ id: "x" }, { id: "y" }] }
|
|
41
|
+
const at = (name: string) => emptyValueFor(schema, values, name, FALLBACK)
|
|
42
|
+
expect(at("members[1].id")).toBeNull()
|
|
43
|
+
expect(at("members[1].name")).toBe("")
|
|
44
|
+
expect(at("members[1].note")).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("without a schema, or with an async one, the fallback stands", () => {
|
|
48
|
+
expect(emptyValueFor(undefined, {}, "field", FALLBACK)).toBe(FALLBACK)
|
|
49
|
+
const asyncSchema = z.object({
|
|
50
|
+
field: z.string().refine(async () => true),
|
|
51
|
+
})
|
|
52
|
+
expect(emptyValueFor(asyncSchema, {}, "field", FALLBACK)).toBe(FALLBACK)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("withEmptyStartValues", () => {
|
|
57
|
+
test('starts a field whose schema rejects `""` at its empty value', () => {
|
|
58
|
+
const schema = z.object({
|
|
59
|
+
email: z.email().optional(),
|
|
60
|
+
name: z.string().min(1),
|
|
61
|
+
note: z.string(),
|
|
62
|
+
ownerId: z.uuid().nullable(),
|
|
63
|
+
})
|
|
64
|
+
const start = withEmptyStartValues(schema, extractZodDefaults(schema))
|
|
65
|
+
expect(start).toEqual({
|
|
66
|
+
email: undefined,
|
|
67
|
+
name: "",
|
|
68
|
+
note: "",
|
|
69
|
+
ownerId: null,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { AnyFieldApi } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import { toFieldName } from "./action-result"
|
|
4
|
+
import { extractZodDefaultAt } from "./schema-defaults"
|
|
5
|
+
import { type StandardSchemaLike, validateSync } from "./schema-required"
|
|
6
|
+
import { getFormSchema } from "./validation-modes"
|
|
7
|
+
|
|
8
|
+
// A field's empty value is the first one its schema accepts of `null`,
|
|
9
|
+
// `undefined` and the type's own empty value (`""` for a string, `[]` for an
|
|
10
|
+
// array, through `extractZodDefaults`). So `.nullable()` empties to `null`,
|
|
11
|
+
// `.optional()` to `undefined`, and a bare `z.string()` to `""`. A field that
|
|
12
|
+
// accepts none of them is required. A cleared field holds its empty value,
|
|
13
|
+
// and so does an untouched one whose schema rejects `""` or `[]`.
|
|
14
|
+
|
|
15
|
+
const NAME_SEGMENT = /[^.[\]]+/g
|
|
16
|
+
const INDEX = /^\d+$/
|
|
17
|
+
|
|
18
|
+
// `members[2].name` (or `members.2.name`) → `["members", 2, "name"]`.
|
|
19
|
+
function toPathKeys(name: string): PropertyKey[] {
|
|
20
|
+
return (name.match(NAME_SEGMENT) ?? []).map((part) =>
|
|
21
|
+
INDEX.test(part) ? Number(part) : part
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function valueAt(root: unknown, keys: readonly PropertyKey[]): unknown {
|
|
26
|
+
let node = root
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
if (typeof node !== "object" || node === null) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
node = (node as Record<PropertyKey, unknown>)[key]
|
|
32
|
+
}
|
|
33
|
+
return node
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A copy of `root` with `value` at `keys`, copying only the nodes on the way.
|
|
37
|
+
function withValueAt(
|
|
38
|
+
root: unknown,
|
|
39
|
+
keys: readonly PropertyKey[],
|
|
40
|
+
value: unknown
|
|
41
|
+
): unknown {
|
|
42
|
+
const [key, ...rest] = keys
|
|
43
|
+
if (key === undefined) {
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
const node = (root ?? (typeof key === "number" ? [] : {})) as Record<
|
|
47
|
+
PropertyKey,
|
|
48
|
+
unknown
|
|
49
|
+
>
|
|
50
|
+
const copy = (Array.isArray(node) ? [...node] : { ...node }) as Record<
|
|
51
|
+
PropertyKey,
|
|
52
|
+
unknown
|
|
53
|
+
>
|
|
54
|
+
copy[key] = withValueAt(node[key], rest, value)
|
|
55
|
+
return copy
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyValueFor(
|
|
59
|
+
schema: StandardSchemaLike | undefined,
|
|
60
|
+
values: unknown,
|
|
61
|
+
name: string,
|
|
62
|
+
fallback: unknown
|
|
63
|
+
): unknown {
|
|
64
|
+
if (!schema) {
|
|
65
|
+
return fallback
|
|
66
|
+
}
|
|
67
|
+
const keys = toPathKeys(name)
|
|
68
|
+
const fieldName = toFieldName(keys)
|
|
69
|
+
const typeEmpty = extractZodDefaultAt(
|
|
70
|
+
schema as Parameters<typeof extractZodDefaultAt>[0],
|
|
71
|
+
keys
|
|
72
|
+
)
|
|
73
|
+
const candidates = typeEmpty.ok
|
|
74
|
+
? [null, undefined, typeEmpty.value]
|
|
75
|
+
: [null, undefined]
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
const issues = validateSync(schema, withValueAt(values, keys, candidate))
|
|
78
|
+
if (!issues) {
|
|
79
|
+
return fallback
|
|
80
|
+
}
|
|
81
|
+
if (!issues.some((issue) => toFieldName(issue.path) === fieldName)) {
|
|
82
|
+
return candidate
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fallback
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isTypeEmpty(value: unknown): boolean {
|
|
89
|
+
return value === "" || (Array.isArray(value) && value.length === 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `extractZodDefaults` starts every string at `""` and every array at `[]`.
|
|
93
|
+
// Where the schema rejects that (`z.uuid().nullable()`,
|
|
94
|
+
// `z.email().optional()`), start the field at its empty value instead, so an
|
|
95
|
+
// untouched optional field passes. A required one keeps `""` or `[]`.
|
|
96
|
+
function withEmptyStartValues(
|
|
97
|
+
schema: StandardSchemaLike,
|
|
98
|
+
defaults: Record<string, unknown>
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
const issues = validateSync(schema, defaults)
|
|
101
|
+
let result: unknown = defaults
|
|
102
|
+
for (const issue of issues ?? []) {
|
|
103
|
+
const name = toFieldName(issue.path)
|
|
104
|
+
const keys = toPathKeys(name)
|
|
105
|
+
const start = valueAt(defaults, keys)
|
|
106
|
+
if (keys.length === 0 || !isTypeEmpty(start)) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const empty = emptyValueFor(schema, result, name, start)
|
|
110
|
+
if (empty !== start) {
|
|
111
|
+
result = withValueAt(result, keys, empty)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result as Record<string, unknown>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The value `field` takes when the user clears it, by the rule above. Typed as
|
|
119
|
+
* the field's value, since the same schema decides both.
|
|
120
|
+
*/
|
|
121
|
+
function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
|
|
122
|
+
return emptyValueFor(
|
|
123
|
+
getFormSchema(field.form),
|
|
124
|
+
field.form.state.values,
|
|
125
|
+
field.name,
|
|
126
|
+
fallback
|
|
127
|
+
) as T
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { emptyValueFor, getEmptyValue, withEmptyStartValues }
|
|
@@ -24,6 +24,7 @@ type SchemaDef = {
|
|
|
24
24
|
readonly innerType?: SchemaNode
|
|
25
25
|
readonly shape?: Record<string, SchemaNode>
|
|
26
26
|
readonly in?: SchemaNode
|
|
27
|
+
readonly element?: SchemaNode
|
|
27
28
|
readonly defaultValue?: unknown
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -91,6 +92,54 @@ function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
|
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Step from an object or array schema into its `key` child, through any
|
|
96
|
+
// wrapper around it. `null` when there is none.
|
|
97
|
+
function childAt(node: SchemaNode, key: PropertyKey): SchemaNode | null {
|
|
98
|
+
let current: SchemaNode | undefined = node
|
|
99
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
100
|
+
const def: SchemaDef | undefined = getDef(current)
|
|
101
|
+
switch (def?.type) {
|
|
102
|
+
case "object":
|
|
103
|
+
return (typeof key === "string" && def.shape?.[key]) || null
|
|
104
|
+
case "array":
|
|
105
|
+
return (typeof key === "number" && def.element) || null
|
|
106
|
+
case "default":
|
|
107
|
+
case "nonoptional":
|
|
108
|
+
case "nullable":
|
|
109
|
+
case "optional":
|
|
110
|
+
case "prefault":
|
|
111
|
+
case "readonly":
|
|
112
|
+
current = def.innerType
|
|
113
|
+
continue
|
|
114
|
+
case "pipe":
|
|
115
|
+
current = def.in
|
|
116
|
+
continue
|
|
117
|
+
default:
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The value `extractZodDefaults` starts the field at `keys` at, array rows
|
|
125
|
+
// included (`["members", 2, "name"]`). `{ ok: false }` when it gives none.
|
|
126
|
+
function extractZodDefaultAt(
|
|
127
|
+
schema: SchemaNode,
|
|
128
|
+
keys: readonly PropertyKey[]
|
|
129
|
+
): ExtractResult {
|
|
130
|
+
try {
|
|
131
|
+
let node: SchemaNode | null = schema
|
|
132
|
+
for (const key of keys) {
|
|
133
|
+
node = node && childAt(node, key)
|
|
134
|
+
}
|
|
135
|
+
return node && keys.length > 0
|
|
136
|
+
? extractDefaultForField(node)
|
|
137
|
+
: { ok: false }
|
|
138
|
+
} catch {
|
|
139
|
+
return { ok: false }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
94
143
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
95
144
|
return (
|
|
96
145
|
typeof value === "object" &&
|
|
@@ -124,4 +173,4 @@ function deepMergeDefaults(
|
|
|
124
173
|
return result
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
export { deepMergeDefaults, extractZodDefaults }
|
|
176
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
|
|
@@ -38,6 +38,42 @@ describe("getRequiredPaths", () => {
|
|
|
38
38
|
expect(required.has("role")).toBe(false)
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
test("a string that accepts the empty value it starts at is not required", () => {
|
|
42
|
+
const schema = z.object({
|
|
43
|
+
email: z.email(),
|
|
44
|
+
members: z.array(z.object({ name: z.string().min(1), note: z.string() })),
|
|
45
|
+
name: z.string().trim().min(1),
|
|
46
|
+
notes: z.string(),
|
|
47
|
+
profile: z.object({ bio: z.string(), title: z.string().min(1) }),
|
|
48
|
+
subtitle: z.string().nullable(),
|
|
49
|
+
})
|
|
50
|
+
const required = getRequiredPaths(schema)
|
|
51
|
+
expect(required.has("notes")).toBe(false)
|
|
52
|
+
expect(required.has("subtitle")).toBe(false)
|
|
53
|
+
expect(required.has("profile.bio")).toBe(false)
|
|
54
|
+
expect(required.has("members[0].note")).toBe(false)
|
|
55
|
+
expect(required.has("email")).toBe(true)
|
|
56
|
+
expect(required.has("name")).toBe(true)
|
|
57
|
+
expect(required.has("profile.title")).toBe(true)
|
|
58
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("a field that accepts null is not required", () => {
|
|
62
|
+
const schema = z.object({
|
|
63
|
+
count: z.number(),
|
|
64
|
+
day: z.date().nullable(),
|
|
65
|
+
kind: z.enum(["a", "b"]).nullable(),
|
|
66
|
+
ownerId: z.uuid().nullable(),
|
|
67
|
+
total: z.number().nullable(),
|
|
68
|
+
})
|
|
69
|
+
const required = getRequiredPaths(schema)
|
|
70
|
+
expect(required.has("count")).toBe(true)
|
|
71
|
+
expect(required.has("day")).toBe(false)
|
|
72
|
+
expect(required.has("kind")).toBe(false)
|
|
73
|
+
expect(required.has("ownerId")).toBe(false)
|
|
74
|
+
expect(required.has("total")).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
41
77
|
test("nested z.object reports the parent and its required children", () => {
|
|
42
78
|
const schema = z.object({
|
|
43
79
|
profile: z.object({
|
|
@@ -60,26 +96,32 @@ describe("getRequiredPaths", () => {
|
|
|
60
96
|
|
|
61
97
|
test("array rows are reported with TanStack bracket syntax", () => {
|
|
62
98
|
const schema = z.object({
|
|
99
|
+
emails: z.array(z.email()),
|
|
63
100
|
members: z.array(
|
|
64
101
|
z.object({
|
|
65
102
|
name: z.string().min(1),
|
|
66
103
|
note: z.string().optional(),
|
|
67
104
|
})
|
|
68
105
|
),
|
|
106
|
+
picked: z.array(z.string()).min(1),
|
|
69
107
|
tags: z.array(z.string()),
|
|
70
108
|
})
|
|
71
109
|
const required = getRequiredPaths(schema)
|
|
72
|
-
expect(required.has("members")).toBe(true)
|
|
73
110
|
expect(required.has("members[0].name")).toBe(true)
|
|
74
111
|
expect(required.has("members.0.name")).toBe(false)
|
|
75
112
|
expect(required.has("members[0].note")).toBe(false)
|
|
76
|
-
expect(required.has("
|
|
113
|
+
expect(required.has("emails[0]")).toBe(true)
|
|
114
|
+
expect(required.has("picked")).toBe(true)
|
|
115
|
+
// `[]` and `""` are what the form starts them at, and both pass.
|
|
116
|
+
expect(required.has("members")).toBe(false)
|
|
117
|
+
expect(required.has("tags")).toBe(false)
|
|
118
|
+
expect(required.has("tags[0]")).toBe(false)
|
|
77
119
|
})
|
|
78
120
|
|
|
79
121
|
test("nested arrays descend through every level", () => {
|
|
80
122
|
const schema = z.object({
|
|
81
123
|
groups: z.array(
|
|
82
|
-
z.object({ members: z.array(z.object({ name: z.string() })) })
|
|
124
|
+
z.object({ members: z.array(z.object({ name: z.string().min(1) })) })
|
|
83
125
|
),
|
|
84
126
|
})
|
|
85
127
|
const required = getRequiredPaths(schema)
|