@igniter-js/cli 0.1.3 β 0.1.4
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/index.js +6 -9
- package/dist/templates/copilot.form.instructions.hbs +1021 -0
- package/dist/templates/feature.interface.hbs +2 -4
- package/dist/templates/feature.procedure.hbs +15 -9
- package/dist/templates/use-form-with-zod.hbs +39 -0
- package/dist/templates/vscode.settings.hbs +3 -0
- package/dist/utils/consts.js +1 -0
- package/dist/utils/handlebars-helpers.js +13 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -326,21 +326,18 @@ class IgniterCLI extends helpers_1.CLIHelper {
|
|
|
326
326
|
const igniterContextFile = template_handler_1.TemplateHandler.render('igniter.context', {});
|
|
327
327
|
const igniterRouterFile = template_handler_1.TemplateHandler.render('igniter.router', {});
|
|
328
328
|
const igniterRouteHandlerFile = template_handler_1.TemplateHandler.render('route', {});
|
|
329
|
+
const useFormFile = template_handler_1.TemplateHandler.render('use-form-with-zod', {});
|
|
329
330
|
const igniterFile = template_handler_1.TemplateHandler.render('igniter', {});
|
|
330
331
|
this.createFile('src/igniter.client.ts', igniterClientFile);
|
|
331
332
|
this.createFile('src/igniter.context.ts', igniterContextFile);
|
|
332
333
|
this.createFile('src/igniter.router.ts', igniterRouterFile);
|
|
334
|
+
this.createFile('src/core/hooks/use-form-with-zod.ts', useFormFile);
|
|
333
335
|
this.createFile('src/app/api/[[...all]]/route.ts', igniterRouteHandlerFile);
|
|
334
336
|
this.createFile('src/igniter.ts', igniterFile);
|
|
335
337
|
this.spinner.succeed('Igniter files created successfully');
|
|
336
338
|
packageJson.name = path_1.default.basename(process.cwd());
|
|
337
339
|
packageJson.version = '1.0.0';
|
|
338
340
|
packageJson.legacyPeerDeps = true;
|
|
339
|
-
packageJson.scripts['docker:up'] = 'docker-compose up -d';
|
|
340
|
-
packageJson.scripts['docker:down'] = 'docker-compose down';
|
|
341
|
-
packageJson.scripts['db:studio'] = 'npx prisma studio';
|
|
342
|
-
packageJson.scripts['db:migrate:dev'] = 'igniter database start && npx prisma migrate dev';
|
|
343
|
-
packageJson.scripts['db:generate'] = 'igniter database start && npx prisma generate';
|
|
344
341
|
packageJson.scripts['igniter'] = 'npx @igniter-js/cli';
|
|
345
342
|
this.saveJSON('package.json', packageJson);
|
|
346
343
|
this.spinner.succeed('Package configuration updated successfully');
|
|
@@ -351,13 +348,13 @@ class IgniterCLI extends helpers_1.CLIHelper {
|
|
|
351
348
|
${chalk_1.default.gray('$')} ${chalk_1.default.white('npm run dev')}
|
|
352
349
|
|
|
353
350
|
${chalk_1.default.cyan('2.')} Start Docker services:
|
|
354
|
-
${chalk_1.default.gray('$')} ${chalk_1.default.white('
|
|
351
|
+
${chalk_1.default.gray('$')} ${chalk_1.default.white('docker compose up -d')}
|
|
355
352
|
|
|
356
|
-
${chalk_1.default.cyan('3.')}
|
|
357
|
-
${chalk_1.default.gray('$')} ${chalk_1.default.white('
|
|
353
|
+
${chalk_1.default.cyan('3.')} Run Prisma migrations client:
|
|
354
|
+
${chalk_1.default.gray('$')} ${chalk_1.default.white('npx prisma migrate dev')}
|
|
358
355
|
|
|
359
356
|
${chalk_1.default.cyan('4.')} Create your first feature:
|
|
360
|
-
${chalk_1.default.gray('$')} ${chalk_1.default.white('
|
|
357
|
+
${chalk_1.default.gray('$')} ${chalk_1.default.white('npx @igniter-js/cli generate feature')}
|
|
361
358
|
|
|
362
359
|
${chalk_1.default.cyan('π')} Documentation: ${chalk_1.default.blue('https://github.com/felipebarcelospro/igniter-js')}
|
|
363
360
|
${chalk_1.default.cyan('π‘')} Need help? ${chalk_1.default.blue('https://github.com/felipebarcelospro/igniter-js/issues')}
|
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Form Building Guide with Igniter.js
|
|
3
|
+
url: https://github.com/felipebarcelospro/igniter-router-next-app
|
|
4
|
+
timestamp: 2025-02-27T10:35:00.000Z
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Form Building Guide for Igniter.js Applications
|
|
8
|
+
|
|
9
|
+
This guide outlines the best practices, patterns, and techniques for building robust, type-safe forms in applications using the Igniter.js framework with Next.js, React Hook Form, Zod, and Shadcn UI.
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
* [Core Form Philosophy](#core-form-philosophy)
|
|
14
|
+
* [Form Architecture](#form-architecture)
|
|
15
|
+
* [Form Components](#form-components)
|
|
16
|
+
* [Form Validation](#form-validation)
|
|
17
|
+
* [Form Submission](#form-submission)
|
|
18
|
+
* [Form State Management](#form-state-management)
|
|
19
|
+
* [Error Handling](#error-handling)
|
|
20
|
+
* [Advanced Form Patterns](#advanced-form-patterns)
|
|
21
|
+
* [Best Practices](#best-practices)
|
|
22
|
+
|
|
23
|
+
## Core Form Philosophy
|
|
24
|
+
|
|
25
|
+
Igniter.js forms follow these core principles:
|
|
26
|
+
|
|
27
|
+
* **Type Safety**: End-to-end type safety from schema definition to form submission
|
|
28
|
+
* **Validation First**: Schema-based validation using Zod
|
|
29
|
+
* **Component Composition**: Forms built from composable, reusable components
|
|
30
|
+
* **Error Resilience**: Comprehensive error handling and user feedback
|
|
31
|
+
* **Performance Optimized**: Forms that maintain performance even with complex validation
|
|
32
|
+
* **Accessibility**: ARIA-compliant forms that work for all users
|
|
33
|
+
|
|
34
|
+
## Form Architecture
|
|
35
|
+
|
|
36
|
+
### Key Components in the Form System
|
|
37
|
+
|
|
38
|
+
1. **Schema Definition**: Using Zod to define form shape and validation rules
|
|
39
|
+
2. **Form Hook**: `useFormWithZod` custom hook for connecting Zod schemas to React Hook Form
|
|
40
|
+
3. **Form Components**: Shadcn UI form primitives for consistent UI/UX
|
|
41
|
+
4. **Form State Management**: React Hook Form for handling form state
|
|
42
|
+
5. **Form Submission**: Integration with Igniter.js mutations for API calls
|
|
43
|
+
|
|
44
|
+
### Diagram of Form Data Flow
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
ββββββββββββββ βββββββββββββββββ βββββββββββββββββ
|
|
48
|
+
β β β β β β
|
|
49
|
+
β Zod ββββββΆβ React Hook ββββββΆβ Form β
|
|
50
|
+
β Schema β β Form β β Components β
|
|
51
|
+
β β β β β β
|
|
52
|
+
ββββββββββββββ βββββββββββββββββ βββββββββββββββββ
|
|
53
|
+
β β
|
|
54
|
+
β β
|
|
55
|
+
βΌ βΌ
|
|
56
|
+
ββββββββββββββ βββββββββββββββββ βββββββββββββββββ
|
|
57
|
+
β β β β β β
|
|
58
|
+
β Igniter βββββββ Form βββββββ User β
|
|
59
|
+
β Mutation β β Submission β β Input β
|
|
60
|
+
β β β β β β
|
|
61
|
+
ββββββββββββββ βββββββββββββββββ βββββββββββββββββ
|
|
62
|
+
β
|
|
63
|
+
β
|
|
64
|
+
βΌ
|
|
65
|
+
ββββββββββββββ
|
|
66
|
+
β β
|
|
67
|
+
β User β
|
|
68
|
+
β Feedback β
|
|
69
|
+
β β
|
|
70
|
+
ββββββββββββββ
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Form Components
|
|
74
|
+
|
|
75
|
+
### Base Form Components
|
|
76
|
+
|
|
77
|
+
Igniter.js applications use Shadcn UI's form components as building blocks:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import {
|
|
81
|
+
Form,
|
|
82
|
+
FormControl,
|
|
83
|
+
FormField,
|
|
84
|
+
FormItem,
|
|
85
|
+
FormLabel,
|
|
86
|
+
FormMessage,
|
|
87
|
+
} from "@/components/ui/form"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Form Container
|
|
91
|
+
|
|
92
|
+
Every form starts with the `Form` component that wraps the form elements:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<Form {...form}>
|
|
96
|
+
<form onSubmit={form.onSubmit} className="space-y-4 py-4">
|
|
97
|
+
{/* Form fields go here */}
|
|
98
|
+
</form>
|
|
99
|
+
</Form>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Form Fields
|
|
103
|
+
|
|
104
|
+
Form fields follow this consistent pattern:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<FormField
|
|
108
|
+
control={form.control}
|
|
109
|
+
name="fieldName"
|
|
110
|
+
render={({ field }) => (
|
|
111
|
+
<FormItem>
|
|
112
|
+
<FormLabel>Field Label</FormLabel>
|
|
113
|
+
<FormControl>
|
|
114
|
+
<Input {...field} />
|
|
115
|
+
</FormControl>
|
|
116
|
+
<FormMessage />
|
|
117
|
+
</FormItem>
|
|
118
|
+
)}
|
|
119
|
+
/>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Field Types
|
|
123
|
+
|
|
124
|
+
#### Text Input
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
<FormField
|
|
128
|
+
control={form.control}
|
|
129
|
+
name="title"
|
|
130
|
+
render={({ field }) => (
|
|
131
|
+
<FormItem>
|
|
132
|
+
<FormLabel>Title</FormLabel>
|
|
133
|
+
<FormControl>
|
|
134
|
+
<Input placeholder="Enter title..." {...field} />
|
|
135
|
+
</FormControl>
|
|
136
|
+
<FormMessage />
|
|
137
|
+
</FormItem>
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### Text Area
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
<FormField
|
|
146
|
+
control={form.control}
|
|
147
|
+
name="description"
|
|
148
|
+
render={({ field }) => (
|
|
149
|
+
<FormItem>
|
|
150
|
+
<FormLabel>Description</FormLabel>
|
|
151
|
+
<FormControl>
|
|
152
|
+
<Textarea
|
|
153
|
+
placeholder="Enter description..."
|
|
154
|
+
{...field}
|
|
155
|
+
/>
|
|
156
|
+
</FormControl>
|
|
157
|
+
<FormMessage />
|
|
158
|
+
</FormItem>
|
|
159
|
+
)}
|
|
160
|
+
/>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### Date Picker
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
<FormField
|
|
167
|
+
control={form.control}
|
|
168
|
+
name="dueDate"
|
|
169
|
+
render={({ field }) => (
|
|
170
|
+
<FormItem className="flex flex-col">
|
|
171
|
+
<FormLabel>Due Date</FormLabel>
|
|
172
|
+
<Popover>
|
|
173
|
+
<PopoverTrigger asChild>
|
|
174
|
+
<FormControl>
|
|
175
|
+
<Button
|
|
176
|
+
variant="outline"
|
|
177
|
+
className={cn(
|
|
178
|
+
'w-full pl-3 text-left font-normal',
|
|
179
|
+
!field.value && 'text-muted-foreground'
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{field.value ? (
|
|
183
|
+
format(field.value, 'PPP')
|
|
184
|
+
) : (
|
|
185
|
+
<span>Pick a date</span>
|
|
186
|
+
)}
|
|
187
|
+
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
|
188
|
+
</Button>
|
|
189
|
+
</FormControl>
|
|
190
|
+
</PopoverTrigger>
|
|
191
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
192
|
+
<Calendar
|
|
193
|
+
mode="single"
|
|
194
|
+
selected={field.value ? new Date(field.value) : undefined}
|
|
195
|
+
onSelect={field.onChange}
|
|
196
|
+
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
|
197
|
+
initialFocus
|
|
198
|
+
/>
|
|
199
|
+
</PopoverContent>
|
|
200
|
+
</Popover>
|
|
201
|
+
<FormMessage />
|
|
202
|
+
</FormItem>
|
|
203
|
+
)}
|
|
204
|
+
/>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Select Field
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
<FormField
|
|
211
|
+
control={form.control}
|
|
212
|
+
name="category"
|
|
213
|
+
render={({ field }) => (
|
|
214
|
+
<FormItem>
|
|
215
|
+
<FormLabel>Category</FormLabel>
|
|
216
|
+
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
217
|
+
<FormControl>
|
|
218
|
+
<SelectTrigger>
|
|
219
|
+
<SelectValue placeholder="Select a category" />
|
|
220
|
+
</SelectTrigger>
|
|
221
|
+
</FormControl>
|
|
222
|
+
<SelectContent>
|
|
223
|
+
<SelectItem value="work">Work</SelectItem>
|
|
224
|
+
<SelectItem value="personal">Personal</SelectItem>
|
|
225
|
+
<SelectItem value="education">Education</SelectItem>
|
|
226
|
+
</SelectContent>
|
|
227
|
+
</Select>
|
|
228
|
+
<FormMessage />
|
|
229
|
+
</FormItem>
|
|
230
|
+
)}
|
|
231
|
+
/>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### Checkbox
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
<FormField
|
|
238
|
+
control={form.control}
|
|
239
|
+
name="isCompleted"
|
|
240
|
+
render={({ field }) => (
|
|
241
|
+
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
|
242
|
+
<FormControl>
|
|
243
|
+
<Checkbox
|
|
244
|
+
checked={field.value}
|
|
245
|
+
onCheckedChange={field.onChange}
|
|
246
|
+
/>
|
|
247
|
+
</FormControl>
|
|
248
|
+
<div className="space-y-1 leading-none">
|
|
249
|
+
<FormLabel>Completed</FormLabel>
|
|
250
|
+
<FormDescription>
|
|
251
|
+
Mark this task as completed
|
|
252
|
+
</FormDescription>
|
|
253
|
+
</div>
|
|
254
|
+
</FormItem>
|
|
255
|
+
)}
|
|
256
|
+
/>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Form Validation
|
|
260
|
+
|
|
261
|
+
### Zod Schema Definition
|
|
262
|
+
|
|
263
|
+
Define validation schemas using Zod:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const schema = z.object({
|
|
267
|
+
id: z.string().optional(),
|
|
268
|
+
title: z.string().min(3, 'Title must be at least 3 characters'),
|
|
269
|
+
description: z.string().optional(),
|
|
270
|
+
dueDate: z.date().transform(value => value.toISOString()).optional(),
|
|
271
|
+
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
|
272
|
+
isCompleted: z.boolean().default(false),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
type FormValues = z.infer<typeof schema>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Common Validation Patterns
|
|
279
|
+
|
|
280
|
+
#### Required Fields
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
z.string().min(1, 'This field is required')
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
#### Email Validation
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
z.string().email('Please enter a valid email address')
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### Number Validation
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
z.number().min(0, 'Value must be positive').max(100, 'Value must be at most 100')
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
#### Date Validation
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
z.date()
|
|
302
|
+
.min(new Date(), 'Date must be in the future')
|
|
303
|
+
.transform(value => value.toISOString())
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### Conditional Validation
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
z.object({
|
|
310
|
+
hasDeadline: z.boolean(),
|
|
311
|
+
deadline: z.date().optional().superRefine((val, ctx) => {
|
|
312
|
+
if (ctx.parent.hasDeadline && !val) {
|
|
313
|
+
ctx.addIssue({
|
|
314
|
+
code: z.ZodIssueCode.custom,
|
|
315
|
+
message: 'Deadline is required when "Has Deadline" is checked',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}),
|
|
319
|
+
})
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Form Submission
|
|
323
|
+
|
|
324
|
+
### Using Custom Hook
|
|
325
|
+
|
|
326
|
+
The `useFormWithZod` custom hook simplifies form creation and submission:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
const form = useFormWithZod({
|
|
330
|
+
schema: schema,
|
|
331
|
+
defaultValues: defaultValues || { title: '', description: '' },
|
|
332
|
+
onSubmit: async (values) => {
|
|
333
|
+
// Handle form submission
|
|
334
|
+
const result = await tryCatch(mutation.mutate({ body: values }))
|
|
335
|
+
|
|
336
|
+
if (result.error) {
|
|
337
|
+
toast.error('Error submitting form. Please try again.')
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
toast.success('Form submitted successfully!')
|
|
342
|
+
// Additional success handling
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Using Igniter.js Mutations
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
const upsertMutation = api.task.upsert.useMutation()
|
|
351
|
+
|
|
352
|
+
// In form submission handler
|
|
353
|
+
const result = await tryCatch(upsertMutation.mutate({
|
|
354
|
+
body: formValues
|
|
355
|
+
}))
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Form Submission States
|
|
359
|
+
|
|
360
|
+
Handle different form submission states:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
<Button
|
|
364
|
+
type="submit"
|
|
365
|
+
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
|
366
|
+
>
|
|
367
|
+
{form.formState.isSubmitting ? (
|
|
368
|
+
<>
|
|
369
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
370
|
+
Submitting...
|
|
371
|
+
</>
|
|
372
|
+
) : (
|
|
373
|
+
<>
|
|
374
|
+
Submit
|
|
375
|
+
<ArrowRight className="ml-2 h-4 w-4" />
|
|
376
|
+
</>
|
|
377
|
+
)}
|
|
378
|
+
</Button>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Form State Management
|
|
382
|
+
|
|
383
|
+
### Using useFormWithZod
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import { useFormWithZod } from '@/hooks/use-form-with-zod'
|
|
387
|
+
|
|
388
|
+
const form = useFormWithZod({
|
|
389
|
+
schema: schema,
|
|
390
|
+
defaultValues: {
|
|
391
|
+
title: '',
|
|
392
|
+
description: '',
|
|
393
|
+
},
|
|
394
|
+
onSubmit: (values) => {
|
|
395
|
+
// Form submission logic
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Access form state
|
|
400
|
+
const { isDirty, isValid, isSubmitting } = form.formState
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Form Reset
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
// Reset form to initial values
|
|
407
|
+
form.reset()
|
|
408
|
+
|
|
409
|
+
// Reset form to specific values
|
|
410
|
+
form.reset({
|
|
411
|
+
title: 'New Title',
|
|
412
|
+
description: 'New Description'
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Form Dialog Integration
|
|
417
|
+
|
|
418
|
+
When using forms inside dialogs, make sure to reset the form when the dialog closes:
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
<Dialog onOpenChange={(open) => {
|
|
422
|
+
if (!open) {
|
|
423
|
+
form.reset()
|
|
424
|
+
}
|
|
425
|
+
}}>
|
|
426
|
+
{/* Dialog content and form */}
|
|
427
|
+
</Dialog>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Error Handling
|
|
431
|
+
|
|
432
|
+
### Try-Catch Pattern
|
|
433
|
+
|
|
434
|
+
Use the `tryCatch` utility to handle form submission errors:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import { tryCatch } from '@/utils/try-catch'
|
|
438
|
+
|
|
439
|
+
// In form submission handler
|
|
440
|
+
const result = await tryCatch(upsertMutation.mutate({ body: values }))
|
|
441
|
+
|
|
442
|
+
if (result.error) {
|
|
443
|
+
toast.error('Error saving task. Please try again.')
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
toast.success('Task created successfully!')
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Field-Level Error Handling
|
|
451
|
+
|
|
452
|
+
Errors are automatically displayed below each field using `FormMessage`:
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
<FormField
|
|
456
|
+
control={form.control}
|
|
457
|
+
name="title"
|
|
458
|
+
render={({ field }) => (
|
|
459
|
+
<FormItem>
|
|
460
|
+
<FormLabel>Title</FormLabel>
|
|
461
|
+
<FormControl>
|
|
462
|
+
<Input {...field} />
|
|
463
|
+
</FormControl>
|
|
464
|
+
<FormMessage />
|
|
465
|
+
</FormItem>
|
|
466
|
+
)}
|
|
467
|
+
/>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Form-Level Error Handling
|
|
471
|
+
|
|
472
|
+
Display form-level errors:
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
{form.formState.errors.root && (
|
|
476
|
+
<Alert variant="destructive">
|
|
477
|
+
<AlertCircle className="h-4 w-4" />
|
|
478
|
+
<AlertTitle>Error</AlertTitle>
|
|
479
|
+
<AlertDescription>
|
|
480
|
+
{form.formState.errors.root.message}
|
|
481
|
+
</AlertDescription>
|
|
482
|
+
</Alert>
|
|
483
|
+
)}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Advanced Form Patterns
|
|
487
|
+
|
|
488
|
+
### Dynamic Fields
|
|
489
|
+
|
|
490
|
+
Using React Hook Form's `useFieldArray`:
|
|
491
|
+
|
|
492
|
+
```tsx
|
|
493
|
+
import { useFieldArray } from "react-hook-form"
|
|
494
|
+
|
|
495
|
+
// Inside component
|
|
496
|
+
const { fields, append, remove } = useFieldArray({
|
|
497
|
+
control: form.control,
|
|
498
|
+
name: "tasks",
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// In JSX
|
|
502
|
+
{fields.map((field, index) => (
|
|
503
|
+
<div key={field.id} className="flex items-center gap-2">
|
|
504
|
+
<FormField
|
|
505
|
+
control={form.control}
|
|
506
|
+
name={`tasks.${index}.title`}
|
|
507
|
+
render={({ field }) => (
|
|
508
|
+
<FormItem className="flex-1">
|
|
509
|
+
<FormControl>
|
|
510
|
+
<Input {...field} />
|
|
511
|
+
</FormControl>
|
|
512
|
+
<FormMessage />
|
|
513
|
+
</FormItem>
|
|
514
|
+
)}
|
|
515
|
+
/>
|
|
516
|
+
<Button
|
|
517
|
+
type="button"
|
|
518
|
+
variant="outline"
|
|
519
|
+
size="icon"
|
|
520
|
+
onClick={() => remove(index)}
|
|
521
|
+
>
|
|
522
|
+
<Trash2 className="h-4 w-4" />
|
|
523
|
+
</Button>
|
|
524
|
+
</div>
|
|
525
|
+
))}
|
|
526
|
+
|
|
527
|
+
<Button
|
|
528
|
+
type="button"
|
|
529
|
+
variant="outline"
|
|
530
|
+
size="sm"
|
|
531
|
+
onClick={() => append({ title: '' })}
|
|
532
|
+
>
|
|
533
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
534
|
+
Add Task
|
|
535
|
+
</Button>
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### Multi-Step Forms
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
function MultiStepForm() {
|
|
542
|
+
const [step, setStep] = useState(0)
|
|
543
|
+
const form = useFormWithZod({
|
|
544
|
+
schema: schema,
|
|
545
|
+
defaultValues: { /* ... */ },
|
|
546
|
+
onSubmit: async (values) => {
|
|
547
|
+
// Submit final form data
|
|
548
|
+
}
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
const steps = [
|
|
552
|
+
// Step 1: Basic Info
|
|
553
|
+
<div key="basic" className="space-y-4">
|
|
554
|
+
<FormField
|
|
555
|
+
control={form.control}
|
|
556
|
+
name="title"
|
|
557
|
+
render={({ field }) => (/* ... */)}
|
|
558
|
+
/>
|
|
559
|
+
{/* More fields */}
|
|
560
|
+
</div>,
|
|
561
|
+
|
|
562
|
+
// Step 2: Additional Details
|
|
563
|
+
<div key="details" className="space-y-4">
|
|
564
|
+
<FormField
|
|
565
|
+
control={form.control}
|
|
566
|
+
name="description"
|
|
567
|
+
render={({ field }) => (/* ... */)}
|
|
568
|
+
/>
|
|
569
|
+
{/* More fields */}
|
|
570
|
+
</div>,
|
|
571
|
+
|
|
572
|
+
// Step 3: Review
|
|
573
|
+
<div key="review" className="space-y-4">
|
|
574
|
+
{/* Review UI */}
|
|
575
|
+
</div>
|
|
576
|
+
]
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
<Form {...form}>
|
|
580
|
+
<form onSubmit={form.onSubmit} className="space-y-8">
|
|
581
|
+
{steps[step]}
|
|
582
|
+
|
|
583
|
+
<div className="flex justify-between">
|
|
584
|
+
<Button
|
|
585
|
+
type="button"
|
|
586
|
+
variant="outline"
|
|
587
|
+
onClick={() => setStep(prev => Math.max(0, prev - 1))}
|
|
588
|
+
disabled={step === 0}
|
|
589
|
+
>
|
|
590
|
+
Previous
|
|
591
|
+
</Button>
|
|
592
|
+
|
|
593
|
+
{step < steps.length - 1 ? (
|
|
594
|
+
<Button
|
|
595
|
+
type="button"
|
|
596
|
+
onClick={() => setStep(prev => Math.min(steps.length - 1, prev + 1))}
|
|
597
|
+
>
|
|
598
|
+
Next
|
|
599
|
+
</Button>
|
|
600
|
+
) : (
|
|
601
|
+
<Button type="submit">Submit</Button>
|
|
602
|
+
)}
|
|
603
|
+
</div>
|
|
604
|
+
</form>
|
|
605
|
+
</Form>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Form with File Upload
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
// Zod schema
|
|
614
|
+
const schema = z.object({
|
|
615
|
+
name: z.string(),
|
|
616
|
+
avatar: z.instanceof(File).optional(),
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// Component
|
|
620
|
+
function FileUploadForm() {
|
|
621
|
+
const form = useFormWithZod({
|
|
622
|
+
schema,
|
|
623
|
+
defaultValues: { name: '' },
|
|
624
|
+
onSubmit: async (values) => {
|
|
625
|
+
// Create FormData for submission
|
|
626
|
+
const formData = new FormData()
|
|
627
|
+
formData.append('name', values.name)
|
|
628
|
+
if (values.avatar) {
|
|
629
|
+
formData.append('avatar', values.avatar)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Submit formData to API
|
|
633
|
+
await uploadMutation.mutate({ formData })
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
return (
|
|
638
|
+
<Form {...form}>
|
|
639
|
+
<form onSubmit={form.onSubmit} className="space-y-4">
|
|
640
|
+
<FormField
|
|
641
|
+
control={form.control}
|
|
642
|
+
name="name"
|
|
643
|
+
render={({ field }) => (/* ... */)}
|
|
644
|
+
/>
|
|
645
|
+
|
|
646
|
+
<FormField
|
|
647
|
+
control={form.control}
|
|
648
|
+
name="avatar"
|
|
649
|
+
render={({ field: { value, onChange, ...field } }) => (
|
|
650
|
+
<FormItem>
|
|
651
|
+
<FormLabel>Profile Picture</FormLabel>
|
|
652
|
+
<FormControl>
|
|
653
|
+
<Input
|
|
654
|
+
type="file"
|
|
655
|
+
accept="image/*"
|
|
656
|
+
onChange={(e) => {
|
|
657
|
+
const file = e.target.files?.[0]
|
|
658
|
+
if (file) onChange(file)
|
|
659
|
+
}}
|
|
660
|
+
{...field}
|
|
661
|
+
/>
|
|
662
|
+
</FormControl>
|
|
663
|
+
<FormMessage />
|
|
664
|
+
</FormItem>
|
|
665
|
+
)}
|
|
666
|
+
/>
|
|
667
|
+
|
|
668
|
+
<Button type="submit">Upload</Button>
|
|
669
|
+
</form>
|
|
670
|
+
</Form>
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
## Cache Invalidation After Form Submission
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
const queryClient = useQueryClient()
|
|
679
|
+
|
|
680
|
+
// In form submission handler
|
|
681
|
+
const handleSubmit = async (values) => {
|
|
682
|
+
const result = await tryCatch(upsertMutation.mutate({ body: values }))
|
|
683
|
+
|
|
684
|
+
if (result.error) {
|
|
685
|
+
toast.error('Error saving data')
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
toast.success('Data saved successfully!')
|
|
690
|
+
|
|
691
|
+
// Invalidate relevant queries to refetch data
|
|
692
|
+
queryClient.invalidate(['task.list'])
|
|
693
|
+
|
|
694
|
+
// Close modal/dialog
|
|
695
|
+
onClose()
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
## Best Practices
|
|
700
|
+
|
|
701
|
+
### 1. Form Organization
|
|
702
|
+
|
|
703
|
+
* Keep form components focused on a single purpose
|
|
704
|
+
* Extract complex form logic into custom hooks
|
|
705
|
+
* Group related fields together
|
|
706
|
+
* Use consistent spacing and layout for all forms
|
|
707
|
+
|
|
708
|
+
### 2. Performance Optimization
|
|
709
|
+
|
|
710
|
+
* Use form validation modes appropriately:
|
|
711
|
+
- `onChange`: Validates as user types (best for simple forms)
|
|
712
|
+
- `onBlur`: Validates when field loses focus (better UX for most forms)
|
|
713
|
+
- `onSubmit`: Validates only on submit (best for complex forms)
|
|
714
|
+
|
|
715
|
+
```typescript
|
|
716
|
+
const form = useFormWithZod({
|
|
717
|
+
schema: schema,
|
|
718
|
+
defaultValues: { /* ... */ },
|
|
719
|
+
mode: 'onBlur', // or 'onChange', 'onSubmit'
|
|
720
|
+
})
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
* Debounce validation for text inputs:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
<Input
|
|
727
|
+
{...field}
|
|
728
|
+
onChange={(e) => {
|
|
729
|
+
clearTimeout(timeout.current)
|
|
730
|
+
timeout.current = setTimeout(() => {
|
|
731
|
+
field.onChange(e)
|
|
732
|
+
}, 300)
|
|
733
|
+
}}
|
|
734
|
+
/>
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### 3. Accessibility
|
|
738
|
+
|
|
739
|
+
* Always use `FormLabel` for form inputs
|
|
740
|
+
* Ensure form controls have appropriate ARIA attributes
|
|
741
|
+
* Provide clear error messages
|
|
742
|
+
* Make forms keyboard navigable
|
|
743
|
+
* Use `fieldset` and `legend` for groups of related inputs
|
|
744
|
+
|
|
745
|
+
```tsx
|
|
746
|
+
<fieldset className="border rounded-md p-4">
|
|
747
|
+
<legend className="text-sm font-medium px-2">Contact Information</legend>
|
|
748
|
+
{/* Form fields */}
|
|
749
|
+
}
|
|
750
|
+
</fieldset>
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### 4. Error Prevention
|
|
754
|
+
|
|
755
|
+
* Provide clear validation messages
|
|
756
|
+
* Use placeholder text to guide users
|
|
757
|
+
* Implement input masks for formatted fields
|
|
758
|
+
* Show validation feedback as users type
|
|
759
|
+
* Confirm destructive actions
|
|
760
|
+
|
|
761
|
+
### 5. Reusability
|
|
762
|
+
|
|
763
|
+
Create custom form field components for common patterns:
|
|
764
|
+
|
|
765
|
+
```tsx
|
|
766
|
+
function FormTextField({ name, label, placeholder, ...props }) {
|
|
767
|
+
return (
|
|
768
|
+
<FormField
|
|
769
|
+
name={name}
|
|
770
|
+
render={({ field }) => (
|
|
771
|
+
<FormItem>
|
|
772
|
+
<FormLabel>{label}</FormLabel>
|
|
773
|
+
<FormControl>
|
|
774
|
+
<Input placeholder={placeholder} {...field} {...props} />
|
|
775
|
+
</FormControl>
|
|
776
|
+
<FormMessage />
|
|
777
|
+
</FormItem>
|
|
778
|
+
)}
|
|
779
|
+
/>
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Usage
|
|
784
|
+
<FormTextField name="title" label="Title" placeholder="Enter a title" />
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### 6. Testing
|
|
788
|
+
|
|
789
|
+
* Test form validation with valid and invalid inputs
|
|
790
|
+
* Test form submission with mock API calls
|
|
791
|
+
* Test form reset functionality
|
|
792
|
+
* Test form accessibility using jest-axe or similar tools
|
|
793
|
+
|
|
794
|
+
## Complete Example: Task Form
|
|
795
|
+
|
|
796
|
+
Here's a complete example of a task creation/editing form:
|
|
797
|
+
|
|
798
|
+
```tsx
|
|
799
|
+
'use client'
|
|
800
|
+
|
|
801
|
+
import * as z from 'zod'
|
|
802
|
+
import { useRef } from 'react'
|
|
803
|
+
import { format } from 'date-fns'
|
|
804
|
+
import { toast } from 'sonner'
|
|
805
|
+
import { api, useQueryClient } from '@/igniter.client'
|
|
806
|
+
import { useFormWithZod } from '@/hooks/use-form-with-zod'
|
|
807
|
+
import { tryCatch } from '@/utils/try-catch'
|
|
808
|
+
import { Task } from '../../task.interface'
|
|
809
|
+
|
|
810
|
+
import { cn } from '@/lib/utils'
|
|
811
|
+
import { Button } from '@/components/ui/button'
|
|
812
|
+
import { Calendar } from '@/components/ui/calendar'
|
|
813
|
+
import {
|
|
814
|
+
Dialog,
|
|
815
|
+
DialogContent,
|
|
816
|
+
DialogHeader,
|
|
817
|
+
DialogTitle,
|
|
818
|
+
DialogFooter,
|
|
819
|
+
DialogTrigger,
|
|
820
|
+
} from '@/components/ui/dialog'
|
|
821
|
+
import {
|
|
822
|
+
Form,
|
|
823
|
+
FormControl,
|
|
824
|
+
FormField,
|
|
825
|
+
FormItem,
|
|
826
|
+
FormLabel,
|
|
827
|
+
FormMessage,
|
|
828
|
+
} from '@/components/ui/form'
|
|
829
|
+
import { Input } from '@/components/ui/input'
|
|
830
|
+
import { Textarea } from '@/components/ui/textarea'
|
|
831
|
+
import {
|
|
832
|
+
Popover,
|
|
833
|
+
PopoverContent,
|
|
834
|
+
PopoverTrigger,
|
|
835
|
+
} from '@/components/ui/popover'
|
|
836
|
+
import { ArrowRight, CalendarIcon, Trash2 } from 'lucide-react'
|
|
837
|
+
|
|
838
|
+
// 1. Define form schema
|
|
839
|
+
const schema = z.object({
|
|
840
|
+
id: z.string().optional(),
|
|
841
|
+
title: z.string().min(3, 'Title must be at least 3 characters'),
|
|
842
|
+
description: z.string().optional(),
|
|
843
|
+
dueDate: z.date().transform(value => value.toISOString()).optional(),
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
type TaskDialogProps = {
|
|
847
|
+
defaultValues?: Task;
|
|
848
|
+
children: React.ReactNode;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function TaskDialog({ defaultValues, children }: TaskDialogProps) {
|
|
852
|
+
// 2. Setup references and API
|
|
853
|
+
const triggerRef = useRef<HTMLDivElement>(null)
|
|
854
|
+
const queryClient = useQueryClient()
|
|
855
|
+
const upsertMutation = api.task.upsert.useMutation()
|
|
856
|
+
const deleteMutation = api.task.delete.useMutation()
|
|
857
|
+
|
|
858
|
+
// 3. Initialize form with Zod
|
|
859
|
+
const form = useFormWithZod({
|
|
860
|
+
schema: schema,
|
|
861
|
+
defaultValues: defaultValues || { title: '', description: '' },
|
|
862
|
+
onSubmit: async (values) => {
|
|
863
|
+
const result = await tryCatch(upsertMutation.mutate({ body: values }))
|
|
864
|
+
|
|
865
|
+
if (result.error) {
|
|
866
|
+
toast.error('Error saving task. Please try again.')
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (values.id) toast.success('Task updated successfully!')
|
|
871
|
+
if (!values.id) toast.success('Task created successfully!')
|
|
872
|
+
|
|
873
|
+
// 4. Invalidate queries to refetch data
|
|
874
|
+
queryClient.invalidate(['task.list'])
|
|
875
|
+
form.reset()
|
|
876
|
+
triggerRef.current?.click() // Close dialog
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
// 5. Handle delete action
|
|
881
|
+
const handleDelete = async (task: Task) => {
|
|
882
|
+
const result = await tryCatch(deleteMutation.mutate({ params: { id: task.id } }))
|
|
883
|
+
|
|
884
|
+
if (result.error) {
|
|
885
|
+
toast.error('Error deleting task. Please try again.')
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
toast.success('Task deleted successfully!')
|
|
890
|
+
queryClient.invalidate(['task.list'])
|
|
891
|
+
triggerRef.current?.click() // Close dialog
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 6. Render form
|
|
895
|
+
return (
|
|
896
|
+
<Dialog onOpenChange={() => form.reset()}>
|
|
897
|
+
<DialogTrigger asChild>
|
|
898
|
+
<div ref={triggerRef}>
|
|
899
|
+
{children}
|
|
900
|
+
</div>
|
|
901
|
+
</DialogTrigger>
|
|
902
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
903
|
+
<DialogHeader>
|
|
904
|
+
<DialogTitle>
|
|
905
|
+
{defaultValues ? 'Edit Task' : 'Create Task'}
|
|
906
|
+
</DialogTitle>
|
|
907
|
+
</DialogHeader>
|
|
908
|
+
|
|
909
|
+
<Form {...form}>
|
|
910
|
+
<form onSubmit={form.onSubmit} className="space-y-4 py-4">
|
|
911
|
+
{/* Title field */}
|
|
912
|
+
<FormField
|
|
913
|
+
control={form.control}
|
|
914
|
+
name="title"
|
|
915
|
+
render={({ field }) => (
|
|
916
|
+
<FormItem>
|
|
917
|
+
<FormLabel>Title</FormLabel>
|
|
918
|
+
<FormControl>
|
|
919
|
+
<Input placeholder="Task title..." {...field} />
|
|
920
|
+
</FormControl>
|
|
921
|
+
<FormMessage />
|
|
922
|
+
</FormItem>
|
|
923
|
+
)}
|
|
924
|
+
/>
|
|
925
|
+
|
|
926
|
+
{/* Description field */}
|
|
927
|
+
<FormField
|
|
928
|
+
control={form.control}
|
|
929
|
+
name="description"
|
|
930
|
+
render={({ field }) => (
|
|
931
|
+
<FormItem>
|
|
932
|
+
<FormLabel>Description</FormLabel>
|
|
933
|
+
<FormControl>
|
|
934
|
+
<Textarea
|
|
935
|
+
placeholder="Task description..."
|
|
936
|
+
{...field}
|
|
937
|
+
/>
|
|
938
|
+
</FormControl>
|
|
939
|
+
<FormMessage />
|
|
940
|
+
</FormItem>
|
|
941
|
+
)}
|
|
942
|
+
/>
|
|
943
|
+
|
|
944
|
+
{/* Due Date field */}
|
|
945
|
+
<FormField
|
|
946
|
+
control={form.control}
|
|
947
|
+
name="dueDate"
|
|
948
|
+
render={({ field }) => (
|
|
949
|
+
<FormItem className="flex flex-col">
|
|
950
|
+
<FormLabel>Due Date</FormLabel>
|
|
951
|
+
<Popover>
|
|
952
|
+
<PopoverTrigger asChild>
|
|
953
|
+
<FormControl>
|
|
954
|
+
<Button
|
|
955
|
+
variant="outline"
|
|
956
|
+
className={cn(
|
|
957
|
+
'w-full pl-3 text-left font-normal',
|
|
958
|
+
!field.value && 'text-muted-foreground'
|
|
959
|
+
)}
|
|
960
|
+
>
|
|
961
|
+
{field.value ? (
|
|
962
|
+
format(field.value, 'PPP')
|
|
963
|
+
) : (
|
|
964
|
+
<span>Pick a date</span>
|
|
965
|
+
)}
|
|
966
|
+
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
|
967
|
+
</Button>
|
|
968
|
+
</FormControl>
|
|
969
|
+
</PopoverTrigger>
|
|
970
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
971
|
+
<Calendar
|
|
972
|
+
mode="single"
|
|
973
|
+
selected={field.value ? new Date(field.value) : undefined}
|
|
974
|
+
onSelect={field.onChange}
|
|
975
|
+
disabled={(date) =>
|
|
976
|
+
date < new Date(new Date().setHours(0, 0, 0, 0))
|
|
977
|
+
}
|
|
978
|
+
initialFocus
|
|
979
|
+
/>
|
|
980
|
+
</PopoverContent>
|
|
981
|
+
</Popover>
|
|
982
|
+
<FormMessage />
|
|
983
|
+
</FormItem>
|
|
984
|
+
)}
|
|
985
|
+
/>
|
|
986
|
+
</form>
|
|
987
|
+
</Form>
|
|
988
|
+
|
|
989
|
+
{/* Action buttons */}
|
|
990
|
+
<DialogFooter className="sm:justify-between">
|
|
991
|
+
<Button type="submit" onClick={form.onSubmit}>
|
|
992
|
+
{defaultValues ? 'Update' : 'Create'}
|
|
993
|
+
<ArrowRight className="ml-2" />
|
|
994
|
+
</Button>
|
|
995
|
+
{defaultValues && (
|
|
996
|
+
<Button variant="destructive" onClick={() => handleDelete(defaultValues)}>
|
|
997
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
998
|
+
Delete
|
|
999
|
+
</Button>
|
|
1000
|
+
)}
|
|
1001
|
+
</DialogFooter>
|
|
1002
|
+
</DialogContent>
|
|
1003
|
+
</Dialog>
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
This example demonstrates:
|
|
1009
|
+
- Schema definition with Zod
|
|
1010
|
+
- Form state management with useFormWithZod
|
|
1011
|
+
- Form field rendering with shadcn/ui components
|
|
1012
|
+
- Form submission with error handling
|
|
1013
|
+
- Cache invalidation after successful submission
|
|
1014
|
+
- Delete functionality with confirmation
|
|
1015
|
+
- Dialog integration with proper state management
|
|
1016
|
+
|
|
1017
|
+
## Conclusion
|
|
1018
|
+
|
|
1019
|
+
By following these guidelines and patterns, you can build robust, type-safe, and user-friendly forms in your Igniter.js applications. Proper form implementation not only improves the developer experience but also significantly enhances the user experience by providing clear validation feedback and smooth interactions.
|
|
1020
|
+
|
|
1021
|
+
Remember that forms are often the primary way users interact with your application, so investing time in creating high-quality form experiences pays significant dividends in user satisfaction and engagement.
|
|
@@ -11,13 +11,11 @@ export interface {{pascalCase name}} {
|
|
|
11
11
|
{{#each fields}}
|
|
12
12
|
{{#if isRelation}}
|
|
13
13
|
{{#if isList}}
|
|
14
|
-
/** Array of IDs representing the related {{pascalCase type}} entities */
|
|
15
|
-
{{name}}Ids: string[];
|
|
16
14
|
/** Related {{pascalCase type}} entities */
|
|
17
|
-
{{name}}
|
|
15
|
+
{{name}}?: {{pascalCase type}}[];
|
|
18
16
|
{{else}}
|
|
19
17
|
/** Related {{pascalCase type}} entity */
|
|
20
|
-
{{name}}: {{pascalCase type}};
|
|
18
|
+
{{name}}{{#if isOptional}}?{{/if}}: {{pascalCase type}};
|
|
21
19
|
{{/if}}
|
|
22
20
|
{{else}}
|
|
23
21
|
/** {{pascalCase name}}'s {{name}} property */
|
|
@@ -7,26 +7,28 @@ export const {{pascalCase name}}FeatureProcedure = igniter.procedure({
|
|
|
7
7
|
return {
|
|
8
8
|
{{camelCase name}}: {
|
|
9
9
|
findMany: async (query: {{pascalCase name}}QueryParams): Promise<{{pascalCase name}}[]> => {
|
|
10
|
-
return context.providers.database.{{
|
|
10
|
+
return context.providers.database.{{lowerCase name }}.findMany({
|
|
11
11
|
where: query.search ? {
|
|
12
12
|
OR: [
|
|
13
13
|
{{#each fields}}
|
|
14
14
|
{{#unless isRelation}}
|
|
15
|
+
{{#unless (or (equals name 'id') (equals name 'createdAt') (equals name 'updatedAt'))}}
|
|
16
|
+
{{#if (equals type 'String')}}
|
|
15
17
|
{ {{name}}: { contains: query.search } },
|
|
18
|
+
{{/if}}
|
|
19
|
+
{{/unless}}
|
|
16
20
|
{{/unless}}
|
|
17
21
|
{{/each}}
|
|
18
22
|
]
|
|
19
23
|
} : undefined,
|
|
20
24
|
skip: query.page ? (query.page - 1) * (query.limit || 10) : undefined,
|
|
21
25
|
take: query.limit,
|
|
22
|
-
orderBy: query.sortBy ? {
|
|
23
|
-
[query.sortBy]: query.sortOrder || 'asc'
|
|
24
|
-
} : undefined
|
|
26
|
+
orderBy: query.sortBy ? {[query.sortBy]: query.sortOrder || 'asc'} : undefined
|
|
25
27
|
});
|
|
26
28
|
},
|
|
27
29
|
|
|
28
30
|
findOne: async (params: { id: string }): Promise<{{pascalCase name}} | null> => {
|
|
29
|
-
return context.providers.database.{{camelCase name}}.findUnique({
|
|
31
|
+
return context.providers.database.{{camelCase name }}.findUnique({
|
|
30
32
|
where: {
|
|
31
33
|
id: params.id
|
|
32
34
|
}
|
|
@@ -34,38 +36,42 @@ export const {{pascalCase name}}FeatureProcedure = igniter.procedure({
|
|
|
34
36
|
},
|
|
35
37
|
|
|
36
38
|
create: async (input: Create{{pascalCase name}}DTO): Promise<{{pascalCase name}}> => {
|
|
37
|
-
return context.providers.database.{{camelCase name}}.create({
|
|
39
|
+
return context.providers.database.{{camelCase name }}.create({
|
|
38
40
|
data: {
|
|
39
41
|
{{#each fields}}
|
|
40
42
|
{{#unless isRelation}}
|
|
43
|
+
{{#unless (or (equals name 'id') (equals name 'createdAt') (equals name 'updatedAt'))}}
|
|
41
44
|
{{name}}: input.{{name}},
|
|
42
45
|
{{/unless}}
|
|
46
|
+
{{/unless}}
|
|
43
47
|
{{/each}}
|
|
44
48
|
}
|
|
45
49
|
});
|
|
46
50
|
},
|
|
47
51
|
|
|
48
52
|
update: async (params: { id: string } & Update{{pascalCase name}}DTO): Promise<{{pascalCase name}}> => {
|
|
49
|
-
const {{camelCase name}} = await context.providers.database.{{
|
|
53
|
+
const {{camelCase name}} = await context.providers.database.{{lowerCase name }}.findUnique({
|
|
50
54
|
where: { id: params.id }
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
if (!{{camelCase name}}) throw new Error("{{pascalCase name}} not found");
|
|
54
58
|
|
|
55
|
-
return context.providers.database.{{
|
|
59
|
+
return context.providers.database.{{lowerCase name }}.update({
|
|
56
60
|
where: { id: params.id },
|
|
57
61
|
data: {
|
|
58
62
|
{{#each fields}}
|
|
59
63
|
{{#unless isRelation}}
|
|
64
|
+
{{#unless (or (equals name 'id') (equals name 'createdAt') (equals name 'updatedAt'))}}
|
|
60
65
|
{{name}}: params.{{name}},
|
|
61
66
|
{{/unless}}
|
|
67
|
+
{{/unless}}
|
|
62
68
|
{{/each}}
|
|
63
69
|
}
|
|
64
70
|
});
|
|
65
71
|
},
|
|
66
72
|
|
|
67
73
|
delete: async (params: { id: string }): Promise<{ id: string }> => {
|
|
68
|
-
await context.providers.database.{{
|
|
74
|
+
await context.providers.database.{{lowerCase name }}.delete({
|
|
69
75
|
where: { id: params.id }
|
|
70
76
|
});
|
|
71
77
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useForm } from "react-hook-form";
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
|
|
6
|
+
interface UseFormOptions<T extends z.ZodType> {
|
|
7
|
+
schema: T;
|
|
8
|
+
defaultValues?: z.infer<T>;
|
|
9
|
+
onSubmit?: (values: z.infer<T>) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useFormWithZod<T extends z.ZodType>({
|
|
13
|
+
schema,
|
|
14
|
+
defaultValues,
|
|
15
|
+
onSubmit,
|
|
16
|
+
...rest
|
|
17
|
+
}: UseFormOptions<T>) {
|
|
18
|
+
const form = useForm<z.infer<T>>({
|
|
19
|
+
resolver: zodResolver(schema),
|
|
20
|
+
defaultValues,
|
|
21
|
+
...rest,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const prevDefaultValuesRef = useRef(defaultValues);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const isDefaultValuesDifferent = JSON.stringify(prevDefaultValuesRef.current) !== JSON.stringify(defaultValues);
|
|
28
|
+
|
|
29
|
+
if (defaultValues && isDefaultValuesDifferent) {
|
|
30
|
+
prevDefaultValuesRef.current = defaultValues;
|
|
31
|
+
form.reset(defaultValues);
|
|
32
|
+
}
|
|
33
|
+
}, [defaultValues, form]);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...form,
|
|
37
|
+
onSubmit: form.handleSubmit(onSubmit || (() => {})),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/utils/consts.js
CHANGED
|
@@ -23,6 +23,7 @@ exports.LIA_FILES = [
|
|
|
23
23
|
{ name: '.github/copilot.next.instructions.md', template: 'copilot.next.instructions.hbs' },
|
|
24
24
|
{ name: '.github/copilot.review.instructions.md', template: 'copilot.review.instructions.hbs' },
|
|
25
25
|
{ name: '.github/copilot.test.instructions.md', template: 'copilot.test.instructions.hbs' },
|
|
26
|
+
{ name: '.github/copilot.form.instructions.md', template: 'copilot.form.instructions.hbs' },
|
|
26
27
|
{ name: '.vscode/settings.json', template: 'vscode.settings.hbs' }
|
|
27
28
|
];
|
|
28
29
|
exports.CONFIG_FILES = [
|
|
@@ -7,7 +7,13 @@ exports.registerHelpers = registerHelpers;
|
|
|
7
7
|
const handlebars_1 = __importDefault(require("handlebars"));
|
|
8
8
|
function registerHelpers() {
|
|
9
9
|
handlebars_1.default.registerHelper('camelCase', (str) => {
|
|
10
|
-
|
|
10
|
+
if (!str)
|
|
11
|
+
return '';
|
|
12
|
+
const result = str
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
|
|
15
|
+
.trim();
|
|
16
|
+
return result;
|
|
11
17
|
});
|
|
12
18
|
handlebars_1.default.registerHelper('pascalCase', (str) => {
|
|
13
19
|
const camelCase = str.replace(/[-_]([a-z])/g, (g) => g[1].toUpperCase());
|
|
@@ -60,4 +66,10 @@ function registerHelpers() {
|
|
|
60
66
|
handlebars_1.default.registerHelper('ne', (a, b) => {
|
|
61
67
|
return a !== b;
|
|
62
68
|
});
|
|
69
|
+
handlebars_1.default.registerHelper('or', (a, b) => {
|
|
70
|
+
return a || b;
|
|
71
|
+
});
|
|
72
|
+
handlebars_1.default.registerHelper('isString', (value) => {
|
|
73
|
+
return typeof value === 'string';
|
|
74
|
+
});
|
|
63
75
|
}
|