@zeno-lib/forms 0.0.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/package.json +47 -0
- package/src/addons/index.ts +2 -0
- package/src/addons/validation-spinner.tsx +25 -0
- package/src/fields/checkbox-field.tsx +80 -0
- package/src/fields/combobox-field.tsx +189 -0
- package/src/fields/date-picker-field.tsx +141 -0
- package/src/fields/email-field.tsx +21 -0
- package/src/fields/index.ts +16 -0
- package/src/fields/input-field.tsx +95 -0
- package/src/fields/number-field.tsx +33 -0
- package/src/fields/otp-field.tsx +111 -0
- package/src/fields/password-field.tsx +20 -0
- package/src/fields/radio-group-field.tsx +105 -0
- package/src/fields/reset-button.tsx +29 -0
- package/src/fields/select-field.tsx +113 -0
- package/src/fields/slider-field.tsx +103 -0
- package/src/fields/submit-button.tsx +31 -0
- package/src/fields/switch-field.tsx +80 -0
- package/src/fields/textarea-field.tsx +91 -0
- package/src/form.tsx +105 -0
- package/src/lib/apply-validation-error.test.ts +97 -0
- package/src/lib/apply-validation-error.ts +31 -0
- package/src/lib/aria.ts +7 -0
- package/src/lib/contexts.ts +9 -0
- package/src/lib/required-indicator.tsx +22 -0
- package/src/lib/schema-defaults.test.ts +195 -0
- package/src/lib/schema-defaults.ts +127 -0
- package/src/lib/schema-required.test.ts +132 -0
- package/src/lib/schema-required.ts +64 -0
- package/src/lib/use-is-invalid.ts +109 -0
- package/src/lib/use-unsaved-changes-warning.ts +57 -0
- package/src/lib/validation-error.test.ts +41 -0
- package/src/lib/validation-error.ts +30 -0
- package/src/lib/validation-logic.test.ts +261 -0
- package/src/lib/validation-logic.ts +192 -0
- package/src/lib/validation-modes.test-d.ts +9 -0
- package/src/lib/validation-modes.test.ts +72 -0
- package/src/lib/validation-modes.ts +73 -0
- package/src/tanstack.ts +2 -0
- package/src/use-app-fields.test-d.ts +112 -0
- package/src/use-app-fields.test.tsx +163 -0
- package/src/use-app-fields.tsx +272 -0
- package/src/use-form.cascade.test.tsx +83 -0
- package/src/use-form.test-d.ts +153 -0
- package/src/use-form.test.tsx +348 -0
- package/src/use-form.tsx +450 -0
- package/src/use-form.unsaved-warning.test.tsx +192 -0
- package/src/use-form.validation-modes.test.tsx +149 -0
package/src/form.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/* biome-ignore-all lint/performance/noBarrelFile: so consumers depend only on `@zeno-lib/forms`. */
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import { type AnyFormApi, createFormHook } from "@tanstack/react-form"
|
|
5
|
+
import type { ComponentProps, FormEvent, ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
import { CheckboxField } from "./fields/checkbox-field"
|
|
8
|
+
import { ComboboxField } from "./fields/combobox-field"
|
|
9
|
+
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
|
+
import { EmailField } from "./fields/email-field"
|
|
11
|
+
import { InputField } from "./fields/input-field"
|
|
12
|
+
import { NumberField } from "./fields/number-field"
|
|
13
|
+
import { OtpField } from "./fields/otp-field"
|
|
14
|
+
import { PasswordField } from "./fields/password-field"
|
|
15
|
+
import { RadioGroupField } from "./fields/radio-group-field"
|
|
16
|
+
import { ResetButton } from "./fields/reset-button"
|
|
17
|
+
import { SelectField } from "./fields/select-field"
|
|
18
|
+
import { SliderField } from "./fields/slider-field"
|
|
19
|
+
import { SubmitButton } from "./fields/submit-button"
|
|
20
|
+
import { SwitchField } from "./fields/switch-field"
|
|
21
|
+
import { TextAreaField } from "./fields/textarea-field"
|
|
22
|
+
import {
|
|
23
|
+
fieldContext,
|
|
24
|
+
formContext,
|
|
25
|
+
FormProvider as RawFormProvider,
|
|
26
|
+
useFormContext,
|
|
27
|
+
} from "./lib/contexts"
|
|
28
|
+
|
|
29
|
+
export * from "./addons"
|
|
30
|
+
export { RadioGroupFieldItem } from "./fields/radio-group-field"
|
|
31
|
+
export { applyValidationError } from "./lib/apply-validation-error"
|
|
32
|
+
export { useFieldContext, useFormContext } from "./lib/contexts"
|
|
33
|
+
export {
|
|
34
|
+
RequiredIndicator,
|
|
35
|
+
type RequiredIndicatorProps,
|
|
36
|
+
} from "./lib/required-indicator"
|
|
37
|
+
export {
|
|
38
|
+
useHideFieldErrors,
|
|
39
|
+
useIsFieldRequired,
|
|
40
|
+
useIsInvalid,
|
|
41
|
+
} from "./lib/use-is-invalid"
|
|
42
|
+
export { type FieldMessage, ValidationError } from "./lib/validation-error"
|
|
43
|
+
export { blurThenChangeLogic } from "./lib/validation-logic"
|
|
44
|
+
export type { ValidationMode } from "./lib/validation-modes"
|
|
45
|
+
export { useAppFields } from "./use-app-fields"
|
|
46
|
+
export { useForm } from "./use-form"
|
|
47
|
+
|
|
48
|
+
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
49
|
+
fieldComponents: {
|
|
50
|
+
CheckboxField,
|
|
51
|
+
ComboboxField,
|
|
52
|
+
DatePickerField,
|
|
53
|
+
EmailField,
|
|
54
|
+
InputField,
|
|
55
|
+
NumberField,
|
|
56
|
+
OtpField,
|
|
57
|
+
PasswordField,
|
|
58
|
+
RadioGroupField,
|
|
59
|
+
SelectField,
|
|
60
|
+
SliderField,
|
|
61
|
+
SwitchField,
|
|
62
|
+
TextAreaField,
|
|
63
|
+
},
|
|
64
|
+
fieldContext,
|
|
65
|
+
formComponents: {
|
|
66
|
+
ResetButton,
|
|
67
|
+
SubmitButton,
|
|
68
|
+
},
|
|
69
|
+
formContext,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export { useAppForm, withFieldGroup, withForm }
|
|
73
|
+
|
|
74
|
+
type FormProviderProps = {
|
|
75
|
+
children: ReactNode
|
|
76
|
+
form: { handleSubmit: () => unknown }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function FormProvider({ children, form }: FormProviderProps) {
|
|
80
|
+
return (
|
|
81
|
+
<RawFormProvider value={form as AnyFormApi}>{children}</RawFormProvider>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type FormProps = Omit<ComponentProps<"form">, "onSubmit">
|
|
86
|
+
|
|
87
|
+
function Form({ children, className, ...props }: FormProps) {
|
|
88
|
+
const form = useFormContext()
|
|
89
|
+
return (
|
|
90
|
+
<form
|
|
91
|
+
className={className}
|
|
92
|
+
noValidate
|
|
93
|
+
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
94
|
+
event.preventDefault()
|
|
95
|
+
event.stopPropagation()
|
|
96
|
+
Promise.resolve(form.handleSubmit()).catch(() => undefined)
|
|
97
|
+
}}
|
|
98
|
+
{...props}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</form>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { Form, FormProvider }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
+
import { describe, expect, test, vi } from "@zeno-lib/vitest"
|
|
3
|
+
|
|
4
|
+
import { applyValidationError } from "./apply-validation-error"
|
|
5
|
+
import { ValidationError } from "./validation-error"
|
|
6
|
+
|
|
7
|
+
type FieldMetaUpdater = (prev: {
|
|
8
|
+
errorMap?: Record<string, unknown>
|
|
9
|
+
errors?: unknown[]
|
|
10
|
+
}) => unknown
|
|
11
|
+
|
|
12
|
+
function makeFormStub() {
|
|
13
|
+
const setFieldMeta = vi.fn(
|
|
14
|
+
(_name: string, _updater: FieldMetaUpdater) => undefined
|
|
15
|
+
)
|
|
16
|
+
const setErrorMap = vi.fn((_map: unknown) => undefined)
|
|
17
|
+
return {
|
|
18
|
+
api: { setErrorMap, setFieldMeta } as unknown as AnyFormApi,
|
|
19
|
+
setErrorMap,
|
|
20
|
+
setFieldMeta,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("applyValidationError", () => {
|
|
25
|
+
test("writes single string message to errorMap.onChange and errors array", () => {
|
|
26
|
+
const { api, setFieldMeta } = makeFormStub()
|
|
27
|
+
applyValidationError(api, new ValidationError({ email: "Taken" }))
|
|
28
|
+
expect(setFieldMeta).toHaveBeenCalledTimes(1)
|
|
29
|
+
const [name, updater] = setFieldMeta.mock.calls[0] as [
|
|
30
|
+
string,
|
|
31
|
+
FieldMetaUpdater,
|
|
32
|
+
]
|
|
33
|
+
expect(name).toBe("email")
|
|
34
|
+
const next = updater({}) as {
|
|
35
|
+
errors: { message: string }[]
|
|
36
|
+
errorMap: { onChange: { message: string } }
|
|
37
|
+
isValid: boolean
|
|
38
|
+
}
|
|
39
|
+
expect(next.errors).toEqual([{ message: "Taken" }])
|
|
40
|
+
expect(next.errorMap.onChange).toEqual({ message: "Taken" })
|
|
41
|
+
expect(next.isValid).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("array of messages forwards all entries; errorMap.onChange gets first", () => {
|
|
45
|
+
const { api, setFieldMeta } = makeFormStub()
|
|
46
|
+
applyValidationError(
|
|
47
|
+
api,
|
|
48
|
+
new ValidationError({ password: ["short", "no digit"] })
|
|
49
|
+
)
|
|
50
|
+
const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
|
|
51
|
+
const next = updater({}) as {
|
|
52
|
+
errors: { message: string }[]
|
|
53
|
+
errorMap: { onChange: { message: string } }
|
|
54
|
+
}
|
|
55
|
+
expect(next.errors).toEqual([{ message: "short" }, { message: "no digit" }])
|
|
56
|
+
expect(next.errorMap.onChange).toEqual({ message: "short" })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("preserves existing errorMap keys via spread", () => {
|
|
60
|
+
const { api, setFieldMeta } = makeFormStub()
|
|
61
|
+
applyValidationError(api, new ValidationError({ email: "Taken" }))
|
|
62
|
+
const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
|
|
63
|
+
const next = updater({ errorMap: { onBlur: { message: "Old" } } }) as {
|
|
64
|
+
errorMap: { onBlur: { message: string }; onChange: { message: string } }
|
|
65
|
+
}
|
|
66
|
+
expect(next.errorMap.onBlur).toEqual({ message: "Old" })
|
|
67
|
+
expect(next.errorMap.onChange).toEqual({ message: "Taken" })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("formError populates form-level error map via setErrorMap", () => {
|
|
71
|
+
const { api, setErrorMap } = makeFormStub()
|
|
72
|
+
applyValidationError(
|
|
73
|
+
api,
|
|
74
|
+
new ValidationError({ email: "Taken" }, { formError: "Server down" })
|
|
75
|
+
)
|
|
76
|
+
expect(setErrorMap).toHaveBeenCalledWith({
|
|
77
|
+
onSubmit: { fields: {}, form: "Server down" },
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("no formError → setErrorMap not called", () => {
|
|
82
|
+
const { api, setErrorMap } = makeFormStub()
|
|
83
|
+
applyValidationError(api, new ValidationError({ email: "Taken" }))
|
|
84
|
+
expect(setErrorMap).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("multiple fields → setFieldMeta called per field", () => {
|
|
88
|
+
const { api, setFieldMeta } = makeFormStub()
|
|
89
|
+
applyValidationError(
|
|
90
|
+
api,
|
|
91
|
+
new ValidationError({ email: "Taken", name: "Required" })
|
|
92
|
+
)
|
|
93
|
+
expect(setFieldMeta).toHaveBeenCalledTimes(2)
|
|
94
|
+
const names = setFieldMeta.mock.calls.map((c) => c[0])
|
|
95
|
+
expect(names).toEqual(expect.arrayContaining(["email", "name"]))
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import type { ValidationError } from "./validation-error"
|
|
4
|
+
|
|
5
|
+
// Write per-field messages from a `ValidationError` onto the form. We target
|
|
6
|
+
// `errorMap.onChange` so the form-level schema overwrites the message as
|
|
7
|
+
// soon as the user edits the field; the error clears automatically the
|
|
8
|
+
// moment the value passes validation. Form-level error message is written
|
|
9
|
+
// to the form's overall error map under the `onSubmit` cause.
|
|
10
|
+
function applyValidationError(
|
|
11
|
+
formApi: AnyFormApi,
|
|
12
|
+
error: ValidationError
|
|
13
|
+
): void {
|
|
14
|
+
for (const [name, message] of Object.entries(error.fields)) {
|
|
15
|
+
const messages = Array.isArray(message) ? message : [message]
|
|
16
|
+
const errors = messages.map((m) => ({ message: m }))
|
|
17
|
+
formApi.setFieldMeta(name, (prev) => ({
|
|
18
|
+
...prev,
|
|
19
|
+
errorMap: { ...prev.errorMap, onChange: errors[0] },
|
|
20
|
+
errors,
|
|
21
|
+
isValid: false,
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
if (error.formError) {
|
|
25
|
+
formApi.setErrorMap({
|
|
26
|
+
onSubmit: { fields: {}, form: error.formError },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { applyValidationError }
|
package/src/lib/aria.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Builds an aria-describedby string from [condition, id] pairs; only ids with a truthy condition are included.
|
|
2
|
+
export function describedBy(
|
|
3
|
+
...entries: [unknown, string][]
|
|
4
|
+
): string | undefined {
|
|
5
|
+
const ids = entries.filter(([show]) => Boolean(show)).map(([, id]) => id)
|
|
6
|
+
return ids.length > 0 ? ids.join(" ") : undefined
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createFormHookContexts } from "@tanstack/react-form"
|
|
4
|
+
|
|
5
|
+
const { fieldContext, formContext, useFieldContext, useFormContext } =
|
|
6
|
+
createFormHookContexts()
|
|
7
|
+
|
|
8
|
+
export { fieldContext, formContext, useFieldContext, useFormContext }
|
|
9
|
+
export const FormProvider = formContext.Provider
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@zeno-lib/ui/lib/utils"
|
|
4
|
+
import type { ComponentProps } from "react"
|
|
5
|
+
|
|
6
|
+
type RequiredIndicatorProps = ComponentProps<"span">
|
|
7
|
+
|
|
8
|
+
function RequiredIndicator({ className, ...props }: RequiredIndicatorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
className={cn("text-destructive", className)}
|
|
13
|
+
data-slot="required-indicator"
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
*
|
|
17
|
+
</span>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { RequiredIndicatorProps }
|
|
22
|
+
export { RequiredIndicator }
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test } from "@zeno-lib/vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { deepMergeDefaults, extractZodDefaults } from "./schema-defaults"
|
|
5
|
+
|
|
6
|
+
describe("extractZodDefaults — primitives", () => {
|
|
7
|
+
test("z.string() → ''", () => {
|
|
8
|
+
expect(extractZodDefaults(z.object({ x: z.string() }))).toEqual({ x: "" })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("z.email() / z.url() / z.uuid() → ''", () => {
|
|
12
|
+
const schema = z.object({
|
|
13
|
+
email: z.email(),
|
|
14
|
+
id: z.uuid(),
|
|
15
|
+
url: z.url(),
|
|
16
|
+
})
|
|
17
|
+
expect(extractZodDefaults(schema)).toEqual({ email: "", id: "", url: "" })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("z.array(...) → []", () => {
|
|
21
|
+
expect(extractZodDefaults(z.object({ tags: z.array(z.string()) }))).toEqual(
|
|
22
|
+
{ tags: [] }
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("z.number() / z.boolean() / z.date() are skipped", () => {
|
|
27
|
+
const schema = z.object({
|
|
28
|
+
active: z.boolean(),
|
|
29
|
+
age: z.number(),
|
|
30
|
+
created: z.date(),
|
|
31
|
+
})
|
|
32
|
+
expect(extractZodDefaults(schema)).toEqual({})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("z.enum / z.literal are skipped", () => {
|
|
36
|
+
const schema = z.object({
|
|
37
|
+
kind: z.literal("a"),
|
|
38
|
+
role: z.enum(["admin", "user"]),
|
|
39
|
+
})
|
|
40
|
+
expect(extractZodDefaults(schema)).toEqual({})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("extractZodDefaults — wrappers", () => {
|
|
45
|
+
test(".default(value) flows through", () => {
|
|
46
|
+
const schema = z.object({
|
|
47
|
+
n: z.number().default(7),
|
|
48
|
+
role: z.string().default("member"),
|
|
49
|
+
})
|
|
50
|
+
expect(extractZodDefaults(schema)).toEqual({ n: 7, role: "member" })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test(".prefault(value) flows through", () => {
|
|
54
|
+
const schema = z.object({
|
|
55
|
+
role: z.prefault(z.string(), "guest"),
|
|
56
|
+
})
|
|
57
|
+
expect(extractZodDefaults(schema)).toEqual({ role: "guest" })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test(".optional() unwraps to inner string → ''", () => {
|
|
61
|
+
const schema = z.object({ x: z.string().optional() })
|
|
62
|
+
expect(extractZodDefaults(schema)).toEqual({ x: "" })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test(".nullable() unwraps to inner string → ''", () => {
|
|
66
|
+
const schema = z.object({ x: z.string().nullable() })
|
|
67
|
+
expect(extractZodDefaults(schema)).toEqual({ x: "" })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test(".readonly() unwraps to inner string → ''", () => {
|
|
71
|
+
const schema = z.object({ x: z.string().readonly() })
|
|
72
|
+
expect(extractZodDefaults(schema)).toEqual({ x: "" })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("z.pipe uses the in side", () => {
|
|
76
|
+
const schema = z.object({
|
|
77
|
+
n: z.string().pipe(z.coerce.number()),
|
|
78
|
+
})
|
|
79
|
+
// The `in` side is z.string() → ''
|
|
80
|
+
expect(extractZodDefaults(schema)).toEqual({ n: "" })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test(".optional() over a number is skipped", () => {
|
|
84
|
+
const schema = z.object({ x: z.number().optional() })
|
|
85
|
+
expect(extractZodDefaults(schema)).toEqual({})
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("extractZodDefaults — nested objects", () => {
|
|
90
|
+
test("nested z.object recurses", () => {
|
|
91
|
+
const schema = z.object({
|
|
92
|
+
profile: z.object({
|
|
93
|
+
email: z.email(),
|
|
94
|
+
nick: z.string(),
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
expect(extractZodDefaults(schema)).toEqual({
|
|
98
|
+
profile: { email: "", nick: "" },
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("empty nested object is skipped", () => {
|
|
103
|
+
const schema = z.object({
|
|
104
|
+
meta: z.object({
|
|
105
|
+
active: z.boolean(),
|
|
106
|
+
age: z.number(),
|
|
107
|
+
}),
|
|
108
|
+
})
|
|
109
|
+
expect(extractZodDefaults(schema)).toEqual({})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("partial nested object produced when at least one leaf survives", () => {
|
|
113
|
+
const schema = z.object({
|
|
114
|
+
profile: z.object({
|
|
115
|
+
age: z.number(),
|
|
116
|
+
name: z.string(),
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
expect(extractZodDefaults(schema)).toEqual({ profile: { name: "" } })
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("extractZodDefaults — degrades safely", () => {
|
|
124
|
+
test("non-object root returns {}", () => {
|
|
125
|
+
expect(extractZodDefaults(z.string() as never)).toEqual({})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("non-Zod schema returns {}", () => {
|
|
129
|
+
expect(
|
|
130
|
+
extractZodDefaults({} as Parameters<typeof extractZodDefaults>[0])
|
|
131
|
+
).toEqual({})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("MAX_WRAPPER_DEPTH=16 does not loop on adversarial chains", () => {
|
|
135
|
+
// Build a cyclic wrapper chain (innerType points back at itself).
|
|
136
|
+
const cyclic: { _zod: { def: unknown } } = {
|
|
137
|
+
_zod: { def: { innerType: null as unknown, type: "optional" } },
|
|
138
|
+
}
|
|
139
|
+
;(cyclic._zod.def as { innerType: unknown }).innerType = cyclic
|
|
140
|
+
const root = {
|
|
141
|
+
_zod: { def: { shape: { x: cyclic }, type: "object" } },
|
|
142
|
+
} as unknown as Parameters<typeof extractZodDefaults>[0]
|
|
143
|
+
// Should bail out at MAX_WRAPPER_DEPTH and skip the field — not hang.
|
|
144
|
+
expect(extractZodDefaults(root)).toEqual({})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe("deepMergeDefaults", () => {
|
|
149
|
+
test("undefined user defaults returns schema defaults reference", () => {
|
|
150
|
+
const schema = { a: 1, nested: { b: 2 } }
|
|
151
|
+
expect(deepMergeDefaults(schema, undefined)).toBe(schema)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("scalar user value wins over schema scalar", () => {
|
|
155
|
+
expect(deepMergeDefaults({ a: 1, b: "x" }, { a: 5 })).toEqual({
|
|
156
|
+
a: 5,
|
|
157
|
+
b: "x",
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("plain object on both sides recurses", () => {
|
|
162
|
+
expect(
|
|
163
|
+
deepMergeDefaults({ nested: { a: 1, b: 2 } }, { nested: { b: 99 } })
|
|
164
|
+
).toEqual({ nested: { a: 1, b: 99 } })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("array is treated atomically (user replaces schema)", () => {
|
|
168
|
+
expect(deepMergeDefaults({ tags: ["a", "b"] }, { tags: ["c"] })).toEqual({
|
|
169
|
+
tags: ["c"],
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("Date is treated atomically", () => {
|
|
174
|
+
const schemaDate = new Date("2020-01-01")
|
|
175
|
+
const userDate = new Date("2030-01-01")
|
|
176
|
+
expect(deepMergeDefaults({ when: schemaDate }, { when: userDate })).toEqual(
|
|
177
|
+
{ when: userDate }
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("user explicit undefined preserved (intentional reset)", () => {
|
|
182
|
+
expect(deepMergeDefaults({ a: 1, b: 2 }, { a: undefined })).toEqual({
|
|
183
|
+
a: undefined,
|
|
184
|
+
b: 2,
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test("key only present in user value flows through", () => {
|
|
189
|
+
expect(deepMergeDefaults({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("schema object + user scalar → user wins (no merge attempt)", () => {
|
|
193
|
+
expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
|
|
194
|
+
})
|
|
195
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Extract default values from a Zod schema for `useForm`.
|
|
2
|
+
//
|
|
3
|
+
// Tier A+ semantics:
|
|
4
|
+
// - `.default(...)` / `.prefault(...)` values flow through.
|
|
5
|
+
// - Strings (and string-format subtypes — email, url, uuid, regex, …) without
|
|
6
|
+
// a default fall back to `""` so React inputs render controlled.
|
|
7
|
+
// - Arrays without a default fall back to `[]`.
|
|
8
|
+
// - Nested `z.object(...)` recurses; emits a sub-record only if at least one
|
|
9
|
+
// leaf produced a value.
|
|
10
|
+
// - Numbers, booleans, dates, enums, unions, intersections, files, literals,
|
|
11
|
+
// etc. are skipped — those carry domain meaning and the user supplies them
|
|
12
|
+
// explicitly via `defaultValues`.
|
|
13
|
+
//
|
|
14
|
+
// Standard Schema doesn't expose introspection; this is Zod-only and reads
|
|
15
|
+
// `_zod.def` (Zod v4). For non-Zod schemas (or unrecognised shapes) every entry
|
|
16
|
+
// silently degrades to "skip", and the user's `defaultValues` flows through
|
|
17
|
+
// untouched. Async schemas, lazy schemas, and any throw inside the walk also
|
|
18
|
+
// degrade to "skip" — never bring the form down.
|
|
19
|
+
|
|
20
|
+
type SchemaNode = { readonly _zod?: { readonly def?: SchemaDef } }
|
|
21
|
+
|
|
22
|
+
type SchemaDef = {
|
|
23
|
+
readonly type?: string
|
|
24
|
+
readonly innerType?: SchemaNode
|
|
25
|
+
readonly shape?: Record<string, SchemaNode>
|
|
26
|
+
readonly in?: SchemaNode
|
|
27
|
+
readonly defaultValue?: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_WRAPPER_DEPTH = 16
|
|
31
|
+
|
|
32
|
+
type ExtractResult = { ok: true; value: unknown } | { ok: false }
|
|
33
|
+
|
|
34
|
+
function getDef(node: SchemaNode | undefined): SchemaDef | undefined {
|
|
35
|
+
return node?._zod?.def
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractDefaultForField(node: SchemaNode): ExtractResult {
|
|
39
|
+
let current: SchemaNode | undefined = node
|
|
40
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth++) {
|
|
41
|
+
const def: SchemaDef | undefined = getDef(current)
|
|
42
|
+
if (!def?.type) {
|
|
43
|
+
return { ok: false }
|
|
44
|
+
}
|
|
45
|
+
switch (def.type) {
|
|
46
|
+
case "default":
|
|
47
|
+
case "prefault":
|
|
48
|
+
return { ok: true, value: def.defaultValue }
|
|
49
|
+
case "optional":
|
|
50
|
+
case "nullable":
|
|
51
|
+
case "nonoptional":
|
|
52
|
+
case "readonly":
|
|
53
|
+
current = def.innerType
|
|
54
|
+
continue
|
|
55
|
+
case "pipe":
|
|
56
|
+
current = def.in
|
|
57
|
+
continue
|
|
58
|
+
case "string":
|
|
59
|
+
return { ok: true, value: "" }
|
|
60
|
+
case "array":
|
|
61
|
+
return { ok: true, value: [] }
|
|
62
|
+
case "object": {
|
|
63
|
+
const nested = current ? extractZodDefaults(current) : {}
|
|
64
|
+
return Object.keys(nested).length > 0
|
|
65
|
+
? { ok: true, value: nested }
|
|
66
|
+
: { ok: false }
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
return { ok: false }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
|
|
76
|
+
try {
|
|
77
|
+
const def: SchemaDef | undefined = getDef(schema)
|
|
78
|
+
if (def?.type !== "object" || !def.shape) {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
const result: Record<string, unknown> = {}
|
|
82
|
+
for (const [key, field] of Object.entries(def.shape)) {
|
|
83
|
+
const extracted = extractDefaultForField(field)
|
|
84
|
+
if (extracted.ok) {
|
|
85
|
+
result[key] = extracted.value
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result
|
|
89
|
+
} catch {
|
|
90
|
+
return {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return (
|
|
96
|
+
typeof value === "object" &&
|
|
97
|
+
value !== null &&
|
|
98
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// User-supplied values always win on conflict. Plain objects on both sides
|
|
103
|
+
// recurse so nested `z.object(...)` defaults aren't clobbered by partial user
|
|
104
|
+
// objects. Arrays, dates, class instances are treated atomically (user value
|
|
105
|
+
// replaces schema value wholesale). User-explicit `undefined` is preserved as
|
|
106
|
+
// an intentional reset.
|
|
107
|
+
function deepMergeDefaults(
|
|
108
|
+
schemaDefaults: Record<string, unknown>,
|
|
109
|
+
userDefaults: Record<string, unknown> | undefined
|
|
110
|
+
): Record<string, unknown> {
|
|
111
|
+
if (!userDefaults) {
|
|
112
|
+
return schemaDefaults
|
|
113
|
+
}
|
|
114
|
+
const result: Record<string, unknown> = { ...schemaDefaults }
|
|
115
|
+
for (const key of Object.keys(userDefaults)) {
|
|
116
|
+
const userValue = userDefaults[key]
|
|
117
|
+
const schemaValue = schemaDefaults[key]
|
|
118
|
+
if (isPlainObject(schemaValue) && isPlainObject(userValue)) {
|
|
119
|
+
result[key] = deepMergeDefaults(schemaValue, userValue)
|
|
120
|
+
} else {
|
|
121
|
+
result[key] = userValue
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { deepMergeDefaults, extractZodDefaults }
|