Frontend UX: object form robustness — double-submit, no dirty guard, inconsistent partial-failure recovery #46

Closed
opened 2026-06-06 18:51:03 +00:00 by logaritmisk · 1 comment
Owner

Severity: High. From a frontend UX audit. These compound into real data-loss / duplicate-record risk in long daily sessions.

Problems

  • Submit button never disabled during save. web/src/objects/object-form.tsx:180-186 — no disabled, no pending label. Both create (object-new-page.tsx:21-42) and edit (object-edit-form.tsx:51-66) do two sequential awaited mutations (create/update, then setFields) with no isPending feedback → a second click can fire a duplicate create or race the two-step write. (publish-control.tsx is the model: it disables on isPending.)
  • No unsaved-changes guard. No useBlocker/beforeunload/isDirty anywhere; onCancel navigates away immediately. 20 minutes of work is lost silently on an accidental Cancel/nav.
  • Two-phase write recovers inconsistently. On create, if setFields fails the user is teleported to the edit page; on edit, it stays put with a banner. Same failure class, two models. The partial write (core saved, fields rejected) is invisible as "your object was created."
  • Thin validation messages. Core errors always show t("form.required") regardless of cause; no range/date validation; the server code (type_mismatch/unresolved/unknown) carried on FieldRejection (queries.ts:13-18) is discarded. (The 422→field-highlight path itself, object-form.tsx:81-88, is good.)
  • Flexible fields aren't grouped/ordered (see the detail issue); no "Save & create another" or keyboard submit for batch entry.

Suggested fixes

  • Disable submit while create/update.isPending || setFields.isPending, show "Saving…".
  • Track formState.isDirty; guard route changes with useBlocker and confirm on Cancel when dirty.
  • Unify partial-failure recovery (stay on form, banner + field highlight in both create & edit); message the create case as "Object created, but a field was rejected."
  • Echo the server code in messages; validate number_of_objects >= 1; add "Save & create another" + Cmd/Ctrl+Enter submit.

Source: frontend UX/design audit, 2026-06-06.

**Severity: High.** _From a frontend UX audit. These compound into real data-loss / duplicate-record risk in long daily sessions._ ## Problems - **Submit button never disabled during save.** `web/src/objects/object-form.tsx:180-186` — no `disabled`, no pending label. Both create (`object-new-page.tsx:21-42`) and edit (`object-edit-form.tsx:51-66`) do **two sequential awaited mutations** (create/update, then `setFields`) with no `isPending` feedback → a second click can fire a duplicate create or race the two-step write. (`publish-control.tsx` is the model: it disables on `isPending`.) - **No unsaved-changes guard.** No `useBlocker`/`beforeunload`/`isDirty` anywhere; `onCancel` navigates away immediately. 20 minutes of work is lost silently on an accidental Cancel/nav. - **Two-phase write recovers inconsistently.** On create, if `setFields` fails the user is *teleported to the edit page*; on edit, it stays put with a banner. Same failure class, two models. The partial write (core saved, fields rejected) is invisible as "your object was created." - **Thin validation messages.** Core errors always show `t("form.required")` regardless of cause; no range/date validation; the server `code` (`type_mismatch`/`unresolved`/`unknown`) carried on `FieldRejection` (`queries.ts:13-18`) is discarded. (The 422→field-highlight path itself, `object-form.tsx:81-88`, is good.) - Flexible fields aren't grouped/ordered (see the detail issue); no "Save & create another" or keyboard submit for batch entry. ## Suggested fixes - Disable submit while `create/update.isPending || setFields.isPending`, show "Saving…". - Track `formState.isDirty`; guard route changes with `useBlocker` and confirm on Cancel when dirty. - Unify partial-failure recovery (stay on form, banner + field highlight in both create & edit); message the create case as "Object created, but a field was rejected." - Echo the server `code` in messages; validate `number_of_objects >= 1`; add "Save & create another" + Cmd/Ctrl+Enter submit. _Source: frontend UX/design audit, 2026-06-06._
Author
Owner

Done — merged to main (28e444c). All the compounding data-loss/duplicate-record risks are closed.

Foundation: migrated the app to a React Router data router (createBrowserRouter, route tree verbatim) so useBlocker is available. This required upgrading Vitest 3→4 (the old jsdom/Node-26 combo clobbered AbortSignal, which the data router's per-navigation Request needs) — done as an isolated first step, @storybook/addon-vitest@10 already supported Vitest 4, swapped @vitest/browser@vitest/browser-playwright. Dev-only; no runtime dependency added.

Form robustness:

  • No double-submit: the form's onSubmit is now awaited by react-hook-form, so isSubmitting is real — submit/Cancel disable and the button reads "Saving…" across both the create/update + setFields writes. (Double-click fires the create exactly once — tested.)
  • Unsaved-changes guard: useBlocker(isDirty && !isSubmitting) blocks in-app navigation when dirty (Cancel and sidebar links flow through one Discard/Keep-editing dialog) + a beforeunload for reload/close. Save-driven navigation is never falsely prompted (it happens while isSubmitting).
  • Unified partial-failure recovery: when the core object saves but a field is rejected, both create and edit land on the edit form; the create case shows a distinct banner "Object created, but a field was rejected — fix it below" + highlights the field.
  • Better validation: field-rejection messages now reflect the server code (type_mismatch/unresolved/unknown); number_of_objects ≥ 1 is validated client-side; core errors show type-specific messages.
  • Batch entry: "Save & create another" (resets the form, refocuses) + Cmd/Ctrl+Enter to submit.

Gate green: typecheck, lint, 203 tests, build, check:size (214.4 KB gz), check:colors; en/sv parity; no codename; no eslint-disable/any.

Follow-ups (out of scope): flexible-field grouping/ordering (#45/detail); per-field server validation rules (#11); turning core-field 422s into per-field rejections server-side; autosave/draft persistence.

Done — merged to `main` (`28e444c`). All the compounding data-loss/duplicate-record risks are closed. **Foundation:** migrated the app to a **React Router data router** (`createBrowserRouter`, route tree verbatim) so `useBlocker` is available. This required upgrading **Vitest 3→4** (the old jsdom/Node-26 combo clobbered `AbortSignal`, which the data router's per-navigation `Request` needs) — done as an isolated first step, `@storybook/addon-vitest@10` already supported Vitest 4, swapped `@vitest/browser`→`@vitest/browser-playwright`. Dev-only; no runtime dependency added. **Form robustness:** - **No double-submit:** the form's `onSubmit` is now awaited by react-hook-form, so `isSubmitting` is real — submit/Cancel disable and the button reads "Saving…" across both the create/update + setFields writes. (Double-click fires the create exactly once — tested.) - **Unsaved-changes guard:** `useBlocker(isDirty && !isSubmitting)` blocks in-app navigation when dirty (Cancel and sidebar links flow through one Discard/Keep-editing dialog) + a `beforeunload` for reload/close. Save-driven navigation is never falsely prompted (it happens while `isSubmitting`). - **Unified partial-failure recovery:** when the core object saves but a field is rejected, both create and edit land on the edit form; the create case shows a distinct banner **"Object created, but a field was rejected — fix it below"** + highlights the field. - **Better validation:** field-rejection messages now reflect the server `code` (`type_mismatch`/`unresolved`/`unknown`); `number_of_objects ≥ 1` is validated client-side; core errors show type-specific messages. - **Batch entry:** **"Save & create another"** (resets the form, refocuses) + **Cmd/Ctrl+Enter** to submit. Gate green: typecheck, lint, **203 tests**, build, check:size (214.4 KB gz), check:colors; en/sv parity; no codename; no `eslint-disable`/`any`. Follow-ups (out of scope): flexible-field grouping/ordering (#45/detail); per-field server validation rules (#11); turning core-field 422s into per-field rejections server-side; autosave/draft persistence.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/biggus-dickus#46