23 KiB
Object Form Robustness Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the object create/edit form safe for long daily sessions — no double-submit, an unsaved-changes guard, one consistent partial-failure recovery, code-aware validation messages, and batch-entry ergonomics.
Architecture: Migrate to a React Router data router (enables useBlocker) keeping the route tree verbatim. The form is react-hook-form; isSubmitting (made real by returning the async onSubmit from handleSubmit) drives submit-disable, and useBlocker(isDirty && !isSubmitting) drives the dirty guard — so save-driven navigation is never falsely blocked and Cancel flows through the same dialog. onSubmit returns a success boolean so the form can reset for "Save & create another".
Tech Stack: React 19 + TS + pnpm, react-router-dom 7 (data router), react-hook-form, react-i18next, Base UI (alert-dialog), Vitest + RTL + MSW. Test runner: pnpm test (single pass).
Conventions: pnpm; no any/eslint-disable/@ts-ignore; no codename; en/sv parity; ui/ no-semicolon, app source double-quote+semicolon; token classes only; guard DOM globals; run tests exactly once per task.
Spec: docs/superpowers/specs/2026-06-07-object-form-robustness-design.md
Key facts (from the code):
object-form.tsx: RHFuseForm<FormShape>;submit = handleSubmit((data) => { onSubmit(...) })— does not return the promise (soisSubmittingnever tracks it; fix in T2). Props:mode/defaults/onSubmit/onCancel/formError/fieldErrorKey.coreFieldrenderserrors.core?.[key] && t("form.required")always.number_of_objectsregistered viacoreField(..., { type: "number", required: true }).object-new-page.tsx:onSubmitcreate→setFields; on setFields failnavigate(\/objects/${id}/edit`, { state: { fieldsError, fieldErrorKey } }); success →/objects/${id}`.object-edit-form.tsx: split intoObjectEditFormLoaded; readslocation.state(fieldsError/fieldErrorKey) to seed the banner;onSubmitupdate→setFields; onFieldRejectionsetsfieldErrorKey+ banner, stays.FieldRejectioncarriesfield+code.useCreateObject/useUpdateObject/useSetFieldsexpose.isPending(unused today).- Router:
app.tsx<BrowserRouter><Routes>(3 top-level siblings).renderAppwrapsuiin<MemoryRouter>with no<Routes>.
Task 1: Migrate to a data router (foundation)
Files: web/src/app.tsx, web/src/test/render.tsx. (Possibly main.tsx — only if needed; it should NOT need changes since App stays the exported component.)
- Step 1:
app.tsx→ data router. Convert the JSX route tree verbatim usingcreateRoutesFromElements. Replace theimport { BrowserRouter, Navigate, Route, Routes }withimport { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";. Keep all thelazy/Suspensewrappers and every<Route>exactly as-is. New shape:
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
{/* ...all the existing nested <Route> elements, verbatim... */}
</Route>
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</>,
),
);
export function App() {
return <RouterProvider router={router} />;
}
Do NOT change any path, element, Suspense, or nesting. (The FormFallback + lazy imports stay.)
- Step 2:
test/render.tsx→createMemoryRouter. ReplaceMemoryRouterusage:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import "../i18n";
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
This is behavior-preserving: ui renders at route; tests that include their own <Routes> still nest under the * route; now a data-router context exists (so useBlocker works later).
- Step 3: Full suite must stay green (the migration gate). Run ONCE:
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build
Expected: ALL existing tests pass unchanged. If a test fails because it relied on MemoryRouter-specific behavior (e.g., asserting a redirect, or a component that rendered without its own <Routes> and needs params), investigate and fix the test's setup to the data-router equivalent WITHOUT weakening it. Report any test that needed adjustment and why. If many break, STOP and report (the migration approach may need a tweak) rather than mass-editing.
- Step 4: Commit
git add web/src/app.tsx web/src/test/render.tsx
git commit -m "refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)"
Task 2: Submit-disable + keyboard submit + "Save & create another"
Files: web/src/objects/object-form.tsx, web/src/objects/object-new-page.tsx, web/src/objects/object-edit-form.tsx, web/src/i18n/en.json, web/src/i18n/sv.json, tests.
-
Step 1: i18n — add to the
formnamespace in BOTH locales (parity):- en:
"saving": "Saving…","createAnother": "Save & create another" - sv:
"saving": "Sparar…","createAnother": "Spara & skapa ny"
- en:
-
Step 2: ObjectForm — make
isSubmittingreal + disable + the new button + Cmd/Ctrl+Enter.- Change the
onSubmitprop type to:onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean; - Destructure
isSubmitting:const { register, handleSubmit, formState: { errors, isSubmitting } } = form; - Add a ref:
const createAnotherRef = useRef(false); - Rewrite
submitto RETURN/await the promise (so RHF tracks it) and reset on create-another success:
- Change the
const submit = handleSubmit(async (data) => {
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
const values =
mode === "create"
? { core: data.core, visibility: data.visibility, fields }
: { core: data.core, fields };
const createAnother = createAnotherRef.current;
createAnotherRef.current = false;
const ok = await onSubmit(values, { createAnother });
if (ok && createAnother) {
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
document.getElementById("object_number")?.focus();
}
});
- Add a keydown handler on the
<form>for Cmd/Ctrl+Enter:onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void submit(); } }} - Footer buttons:
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={isSubmitting} onClick={() => (createAnotherRef.current = false)}>
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
</Button>
{mode === "create" && (
<Button
type="submit"
variant="secondary"
disabled={isSubmitting}
onClick={() => (createAnotherRef.current = true)}
>
{t("form.createAnother")}
</Button>
)}
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
{t("form.cancel")}
</Button>
</div>
(Add useRef to the React import. variant="secondary" — confirm it exists in ui/button.tsx; if not, use variant="outline" or default — check.)
- Step 3: Update pages'
onSubmitto returnboolean+ honorcreateAnother.object-new-page.tsx:
const onSubmit = async (values: ObjectFormValues, opts?: { createAnother?: boolean }): Promise<boolean> => {
setError(null);
let id: string;
try {
const created = await create.mutateAsync({ ...values.core, visibility: values.visibility ?? "draft" });
id = created.id;
} catch {
setError(t("form.rejected"));
return false;
}
if (Object.keys(values.fields).length > 0) {
try {
await setFields.mutateAsync({ id, fields: values.fields });
} catch (e) {
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined;
navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } });
return true;
}
}
if (opts?.createAnother) return true; // success; ObjectForm resets, stays on /objects/new
navigate(`/objects/${id}`);
return true;
};
-
object-edit-form.tsxObjectEditFormLoaded.onSubmit: returnfalsein the catch,trueafter the success navigate. (Edit mode never passescreateAnother.) -
Step 4: Tests. Extend
object-form.test.tsx/object-new-page.test.tsx:- During an in-flight create (MSW delayed handler, or assert the button is
disabled+ shows "Saving…" synchronously after submit), the create mutation is called exactly once on a double-click. (If timing is hard, at minimum assert the button becomesdisabledwhile submitting and readst("form.saving").) - "Save & create another": click it in create mode with a delayed/immediate success handler → after success the form is reset (e.g.,
object_numberinput is empty) and the location is still/objects/new(no navigation to detail). Use the renderApp data-router harness; assert location via a probe or that the form is still present + cleared. - Cmd/Ctrl+Enter triggers submit (fireEvent.keyDown with
{ key: "Enter", metaKey: true }→ the create mutation fires).
- During an in-flight create (MSW delayed handler, or assert the button is
-
Step 5: Verify (vitest ONCE).
cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint. Expected: PASS. -
Step 6: Commit
git add web/src/objects/object-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-form.test.tsx web/src/objects/object-new-page.test.tsx
git commit -m "feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46)"
Task 3: Validation messages (server code echo, type-specific core errors, min count)
Files: web/src/objects/object-form.tsx, web/src/objects/object-new-page.tsx, web/src/objects/object-edit-form.tsx, web/src/i18n/en.json, web/src/i18n/sv.json, tests.
-
Step 1: i18n — add to the
formnamespace (both locales, parity):- en:
"minCount": "Must be at least 1", and a nested"fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }. - sv:
"minCount": "Måste vara minst 1","fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }.
- en:
-
Step 2: ObjectForm — carry the rejection
code+ type-specific messages.- Add prop
fieldErrorCode?: string | null;(alongsidefieldErrorKey). - The highlight effect picks the code-specific message:
- Add prop
useEffect(() => {
if (fieldErrorKey) {
const codeKey = fieldErrorCode ? `form.fieldError.${fieldErrorCode}` : "";
const message =
fieldErrorCode && t(codeKey) !== codeKey ? t(codeKey) : t("form.fieldRejected", { field: fieldErrorKey });
form.setError(`fields.${fieldErrorKey}` as never, { type: "server", message });
}
}, [fieldErrorKey, fieldErrorCode, form, t]);
- Core error render → message-aware (so
minshows minCount, required falls back):
{errors.core?.[key] && (
<p role="alert" className="text-xs text-destructive">
{errors.core[key]?.message || t("form.required")}
</p>
)}
-
number_of_objectsmin: incoreField, when registering a number with required, also passmin. Simplest: special-case the count field by givingcoreFieldan optionalminand rendering. Concretely change thenumber_of_objectsregistration to includemin: { value: 1, message: t("form.minCount") }. Implement by extendingcoreField'soptswithmin?: numberand, when set, register{ valueAsNumber: true, required, min: { value: opts.min, message: t("form.minCount") } }; callcoreField("number_of_objects", "count", { type: "number", required: true, min: 1 }). -
Step 3: Pass the code through the pages.
object-edit-form.tsx: in theFieldRejectioncatch, alsosetFieldErrorCode(e.code)(add afieldErrorCodestate) and passfieldErrorCodeto<ObjectForm>. Also seed it fromlocation.state.fieldErrorCode(set by the create teleport). The banner staysform.fieldRejected(or upgrade to code-specific too — optional; the field highlight is the key UX).<ObjectForm ... fieldErrorKey={fieldErrorKey} fieldErrorCode={fieldErrorCode} />.
-
Step 4: Tests. Extend
object-edit-form.test.tsx:- A
setFields422 with{ field: "...", code: "type_mismatch" }→ the field shows theform.fieldError.type_mismatchmessage (assert the text). number_of_objectsset to0and submit → theform.minCountmessage shows and NO create/update mutation is called (client-side block). (Inobject-form.test.tsxor a page test.)
- A
-
Step 5: Verify (vitest ONCE).
cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint. PASS. -
Step 6: Commit
git add web/src/objects/object-form.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-new-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-edit-form.test.tsx web/src/objects/object-form.test.tsx
git commit -m "feat(web): code-aware field errors + min count validation (#46)"
Task 4: Unsaved-changes guard
Files: web/src/lib/use-unsaved-changes.tsx (new), web/src/objects/object-form.tsx, web/src/i18n/en.json, web/src/i18n/sv.json, web/src/lib/use-unsaved-changes.test.tsx (new) or extend object-form tests.
-
Step 1: i18n — add a
form.unsavednamespace (both locales, parity):- en:
"unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } - sv:
"unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" }
- en:
-
Step 2: The hook + dialog
web/src/lib/use-unsaved-changes.tsx:
import { useEffect } from "react";
import { useBlocker } from "react-router-dom";
export function useUnsavedChanges(active: boolean) {
const blocker = useBlocker(active);
useEffect(() => {
if (!active) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [active]);
return blocker;
}
And an UnsavedChangesDialog component (same file or a sibling) using ui/alert-dialog, driven by the blocker:
import { useTranslation } from "react-i18next";
import type { Blocker } from "react-router-dom";
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from "@/components/ui/alert-dialog";
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
const { t } = useTranslation();
const open = blocker.state === "blocked";
return (
<AlertDialog open={open} onOpenChange={(o) => { if (!o) blocker.reset?.(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => blocker.reset?.()}>{t("form.unsaved.stay")}</AlertDialogCancel>
<AlertDialogAction onClick={() => blocker.proceed?.()}>{t("form.unsaved.leave")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialog>
</AlertDialog>
);
}
IMPORTANT: open web/src/components/ui/alert-dialog.tsx and match the EXACT exported part names/props (AlertDialog may take open/onOpenChange, or be trigger-driven — adapt to the real API; the delete dialogs in web/src/objects/delete-object-dialog.tsx / components/delete-confirm-dialog.tsx show the controlled usage to mirror). The dialog must be openable WITHOUT a trigger (controlled by open). Validate by running the test.
-
Step 3: Wire into ObjectForm.
const isDirty = form.formState.isDirty;const blocker = useUnsavedChanges(isDirty && !isSubmitting);- Render
<UnsavedChangesDialog blocker={blocker} />inside the form's container. - Cancel now just calls
onCancel(which navigates) — the blocker intercepts it and shows the dialog automatically when dirty. (No separate confirm needed; confirm this in the test.)
-
Step 4: Tests
web/src/lib/use-unsaved-changes.test.tsx(and/or extend object-form):- Render a small component (or the ObjectForm) under the
renderAppdata-router harness with two routes; with a dirty form, click a<Link>/Cancel → the dialog appears; "Keep editing" stays (location unchanged), "Discard" proceeds (location changes). beforeunload: withactive=true, abeforeunloadevent is registered (spy onwindow.addEventListener) and not when inactive.- A clean form navigates without the dialog.
- Saving (isSubmitting true) does NOT block — simulate or assert via the
isDirty && !isSubmittingcondition (e.g., the blocker arg is false during submit).
- Render a small component (or the ObjectForm) under the
-
Step 5: Verify (vitest ONCE).
cd web && pnpm vitest run src/objects src/lib && pnpm typecheck && pnpm lint. PASS. -
Step 6: Commit
git add web/src/lib/use-unsaved-changes.tsx web/src/objects/object-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/lib/use-unsaved-changes.test.tsx
git commit -m "feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)"
Task 5: Partial-failure unification + final gate
Files: web/src/objects/object-edit-form.tsx, web/src/i18n/en.json, web/src/i18n/sv.json, tests.
(Task 2 already changed the create page to pass state: { created: true, fieldErrorKey, fieldErrorCode }. This task handles the edit page's reading of it + messaging.)
-
Step 1: i18n — add (both locales, parity): en
"createdButFieldRejected": "Object created, but a field was rejected — fix it below."; sv"createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.". -
Step 2: Edit page reads
created. InObjectEditFormLoaded, broaden thelocationStatetype to{ created?: boolean; fieldsError?: boolean; fieldErrorKey?: string; fieldErrorCode?: string } | nulland seed the banner:
const [error, setError] = useState<string | null>(() => {
if (locationState?.created) return t("form.createdButFieldRejected");
if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey });
if (locationState?.fieldsError) return t("form.rejected");
return null;
});
const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(locationState?.fieldErrorKey ?? null);
const [fieldErrorCode, setFieldErrorCode] = useState<string | null>(locationState?.fieldErrorCode ?? null);
(Keep backward compatibility: the create page from T2 sends created; older fieldsError branch can remain or be removed since T2 replaced it — remove fieldsError seeding if no longer sent.)
-
Step 3: Tests. Update
object-new-page.test.tsx: the create→setFields-422 path now navigates to/objects/:id/editand the edit form shows theform.createdButFieldRejectedbanner + highlights the field. (The existing partial-failure test asserted the oldfieldsErrorflow — update it to the newcreatedmessage, not weakened.) -
Step 4: FULL GATE (run tests EXACTLY ONCE):
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
All green. Report test totals, largest chunk, check:colors line.
- Step 5: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
-
Step 6: Manual smoke (recommended).
pnpm dev: create with a bad field → lands on edit with "Object created, but a field was rejected"; submit disables + "Saving…"; edit a field then try to leave (sidebar/Cancel/reload) → guard prompts; "Save & create another" resets; count 0 blocked client-side; Cmd+Enter submits. -
Step 7: Commit
git add web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-new-page.test.tsx
git commit -m "feat(web): unify create/edit partial-failure recovery with 'created' banner (#46)"
Self-Review (completed)
Spec coverage: data-router migration + harness, full suite green (T1); submit-disable via real isSubmitting + Cmd/Ctrl+Enter + Save-&-create-another (T2); code-aware field errors + type-specific core errors + min count (T3); unsaved-changes guard via useBlocker(isDirty && !isSubmitting) + beforeunload + dialog, Cancel through the blocker (T4); partial-failure unified to the edit route with a "created" banner (T5, building on T2's create-side state). All acceptance criteria 1–7 mapped. ✓
Placeholder scan: the alert-dialog wiring says "match the exact exported parts" with the delete dialogs named as the reference — a concrete adapt-to-real-API step, not a TODO. variant="secondary" flagged to verify against button.tsx. No vague steps; all code blocks complete. ✓
Type/flow consistency: onSubmit returns Promise<boolean>|boolean (T2) — both pages updated to return booleans; createAnotherRef gates the reset; fieldErrorCode prop added (T3) and threaded from both the edit catch and the create teleport state (T2/T5); the guard condition isDirty && !isSubmitting ensures save/teleport navigation (still submitting) is never blocked — consistent across T2/T4/T5. ✓
Notes
- The single biggest correctness lever:
handleSubmitmust RETURN/awaitonSubmitsoisSubmittingis real (T2) — both the submit-disable AND the guard's non-blocking-while-saving depend on it. useBlocker(boolean)(RR v7) blocks all nav when true; Cancel and sidebar links both flow through the one dialog. Save-driven nav happens whileisSubmitting→ condition false → not blocked.- No new dependency. New i18n keys:
form.saving/createAnother/minCount/createdButFieldRejected+form.fieldError.*+form.unsaved.*(en+sv parity). No keys removed.