# Consistent, Status-Aware Mutation Error Feedback — 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:** Replace the generic, inconsistent mutation-error feedback with one status-aware mapping rendered consistently inline across every create/edit/delete surface. **Architecture:** A shared `errorMessageKey(error)` maps `InUseError`/`HttpError(status)` → i18n keys (single source of truth). A shared `` renders the inline alert. Mutations throw `HttpError(status)` so the status reaches the helper. All inline sites adopt the helper/component; the two non-suppressed update mutations suppress the toast and show inline like their siblings. **Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, react-i18next, Vitest 4 (jsdom) + RTL + MSW. Test runner: `pnpm test` (single pass). **Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; app source double-quote+semicolon; `ui/` files untouched; token classes only. Run a single test pass. **Spec:** `docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md` **Key facts:** - `HttpError`/`InUseError`/`FieldRejection` are exported from `web/src/api/queries.ts` (lines 6, 13, 20). `HttpError(status)` carries `.status`; `InUseError(count)` carries `.count`. - `web/src/api/query-client.ts:11-23` `mutationErrorMessage(error, meta)` currently special-cases `InUseError`, `HttpError 503 → search.unavailable`, `meta.errorMessage`, else `toast.error`. The `MutationCache.onError` skips when `meta.suppressErrorToast`. - **16 of 18 mutations already `suppressErrorToast`.** Only `useUpdateTerm` (`queries.ts:444`) and `useUpdateAuthority` (`:521`) do not — they have `meta: { successMessage: "toast.saved" }`. - The 16 **mutation** `throw new Error("…")` sites: `queries.ts:184,203,230,248,279,306,331,382,441,458,475,492,518,535,562,579`. `useCreateObject` (`:184`) and `useCreateVocabulary` (`:279`) destructure `{ data, error }` (no `response`) — add `response`. Lines `38,73,90,105,152,169,264` are **query** fetch errors and `:121` is the login map — **do not change these**. `useSearch` (`:360`) already throws `HttpError`. - Inline generic-error sites (all render `{X.isError &&

{t("form.rejected")}

}`): `authorities-page.tsx:144-148` (`create`), `vocabulary-terms.tsx:119-123` (`addTerm`), `vocabulary-list.tsx:57-61` (`create`) + `:109-113` (`renameVocabulary`), `field-form.tsx:201-205` (`failed = isEdit ? update.isError : create.isError`, line 98). `delete-confirm-dialog.tsx:40` sets `err instanceof InUseError ? actions.inUse : form.rejected`. `object-new-page.tsx:36-39` and `object-edit-form.tsx:86-88` set `t("form.rejected")` in a catch else. - `objects.loadError` ("Could not load objects") already exists in both locales. `term-row.tsx`/`authority-row.tsx` Edit-button `onClick` currently does `setLabels(...); setUri(...); setEditing(true);`. - Test env: jsdom project, MSW `onUnhandledRequest:"error"`. `renderApp` (`src/test/render.tsx`) mounts `ui` at `path:"*"` in a memory router. `src/api/mutation-feedback.test.tsx` exists (tests toast wiring via `makeQueryClient` + `ToastRegion` + `renderHook`). --- # Task 1: Shared helper + `` + i18n + query-client rewire **Files:** Create `web/src/api/error-message.ts`, `web/src/api/error-message.test.ts`, `web/src/components/mutation-error.tsx`, `web/src/components/mutation-error.test.tsx`; Modify `web/src/api/query-client.ts`, `web/src/api/mutation-feedback.test.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`. - [ ] **Step 1: Add the 5 i18n keys (both locales, parity).** Add a new top-level `"errors"` object to `web/src/i18n/en.json`: ```json "errors": { "forbidden": "You don't have permission to do that.", "notFound": "That item no longer exists.", "conflict": "That conflicts with existing data.", "validation": "Some values weren't accepted.", "server": "The server had a problem. Please try again." }, ``` and to `web/src/i18n/sv.json`: ```json "errors": { "forbidden": "Du har inte behörighet att göra det.", "notFound": "Objektet finns inte längre.", "conflict": "Det står i konflikt med befintliga data.", "validation": "Vissa värden godtogs inte.", "server": "Servern hade ett problem. Försök igen." }, ``` (Valid JSON — mind commas. Place it consistently in both files, e.g. before `toast`.) - [ ] **Step 2: Create `web/src/api/error-message.ts`:** ```ts import { HttpError, InUseError } from "./queries"; /** Maps a caught mutation error to an i18n key (+ interpolation opts). The single * source of truth shared by the global toast fallback and every inline display. */ export function errorMessageKey(error: unknown): { key: string; opts?: Record } { if (error instanceof InUseError) return { key: "actions.inUse", opts: { count: error.count } }; if (error instanceof HttpError) return { key: statusKey(error.status) }; return { key: "toast.error" }; } function statusKey(status: number): string { if (status === 403) return "errors.forbidden"; if (status === 404) return "errors.notFound"; if (status === 409) return "errors.conflict"; if (status === 422) return "errors.validation"; if (status >= 500) return "errors.server"; return "toast.error"; } ``` - [ ] **Step 3: Create `web/src/api/error-message.test.ts`** (write + run): ```ts import { expect, test } from "vitest"; import { errorMessageKey } from "./error-message"; import { HttpError, InUseError } from "./queries"; test("maps HTTP statuses to specific keys", () => { expect(errorMessageKey(new HttpError(403))).toEqual({ key: "errors.forbidden" }); expect(errorMessageKey(new HttpError(404))).toEqual({ key: "errors.notFound" }); expect(errorMessageKey(new HttpError(409))).toEqual({ key: "errors.conflict" }); expect(errorMessageKey(new HttpError(422))).toEqual({ key: "errors.validation" }); expect(errorMessageKey(new HttpError(500))).toEqual({ key: "errors.server" }); expect(errorMessageKey(new HttpError(502))).toEqual({ key: "errors.server" }); }); test("an unmapped status falls back to the generic key", () => { expect(errorMessageKey(new HttpError(418))).toEqual({ key: "toast.error" }); }); test("InUseError carries the count", () => { expect(errorMessageKey(new InUseError(3))).toEqual({ key: "actions.inUse", opts: { count: 3 } }); }); test("a bare Error or unknown maps to the generic key", () => { expect(errorMessageKey(new Error("boom"))).toEqual({ key: "toast.error" }); expect(errorMessageKey(null)).toEqual({ key: "toast.error" }); }); ``` Run: `cd web && pnpm vitest run src/api/error-message.test.ts` → 4 passing. - [ ] **Step 4: Create `web/src/components/mutation-error.tsx`:** ```tsx import { useTranslation } from "react-i18next"; import { errorMessageKey } from "../api/error-message"; /** Renders a caught mutation error as an inline alert, or nothing when error is falsy. * Replaces the duplicated `

` markup. */ export function MutationError({ error }: { error: unknown }) { const { t } = useTranslation(); if (!error) return null; const { key, opts } = errorMessageKey(error); return (

{t(key, opts)}

); } ``` - [ ] **Step 5: Create `web/src/components/mutation-error.test.tsx`** (write + run): ```tsx import { expect, test } from "vitest"; import { render, screen } from "@testing-library/react"; import "../i18n"; import { MutationError } from "./mutation-error"; import { HttpError, InUseError } from "../api/queries"; test("renders the status-specific message for an HttpError", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent(/permission/i); }); test("renders the in-use count for an InUseError", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent(/2/); }); test("renders nothing when there is no error", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); ``` Run: `cd web && pnpm vitest run src/components/mutation-error.test.tsx` → 3 passing. - [ ] **Step 6: Rewire `web/src/api/query-client.ts`.** Change the import line `import { HttpError, InUseError } from "./queries";` to `import { errorMessageKey } from "./error-message";`, and replace the whole `mutationErrorMessage` function body with: ```ts function mutationErrorMessage( error: unknown, meta: MutationMeta | undefined, ): string { if (meta?.errorMessage) return i18n.t(meta.errorMessage); const { key, opts } = errorMessageKey(error); return i18n.t(key, opts); } ``` (Leave the `MutationCache` `onError`/`onSuccess` wiring unchanged.) - [ ] **Step 7: Rework the obsolete toast test in `web/src/api/mutation-feedback.test.tsx`.** The "a non-suppressed mutation failing shows the catch-all error toast" test uses `useUpdateTerm`, which will suppress in Task 3 — decouple it now by driving the `MutationCache` fallback with a synthetic non-suppressed mutation. Add `useMutation` to the `@tanstack/react-query` import and `HttpError` to the queries import, then replace that single test with: ```tsx test("a non-suppressed mutation failing shows the status-mapped error toast", async () => { const { result, unmount } = renderHook( () => useMutation({ mutationFn: async () => { throw new HttpError(500); }, }), { wrapper: makeWrapper() }, ); await expect(result.current.mutateAsync()).rejects.toThrow(); await waitFor(() => { expect( within(document.body).getByText(i18n.t("errors.server")), ).toBeInTheDocument(); }); unmount(); }); ``` (Keep the other two tests — success toast via `useUpdateTerm`, and suppressed `useDeleteVocabulary` adds no toast — unchanged. Imports: add `useMutation`; add `HttpError` from `./queries`.) - [ ] **Step 8: Verify (vitest ONCE for the touched files), then typecheck + lint:** ```bash cd web && pnpm vitest run src/api/error-message.test.ts src/components/mutation-error.test.tsx src/api/mutation-feedback.test.tsx src/i18n && pnpm typecheck && pnpm lint ``` Expected: all green (helper 4, component 3, feedback 3, i18n parity covering the 5 new keys). - [ ] **Step 9: Commit** ```bash cd /Users/olsson/Laboratory/biggus-dickus git add web/src/api/error-message.ts web/src/api/error-message.test.ts web/src/components/mutation-error.tsx web/src/components/mutation-error.test.tsx web/src/api/query-client.ts web/src/api/mutation-feedback.test.tsx web/src/i18n/en.json web/src/i18n/sv.json git commit -m "feat(web): shared status-aware error-message helper + MutationError component (#63)" ``` --- # Task 2: Make mutation errors load-bearing (throw `HttpError(status)`) **Files:** Modify `web/src/api/queries.ts`. - [ ] **Step 1: Convert the 16 mutation throws to `HttpError`.** In `web/src/api/queries.ts`, at each of these lines replace `throw new Error("…")` with `throw new HttpError(response.status)` — keeping every surrounding `InUseError` (409) and `FieldRejection` (422) branch exactly as-is: - `:203` `useUpdateObject` — `if (response.status !== 204) throw new HttpError(response.status);` - `:230` `useSetFields` — the final fallback after the 204/422 checks: `throw new HttpError(response.status);` - `:248` `useDeleteObject` — `throw new HttpError(response.status);` - `:306` `useAddTerm` — `if (response.status !== 201) throw new HttpError(response.status);` - `:331` `useCreateAuthority` — `throw new HttpError(response.status);` - `:382` `useCreateFieldDefinition` — `if (response.status !== 201 || !data) throw new HttpError(response.status);` - `:441` `useUpdateTerm` — `throw new HttpError(response.status);` - `:458` `useDeleteTerm` — the post-409 fallback: `if (response.status !== 204) throw new HttpError(response.status);` - `:475` `useRenameVocabulary` — `throw new HttpError(response.status);` - `:492` `useDeleteVocabulary` — post-409 fallback: `throw new HttpError(response.status);` - `:518` `useUpdateAuthority` — `throw new HttpError(response.status);` - `:535` `useDeleteAuthority` — post-409 fallback: `throw new HttpError(response.status);` - `:562` `useUpdateFieldDefinition` — `throw new HttpError(response.status);` - `:579` `useDeleteFieldDefinition` — post-409 fallback: `throw new HttpError(response.status);` - [ ] **Step 2: The two POSTs that don't destructure `response`.** For `useCreateObject` (`:184`) and `useCreateVocabulary` (`:279`), change `const { data, error } = await api.POST(...)` to `const { data, error, response } = await api.POST(...)` and replace `if (error || !data) throw new Error("…")` with `if (error || !data) throw new HttpError(response.status);`. - [ ] **Step 3: Do NOT touch** the query fetch errors (`:38,73,90,105,152,169,264`) or the login map (`:121`) — they keep `throw new Error(...)` (consumed by component `isError`/login mapping, not the toast). `HttpError` is already imported in this file (it's defined here), so no import change. - [ ] **Step 4: Verify (vitest ONCE), typecheck, lint:** ```bash cd web && pnpm vitest run src/api/queries.test.ts src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint ``` Expected: green. `HttpError` extends `Error`, so any `.rejects.toThrow()` assertions still pass (no test asserts the old message strings). If `queries.test.ts` asserts a specific thrown type/message that now differs, update it to assert `HttpError`/status (do not weaken). - [ ] **Step 5: Commit** ```bash cd /Users/olsson/Laboratory/biggus-dickus git add web/src/api/queries.ts git commit -m "feat(web): mutations throw HttpError(status) so failures are status-aware (#63)" ``` --- # Task 3: Edit-row consistency + delete-dialog adoption **Files:** Modify `web/src/api/queries.ts`, `web/src/vocab/term-row.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/components/delete-confirm-dialog.tsx`, and their tests / `web/src/api/mutation-feedback.test.tsx` consumers. - [ ] **Step 1: Suppress the two update toasts.** In `web/src/api/queries.ts`, change `useUpdateTerm`'s meta (`:444`) from `meta: { successMessage: "toast.saved" }` to `meta: { successMessage: "toast.saved", suppressErrorToast: true }`, and the same for `useUpdateAuthority` (`:521`). - [ ] **Step 2: Inline error + reset in `web/src/vocab/term-row.tsx`.** Add the import `import { MutationError } from "../components/mutation-error";`. In the editing view, add `` immediately after the save/cancel `
` (still inside the `
  • `). In the non-editing view's Edit `