@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,251 @@
|
|
|
1
|
+
import { cleanup, render, screen, within } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const party = z.object({ id: z.number(), kind: z.enum(["company", "person"]) })
|
|
13
|
+
type Party = z.infer<typeof party>
|
|
14
|
+
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
parties: z.array(party),
|
|
17
|
+
payer: party.nullable(),
|
|
18
|
+
})
|
|
19
|
+
type Values = z.infer<typeof schema>
|
|
20
|
+
|
|
21
|
+
// Rebuilt on every render, as options mapped from fetched rows are, so no
|
|
22
|
+
// item value is ever the stored object. A person and a company share an id.
|
|
23
|
+
function partyGroups() {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
items: [{ label: "Ada Lovelace", value: { id: 1, kind: "person" } }],
|
|
27
|
+
label: "People",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
items: [
|
|
31
|
+
{ label: "Globex", value: { id: 1, kind: "company" } },
|
|
32
|
+
{ label: "Initech", value: { id: 2, kind: "company" } },
|
|
33
|
+
],
|
|
34
|
+
label: "Companies",
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isSameParty = (a: Party, b: Party) => a.kind === b.kind && a.id === b.id
|
|
40
|
+
|
|
41
|
+
function Harness({ captured }: { captured: { values?: Values } }) {
|
|
42
|
+
const form = useForm({
|
|
43
|
+
defaultValues: {
|
|
44
|
+
parties: [{ id: 1, kind: "company" }],
|
|
45
|
+
payer: { id: 1, kind: "company" },
|
|
46
|
+
} as Values,
|
|
47
|
+
onSubmit: vi.fn(),
|
|
48
|
+
schema,
|
|
49
|
+
})
|
|
50
|
+
const { ComboboxField, MultiSelectField, Subscribe } = form
|
|
51
|
+
return (
|
|
52
|
+
<FormProvider form={form}>
|
|
53
|
+
<Form>
|
|
54
|
+
<ComboboxField
|
|
55
|
+
isItemEqualToValue={isSameParty}
|
|
56
|
+
items={partyGroups()}
|
|
57
|
+
label="Payer"
|
|
58
|
+
name="payer"
|
|
59
|
+
/>
|
|
60
|
+
<MultiSelectField
|
|
61
|
+
isItemEqualToValue={isSameParty}
|
|
62
|
+
items={partyGroups()}
|
|
63
|
+
label="Parties"
|
|
64
|
+
name="parties"
|
|
65
|
+
/>
|
|
66
|
+
<Subscribe selector={(state) => state.values}>
|
|
67
|
+
{(values) => {
|
|
68
|
+
captured.values = values
|
|
69
|
+
return null
|
|
70
|
+
}}
|
|
71
|
+
</Subscribe>
|
|
72
|
+
</Form>
|
|
73
|
+
</FormProvider>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fieldRoot(container: HTMLElement, name: string): HTMLElement {
|
|
78
|
+
const root = container.querySelector<HTMLElement>(`[data-field="${name}"]`)
|
|
79
|
+
if (!root) {
|
|
80
|
+
throw new Error(`no ${name} field`)
|
|
81
|
+
}
|
|
82
|
+
return root
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function chips(container: HTMLElement): string[] {
|
|
86
|
+
return [
|
|
87
|
+
...fieldRoot(container, "parties").querySelectorAll(
|
|
88
|
+
'[data-slot="combobox-chip"]'
|
|
89
|
+
),
|
|
90
|
+
].map((chip) => chip.textContent ?? "")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("grouped options with object values", () => {
|
|
94
|
+
test("ComboboxField keeps a stored object selected, stores the picked one, and clears to the empty value", async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
const captured: { values?: Values } = {}
|
|
97
|
+
const { container, rerender } = render(<Harness captured={captured} />)
|
|
98
|
+
const input = screen.getByRole("combobox", { name: "Payer" })
|
|
99
|
+
expect(input).toHaveProperty("value", "Globex")
|
|
100
|
+
|
|
101
|
+
await user.click(input)
|
|
102
|
+
const companies = await screen.findByRole("group", { name: "Companies" })
|
|
103
|
+
expect(
|
|
104
|
+
within(companies)
|
|
105
|
+
.getByRole("option", { name: "Globex" })
|
|
106
|
+
.getAttribute("aria-selected")
|
|
107
|
+
).toBe("true")
|
|
108
|
+
await user.click(screen.getByRole("option", { name: "Ada Lovelace" }))
|
|
109
|
+
expect(captured.values?.payer).toEqual({ id: 1, kind: "person" })
|
|
110
|
+
|
|
111
|
+
rerender(<Harness captured={captured} />)
|
|
112
|
+
expect(input).toHaveProperty("value", "Ada Lovelace")
|
|
113
|
+
|
|
114
|
+
await user.click(
|
|
115
|
+
within(fieldRoot(container, "payer")).getByRole("button", {
|
|
116
|
+
name: "Clear",
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
expect(captured.values?.payer).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("MultiSelectField keeps stored objects selected, adds the picked one, and clears", async () => {
|
|
123
|
+
const user = userEvent.setup()
|
|
124
|
+
const captured: { values?: Values } = {}
|
|
125
|
+
const { container, rerender } = render(<Harness captured={captured} />)
|
|
126
|
+
expect(chips(container)).toEqual(["Globex"])
|
|
127
|
+
|
|
128
|
+
await user.click(screen.getByRole("combobox", { name: "Parties" }))
|
|
129
|
+
const people = await screen.findByRole("group", { name: "People" })
|
|
130
|
+
await user.click(
|
|
131
|
+
within(people).getByRole("option", { name: "Ada Lovelace" })
|
|
132
|
+
)
|
|
133
|
+
await user.keyboard("{Escape}")
|
|
134
|
+
expect(captured.values?.parties).toEqual([
|
|
135
|
+
{ id: 1, kind: "company" },
|
|
136
|
+
{ id: 1, kind: "person" },
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
rerender(<Harness captured={captured} />)
|
|
140
|
+
expect(chips(container)).toEqual(["Globex", "Ada Lovelace"])
|
|
141
|
+
|
|
142
|
+
await user.click(
|
|
143
|
+
within(fieldRoot(container, "parties")).getByRole("button", {
|
|
144
|
+
name: "Clear",
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
expect(captured.values?.parties).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("grouped options", () => {
|
|
152
|
+
test.each([
|
|
153
|
+
["a group first", [{ items: ["Ada"], label: "People" }, "Globex"]],
|
|
154
|
+
["a plain item first", ["Globex", { items: ["Ada"], label: "People" }]],
|
|
155
|
+
])("mix groups and plain items, with %s", async (_, items) => {
|
|
156
|
+
const user = userEvent.setup()
|
|
157
|
+
const captured: { values?: { name: string | null } } = {}
|
|
158
|
+
function H() {
|
|
159
|
+
const form = useForm({
|
|
160
|
+
defaultValues: { name: null as string | null },
|
|
161
|
+
onSubmit: vi.fn(),
|
|
162
|
+
})
|
|
163
|
+
return (
|
|
164
|
+
<FormProvider form={form}>
|
|
165
|
+
<form.ComboboxField items={items} label="Name" name="name" />
|
|
166
|
+
<form.Subscribe selector={(state) => state.values}>
|
|
167
|
+
{(values) => {
|
|
168
|
+
captured.values = values
|
|
169
|
+
return null
|
|
170
|
+
}}
|
|
171
|
+
</form.Subscribe>
|
|
172
|
+
</FormProvider>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
render(<H />)
|
|
176
|
+
const input = screen.getByRole("combobox", { name: "Name" })
|
|
177
|
+
await user.type(input, "Ad")
|
|
178
|
+
const people = await screen.findByRole("group", { name: "People" })
|
|
179
|
+
await user.click(within(people).getByRole("option", { name: "Ada" }))
|
|
180
|
+
expect(captured.values?.name).toBe("Ada")
|
|
181
|
+
await user.clear(input)
|
|
182
|
+
await user.type(input, "Glo")
|
|
183
|
+
await user.click(await screen.findByRole("option", { name: "Globex" }))
|
|
184
|
+
expect(captured.values?.name).toBe("Globex")
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test("ComboboxField keeps what the user types while the items are rebuilt", async () => {
|
|
188
|
+
const user = userEvent.setup()
|
|
189
|
+
function H() {
|
|
190
|
+
const [, setQuery] = useState("")
|
|
191
|
+
const form = useForm({
|
|
192
|
+
defaultValues: { payer: { id: 1, kind: "company" } } as Values,
|
|
193
|
+
onSubmit: vi.fn(),
|
|
194
|
+
schema,
|
|
195
|
+
})
|
|
196
|
+
return (
|
|
197
|
+
<FormProvider form={form}>
|
|
198
|
+
<form.ComboboxField
|
|
199
|
+
isItemEqualToValue={isSameParty}
|
|
200
|
+
items={partyGroups()}
|
|
201
|
+
label="Payer"
|
|
202
|
+
name="payer"
|
|
203
|
+
onInputValueChange={setQuery}
|
|
204
|
+
/>
|
|
205
|
+
</FormProvider>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
render(<H />)
|
|
209
|
+
const input = screen.getByRole("combobox", { name: "Payer" })
|
|
210
|
+
await user.type(input, " Corp")
|
|
211
|
+
expect(input).toHaveProperty("value", "Globex Corp")
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("MultiSelectField keeps a stored value whose item isn't loaded yet", async () => {
|
|
215
|
+
const user = userEvent.setup()
|
|
216
|
+
const captured: { values?: Values } = {}
|
|
217
|
+
function H() {
|
|
218
|
+
const form = useForm({
|
|
219
|
+
defaultValues: { parties: [{ id: 9, kind: "company" }] } as Values,
|
|
220
|
+
onSubmit: vi.fn(),
|
|
221
|
+
schema,
|
|
222
|
+
})
|
|
223
|
+
return (
|
|
224
|
+
<FormProvider form={form}>
|
|
225
|
+
<form.MultiSelectField
|
|
226
|
+
isItemEqualToValue={isSameParty}
|
|
227
|
+
// The companies are still loading.
|
|
228
|
+
items={partyGroups().slice(0, 1)}
|
|
229
|
+
label="Parties"
|
|
230
|
+
name="parties"
|
|
231
|
+
/>
|
|
232
|
+
<form.Subscribe selector={(state) => state.values}>
|
|
233
|
+
{(values) => {
|
|
234
|
+
captured.values = values
|
|
235
|
+
return null
|
|
236
|
+
}}
|
|
237
|
+
</form.Subscribe>
|
|
238
|
+
</FormProvider>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
render(<H />)
|
|
242
|
+
await user.click(screen.getByRole("combobox", { name: "Parties" }))
|
|
243
|
+
await user.click(
|
|
244
|
+
await screen.findByRole("option", { name: "Ada Lovelace" })
|
|
245
|
+
)
|
|
246
|
+
expect(captured.values?.parties).toEqual([
|
|
247
|
+
{ id: 9, kind: "company" },
|
|
248
|
+
{ id: 1, kind: "person" },
|
|
249
|
+
])
|
|
250
|
+
})
|
|
251
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -12,12 +12,19 @@ export {
|
|
|
12
12
|
} from "./lib/action-result"
|
|
13
13
|
export { applyValidationError } from "./lib/apply-validation-error"
|
|
14
14
|
export { useFieldContext, useFormContext } from "./lib/contexts"
|
|
15
|
+
export { useIsFormDisabled } from "./lib/form-disabled"
|
|
15
16
|
export {
|
|
16
17
|
applyActionError,
|
|
17
18
|
type SubmitActionOptions,
|
|
18
19
|
type SubmitActionSchema,
|
|
19
20
|
submitAction,
|
|
20
21
|
} from "./lib/submit-action"
|
|
22
|
+
export {
|
|
23
|
+
type FieldControl,
|
|
24
|
+
type FieldControlProps,
|
|
25
|
+
type UseFieldControlOptions,
|
|
26
|
+
useFieldControl,
|
|
27
|
+
} from "./lib/use-field-control"
|
|
21
28
|
export {
|
|
22
29
|
useHideFieldErrors,
|
|
23
30
|
useIsFieldRequired,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import { formatIsoDate, parseDateValue } from "./date-value"
|
|
3
|
+
|
|
4
|
+
describe("parseDateValue", () => {
|
|
5
|
+
test("reads a day the month doesn't have as empty, not as next month's", () => {
|
|
6
|
+
expect(parseDateValue("2026-02-31")).toBeUndefined()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("keeps a year below 100, which `new Date(y, m, d)` reads as 19yy", () => {
|
|
10
|
+
const date = parseDateValue("0042-03-01")
|
|
11
|
+
expect(date?.getFullYear()).toBe(42)
|
|
12
|
+
expect(date && formatIsoDate(date)).toBe("0042-03-01")
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a date field stores for a picked day:
|
|
3
|
+
* - `"date"`: a `Date`, at local midnight.
|
|
4
|
+
* - `"iso-date"`: the day as `YYYY-MM-DD`, as `z.iso.date()` accepts and a
|
|
5
|
+
* `date` column stores it.
|
|
6
|
+
* - `"iso-datetime"`: the day's local midnight as an ISO 8601 timestamp in
|
|
7
|
+
* UTC (`toISOString()`), as `z.iso.datetime()` accepts and a timestamp
|
|
8
|
+
* column takes it.
|
|
9
|
+
*/
|
|
10
|
+
type DateValueType = "date" | "iso-date" | "iso-datetime"
|
|
11
|
+
|
|
12
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
13
|
+
|
|
14
|
+
function pad(value: number, length: number): string {
|
|
15
|
+
return String(value).padStart(length, "0")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// `new Date("2026-10-09")` reads a date-only string as UTC midnight, which is
|
|
19
|
+
// the day before anywhere west of UTC. Read it as the local day instead.
|
|
20
|
+
function parseIsoDate(value: string): Date | undefined {
|
|
21
|
+
const match = ISO_DATE.exec(value)
|
|
22
|
+
if (!match) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
const [year, month, day] = match.slice(1).map(Number) as [
|
|
26
|
+
number,
|
|
27
|
+
number,
|
|
28
|
+
number,
|
|
29
|
+
]
|
|
30
|
+
const date = new Date(year, month - 1, day)
|
|
31
|
+
// `new Date` reads the years 0 to 99 as 1900 to 1999.
|
|
32
|
+
date.setFullYear(year, month - 1, day)
|
|
33
|
+
// `2026-02-31` would roll over to March.
|
|
34
|
+
return date.getMonth() === month - 1 ? date : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The day a stored value stands for, as a local `Date`, or `undefined` for an
|
|
39
|
+
* empty or unreadable one. Reads a `Date`, a `YYYY-MM-DD` string as that day
|
|
40
|
+
* in local time, and any other string `Date` parses, such as an ISO 8601
|
|
41
|
+
* timestamp.
|
|
42
|
+
*/
|
|
43
|
+
function parseDateValue(value: unknown): Date | undefined {
|
|
44
|
+
let date: Date | undefined
|
|
45
|
+
if (value instanceof Date) {
|
|
46
|
+
date = value
|
|
47
|
+
} else if (typeof value === "string" && value !== "") {
|
|
48
|
+
date = ISO_DATE.test(value) ? parseIsoDate(value) : new Date(value)
|
|
49
|
+
}
|
|
50
|
+
return date && !Number.isNaN(date.getTime()) ? date : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The day of a local `Date` as `YYYY-MM-DD`, without going through UTC. */
|
|
54
|
+
function formatIsoDate(date: Date): string {
|
|
55
|
+
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The value to store for `date`, a day picked in local time. */
|
|
59
|
+
function toDateValue(date: Date, type: DateValueType): Date | string {
|
|
60
|
+
if (type === "iso-date") {
|
|
61
|
+
return formatIsoDate(date)
|
|
62
|
+
}
|
|
63
|
+
if (type === "iso-datetime") {
|
|
64
|
+
return date.toISOString()
|
|
65
|
+
}
|
|
66
|
+
return date
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type { DateValueType }
|
|
70
|
+
export { formatIsoDate, parseDateValue, toDateValue }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The props a component renders with: `props` over `defaults`. A prop passed
|
|
3
|
+
* as `undefined` keeps its default, as a destructuring default would.
|
|
4
|
+
*/
|
|
5
|
+
export function withDefaultProps<P extends object>(
|
|
6
|
+
defaults: Partial<P> | undefined,
|
|
7
|
+
props: P
|
|
8
|
+
): P {
|
|
9
|
+
if (defaults === undefined) {
|
|
10
|
+
return props
|
|
11
|
+
}
|
|
12
|
+
const merged: Record<string, unknown> = { ...defaults }
|
|
13
|
+
for (const [key, value] of Object.entries(props)) {
|
|
14
|
+
if (value !== undefined) {
|
|
15
|
+
merged[key] = value
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return merged as P
|
|
19
|
+
}
|
|
@@ -69,4 +69,23 @@ describe("withEmptyStartValues", () => {
|
|
|
69
69
|
ownerId: null,
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
test("starts an object the defaults only partly fill at its empty value, when it can be absent", () => {
|
|
74
|
+
const reference = z.object({ id: z.uuid(), table: z.string() })
|
|
75
|
+
const schema = z.object({
|
|
76
|
+
address: z.object({ city: z.string(), street: z.string() }).nullable(),
|
|
77
|
+
owner: reference,
|
|
78
|
+
payee: reference.optional(),
|
|
79
|
+
payer: reference.nullable(),
|
|
80
|
+
})
|
|
81
|
+
const start = withEmptyStartValues(schema, extractZodDefaults(schema))
|
|
82
|
+
expect(start).toEqual({
|
|
83
|
+
// Valid as filled, so its inputs stay controlled.
|
|
84
|
+
address: { city: "", street: "" },
|
|
85
|
+
// Required: its own fields still report what is missing.
|
|
86
|
+
owner: { id: "", table: "" },
|
|
87
|
+
payee: undefined,
|
|
88
|
+
payer: null,
|
|
89
|
+
})
|
|
90
|
+
})
|
|
72
91
|
})
|
package/src/lib/empty-value.ts
CHANGED
|
@@ -89,21 +89,65 @@ function isTypeEmpty(value: unknown): boolean {
|
|
|
89
89
|
return value === "" || (Array.isArray(value) && value.length === 0)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function isUnder(name: string, prefix: string): boolean {
|
|
93
|
+
return (
|
|
94
|
+
name === prefix ||
|
|
95
|
+
name.startsWith(`${prefix}.`) ||
|
|
96
|
+
name.startsWith(`${prefix}[`)
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The outermost object above `keys` that the defaults fill and the schema
|
|
101
|
+
// lets be absent, with its empty value (`null` or `undefined`).
|
|
102
|
+
function emptiableAncestor(
|
|
103
|
+
schema: StandardSchemaLike,
|
|
104
|
+
values: unknown,
|
|
105
|
+
keys: readonly PropertyKey[]
|
|
106
|
+
): { keys: readonly PropertyKey[]; value: unknown } | null {
|
|
107
|
+
for (let length = 1; length < keys.length; length += 1) {
|
|
108
|
+
const prefix = keys.slice(0, length)
|
|
109
|
+
const start = valueAt(values, prefix)
|
|
110
|
+
if (typeof start !== "object" || start === null || Array.isArray(start)) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const empty = emptyValueFor(schema, values, toFieldName(prefix), start)
|
|
114
|
+
if (empty === null || empty === undefined) {
|
|
115
|
+
return { keys: prefix, value: empty }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
// `extractZodDefaults` starts every string at `""` and every array at `[]`.
|
|
93
122
|
// Where the schema rejects that (`z.uuid().nullable()`,
|
|
94
123
|
// `z.email().optional()`), start the field at its empty value instead, so an
|
|
95
124
|
// untouched optional field passes. A required one keeps `""` or `[]`.
|
|
125
|
+
//
|
|
126
|
+
// An object the defaults only partly fill (`{ table: "" }` for
|
|
127
|
+
// `z.object({ table: z.string(), id: z.number() }).nullable()`, a combobox's
|
|
128
|
+
// value) would block submit on a path no field shows: start it at its empty
|
|
129
|
+
// value too, when the schema lets it be absent.
|
|
96
130
|
function withEmptyStartValues(
|
|
97
131
|
schema: StandardSchemaLike,
|
|
98
132
|
defaults: Record<string, unknown>
|
|
99
133
|
): Record<string, unknown> {
|
|
100
134
|
const issues = validateSync(schema, defaults)
|
|
101
135
|
let result: unknown = defaults
|
|
136
|
+
const emptied: string[] = []
|
|
102
137
|
for (const issue of issues ?? []) {
|
|
103
138
|
const name = toFieldName(issue.path)
|
|
104
139
|
const keys = toPathKeys(name)
|
|
140
|
+
if (keys.length === 0 || emptied.some((prefix) => isUnder(name, prefix))) {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const ancestor = emptiableAncestor(schema, result, keys)
|
|
144
|
+
if (ancestor) {
|
|
145
|
+
result = withValueAt(result, ancestor.keys, ancestor.value)
|
|
146
|
+
emptied.push(toFieldName(ancestor.keys))
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
105
149
|
const start = valueAt(defaults, keys)
|
|
106
|
-
if (
|
|
150
|
+
if (!isTypeEmpty(start)) {
|
|
107
151
|
continue
|
|
108
152
|
}
|
|
109
153
|
const empty = emptyValueFor(schema, result, name, start)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from "react"
|
|
11
|
+
|
|
12
|
+
import { formContext } from "./contexts"
|
|
13
|
+
|
|
14
|
+
// `useForm({ disabled })`, per form. Keyed by `form.store`, as the rest of the
|
|
15
|
+
// per-form Zeno state is (`validation-modes.ts`), and read through
|
|
16
|
+
// `useSyncExternalStore`, so a toggle re-renders every field, a memoized one
|
|
17
|
+
// or one with no provider above it included.
|
|
18
|
+
const disabledOption = new WeakMap<object, boolean>()
|
|
19
|
+
const listeners = new WeakMap<object, Set<() => void>>()
|
|
20
|
+
|
|
21
|
+
function subscribe(key: object, listener: () => void): () => void {
|
|
22
|
+
let set = listeners.get(key)
|
|
23
|
+
if (!set) {
|
|
24
|
+
set = new Set()
|
|
25
|
+
listeners.set(key, set)
|
|
26
|
+
}
|
|
27
|
+
set.add(listener)
|
|
28
|
+
return () => {
|
|
29
|
+
set.delete(listener)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Apply `useForm({ disabled })` to `form`. */
|
|
34
|
+
function useDisabledOption(form: AnyFormApi, disabled: boolean): void {
|
|
35
|
+
const key = form.store
|
|
36
|
+
// Seeded while rendering, so the first render, the server's included,
|
|
37
|
+
// already has it. A change lands on commit, so a render React drops (a
|
|
38
|
+
// suspended transition) never reaches the fields.
|
|
39
|
+
if (!disabledOption.has(key)) {
|
|
40
|
+
disabledOption.set(key, disabled)
|
|
41
|
+
}
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
if (disabledOption.get(key) === disabled) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
disabledOption.set(key, disabled)
|
|
47
|
+
for (const listener of listeners.get(key) ?? []) {
|
|
48
|
+
listener()
|
|
49
|
+
}
|
|
50
|
+
}, [key, disabled])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type FormDisabledScope = {
|
|
54
|
+
readonly disabled: boolean
|
|
55
|
+
/** The `store` of the form it disables. */
|
|
56
|
+
readonly key: object
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The disabled state `FormProvider` and `<Form>` give what they wrap, for
|
|
61
|
+
* their own form only: a field of another form rendered inside (a quick-add
|
|
62
|
+
* popover) keeps that form's state.
|
|
63
|
+
*/
|
|
64
|
+
const FormDisabledContext = createContext<FormDisabledScope | null>(null)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the form is disabled, by `useForm({ disabled })`,
|
|
68
|
+
* `<FormProvider disabled>`, `<Form disabled>` or `FormDialog`'s `disabled`.
|
|
69
|
+
* Every kit field reads it, so a control the fieldset of a disabled dialog
|
|
70
|
+
* can't reach (a checkbox, a switch, a radio, a slider) turns off too.
|
|
71
|
+
*
|
|
72
|
+
* Reads the form of the nearest provider; inside a field, pass `field.form`.
|
|
73
|
+
* `useForm({ disabled })` applies even where no provider of the form is
|
|
74
|
+
* mounted.
|
|
75
|
+
*/
|
|
76
|
+
function useIsFormDisabled(form?: AnyFormApi): boolean {
|
|
77
|
+
const scope = useContext(FormDisabledContext)
|
|
78
|
+
const contextForm = useContext(formContext) as AnyFormApi | null
|
|
79
|
+
const key = (form ?? contextForm)?.store
|
|
80
|
+
const subscribeToKey = useCallback(
|
|
81
|
+
(listener: () => void) => (key ? subscribe(key, listener) : () => null),
|
|
82
|
+
[key]
|
|
83
|
+
)
|
|
84
|
+
const getSnapshot = () => (key ? (disabledOption.get(key) ?? false) : false)
|
|
85
|
+
const byOption = useSyncExternalStore(
|
|
86
|
+
subscribeToKey,
|
|
87
|
+
getSnapshot,
|
|
88
|
+
getSnapshot
|
|
89
|
+
)
|
|
90
|
+
const byScope =
|
|
91
|
+
scope?.disabled === true && (key === undefined || scope.key === key)
|
|
92
|
+
return byScope || byOption
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { FormDisabledContext, useDisabledOption, useIsFormDisabled }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { valueKey } from "./item-options"
|
|
4
|
+
|
|
5
|
+
describe("valueKey", () => {
|
|
6
|
+
test("keeps values that print alike apart, so React keys don't collide", () => {
|
|
7
|
+
expect(valueKey(1)).not.toBe(valueKey("1"))
|
|
8
|
+
expect(valueKey(null)).not.toBe(valueKey("null"))
|
|
9
|
+
expect(valueKey({ id: 1 })).not.toBe(valueKey('{"id":1}'))
|
|
10
|
+
})
|
|
11
|
+
})
|