Foundation — Forms¶
Scope note: Foundation builds the form primitives, not any business forms. Login is the only working form today; account-stub is wired in M2 but is mostly read-only. The deliverable here is the field-wrapper layer in src/components/forms/ plus the conventions every feature form follows.
Authoritative cross-cutting source: ../../standards.md § Forms. This doc describes what foundation builds + what feature plans should assume is available.
Stack¶
| Concern | Library |
|---|---|
| Form state | React Hook Form |
| Schema + validation | Zod (single source of truth — types come from z.infer<typeof schema>) |
| Resolver | zodResolver from @hookform/resolvers/zod |
| i18n of Zod messages | zod-i18n-map (already wired in src/lib/i18n.ts) |
| Field wrappers | src/components/forms/ (foundation ships these) |
| Server error mapping | applyFieldErrorsToForm(form, apiError) (foundation ships this) |
Schemas come from Orval — derive, don't hand-author¶
The BE owns the canonical validation contract. Orval generates Zod schemas alongside the TanStack Query hooks (src/generated/schemas/<module>.ts), and form schemas derive from those generated schemas rather than being hand-authored from scratch. This way:
- Field constraints (min/max length, regex, enum values, required-ness) stay in sync with BE without manual mirroring.
- A BE-side constraint change shows up as a TypeScript error in the FE PR that regenerates Orval — visible drift, not silent drift.
- The form schema can still customise what the BE allows (e.g. require a field the BE leaves optional, or drop fields the form doesn't expose) via standard Zod methods.
Pattern¶
Generated code (don't edit):
// src/generated/schemas/item-catalog.ts (Orval output)
export const itemCreateSchema = z.object({
name: z.string().min(3).max(100),
sku: z.string().regex(/^[A-Z0-9-]+$/),
description: z.string().optional(),
quantity: z.number().int().min(0),
category_id: z.string().uuid().optional(),
})
export type ItemCreate = z.infer<typeof itemCreateSchema>
Form schema co-located with the page that owns the form:
// features/item-catalog/pages/ItemCreatePage.tsx
import { itemCreateSchema } from '@/generated/schemas/item-catalog'
// Pick fields the form actually exposes; tighten where needed.
const itemFormSchema = itemCreateSchema
.pick({ name: true, sku: true, description: true, quantity: true })
.extend({
description: z.string().min(1, 'Description is required for new items'),
})
type ItemFormValues = z.infer<typeof itemFormSchema>
const form = useForm<ItemFormValues>({
resolver: zodResolver(itemFormSchema),
mode: 'onBlur',
})
Common transforms¶
| Need | Zod method |
|---|---|
| Form exposes only a subset of the DTO fields | .pick({ field: true, ... }) |
| Form exposes everything except a few fields | .omit({ field: true, ... }) |
| Form needs an extra field not in the DTO (UI-only flag, computed) | .extend({ field: z.boolean() }) |
| Form tightens a BE-optional field to required | .extend({ field: z.string().min(1) }) |
| Edit form needs everything optional (PATCH semantics) | .partial() |
| Form combines fields from multiple DTOs | .merge(otherSchema) |
When hand-authoring a schema is OK¶
- No BE DTO exists. Filter-bar state, local UI preferences, ephemeral wizard step state — these live entirely on the FE, so there's no generated schema to derive from. Hand-author next to the consumer.
- Form input differs structurally from API payload. E.g. the form has a free-text address but the API takes a structured
{street, city, zip}. Hand-author the form schema; transform to the API shape in the submit handler. Type the transform function with both schemas so the mapping stays honest.
Where the schema lives¶
- Single-use form schema — co-located with the page or component that uses it. Same file is fine if it's small.
- Schema reused across 2+ files — promote to
features/<module>/schemas.ts(singular intent — schemas, not a grab-bag types file). - Generated schemas — always imported directly from
@/generated/schemas/<module>. No re-export barrel atfeatures/<module>/types.ts— that pattern was removed (seefe-plan.md§ Decisions for rationale; CLAUDE.md + architecture.md updated as part of M0).
Foundation does not ship any schemas itself — it only ships the wrappers and establishes this pattern.
Validation modes¶
| Mode | When |
|---|---|
onBlur |
Default. Most fields. Validation runs when the user finishes typing and tabs out. |
onChange |
Only when instant feedback materially helps (e.g. SKU uniqueness preview, password strength meter). Justify per field in the feature plan. |
onSubmit |
Avoid — users hate "type the whole thing, hit submit, see errors". Only acceptable for one-off action confirmations (e.g. "type DELETE to confirm"). |
Field wrappers (M4 — RHF + Zod field wrappers ticket)¶
All wrappers live under src/components/forms/. Each is a thin RHF Controller (or register-based for native inputs) wrapper that pulls the error from formState.errors[name] and resolves its message via i18n.
| Wrapper | Underlies | When to use |
|---|---|---|
<TextField> |
<Input> from components/ui/ |
Any string input — short text, email, SKU, etc. |
<NumberField> |
<Input type="number"> |
Integers and decimals. Wrapper handles locale-aware decimal separator (PL uses ,). |
<TextareaField> |
<Textarea> |
Multi-line strings — descriptions, notes. |
<SelectField> |
<Select> |
Single-choice from a known list (enum, FK dropdown). |
<CheckboxField> |
<Checkbox> |
Single boolean flag. |
<SwitchField> |
<Switch> |
Single boolean — used when the on/off distinction is a state (active/archived), not just a binary flag. |
<DateField> |
<Input type="date"> + helper |
Calendar inputs. Stores ISO 8601 strings; displays via Intl.DateTimeFormat for the locale. |
Common contract — every wrapper accepts:
type FieldProps<T extends FieldValues> = {
control: Control<T> // from useForm
name: FieldPath<T> // typed by RHF
label: string // already translated by caller (call site owns the t() call)
description?: string // optional helper text below the field
required?: boolean // adds the asterisk; does NOT add Zod validation
disabled?: boolean
autoFocus?: boolean
}
Errors render below the field via formState.errors[name].message. The message is already translated because zod-i18n-map runs at schema definition time.
Server error mapping¶
The error envelope's details[] is field-scoped — each entry has a field key matching the form field name. applyFieldErrorsToForm(form, apiError) walks the list and calls form.setError(field, { type: 'server', message: e.message }).
const mutation = useIdempotentMutation(useUpdateItem(itemId))
const onSubmit = async (values: ItemFormValues) => {
try {
await mutation.mutateAsync(values)
} catch (e) {
const apiError = parseApiError(e)
if (apiError) applyFieldErrorsToForm(form, apiError)
// Non-field errors are handled by useApiErrorToast (M3) — call site doesn't dispatch them.
}
}
Fields with server errors render the message inline; the user fixes and re-submits; on next blur the server error clears automatically because RHF treats next-blur validation as fresh.
Permission-gated form actions¶
The Permission-Gated UI convention (per ../../standards.md § Permission-Gated UI — added by parallel session) applies to form actions via semantic buttons:
<FormActions>
<CancelButton onClick={onCancel} />
<SaveButton
onClick={form.handleSubmit(onSubmit)}
requiredPermission={Permission.ITEM_UPDATE}
disabled={!form.formState.isDirty}
loading={mutation.isPending}
/>
</FormActions>
- If the user lacks
ITEM_UPDATE→ button is disabled with a Tooltip showingcommon:errors.insufficientPermission. - The
loadingand permission-denied states compose — a saving button can also be permission-denied (the tooltip wins). - Until identity ships,
usePermission()is a no-op pass-through (always returnstrue), so foundation buttons always render enabled.
What foundation does not build¶
- Any actual form. The wrappers exist; the schemas, page wiring, and submit handlers are per-feature.
- A multi-step form wizard component. Not needed by any planned module — add when there's a real consumer.
- A "dirty form, leave anyway?" guard. Defer until the first form that has expensive in-progress work to lose.
- A
<FieldArray>wrapper. Add when the first repeating-row form (probably manufacturing's BOM editor) needs it.
Files¶
| File | Owner ticket |
|---|---|
src/components/forms/TextField.tsx |
M4 — RHF + Zod field wrappers |
src/components/forms/NumberField.tsx |
M4 |
src/components/forms/TextareaField.tsx |
M4 |
src/components/forms/SelectField.tsx |
M4 |
src/components/forms/CheckboxField.tsx |
M4 |
src/components/forms/SwitchField.tsx |
M4 |
src/components/forms/DateField.tsx |
M4 |
src/lib/errors.ts (applyFieldErrorsToForm) |
M3 — Error envelope parser ticket |