@kitr/forms 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/index.d.ts +96 -0
- package/dist/index.js +183 -0
- package/dist/kitr.d.ts +28 -0
- package/dist/kitr.js +49 -0
- package/docs/api.md +77 -0
- package/docs/index.md +841 -0
- package/docs/overview.md +95 -0
- package/package.json +68 -3
package/docs/index.md
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Forms
|
|
3
|
+
description: Build forms with validation, and show server errors next to the right field.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Forms
|
|
8
|
+
|
|
9
|
+
Sign-up pages, invoice editors and settings screens all need the same three things: check the input before it is sent, stop a second click while it saves, and show the server's "this customer does not exist" under the right field instead of in a generic alert. `@kitr/forms` gives you all three from one schema and one `<Form>` component.
|
|
10
|
+
|
|
11
|
+
> [!SUMMARY]
|
|
12
|
+
> Forms with schema validation and server errors shown on the right field. Add it with `kitr add forms`; the overview is at [/packages/forms](/packages/forms).
|
|
13
|
+
|
|
14
|
+
It is [React Hook Form](https://react-hook-form.com) re-exported unchanged, plus `useSchemaForm` (with a [Standard Schema](https://standardschema.dev) resolver), a `<Form>` that maps server errors onto fields, and headless, accessible field helpers. It runs in the browser; for the server half of a form see [server actions](/packages/actions) or [API routes](/packages/api).
|
|
15
|
+
|
|
16
|
+
The examples follow one form through the page: Ada creates invoices for Acme's customers in the app at `app.acme.com`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
kitr add forms
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
<details>
|
|
25
|
+
<summary>Everything kitr add changes</summary>
|
|
26
|
+
|
|
27
|
+
What `kitr add` does:
|
|
28
|
+
|
|
29
|
+
- Installs `@kitr/forms`, `react-hook-form`, `@hookform/resolvers` and `zod`.
|
|
30
|
+
- Adds `@kitr/forms` to `packages` in `kitrrc.ts`.
|
|
31
|
+
- Adds a `## kitr: forms` section to `AGENTS.md` that tells agents to build forms with `useSchemaForm` and `<Form>`, and that a `KitrError` with `fieldErrors` lands on fields while anything else goes to `useRootError()`.
|
|
32
|
+
|
|
33
|
+
</details>
|
|
34
|
+
|
|
35
|
+
`kitr add forms` writes no config file and asks no questions. The `app` and `fullstack` kits include it, with the styled `form` component from [Style fields](#style-fields).
|
|
36
|
+
|
|
37
|
+
<details>
|
|
38
|
+
<summary>What kitr remove does</summary>
|
|
39
|
+
|
|
40
|
+
In a server-only project `kitr add` skips forms with a warning, because it needs React. `kitr remove forms` removes the package from `kitrrc.ts`, uninstalls `@kitr/forms`, `react-hook-form` and `@hookform/resolvers`, and removes the `AGENTS.md` section. It leaves `zod` installed, because your server code usually uses it too.
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
## Configure
|
|
45
|
+
|
|
46
|
+
`@kitr/forms` has no config file. Each form is set up where you write it, with `useSchemaForm` and `<Form>`:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// app/components/invoices/new-invoice-form.tsx
|
|
50
|
+
import { Form, useSchemaForm } from '@kitr/forms'
|
|
51
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
52
|
+
|
|
53
|
+
export function NewInvoiceForm({ onCreate }: { onCreate: (values: unknown) => Promise<unknown> }) {
|
|
54
|
+
const form = useSchemaForm(newInvoiceSchema, {
|
|
55
|
+
mode: 'onTouched',
|
|
56
|
+
defaultValues: { customerEmail: '', amount: '', dueOn: '' },
|
|
57
|
+
})
|
|
58
|
+
return (
|
|
59
|
+
<Form form={form} onSubmit={onCreate} resetOnSuccess>
|
|
60
|
+
{/* fields */}
|
|
61
|
+
</Form>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`useSchemaForm(schema, options)` takes every [`useForm` option](https://react-hook-form.com/docs/useform) except `resolver`. The common ones:
|
|
67
|
+
|
|
68
|
+
| Option | Default | Description |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `mode` | `'onTouched'` | When a field is validated first: `'onTouched'` waits until it loses focus. React Hook Form's own default is `'onSubmit'`. |
|
|
71
|
+
| `reValidateMode` | `'onChange'` | When a field that already failed is checked again. |
|
|
72
|
+
| `defaultValues` | `undefined` | The starting values, and what `form.reset()` returns to. Give every field one so inputs stay controlled. |
|
|
73
|
+
| `values` | `undefined` | Values that replace the form's values whenever they change, for forms fed by a query. |
|
|
74
|
+
| `shouldFocusError` | `true` | Focus the first invalid field on submit. |
|
|
75
|
+
| `disabled` | `false` | Disable every registered input. |
|
|
76
|
+
|
|
77
|
+
`<Form>` takes these props. Any other prop goes on the `<form>` element.
|
|
78
|
+
|
|
79
|
+
| Option | Default | Description |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `form` | required | The object `useSchemaForm` (or `useForm`) returned. |
|
|
82
|
+
| `onSubmit` | required | Runs with the validated, transformed values. Throw or reject to report errors. |
|
|
83
|
+
| `onSuccess` | `undefined` | `(result, values) => void`. Runs after `onSubmit` resolves, with what it returned. |
|
|
84
|
+
| `onError` | `undefined` | `(error: KitrError) => void`. Runs after a thrown error has been put on the fields. |
|
|
85
|
+
| `resetOnSuccess` | `false` | Call `form.reset()` after a successful submit, which returns every field to `defaultValues`. |
|
|
86
|
+
| `noValidate` | `true` | Turns off the browser's own validation bubbles so the schema's messages are the only ones. |
|
|
87
|
+
|
|
88
|
+
## Build your first form
|
|
89
|
+
|
|
90
|
+
Start with a schema in its own file, so the server can import the same one.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// app/validators/invoices.ts
|
|
94
|
+
import { z } from 'zod'
|
|
95
|
+
|
|
96
|
+
export const newInvoiceSchema = z.object({
|
|
97
|
+
customerEmail: z.email('Enter the customer’s email'),
|
|
98
|
+
amount: z.coerce.number().positive('Amount must be more than zero'),
|
|
99
|
+
dueOn: z.iso.date('Pick a due date'),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export type NewInvoice = z.output<typeof newInvoiceSchema>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then the form. Each field is a `<FormField>` with an input inside it.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
// app/components/invoices/new-invoice-form.tsx
|
|
109
|
+
import { FieldInput, Form, FormField, SubmitButton, useRootError, useSchemaForm } from '@kitr/forms'
|
|
110
|
+
import { type NewInvoice, newInvoiceSchema } from '#app/validators/invoices'
|
|
111
|
+
|
|
112
|
+
export function NewInvoiceForm({ onCreate }: { onCreate: (invoice: NewInvoice) => Promise<unknown> }) {
|
|
113
|
+
const form = useSchemaForm(newInvoiceSchema, {
|
|
114
|
+
defaultValues: { customerEmail: '', amount: '', dueOn: '' },
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Form form={form} onSubmit={onCreate} resetOnSuccess aria-label="New invoice">
|
|
119
|
+
<FormField name="customerEmail" label="Customer email">
|
|
120
|
+
<FieldInput type="email" autoComplete="off" />
|
|
121
|
+
</FormField>
|
|
122
|
+
<FormField name="amount" label="Amount (USD)">
|
|
123
|
+
<FieldInput inputMode="decimal" />
|
|
124
|
+
</FormField>
|
|
125
|
+
<FormField name="dueOn" label="Due on" description="Customers see this date on the invoice.">
|
|
126
|
+
<FieldInput type="date" />
|
|
127
|
+
</FormField>
|
|
128
|
+
<RootError />
|
|
129
|
+
<SubmitButton pendingText="Creating…">Create invoice</SubmitButton>
|
|
130
|
+
</Form>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function RootError() {
|
|
135
|
+
const error = useRootError()
|
|
136
|
+
return error ? <p role="alert">{error}</p> : null
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Render it from a route and give it something to do on submit:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// app/routes.ts
|
|
144
|
+
import { NewInvoiceForm } from '#app/components/invoices/new-invoice-form'
|
|
145
|
+
|
|
146
|
+
r.page('/invoices/new', {
|
|
147
|
+
<NewInvoiceForm onCreate={async (invoice) => console.log('created', invoice)} />
|
|
148
|
+
),
|
|
149
|
+
lazy: () => import('./pages/invoices/new'),
|
|
150
|
+
})
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
// app/pages/invoices/new.tsx
|
|
155
|
+
export default () => (
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Open `/invoices/new`, tab out of the empty email field and "Enter the customer’s email" appears under it. Fill the form with `grace@acme.com`, `120` and a date, press the button, and the console prints:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
created { customerEmail: 'grace@acme.com', amount: 120, dueOn: '2026-12-01' }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`amount` arrives as the number `120` because `onSubmit` receives the schema's output. While `onCreate` runs, the button reads "Creating…" and is disabled; when it resolves, `resetOnSuccess` clears the form.
|
|
165
|
+
|
|
166
|
+
## Validate with any schema library
|
|
167
|
+
|
|
168
|
+
`useSchemaForm` accepts any [Standard Schema](https://standardschema.dev): zod, [valibot](https://valibot.dev) or [arktype](https://arktype.io).
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
// app/components/invoices/new-invoice-form.tsx
|
|
172
|
+
import * as v from 'valibot'
|
|
173
|
+
import { useSchemaForm } from '@kitr/forms'
|
|
174
|
+
|
|
175
|
+
const newInvoiceSchema = v.object({
|
|
176
|
+
customerEmail: v.pipe(v.string(), v.email('Enter the customer’s email')),
|
|
177
|
+
amount: v.pipe(v.string(), v.transform(Number), v.minValue(0.01, 'Amount must be more than zero')),
|
|
178
|
+
dueOn: v.pipe(v.string(), v.isoDate('Pick a due date')),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
export function useNewInvoiceForm() {
|
|
182
|
+
return useSchemaForm(newInvoiceSchema, {
|
|
183
|
+
defaultValues: { customerEmail: '', amount: '', dueOn: '' },
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The schema's input type is what the fields hold (`amount` is a string); its output type is what `onSubmit` receives (`amount` is a number). Both are inferred, so `form.setValue('amount', 120)` is a type error and `values.amount.toFixed(2)` in `onSubmit` is not. Async checks such as `.refine(async ...)` work too.
|
|
189
|
+
|
|
190
|
+
## Choose when fields are validated
|
|
191
|
+
|
|
192
|
+
`useSchemaForm` defaults to `mode: 'onTouched'`: a field is checked when it first loses focus, then on every change until it passes.
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
// app/components/invoices/quick-invoice-form.tsx
|
|
196
|
+
import { useSchemaForm } from '@kitr/forms'
|
|
197
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
198
|
+
|
|
199
|
+
export function useQuickInvoiceForm() {
|
|
200
|
+
return useSchemaForm(newInvoiceSchema, {
|
|
201
|
+
mode: 'onSubmit',
|
|
202
|
+
reValidateMode: 'onBlur',
|
|
203
|
+
defaultValues: { customerEmail: '', amount: '', dueOn: '' },
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| `mode` | First check happens |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `'onTouched'` | When the field loses focus (kitr's default). |
|
|
211
|
+
| `'onSubmit'` | When the form is submitted (React Hook Form's default). |
|
|
212
|
+
| `'onBlur'` | On every blur. |
|
|
213
|
+
| `'onChange'` | On every keystroke. Costs a schema run per key. |
|
|
214
|
+
| `'all'` | On blur and on change. |
|
|
215
|
+
|
|
216
|
+
Submitting always validates every field, whatever the mode.
|
|
217
|
+
|
|
218
|
+
## Submit to your API
|
|
219
|
+
|
|
220
|
+
`onSubmit` can return any promise. To update the query cache as well, pass a [query](/packages/query) mutation's `mutateAsync`. Here it posts to an external API through [http](/packages/http):
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// app/queries/invoices.ts
|
|
224
|
+
import { mutationFrom, queryFrom } from '@kitr/query'
|
|
225
|
+
import { httpMutation, httpQuery } from '@kitr/http'
|
|
226
|
+
import { app } from '#app/app'
|
|
227
|
+
import type { NewInvoice } from './schema'
|
|
228
|
+
|
|
229
|
+
const http = () => app.container.use('http')
|
|
230
|
+
|
|
231
|
+
export const invoiceQuery = queryFrom(
|
|
232
|
+
httpQuery<string, NewInvoice & { id: string }>(http, {
|
|
233
|
+
key: (id) => ['invoices', 'detail', id],
|
|
234
|
+
path: (id) => `invoices/${id}`,
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
export const createInvoice = mutationFrom(
|
|
239
|
+
httpMutation<NewInvoice, { id: string; number: string }>(http, {
|
|
240
|
+
path: 'invoices',
|
|
241
|
+
effects: { invalidate: [['invoices', 'list']] },
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// app/routes.ts
|
|
248
|
+
r.page('/invoices/new', { lazy: () => import('./pages/invoices/new') })
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
// app/pages/invoices/new.tsx
|
|
253
|
+
import { useMutation } from '@kitr/query'
|
|
254
|
+
import { useNavigate } from '@kitr/router'
|
|
255
|
+
import { NewInvoiceForm } from '#app/components/invoices/new-invoice-form'
|
|
256
|
+
import { createInvoice } from '#app/queries/invoices'
|
|
257
|
+
|
|
258
|
+
export default function NewInvoicePage() {
|
|
259
|
+
const create = useMutation(createInvoice)
|
|
260
|
+
const navigate = useNavigate()
|
|
261
|
+
return (
|
|
262
|
+
<NewInvoiceForm
|
|
263
|
+
onCreate={async (invoice) => {
|
|
264
|
+
const created = await create.mutateAsync(invoice)
|
|
265
|
+
await navigate({ to: '/invoices/$id', params: { id: created.id } })
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
> [!WARNING]
|
|
273
|
+
> Use `mutateAsync`, not `mutate`. `mutate` swallows the error, so `<Form>` never sees the field errors and the button stops pending early.
|
|
274
|
+
|
|
275
|
+
## Submit to a server action
|
|
276
|
+
|
|
277
|
+
In a project with [server actions](/packages/actions), the action validates with the same schema on the server:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
// app/actions/invoices/create_invoice.ts
|
|
281
|
+
import { defineMutation } from '@kitr/actions'
|
|
282
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
283
|
+
|
|
284
|
+
export const createInvoice = defineMutation('invoices.create', {
|
|
285
|
+
input: newInvoiceSchema,
|
|
286
|
+
effects: { invalidate: [['invoices', 'list']] },
|
|
287
|
+
handler: async ({ input }) => {
|
|
288
|
+
// save the invoice
|
|
289
|
+
return { id: 'inv_1042', number: 'ACME-1042', ...input }
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
// app/pages/invoices/new.tsx
|
|
296
|
+
import { useMutation } from '@kitr/query'
|
|
297
|
+
import { NewInvoiceForm } from '#app/components/invoices/new-invoice-form'
|
|
298
|
+
|
|
299
|
+
export default function NewInvoicePage() {
|
|
300
|
+
const create = useMutation('invoices.create')
|
|
301
|
+
return <NewInvoiceForm onCreate={(invoice) => create.mutateAsync(invoice)} />
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`useMutation('invoices.create')` finds the mutation by its `defineMutation` name; `kitr types` types it. If someone bypasses the browser check, the action rejects with `VALIDATION` and the same messages land under the same fields.
|
|
306
|
+
|
|
307
|
+
## Show server errors on fields
|
|
308
|
+
|
|
309
|
+
When `onSubmit` throws or rejects, `<Form>` reads the `KitrError`'s `fieldErrors`: each key is a field path, each value a list of messages, and the first shows under the field:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// what the server sent back (HTTP 422)
|
|
313
|
+
{
|
|
314
|
+
code: 'VALIDATION',
|
|
315
|
+
message: 'Validation failed',
|
|
316
|
+
status: 422,
|
|
317
|
+
fieldErrors: {
|
|
318
|
+
customerEmail: ['No customer with this email'],
|
|
319
|
+
'lines.0.quantity': ['Only 3 left in stock'],
|
|
320
|
+
_root: ['Invoices are locked while the month closes'],
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The rules:
|
|
326
|
+
|
|
327
|
+
- A key whose first segment is a field in the form (`customerEmail`, or `lines` for `lines.0.quantity`) goes on that field.
|
|
328
|
+
- `_root`, and any key the form does not have (`plan`, `billing.vatId` when there is no `billing` field), goes to the root error, which `useRootError()` returns.
|
|
329
|
+
- An error without `fieldErrors` (a `CONFLICT`, a `NETWORK` failure, a 500) puts its `message` on the root error.
|
|
330
|
+
- Only the first message per field is shown.
|
|
331
|
+
|
|
332
|
+
[API routes](/packages/api) and [server actions](/packages/actions) answer failed validation with `fieldErrors` already, and [http](/packages/http) converts common backend formats.
|
|
333
|
+
|
|
334
|
+
## Throw field errors from the server
|
|
335
|
+
|
|
336
|
+
For a rule the schema cannot check, such as "this customer exists", throw `KitrError.validation` with the field it belongs to:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
// app/actions/invoices/create_invoice.ts
|
|
340
|
+
import { defineMutation } from '@kitr/actions'
|
|
341
|
+
import { KitrError } from '@kitr/kitr/app'
|
|
342
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
343
|
+
|
|
344
|
+
const customers = new Set(['grace@acme.com', 'linus@acme.com'])
|
|
345
|
+
|
|
346
|
+
export const createInvoice = defineMutation('invoices.create', {
|
|
347
|
+
input: newInvoiceSchema,
|
|
348
|
+
handler: async ({ input }) => {
|
|
349
|
+
if (!customers.has(input.customerEmail)) {
|
|
350
|
+
throw KitrError.validation({ customerEmail: ['No customer with this email'] })
|
|
351
|
+
}
|
|
352
|
+
return { id: 'inv_1042', number: 'ACME-1042', ...input }
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Ada sees "No customer with this email" under the email field. A message for the whole form goes under `_root`:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
throw KitrError.validation({ _root: ['Invoices are locked while the month closes'] })
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Nested fields use dot paths: `'billing.vatId'` or `'lines.2.quantity'`.
|
|
364
|
+
|
|
365
|
+
## Show a form-level error
|
|
366
|
+
|
|
367
|
+
`useRootError()` returns the message of errors that are not about one field, or `undefined`. Call it inside `<Form>`:
|
|
368
|
+
|
|
369
|
+
```tsx
|
|
370
|
+
// app/components/invoices/form-alert.tsx
|
|
371
|
+
import { useRootError } from '@kitr/forms'
|
|
372
|
+
|
|
373
|
+
export function FormAlert() {
|
|
374
|
+
const error = useRootError()
|
|
375
|
+
if (!error) return null
|
|
376
|
+
return (
|
|
377
|
+
<p role="alert" className="rounded-md border border-destructive/40 p-3 text-destructive">
|
|
378
|
+
{error}
|
|
379
|
+
</p>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The root error clears on each submit. A dropped connection shows "Network request failed"; a 500 in production shows "Internal server error" and keeps the real message in the server log.
|
|
385
|
+
|
|
386
|
+
## Show a pending state
|
|
387
|
+
|
|
388
|
+
`<SubmitButton>` is a `type="submit"` button that is disabled and has `aria-busy="true"` while `onSubmit` runs. `pendingText` replaces its label in that time:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
<SubmitButton pendingText="Creating…" className="btn-primary">
|
|
392
|
+
Create invoice
|
|
393
|
+
</SubmitButton>
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
A `disabled` prop you pass is combined with the pending state. For anything else that reacts to the submit, call `useFormStatus()` inside `<Form>`:
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
// app/components/invoices/cancel-link.tsx
|
|
400
|
+
import { useFormStatus } from '@kitr/forms'
|
|
401
|
+
import { Link } from '@kitr/router'
|
|
402
|
+
|
|
403
|
+
export function CancelLink() {
|
|
404
|
+
const { pending, isValid } = useFormStatus()
|
|
405
|
+
return (
|
|
406
|
+
<Link to="/invoices" disabled={pending} data-ready={isValid || undefined}>
|
|
407
|
+
Cancel
|
|
408
|
+
</Link>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`pending` is true while `onSubmit` runs; `isValid` is React Hook Form's `formState.isValid`.
|
|
414
|
+
|
|
415
|
+
## Run code after a submit
|
|
416
|
+
|
|
417
|
+
`onSuccess` runs with what `onSubmit` returned and the submitted values. `onError` runs after the error is on the fields, with a `KitrError`. Neither runs when the browser-side schema check fails.
|
|
418
|
+
|
|
419
|
+
```tsx
|
|
420
|
+
// app/components/invoices/new-invoice-panel.tsx
|
|
421
|
+
import { toast } from '@kitr/toast'
|
|
422
|
+
import { Form, useSchemaForm } from '@kitr/forms'
|
|
423
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
424
|
+
|
|
425
|
+
export function NewInvoicePanel({ onCreate }: { onCreate: (v: unknown) => Promise<{ number: string }> }) {
|
|
426
|
+
const form = useSchemaForm(newInvoiceSchema, {
|
|
427
|
+
defaultValues: { customerEmail: '', amount: '', dueOn: '' },
|
|
428
|
+
})
|
|
429
|
+
return (
|
|
430
|
+
<Form
|
|
431
|
+
form={form}
|
|
432
|
+
onSubmit={onCreate}
|
|
433
|
+
onSuccess={(invoice) => toast.success(`Invoice ${(invoice as { number: string }).number} created`)}
|
|
434
|
+
onError={(error) => {
|
|
435
|
+
if (error.code !== 'VALIDATION') toast.error(error.message)
|
|
436
|
+
}}
|
|
437
|
+
resetOnSuccess
|
|
438
|
+
>
|
|
439
|
+
{/* fields */}
|
|
440
|
+
</Form>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
## Edit an existing record
|
|
448
|
+
|
|
449
|
+
Load the record first and render the form only once it is there, passing it as `defaultValues`. Leave `resetOnSuccess` off for edit forms:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
// app/routes.ts
|
|
453
|
+
r.page('/invoices/$id/edit', { lazy: () => import('./pages/invoices/edit') })
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
// app/pages/invoices/edit.tsx
|
|
458
|
+
import { FieldInput, Form, FormField, SubmitButton, useSchemaForm } from '@kitr/forms'
|
|
459
|
+
import { useSuspenseQuery } from '@kitr/query'
|
|
460
|
+
import { getRouteApi } from '@kitr/router'
|
|
461
|
+
import { newInvoiceSchema } from '#app/validators/invoices'
|
|
462
|
+
import { invoiceQuery } from '#app/queries/invoices'
|
|
463
|
+
|
|
464
|
+
const route = getRouteApi('/invoices/$id/edit')
|
|
465
|
+
|
|
466
|
+
export default function EditInvoicePage() {
|
|
467
|
+
const { id } = route.useParams()
|
|
468
|
+
const { data: invoice } = useSuspenseQuery(invoiceQuery.options(id))
|
|
469
|
+
const form = useSchemaForm(newInvoiceSchema, {
|
|
470
|
+
defaultValues: {
|
|
471
|
+
customerEmail: invoice.customerEmail,
|
|
472
|
+
amount: String(invoice.amount),
|
|
473
|
+
dueOn: invoice.dueOn,
|
|
474
|
+
},
|
|
475
|
+
})
|
|
476
|
+
return (
|
|
477
|
+
<Form form={form} onSubmit={(values) => console.log('save', id, values)}>
|
|
478
|
+
<FormField name="dueOn" label="Due on">
|
|
479
|
+
<FieldInput type="date" />
|
|
480
|
+
</FormField>
|
|
481
|
+
<SubmitButton>Save</SubmitButton>
|
|
482
|
+
</Form>
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
To follow data that changes while the form is open, pass `values` instead of `defaultValues`; the form resets each time `values` changes. `form.formState.isDirty` tells you whether Ada changed anything.
|
|
488
|
+
|
|
489
|
+
## Use your own inputs
|
|
490
|
+
|
|
491
|
+
`<FieldInput>` is a plain `<input>`. For a `<select>`, a `<textarea>` or a styled input, call `useFieldProps()` inside `<FormField>` and spread the result:
|
|
492
|
+
|
|
493
|
+
```tsx
|
|
494
|
+
// app/components/invoices/currency-select.tsx
|
|
495
|
+
import { useFieldProps } from '@kitr/forms'
|
|
496
|
+
|
|
497
|
+
export function CurrencySelect() {
|
|
498
|
+
const props = useFieldProps()
|
|
499
|
+
// => { id, 'aria-invalid', 'aria-describedby', name, onChange, onBlur, ref }
|
|
500
|
+
return (
|
|
501
|
+
<select {...props}>
|
|
502
|
+
<option value="USD">US dollar</option>
|
|
503
|
+
<option value="EUR">Euro</option>
|
|
504
|
+
</select>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
<FormField name="currency" label="Currency">
|
|
511
|
+
<CurrencySelect />
|
|
512
|
+
</FormField>
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`useFieldProps()` returns the field's `id`, `aria-invalid` and `aria-describedby`, plus everything `form.register(name)` returns. A checkbox works the same way and arrives as a boolean:
|
|
516
|
+
|
|
517
|
+
```tsx
|
|
518
|
+
<FormField name="sendNow" label="Email the invoice to the customer now">
|
|
519
|
+
<FieldInput type="checkbox" />
|
|
520
|
+
</FormField>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
## Lay out a field yourself
|
|
524
|
+
|
|
525
|
+
`<FormField>` renders the label, children, description, then error. To place things differently, pass a function as its child; it receives `{ id, name, invalid, describedBy }`. Call `form.register` there rather than `useFieldProps()`:
|
|
526
|
+
|
|
527
|
+
```tsx
|
|
528
|
+
// app/components/invoices/note-field.tsx
|
|
529
|
+
import { FormField, useFormContext } from '@kitr/forms'
|
|
530
|
+
|
|
531
|
+
export function NoteField() {
|
|
532
|
+
const form = useFormContext<{ note: string }>()
|
|
533
|
+
return (
|
|
534
|
+
<FormField name="note" label="Note to the customer" description="Up to 500 characters.">
|
|
535
|
+
{({ id, invalid, describedBy }) => (
|
|
536
|
+
<textarea
|
|
537
|
+
id={id}
|
|
538
|
+
rows={4}
|
|
539
|
+
aria-invalid={invalid || undefined}
|
|
540
|
+
aria-describedby={describedBy || undefined}
|
|
541
|
+
{...form.register('note')}
|
|
542
|
+
/>
|
|
543
|
+
)}
|
|
544
|
+
</FormField>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
## Wrap a controlled component
|
|
552
|
+
|
|
553
|
+
Date pickers, comboboxes and rich-text editors often take `value` and `onChange` but no `ref`. Use React Hook Form's `Controller`, exported from `@kitr/forms`:
|
|
554
|
+
|
|
555
|
+
```tsx
|
|
556
|
+
// app/components/invoices/due-date-field.tsx
|
|
557
|
+
import { Controller, FormField, useFormContext } from '@kitr/forms'
|
|
558
|
+
import { DatePicker } from '#components/date-picker'
|
|
559
|
+
|
|
560
|
+
export function DueDateField() {
|
|
561
|
+
const form = useFormContext<{ dueOn: string }>()
|
|
562
|
+
return (
|
|
563
|
+
<FormField name="dueOn" label="Due on">
|
|
564
|
+
{({ id, describedBy }) => (
|
|
565
|
+
<Controller
|
|
566
|
+
control={form.control}
|
|
567
|
+
name="dueOn"
|
|
568
|
+
render={({ field }) => (
|
|
569
|
+
<DatePicker id={id} aria-describedby={describedBy} value={field.value} onChange={field.onChange} />
|
|
570
|
+
)}
|
|
571
|
+
/>
|
|
572
|
+
)}
|
|
573
|
+
</FormField>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Field and server errors still appear, because they are keyed by name.
|
|
579
|
+
|
|
580
|
+
## Repeat fields
|
|
581
|
+
|
|
582
|
+
Invoice lines are a list. `useFieldArray` adds and removes rows; each row's fields use an index path:
|
|
583
|
+
|
|
584
|
+
```tsx
|
|
585
|
+
// app/components/invoices/invoice-lines.tsx
|
|
586
|
+
import { FieldInput, FormField, useFieldArray, useFormContext } from '@kitr/forms'
|
|
587
|
+
|
|
588
|
+
type Lines = { lines: { description: string; quantity: string }[] }
|
|
589
|
+
|
|
590
|
+
export function InvoiceLines() {
|
|
591
|
+
const form = useFormContext<Lines>()
|
|
592
|
+
const lines = useFieldArray({ control: form.control, name: 'lines' })
|
|
593
|
+
return (
|
|
594
|
+
<fieldset>
|
|
595
|
+
<legend>Lines</legend>
|
|
596
|
+
{lines.fields.map((line, index) => (
|
|
597
|
+
<div key={line.id}>
|
|
598
|
+
<FormField name={`lines.${index}.description`} label="Description">
|
|
599
|
+
<FieldInput />
|
|
600
|
+
</FormField>
|
|
601
|
+
<FormField name={`lines.${index}.quantity`} label="Quantity">
|
|
602
|
+
<FieldInput inputMode="numeric" />
|
|
603
|
+
</FormField>
|
|
604
|
+
<button type="button" onClick={() => lines.remove(index)}>
|
|
605
|
+
Remove
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
))}
|
|
609
|
+
<button type="button" onClick={() => lines.append({ description: '', quantity: '1' })}>
|
|
610
|
+
Add line
|
|
611
|
+
</button>
|
|
612
|
+
</fieldset>
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Use `line.id` as the key, not `index`. A server error for `lines.1.quantity` lands under the second row's input.
|
|
618
|
+
|
|
619
|
+
## Watch a field
|
|
620
|
+
|
|
621
|
+
To show or hide part of the form based on a value, read it with `form.watch`:
|
|
622
|
+
|
|
623
|
+
```tsx
|
|
624
|
+
// app/components/invoices/delivery-fields.tsx
|
|
625
|
+
import { FieldInput, FormField, useFormContext } from '@kitr/forms'
|
|
626
|
+
|
|
627
|
+
export function DeliveryFields() {
|
|
628
|
+
const form = useFormContext<{ sendNow: boolean; ccEmail: string }>()
|
|
629
|
+
const sendNow = form.watch('sendNow')
|
|
630
|
+
if (!sendNow) return null
|
|
631
|
+
return (
|
|
632
|
+
<FormField name="ccEmail" label="Copy to" description="Grace in finance gets a copy.">
|
|
633
|
+
<FieldInput type="email" />
|
|
634
|
+
</FormField>
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
The component re-renders when `sendNow` changes. Make a hidden field optional in the schema, or use a zod `discriminatedUnion`.
|
|
640
|
+
|
|
641
|
+
## Submit without the Form component
|
|
642
|
+
|
|
643
|
+
When a submit does not go through `<Form>`, such as a "Mark as paid" button, call `applyServerErrors` yourself. It puts the error on the form as `<Form>` does and returns `true` for a `VALIDATION` error:
|
|
644
|
+
|
|
645
|
+
```tsx
|
|
646
|
+
// app/components/invoices/mark-paid.tsx
|
|
647
|
+
import { applyServerErrors, useForm } from '@kitr/forms'
|
|
648
|
+
|
|
649
|
+
export function MarkPaid({ save }: { save: (paidOn: string) => Promise<void> }) {
|
|
650
|
+
const form = useForm({ defaultValues: { paidOn: '' } })
|
|
651
|
+
const onClick = async () => {
|
|
652
|
+
try {
|
|
653
|
+
await save(form.getValues('paidOn'))
|
|
654
|
+
} catch (error) {
|
|
655
|
+
const isValidation = applyServerErrors(form, error)
|
|
656
|
+
// => true for KitrError.validation(...), false for anything else
|
|
657
|
+
if (!isValidation) console.error(error)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return (
|
|
661
|
+
<>
|
|
662
|
+
<input type="date" {...form.register('paidOn')} />
|
|
663
|
+
<button type="button" onClick={onClick}>Mark as paid</button>
|
|
664
|
+
<p role="alert">{form.formState.errors.paidOn?.message}</p>
|
|
665
|
+
</>
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
It accepts a `KitrError`, the JSON shape a server sent, or any thrown value; anything that is not a field error ends up on `root.server`.
|
|
671
|
+
|
|
672
|
+
## Style fields
|
|
673
|
+
|
|
674
|
+
The components are headless. `<FormField>` marks its parts with `data-kitr` attributes so you can style them from CSS or with [Tailwind](/packages/tailwind) arbitrary variants:
|
|
675
|
+
|
|
676
|
+
| Attribute | Element |
|
|
677
|
+
| --- | --- |
|
|
678
|
+
| `data-kitr="field"` | The wrapper `<div>`. Has `data-invalid` when the field has an error. |
|
|
679
|
+
| `data-kitr="label"` | The `<label>`. |
|
|
680
|
+
| `data-kitr="description"` | The description `<p>`. |
|
|
681
|
+
| `data-kitr="field-error"` | The error `<p>`, which also has `role="alert"`. |
|
|
682
|
+
|
|
683
|
+
```tsx
|
|
684
|
+
<FormField
|
|
685
|
+
name="amount"
|
|
686
|
+
label="Amount (USD)"
|
|
687
|
+
className="grid gap-1.5 [&_[data-kitr=field-error]]:text-sm [&_[data-kitr=field-error]]:text-destructive"
|
|
688
|
+
>
|
|
689
|
+
<FieldInput />
|
|
690
|
+
</FormField>
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
For a finished look, copy the styled components into your app:
|
|
694
|
+
|
|
695
|
+
```sh
|
|
696
|
+
kitr ui add form
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
This writes `app/components/ui/form.tsx` (plus `button`, `input` and `label` if missing), exporting `Form`, `FormField`, `FieldInput`, `FormMessage` (the root error) and `SubmitButton`. Import from `#components/ui/form` instead of `@kitr/forms`; the file is yours to edit.
|
|
700
|
+
|
|
701
|
+
## Make forms accessible
|
|
702
|
+
|
|
703
|
+
`<FormField>` and `useFieldProps()` produce this markup for a field with a description and a failed check:
|
|
704
|
+
|
|
705
|
+
```html
|
|
706
|
+
<div data-kitr="field" data-invalid="">
|
|
707
|
+
<label for="_r_2_" data-kitr="label">Due on</label>
|
|
708
|
+
<input id="_r_2_" aria-describedby="_r_2_-description _r_2_-error" type="date" name="dueOn" aria-invalid="true">
|
|
709
|
+
<p id="_r_2_-description" data-kitr="description">Customers see this date on the invoice.</p>
|
|
710
|
+
<p id="_r_2_-error" role="alert" data-kitr="field-error">Pick a due date</p>
|
|
711
|
+
</div>
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
- Clicking the label focuses the input, because the ids match.
|
|
715
|
+
- A screen reader reads the description and the error with the input, through `aria-describedby`.
|
|
716
|
+
- The error is announced when it appears, through `role="alert"`.
|
|
717
|
+
- `<Form>` sets `noValidate` on the `<form>`, so the browser's own bubbles never compete with these messages.
|
|
718
|
+
|
|
719
|
+
Give the `<form>` an accessible name (`aria-label="New invoice"`) when the page has more than one.
|
|
720
|
+
|
|
721
|
+
## Commands
|
|
722
|
+
|
|
723
|
+
| Command | What it does |
|
|
724
|
+
| --- | --- |
|
|
725
|
+
| `kitr make:form <name>` | Writes `app/components/forms/<name>_form.tsx`: a zod schema, its type and a form component using the styled `#components/ui/form`. |
|
|
726
|
+
| `kitr ui add form` | Copies the styled form components into `app/components/ui/form.tsx`. |
|
|
727
|
+
| `kitr eject stub form` | Copies the `make:form` template to `stubs/form.ts` so you can change what it generates. |
|
|
728
|
+
|
|
729
|
+
```sh
|
|
730
|
+
kitr make:form invoice
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
writes:
|
|
734
|
+
|
|
735
|
+
```tsx
|
|
736
|
+
// app/components/forms/invoice-form.tsx
|
|
737
|
+
import { useSchemaForm } from '@kitr/forms'
|
|
738
|
+
import { z } from 'zod'
|
|
739
|
+
import { FieldInput, Form, FormField, FormMessage, SubmitButton } from '#components/ui/form'
|
|
740
|
+
|
|
741
|
+
export const invoiceSchema = z.object({
|
|
742
|
+
name: z.string().min(2, 'Name must be at least 2 characters'),
|
|
743
|
+
})
|
|
744
|
+
export type InvoiceValues = z.infer<typeof invoiceSchema>
|
|
745
|
+
|
|
746
|
+
export function InvoiceForm({ defaultValues, onSubmit }: { defaultValues?: Partial<InvoiceValues>; onSubmit: (values: InvoiceValues) => Promise<unknown> }) {
|
|
747
|
+
const form = useSchemaForm(invoiceSchema, { defaultValues: { name: '', ...defaultValues } })
|
|
748
|
+
return (
|
|
749
|
+
<Form form={form} onSubmit={onSubmit} className="grid gap-4">
|
|
750
|
+
<FormMessage />
|
|
751
|
+
<FormField name="name" label="Name">
|
|
752
|
+
<FieldInput autoComplete="off" />
|
|
753
|
+
</FormField>
|
|
754
|
+
<SubmitButton>Save</SubmitButton>
|
|
755
|
+
</Form>
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
> [!WARNING]
|
|
761
|
+
> The generated file needs `kitr ui add form` first (the `app` and `fullstack` kits ran it). Import from `@kitr/forms`, not `react-hook-form`; `kitr doctor` warns otherwise.
|
|
762
|
+
|
|
763
|
+
## Errors
|
|
764
|
+
|
|
765
|
+
These are the `KitrError` codes a form shows and where each appears.
|
|
766
|
+
|
|
767
|
+
| Code | When | What to do |
|
|
768
|
+
| --- | --- | --- |
|
|
769
|
+
| `VALIDATION` | The server rejected the input (HTTP 422). Messages land on fields; unknown fields and `_root` go to `useRootError()`. | Nothing; the user fixes the fields. Make sure the server's field names match the form's. |
|
|
770
|
+
| `UNAUTHORIZED` | The session expired mid-form (401). The message goes to `useRootError()`. | Send the user to sign in from `onError`. |
|
|
771
|
+
| `CONFLICT` | Another user changed the record (409). | Reload the record and ask the user to try again. |
|
|
772
|
+
| `NETWORK` | The request never reached the server. Root error: "Network request failed". | Keep the values; let the user resubmit. |
|
|
773
|
+
| `INTERNAL` | The server crashed (500). In production the root error is "Internal server error". | Read the server log. |
|
|
774
|
+
| none | `useFieldProps()` was called outside `<FormField>`: `[@kitr/forms] useFieldProps() must be used inside <FormField>.` | Move the input inside a `<FormField>`, or use the render-function child. |
|
|
775
|
+
|
|
776
|
+
## Testing
|
|
777
|
+
|
|
778
|
+
Forms run in a DOM test without an app, router or server. Render, type, submit; to test server errors, make the submit function reject with the error the server would send.
|
|
779
|
+
|
|
780
|
+
```tsx
|
|
781
|
+
// app/components/invoices/new-invoice-form.test.tsx
|
|
782
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
783
|
+
import { KitrError } from '@kitr/kitr/app'
|
|
784
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
785
|
+
import { NewInvoiceForm } from './new-invoice-form'
|
|
786
|
+
|
|
787
|
+
function fill(email: string, amount: string, dueOn: string) {
|
|
788
|
+
fireEvent.change(screen.getByLabelText('Customer email'), { target: { value: email } })
|
|
789
|
+
fireEvent.change(screen.getByLabelText('Amount (USD)'), { target: { value: amount } })
|
|
790
|
+
fireEvent.change(screen.getByLabelText('Due on'), { target: { value: dueOn } })
|
|
791
|
+
fireEvent.submit(screen.getByRole('form', { name: 'New invoice' }))
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
describe('NewInvoiceForm', () => {
|
|
795
|
+
it('shows schema errors without calling the server', async () => {
|
|
796
|
+
const onCreate = vi.fn()
|
|
797
|
+
render(<NewInvoiceForm onCreate={onCreate} />)
|
|
798
|
+
fill('grace', '0', '')
|
|
799
|
+
expect(await screen.findByText('Enter the customer’s email')).toBeTruthy()
|
|
800
|
+
expect(screen.getByText('Amount must be more than zero')).toBeTruthy()
|
|
801
|
+
expect(screen.getByLabelText('Customer email').getAttribute('aria-invalid')).toBe('true')
|
|
802
|
+
expect(onCreate).not.toHaveBeenCalled()
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('submits typed values and resets', async () => {
|
|
806
|
+
const onCreate = vi.fn().mockResolvedValue({ id: 'inv_1042' })
|
|
807
|
+
render(<NewInvoiceForm onCreate={onCreate} />)
|
|
808
|
+
fill('grace@acme.com', '120', '2026-12-01')
|
|
809
|
+
await waitFor(() =>
|
|
810
|
+
expect(onCreate).toHaveBeenCalledWith({
|
|
811
|
+
customerEmail: 'grace@acme.com',
|
|
812
|
+
amount: 120,
|
|
813
|
+
dueOn: '2026-12-01',
|
|
814
|
+
}),
|
|
815
|
+
)
|
|
816
|
+
await waitFor(() =>
|
|
817
|
+
expect((screen.getByLabelText('Customer email') as HTMLInputElement).value).toBe(''),
|
|
818
|
+
)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('puts server errors under the right field and the rest at the top', async () => {
|
|
822
|
+
const onCreate = vi
|
|
823
|
+
.fn()
|
|
824
|
+
.mockRejectedValueOnce(KitrError.validation({ customerEmail: ['No customer with this email'] }))
|
|
825
|
+
.mockRejectedValueOnce(new KitrError('CONFLICT', 'Invoice ACME-1042 already exists'))
|
|
826
|
+
render(<NewInvoiceForm onCreate={onCreate} />)
|
|
827
|
+
fill('linus@acme.com', '80', '2026-12-01')
|
|
828
|
+
expect(await screen.findByText('No customer with this email')).toBeTruthy()
|
|
829
|
+
fireEvent.submit(screen.getByRole('form', { name: 'New invoice' }))
|
|
830
|
+
expect(await screen.findByText('Invoice ACME-1042 already exists')).toBeTruthy()
|
|
831
|
+
})
|
|
832
|
+
})
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
A form that calls `useMutation` needs the query client: render it with `renderWithKitr` from [testing](/packages/testing).
|
|
836
|
+
|
|
837
|
+
## Next steps
|
|
838
|
+
|
|
839
|
+
- [API reference](/packages/forms/api): every export with its signature.
|
|
840
|
+
- [Server actions](/packages/actions): the server half of a form, with the same error shape.
|
|
841
|
+
- [Query](/packages/query): mutations, cache effects and `mutateAsync`.
|