Skip to content

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 at features/<module>/types.ts — that pattern was removed (see fe-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 showing common:errors.insufficientPermission.
  • The loading and 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 returns true), 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