@zeno-lib/forms 0.0.0 → 0.1.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 +21 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +49 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/form-element.tsx +51 -0
- package/src/index.ts +14 -0
- package/src/lib/apply-validation-error.test.ts +1 -2
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { describe, expect, test } from "
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getRequiredPaths,
|
|
6
|
+
type StandardSchemaLike,
|
|
7
|
+
toRequiredPathKey,
|
|
8
|
+
} from "./schema-required"
|
|
5
9
|
|
|
6
10
|
describe("getRequiredPaths", () => {
|
|
7
11
|
test("plain required fields appear in the set", () => {
|
|
@@ -34,17 +38,52 @@ describe("getRequiredPaths", () => {
|
|
|
34
38
|
expect(required.has("role")).toBe(false)
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
test("nested z.object reports the
|
|
38
|
-
// Probing with `{}` only reveals the top-level required key; Zod never
|
|
39
|
-
// recurses past a missing parent.
|
|
41
|
+
test("nested z.object reports the parent and its required children", () => {
|
|
40
42
|
const schema = z.object({
|
|
41
43
|
profile: z.object({
|
|
44
|
+
bio: z.string().optional(),
|
|
42
45
|
email: z.email(),
|
|
43
46
|
}),
|
|
44
47
|
})
|
|
45
48
|
const required = getRequiredPaths(schema)
|
|
46
49
|
expect(required.has("profile")).toBe(true)
|
|
47
|
-
expect(required.has("profile.email")).toBe(
|
|
50
|
+
expect(required.has("profile.email")).toBe(true)
|
|
51
|
+
expect(required.has("profile.bio")).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("optional nested object is not descended into", () => {
|
|
55
|
+
const schema = z.object({
|
|
56
|
+
profile: z.object({ email: z.email() }).optional(),
|
|
57
|
+
})
|
|
58
|
+
expect(getRequiredPaths(schema).size).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("array rows are reported with TanStack bracket syntax", () => {
|
|
62
|
+
const schema = z.object({
|
|
63
|
+
members: z.array(
|
|
64
|
+
z.object({
|
|
65
|
+
name: z.string().min(1),
|
|
66
|
+
note: z.string().optional(),
|
|
67
|
+
})
|
|
68
|
+
),
|
|
69
|
+
tags: z.array(z.string()),
|
|
70
|
+
})
|
|
71
|
+
const required = getRequiredPaths(schema)
|
|
72
|
+
expect(required.has("members")).toBe(true)
|
|
73
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
74
|
+
expect(required.has("members.0.name")).toBe(false)
|
|
75
|
+
expect(required.has("members[0].note")).toBe(false)
|
|
76
|
+
expect(required.has("tags[0]")).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("nested arrays descend through every level", () => {
|
|
80
|
+
const schema = z.object({
|
|
81
|
+
groups: z.array(
|
|
82
|
+
z.object({ members: z.array(z.object({ name: z.string() })) })
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
const required = getRequiredPaths(schema)
|
|
86
|
+
expect(required.has("groups[0].members[0].name")).toBe(true)
|
|
48
87
|
})
|
|
49
88
|
|
|
50
89
|
test("dot-joined path is built when issues carry multi-segment paths", () => {
|
|
@@ -105,6 +144,47 @@ describe("getRequiredPaths", () => {
|
|
|
105
144
|
expect(getRequiredPaths(empty).size).toBe(0)
|
|
106
145
|
})
|
|
107
146
|
|
|
147
|
+
test("numeric path segments render as bracket indices, normalised to 0", () => {
|
|
148
|
+
const schema: StandardSchemaLike = {
|
|
149
|
+
"~standard": {
|
|
150
|
+
validate: () => ({
|
|
151
|
+
issues: [
|
|
152
|
+
{ path: ["members", 2, "name"] },
|
|
153
|
+
{ path: ["rows", "1", "value"] },
|
|
154
|
+
{ path: [{ key: "items" }, { key: 3 }] },
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
const required = getRequiredPaths(schema)
|
|
160
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
161
|
+
expect(required.has("rows[0].value")).toBe(true)
|
|
162
|
+
expect(required.has("items[0]")).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("Valibot-style capitalised `expected` hints are descended", () => {
|
|
166
|
+
const schema: StandardSchemaLike = {
|
|
167
|
+
"~standard": {
|
|
168
|
+
validate: (value) => {
|
|
169
|
+
const profile = (value as { profile?: unknown }).profile
|
|
170
|
+
return profile === undefined
|
|
171
|
+
? { issues: [{ expected: "Object", path: [{ key: "profile" }] }] }
|
|
172
|
+
: {
|
|
173
|
+
issues: [
|
|
174
|
+
{
|
|
175
|
+
expected: "string",
|
|
176
|
+
path: [{ key: "profile" }, { key: "email" }],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
const required = getRequiredPaths(schema)
|
|
184
|
+
expect(required.has("profile")).toBe(true)
|
|
185
|
+
expect(required.has("profile.email")).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
108
188
|
test("path entries shaped like { key } (Valibot) are normalised", () => {
|
|
109
189
|
const valibotStyle: StandardSchemaLike = {
|
|
110
190
|
"~standard": {
|
|
@@ -130,3 +210,15 @@ describe("getRequiredPaths", () => {
|
|
|
130
210
|
expect(getRequiredPaths(formLevelOnly).size).toBe(0)
|
|
131
211
|
})
|
|
132
212
|
})
|
|
213
|
+
|
|
214
|
+
describe("toRequiredPathKey", () => {
|
|
215
|
+
test("normalises bracket and dotted indices to [0]", () => {
|
|
216
|
+
expect(toRequiredPathKey("members[3].name")).toBe("members[0].name")
|
|
217
|
+
expect(toRequiredPathKey("members.3.name")).toBe("members[0].name")
|
|
218
|
+
expect(toRequiredPathKey("groups[1].members[12]")).toBe(
|
|
219
|
+
"groups[0].members[0]"
|
|
220
|
+
)
|
|
221
|
+
expect(toRequiredPathKey("email")).toBe("email")
|
|
222
|
+
expect(toRequiredPathKey("line2")).toBe("line2")
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
// issue with a `path` we can record. Fields with defaults or `.optional()` stay
|
|
7
7
|
// silent because the schema considers them satisfied.
|
|
8
8
|
//
|
|
9
|
+
// A missing parent hides its children (the schema stops at the missing key), so
|
|
10
|
+
// the probe descends: when an issue says it expected an object or an array
|
|
11
|
+
// (Zod and Valibot both put that on `issue.expected`), the next pass fills that
|
|
12
|
+
// path with `{}` / `[{}]` and validates again. Array rows are probed through
|
|
13
|
+
// index `0` and recorded index-agnostically, so `members[0].name` in the set
|
|
14
|
+
// matches every row's `members[3].name` via `toRequiredPathKey`.
|
|
15
|
+
//
|
|
16
|
+
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
17
|
+
// for array indices (`members[0].name`, not `members.0.name`).
|
|
18
|
+
//
|
|
9
19
|
// Limitations:
|
|
10
20
|
// - Async schemas are skipped (returns an empty set). Required-ness is a
|
|
11
21
|
// visual hint; the validator itself still runs at submit time.
|
|
12
|
-
// - Cross-field refinements that fail on
|
|
13
|
-
// intrinsically required. In practice this is the same heuristic
|
|
14
|
-
// would apply by eye.
|
|
22
|
+
// - Cross-field refinements that fail on the probe value may flag fields that
|
|
23
|
+
// aren't intrinsically required. In practice this is the same heuristic
|
|
24
|
+
// users would apply by eye.
|
|
25
|
+
// - Schemas whose issues carry no `expected` hint stop at the top level.
|
|
26
|
+
|
|
27
|
+
type PathPart = PropertyKey | { readonly key: PropertyKey }
|
|
15
28
|
|
|
16
29
|
type StandardIssue = {
|
|
17
|
-
readonly path?:
|
|
30
|
+
readonly path?: readonly PathPart[]
|
|
31
|
+
readonly expected?: unknown
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
type StandardSchemaLike = {
|
|
@@ -28,37 +42,127 @@ type StandardSchemaLike = {
|
|
|
28
42
|
}
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
const MAX_PROBE_DEPTH = 8
|
|
46
|
+
|
|
47
|
+
const INDEX_SEGMENT = /^\d+$/
|
|
48
|
+
|
|
49
|
+
// Matches `[3]` and `.3` index segments in a field name.
|
|
50
|
+
const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
|
|
51
|
+
|
|
52
|
+
function pathKey(part: PathPart): PropertyKey {
|
|
34
53
|
if (typeof part === "object" && part !== null && "key" in part) {
|
|
35
|
-
return
|
|
54
|
+
return part.key
|
|
36
55
|
}
|
|
37
|
-
return
|
|
56
|
+
return part
|
|
38
57
|
}
|
|
39
58
|
|
|
40
|
-
function
|
|
41
|
-
|
|
59
|
+
function isIndex(key: PropertyKey): boolean {
|
|
60
|
+
return (
|
|
61
|
+
typeof key === "number" ||
|
|
62
|
+
(typeof key === "string" && INDEX_SEGMENT.test(key))
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Render issue path segments as a TanStack field name, with every array index
|
|
67
|
+
// normalised to `0` so one entry covers all rows.
|
|
68
|
+
function joinPath(keys: readonly PropertyKey[]): string {
|
|
69
|
+
let out = ""
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
if (isIndex(key)) {
|
|
72
|
+
out += "[0]"
|
|
73
|
+
} else {
|
|
74
|
+
out += out === "" ? String(key) : `.${String(key)}`
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalise a field name (`members[3].name` or `members.3.name`) to the key
|
|
82
|
+
* `getRequiredPaths` records (`members[0].name`).
|
|
83
|
+
*/
|
|
84
|
+
function toRequiredPathKey(name: string): string {
|
|
85
|
+
return name.replace(INDEX_IN_NAME, "[0]")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function probeContainer(expected: unknown): unknown {
|
|
89
|
+
if (typeof expected !== "string") {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const kind = expected.toLowerCase()
|
|
93
|
+
if (kind === "object") {
|
|
94
|
+
return {}
|
|
95
|
+
}
|
|
96
|
+
if (kind === "array") {
|
|
97
|
+
return [{}]
|
|
98
|
+
}
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Write `value` at `keys` inside `root`, only where nothing is set yet.
|
|
103
|
+
function fillAt(
|
|
104
|
+
root: Record<PropertyKey, unknown>,
|
|
105
|
+
keys: readonly PropertyKey[],
|
|
106
|
+
value: unknown
|
|
107
|
+
): boolean {
|
|
108
|
+
let node: Record<PropertyKey, unknown> = root
|
|
109
|
+
for (const key of keys.slice(0, -1)) {
|
|
110
|
+
const next = node[key]
|
|
111
|
+
if (typeof next !== "object" || next === null) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
node = next as Record<PropertyKey, unknown>
|
|
115
|
+
}
|
|
116
|
+
const last = keys.at(-1) as PropertyKey
|
|
117
|
+
if (node[last] !== undefined) {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
node[last] = value
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateSync(
|
|
125
|
+
schema: StandardSchemaLike,
|
|
126
|
+
value: unknown
|
|
127
|
+
): readonly StandardIssue[] | undefined {
|
|
42
128
|
let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
|
|
43
129
|
try {
|
|
44
|
-
result = schema["~standard"].validate(
|
|
130
|
+
result = schema["~standard"].validate(value)
|
|
45
131
|
} catch {
|
|
46
|
-
return
|
|
132
|
+
return
|
|
47
133
|
}
|
|
48
134
|
if (result instanceof Promise) {
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
if (!("issues" in result && result.issues)) {
|
|
52
|
-
return required
|
|
135
|
+
return
|
|
53
136
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
137
|
+
return "issues" in result && result.issues ? result.issues : []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
141
|
+
const required = new Set<string>()
|
|
142
|
+
const probe: Record<PropertyKey, unknown> = {}
|
|
143
|
+
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
|
|
144
|
+
const issues = validateSync(schema, probe)
|
|
145
|
+
if (!issues) {
|
|
146
|
+
return required
|
|
147
|
+
}
|
|
148
|
+
let descended = false
|
|
149
|
+
for (const issue of issues) {
|
|
150
|
+
const keys = issue.path?.map(pathKey) ?? []
|
|
151
|
+
if (keys.length === 0) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
required.add(joinPath(keys))
|
|
155
|
+
const container = probeContainer(issue.expected)
|
|
156
|
+
if (container !== undefined && fillAt(probe, keys, container)) {
|
|
157
|
+
descended = true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!descended) {
|
|
161
|
+
break
|
|
58
162
|
}
|
|
59
163
|
}
|
|
60
164
|
return required
|
|
61
165
|
}
|
|
62
166
|
|
|
63
167
|
export type { StandardSchemaLike }
|
|
64
|
-
export { getRequiredPaths }
|
|
168
|
+
export { getRequiredPaths, toRequiredPathKey }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { type AnyFieldApi,
|
|
3
|
+
import { type AnyFieldApi, useSelector } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
getFormHideFieldErrors,
|
|
@@ -15,7 +15,7 @@ const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
|
|
|
15
15
|
|
|
16
16
|
// Has the user explicitly opted into a `validators={{ onChange | onBlur |
|
|
17
17
|
// onSubmit }}` cause on this field? If yes, we honour the cause directly —
|
|
18
|
-
// the form's `
|
|
18
|
+
// the form's `validators` mode does not gate per-field validators (a user
|
|
19
19
|
// who wrote `onChange` expects live feedback, even if the form is in
|
|
20
20
|
// `blur-then-change` mode).
|
|
21
21
|
function hasFieldLevelError(
|
|
@@ -46,7 +46,7 @@ function modeAllowsDisplay(
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Returns the "should we display errors?" gate for a field. The gate matches
|
|
49
|
-
// the form's `
|
|
49
|
+
// the form's `validators` mode (set via `useForm({ validators })`):
|
|
50
50
|
//
|
|
51
51
|
// - `change` → show as soon as the user has typed (live)
|
|
52
52
|
// - `blur` → show after first blur
|
|
@@ -60,7 +60,7 @@ function modeAllowsDisplay(
|
|
|
60
60
|
// `validators={{ onChange }}` shows live (after first keystroke) regardless
|
|
61
61
|
// of the form's gating, because the user explicitly opted into that cause.
|
|
62
62
|
function useIsInvalid(field: AnyFieldApi): boolean {
|
|
63
|
-
const wasSubmitted =
|
|
63
|
+
const wasSubmitted = useSelector(
|
|
64
64
|
field.form.store,
|
|
65
65
|
(state) => state.submissionAttempts > 0
|
|
66
66
|
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { type AnyFormApi, evaluate } from "@tanstack/react-form"
|
|
4
|
+
import { useCallback, useRef } from "react"
|
|
5
|
+
|
|
6
|
+
// TanStack's `useForm` calls `formApi.update(options)` after every render. While
|
|
7
|
+
// the form is untouched, `update` re-applies `options.defaultValues` whenever it
|
|
8
|
+
// deep-differs from the form's live defaults. `formApi.reset(values)` rebases
|
|
9
|
+
// those live defaults, so a caller that keeps passing the same `defaultValues`
|
|
10
|
+
// literal would see the reset undone on the next render (the classic "reset to
|
|
11
|
+
// the saved record after submit" flow).
|
|
12
|
+
//
|
|
13
|
+
// This hook forwards the caller's defaults only when *they* change (deep
|
|
14
|
+
// compare, the same `evaluate` TanStack's `update` uses). Otherwise it forwards
|
|
15
|
+
// the form's live defaults back, so `update` sees no change and a reset sticks.
|
|
16
|
+
// The live FormApi instance comes from the form-level `listeners.onMount`
|
|
17
|
+
// callback: the object `useForm` returns is a spread snapshot whose `options`
|
|
18
|
+
// never updates.
|
|
19
|
+
function useRebasedDefaultValues<T>(callerDefaults: T): {
|
|
20
|
+
defaultValues: T
|
|
21
|
+
onMount: (props: { formApi: AnyFormApi }) => void
|
|
22
|
+
} {
|
|
23
|
+
const liveForm = useRef<AnyFormApi | null>(null)
|
|
24
|
+
const lastCallerDefaults = useRef(callerDefaults)
|
|
25
|
+
|
|
26
|
+
let defaultValues = callerDefaults
|
|
27
|
+
if (evaluate(lastCallerDefaults.current, callerDefaults)) {
|
|
28
|
+
if (liveForm.current) {
|
|
29
|
+
defaultValues = liveForm.current.options.defaultValues as T
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
lastCallerDefaults.current = callerDefaults
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const onMount = useCallback((props: { formApi: AnyFormApi }) => {
|
|
36
|
+
liveForm.current = props.formApi
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return { defaultValues, onMount }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { useRebasedDefaultValues }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { type AnyFormApi,
|
|
3
|
+
import { type AnyFormApi, useSelector } from "@tanstack/react-form"
|
|
4
4
|
import { useEffect } from "react"
|
|
5
5
|
|
|
6
6
|
type UnsavedChangesMode = "if-changed" | "if-touched"
|
|
@@ -34,10 +34,10 @@ function useUnsavedChangesWarning(
|
|
|
34
34
|
} else {
|
|
35
35
|
mode = enabled
|
|
36
36
|
}
|
|
37
|
-
const dirty =
|
|
37
|
+
const dirty = useSelector(form.store, (state) =>
|
|
38
38
|
mode === "if-touched" ? state.isDirty : !state.isDefaultValue
|
|
39
39
|
)
|
|
40
|
-
const isSubmitting =
|
|
40
|
+
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
|
|
41
41
|
const active = mode !== null && dirty && !isSubmitting
|
|
42
42
|
|
|
43
43
|
useEffect(() => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
-
import { describe, expect, test } from "
|
|
3
|
-
|
|
2
|
+
import { describe, expect, test } from "vitest"
|
|
4
3
|
import {
|
|
5
4
|
DEFAULT_VALIDATION_MODE,
|
|
6
5
|
getFormHideFieldErrors,
|
|
@@ -50,6 +49,17 @@ describe("validation-modes WeakMap state", () => {
|
|
|
50
49
|
expect(isFieldRequired(form, "email")).toBe(false)
|
|
51
50
|
})
|
|
52
51
|
|
|
52
|
+
test("isFieldRequired matches array rows by TanStack field name", () => {
|
|
53
|
+
const form = makeFormStub()
|
|
54
|
+
setFormZenoState(form, {
|
|
55
|
+
requiredFields: new Set(["members[0].name"]),
|
|
56
|
+
requiredIndicator: true,
|
|
57
|
+
})
|
|
58
|
+
expect(isFieldRequired(form, "members[0].name")).toBe(true)
|
|
59
|
+
expect(isFieldRequired(form, "members[4].name")).toBe(true)
|
|
60
|
+
expect(isFieldRequired(form, "members[4].note")).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
53
63
|
test("two forms keyed on different store references are independent", () => {
|
|
54
64
|
const a = makeFormStub()
|
|
55
65
|
const b = makeFormStub()
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
|
+
import { toRequiredPathKey } from "./schema-required"
|
|
6
|
+
|
|
5
7
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
|
|
6
8
|
|
|
7
9
|
const DEFAULT_VALIDATION_MODE: ValidationMode = "blur-then-change"
|
|
@@ -60,7 +62,7 @@ function isFieldRequired(form: AnyFormApi, name: string): boolean {
|
|
|
60
62
|
if (!state.requiredIndicator) {
|
|
61
63
|
return false
|
|
62
64
|
}
|
|
63
|
-
return state.requiredFields.has(name)
|
|
65
|
+
return state.requiredFields.has(toRequiredPathKey(name))
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { test } from "
|
|
1
|
+
import { test } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
|
-
|
|
4
|
-
import { useForm } from "./use-form"
|
|
3
|
+
import { useForm } from "./create-form"
|
|
5
4
|
|
|
6
5
|
const schema = z.object({
|
|
7
6
|
accept: z.boolean(),
|
|
@@ -14,10 +13,6 @@ const schema = z.object({
|
|
|
14
13
|
size: z.string(),
|
|
15
14
|
})
|
|
16
15
|
|
|
17
|
-
const schemaNoEmail = z.object({
|
|
18
|
-
contact: z.string(),
|
|
19
|
-
})
|
|
20
|
-
|
|
21
16
|
test("InputField.name is constrained to DeepKeys<T>", () => {
|
|
22
17
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
23
18
|
const { InputField } = form
|
|
@@ -57,36 +52,24 @@ test("TextAreaField.name is constrained to DeepKeys<T>", () => {
|
|
|
57
52
|
form.TextAreaField({ name: "nonexistent" })
|
|
58
53
|
})
|
|
59
54
|
|
|
60
|
-
test("EmailField.name is
|
|
55
|
+
test("EmailField.name is required and constrained to DeepKeys<T>", () => {
|
|
61
56
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
62
57
|
const { EmailField } = form
|
|
63
|
-
//
|
|
58
|
+
// @ts-expect-error — name is required
|
|
64
59
|
EmailField({})
|
|
65
|
-
EmailField({ label: "Email" })
|
|
66
60
|
EmailField({ name: "email" })
|
|
61
|
+
// @ts-expect-error — not a valid path
|
|
62
|
+
EmailField({ name: "nonexistent" })
|
|
67
63
|
})
|
|
68
64
|
|
|
69
|
-
test("
|
|
70
|
-
const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
|
|
71
|
-
// @ts-expect-error — name becomes required without an "email" key on T
|
|
72
|
-
form.EmailField({})
|
|
73
|
-
form.EmailField({ name: "contact" })
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test("PasswordField.name is optional when T has a `password` field", () => {
|
|
65
|
+
test("PasswordField.name is required and constrained to DeepKeys<T>", () => {
|
|
77
66
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
78
67
|
const { PasswordField } = form
|
|
68
|
+
// @ts-expect-error — name is required
|
|
79
69
|
PasswordField({})
|
|
80
70
|
PasswordField({ name: "password" })
|
|
81
71
|
})
|
|
82
72
|
|
|
83
|
-
test("PasswordField.name is required when T has no `password` field", () => {
|
|
84
|
-
const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
|
|
85
|
-
// @ts-expect-error — name becomes required without a "password" key on T
|
|
86
|
-
form.PasswordField({})
|
|
87
|
-
form.PasswordField({ name: "contact" })
|
|
88
|
-
})
|
|
89
|
-
|
|
90
73
|
test("validators prop is accepted on every field wrapper", () => {
|
|
91
74
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
92
75
|
form.InputField({
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
4
|
import { z } from "zod"
|
|
5
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
5
6
|
|
|
6
|
-
import { Form, FormProvider } from "./form"
|
|
7
|
-
import { useForm } from "./use-form"
|
|
8
|
-
|
|
9
|
-
const EMAIL_LABEL = /Email/
|
|
10
7
|
const CONTACT_EMAIL_LABEL = /Contact email/
|
|
11
8
|
const PASSWORD_LABEL = /Password/
|
|
12
9
|
const NAME_LABEL = /Name/
|
|
@@ -16,27 +13,7 @@ afterEach(() => {
|
|
|
16
13
|
cleanup()
|
|
17
14
|
})
|
|
18
15
|
|
|
19
|
-
describe("EmailField / PasswordField
|
|
20
|
-
test("EmailField with no `name` renders an input with id='email'", () => {
|
|
21
|
-
const schema = z.object({ email: z.email() })
|
|
22
|
-
function H() {
|
|
23
|
-
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
24
|
-
const { EmailField } = form
|
|
25
|
-
return (
|
|
26
|
-
<FormProvider form={form}>
|
|
27
|
-
<Form>
|
|
28
|
-
<EmailField label="Email" />
|
|
29
|
-
</Form>
|
|
30
|
-
</FormProvider>
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
render(<H />)
|
|
34
|
-
const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
|
|
35
|
-
expect(input.id).toBe("email")
|
|
36
|
-
expect(input.name).toBe("email")
|
|
37
|
-
expect(input.type).toBe("email")
|
|
38
|
-
})
|
|
39
|
-
|
|
16
|
+
describe("EmailField / PasswordField", () => {
|
|
40
17
|
test("EmailField with an explicit `name` binds to that field", async () => {
|
|
41
18
|
const user = userEvent.setup()
|
|
42
19
|
const schema = z.object({ contactEmail: z.email() })
|
|
@@ -59,19 +36,18 @@ describe("EmailField / PasswordField — default name", () => {
|
|
|
59
36
|
expect(input.value).toBe("u@example.com")
|
|
60
37
|
})
|
|
61
38
|
|
|
62
|
-
test("PasswordField with
|
|
39
|
+
test("PasswordField with an explicit `name` renders a password input", () => {
|
|
63
40
|
const schema = z.object({
|
|
64
41
|
email: z.email(),
|
|
65
42
|
password: z.string().min(1),
|
|
66
43
|
})
|
|
67
44
|
function H() {
|
|
68
45
|
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
69
|
-
const {
|
|
46
|
+
const { PasswordField } = form
|
|
70
47
|
return (
|
|
71
48
|
<FormProvider form={form}>
|
|
72
49
|
<Form>
|
|
73
|
-
<
|
|
74
|
-
<PasswordField label="Password" />
|
|
50
|
+
<PasswordField label="Password" name="password" />
|
|
75
51
|
</Form>
|
|
76
52
|
</FormProvider>
|
|
77
53
|
)
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
4
|
import { z } from "zod"
|
|
5
|
-
|
|
6
|
-
import { Form, FormProvider } from "./form"
|
|
7
|
-
import { useForm } from "./use-form"
|
|
5
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
8
6
|
|
|
9
7
|
const DIET_LABEL = /Diet/
|
|
10
8
|
const SAUSAGE_LABEL = /Sausage/
|