@zeno-lib/forms 0.1.0 → 0.2.1
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 +118 -0
- package/dist/create-zeno-form.mjs +121 -0
- package/dist/form-element.d.mts +13 -0
- package/dist/form-element.mjs +31 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +10 -0
- package/dist/lib/action-result.d.mts +22 -0
- package/dist/lib/action-result.mjs +32 -0
- package/dist/lib/apply-validation-error.d.mts +6 -0
- package/dist/lib/apply-validation-error.mjs +41 -0
- package/dist/lib/aria.d.mts +4 -0
- package/dist/lib/aria.mjs +7 -0
- package/dist/lib/contexts.d.mts +5 -0
- package/dist/lib/contexts.mjs +7 -0
- package/dist/lib/formatted-number.d.mts +56 -0
- package/dist/lib/formatted-number.mjs +138 -0
- package/dist/lib/schema-defaults.d.mts +17 -0
- package/dist/lib/schema-defaults.mjs +75 -0
- package/dist/lib/schema-required.d.mts +25 -0
- package/dist/lib/schema-required.mjs +72 -0
- package/dist/lib/submit-action.d.mts +64 -0
- package/dist/lib/submit-action.mjs +43 -0
- package/dist/lib/use-form-dialog.d.mts +73 -0
- package/dist/lib/use-form-dialog.mjs +90 -0
- package/dist/lib/use-formatted-number.d.mts +61 -0
- package/dist/lib/use-formatted-number.mjs +104 -0
- package/dist/lib/use-is-invalid.d.mts +7 -0
- package/dist/lib/use-is-invalid.mjs +31 -0
- package/dist/lib/use-rebased-default-values.d.mts +10 -0
- package/dist/lib/use-rebased-default-values.mjs +21 -0
- package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
- package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
- package/dist/lib/validation-error.d.mts +12 -0
- package/dist/lib/validation-error.mjs +11 -0
- package/dist/lib/validation-logic.d.mts +5 -0
- package/dist/lib/validation-logic.mjs +114 -0
- package/dist/lib/validation-modes.d.mts +16 -0
- package/dist/lib/validation-modes.mjs +38 -0
- package/dist/tanstack.d.mts +1 -0
- package/dist/tanstack.mjs +2 -0
- package/package.json +25 -7
- package/src/create-form.tsx +10 -0
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/index.ts +5 -0
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +13 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +15 -4
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { FormProvider } from "@zeno-lib/forms"
|
|
4
|
+
import {
|
|
5
|
+
type FormDialogController,
|
|
6
|
+
findFieldElement,
|
|
7
|
+
useLeaveGuard,
|
|
8
|
+
} from "@zeno-lib/forms/lib/use-form-dialog"
|
|
9
|
+
import { useUnsavedChangesWarning } from "@zeno-lib/forms/lib/use-unsaved-changes-warning"
|
|
10
|
+
import { type AnyFormApi, useSelector } from "@zeno-lib/forms/tanstack"
|
|
11
|
+
import {
|
|
12
|
+
type FormEvent,
|
|
13
|
+
type ReactElement,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
useId,
|
|
16
|
+
useLayoutEffect,
|
|
17
|
+
useRef,
|
|
18
|
+
} from "react"
|
|
19
|
+
import {
|
|
20
|
+
AlertDialog,
|
|
21
|
+
AlertDialogAction,
|
|
22
|
+
AlertDialogCancel,
|
|
23
|
+
AlertDialogContent,
|
|
24
|
+
AlertDialogDescription,
|
|
25
|
+
AlertDialogFooter,
|
|
26
|
+
AlertDialogHeader,
|
|
27
|
+
AlertDialogTitle,
|
|
28
|
+
} from "@/components/ui/alert-dialog"
|
|
29
|
+
import { Button } from "@/components/ui/button"
|
|
30
|
+
import {
|
|
31
|
+
Dialog,
|
|
32
|
+
DialogClose,
|
|
33
|
+
DialogContent,
|
|
34
|
+
DialogDescription,
|
|
35
|
+
DialogFooter,
|
|
36
|
+
DialogHeader,
|
|
37
|
+
DialogTitle,
|
|
38
|
+
DialogTrigger,
|
|
39
|
+
} from "@/components/ui/dialog"
|
|
40
|
+
import { Spinner } from "@/components/ui/spinner"
|
|
41
|
+
|
|
42
|
+
type DiscardPromptText = {
|
|
43
|
+
title?: ReactNode
|
|
44
|
+
description?: ReactNode
|
|
45
|
+
confirmLabel?: ReactNode
|
|
46
|
+
cancelLabel?: ReactNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The slice of the `useForm()` API the dialog uses. Structural, so any Zeno /
|
|
50
|
+
// TanStack form fits whatever its validator and submit-meta generics are.
|
|
51
|
+
type DialogFormState = {
|
|
52
|
+
isDefaultValue: boolean
|
|
53
|
+
isSubmitSuccessful: boolean
|
|
54
|
+
isSubmitting: boolean
|
|
55
|
+
isValid: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type DialogForm<TValues> = {
|
|
59
|
+
store: {
|
|
60
|
+
get: () => DialogFormState
|
|
61
|
+
subscribe: (listener: (state: DialogFormState) => void) => {
|
|
62
|
+
unsubscribe: () => void
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
readonly state: DialogFormState
|
|
66
|
+
handleSubmit(): unknown
|
|
67
|
+
reset(values?: TValues): void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type FormDialogProps<TValues> = {
|
|
71
|
+
/** The controller from `useFormDialog()`. */
|
|
72
|
+
dialog: FormDialogController<TValues>
|
|
73
|
+
/** The form from `useForm()`; its `onSubmit` runs on Save. */
|
|
74
|
+
form: DialogForm<TValues>
|
|
75
|
+
title: ReactNode
|
|
76
|
+
description?: ReactNode
|
|
77
|
+
/** The fields. Rendered inside the `<form>`. */
|
|
78
|
+
children: ReactNode
|
|
79
|
+
/** Element that opens the dialog (with the hook's defaults) on click. */
|
|
80
|
+
trigger?: ReactElement
|
|
81
|
+
/** Defaults to `"Save"`. */
|
|
82
|
+
submitLabel?: ReactNode
|
|
83
|
+
/** Defaults to `"Cancel"`. */
|
|
84
|
+
cancelLabel?: ReactNode
|
|
85
|
+
/** Extra footer content, rendered before Cancel / Save. */
|
|
86
|
+
footer?: ReactNode
|
|
87
|
+
/** Close after a successful submit. Defaults to `true`. */
|
|
88
|
+
closeOnSubmit?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* Ask before closing with unsaved changes, and warn on page unload while
|
|
91
|
+
* open. Defaults to `true`.
|
|
92
|
+
*/
|
|
93
|
+
guard?: boolean
|
|
94
|
+
/** Copy for the discard prompt. */
|
|
95
|
+
discardPrompt?: DiscardPromptText
|
|
96
|
+
/** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
|
|
97
|
+
className?: string
|
|
98
|
+
/** Class for the `<form>` element. */
|
|
99
|
+
formClassName?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A dialog-hosted form: opens with fresh values per session, submits from a
|
|
104
|
+
* footer button outside the `<form>` (via `form="id"`), shows a spinner while
|
|
105
|
+
* submitting, closes on success, and asks before discarding unsaved changes
|
|
106
|
+
* (Cancel, ×, Escape, outside press) or leaving the page.
|
|
107
|
+
*/
|
|
108
|
+
function FormDialog<TValues>({
|
|
109
|
+
cancelLabel = "Cancel",
|
|
110
|
+
children,
|
|
111
|
+
className,
|
|
112
|
+
closeOnSubmit = true,
|
|
113
|
+
description,
|
|
114
|
+
dialog,
|
|
115
|
+
discardPrompt,
|
|
116
|
+
footer,
|
|
117
|
+
form,
|
|
118
|
+
formClassName,
|
|
119
|
+
guard = true,
|
|
120
|
+
submitLabel = "Save",
|
|
121
|
+
title,
|
|
122
|
+
trigger,
|
|
123
|
+
}: FormDialogProps<TValues>) {
|
|
124
|
+
const formId = useId()
|
|
125
|
+
const popupRef = useRef<HTMLDivElement>(null)
|
|
126
|
+
const { close, defaultValues, focus, isOpen, open, session } = dialog
|
|
127
|
+
|
|
128
|
+
// `isDefaultValue`, not `isDirty`: `isDirty` stays true after the user
|
|
129
|
+
// reverts an edit, `isDefaultValue` compares the values themselves.
|
|
130
|
+
const hasChanges = useSelector(form.store, (state) => !state.isDefaultValue)
|
|
131
|
+
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
|
|
132
|
+
const { cancelLeave, confirmLeave, isConfirming, requestLeave } =
|
|
133
|
+
useLeaveGuard({ hasUnsavedChanges: guard && hasChanges })
|
|
134
|
+
useUnsavedChangesWarning(
|
|
135
|
+
form as unknown as AnyFormApi,
|
|
136
|
+
guard && isOpen ? "if-changed" : false
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Start every session from its own defaults. `reset(values)` also rebases
|
|
140
|
+
// the form's defaults, so the session's values count as "unchanged".
|
|
141
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: once per session.
|
|
142
|
+
useLayoutEffect(() => {
|
|
143
|
+
if (session > 0) {
|
|
144
|
+
form.reset(defaultValues)
|
|
145
|
+
}
|
|
146
|
+
}, [session])
|
|
147
|
+
|
|
148
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
event.stopPropagation()
|
|
151
|
+
try {
|
|
152
|
+
await form.handleSubmit()
|
|
153
|
+
} catch {
|
|
154
|
+
return // the submit handler threw; keep the dialog open
|
|
155
|
+
}
|
|
156
|
+
const { isSubmitSuccessful, isValid } = form.state
|
|
157
|
+
if (closeOnSubmit && isSubmitSuccessful && isValid) {
|
|
158
|
+
// Close directly, past the guard: the changes are saved. The form is
|
|
159
|
+
// reset once the exit animation completes (see `onOpenChangeComplete`).
|
|
160
|
+
close()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Dialog
|
|
166
|
+
onOpenChange={(next) => {
|
|
167
|
+
if (next) {
|
|
168
|
+
open()
|
|
169
|
+
} else {
|
|
170
|
+
requestLeave(close)
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
onOpenChangeComplete={(opened) => {
|
|
174
|
+
// Drop abandoned edits once the close animation is done, so the
|
|
175
|
+
// fields don't visibly snap back while fading out.
|
|
176
|
+
if (!opened) {
|
|
177
|
+
form.reset()
|
|
178
|
+
}
|
|
179
|
+
}}
|
|
180
|
+
open={isOpen}
|
|
181
|
+
>
|
|
182
|
+
{trigger && <DialogTrigger render={trigger} />}
|
|
183
|
+
<DialogContent
|
|
184
|
+
className={className}
|
|
185
|
+
initialFocus={() => {
|
|
186
|
+
const target = focus
|
|
187
|
+
? findFieldElement(popupRef.current, focus)
|
|
188
|
+
: null
|
|
189
|
+
return target ?? true
|
|
190
|
+
}}
|
|
191
|
+
ref={popupRef}
|
|
192
|
+
>
|
|
193
|
+
<DialogHeader>
|
|
194
|
+
<DialogTitle>{title}</DialogTitle>
|
|
195
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
196
|
+
</DialogHeader>
|
|
197
|
+
<FormProvider form={form}>
|
|
198
|
+
<form
|
|
199
|
+
className={formClassName}
|
|
200
|
+
id={formId}
|
|
201
|
+
noValidate
|
|
202
|
+
onSubmit={handleSubmit}
|
|
203
|
+
>
|
|
204
|
+
{children}
|
|
205
|
+
</form>
|
|
206
|
+
</FormProvider>
|
|
207
|
+
<DialogFooter>
|
|
208
|
+
{footer}
|
|
209
|
+
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
210
|
+
{cancelLabel}
|
|
211
|
+
</DialogClose>
|
|
212
|
+
<Button disabled={isSubmitting} form={formId} type="submit">
|
|
213
|
+
{isSubmitting && <Spinner />}
|
|
214
|
+
{submitLabel}
|
|
215
|
+
</Button>
|
|
216
|
+
</DialogFooter>
|
|
217
|
+
</DialogContent>
|
|
218
|
+
{/* Inside the Dialog root (so it nests over the form) but outside the
|
|
219
|
+
popup (so it can finish its own exit animation). */}
|
|
220
|
+
<AlertDialog
|
|
221
|
+
onOpenChange={(next) => {
|
|
222
|
+
if (!next) {
|
|
223
|
+
cancelLeave()
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
open={isConfirming}
|
|
227
|
+
>
|
|
228
|
+
<AlertDialogContent size="sm">
|
|
229
|
+
<AlertDialogHeader>
|
|
230
|
+
<AlertDialogTitle>
|
|
231
|
+
{discardPrompt?.title ?? "Discard changes?"}
|
|
232
|
+
</AlertDialogTitle>
|
|
233
|
+
<AlertDialogDescription>
|
|
234
|
+
{discardPrompt?.description ??
|
|
235
|
+
"Your unsaved changes will be lost."}
|
|
236
|
+
</AlertDialogDescription>
|
|
237
|
+
</AlertDialogHeader>
|
|
238
|
+
<AlertDialogFooter>
|
|
239
|
+
<AlertDialogCancel>
|
|
240
|
+
{discardPrompt?.cancelLabel ?? "Keep editing"}
|
|
241
|
+
</AlertDialogCancel>
|
|
242
|
+
<AlertDialogAction onClick={confirmLeave} variant="destructive">
|
|
243
|
+
{discardPrompt?.confirmLabel ?? "Discard"}
|
|
244
|
+
</AlertDialogAction>
|
|
245
|
+
</AlertDialogFooter>
|
|
246
|
+
</AlertDialogContent>
|
|
247
|
+
</AlertDialog>
|
|
248
|
+
</Dialog>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type { DialogForm, DiscardPromptText, FormDialogProps }
|
|
253
|
+
export { FormDialog }
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
cleanup,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
screen,
|
|
7
|
+
} from "@zeno-lib/test/testing-library"
|
|
8
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
9
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
10
|
+
import { z } from "zod"
|
|
11
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
12
|
+
import { getNumberSeparators } from "./lib/formatted-number"
|
|
13
|
+
|
|
14
|
+
const AMOUNT = /Amount/
|
|
15
|
+
const RATE = /Rate/
|
|
16
|
+
const YEAR = /Year/
|
|
17
|
+
const CH = getNumberSeparators("de-CH").group
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
type Captured = { current: ReturnType<typeof useAmountForm> | null }
|
|
24
|
+
|
|
25
|
+
const AMOUNT_DEFAULTS = { amount: null as number | null }
|
|
26
|
+
|
|
27
|
+
function useAmountForm(onSubmit = vi.fn()) {
|
|
28
|
+
return useForm({ defaultValues: AMOUNT_DEFAULTS, onSubmit })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function MoneyHarness({
|
|
32
|
+
captured,
|
|
33
|
+
locale = "de-CH",
|
|
34
|
+
currency = "CHF",
|
|
35
|
+
}: {
|
|
36
|
+
captured?: Captured
|
|
37
|
+
locale?: string
|
|
38
|
+
currency?: string
|
|
39
|
+
}) {
|
|
40
|
+
const form = useAmountForm()
|
|
41
|
+
if (captured) {
|
|
42
|
+
captured.current = form
|
|
43
|
+
}
|
|
44
|
+
const { MoneyField } = form
|
|
45
|
+
return (
|
|
46
|
+
<FormProvider form={form}>
|
|
47
|
+
<Form>
|
|
48
|
+
<MoneyField
|
|
49
|
+
currency={currency}
|
|
50
|
+
label="Amount"
|
|
51
|
+
locale={locale}
|
|
52
|
+
name="amount"
|
|
53
|
+
/>
|
|
54
|
+
</Form>
|
|
55
|
+
</FormProvider>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("MoneyField", () => {
|
|
60
|
+
test("formats with thousands separators while typing and stores a number", async () => {
|
|
61
|
+
const user = userEvent.setup()
|
|
62
|
+
const captured: Captured = { current: null }
|
|
63
|
+
render(<MoneyHarness captured={captured} />)
|
|
64
|
+
const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
|
|
65
|
+
expect(input.inputMode).toBe("decimal")
|
|
66
|
+
expect(input.type).toBe("text")
|
|
67
|
+
|
|
68
|
+
await user.type(input, "1234567.5")
|
|
69
|
+
expect(input.value).toBe(`1${CH}234${CH}567.5`)
|
|
70
|
+
expect(captured.current?.state.values.amount).toBe(1_234_567.5)
|
|
71
|
+
|
|
72
|
+
await user.tab()
|
|
73
|
+
expect(input.value).toBe(`1${CH}234${CH}567.50`)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("shows the currency as an add-on on the locale's side", () => {
|
|
77
|
+
render(<MoneyHarness />)
|
|
78
|
+
const addon = screen.getByText("CHF")
|
|
79
|
+
const group = addon.closest("[data-slot=input-group-addon]")
|
|
80
|
+
expect(group?.getAttribute("data-align")).toBe("inline-start")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("parses pasted text from another locale", () => {
|
|
84
|
+
const captured: Captured = { current: null }
|
|
85
|
+
render(<MoneyHarness captured={captured} />)
|
|
86
|
+
const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
|
|
87
|
+
fireEvent.paste(input, {
|
|
88
|
+
clipboardData: { getData: () => "EUR 1.234,56" },
|
|
89
|
+
})
|
|
90
|
+
expect(captured.current?.state.values.amount).toBe(1234.56)
|
|
91
|
+
expect(input.value).toBe(`1${CH}234.56`)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("clearing the input stores null", async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
const captured: Captured = { current: null }
|
|
97
|
+
render(<MoneyHarness captured={captured} />)
|
|
98
|
+
const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
|
|
99
|
+
await user.type(input, "12")
|
|
100
|
+
await user.clear(input)
|
|
101
|
+
expect(captured.current?.state.values.amount).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("re-syncs the text when the value changes from outside", async () => {
|
|
105
|
+
const user = userEvent.setup()
|
|
106
|
+
const captured: Captured = { current: null }
|
|
107
|
+
render(<MoneyHarness captured={captured} />)
|
|
108
|
+
const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
|
|
109
|
+
await user.type(input, "5")
|
|
110
|
+
act(() => {
|
|
111
|
+
captured.current?.setFieldValue("amount", 9876)
|
|
112
|
+
})
|
|
113
|
+
console.log(
|
|
114
|
+
"VALS",
|
|
115
|
+
JSON.stringify(captured.current?.state.values),
|
|
116
|
+
input.value
|
|
117
|
+
)
|
|
118
|
+
expect(input.value).toBe(`9${CH}876`)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("rejects letters and keeps integers bare on blur", async () => {
|
|
122
|
+
const user = userEvent.setup()
|
|
123
|
+
render(<MoneyHarness />)
|
|
124
|
+
const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
|
|
125
|
+
await user.type(input, "12a3")
|
|
126
|
+
expect(input.value).toBe("123")
|
|
127
|
+
await user.tab()
|
|
128
|
+
expect(input.value).toBe("123")
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe("PercentageField", () => {
|
|
133
|
+
function PercentHarness({
|
|
134
|
+
captured,
|
|
135
|
+
scale,
|
|
136
|
+
}: {
|
|
137
|
+
captured: { current: number | null | undefined }
|
|
138
|
+
scale?: "fraction" | "percent"
|
|
139
|
+
}) {
|
|
140
|
+
const form = useForm({
|
|
141
|
+
defaultValues: { rate: 0.05 as number | null },
|
|
142
|
+
onSubmit: vi.fn(),
|
|
143
|
+
})
|
|
144
|
+
const { PercentageField, Subscribe } = form
|
|
145
|
+
return (
|
|
146
|
+
<FormProvider form={form}>
|
|
147
|
+
<Form>
|
|
148
|
+
<PercentageField
|
|
149
|
+
label="Rate"
|
|
150
|
+
locale="en-US"
|
|
151
|
+
name="rate"
|
|
152
|
+
scale={scale}
|
|
153
|
+
/>
|
|
154
|
+
<Subscribe selector={(state) => state.values.rate}>
|
|
155
|
+
{(rate) => {
|
|
156
|
+
captured.current = rate
|
|
157
|
+
return null
|
|
158
|
+
}}
|
|
159
|
+
</Subscribe>
|
|
160
|
+
</Form>
|
|
161
|
+
</FormProvider>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("edits a 0–1 fraction as a percentage with scale='fraction'", async () => {
|
|
166
|
+
const user = userEvent.setup()
|
|
167
|
+
const captured = { current: undefined as number | null | undefined }
|
|
168
|
+
render(<PercentHarness captured={captured} scale="fraction" />)
|
|
169
|
+
const input = screen.getByLabelText(RATE) as HTMLInputElement
|
|
170
|
+
expect(input.value).toBe("5")
|
|
171
|
+
expect(screen.getByText("%")).toBeTruthy()
|
|
172
|
+
|
|
173
|
+
await user.clear(input)
|
|
174
|
+
await user.type(input, "12.5")
|
|
175
|
+
expect(captured.current).toBe(0.125)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("stores the typed number by default", async () => {
|
|
179
|
+
const user = userEvent.setup()
|
|
180
|
+
const captured = { current: undefined as number | null | undefined }
|
|
181
|
+
render(<PercentHarness captured={captured} />)
|
|
182
|
+
const input = screen.getByLabelText(RATE) as HTMLInputElement
|
|
183
|
+
await user.clear(input)
|
|
184
|
+
await user.type(input, "7.25")
|
|
185
|
+
expect(captured.current).toBe(7.25)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe("YearField", () => {
|
|
190
|
+
function YearHarness({
|
|
191
|
+
captured,
|
|
192
|
+
}: {
|
|
193
|
+
captured: { current: number | null | undefined }
|
|
194
|
+
}) {
|
|
195
|
+
const form = useForm({
|
|
196
|
+
onSubmit: vi.fn(),
|
|
197
|
+
schema: z.object({ year: z.number().int().nullable() }),
|
|
198
|
+
})
|
|
199
|
+
const { Subscribe, YearField } = form
|
|
200
|
+
return (
|
|
201
|
+
<FormProvider form={form}>
|
|
202
|
+
<Form>
|
|
203
|
+
<YearField label="Year" max={2100} min={1900} name="year" />
|
|
204
|
+
<Subscribe selector={(state) => state.values.year}>
|
|
205
|
+
{(year) => {
|
|
206
|
+
captured.current = year
|
|
207
|
+
return null
|
|
208
|
+
}}
|
|
209
|
+
</Subscribe>
|
|
210
|
+
</Form>
|
|
211
|
+
</FormProvider>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test("caps at four digits without grouping", async () => {
|
|
216
|
+
const user = userEvent.setup()
|
|
217
|
+
const captured = { current: undefined as number | null | undefined }
|
|
218
|
+
render(<YearHarness captured={captured} />)
|
|
219
|
+
const input = screen.getByLabelText(YEAR) as HTMLInputElement
|
|
220
|
+
expect(input.inputMode).toBe("numeric")
|
|
221
|
+
await user.type(input, "20245")
|
|
222
|
+
expect(input.value).toBe("2024")
|
|
223
|
+
expect(captured.current).toBe(2024)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test("clamps to min/max on blur", async () => {
|
|
227
|
+
const user = userEvent.setup()
|
|
228
|
+
const captured = { current: undefined as number | null | undefined }
|
|
229
|
+
render(<YearHarness captured={captured} />)
|
|
230
|
+
const input = screen.getByLabelText(YEAR) as HTMLInputElement
|
|
231
|
+
await user.type(input, "1850")
|
|
232
|
+
await user.tab()
|
|
233
|
+
expect(input.value).toBe("1900")
|
|
234
|
+
expect(captured.current).toBe(1900)
|
|
235
|
+
})
|
|
236
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
export { createZenoForm } from "./create-zeno-form"
|
|
4
4
|
export { Form, FormProvider } from "./form-element"
|
|
5
|
+
export {
|
|
6
|
+
type ActionError,
|
|
7
|
+
type ActionIssue,
|
|
8
|
+
type ActionResult,
|
|
9
|
+
toActionError,
|
|
10
|
+
toFieldName,
|
|
11
|
+
} from "./lib/action-result"
|
|
5
12
|
export { applyValidationError } from "./lib/apply-validation-error"
|
|
6
13
|
export { useFieldContext, useFormContext } from "./lib/contexts"
|
|
14
|
+
export {
|
|
15
|
+
applyActionError,
|
|
16
|
+
type SubmitActionOptions,
|
|
17
|
+
type SubmitActionSchema,
|
|
18
|
+
submitAction,
|
|
19
|
+
} from "./lib/submit-action"
|
|
7
20
|
export {
|
|
8
21
|
useHideFieldErrors,
|
|
9
22
|
useIsFieldRequired,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The result shape a server action returns to a form. Declared here rather
|
|
2
|
+
// than imported so `@zeno-lib/forms` has no dependency on a server package:
|
|
3
|
+
// `defineFormAction` in `@zeno-lib/db/next` returns the same shape, and any
|
|
4
|
+
// action that resolves to it (hand-written or from another library) works
|
|
5
|
+
// with `submitAction`. The types are read-only so a producer's mutable arrays
|
|
6
|
+
// are assignable.
|
|
7
|
+
|
|
8
|
+
type ActionError = {
|
|
9
|
+
// Keyed by TanStack Form field name: `address.city`, `owners[0].percentage`.
|
|
10
|
+
readonly fieldErrors: Readonly<Record<string, readonly string[]>>
|
|
11
|
+
// Messages with no field (a whole-object refinement, a closed record).
|
|
12
|
+
readonly formErrors: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ActionResult<TData> =
|
|
16
|
+
| { readonly ok: true; readonly data: TData }
|
|
17
|
+
| { readonly ok: false; readonly error: ActionError }
|
|
18
|
+
|
|
19
|
+
// A Standard Schema issue, reduced to what the path mapping reads.
|
|
20
|
+
type ActionIssue = {
|
|
21
|
+
readonly message: string
|
|
22
|
+
readonly path?:
|
|
23
|
+
| ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
|
|
24
|
+
| undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `["owners", 0, "percentage"]` → `owners[0].percentage`, the name the field
|
|
28
|
+
// was registered under. An empty path yields `""`.
|
|
29
|
+
function toFieldName(path: ActionIssue["path"]): string {
|
|
30
|
+
let name = ""
|
|
31
|
+
for (const segment of path ?? []) {
|
|
32
|
+
const key = typeof segment === "object" ? segment.key : segment
|
|
33
|
+
if (typeof key === "number") {
|
|
34
|
+
name += `[${key}]`
|
|
35
|
+
} else {
|
|
36
|
+
const part = typeof key === "symbol" ? (key.description ?? "") : key
|
|
37
|
+
name += name === "" ? part : `.${part}`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return name
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Group issues by field name; a path-less issue goes to `formErrors`.
|
|
44
|
+
function toActionError(issues: readonly ActionIssue[]): ActionError {
|
|
45
|
+
const fieldErrors: Record<string, string[]> = {}
|
|
46
|
+
const formErrors: string[] = []
|
|
47
|
+
for (const issue of issues) {
|
|
48
|
+
const name = toFieldName(issue.path)
|
|
49
|
+
if (name === "") {
|
|
50
|
+
formErrors.push(issue.message)
|
|
51
|
+
} else {
|
|
52
|
+
const messages = fieldErrors[name] ?? []
|
|
53
|
+
messages.push(issue.message)
|
|
54
|
+
fieldErrors[name] = messages
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { fieldErrors, formErrors }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type { ActionError, ActionIssue, ActionResult }
|
|
61
|
+
export { toActionError, toFieldName }
|
|
@@ -13,8 +13,15 @@ function makeFormStub() {
|
|
|
13
13
|
(_name: string, _updater: FieldMetaUpdater) => undefined
|
|
14
14
|
)
|
|
15
15
|
const setErrorMap = vi.fn((_map: unknown) => undefined)
|
|
16
|
+
const store = { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }
|
|
16
17
|
return {
|
|
17
|
-
api: {
|
|
18
|
+
api: {
|
|
19
|
+
getFieldMeta: vi.fn(() => undefined),
|
|
20
|
+
getFieldValue: vi.fn(() => undefined),
|
|
21
|
+
setErrorMap,
|
|
22
|
+
setFieldMeta,
|
|
23
|
+
store,
|
|
24
|
+
} as unknown as AnyFormApi,
|
|
18
25
|
setErrorMap,
|
|
19
26
|
setFieldMeta,
|
|
20
27
|
}
|
|
@@ -40,7 +47,7 @@ describe("applyValidationError", () => {
|
|
|
40
47
|
expect(next.isValid).toBe(false)
|
|
41
48
|
})
|
|
42
49
|
|
|
43
|
-
test("array of messages forwards all entries
|
|
50
|
+
test("array of messages forwards all entries, errorMap.onChange included", () => {
|
|
44
51
|
const { api, setFieldMeta } = makeFormStub()
|
|
45
52
|
applyValidationError(
|
|
46
53
|
api,
|
|
@@ -49,10 +56,14 @@ describe("applyValidationError", () => {
|
|
|
49
56
|
const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
|
|
50
57
|
const next = updater({}) as {
|
|
51
58
|
errors: { message: string }[]
|
|
52
|
-
errorMap: { onChange: { message: string } }
|
|
59
|
+
errorMap: { onChange: { message: string }[] }
|
|
53
60
|
}
|
|
54
61
|
expect(next.errors).toEqual([{ message: "short" }, { message: "no digit" }])
|
|
55
|
-
|
|
62
|
+
// TanStack flattens an array entry into `meta.errors`, so every message shows.
|
|
63
|
+
expect(next.errorMap.onChange).toEqual([
|
|
64
|
+
{ message: "short" },
|
|
65
|
+
{ message: "no digit" },
|
|
66
|
+
])
|
|
56
67
|
})
|
|
57
68
|
|
|
58
69
|
test("preserves existing errorMap keys via spread", () => {
|
|
@@ -2,11 +2,41 @@ import type { AnyFormApi } from "@tanstack/react-form"
|
|
|
2
2
|
|
|
3
3
|
import type { ValidationError } from "./validation-error"
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Clear a server message the first time the field's value moves off the value
|
|
6
|
+
// it was rejected with. TanStack Form does not do this on its own: a field
|
|
7
|
+
// with no validators of its own runs nothing on change, and the form-level
|
|
8
|
+
// pass keeps an error it did not write, so the message would otherwise stick,
|
|
9
|
+
// and keep `canSubmit` false, until the form is reset. Only that field is
|
|
10
|
+
// cleared, and only while the entry is still ours: a schema error written over
|
|
11
|
+
// it in the meantime is left alone.
|
|
12
|
+
function clearWhenEdited(
|
|
13
|
+
formApi: AnyFormApi,
|
|
14
|
+
name: string,
|
|
15
|
+
entry: unknown
|
|
16
|
+
): void {
|
|
17
|
+
const rejected = formApi.getFieldValue(name)
|
|
18
|
+
const subscription = formApi.store.subscribe(() => {
|
|
19
|
+
if (formApi.getFieldMeta(name)?.errorMap?.onChange !== entry) {
|
|
20
|
+
subscription.unsubscribe()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (Object.is(formApi.getFieldValue(name), rejected)) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
subscription.unsubscribe()
|
|
27
|
+
formApi.setFieldMeta(name, (prev) => ({
|
|
28
|
+
...prev,
|
|
29
|
+
errorMap: { ...prev.errorMap, onChange: undefined },
|
|
30
|
+
}))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Write per-field messages from a `ValidationError` onto the form. They go in
|
|
35
|
+
// `errorMap.onChange` (an array when there are several, which TanStack
|
|
36
|
+
// flattens into `meta.errors`) and clear as soon as the user edits that
|
|
37
|
+
// field; see `clearWhenEdited`. The form-level message is written to the
|
|
38
|
+
// form's error map under the `onSubmit` cause, which TanStack clears on the
|
|
39
|
+
// next change that validates cleanly.
|
|
10
40
|
function applyValidationError(
|
|
11
41
|
formApi: AnyFormApi,
|
|
12
42
|
error: ValidationError
|
|
@@ -14,12 +44,14 @@ function applyValidationError(
|
|
|
14
44
|
for (const [name, message] of Object.entries(error.fields)) {
|
|
15
45
|
const messages = Array.isArray(message) ? message : [message]
|
|
16
46
|
const errors = messages.map((m) => ({ message: m }))
|
|
47
|
+
const entry = errors.length === 1 ? errors[0] : errors
|
|
17
48
|
formApi.setFieldMeta(name, (prev) => ({
|
|
18
49
|
...prev,
|
|
19
|
-
errorMap: { ...prev.errorMap, onChange:
|
|
50
|
+
errorMap: { ...prev.errorMap, onChange: entry },
|
|
20
51
|
errors,
|
|
21
52
|
isValid: false,
|
|
22
53
|
}))
|
|
54
|
+
clearWhenEdited(formApi, name, entry)
|
|
23
55
|
}
|
|
24
56
|
if (error.formError) {
|
|
25
57
|
formApi.setErrorMap({
|