@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, test } from "@zeno-lib/vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { getRequiredPaths, type StandardSchemaLike } from "./schema-required"
|
|
5
|
+
|
|
6
|
+
describe("getRequiredPaths", () => {
|
|
7
|
+
test("plain required fields appear in the set", () => {
|
|
8
|
+
const schema = z.object({
|
|
9
|
+
email: z.email(),
|
|
10
|
+
name: z.string().min(1),
|
|
11
|
+
})
|
|
12
|
+
const required = getRequiredPaths(schema)
|
|
13
|
+
expect(required.has("email")).toBe(true)
|
|
14
|
+
expect(required.has("name")).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test(".optional() fields do not appear", () => {
|
|
18
|
+
const schema = z.object({
|
|
19
|
+
email: z.email(),
|
|
20
|
+
nickname: z.string().optional(),
|
|
21
|
+
})
|
|
22
|
+
const required = getRequiredPaths(schema)
|
|
23
|
+
expect(required.has("email")).toBe(true)
|
|
24
|
+
expect(required.has("nickname")).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test(".default(...) fields do not appear", () => {
|
|
28
|
+
const schema = z.object({
|
|
29
|
+
email: z.email(),
|
|
30
|
+
role: z.string().default("member"),
|
|
31
|
+
})
|
|
32
|
+
const required = getRequiredPaths(schema)
|
|
33
|
+
expect(required.has("email")).toBe(true)
|
|
34
|
+
expect(required.has("role")).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("nested z.object reports the top-level key when missing", () => {
|
|
38
|
+
// Probing with `{}` only reveals the top-level required key; Zod never
|
|
39
|
+
// recurses past a missing parent.
|
|
40
|
+
const schema = z.object({
|
|
41
|
+
profile: z.object({
|
|
42
|
+
email: z.email(),
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
const required = getRequiredPaths(schema)
|
|
46
|
+
expect(required.has("profile")).toBe(true)
|
|
47
|
+
expect(required.has("profile.email")).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("dot-joined path is built when issues carry multi-segment paths", () => {
|
|
51
|
+
// Whether Zod surfaces nested paths depends on schema shape (defaults,
|
|
52
|
+
// refine targets, etc.). We exercise the path-joining itself via a
|
|
53
|
+
// stand-in schema that emits a multi-segment issue path.
|
|
54
|
+
const schema = {
|
|
55
|
+
"~standard": {
|
|
56
|
+
validate: () => ({
|
|
57
|
+
issues: [{ path: ["profile", "email"] }],
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
const required = getRequiredPaths(schema)
|
|
62
|
+
expect(required.has("profile.email")).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test(".refine() cross-field issues are tolerated (do not crash)", () => {
|
|
66
|
+
const schema = z
|
|
67
|
+
.object({
|
|
68
|
+
confirm: z.string().min(8),
|
|
69
|
+
password: z.string().min(8),
|
|
70
|
+
})
|
|
71
|
+
.refine((value) => value.password === value.confirm, {
|
|
72
|
+
message: "Passwords must match",
|
|
73
|
+
path: ["confirm"],
|
|
74
|
+
})
|
|
75
|
+
expect(() => getRequiredPaths(schema)).not.toThrow()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("async schema returns empty set", () => {
|
|
79
|
+
const asyncSchema: StandardSchemaLike = {
|
|
80
|
+
"~standard": {
|
|
81
|
+
validate: (_: unknown) =>
|
|
82
|
+
Promise.resolve({ value: undefined } as { value: unknown }),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
expect(getRequiredPaths(asyncSchema).size).toBe(0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("throwing schema returns empty set", () => {
|
|
89
|
+
const throwing: StandardSchemaLike = {
|
|
90
|
+
"~standard": {
|
|
91
|
+
validate: () => {
|
|
92
|
+
throw new Error("boom")
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
expect(getRequiredPaths(throwing).size).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("schema returning no issues returns empty set", () => {
|
|
100
|
+
const empty: StandardSchemaLike = {
|
|
101
|
+
"~standard": {
|
|
102
|
+
validate: () => ({ value: undefined }),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
expect(getRequiredPaths(empty).size).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("path entries shaped like { key } (Valibot) are normalised", () => {
|
|
109
|
+
const valibotStyle: StandardSchemaLike = {
|
|
110
|
+
"~standard": {
|
|
111
|
+
validate: () => ({
|
|
112
|
+
issues: [
|
|
113
|
+
{ path: [{ key: "profile" }, { key: "email" }] },
|
|
114
|
+
{ path: ["plain"] },
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
const required = getRequiredPaths(valibotStyle)
|
|
120
|
+
expect(required.has("profile.email")).toBe(true)
|
|
121
|
+
expect(required.has("plain")).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("issues with empty path are skipped (form-level only)", () => {
|
|
125
|
+
const formLevelOnly: StandardSchemaLike = {
|
|
126
|
+
"~standard": {
|
|
127
|
+
validate: () => ({ issues: [{ path: [] }] }),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
expect(getRequiredPaths(formLevelOnly).size).toBe(0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Inspect a Standard Schema to discover which field paths it treats as required.
|
|
2
|
+
//
|
|
3
|
+
// Standard Schema (Zod, Valibot, ArkType, …) doesn't expose introspection — only
|
|
4
|
+
// a `validate(value)` function. We probe by validating an empty object: every
|
|
5
|
+
// field that is *required* (i.e. not optional, nullable, or defaulted) emits an
|
|
6
|
+
// issue with a `path` we can record. Fields with defaults or `.optional()` stay
|
|
7
|
+
// silent because the schema considers them satisfied.
|
|
8
|
+
//
|
|
9
|
+
// Limitations:
|
|
10
|
+
// - Async schemas are skipped (returns an empty set). Required-ness is a
|
|
11
|
+
// visual hint; the validator itself still runs at submit time.
|
|
12
|
+
// - Cross-field refinements that fail on `{}` may flag fields that aren't
|
|
13
|
+
// intrinsically required. In practice this is the same heuristic users
|
|
14
|
+
// would apply by eye.
|
|
15
|
+
|
|
16
|
+
type StandardIssue = {
|
|
17
|
+
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type StandardSchemaLike = {
|
|
21
|
+
readonly "~standard": {
|
|
22
|
+
readonly validate: (
|
|
23
|
+
value: unknown
|
|
24
|
+
) =>
|
|
25
|
+
| { readonly value: unknown }
|
|
26
|
+
| { readonly issues: readonly StandardIssue[] }
|
|
27
|
+
| Promise<unknown>
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pathSegment(
|
|
32
|
+
part: PropertyKey | { readonly key: PropertyKey }
|
|
33
|
+
): string {
|
|
34
|
+
if (typeof part === "object" && part !== null && "key" in part) {
|
|
35
|
+
return String(part.key)
|
|
36
|
+
}
|
|
37
|
+
return String(part)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
41
|
+
const required = new Set<string>()
|
|
42
|
+
let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
|
|
43
|
+
try {
|
|
44
|
+
result = schema["~standard"].validate({})
|
|
45
|
+
} catch {
|
|
46
|
+
return required
|
|
47
|
+
}
|
|
48
|
+
if (result instanceof Promise) {
|
|
49
|
+
return required
|
|
50
|
+
}
|
|
51
|
+
if (!("issues" in result && result.issues)) {
|
|
52
|
+
return required
|
|
53
|
+
}
|
|
54
|
+
for (const issue of result.issues) {
|
|
55
|
+
const segments = issue.path?.map(pathSegment) ?? []
|
|
56
|
+
if (segments.length > 0) {
|
|
57
|
+
required.add(segments.join("."))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return required
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { StandardSchemaLike }
|
|
64
|
+
export { getRequiredPaths }
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { type AnyFieldApi, useStore } from "@tanstack/react-form"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
getFormHideFieldErrors,
|
|
7
|
+
getFormValidationMode,
|
|
8
|
+
isFieldRequired,
|
|
9
|
+
type ValidationMode,
|
|
10
|
+
} from "./validation-modes"
|
|
11
|
+
|
|
12
|
+
type ErrorSourceMap = Record<string, "field" | "form" | undefined>
|
|
13
|
+
|
|
14
|
+
const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
|
|
15
|
+
|
|
16
|
+
// Has the user explicitly opted into a `validators={{ onChange | onBlur |
|
|
17
|
+
// onSubmit }}` cause on this field? If yes, we honour the cause directly —
|
|
18
|
+
// the form's `validation` mode does not gate per-field validators (a user
|
|
19
|
+
// who wrote `onChange` expects live feedback, even if the form is in
|
|
20
|
+
// `blur-then-change` mode).
|
|
21
|
+
function hasFieldLevelError(
|
|
22
|
+
errorMap: Record<string, unknown> | undefined,
|
|
23
|
+
errorSourceMap: ErrorSourceMap | undefined,
|
|
24
|
+
cause: (typeof FIELD_LEVEL_CAUSES)[number]
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!(errorMap && errorSourceMap)) {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
return errorSourceMap[cause] === "field" && errorMap[cause] !== undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function modeAllowsDisplay(
|
|
33
|
+
mode: ValidationMode,
|
|
34
|
+
isDirty: boolean,
|
|
35
|
+
isBlurred: boolean,
|
|
36
|
+
wasSubmitted: boolean
|
|
37
|
+
): boolean {
|
|
38
|
+
if (mode === "change") {
|
|
39
|
+
return isDirty || wasSubmitted
|
|
40
|
+
}
|
|
41
|
+
if (mode === "submit") {
|
|
42
|
+
return wasSubmitted
|
|
43
|
+
}
|
|
44
|
+
// 'blur' and 'blur-then-change'
|
|
45
|
+
return isBlurred || wasSubmitted
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns the "should we display errors?" gate for a field. The gate matches
|
|
49
|
+
// the form's `validation` mode (set via `useForm({ validation })`):
|
|
50
|
+
//
|
|
51
|
+
// - `change` → show as soon as the user has typed (live)
|
|
52
|
+
// - `blur` → show after first blur
|
|
53
|
+
// - `submit` → show after first submit attempt
|
|
54
|
+
// - `blur-then-change` → show after first blur (default)
|
|
55
|
+
//
|
|
56
|
+
// All modes also show errors after a submit attempt, so a user who clicks
|
|
57
|
+
// submit without ever interacting with required fields still sees them.
|
|
58
|
+
//
|
|
59
|
+
// Per-field validators bypass the form's mode entirely: an error written by
|
|
60
|
+
// `validators={{ onChange }}` shows live (after first keystroke) regardless
|
|
61
|
+
// of the form's gating, because the user explicitly opted into that cause.
|
|
62
|
+
function useIsInvalid(field: AnyFieldApi): boolean {
|
|
63
|
+
const wasSubmitted = useStore(
|
|
64
|
+
field.form.store,
|
|
65
|
+
(state) => state.submissionAttempts > 0
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if (field.state.meta.isValid) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { isDirty, isBlurred, errorMap, errorSourceMap } = field.state.meta
|
|
73
|
+
const sourceMap = errorSourceMap as ErrorSourceMap | undefined
|
|
74
|
+
|
|
75
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onChange")) {
|
|
76
|
+
return isDirty || wasSubmitted
|
|
77
|
+
}
|
|
78
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onBlur")) {
|
|
79
|
+
return isBlurred || wasSubmitted
|
|
80
|
+
}
|
|
81
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onSubmit")) {
|
|
82
|
+
return wasSubmitted
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return modeAllowsDisplay(
|
|
86
|
+
getFormValidationMode(field.form),
|
|
87
|
+
isDirty,
|
|
88
|
+
isBlurred,
|
|
89
|
+
wasSubmitted
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Returns whether the form was configured to hide inline field errors via
|
|
94
|
+
// `useForm({ hideFieldErrors: true })`. The shipped fields read this to
|
|
95
|
+
// skip rendering their inline `<FieldError>` while still flipping
|
|
96
|
+
// `data-invalid` / `aria-invalid` for invalid styling.
|
|
97
|
+
function useHideFieldErrors(field: AnyFieldApi): boolean {
|
|
98
|
+
return getFormHideFieldErrors(field.form)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Returns whether the form's schema marks this field as required. Drives the
|
|
102
|
+
// inline `*` indicator next to field labels. Returns `false` when the form
|
|
103
|
+
// has no schema, was constructed with `requiredIndicator: false`, or the
|
|
104
|
+
// schema doesn't mark this field's path as required.
|
|
105
|
+
function useIsFieldRequired(field: AnyFieldApi): boolean {
|
|
106
|
+
return isFieldRequired(field.form, field.name)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { useHideFieldErrors, useIsFieldRequired, useIsInvalid }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { type AnyFormApi, useStore } from "@tanstack/react-form"
|
|
4
|
+
import { useEffect } from "react"
|
|
5
|
+
|
|
6
|
+
type UnsavedChangesMode = "if-changed" | "if-touched"
|
|
7
|
+
|
|
8
|
+
// Warn the user before they leave the page with unsaved changes.
|
|
9
|
+
//
|
|
10
|
+
// Two trigger modes, selected by the caller:
|
|
11
|
+
// - "if-changed" (default) — fires while current values differ from
|
|
12
|
+
// defaults (`!state.isDefaultValue`). Clears when the user restores
|
|
13
|
+
// the originals, or after `formApi.reset(value)` rebases defaults.
|
|
14
|
+
// - "if-touched" — fires after the user has edited any field, even if
|
|
15
|
+
// they reverted (`state.isDirty`, sticky once edited until reset).
|
|
16
|
+
//
|
|
17
|
+
// `isSubmitting` is always read so the listener detaches during the
|
|
18
|
+
// submit round-trip — a successful submit transitions through
|
|
19
|
+
// `isSubmitting=true` before the parent typically resets/navigates, and
|
|
20
|
+
// we don't want a stale warning to fire in that window.
|
|
21
|
+
//
|
|
22
|
+
// Modern browsers ignore custom strings on `beforeunload` and show their
|
|
23
|
+
// own generic prompt; we still set `returnValue` because some older
|
|
24
|
+
// browsers and embedded webviews honour it.
|
|
25
|
+
function useUnsavedChangesWarning(
|
|
26
|
+
form: AnyFormApi,
|
|
27
|
+
enabled: boolean | UnsavedChangesMode
|
|
28
|
+
): void {
|
|
29
|
+
let mode: UnsavedChangesMode | null
|
|
30
|
+
if (enabled === false) {
|
|
31
|
+
mode = null
|
|
32
|
+
} else if (enabled === true) {
|
|
33
|
+
mode = "if-changed"
|
|
34
|
+
} else {
|
|
35
|
+
mode = enabled
|
|
36
|
+
}
|
|
37
|
+
const dirty = useStore(form.store, (state) =>
|
|
38
|
+
mode === "if-touched" ? state.isDirty : !state.isDefaultValue
|
|
39
|
+
)
|
|
40
|
+
const isSubmitting = useStore(form.store, (state) => state.isSubmitting)
|
|
41
|
+
const active = mode !== null && dirty && !isSubmitting
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!active) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const handler = (event: BeforeUnloadEvent) => {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
event.returnValue = ""
|
|
50
|
+
}
|
|
51
|
+
window.addEventListener("beforeunload", handler)
|
|
52
|
+
return () => window.removeEventListener("beforeunload", handler)
|
|
53
|
+
}, [active])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type { UnsavedChangesMode }
|
|
57
|
+
export { useUnsavedChangesWarning }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "@zeno-lib/vitest"
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from "./validation-error"
|
|
4
|
+
|
|
5
|
+
describe("ValidationError", () => {
|
|
6
|
+
test("extends Error and sets name", () => {
|
|
7
|
+
const err = new ValidationError({ email: "Taken" })
|
|
8
|
+
expect(err).toBeInstanceOf(Error)
|
|
9
|
+
expect(err.name).toBe("ValidationError")
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("default message is 'Validation failed'", () => {
|
|
13
|
+
const err = new ValidationError({ email: "Taken" })
|
|
14
|
+
expect(err.message).toBe("Validation failed")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("custom message via options.message", () => {
|
|
18
|
+
const err = new ValidationError({ email: "Taken" }, { message: "Boom" })
|
|
19
|
+
expect(err.message).toBe("Boom")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("exposes fields and formError", () => {
|
|
23
|
+
const err = new ValidationError(
|
|
24
|
+
{ email: "Taken", name: ["A", "B"] },
|
|
25
|
+
{ formError: "Server down" }
|
|
26
|
+
)
|
|
27
|
+
expect(err.fields).toEqual({ email: "Taken", name: ["A", "B"] })
|
|
28
|
+
expect(err.formError).toBe("Server down")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("formError is undefined when not provided", () => {
|
|
32
|
+
const err = new ValidationError({ email: "Taken" })
|
|
33
|
+
expect(err.formError).toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("accepts readonly array of messages for a field", () => {
|
|
37
|
+
const messages = ["too short", "missing digit"] as const
|
|
38
|
+
const err = new ValidationError({ password: messages })
|
|
39
|
+
expect(err.fields.password).toEqual(messages)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Throwable error that carries per-field validation messages. `useForm`
|
|
2
|
+
// catches this inside `onSubmit` and writes each entry onto the matching
|
|
3
|
+
// field's meta, so callers can `throw new ValidationError({ email: '…' })`
|
|
4
|
+
// from anywhere (server response handler, custom async check, etc.) and have
|
|
5
|
+
// the messages land in the right place.
|
|
6
|
+
//
|
|
7
|
+
// Errors are written to `errorMap.onChange`, which means the form-level
|
|
8
|
+
// schema overwrites them as soon as the user edits the field — the message
|
|
9
|
+
// clears automatically once the value changes. Pass an array of messages to
|
|
10
|
+
// surface multiple issues for the same field.
|
|
11
|
+
|
|
12
|
+
type FieldMessage = string | readonly string[]
|
|
13
|
+
|
|
14
|
+
class ValidationError extends Error {
|
|
15
|
+
readonly fields: Readonly<Record<string, FieldMessage>>
|
|
16
|
+
readonly formError?: string
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
fields: Readonly<Record<string, FieldMessage>>,
|
|
20
|
+
options: { formError?: string; message?: string } = {}
|
|
21
|
+
) {
|
|
22
|
+
super(options.message ?? "Validation failed")
|
|
23
|
+
this.name = "ValidationError"
|
|
24
|
+
this.fields = fields
|
|
25
|
+
this.formError = options.formError
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type { FieldMessage }
|
|
30
|
+
export { ValidationError }
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { AnyFormApi, ValidationLogicFn } from "@tanstack/react-form"
|
|
2
|
+
import { describe, expect, test, vi } from "@zeno-lib/vitest"
|
|
3
|
+
|
|
4
|
+
import { blurThenChangeLogic } from "./validation-logic"
|
|
5
|
+
|
|
6
|
+
type LogicArgs = Parameters<ValidationLogicFn>[0]
|
|
7
|
+
type Validators = LogicArgs["validators"]
|
|
8
|
+
|
|
9
|
+
type ObservedValidator = {
|
|
10
|
+
cause: string
|
|
11
|
+
fn: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeForm(overrides?: {
|
|
15
|
+
submissionAttempts?: number
|
|
16
|
+
blurredFieldNames?: string[]
|
|
17
|
+
}) {
|
|
18
|
+
const fieldMeta: Record<string, { isBlurred: boolean }> = {}
|
|
19
|
+
for (const name of overrides?.blurredFieldNames ?? []) {
|
|
20
|
+
fieldMeta[name] = { isBlurred: true }
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
state: {
|
|
24
|
+
fieldMeta,
|
|
25
|
+
submissionAttempts: overrides?.submissionAttempts ?? 0,
|
|
26
|
+
},
|
|
27
|
+
} as unknown as AnyFormApi
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeRun() {
|
|
31
|
+
const observed: ObservedValidator[][] = []
|
|
32
|
+
const runValidation = vi.fn(
|
|
33
|
+
(props: { form: AnyFormApi; validators: ObservedValidator[] }) => {
|
|
34
|
+
observed.push(props.validators)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
return { observed, runValidation }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SCHEMA_FN = () => undefined
|
|
42
|
+
const ASYNC_SCHEMA_FN = async () => undefined
|
|
43
|
+
|
|
44
|
+
const SCHEMA_VALIDATORS: Validators = {
|
|
45
|
+
onBlur: SCHEMA_FN,
|
|
46
|
+
onChange: SCHEMA_FN,
|
|
47
|
+
onSubmit: SCHEMA_FN,
|
|
48
|
+
} as unknown as Validators
|
|
49
|
+
|
|
50
|
+
const SCHEMA_VALIDATORS_BLUR_ONLY: Validators = {
|
|
51
|
+
onBlur: SCHEMA_FN,
|
|
52
|
+
onSubmit: SCHEMA_FN,
|
|
53
|
+
} as unknown as Validators
|
|
54
|
+
|
|
55
|
+
const SCHEMA_VALIDATORS_ASYNC: Validators = {
|
|
56
|
+
onBlurAsync: ASYNC_SCHEMA_FN,
|
|
57
|
+
onChangeAsync: ASYNC_SCHEMA_FN,
|
|
58
|
+
onSubmitAsync: ASYNC_SCHEMA_FN,
|
|
59
|
+
} as unknown as Validators
|
|
60
|
+
|
|
61
|
+
describe("blurThenChangeLogic — form-level path", () => {
|
|
62
|
+
test("missing validators yields []", () => {
|
|
63
|
+
const { observed, runValidation } = makeRun()
|
|
64
|
+
blurThenChangeLogic({
|
|
65
|
+
event: { async: false, type: "change" } as LogicArgs["event"],
|
|
66
|
+
form: makeForm(),
|
|
67
|
+
runValidation,
|
|
68
|
+
validators: undefined,
|
|
69
|
+
} as LogicArgs)
|
|
70
|
+
expect(observed[0]).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("mount fires onMount only (sync)", () => {
|
|
74
|
+
const onMount = vi.fn()
|
|
75
|
+
const { observed, runValidation } = makeRun()
|
|
76
|
+
blurThenChangeLogic({
|
|
77
|
+
event: { async: false, type: "mount" } as LogicArgs["event"],
|
|
78
|
+
form: makeForm(),
|
|
79
|
+
runValidation,
|
|
80
|
+
validators: { onMount } as unknown as Validators,
|
|
81
|
+
} as LogicArgs)
|
|
82
|
+
expect(observed[0]).toHaveLength(1)
|
|
83
|
+
expect(observed[0]?.[0]).toEqual({ cause: "mount", fn: onMount })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("mount with async event yields [] (no async mount)", () => {
|
|
87
|
+
const { observed, runValidation } = makeRun()
|
|
88
|
+
blurThenChangeLogic({
|
|
89
|
+
event: { async: true, type: "mount" } as LogicArgs["event"],
|
|
90
|
+
form: makeForm(),
|
|
91
|
+
runValidation,
|
|
92
|
+
validators: SCHEMA_VALIDATORS,
|
|
93
|
+
} as LogicArgs)
|
|
94
|
+
expect(observed[0]).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("blur fires the live validator regardless of state", () => {
|
|
98
|
+
const { observed, runValidation } = makeRun()
|
|
99
|
+
blurThenChangeLogic({
|
|
100
|
+
event: { async: false, type: "blur" } as LogicArgs["event"],
|
|
101
|
+
form: makeForm(),
|
|
102
|
+
runValidation,
|
|
103
|
+
validators: SCHEMA_VALIDATORS,
|
|
104
|
+
} as LogicArgs)
|
|
105
|
+
expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("blur falls back to onBlur when onChange is absent", () => {
|
|
109
|
+
const { observed, runValidation } = makeRun()
|
|
110
|
+
blurThenChangeLogic({
|
|
111
|
+
event: { async: false, type: "blur" } as LogicArgs["event"],
|
|
112
|
+
form: makeForm(),
|
|
113
|
+
runValidation,
|
|
114
|
+
validators: SCHEMA_VALIDATORS_BLUR_ONLY,
|
|
115
|
+
} as LogicArgs)
|
|
116
|
+
expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("change while pristine yields [] (no field blurred, no submit)", () => {
|
|
120
|
+
const { observed, runValidation } = makeRun()
|
|
121
|
+
blurThenChangeLogic({
|
|
122
|
+
event: { async: false, type: "change" } as LogicArgs["event"],
|
|
123
|
+
form: makeForm(),
|
|
124
|
+
runValidation,
|
|
125
|
+
validators: SCHEMA_VALIDATORS,
|
|
126
|
+
} as LogicArgs)
|
|
127
|
+
expect(observed[0]).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("change after a field was blurred runs the live validator", () => {
|
|
131
|
+
const { observed, runValidation } = makeRun()
|
|
132
|
+
blurThenChangeLogic({
|
|
133
|
+
event: { async: false, type: "change" } as LogicArgs["event"],
|
|
134
|
+
form: makeForm({ blurredFieldNames: ["email"] }),
|
|
135
|
+
runValidation,
|
|
136
|
+
validators: SCHEMA_VALIDATORS,
|
|
137
|
+
} as LogicArgs)
|
|
138
|
+
expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("change after a submit attempt runs the live validator", () => {
|
|
142
|
+
const { observed, runValidation } = makeRun()
|
|
143
|
+
blurThenChangeLogic({
|
|
144
|
+
event: { async: false, type: "change" } as LogicArgs["event"],
|
|
145
|
+
form: makeForm({ submissionAttempts: 1 }),
|
|
146
|
+
runValidation,
|
|
147
|
+
validators: SCHEMA_VALIDATORS,
|
|
148
|
+
} as LogicArgs)
|
|
149
|
+
expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("sync submit yields live + submit + server placeholder", () => {
|
|
153
|
+
const { observed, runValidation } = makeRun()
|
|
154
|
+
blurThenChangeLogic({
|
|
155
|
+
event: { async: false, type: "submit" } as LogicArgs["event"],
|
|
156
|
+
form: makeForm(),
|
|
157
|
+
runValidation,
|
|
158
|
+
validators: SCHEMA_VALIDATORS,
|
|
159
|
+
} as LogicArgs)
|
|
160
|
+
expect(observed[0]?.map((v) => v.cause)).toEqual([
|
|
161
|
+
"change",
|
|
162
|
+
"submit",
|
|
163
|
+
"server",
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("async submit yields live + submitAsync only (no server placeholder)", () => {
|
|
168
|
+
const { observed, runValidation } = makeRun()
|
|
169
|
+
blurThenChangeLogic({
|
|
170
|
+
event: { async: true, type: "submit" } as LogicArgs["event"],
|
|
171
|
+
form: makeForm(),
|
|
172
|
+
runValidation,
|
|
173
|
+
validators: SCHEMA_VALIDATORS_ASYNC,
|
|
174
|
+
} as LogicArgs)
|
|
175
|
+
expect(observed[0]?.map((v) => v.cause)).toEqual(["change", "submit"])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe("blurThenChangeLogic — field-level path", () => {
|
|
180
|
+
function fieldEvent(
|
|
181
|
+
type: "change" | "blur" | "submit" | "mount",
|
|
182
|
+
async = false
|
|
183
|
+
): LogicArgs["event"] {
|
|
184
|
+
return { async, fieldName: "email", type } as LogicArgs["event"]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
test("field-level change uses validators.onChange directly (no gating)", () => {
|
|
188
|
+
const onChange = vi.fn()
|
|
189
|
+
const { observed, runValidation } = makeRun()
|
|
190
|
+
blurThenChangeLogic({
|
|
191
|
+
event: fieldEvent("change"),
|
|
192
|
+
form: makeForm(),
|
|
193
|
+
runValidation,
|
|
194
|
+
validators: { onChange } as unknown as Validators,
|
|
195
|
+
} as LogicArgs)
|
|
196
|
+
expect(observed[0]).toEqual([{ cause: "change", fn: onChange }])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("field-level blur uses validators.onBlur directly", () => {
|
|
200
|
+
const onBlur = vi.fn()
|
|
201
|
+
const { observed, runValidation } = makeRun()
|
|
202
|
+
blurThenChangeLogic({
|
|
203
|
+
event: fieldEvent("blur"),
|
|
204
|
+
form: makeForm(),
|
|
205
|
+
runValidation,
|
|
206
|
+
validators: { onBlur } as unknown as Validators,
|
|
207
|
+
} as LogicArgs)
|
|
208
|
+
expect(observed[0]).toEqual([{ cause: "blur", fn: onBlur }])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("field-level submit runs change + blur + submit", () => {
|
|
212
|
+
const onChange = vi.fn()
|
|
213
|
+
const onBlur = vi.fn()
|
|
214
|
+
const onSubmit = vi.fn()
|
|
215
|
+
const { observed, runValidation } = makeRun()
|
|
216
|
+
blurThenChangeLogic({
|
|
217
|
+
event: fieldEvent("submit"),
|
|
218
|
+
form: makeForm(),
|
|
219
|
+
runValidation,
|
|
220
|
+
validators: { onBlur, onChange, onSubmit } as unknown as Validators,
|
|
221
|
+
} as LogicArgs)
|
|
222
|
+
expect(observed[0]?.map((v) => v.cause)).toEqual([
|
|
223
|
+
"change",
|
|
224
|
+
"blur",
|
|
225
|
+
"submit",
|
|
226
|
+
])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("field-level no validators yields []", () => {
|
|
230
|
+
const { observed, runValidation } = makeRun()
|
|
231
|
+
blurThenChangeLogic({
|
|
232
|
+
event: fieldEvent("change"),
|
|
233
|
+
form: makeForm(),
|
|
234
|
+
runValidation,
|
|
235
|
+
validators: undefined,
|
|
236
|
+
} as LogicArgs)
|
|
237
|
+
expect(observed[0]).toEqual([])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("field-level async submit uses async slots", () => {
|
|
241
|
+
const onChangeAsync = vi.fn()
|
|
242
|
+
const onBlurAsync = vi.fn()
|
|
243
|
+
const onSubmitAsync = vi.fn()
|
|
244
|
+
const { observed, runValidation } = makeRun()
|
|
245
|
+
blurThenChangeLogic({
|
|
246
|
+
event: fieldEvent("submit", true),
|
|
247
|
+
form: makeForm(),
|
|
248
|
+
runValidation,
|
|
249
|
+
validators: {
|
|
250
|
+
onBlurAsync,
|
|
251
|
+
onChangeAsync,
|
|
252
|
+
onSubmitAsync,
|
|
253
|
+
} as unknown as Validators,
|
|
254
|
+
} as LogicArgs)
|
|
255
|
+
expect(observed[0]?.map((v) => v.fn)).toEqual([
|
|
256
|
+
onChangeAsync,
|
|
257
|
+
onBlurAsync,
|
|
258
|
+
onSubmitAsync,
|
|
259
|
+
])
|
|
260
|
+
})
|
|
261
|
+
})
|