Skip to content

Adopt react-hook-form + zod for hand-rolled admin console forms #236

Description

@stephane-segning

Summary

Seven admin console screens hand-roll their own create/edit form state with
useState + manual onChange handlers + no client-side validation, even
though react-hook-form (7.85.0) and zod (4.4.3) are already
dependencies and are already used correctly in the composer
(admin/app/page.tsx). Adopt the same pattern for the remaining forms.

Source of truth: docs/design/frontend-package-audit.md ("Forms:
react-hook-form + zod — adopt" section), from the frontend package audit
requested by the maintainer.

Scope

Convert the hand-rolled form state in these screens to react-hook-form +
zodResolver, following the composer's existing pattern:

  • admin/app/providers/providers-screen.tsx — edit dialog (EditFormState)
  • admin/app/routes/routes-screen.tsx — create/edit route
  • admin/app/webhooks/webhooks-screen.tsx — create endpoint, rotate-secret
    confirmation
  • admin/app/sender-ids/sender-ids-screen.tsx — create sender ID, edit
    registration (two separate forms)
  • admin/app/users/users-screen.tsx — provision user, edit role assignment
    (two forms)
  • admin/app/apps/apps-screen.tsx — provision app, edit app (two forms)
  • admin/app/opt-outs/opt-outs-screen.tsx — search-by-MSISDN form,
    record-opt-out form (two forms)

Roughly ten independent form-state machines across these seven screens, each
currently ~30–50 lines of state interface + useEffect population + manual
onChange + ad hoc coercion at submit time. Estimated 250–350 lines
removed/simplified.

Properties that must survive this change — verify each explicitly

  • 412 (isStaleWriteError) handling is unaffected and must stay exactly
    as-is.
    This lives in packages/gateway/src/errors.ts and is independent
    of form-state management — it's a response-shape check on the mutation
    result, not something react-hook-form should intercept or reinterpret.
    The "someone else changed this row, reload" UX (#59) depends on it
    reaching the screen unchanged.
  • Layer-2 403 denials must keep surfacing as a real, visible error,
    not be silently absorbed into react-hook-form's field-level error
    display as if they were a validation failure. Keep the existing pattern of
    a form-level inline banner (e.g. providers-screen.tsx's
    updateMutation.isError && <div>Save failed: {message}</div>) — a
    permission denial is not a field error.
  • trpc.ts's errorFormatter already threads GatewayError.fieldErrors
    through to error.data
    (packages/api/src/trpc.ts) — built for
    react-hook-form's setError, currently unused by every hand-rolled
    screen. Wire it up as part of this change so it's live, not dormant.
  • Number-typed fields need explicit zod coercion at the boundary
    (z.coerce.number() or RHF's valueAsNumber) — replacing the current
    hand-rolled Number(form.maxTps)-style coercion, which silently produces
    NaN on an empty string.
  • Decimal-on-the-wire fields (costPerSegmentXaf and similar) must stay
    z.string(), never a numeric type.
    This repo's money-safety convention
    is "never floating point for minor units" — a zod schema that coerces one
    of these to number is a real regression, not a style choice. Validate
    the string shape (regex/format), don't coerce it.

Sequencing note

The admin console redesign (docs/design/console-redesign.md) is currently
in flight and touches these same screen files for visual/structural reasons.
This issue is about form state and validation logic, not layout — but
touches the same files, so coordinate timing with whoever is executing that
redesign to avoid a collision (either land after it settles, or split this
work per-screen alongside it).

Verification

  • pnpm --filter admin typecheck and the existing screen-level tests (if
    any) stay green.
  • Manually exercise: a stale If-Match (two tabs editing the same row) still
    shows the reload-and-retry UX, not a generic error.
  • Manually exercise: a role without the relevant *:update/*:manage
    permission still gets a visible 403, not a silently failed submit.
  • A money field (costPerSegmentXaf) round-trips as a string through the
    form, never becomes a JS number.

AI Usage Declaration

This issue was filed by an AI agent (Claude) performing a maintainer-
requested audit of packages/* for hand-rolled machinery a well-maintained
library could replace. The scope, the must-survive properties, and the
verdict were derived by reading every named screen's source directly (not
inferred) and cross-checking against this repo's own documented conventions
(AGENTS.md, docs/design/console-redesign.md). A human should review the
scoped file list and the must-survive properties before implementation
begins, and should confirm implementation before merging.

  • Human has reviewed the scope and must-survive properties above
  • Human confirms before merge that 412/403 handling and money-field
    string-safety were verified live, not just typechecked

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions