@zeno-lib/forms 0.5.0 → 0.7.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.d.mts +27 -6
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/use-field-control.d.mts +83 -0
- package/dist/lib/use-field-control.mjs +70 -0
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +36 -28
- package/src/create-zeno-form.tsx +144 -52
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +80 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/input-field.tsx +16 -48
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +16 -48
- package/src/form-dialog.test.tsx +102 -1
- package/src/form-dialog.tsx +143 -42
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +7 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/use-field-control.ts +191 -0
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The items of `ComboboxField` and `MultiSelectField`: plain values, which
|
|
2
|
+
// are their own label, `{ value, label }` objects, or `{ label, items }`
|
|
3
|
+
// groups of either.
|
|
4
|
+
|
|
5
|
+
/** An item whose value isn't its label. */
|
|
6
|
+
type ItemObject<V> = { value: V; label: string }
|
|
7
|
+
|
|
8
|
+
/** Items under a heading. */
|
|
9
|
+
type ItemGroup<T> = { label: string; items: readonly T[] }
|
|
10
|
+
|
|
11
|
+
/** An item of `items`, or of one of its groups. */
|
|
12
|
+
type ItemOption<T> = T extends ItemGroup<infer I> ? I : T
|
|
13
|
+
|
|
14
|
+
/** What picking an item stores: `item.value` for an object, else the item. */
|
|
15
|
+
type ItemValue<T> =
|
|
16
|
+
T extends ItemGroup<infer I>
|
|
17
|
+
? ItemValue<I>
|
|
18
|
+
: T extends ItemObject<infer V>
|
|
19
|
+
? V
|
|
20
|
+
: T
|
|
21
|
+
|
|
22
|
+
/** Whether an item's value is a stored one, as `isItemEqualToValue` says. */
|
|
23
|
+
type ValueComparator = (itemValue: unknown, value: unknown) => boolean
|
|
24
|
+
|
|
25
|
+
function isItemObject(item: unknown): item is ItemObject<unknown> {
|
|
26
|
+
return (
|
|
27
|
+
typeof item === "object" &&
|
|
28
|
+
item !== null &&
|
|
29
|
+
"value" in item &&
|
|
30
|
+
"label" in item
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What Base UI takes for a group: an object with an `items` array. */
|
|
35
|
+
function isItemGroup(item: unknown): item is ItemGroup<unknown> {
|
|
36
|
+
return (
|
|
37
|
+
typeof item === "object" &&
|
|
38
|
+
item !== null &&
|
|
39
|
+
Array.isArray((item as { items?: unknown }).items)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function itemValue(item: unknown): unknown {
|
|
44
|
+
return isItemObject(item) ? item.value : item
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function itemLabel(item: unknown): string {
|
|
48
|
+
return isItemObject(item) ? item.label : String(item)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A React key per value: `1` and `"1"` get two, an object gets its JSON. */
|
|
52
|
+
function valueKey(value: unknown): string {
|
|
53
|
+
if (typeof value !== "object" || value === null) {
|
|
54
|
+
return `${typeof value}:${String(value)}`
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return `object:${JSON.stringify(value)}`
|
|
58
|
+
} catch {
|
|
59
|
+
return `object:${String(value)}`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ItemList<T> = {
|
|
64
|
+
/**
|
|
65
|
+
* `items` as Base UI takes them. It tells a grouped list by its first entry
|
|
66
|
+
* alone, so once there is a group, each run of plain items beside one goes
|
|
67
|
+
* under a group with no heading (`label: ""`).
|
|
68
|
+
*/
|
|
69
|
+
readonly items: readonly T[]
|
|
70
|
+
/** Every option, the grouped ones included, in order. */
|
|
71
|
+
readonly options: readonly ItemOption<T>[]
|
|
72
|
+
/** The first option of each `valueKey`, so finding a value is no scan. */
|
|
73
|
+
readonly byKey: ReadonlyMap<string, ItemOption<T>>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toItemList<T>(items: readonly T[]): ItemList<T> {
|
|
77
|
+
const hasGroup = items.some(isItemGroup)
|
|
78
|
+
const options: ItemOption<T>[] = []
|
|
79
|
+
const groups: ItemGroup<unknown>[] = []
|
|
80
|
+
let run: unknown[] | undefined
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
if (isItemGroup(item)) {
|
|
83
|
+
for (const option of item.items) {
|
|
84
|
+
options.push(option as ItemOption<T>)
|
|
85
|
+
}
|
|
86
|
+
groups.push(item)
|
|
87
|
+
run = undefined
|
|
88
|
+
} else {
|
|
89
|
+
options.push(item as ItemOption<T>)
|
|
90
|
+
if (hasGroup && !run) {
|
|
91
|
+
run = []
|
|
92
|
+
groups.push({ items: run, label: "" })
|
|
93
|
+
}
|
|
94
|
+
run?.push(item)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const byKey = new Map<string, ItemOption<T>>()
|
|
98
|
+
for (const option of options) {
|
|
99
|
+
const key = valueKey(itemValue(option))
|
|
100
|
+
if (!byKey.has(key)) {
|
|
101
|
+
byKey.set(key, option)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
byKey,
|
|
106
|
+
items: hasGroup ? (groups as unknown as readonly T[]) : items,
|
|
107
|
+
options,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `Object.is`, then `isItemEqualToValue`, which never sees an empty value.
|
|
113
|
+
*/
|
|
114
|
+
function isSameValue(
|
|
115
|
+
a: unknown,
|
|
116
|
+
b: unknown,
|
|
117
|
+
isItemEqualToValue: ValueComparator | undefined
|
|
118
|
+
): boolean {
|
|
119
|
+
if (Object.is(a, b)) {
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
return isItemEqualToValue?.(a, b) ?? false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The option whose value is `value`, if `items` has it. */
|
|
129
|
+
function findOption<T>(
|
|
130
|
+
list: ItemList<T>,
|
|
131
|
+
value: unknown,
|
|
132
|
+
isItemEqualToValue: ValueComparator | undefined
|
|
133
|
+
): ItemOption<T> | undefined {
|
|
134
|
+
const hit = list.byKey.get(valueKey(value))
|
|
135
|
+
if (
|
|
136
|
+
hit !== undefined &&
|
|
137
|
+
isSameValue(itemValue(hit), value, isItemEqualToValue)
|
|
138
|
+
) {
|
|
139
|
+
return hit
|
|
140
|
+
}
|
|
141
|
+
// A comparator can match values whose keys differ (`{ id }` against
|
|
142
|
+
// `{ id, name }`).
|
|
143
|
+
return list.options.find((option) =>
|
|
144
|
+
isSameValue(itemValue(option), value, isItemEqualToValue)
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type {
|
|
149
|
+
ItemGroup,
|
|
150
|
+
ItemList,
|
|
151
|
+
ItemObject,
|
|
152
|
+
ItemOption,
|
|
153
|
+
ItemValue,
|
|
154
|
+
ValueComparator,
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
findOption,
|
|
158
|
+
isItemGroup,
|
|
159
|
+
isItemObject,
|
|
160
|
+
isSameValue,
|
|
161
|
+
itemLabel,
|
|
162
|
+
itemValue,
|
|
163
|
+
toItemList,
|
|
164
|
+
valueKey,
|
|
165
|
+
}
|
|
@@ -87,6 +87,15 @@ describe("getRequiredPaths", () => {
|
|
|
87
87
|
expect(required.has("profile.bio")).toBe(false)
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test("a nullable object is not required, though its own fields are", () => {
|
|
91
|
+
const schema = z.object({
|
|
92
|
+
payer: z.object({ id: z.number(), kind: z.string().min(1) }).nullable(),
|
|
93
|
+
})
|
|
94
|
+
const required = getRequiredPaths(schema)
|
|
95
|
+
expect(required.has("payer")).toBe(false)
|
|
96
|
+
expect(required.has("payer.kind")).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
90
99
|
test("optional nested object is not descended into", () => {
|
|
91
100
|
const schema = z.object({
|
|
92
101
|
profile: z.object({ email: z.email() }).optional(),
|
|
@@ -221,6 +221,38 @@ function settleEmptied(
|
|
|
221
221
|
return { descended, next }
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// The probe descends into a missing object with `{}`, so it never tries
|
|
225
|
+
// `null` there. Try it on the side: a `.nullable()` object accepts it, so it
|
|
226
|
+
// isn't required, though its own fields still are once it is set.
|
|
227
|
+
function clearNullableObjects(
|
|
228
|
+
schema: StandardSchemaLike,
|
|
229
|
+
filled: readonly ProbeFill[],
|
|
230
|
+
probe: Record<PropertyKey, unknown>,
|
|
231
|
+
required: Set<string>,
|
|
232
|
+
cleared: Set<string>
|
|
233
|
+
): void {
|
|
234
|
+
for (const fill of filled) {
|
|
235
|
+
if (isEmptyProbe(fill.value) || cleared.has(fill.path)) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
fillAt(probe, fill.keys, null, true)
|
|
239
|
+
const issues = validateSync(schema, probe)
|
|
240
|
+
fillAt(probe, fill.keys, fill.value, true)
|
|
241
|
+
const rejected = issues?.some((issue) => {
|
|
242
|
+
const path = joinPath(issue.path?.map(pathKey) ?? [])
|
|
243
|
+
return (
|
|
244
|
+
path === fill.path ||
|
|
245
|
+
path.startsWith(`${fill.path}.`) ||
|
|
246
|
+
path.startsWith(`${fill.path}[`)
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
if (issues && !rejected) {
|
|
250
|
+
required.delete(fill.path)
|
|
251
|
+
cleared.add(fill.path)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
225
257
|
const required = new Set<string>()
|
|
226
258
|
const cleared = new Set<string>()
|
|
@@ -234,6 +266,7 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
|
234
266
|
return required
|
|
235
267
|
}
|
|
236
268
|
const { failing, filled } = recordPass(issues, probe, required, cleared)
|
|
269
|
+
clearNullableObjects(schema, filled, probe, required, cleared)
|
|
237
270
|
const settled = settleEmptied(emptied, failing, probe, required, cleared)
|
|
238
271
|
emptied = [
|
|
239
272
|
...filled.filter(({ value }) => isEmptyProbe(value)),
|
package/src/lib/submit-action.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./action-result"
|
|
9
9
|
import { applyValidationError } from "./apply-validation-error"
|
|
10
10
|
import { ValidationError } from "./validation-error"
|
|
11
|
+
import { getFormSchema } from "./validation-modes"
|
|
11
12
|
|
|
12
13
|
// Write an action's failure onto the form. Field messages go through
|
|
13
14
|
// `applyValidationError`, so they live in `errorMap.onChange` and clear the
|
|
@@ -110,7 +111,9 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
|
|
|
110
111
|
/**
|
|
111
112
|
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
112
113
|
* input values, and the action gets the schema's output. A failure is
|
|
113
|
-
* applied to the form and the action is never called.
|
|
114
|
+
* applied to the form and the action is never called. Replaces the form's
|
|
115
|
+
* own `schema` for this call; pass one only when the action takes a
|
|
116
|
+
* different schema than the form.
|
|
114
117
|
*/
|
|
115
118
|
schema: SubmitActionSchema<TOutput>
|
|
116
119
|
}
|
|
@@ -120,9 +123,13 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
|
|
|
120
123
|
* form. Pass `onSubmit`'s own argument straight through:
|
|
121
124
|
*
|
|
122
125
|
* ```ts
|
|
123
|
-
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true
|
|
126
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true })
|
|
124
127
|
* ```
|
|
125
128
|
*
|
|
129
|
+
* The values are parsed with `options.schema`, else with the form's own
|
|
130
|
+
* `useForm({ schema })`, and the action gets the schema's output. A form with
|
|
131
|
+
* no schema (`useForm` without one, `useAppForm`) sends its values as they are.
|
|
132
|
+
*
|
|
126
133
|
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
127
134
|
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
128
135
|
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
@@ -145,10 +152,17 @@ async function submitAction(
|
|
|
145
152
|
options: { reset?: unknown; schema?: SubmitActionSchema<unknown> } = {}
|
|
146
153
|
): Promise<ActionResult<unknown>> {
|
|
147
154
|
const { formApi, value } = props
|
|
155
|
+
// `useForm` types its schema as `StandardSchema<Values>`, so the output
|
|
156
|
+
// still fits the `TValues` the action is typed against.
|
|
157
|
+
const schema =
|
|
158
|
+
options.schema ??
|
|
159
|
+
(getFormSchema(formApi as AnyFormApi) as
|
|
160
|
+
| SubmitActionSchema<unknown>
|
|
161
|
+
| undefined)
|
|
148
162
|
let input: unknown = value
|
|
149
163
|
|
|
150
|
-
if (
|
|
151
|
-
const parsed = await
|
|
164
|
+
if (schema) {
|
|
165
|
+
const parsed = await schema["~standard"].validate(value)
|
|
152
166
|
if (parsed.issues) {
|
|
153
167
|
const error = toActionError(parsed.issues)
|
|
154
168
|
applyActionError(formApi, error)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AnyFieldMeta } from "@tanstack/react-form"
|
|
4
|
+
import { type ReactNode, useCallback, useId } from "react"
|
|
5
|
+
|
|
6
|
+
import { describedBy } from "./aria"
|
|
7
|
+
import { useFieldContext } from "./contexts"
|
|
8
|
+
import { emptyValueFor } from "./empty-value"
|
|
9
|
+
import { useIsFormDisabled } from "./form-disabled"
|
|
10
|
+
import {
|
|
11
|
+
useHideFieldErrors,
|
|
12
|
+
useIsFieldRequired,
|
|
13
|
+
useIsFieldRequiredBySchema,
|
|
14
|
+
useIsInvalid,
|
|
15
|
+
} from "./use-is-invalid"
|
|
16
|
+
import { getFormSchema } from "./validation-modes"
|
|
17
|
+
|
|
18
|
+
type UseFieldControlOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* The description the field renders, if any. The control is described by
|
|
21
|
+
* it, and by the error while one shows.
|
|
22
|
+
*/
|
|
23
|
+
description?: ReactNode
|
|
24
|
+
/**
|
|
25
|
+
* Disable the control. A disabled form (`useForm({ disabled })`,
|
|
26
|
+
* `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
|
|
27
|
+
*/
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* The label the field renders, if any. The control gets `aria-labelledby`,
|
|
31
|
+
* which names a control a `<label htmlFor>` can't, such as a
|
|
32
|
+
* `role="radiogroup"`.
|
|
33
|
+
*/
|
|
34
|
+
label?: ReactNode
|
|
35
|
+
/**
|
|
36
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
37
|
+
* `aria-required`.
|
|
38
|
+
*/
|
|
39
|
+
required?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The props that bind a control to its field. Spread them on the control. */
|
|
43
|
+
type FieldControlProps<TValue = unknown> = {
|
|
44
|
+
"aria-describedby": string | undefined
|
|
45
|
+
"aria-invalid": true | undefined
|
|
46
|
+
"aria-labelledby": string | undefined
|
|
47
|
+
"aria-required": true | undefined
|
|
48
|
+
/** The field's own `disabled`, or the form's. */
|
|
49
|
+
disabled: boolean | undefined
|
|
50
|
+
id: string
|
|
51
|
+
name: string
|
|
52
|
+
/** Marks the field touched. */
|
|
53
|
+
onBlur: () => void
|
|
54
|
+
/**
|
|
55
|
+
* Stores `value`, not a change event: a control that reports one maps it
|
|
56
|
+
* (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
|
|
57
|
+
* `""`, `NaN` or `[]`) stores the field's empty value instead, which its
|
|
58
|
+
* schema picks. Stable across renders.
|
|
59
|
+
*/
|
|
60
|
+
onChange: (value: TValue) => void
|
|
61
|
+
/**
|
|
62
|
+
* The value as stored, the field's empty value (`undefined` or `null`)
|
|
63
|
+
* included. A control that needs a defined value maps it
|
|
64
|
+
* (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
|
|
65
|
+
*/
|
|
66
|
+
value: TValue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type FieldControl<TValue = unknown> = {
|
|
70
|
+
controlProps: FieldControlProps<TValue>
|
|
71
|
+
descriptionId: string
|
|
72
|
+
errorId: string
|
|
73
|
+
/**
|
|
74
|
+
* The errors to render: none while the field's errors are hidden, by the
|
|
75
|
+
* form's validation mode or `hideFieldErrors`.
|
|
76
|
+
*/
|
|
77
|
+
errors: AnyFieldMeta["errors"]
|
|
78
|
+
/** The control's id, for the label's `htmlFor`. */
|
|
79
|
+
id: string
|
|
80
|
+
/** The field is disabled, by its own `disabled` or the form's. */
|
|
81
|
+
isDisabled: boolean
|
|
82
|
+
isInvalid: boolean
|
|
83
|
+
/** The field requires a value, by the schema or `required`. */
|
|
84
|
+
isRequired: boolean
|
|
85
|
+
/** The label's id, when `label` is passed. */
|
|
86
|
+
labelId: string | undefined
|
|
87
|
+
name: string
|
|
88
|
+
/** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
|
|
89
|
+
showRequiredIndicator: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isEmpty(value: unknown): boolean {
|
|
93
|
+
return (
|
|
94
|
+
value === null ||
|
|
95
|
+
value === undefined ||
|
|
96
|
+
value === "" ||
|
|
97
|
+
Number.isNaN(value) ||
|
|
98
|
+
(Array.isArray(value) && value.length === 0)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A React synthetic event, from spreading the control props on a native input
|
|
103
|
+
// or an event-emitting component. Storing it would put the event in the form.
|
|
104
|
+
function isSyntheticEvent(value: unknown): boolean {
|
|
105
|
+
return (
|
|
106
|
+
typeof value === "object" &&
|
|
107
|
+
value !== null &&
|
|
108
|
+
"nativeEvent" in value &&
|
|
109
|
+
"target" in value
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Everything a control needs to act as a field of the form, inside a field
|
|
115
|
+
* component the form renders (`form.AppField`, or one registered with
|
|
116
|
+
* `createZenoForm`): the props to spread on the control, and the ids and
|
|
117
|
+
* state its label, description and error need.
|
|
118
|
+
*/
|
|
119
|
+
function useFieldControl<TValue = unknown>({
|
|
120
|
+
description,
|
|
121
|
+
disabled,
|
|
122
|
+
label,
|
|
123
|
+
required,
|
|
124
|
+
}: UseFieldControlOptions = {}): FieldControl<TValue> {
|
|
125
|
+
const field = useFieldContext<TValue>()
|
|
126
|
+
const id = useId()
|
|
127
|
+
const labelId = label ? `${id}-label` : undefined
|
|
128
|
+
const descriptionId = `${id}-description`
|
|
129
|
+
const errorId = `${id}-error`
|
|
130
|
+
const isInvalid = useIsInvalid(field)
|
|
131
|
+
const hideErrors = useHideFieldErrors(field)
|
|
132
|
+
const showError = isInvalid && !hideErrors
|
|
133
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
134
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
135
|
+
const isRequired = required ?? schemaRequiresValue
|
|
136
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
137
|
+
// `field` is rebuilt on every value change; these members aren't, so a
|
|
138
|
+
// control that memoizes on `onChange` doesn't see a new one each render.
|
|
139
|
+
const { form, handleBlur, handleChange, name } = field
|
|
140
|
+
const onChange = useCallback(
|
|
141
|
+
(value: TValue) => {
|
|
142
|
+
if (isSyntheticEvent(value)) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
`Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
handleChange(
|
|
148
|
+
isEmpty(value)
|
|
149
|
+
? (emptyValueFor(
|
|
150
|
+
getFormSchema(form),
|
|
151
|
+
form.state.values,
|
|
152
|
+
name,
|
|
153
|
+
value
|
|
154
|
+
) as TValue)
|
|
155
|
+
: value
|
|
156
|
+
)
|
|
157
|
+
},
|
|
158
|
+
[form, handleChange, name]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
controlProps: {
|
|
163
|
+
"aria-describedby": describedBy(
|
|
164
|
+
[description, descriptionId],
|
|
165
|
+
[showError, errorId]
|
|
166
|
+
),
|
|
167
|
+
"aria-invalid": isInvalid || undefined,
|
|
168
|
+
"aria-labelledby": labelId,
|
|
169
|
+
"aria-required": isRequired || undefined,
|
|
170
|
+
disabled: isDisabled,
|
|
171
|
+
id,
|
|
172
|
+
name,
|
|
173
|
+
onBlur: handleBlur,
|
|
174
|
+
onChange,
|
|
175
|
+
value: field.state.value,
|
|
176
|
+
},
|
|
177
|
+
descriptionId,
|
|
178
|
+
errorId,
|
|
179
|
+
errors: showError ? field.state.meta.errors : [],
|
|
180
|
+
id,
|
|
181
|
+
isDisabled,
|
|
182
|
+
isInvalid,
|
|
183
|
+
isRequired,
|
|
184
|
+
labelId,
|
|
185
|
+
name,
|
|
186
|
+
showRequiredIndicator: required ?? schemaRequired,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type { FieldControl, FieldControlProps, UseFieldControlOptions }
|
|
191
|
+
export { useFieldControl }
|
|
@@ -244,6 +244,109 @@ describe("submitAction — field errors from the action", () => {
|
|
|
244
244
|
})
|
|
245
245
|
|
|
246
246
|
describe("submitAction — schema", () => {
|
|
247
|
+
test("parses with the form's own schema, and reset: true rebases on what the action saved", async () => {
|
|
248
|
+
const user = userEvent.setup()
|
|
249
|
+
const schema = z.object({ age: z.coerce.number(), name: z.string().trim() })
|
|
250
|
+
type Values = z.input<typeof schema>
|
|
251
|
+
const action = vi.fn((input: Values) =>
|
|
252
|
+
Promise.resolve<ActionResult<Values>>({ data: input, ok: true })
|
|
253
|
+
)
|
|
254
|
+
const formRef: { current: unknown } = { current: undefined }
|
|
255
|
+
function Harness() {
|
|
256
|
+
const form = useForm({
|
|
257
|
+
defaultValues: { age: "42", name: " Ada " },
|
|
258
|
+
onSubmit: (submit) => submitAction(submit, action, { reset: true }),
|
|
259
|
+
schema,
|
|
260
|
+
})
|
|
261
|
+
formRef.current = form
|
|
262
|
+
const { InputField, SubmitButton } = form
|
|
263
|
+
return (
|
|
264
|
+
<FormProvider form={form}>
|
|
265
|
+
<Form>
|
|
266
|
+
<InputField label="Name" name="name" />
|
|
267
|
+
<SubmitButton>Submit</SubmitButton>
|
|
268
|
+
</Form>
|
|
269
|
+
</FormProvider>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
render(<Harness />)
|
|
273
|
+
|
|
274
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
275
|
+
|
|
276
|
+
await vi.waitFor(() =>
|
|
277
|
+
expect(action).toHaveBeenCalledWith({ age: 42, name: "Ada" })
|
|
278
|
+
)
|
|
279
|
+
const form = formRef.current as {
|
|
280
|
+
state: { isDefaultValue: boolean; values: Values }
|
|
281
|
+
}
|
|
282
|
+
await vi.waitFor(() =>
|
|
283
|
+
expect(form.state.values).toEqual({ age: 42, name: "Ada" })
|
|
284
|
+
)
|
|
285
|
+
expect(form.state.isDefaultValue).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test("an explicit schema wins over the form's", async () => {
|
|
289
|
+
const user = userEvent.setup()
|
|
290
|
+
const action = vi.fn((_input: { name: string }) =>
|
|
291
|
+
Promise.resolve<ActionResult<null>>({ data: null, ok: true })
|
|
292
|
+
)
|
|
293
|
+
const actionSchema = z.object({ name: z.string().toUpperCase() })
|
|
294
|
+
function Harness() {
|
|
295
|
+
const form = useForm({
|
|
296
|
+
defaultValues: { name: " Ada " },
|
|
297
|
+
onSubmit: (submit) =>
|
|
298
|
+
submitAction(submit, action, { schema: actionSchema }),
|
|
299
|
+
schema: z.object({ name: z.string().trim() }),
|
|
300
|
+
})
|
|
301
|
+
const { InputField, SubmitButton } = form
|
|
302
|
+
return (
|
|
303
|
+
<FormProvider form={form}>
|
|
304
|
+
<Form>
|
|
305
|
+
<InputField label="Name" name="name" />
|
|
306
|
+
<SubmitButton>Submit</SubmitButton>
|
|
307
|
+
</Form>
|
|
308
|
+
</FormProvider>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
render(<Harness />)
|
|
312
|
+
|
|
313
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
314
|
+
|
|
315
|
+
await vi.waitFor(() =>
|
|
316
|
+
expect(action).toHaveBeenCalledWith({ name: " ADA " })
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// A schema passed as a plain TanStack validator is not the form's schema:
|
|
321
|
+
// the action gets the values as they are, as before.
|
|
322
|
+
const trimmed = z.object({ name: z.string().trim() })
|
|
323
|
+
|
|
324
|
+
test("useForm without a schema sends the values as they are", async () => {
|
|
325
|
+
const user = userEvent.setup()
|
|
326
|
+
const action = vi.fn((_input: { name: string }) =>
|
|
327
|
+
Promise.resolve<ActionResult<null>>({ data: null, ok: true })
|
|
328
|
+
)
|
|
329
|
+
function Harness() {
|
|
330
|
+
const form = useForm({
|
|
331
|
+
defaultValues: { name: " Ada " },
|
|
332
|
+
onSubmit: (submit) => submitAction(submit, action),
|
|
333
|
+
validators: { onChange: trimmed },
|
|
334
|
+
})
|
|
335
|
+
return (
|
|
336
|
+
<button onClick={() => form.handleSubmit()} type="button">
|
|
337
|
+
Submit
|
|
338
|
+
</button>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
render(<Harness />)
|
|
342
|
+
|
|
343
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
344
|
+
|
|
345
|
+
await vi.waitFor(() =>
|
|
346
|
+
expect(action).toHaveBeenCalledWith({ name: " Ada " })
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
|
|
247
350
|
test("passes the schema output to the action, not the input values", async () => {
|
|
248
351
|
const user = userEvent.setup()
|
|
249
352
|
const schema = z.object({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { expectTypeOf, test } from "vitest"
|
|
2
3
|
import { z } from "zod"
|
|
3
4
|
import { useForm } from "./create-form"
|
|
5
|
+
import { createZenoForm } from "./create-zeno-form"
|
|
4
6
|
|
|
5
7
|
const schema = z.object({
|
|
6
8
|
accept: z.boolean(),
|
|
@@ -93,3 +95,51 @@ test("listeners prop is accepted on every field wrapper", () => {
|
|
|
93
95
|
name: "email",
|
|
94
96
|
})
|
|
95
97
|
})
|
|
98
|
+
|
|
99
|
+
test("ControlField types its control with the value at `name`", () => {
|
|
100
|
+
const form = useForm({
|
|
101
|
+
onSubmit: () => undefined,
|
|
102
|
+
schema: z.object({
|
|
103
|
+
phone: z.string().nullable(),
|
|
104
|
+
rating: z.number(),
|
|
105
|
+
}),
|
|
106
|
+
})
|
|
107
|
+
form.ControlField({
|
|
108
|
+
name: "phone",
|
|
109
|
+
render: (control) => {
|
|
110
|
+
expectTypeOf(control.value).toEqualTypeOf<string | null>()
|
|
111
|
+
return null
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
form.ControlField({
|
|
115
|
+
name: "rating",
|
|
116
|
+
render: (control) => {
|
|
117
|
+
control.onChange(3)
|
|
118
|
+
// @ts-expect-error — the field holds a number
|
|
119
|
+
control.onChange("3")
|
|
120
|
+
return null
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
// @ts-expect-error — not a valid path
|
|
124
|
+
form.ControlField({ name: "nonexistent", render: () => null })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("a field whose `render` takes another argument keeps its own type", () => {
|
|
128
|
+
const { useForm: useListForm } = createZenoForm({
|
|
129
|
+
fieldComponents: {
|
|
130
|
+
ListField: (_: { render: (row: { id: string }) => ReactNode }) => null,
|
|
131
|
+
},
|
|
132
|
+
formComponents: {},
|
|
133
|
+
})
|
|
134
|
+
const form = useListForm({
|
|
135
|
+
onSubmit: () => undefined,
|
|
136
|
+
schema: z.object({ rows: z.array(z.string()) }),
|
|
137
|
+
})
|
|
138
|
+
form.ListField({
|
|
139
|
+
name: "rows",
|
|
140
|
+
render: (row) => {
|
|
141
|
+
expectTypeOf(row).toEqualTypeOf<{ id: string }>()
|
|
142
|
+
return null
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
})
|