Files
budget/.plans/13-react-hook-form-zod.md
T
Spencer Twaddle 087fbdd176 Split into Budget.Core / Budget.Infrastructure / Budget.Api projects
Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps)
Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService
Budget.Api: controllers, middleware, Program.cs — references both projects

EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer,
HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated
to copy all three project directories before publishing. Migration namespaces
updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model
type strings updated to Budget.Core.Models.* in the snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:30:31 -05:00

127 lines
4.3 KiB
Markdown

# Plan: Add react-hook-form + zod for Form Validation
## Goal
Replace ad-hoc form state (`useState` per field, manual validation) with `react-hook-form`
and `zod` schemas, matching the stwaddle stack convention. Validation errors render inline
next to fields.
**Prerequisite:** Plan #12 (TanStack React Query) must be complete. Mutation hooks from
that plan are used as the submit handlers here — `useForm` `handleSubmit` calls
`mutation.mutateAsync()`.
## Current state
Forms are managed ad-hoc:
```tsx
const [newName, setNewName] = useState('');
const handleCreate = async () => {
if (!newName.trim()) return;
await createMutation.mutateAsync({ name: newName.trim() });
};
```
No schema validation, no field-level error messages, no loading/disabled states during
submit.
## Steps
### Phase 1 — Install
1. `npm install react-hook-form zod @hookform/resolvers` in `src/Budget.Client/`.
### Phase 2 — Create zod schemas
2. Create `src/Budget.Client/src/schemas/index.ts` with one schema per create/update request:
```ts
export const createBudgetSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
});
export const createIncomeSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
frequency: z.nativeEnum(Frequency),
amount: z.number({ invalid_type_error: 'Amount is required' }).positive('Must be positive'),
});
export const createOutgoSchema = z.object({
name: z.string().min(1).max(200),
category: z.string().max(100).optional(),
type: z.nativeEnum(OutgoType),
frequency: z.nativeEnum(Frequency),
amount: z.number().positive(),
paymentSource: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
});
export const createShareSchema = z.object({
email: z.string().email('Must be a valid email'),
permission: z.nativeEnum(SharePermission),
});
export const updateTaxRateSchema = z.object({
effectiveTaxRate: z.number().min(0).max(0.99),
});
```
### Phase 3 — Update forms in pages
For each form, the pattern is:
```tsx
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<CreateBudgetRequest>({
resolver: zodResolver(createBudgetSchema),
});
const onSubmit = async (data: CreateBudgetRequest) => {
await createBudget.mutateAsync(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<button type="submit" disabled={isSubmitting}>Create</button>
</form>
);
```
3. **`BudgetsPage.tsx`** — wire `createBudgetSchema` into the budget name input.
4. **`IncomePage.tsx`** — wire `createIncomeSchema` / `updateIncomeSchema` for the add and edit income forms.
5. **`OutgoPage.tsx`** — wire `createOutgoSchema` / `updateOutgoSchema` for add and edit outgo forms (this is the most complex form).
6. **`SettingsPage.tsx`** — wire `createShareSchema` for the invite form; `updateShareSchema` for the permission dropdown.
7. **`SummaryPage.tsx`** — wire `updateTaxRateSchema` for the tax rate field.
### Phase 4 — Disable submit during mutation
8. Set `disabled={isSubmitting || mutation.isPending}` on submit buttons. This prevents
double-submits while the network request is in flight.
### Phase 5 — Build and verify
9. `npm run build` — zero TypeScript errors.
10. Manually verify that submitting an empty name shows an inline error without hitting the API.
## Key decisions
- Schemas live in a single `schemas/index.ts` for now — split into per-domain files if they grow large.
- `amount` fields: HTML `<input type="number">` returns a string; use `z.coerce.number()` or
`valueAsNumber: true` in `register()` to get a proper number before Zod validates.
- Update schemas (for edit forms) reuse the same shape as create schemas where the fields are
identical — they are not duplicated.
- No server-side error mapping in this plan: if the API returns 422/400 with `{ error: "..." }`,
the global MutationCache toast (from plan #12) handles it. Field-level server errors can be
mapped via `setError` in a future pass if needed.
## Files affected
- `package.json`
- New: `src/Budget.Client/src/schemas/index.ts`
- `src/Budget.Client/src/pages/BudgetsPage.tsx`
- `src/Budget.Client/src/pages/IncomePage.tsx`
- `src/Budget.Client/src/pages/OutgoPage.tsx`
- `src/Budget.Client/src/pages/SettingsPage.tsx`
- `src/Budget.Client/src/pages/SummaryPage.tsx`