red64-cli 0.1.0 → 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/README.md +1 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +5 -0
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/components/init/CompleteStep.d.ts.map +1 -1
- package/dist/components/init/CompleteStep.js +2 -2
- package/dist/components/init/CompleteStep.js.map +1 -1
- package/dist/components/init/TestCheckStep.d.ts +16 -0
- package/dist/components/init/TestCheckStep.d.ts.map +1 -0
- package/dist/components/init/TestCheckStep.js +120 -0
- package/dist/components/init/TestCheckStep.js.map +1 -0
- package/dist/components/init/index.d.ts +1 -0
- package/dist/components/init/index.d.ts.map +1 -1
- package/dist/components/init/index.js +1 -0
- package/dist/components/init/index.js.map +1 -1
- package/dist/components/init/types.d.ts +9 -0
- package/dist/components/init/types.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.js +69 -6
- package/dist/components/screens/InitScreen.js.map +1 -1
- package/dist/components/screens/ListScreen.d.ts.map +1 -1
- package/dist/components/screens/ListScreen.js +28 -3
- package/dist/components/screens/ListScreen.js.map +1 -1
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +212 -13
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/components/ui/ArtifactsSidebar.d.ts +19 -0
- package/dist/components/ui/ArtifactsSidebar.d.ts.map +1 -0
- package/dist/components/ui/ArtifactsSidebar.js +51 -0
- package/dist/components/ui/ArtifactsSidebar.js.map +1 -0
- package/dist/components/ui/FeatureSidebar.d.ts.map +1 -1
- package/dist/components/ui/FeatureSidebar.js +1 -1
- package/dist/components/ui/FeatureSidebar.js.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/services/ClaudeErrorDetector.js +3 -3
- package/dist/services/ClaudeErrorDetector.js.map +1 -1
- package/dist/services/ConfigService.d.ts +1 -0
- package/dist/services/ConfigService.d.ts.map +1 -1
- package/dist/services/ConfigService.js.map +1 -1
- package/dist/services/ProjectDetector.d.ts +28 -0
- package/dist/services/ProjectDetector.d.ts.map +1 -0
- package/dist/services/ProjectDetector.js +236 -0
- package/dist/services/ProjectDetector.js.map +1 -0
- package/dist/services/TestRunner.d.ts +46 -0
- package/dist/services/TestRunner.d.ts.map +1 -0
- package/dist/services/TestRunner.js +85 -0
- package/dist/services/TestRunner.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/framework/.red64/settings/templates/specs/gap-analysis.md +163 -0
- package/framework/agents/claude/.claude/agents/red64/spec-impl.md +131 -2
- package/framework/agents/claude/.claude/agents/red64/validate-gap.md +13 -7
- package/framework/agents/claude/.claude/commands/red64/spec-impl.md +24 -0
- package/framework/agents/claude/.claude/commands/red64/validate-gap.md +4 -0
- package/framework/agents/codex/.codex/agents/red64/spec-impl.md +131 -2
- package/framework/agents/codex/.codex/agents/red64/validate-gap.md +13 -7
- package/framework/agents/codex/.codex/commands/red64/spec-impl.md +24 -0
- package/framework/agents/codex/.codex/commands/red64/validate-gap.md +4 -0
- package/framework/stacks/generic/feedback.md +80 -0
- package/framework/stacks/nextjs/accessibility.md +437 -0
- package/framework/stacks/nextjs/api.md +431 -0
- package/framework/stacks/nextjs/coding-style.md +282 -0
- package/framework/stacks/nextjs/commenting.md +226 -0
- package/framework/stacks/nextjs/components.md +411 -0
- package/framework/stacks/nextjs/conventions.md +333 -0
- package/framework/stacks/nextjs/css.md +310 -0
- package/framework/stacks/nextjs/error-handling.md +442 -0
- package/framework/stacks/nextjs/feedback.md +124 -0
- package/framework/stacks/nextjs/migrations.md +332 -0
- package/framework/stacks/nextjs/models.md +362 -0
- package/framework/stacks/nextjs/queries.md +410 -0
- package/framework/stacks/nextjs/responsive.md +338 -0
- package/framework/stacks/nextjs/tech-stack.md +177 -0
- package/framework/stacks/nextjs/test-writing.md +475 -0
- package/framework/stacks/nextjs/validation.md +467 -0
- package/framework/stacks/python/api.md +468 -0
- package/framework/stacks/python/authentication.md +342 -0
- package/framework/stacks/python/code-quality.md +283 -0
- package/framework/stacks/python/code-refactoring.md +315 -0
- package/framework/stacks/python/coding-style.md +462 -0
- package/framework/stacks/python/conventions.md +399 -0
- package/framework/stacks/python/error-handling.md +512 -0
- package/framework/stacks/python/feedback.md +92 -0
- package/framework/stacks/python/implement-ai-llm.md +468 -0
- package/framework/stacks/python/migrations.md +388 -0
- package/framework/stacks/python/models.md +399 -0
- package/framework/stacks/python/python.md +232 -0
- package/framework/stacks/python/queries.md +451 -0
- package/framework/stacks/python/structure.md +245 -58
- package/framework/stacks/python/tech.md +92 -35
- package/framework/stacks/python/testing.md +380 -0
- package/framework/stacks/python/validation.md +471 -0
- package/framework/stacks/rails/authentication.md +176 -0
- package/framework/stacks/rails/code-quality.md +287 -0
- package/framework/stacks/rails/code-refactoring.md +299 -0
- package/framework/stacks/rails/feedback.md +130 -0
- package/framework/stacks/rails/implement-ai-llm-with-rubyllm.md +342 -0
- package/framework/stacks/rails/rails.md +301 -0
- package/framework/stacks/rails/rails8-best-practices.md +498 -0
- package/framework/stacks/rails/rails8-css.md +573 -0
- package/framework/stacks/rails/structure.md +140 -0
- package/framework/stacks/rails/tech.md +108 -0
- package/framework/stacks/react/code-quality.md +521 -0
- package/framework/stacks/react/components.md +625 -0
- package/framework/stacks/react/data-fetching.md +586 -0
- package/framework/stacks/react/feedback.md +110 -0
- package/framework/stacks/react/forms.md +694 -0
- package/framework/stacks/react/performance.md +640 -0
- package/framework/stacks/react/product.md +22 -9
- package/framework/stacks/react/state-management.md +472 -0
- package/framework/stacks/react/structure.md +351 -44
- package/framework/stacks/react/tech.md +219 -30
- package/framework/stacks/react/testing.md +690 -0
- package/package.json +1 -1
- package/framework/stacks/node/product.md +0 -27
- package/framework/stacks/node/structure.md +0 -82
- package/framework/stacks/node/tech.md +0 -63
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
# Form Patterns
|
|
2
|
+
|
|
3
|
+
Form handling with React Hook Form and Zod for type-safe, performant forms with excellent UX.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Minimal re-renders**: Uncontrolled inputs with React Hook Form
|
|
10
|
+
- **Schema-first validation**: Define shape and rules in Zod, infer types
|
|
11
|
+
- **Accessible errors**: Clear, associated error messages
|
|
12
|
+
- **Optimistic UX**: Instant feedback, disable during submission
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add react-hook-form zod @hookform/resolvers
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Basic Form
|
|
25
|
+
|
|
26
|
+
### Schema Definition
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// features/users/schemas/user.schema.ts
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
|
|
32
|
+
export const createUserSchema = z.object({
|
|
33
|
+
name: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1, 'Name is required')
|
|
36
|
+
.max(100, 'Name must be less than 100 characters'),
|
|
37
|
+
email: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1, 'Email is required')
|
|
40
|
+
.email('Invalid email address'),
|
|
41
|
+
password: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(8, 'Password must be at least 8 characters')
|
|
44
|
+
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
|
45
|
+
.regex(/[0-9]/, 'Password must contain a number'),
|
|
46
|
+
role: z.enum(['admin', 'user', 'guest'], {
|
|
47
|
+
errorMap: () => ({ message: 'Please select a role' }),
|
|
48
|
+
}),
|
|
49
|
+
bio: z.string().max(500).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Infer TypeScript type from schema
|
|
53
|
+
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Form Component
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// features/users/components/CreateUserForm.tsx
|
|
60
|
+
import { useForm } from 'react-hook-form';
|
|
61
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
62
|
+
import { createUserSchema, type CreateUserInput } from '../schemas/user.schema';
|
|
63
|
+
import { useCreateUser } from '../hooks/useCreateUser';
|
|
64
|
+
|
|
65
|
+
export function CreateUserForm({ onSuccess }: { onSuccess?: () => void }) {
|
|
66
|
+
const createUser = useCreateUser();
|
|
67
|
+
|
|
68
|
+
const form = useForm<CreateUserInput>({
|
|
69
|
+
resolver: zodResolver(createUserSchema),
|
|
70
|
+
defaultValues: {
|
|
71
|
+
name: '',
|
|
72
|
+
email: '',
|
|
73
|
+
password: '',
|
|
74
|
+
role: 'user',
|
|
75
|
+
bio: '',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const onSubmit = (data: CreateUserInput) => {
|
|
80
|
+
createUser.mutate(data, {
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
form.reset();
|
|
83
|
+
onSuccess?.();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
|
90
|
+
<FormField
|
|
91
|
+
label="Name"
|
|
92
|
+
error={form.formState.errors.name?.message}
|
|
93
|
+
>
|
|
94
|
+
<input
|
|
95
|
+
{...form.register('name')}
|
|
96
|
+
type="text"
|
|
97
|
+
aria-invalid={!!form.formState.errors.name}
|
|
98
|
+
/>
|
|
99
|
+
</FormField>
|
|
100
|
+
|
|
101
|
+
<FormField
|
|
102
|
+
label="Email"
|
|
103
|
+
error={form.formState.errors.email?.message}
|
|
104
|
+
>
|
|
105
|
+
<input
|
|
106
|
+
{...form.register('email')}
|
|
107
|
+
type="email"
|
|
108
|
+
aria-invalid={!!form.formState.errors.email}
|
|
109
|
+
/>
|
|
110
|
+
</FormField>
|
|
111
|
+
|
|
112
|
+
<FormField
|
|
113
|
+
label="Password"
|
|
114
|
+
error={form.formState.errors.password?.message}
|
|
115
|
+
>
|
|
116
|
+
<input
|
|
117
|
+
{...form.register('password')}
|
|
118
|
+
type="password"
|
|
119
|
+
aria-invalid={!!form.formState.errors.password}
|
|
120
|
+
/>
|
|
121
|
+
</FormField>
|
|
122
|
+
|
|
123
|
+
<FormField
|
|
124
|
+
label="Role"
|
|
125
|
+
error={form.formState.errors.role?.message}
|
|
126
|
+
>
|
|
127
|
+
<select {...form.register('role')}>
|
|
128
|
+
<option value="">Select a role</option>
|
|
129
|
+
<option value="admin">Admin</option>
|
|
130
|
+
<option value="user">User</option>
|
|
131
|
+
<option value="guest">Guest</option>
|
|
132
|
+
</select>
|
|
133
|
+
</FormField>
|
|
134
|
+
|
|
135
|
+
<FormField
|
|
136
|
+
label="Bio (optional)"
|
|
137
|
+
error={form.formState.errors.bio?.message}
|
|
138
|
+
>
|
|
139
|
+
<textarea {...form.register('bio')} rows={4} />
|
|
140
|
+
</FormField>
|
|
141
|
+
|
|
142
|
+
<Button
|
|
143
|
+
type="submit"
|
|
144
|
+
disabled={form.formState.isSubmitting}
|
|
145
|
+
isLoading={form.formState.isSubmitting}
|
|
146
|
+
>
|
|
147
|
+
Create User
|
|
148
|
+
</Button>
|
|
149
|
+
</form>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Form Field Component
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
// components/ui/FormField/FormField.tsx
|
|
160
|
+
import { useId, type ReactNode } from 'react';
|
|
161
|
+
import { cn } from '@/utils/cn';
|
|
162
|
+
|
|
163
|
+
interface FormFieldProps {
|
|
164
|
+
label: string;
|
|
165
|
+
error?: string;
|
|
166
|
+
description?: string;
|
|
167
|
+
required?: boolean;
|
|
168
|
+
children: ReactNode;
|
|
169
|
+
className?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function FormField({
|
|
173
|
+
label,
|
|
174
|
+
error,
|
|
175
|
+
description,
|
|
176
|
+
required,
|
|
177
|
+
children,
|
|
178
|
+
className,
|
|
179
|
+
}: FormFieldProps) {
|
|
180
|
+
const id = useId();
|
|
181
|
+
const errorId = `${id}-error`;
|
|
182
|
+
const descriptionId = `${id}-description`;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className={cn('mb-4', className)}>
|
|
186
|
+
<label htmlFor={id} className="mb-1 block text-sm font-medium">
|
|
187
|
+
{label}
|
|
188
|
+
{required && <span className="text-red-500 ml-1">*</span>}
|
|
189
|
+
</label>
|
|
190
|
+
|
|
191
|
+
{description && (
|
|
192
|
+
<p id={descriptionId} className="mb-1 text-sm text-gray-500">
|
|
193
|
+
{description}
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* Clone child and add props */}
|
|
198
|
+
<div
|
|
199
|
+
className={cn(
|
|
200
|
+
'[&>input]:w-full [&>input]:rounded-md [&>input]:border [&>input]:px-3 [&>input]:py-2',
|
|
201
|
+
'[&>select]:w-full [&>select]:rounded-md [&>select]:border [&>select]:px-3 [&>select]:py-2',
|
|
202
|
+
'[&>textarea]:w-full [&>textarea]:rounded-md [&>textarea]:border [&>textarea]:px-3 [&>textarea]:py-2',
|
|
203
|
+
error && '[&>*]:border-red-500'
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{children}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{error && (
|
|
210
|
+
<p id={errorId} className="mt-1 text-sm text-red-500" role="alert">
|
|
211
|
+
{error}
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Advanced Validation
|
|
222
|
+
|
|
223
|
+
### Cross-Field Validation
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const changePasswordSchema = z
|
|
227
|
+
.object({
|
|
228
|
+
currentPassword: z.string().min(1, 'Current password is required'),
|
|
229
|
+
newPassword: z
|
|
230
|
+
.string()
|
|
231
|
+
.min(8, 'Password must be at least 8 characters')
|
|
232
|
+
.regex(/[A-Z]/, 'Must contain uppercase letter')
|
|
233
|
+
.regex(/[0-9]/, 'Must contain number'),
|
|
234
|
+
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
|
235
|
+
})
|
|
236
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
237
|
+
message: 'Passwords do not match',
|
|
238
|
+
path: ['confirmPassword'], // Error shown on this field
|
|
239
|
+
})
|
|
240
|
+
.refine((data) => data.newPassword !== data.currentPassword, {
|
|
241
|
+
message: 'New password must be different from current password',
|
|
242
|
+
path: ['newPassword'],
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Conditional Validation
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
const orderSchema = z
|
|
250
|
+
.object({
|
|
251
|
+
deliveryMethod: z.enum(['pickup', 'delivery']),
|
|
252
|
+
address: z.string().optional(),
|
|
253
|
+
city: z.string().optional(),
|
|
254
|
+
zipCode: z.string().optional(),
|
|
255
|
+
})
|
|
256
|
+
.refine(
|
|
257
|
+
(data) => {
|
|
258
|
+
if (data.deliveryMethod === 'delivery') {
|
|
259
|
+
return data.address && data.city && data.zipCode;
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
message: 'Address is required for delivery',
|
|
265
|
+
path: ['address'],
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Or use discriminated union
|
|
270
|
+
const orderSchema = z.discriminatedUnion('deliveryMethod', [
|
|
271
|
+
z.object({
|
|
272
|
+
deliveryMethod: z.literal('pickup'),
|
|
273
|
+
}),
|
|
274
|
+
z.object({
|
|
275
|
+
deliveryMethod: z.literal('delivery'),
|
|
276
|
+
address: z.string().min(1, 'Address is required'),
|
|
277
|
+
city: z.string().min(1, 'City is required'),
|
|
278
|
+
zipCode: z.string().regex(/^\d{5}$/, 'Invalid zip code'),
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Async Validation
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
const usernameSchema = z.object({
|
|
287
|
+
username: z
|
|
288
|
+
.string()
|
|
289
|
+
.min(3, 'Username must be at least 3 characters')
|
|
290
|
+
.refine(
|
|
291
|
+
async (username) => {
|
|
292
|
+
// Check if username is available
|
|
293
|
+
const response = await fetch(`/api/check-username?q=${username}`);
|
|
294
|
+
const { available } = await response.json();
|
|
295
|
+
return available;
|
|
296
|
+
},
|
|
297
|
+
{ message: 'Username is already taken' }
|
|
298
|
+
),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Use mode: 'onBlur' for async validation
|
|
302
|
+
const form = useForm({
|
|
303
|
+
resolver: zodResolver(usernameSchema),
|
|
304
|
+
mode: 'onBlur', // Validate on blur instead of every keystroke
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Controlled Components with Controller
|
|
311
|
+
|
|
312
|
+
For custom/third-party components that don't support `ref`:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
316
|
+
import { DatePicker } from '@/components/ui/DatePicker';
|
|
317
|
+
import { Select } from '@/components/ui/Select';
|
|
318
|
+
|
|
319
|
+
function EventForm() {
|
|
320
|
+
const form = useForm<EventInput>({
|
|
321
|
+
resolver: zodResolver(eventSchema),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
326
|
+
{/* DatePicker needs Controller */}
|
|
327
|
+
<Controller
|
|
328
|
+
name="date"
|
|
329
|
+
control={form.control}
|
|
330
|
+
render={({ field, fieldState }) => (
|
|
331
|
+
<FormField label="Event Date" error={fieldState.error?.message}>
|
|
332
|
+
<DatePicker
|
|
333
|
+
value={field.value}
|
|
334
|
+
onChange={field.onChange}
|
|
335
|
+
onBlur={field.onBlur}
|
|
336
|
+
/>
|
|
337
|
+
</FormField>
|
|
338
|
+
)}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
{/* Custom Select component */}
|
|
342
|
+
<Controller
|
|
343
|
+
name="category"
|
|
344
|
+
control={form.control}
|
|
345
|
+
render={({ field, fieldState }) => (
|
|
346
|
+
<FormField label="Category" error={fieldState.error?.message}>
|
|
347
|
+
<Select
|
|
348
|
+
options={categories}
|
|
349
|
+
value={field.value}
|
|
350
|
+
onChange={field.onChange}
|
|
351
|
+
placeholder="Select category"
|
|
352
|
+
/>
|
|
353
|
+
</FormField>
|
|
354
|
+
)}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
<Button type="submit">Create Event</Button>
|
|
358
|
+
</form>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Array Fields
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { useFieldArray, useForm } from 'react-hook-form';
|
|
369
|
+
|
|
370
|
+
const teamSchema = z.object({
|
|
371
|
+
name: z.string().min(1),
|
|
372
|
+
members: z
|
|
373
|
+
.array(
|
|
374
|
+
z.object({
|
|
375
|
+
name: z.string().min(1, 'Member name is required'),
|
|
376
|
+
email: z.string().email('Invalid email'),
|
|
377
|
+
})
|
|
378
|
+
)
|
|
379
|
+
.min(1, 'At least one member required'),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
function TeamForm() {
|
|
383
|
+
const form = useForm<TeamInput>({
|
|
384
|
+
resolver: zodResolver(teamSchema),
|
|
385
|
+
defaultValues: {
|
|
386
|
+
name: '',
|
|
387
|
+
members: [{ name: '', email: '' }],
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const { fields, append, remove } = useFieldArray({
|
|
392
|
+
control: form.control,
|
|
393
|
+
name: 'members',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
398
|
+
<FormField label="Team Name" error={form.formState.errors.name?.message}>
|
|
399
|
+
<input {...form.register('name')} />
|
|
400
|
+
</FormField>
|
|
401
|
+
|
|
402
|
+
<div className="space-y-4">
|
|
403
|
+
<h3>Team Members</h3>
|
|
404
|
+
{fields.map((field, index) => (
|
|
405
|
+
<div key={field.id} className="flex gap-4">
|
|
406
|
+
<FormField
|
|
407
|
+
label="Name"
|
|
408
|
+
error={form.formState.errors.members?.[index]?.name?.message}
|
|
409
|
+
>
|
|
410
|
+
<input {...form.register(`members.${index}.name`)} />
|
|
411
|
+
</FormField>
|
|
412
|
+
|
|
413
|
+
<FormField
|
|
414
|
+
label="Email"
|
|
415
|
+
error={form.formState.errors.members?.[index]?.email?.message}
|
|
416
|
+
>
|
|
417
|
+
<input {...form.register(`members.${index}.email`)} type="email" />
|
|
418
|
+
</FormField>
|
|
419
|
+
|
|
420
|
+
{fields.length > 1 && (
|
|
421
|
+
<Button
|
|
422
|
+
type="button"
|
|
423
|
+
variant="ghost"
|
|
424
|
+
onClick={() => remove(index)}
|
|
425
|
+
>
|
|
426
|
+
Remove
|
|
427
|
+
</Button>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
))}
|
|
431
|
+
|
|
432
|
+
<Button type="button" variant="secondary" onClick={() => append({ name: '', email: '' })}>
|
|
433
|
+
Add Member
|
|
434
|
+
</Button>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<Button type="submit">Create Team</Button>
|
|
438
|
+
</form>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## Form with File Upload
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
const profileSchema = z.object({
|
|
449
|
+
name: z.string().min(1),
|
|
450
|
+
avatar: z
|
|
451
|
+
.instanceof(FileList)
|
|
452
|
+
.refine((files) => files.length <= 1, 'Only one file allowed')
|
|
453
|
+
.refine(
|
|
454
|
+
(files) => files.length === 0 || files[0].size <= 5 * 1024 * 1024,
|
|
455
|
+
'File must be less than 5MB'
|
|
456
|
+
)
|
|
457
|
+
.refine(
|
|
458
|
+
(files) => files.length === 0 || ['image/jpeg', 'image/png'].includes(files[0].type),
|
|
459
|
+
'Only JPEG and PNG allowed'
|
|
460
|
+
)
|
|
461
|
+
.optional(),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
function ProfileForm() {
|
|
465
|
+
const form = useForm<ProfileInput>({
|
|
466
|
+
resolver: zodResolver(profileSchema),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
470
|
+
|
|
471
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
472
|
+
const file = e.target.files?.[0];
|
|
473
|
+
if (file) {
|
|
474
|
+
setPreview(URL.createObjectURL(file));
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
return (
|
|
479
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
480
|
+
<FormField label="Avatar" error={form.formState.errors.avatar?.message}>
|
|
481
|
+
<input
|
|
482
|
+
{...form.register('avatar')}
|
|
483
|
+
type="file"
|
|
484
|
+
accept="image/jpeg,image/png"
|
|
485
|
+
onChange={(e) => {
|
|
486
|
+
form.register('avatar').onChange(e);
|
|
487
|
+
handleFileChange(e);
|
|
488
|
+
}}
|
|
489
|
+
/>
|
|
490
|
+
{preview && <img src={preview} alt="Preview" className="mt-2 h-20 w-20 rounded" />}
|
|
491
|
+
</FormField>
|
|
492
|
+
|
|
493
|
+
<Button type="submit">Save</Button>
|
|
494
|
+
</form>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Multi-Step Forms
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
const stepSchemas = [
|
|
505
|
+
z.object({
|
|
506
|
+
firstName: z.string().min(1),
|
|
507
|
+
lastName: z.string().min(1),
|
|
508
|
+
}),
|
|
509
|
+
z.object({
|
|
510
|
+
email: z.string().email(),
|
|
511
|
+
phone: z.string().min(10),
|
|
512
|
+
}),
|
|
513
|
+
z.object({
|
|
514
|
+
address: z.string().min(1),
|
|
515
|
+
city: z.string().min(1),
|
|
516
|
+
}),
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
function MultiStepForm() {
|
|
520
|
+
const [step, setStep] = useState(0);
|
|
521
|
+
const [formData, setFormData] = useState({});
|
|
522
|
+
|
|
523
|
+
const form = useForm({
|
|
524
|
+
resolver: zodResolver(stepSchemas[step]),
|
|
525
|
+
defaultValues: formData,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const onNext = (data: Record<string, unknown>) => {
|
|
529
|
+
setFormData((prev) => ({ ...prev, ...data }));
|
|
530
|
+
if (step < stepSchemas.length - 1) {
|
|
531
|
+
setStep((s) => s + 1);
|
|
532
|
+
} else {
|
|
533
|
+
// Final submission
|
|
534
|
+
submitFinalForm({ ...formData, ...data });
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const onBack = () => {
|
|
539
|
+
setStep((s) => Math.max(0, s - 1));
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
return (
|
|
543
|
+
<form onSubmit={form.handleSubmit(onNext)}>
|
|
544
|
+
{/* Step indicator */}
|
|
545
|
+
<div className="mb-8 flex justify-between">
|
|
546
|
+
{stepSchemas.map((_, i) => (
|
|
547
|
+
<div
|
|
548
|
+
key={i}
|
|
549
|
+
className={cn(
|
|
550
|
+
'h-2 flex-1 rounded',
|
|
551
|
+
i <= step ? 'bg-blue-500' : 'bg-gray-200'
|
|
552
|
+
)}
|
|
553
|
+
/>
|
|
554
|
+
))}
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
{/* Step content */}
|
|
558
|
+
{step === 0 && <PersonalInfoStep form={form} />}
|
|
559
|
+
{step === 1 && <ContactInfoStep form={form} />}
|
|
560
|
+
{step === 2 && <AddressStep form={form} />}
|
|
561
|
+
|
|
562
|
+
{/* Navigation */}
|
|
563
|
+
<div className="flex justify-between mt-6">
|
|
564
|
+
<Button type="button" variant="ghost" onClick={onBack} disabled={step === 0}>
|
|
565
|
+
Back
|
|
566
|
+
</Button>
|
|
567
|
+
<Button type="submit">
|
|
568
|
+
{step === stepSchemas.length - 1 ? 'Submit' : 'Next'}
|
|
569
|
+
</Button>
|
|
570
|
+
</div>
|
|
571
|
+
</form>
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Server Errors
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
function LoginForm() {
|
|
582
|
+
const form = useForm<LoginInput>({
|
|
583
|
+
resolver: zodResolver(loginSchema),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const login = useLogin();
|
|
587
|
+
|
|
588
|
+
const onSubmit = async (data: LoginInput) => {
|
|
589
|
+
try {
|
|
590
|
+
await login.mutateAsync(data);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (error instanceof ApiError) {
|
|
593
|
+
// Set server-side validation errors
|
|
594
|
+
if (error.code === 'INVALID_CREDENTIALS') {
|
|
595
|
+
form.setError('email', { message: 'Invalid email or password' });
|
|
596
|
+
} else if (error.code === 'ACCOUNT_LOCKED') {
|
|
597
|
+
form.setError('root', { message: 'Account is locked. Please contact support.' });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
605
|
+
{/* Root error (not tied to specific field) */}
|
|
606
|
+
{form.formState.errors.root && (
|
|
607
|
+
<Alert variant="error">{form.formState.errors.root.message}</Alert>
|
|
608
|
+
)}
|
|
609
|
+
|
|
610
|
+
<FormField label="Email" error={form.formState.errors.email?.message}>
|
|
611
|
+
<input {...form.register('email')} type="email" />
|
|
612
|
+
</FormField>
|
|
613
|
+
|
|
614
|
+
<FormField label="Password" error={form.formState.errors.password?.message}>
|
|
615
|
+
<input {...form.register('password')} type="password" />
|
|
616
|
+
</FormField>
|
|
617
|
+
|
|
618
|
+
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
619
|
+
Login
|
|
620
|
+
</Button>
|
|
621
|
+
</form>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## Form Hooks
|
|
629
|
+
|
|
630
|
+
### Extract Form Logic
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
// features/users/hooks/useUserForm.ts
|
|
634
|
+
export function useUserForm(options?: { onSuccess?: () => void }) {
|
|
635
|
+
const createUser = useCreateUser();
|
|
636
|
+
|
|
637
|
+
const form = useForm<CreateUserInput>({
|
|
638
|
+
resolver: zodResolver(createUserSchema),
|
|
639
|
+
defaultValues: {
|
|
640
|
+
name: '',
|
|
641
|
+
email: '',
|
|
642
|
+
role: 'user',
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const onSubmit = form.handleSubmit((data) => {
|
|
647
|
+
createUser.mutate(data, {
|
|
648
|
+
onSuccess: () => {
|
|
649
|
+
form.reset();
|
|
650
|
+
options?.onSuccess?.();
|
|
651
|
+
},
|
|
652
|
+
onError: (error) => {
|
|
653
|
+
form.setError('root', { message: error.message });
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
form,
|
|
660
|
+
onSubmit,
|
|
661
|
+
isSubmitting: form.formState.isSubmitting || createUser.isPending,
|
|
662
|
+
isSuccess: createUser.isSuccess,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Usage in component
|
|
667
|
+
function CreateUserForm({ onSuccess }: Props) {
|
|
668
|
+
const { form, onSubmit, isSubmitting } = useUserForm({ onSuccess });
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
<form onSubmit={onSubmit}>
|
|
672
|
+
{/* Form fields */}
|
|
673
|
+
</form>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
## Anti-Patterns
|
|
681
|
+
|
|
682
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
683
|
+
|--------------|---------|------------------|
|
|
684
|
+
| Controlled inputs for everything | Re-renders on every keystroke | Use React Hook Form (uncontrolled) |
|
|
685
|
+
| Validation in component | Not reusable, hard to test | Use Zod schemas |
|
|
686
|
+
| Not using resolver | Manual error handling | Use @hookform/resolvers |
|
|
687
|
+
| Disabled submit always | Bad UX | Disable only during submission |
|
|
688
|
+
| No loading state | User doesn't know it's working | Show spinner/loading text |
|
|
689
|
+
| Alert for every error | Overwhelming | Inline field errors |
|
|
690
|
+
| Form without noValidate | Browser validation conflicts | Add noValidate to form |
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
_Forms are user input. Make them fast, validate thoroughly, and provide clear feedback._
|