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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user