@zeno-lib/forms 0.3.0 → 0.5.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 +9 -3
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -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 +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- 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 +1 -1
- package/src/create-form.tsx +2 -0
- package/src/create-zeno-form.tsx +25 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +1 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +1 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -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 }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "vitest"
|
|
2
|
+
import { focusFirstInvalid } from "./focus-first-invalid"
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
document.body.innerHTML = ""
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
describe("focusFirstInvalid", () => {
|
|
9
|
+
test("skips an invalid control that won't take focus", () => {
|
|
10
|
+
document.body.innerHTML = `
|
|
11
|
+
<form>
|
|
12
|
+
<input aria-invalid="true" disabled name="locked" />
|
|
13
|
+
<div aria-invalid="true" role="radiogroup">
|
|
14
|
+
<button disabled role="radio" type="button">A</button>
|
|
15
|
+
<button role="radio" type="button">B</button>
|
|
16
|
+
</div>
|
|
17
|
+
</form>`
|
|
18
|
+
const focused = focusFirstInvalid(document.querySelector("form"))
|
|
19
|
+
expect(focused?.textContent).toBe("B")
|
|
20
|
+
expect(document.activeElement).toBe(focused)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move focus to the first invalid control inside `root` (a `<form>`): the
|
|
3
|
+
* first element with `aria-invalid="true"`, or, for a group root that can't
|
|
4
|
+
* take focus itself (radio group, slider), its first tabbable descendant. An
|
|
5
|
+
* invalid control that won't take focus (disabled, hidden) is skipped for the
|
|
6
|
+
* next one. Call it after `await form.handleSubmit()` leaves the form invalid.
|
|
7
|
+
* Searching only `root` keeps focus inside the submitted form, so a dialog's
|
|
8
|
+
* form never sends it to the page behind. Returns the focused element, or
|
|
9
|
+
* `null`.
|
|
10
|
+
*/
|
|
11
|
+
function focusFirstInvalid(
|
|
12
|
+
root: ParentNode | null | undefined
|
|
13
|
+
): HTMLElement | null {
|
|
14
|
+
const invalid = root?.querySelectorAll<HTMLElement>('[aria-invalid="true"]')
|
|
15
|
+
for (const element of invalid ?? []) {
|
|
16
|
+
for (const target of [
|
|
17
|
+
element,
|
|
18
|
+
...element.querySelectorAll<HTMLElement>("*"),
|
|
19
|
+
]) {
|
|
20
|
+
if (target.tabIndex < 0) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
target.focus()
|
|
24
|
+
if (target.ownerDocument.activeElement === target) {
|
|
25
|
+
return target
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Give focus back to `element`, what had it when the submit started, if the
|
|
34
|
+
* submit dropped it to the body. A submit button that disables itself while
|
|
35
|
+
* submitting does that in browsers, so a failed submit with no invalid field
|
|
36
|
+
* to focus would otherwise leave the user nowhere. It doesn't scroll, so a
|
|
37
|
+
* user who scrolled away during a slow submit stays put. Returns whether it
|
|
38
|
+
* moved focus.
|
|
39
|
+
*/
|
|
40
|
+
function restoreFocus(element: Element | null): boolean {
|
|
41
|
+
if (!(element instanceof HTMLElement && element.isConnected)) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
const active = element.ownerDocument.activeElement
|
|
45
|
+
if (active !== null && active !== element.ownerDocument.body) {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
element.focus({ preventScroll: true })
|
|
49
|
+
return element.ownerDocument.activeElement === element
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { focusFirstInvalid, restoreFocus }
|
|
@@ -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 }
|
|
@@ -58,6 +58,22 @@ describe("getRequiredPaths", () => {
|
|
|
58
58
|
expect(required.has("members[0].name")).toBe(true)
|
|
59
59
|
})
|
|
60
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
|
+
|
|
61
77
|
test("nested z.object reports the parent and its required children", () => {
|
|
62
78
|
const schema = z.object({
|
|
63
79
|
profile: z.object({
|
|
@@ -80,20 +96,26 @@ describe("getRequiredPaths", () => {
|
|
|
80
96
|
|
|
81
97
|
test("array rows are reported with TanStack bracket syntax", () => {
|
|
82
98
|
const schema = z.object({
|
|
99
|
+
emails: z.array(z.email()),
|
|
83
100
|
members: z.array(
|
|
84
101
|
z.object({
|
|
85
102
|
name: z.string().min(1),
|
|
86
103
|
note: z.string().optional(),
|
|
87
104
|
})
|
|
88
105
|
),
|
|
106
|
+
picked: z.array(z.string()).min(1),
|
|
89
107
|
tags: z.array(z.string()),
|
|
90
108
|
})
|
|
91
109
|
const required = getRequiredPaths(schema)
|
|
92
|
-
expect(required.has("members")).toBe(true)
|
|
93
110
|
expect(required.has("members[0].name")).toBe(true)
|
|
94
111
|
expect(required.has("members.0.name")).toBe(false)
|
|
95
112
|
expect(required.has("members[0].note")).toBe(false)
|
|
96
|
-
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)
|
|
97
119
|
})
|
|
98
120
|
|
|
99
121
|
test("nested arrays descend through every level", () => {
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
// A missing parent hides its children (the schema stops at the missing key), so
|
|
10
10
|
// the probe descends: when an issue says it expected an object or an array
|
|
11
11
|
// (Zod and Valibot both put that on `issue.expected`), the next pass fills that
|
|
12
|
-
// path
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// path and validates again. An object gets `{}`. An array gets one missing row
|
|
13
|
+
// (`[undefined]`), so the following pass probes the row's own schema. Rows are
|
|
14
|
+
// probed through index `0` and recorded index-agnostically, so
|
|
15
|
+
// `members[0].name` in the set matches every row's `members[3].name` via
|
|
16
|
+
// `toRequiredPathKey`.
|
|
15
17
|
//
|
|
16
|
-
// A missing
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// (`.min(1)`,
|
|
18
|
+
// A missing field is then probed with an empty value: `""` for a string, then
|
|
19
|
+
// `null`; `[]` for an array; `null` for anything else. A field that accepts one
|
|
20
|
+
// (a bare `z.string()` or `z.array()`, anything `.nullable()`) leaves the set,
|
|
21
|
+
// matching `lib/empty-value.ts`; one that rejects them all (`.min(1)`,
|
|
22
|
+
// `z.email()`, a bare `z.number()`) stays.
|
|
20
23
|
//
|
|
21
24
|
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
22
25
|
// for array indices (`members[0].name`, not `members.0.name`).
|
|
@@ -47,7 +50,8 @@ type StandardSchemaLike = {
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
// Each level can take two passes: the empty value, then the descent.
|
|
54
|
+
const MAX_PROBE_DEPTH = 16
|
|
51
55
|
|
|
52
56
|
const INDEX_SEGMENT = /^\d+$/
|
|
53
57
|
|
|
@@ -92,26 +96,25 @@ function toRequiredPathKey(name: string): string {
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function probeValue(expected: unknown): unknown {
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
switch (expected.toLowerCase()) {
|
|
99
|
+
switch (typeof expected === "string" ? expected.toLowerCase() : undefined) {
|
|
99
100
|
case "object":
|
|
100
101
|
return {}
|
|
101
102
|
case "array":
|
|
102
|
-
return [
|
|
103
|
+
return []
|
|
103
104
|
case "string":
|
|
104
105
|
return ""
|
|
105
106
|
default:
|
|
106
|
-
return
|
|
107
|
+
return null
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
// Write `value` at `keys` inside `root`, only where nothing is set yet
|
|
111
|
+
// Write `value` at `keys` inside `root`, only where nothing is set yet unless
|
|
112
|
+
// `replace` is set.
|
|
111
113
|
function fillAt(
|
|
112
114
|
root: Record<PropertyKey, unknown>,
|
|
113
115
|
keys: readonly PropertyKey[],
|
|
114
|
-
value: unknown
|
|
116
|
+
value: unknown,
|
|
117
|
+
replace = false
|
|
115
118
|
): boolean {
|
|
116
119
|
let node: Record<PropertyKey, unknown> = root
|
|
117
120
|
for (const key of keys.slice(0, -1)) {
|
|
@@ -122,7 +125,7 @@ function fillAt(
|
|
|
122
125
|
node = next as Record<PropertyKey, unknown>
|
|
123
126
|
}
|
|
124
127
|
const last = keys.at(-1) as PropertyKey
|
|
125
|
-
if (node[last] !== undefined) {
|
|
128
|
+
if (!replace && node[last] !== undefined) {
|
|
126
129
|
return false
|
|
127
130
|
}
|
|
128
131
|
node[last] = value
|
|
@@ -145,22 +148,27 @@ function validateSync(
|
|
|
145
148
|
return "issues" in result && result.issues ? result.issues : []
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
type ProbeFill = {
|
|
152
|
+
readonly keys: readonly PropertyKey[]
|
|
153
|
+
readonly path: string
|
|
154
|
+
readonly value: unknown
|
|
155
|
+
}
|
|
156
|
+
|
|
148
157
|
type ProbePass = {
|
|
149
|
-
readonly descended: boolean
|
|
150
158
|
readonly failing: ReadonlySet<string>
|
|
151
|
-
readonly
|
|
159
|
+
readonly filled: readonly ProbeFill[]
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
// Record one pass's failing paths, and fill the probe at each of them for the
|
|
155
|
-
// next pass.
|
|
163
|
+
// next pass. A path whose empty value already passed stays out of the set.
|
|
156
164
|
function recordPass(
|
|
157
165
|
issues: readonly StandardIssue[],
|
|
158
166
|
probe: Record<PropertyKey, unknown>,
|
|
159
|
-
required: Set<string
|
|
167
|
+
required: Set<string>,
|
|
168
|
+
cleared: ReadonlySet<string>
|
|
160
169
|
): ProbePass {
|
|
161
170
|
const failing = new Set<string>()
|
|
162
|
-
const
|
|
163
|
-
let descended = false
|
|
171
|
+
const filled: ProbeFill[] = []
|
|
164
172
|
for (const issue of issues) {
|
|
165
173
|
const keys = issue.path?.map(pathKey) ?? []
|
|
166
174
|
if (keys.length === 0) {
|
|
@@ -168,40 +176,70 @@ function recordPass(
|
|
|
168
176
|
}
|
|
169
177
|
const path = joinPath(keys)
|
|
170
178
|
failing.add(path)
|
|
171
|
-
|
|
179
|
+
if (!cleared.has(path)) {
|
|
180
|
+
required.add(path)
|
|
181
|
+
}
|
|
172
182
|
const value = probeValue(issue.expected)
|
|
173
183
|
if (value !== undefined && fillAt(probe, keys, value)) {
|
|
184
|
+
filled.push({ keys, path, value })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { failing, filled }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isEmptyProbe(value: unknown): boolean {
|
|
191
|
+
return value === "" || value === null || Array.isArray(value)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Settle what the previous pass filled with an empty value: a path that now
|
|
195
|
+
// passes leaves the required set. A string that rejects `""` gets `null` next,
|
|
196
|
+
// and an array checked empty gets one missing row, so the row is probed.
|
|
197
|
+
function settleEmptied(
|
|
198
|
+
emptied: readonly ProbeFill[],
|
|
199
|
+
failing: ReadonlySet<string>,
|
|
200
|
+
probe: Record<PropertyKey, unknown>,
|
|
201
|
+
required: Set<string>,
|
|
202
|
+
cleared: Set<string>
|
|
203
|
+
): { readonly next: ProbeFill[]; readonly descended: boolean } {
|
|
204
|
+
const next: ProbeFill[] = []
|
|
205
|
+
let descended = false
|
|
206
|
+
for (const fill of emptied) {
|
|
207
|
+
const passed = !failing.has(fill.path)
|
|
208
|
+
if (passed) {
|
|
209
|
+
required.delete(fill.path)
|
|
210
|
+
cleared.add(fill.path)
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(fill.value)) {
|
|
213
|
+
fill.value.push(undefined)
|
|
214
|
+
descended = true
|
|
215
|
+
} else if (!passed && fill.value === "") {
|
|
216
|
+
fillAt(probe, fill.keys, null, true)
|
|
217
|
+
next.push({ ...fill, value: null })
|
|
174
218
|
descended = true
|
|
175
|
-
if (value === "") {
|
|
176
|
-
filledStrings.push(path)
|
|
177
|
-
}
|
|
178
219
|
}
|
|
179
220
|
}
|
|
180
|
-
return { descended,
|
|
221
|
+
return { descended, next }
|
|
181
222
|
}
|
|
182
223
|
|
|
183
224
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
184
225
|
const required = new Set<string>()
|
|
226
|
+
const cleared = new Set<string>()
|
|
185
227
|
const probe: Record<PropertyKey, unknown> = {}
|
|
186
|
-
//
|
|
187
|
-
|
|
228
|
+
// What the previous pass filled with an empty value: each stays required
|
|
229
|
+
// only if it still fails.
|
|
230
|
+
let emptied: readonly ProbeFill[] = []
|
|
188
231
|
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
189
232
|
const issues = validateSync(schema, probe)
|
|
190
233
|
if (!issues) {
|
|
191
234
|
return required
|
|
192
235
|
}
|
|
193
|
-
const {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
required.delete(path)
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
filledStrings = filled
|
|
204
|
-
if (!descended) {
|
|
236
|
+
const { failing, filled } = recordPass(issues, probe, required, cleared)
|
|
237
|
+
const settled = settleEmptied(emptied, failing, probe, required, cleared)
|
|
238
|
+
emptied = [
|
|
239
|
+
...filled.filter(({ value }) => isEmptyProbe(value)),
|
|
240
|
+
...settled.next,
|
|
241
|
+
]
|
|
242
|
+
if (filled.length === 0 && !settled.descended) {
|
|
205
243
|
break
|
|
206
244
|
}
|
|
207
245
|
}
|
|
@@ -209,4 +247,4 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
export type { StandardSchemaLike }
|
|
212
|
-
export { getRequiredPaths, toRequiredPathKey }
|
|
250
|
+
export { getRequiredPaths, toRequiredPathKey, validateSync }
|