11 KiB
Object Form Robustness — Design
Date: 2026-06-07 Status: Approved (brainstorming) — ready for implementation planning. Issue: #46.
Context
The object create/edit form has data-integrity gaps that compound in long daily cataloguing sessions (High severity):
- Submit never disabled during save.
object-form.tsxsubmit button has nodisabled/pending. Both create (object-new-page.tsx) and edit (object-edit-form.tsx) do two sequential awaited mutations (create/update, thensetFields) with no feedback → a second click can duplicate-create or race the two-step write.publish-control.tsxis the model (disables onisPending). - No unsaved-changes guard. No
isDirty/useBlocker/beforeunload; Cancel/nav loses work silently. - Two-phase write recovers inconsistently. Create teleports to the edit page on
setFieldsfailure; edit stays put with a banner. The partial write (core saved, field rejected) reads as nothing happened. - Thin validation. Core errors always show
form.required; the servercode(type_mismatch/unresolved/unknown) onFieldRejectionis discarded; nonumber_of_objects >= 1. - No "Save & create another" / keyboard submit for batch entry.
Facts established: the form uses react-hook-form (so formState.isDirty + isSubmitting are
available; isSubmitting stays true across both awaited mutations — the unified pending signal).
ObjectForm props: mode, defaults, onSubmit, onCancel, formError, fieldErrorKey; the
422→highlight path uses form.setError. FieldRejection (queries.ts) carries field + code.
The router is <BrowserRouter> + <Routes> (component router) in app.tsx; useBlocker requires
a data router (createBrowserRouter + RouterProvider). renderApp wraps ui in <MemoryRouter>
with no <Routes> (tests supply their own when they need params).
Decisions (from brainstorming)
- Migrate to a data router (the long-term-correct foundation; unblocks
useBlockerand React Router v7 data APIs). Done viacreateRoutesFromElementsto keep the exact route tree (minimal diff), isolated as the first task, with the test harness moved tocreateMemoryRouter. - Unify create partial-failure to the edit route with a distinct "Object created, but a field was rejected" banner (single recovery surface).
- Include batch entry ("Save & create another" + Cmd/Ctrl+Enter).
Components
0. Data-router migration (foundation — isolated)
app.tsx: replace<BrowserRouter><Routes>…</Routes></BrowserRouter>with a module-levelconst router = createBrowserRouter(createRoutesFromElements(<>…the existing <Route> tree…</>))andexport function App() { return <RouterProvider router={router} />; }. The route JSX (login,RequireAuth→AppShellnest with all nested routes, lazySuspensewrappers, splat) moves verbatim intocreateRoutesFromElements. No behavior/path change.main.tsx: unchanged provider stack (QueryClient → Config → Toast) now wraps<App/>which is<RouterProvider/>.test/render.tsx:renderApp(ui, { route })switches tocreateMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] })rendered via<RouterProvider/>(insideQueryClientProvider). Behavior-preserving:uirenders atroute, and tests that supply their own<Routes>still nest correctly; now a data-router context exists souseBlockerworks. Acceptance: the entire existing suite stays green before any feature is built on top.
1. Submit pending + keyboard + batch (object-form.tsx)
- Submit
<Button type="submit" disabled={form.formState.isSubmitting}>showingisSubmitting ? t("form.saving") : (mode === "create" ? t("form.create") : t("form.save")). Disable Cancel while submitting. (isSubmittingis true across both awaited mutations because RHF awaits the asynconSubmit.) - Cmd/Ctrl+Enter submits: a keydown handler on the form (
(e.metaKey||e.ctrlKey) && e.key==="Enter"→form.handleSubmit(...)), or rely on the native submit + a small handler. Implementer picks the clean form; must not double-submit. - "Save & create another" (create mode only): a secondary submit button.
ObjectFormgains an optionalonSubmitAndNew?: (values) => Promise<void>(or asubmitActionflag passed toonSubmit) so the create page knows which button fired. On success itform.reset(blankDefaults)and focuses the first field instead of navigating. Edit mode does not show this button.
2. Validation messages
- Server
codeecho: when aFieldRejectionis shown, pick the message bycode—form.fieldError.type_mismatch/form.fieldError.unresolved/form.fieldError.unknown(fallback to the existingform.fieldRejected). The create/edit pages already extracte.field; also passe.codethrough toObjectForm(extendfieldErrorKeyhandling to carry a code, e.g. afieldError?: { key: string; code?: string }prop, or a secondfieldErrorCode?prop). The highlightform.setErrormessage uses the code-specific string. - Core-field messages: render the RHF error by
type—errors.core[key]?.type === "min"→form.min(with the limit), elseform.required. (Today it's alwaysform.required.) number_of_objects >= 1: register withmin: { value: 1, message: t("form.minCount") }(RHF, client-side, before the round-trip). Keeprequired.
3. Unsaved-changes guard (lib/use-unsaved-changes.tsx)
useUnsavedChanges(isDirty: boolean):- adds a
beforeunloadlistener whileisDirty(covers reload / tab-close / browser back), - calls
useBlocker(({currentLocation,nextLocation}) => isDirty && currentLocation.pathname !== nextLocation.pathname)to block in-app navigation while dirty, - returns the
blockerso the caller renders anUnsavedChangesDialogwhenblocker.state === "blocked"(Stay →blocker.reset(), Leave →blocker.proceed()).
- adds a
UnsavedChangesDialogbuilt onui/alert-dialog(title/body/Stay/Leave). New i18nform.unsaved.{title,body,stay,leave}.- Wire into
ObjectForm:const isDirty = form.formState.isDirty;→useUnsavedChanges(isDirty); render the dialog. The Cancel button: if dirty, open the same confirm dialog (Leave →onCancel); if clean,onCanceldirectly. - Suppress on successful save: after a successful submit the form navigates;
isDirtymust be false by then (RHFreseton success, or the blocker condition excludes the programmatic post-save nav). The create→edit partial-failure nav is programmatic and must not be blocked — reset dirty (or set a "saving" ref that bypasses the blocker) around the save so the post-save/teleport navigation isn't caught. Implementer ensures save-driven navigation never triggers the guard.
4. Partial-failure unification
- Create (
object-new-page.tsx): onsetFieldsfailure after the core create succeeds, navigate to/objects/${id}/editwithstate: { created: true, fieldErrorKey: e.field, fieldErrorCode: e.code }(today it passesfieldsError/fieldErrorKey; addcreated+code). The core-create already succeeded, so this is correct (the object exists; editing it is the right URL). - Edit (
object-edit-form.tsx): readlocation.state. Ifcreated, show the bannerform.createdButFieldRejected("Object created, but a field was rejected — fix it below"); otherwise the existing rejection banner. Both highlight the field (viafieldErrorKey+ code). Edit's own in-place partial-failure (update ok, setFields fails) keeps staying-put with its banner. - Result: a single recovery surface (the edit form) for both flows, with create messaged as "created."
Data flow
RHF manages form state → isSubmitting disables submit across both mutations → isDirty drives the
guard (beforeunload + useBlocker + dialog) → on save success the form is clean and navigates → on
create partial-failure it navigates to the edit route with created state → the edit form shows the
"created" banner + field highlight (code-specific message).
Error handling / edges
isSubmittingcovers the whole two-phase write (RHF awaitsonSubmit); a second click is a no-op (button disabled).- The guard must not fire on save-driven navigation (post-success or create→edit teleport) — reset dirty / bypass ref around saves.
beforeunloadonly prompts while dirty (don't attach unconditionally).useBlockernow works (data router). Tests render undercreateMemoryRouter(harness change) so the blocker is exercisable.- A
FieldRejectionwith an unknowncodefalls back to the genericform.fieldRejected. number_of_objectsmin is client-side UX; the server remains source of truth.
Testing
- Migration: the full existing suite passes under the new
createMemoryRouterharness (no behavior change) — this is the gate for Task 1. - Submit disable: during an in-flight save the submit button is
disabledand reads "Saving…"; a double-click fires the create mutation once (assert call count). - Keyboard: Cmd/Ctrl+Enter submits.
- Save & create another: create mode → success resets the form (fields cleared, stays on /new), does not navigate to detail.
- Validation: a
FieldRejectionwithcode: "type_mismatch"shows the code-specific message;number_of_objects = 0showsform.minCountwithout hitting the server. - Dirty guard: with a dirty form, attempting in-app nav shows the dialog (Stay keeps you, Leave
proceeds); Cancel-when-dirty confirms; a clean form navigates freely;
beforeunloadlistener added only when dirty (assert via the registered handler / a spy). - Partial-failure: create with a
setFields422 → lands on/objects/:id/editwith the "Object created, but a field was rejected" banner + field highlight; edit's own setFields 422 → stays with its banner. (Extendobject-new-page.test.tsx/object-edit-form.test.tsx.) - Gate:
typecheck/lint/test/build/check:size/check:colors; en/sv parity for all new keys; no codename.
Acceptance criteria
- The app uses a data router (
createBrowserRouter+RouterProvider) with the route tree unchanged; the test harness usescreateMemoryRouter; the full suite is green. - The submit button is disabled and shows "Saving…" while either mutation is pending; no double-submit / duplicate-create is possible from the UI.
- A dirty form guards navigation:
useBlockerdialog on in-app nav,beforeunloadon reload/close, and Cancel confirms when dirty; saving navigates without a false prompt. - Create and edit share one partial-failure recovery surface (the edit form); the create case is messaged "Object created, but a field was rejected" and highlights the field.
- Field-rejection messages reflect the server
code; core errors show type-specific messages;number_of_objects >= 1is validated client-side. - Create mode offers "Save & create another" (resets the form) and Cmd/Ctrl+Enter submit.
typecheck/lint/test/build/check:size/check:colorsgreen; en/sv parity; no codename; no new npm dependency.
Out of scope → follow-ups
- Flexible-field grouping/ordering (#45 / detail), per-field server validation rules (#11).
- Turning core-field 422s into per-field
FieldRejections server-side (the core mutations still throw generic errors; onlysetFieldsyields field-level codes). - Autosave / draft persistence.