diff --git a/docs/superpowers/plans/2026-06-08-mutation-error-feedback.md b/docs/superpowers/plans/2026-06-08-mutation-error-feedback.md
new file mode 100644
index 0000000..966b69a
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-mutation-error-feedback.md
@@ -0,0 +1,421 @@
+# 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 `` `onClick`, add `updateTerm.reset();` as the first statement (before `setLabels(...)`):
+```tsx
+ onClick={() => {
+ updateTerm.reset();
+ setLabels(term.labels as LabelInput[]);
+ setUri(term.external_uri ?? "");
+ setEditing(true);
+ }}
+```
+
+- [ ] **Step 2b: Same for `web/src/authorities/authority-row.tsx`.** Import `MutationError`; add ` ` after the save/cancel `…
`; add `updateAuthority.reset();` as the first statement in the Edit button's `onClick`.
+
+- [ ] **Step 3: Status-aware delete dialog (`web/src/components/delete-confirm-dialog.tsx`).** Add `import { errorMessageKey } from "../api/error-message";`, remove the now-unused `import { InUseError } from "../api/queries";`, and change the `catch` block (lines ~37-41) from the `err instanceof InUseError ? … : t("form.rejected")` line to:
+```tsx
+ } catch (err) {
+ // Keep the dialog open; show the blocking reason. Never let the rejected
+ // mutation escape as an unhandled rejection.
+ const { key, opts } = errorMessageKey(err);
+ setMessage(t(key, opts));
+ return;
+ }
+```
+(`errorMessageKey` already maps `InUseError` → `actions.inUse` with the count, so the in-use message is preserved and a 403/404 delete now shows a specific message.)
+
+- [ ] **Step 4: Tests.** Add a failing-update test to `web/src/vocab/term-row.tsx`'s area — create `web/src/vocab/term-row.test.tsx` if absent (check first), else extend. Render a `TermRow` inside `renderApp` with the `makeQueryClient` toast wrapper is overkill — use the existing `renderApp` and MSW. Concretely:
+```tsx
+import { expect, test } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { TermRow } from "./term-row";
+
+const term = { id: "t1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] };
+
+test("a failed term update shows an inline error and keeps the row editable", async () => {
+ server.use(
+ http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
+ );
+ renderApp();
+
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await userEvent.click(screen.getByRole("button", { name: /save/i }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/permission/i);
+ // still editable: the save button is still present (editor did not close)
+ expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
+});
+
+test("re-entering edit after a failure clears the stale error", async () => {
+ server.use(
+ http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
+ );
+ renderApp();
+
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await userEvent.click(screen.getByRole("button", { name: /save/i }));
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+
+ expect(screen.queryByRole("alert")).toBeNull();
+});
+```
+(Adjust the `lang`/`term` cast to match `TermView`. If a `term-row.test.tsx` already exists, append these and keep existing tests green. The `mutation-feedback.test.tsx` test #1 — `useUpdateTerm` success → `toast.saved` — still passes since suppress only affects errors; confirm it stays green.)
+
+- [ ] **Step 5: Verify (vitest ONCE):**
+```bash
+cd web && pnpm vitest run src/vocab/term-row.test.tsx src/authorities src/components/delete-confirm-dialog.test.tsx src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint
+```
+Expected: green (new row tests pass; delete-dialog story/test still green; authority tests green). If `delete-confirm-dialog.test.tsx` doesn't exist, drop it from the command.
+
+- [ ] **Step 6: Commit**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git add web/src/api/queries.ts web/src/vocab/term-row.tsx web/src/authorities/authority-row.tsx web/src/components/delete-confirm-dialog.tsx web/src/vocab/term-row.test.tsx
+git commit -m "feat(web): inline status-aware errors on term/authority edit rows + delete dialog (#63)"
+```
+
+---
+
+# Task 4: Create/rename/object form adoption + fetch fix + full gate
+
+**Files:** Modify `web/src/authorities/authorities-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/vocabulary-list.tsx`, `web/src/fields/field-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx` (+ a test).
+
+- [ ] **Step 1: Adopt `` at the create/rename inline sites.** In each file, add `import { MutationError } from "../components/mutation-error";` (path `../components/mutation-error` from these dirs) and replace the `{X.isError && {t("form.rejected")}
}` block with ` `:
+ - `authorities-page.tsx:144-148` → ` `
+ - `vocabulary-terms.tsx:119-123` → ` `
+ - `vocabulary-list.tsx:57-61` → ` `; and `:109-113` → ` `
+ - `field-form.tsx:201-205` → ` `; then delete the now-unused `const failed = isEdit ? update.isError : create.isError;` (`:98`). Keep `const pending = …` and the `{error && …form.required}` validation block.
+
+- [ ] **Step 2: Object create/edit catch-else via the helper.** In `web/src/objects/object-new-page.tsx`, add `import { errorMessageKey } from "../api/error-message";` and change the create catch (`:36-39`) from `} catch { setError(t("form.rejected")); return false; }` to:
+```tsx
+ } catch (e) {
+ const { key, opts } = errorMessageKey(e);
+ setError(t(key, opts));
+ return false;
+ }
+```
+In `web/src/objects/object-edit-form.tsx`, add the same import and change the non-`FieldRejection` else (`:86-88`) from `setError(t("form.rejected"));` to:
+```tsx
+ } else {
+ const { key, opts } = errorMessageKey(e);
+ setError(t(key, opts));
+ }
+```
+
+- [ ] **Step 3: Fetch-error fix in `web/src/objects/object-edit-form.tsx`.** Change `const { data: object, isLoading } = useObject(id!);` to `const { data: object, isLoading, isError } = useObject(id!);` and insert, between the `isLoading` and `!object` guards:
+```tsx
+ if (isError) return {t("objects.loadError")}
;
+```
+
+- [ ] **Step 4: Test the fetch fix + one create-form adoption.** Add to `web/src/objects/object-edit-form.test.tsx` (create if absent):
+```tsx
+test("renders a load error (not 'not found') when the object fetch fails", async () => {
+ server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 500 })));
+ renderApp( } /> , {
+ route: "/objects/abc/edit",
+ });
+ expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
+ expect(screen.queryByText(/not found/i)).toBeNull();
+});
+```
+(Use the file's existing imports/harness; add `http`/`HttpResponse` from `msw`, `Routes`/`Route`, `server`, `renderApp`, `ObjectEditForm` as needed. If the file exists, append and keep its tests green.) Also add to `web/src/authorities/authorities-page.test.tsx` (or the nearest existing authorities test) a case asserting a failed create shows the status message:
+```tsx
+test("a failed create shows a status-aware inline error", async () => {
+ server.use(http.post("/api/admin/authorities", () => new HttpResponse(null, { status: 403 })));
+ // …render the authorities page, fill a label, submit…
+ expect(await screen.findByText(/permission/i)).toBeInTheDocument();
+});
+```
+(Wire the render/fill/submit to match the existing authorities test harness; if that harness isn't readily reusable, cover the create-error path through `field-form` or skip this second assertion — the `MutationError` component test already proves the rendering, and `error-message.test.ts` proves the mapping. Do NOT add a brittle test just to hit a number.)
+
+- [ ] **Step 5: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
+```bash
+cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
+```
+All green. Report test totals, largest chunk (gz), and the `check:colors` line.
+
+- [ ] **Step 6: Codename + status:**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
+git status --short
+```
+Expected: no matches (`codename-exit=1`).
+
+- [ ] **Step 7: Manual smoke (recommended).** With the stack + server + `pnpm dev`: cause a failing save (e.g. stop the server, or a 409 on a duplicate) on a term/authority edit row → inline status message appears at the row, row stays editable; a failed create shows the inline message; loading an edit form whose object 500s shows "Could not load objects" not "not found."
+
+- [ ] **Step 8: Commit**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git add web/src/authorities/authorities-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/vocabulary-list.tsx web/src/fields/field-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-edit-form.test.tsx web/src/authorities/authorities-page.test.tsx
+git commit -m "feat(web): adopt MutationError across create/object forms; distinguish edit-form fetch error (#63)"
+```
+
+---
+
+## Self-Review (completed)
+
+**Spec coverage:** AC1 `errorMessageKey` + unit test (T1 S2-S3); AC2 `` + adoption at delete-dialog (T3 S3), create/rename forms (T4 S1), object-form `formError` via helper (T4 S2), edit rows (T3 S2); AC3 16 throws → `HttpError` + query-client rewire (T2, T1 S6); AC4 update suppress + inline + reset (T3 S1-S2); AC5 edit-form fetch error (T4 S3); AC6 gate + parity + codename (T4 S5-S6, T1 S1). ✓
+
+**Placeholder scan:** every code step shows complete code; tests have concrete assertions and exact statuses (403/500); the one soft spot (T4 S4 second assertion) is explicitly bounded with "don't add a brittle test to hit a number," and the mapping/rendering are already proven by T1's two test files. No TODO/TBD. ✓
+
+**Type/consistency:** `errorMessageKey(error: unknown): { key: string; opts? }` defined in T1, consumed identically by `query-client.ts`, `MutationError`, `delete-confirm-dialog`, `object-new-page`, `object-edit-form`. `HttpError(status)` (T2) feeds `errorMessageKey`'s `instanceof HttpError` branch. `suppressErrorToast` added (T3 S1) matches the sibling mutations' meta shape. `objects.loadError` pre-exists. ✓
+
+## Notes
+- No new dependency. `ui/*` untouched. en/sv parity preserved (5 new `errors.*` keys, guarded by the #60 parity test).
+- After Task 2 (before Task 3), `useUpdateTerm`/`useUpdateAuthority` are still non-suppressed and now throw `HttpError`, so a failed update shows a status-mapped **toast** — a transient mid-milestone state; Task 3 moves it inline. Each commit is independently green.
+- Error classes stay in `queries.ts`; relocating them to `api/errors.ts` is tracked in #65.
diff --git a/docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md b/docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md
new file mode 100644
index 0000000..3e03469
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md
@@ -0,0 +1,213 @@
+# Consistent, Status-Aware Mutation Error Feedback — Design
+
+**Date:** 2026-06-08
+**Status:** Approved (brainstorming) — ready for implementation planning.
+**Issue:** #63 (silent update failures + dead/unreachable mutation error strings).
+
+## Context
+
+A frontend deep audit flagged the TanStack Query mutation layer for inconsistent error feedback.
+Grounding in the code refined the picture:
+
+- **The update mutations are not silent — they're inconsistent.** `useUpdateTerm` (`queries.ts:444`) and
+ `useUpdateAuthority` (`:521`) are the **only 2 of 18 mutations** without `meta.suppressErrorToast`, so on
+ failure they fire the global `MutationCache` error toast while the row stays in edit mode. The matching
+ **create** and **delete** actions on the same screens suppress the toast and show an **inline** message
+ instead. So within one screen, create-failure → inline, delete-failure → inline, edit-failure → a
+ disconnected global toast.
+- **Once the 2 update mutations also suppress (the consistency fix), all 18 mutations suppress** → the
+ global error toast becomes vestigial. Therefore status differentiation only delivers value at the
+ **inline** error sites, which today all render a generic `t("form.rejected")` ("Could not be saved").
+- **The generic error strings are dead.** 16 mutations `throw new Error("update failed" | …)`, but
+ `mutationErrorMessage` (`query-client.ts:11-23`) never reads `.message` — it falls back to
+ `t("toast.error")`. The bespoke strings are unreachable.
+- **`object-edit-form` mislabels a fetch failure.** `objects/object-edit-form.tsx:17` destructures only
+ `{ data, isLoading }` from `useObject`; a non-404 fetch error (network/500) falls through to the
+ "object not found" branch. (The **submit** path already handles `FieldRejection` and a generic else
+ correctly.)
+
+### Decisions (from brainstorming)
+1. Differentiate errors **by HTTP status**, surfaced via one shared mapping.
+2. Because all mutations suppress the toast, apply status-aware messages at **all inline error sites**
+ (via one shared `` component), not the (now-vestigial) toast. Half-applying would
+ create a new inline inconsistency.
+3. Make `useUpdateTerm`/`useUpdateAuthority` consistent with their create/delete siblings (suppress +
+ inline).
+4. Fix the `object-edit-form` fetch mislabel.
+
+## Components
+
+### `web/src/api/error-message.ts` (new) — single source of truth
+```ts
+import { HttpError, InUseError } from "./queries";
+
+/** Maps a caught mutation error to an i18n key (+ interpolation opts). Used by
+ * both the global toast fallback and every inline error 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";
+}
+```
+Returns a key+opts (not a resolved string) so callers render with their own `t` (reactive to language).
+`InUseError` is checked first so 409-with-count keeps its richer message. (No circular import:
+`queries.ts` does not import this module; `query-client.ts` imports both.)
+
+### `web/src/components/mutation-error.tsx` (new) — shared inline display
+```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)}
+
+ );
+}
+```
+
+### `web/src/api/query-client.ts` (rewire)
+`mutationErrorMessage` becomes:
+```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);
+}
+```
+This keeps the toast path as a one-source-of-truth fallback (for any future non-suppressed mutation) and
+drops the old special-cases (`InUseError` and `503 → search.unavailable` now flow through
+`errorMessageKey`; no mutation throws 503, and the search **query**'s own inline 503 handling in
+`search-panel.tsx` is untouched). Import moves from `{ HttpError, InUseError }` to `{ errorMessageKey }`.
+
+### `web/src/api/queries.ts` (load-bearing errors)
+Replace the 16 **mutation** `throw new Error("…")` with `throw new HttpError(response.status)`, preserving
+the existing `InUseError` (409) and `FieldRejection` (422 on `setFields`) branches. Two POSTs
+(`useCreateObject` `:184`, `useCreateVocabulary` `:279`) destructure only `{ data, error }` — add
+`response` so the status is available. **Query** fetch errors (`:38,73,90,105,152,169,264`) and the login
+`"invalid"/"network"` mapping (`:121`) are NOT changed (they're consumed by component `isError`/login
+mapping, not the mutation toast). `useUpdateObject`'s 422 maps to the generic `errors.validation`;
+surfacing a *structured* core-field rejection there is uncertain backend behavior → out of scope.
+
+### Inline-site adoption — replace generic `form.rejected` with ``
+- `web/src/components/delete-confirm-dialog.tsx:33-42` — the `confirm` catch currently sets
+ `err instanceof InUseError ? t("actions.inUse", {count}) : t("form.rejected")`. Replace with
+ `const { key, opts } = errorMessageKey(err); setMessage(t(key, opts));` — `errorMessageKey` already
+ handles `InUseError`, so the dialog simplifies and a 403/404 delete now reads a specific message. (The
+ dialog keeps its own `message` state because it must stay open on failure; it does not render
+ `` directly.)
+- `web/src/authorities/authorities-page.tsx:144-148` — replace the `{create.isError && form.rejected}`
+ with ` `.
+- `web/src/vocab/vocabulary-terms.tsx:119-123` — ` `.
+- `web/src/vocab/vocabulary-list.tsx:57-61` (create) and `:109-113` (rename) — ` `
+ and ` `.
+- `web/src/fields/field-form.tsx:202-204` — replace the `{failed &&
form.rejected}` with
+ ` `.
+- **Object create/edit:** `object-new-page.tsx` and `object-edit-form.tsx` keep the `FieldRejection`
+ field-specific branch; in the non-`FieldRejection` else, replace `setError(t("form.rejected"))` with
+ `const { key, opts } = errorMessageKey(e); setError(t(key, opts));` (these set a string passed to
+ `ObjectForm`'s `formError` prop, so they use `errorMessageKey` directly, not ``).
+
+### Edit-row consistency — `term-row.tsx`, `authority-row.tsx`, `queries.ts`
+- Add `suppressErrorToast: true` to `useUpdateTerm` (`:444`) and `useUpdateAuthority` (`:521`) meta.
+- In each row's edit view, render ` ` (resp. `updateAuthority.error`)
+ below the save/cancel buttons.
+- Call `updateTerm.reset()` (resp. `updateAuthority.reset()`) inside the Edit button's `onClick` (alongside
+ the existing `setLabels`/`setUri`/`setEditing`) so a stale error from a prior failed save doesn't linger
+ when re-entering edit mode. On a failed save the row already stays editable (the `onSuccess`
+ `setEditing(false)` doesn't fire), preserving the user's input.
+
+### `web/src/objects/object-edit-form.tsx` (fetch fix)
+```tsx
+const { data: object, isLoading, isError } = useObject(id!);
+if (isLoading) return ;
+if (isError) return {t("objects.loadError")}
;
+if (!object) return {t("objects.notFound")}
;
+```
+`objects.loadError` ("Could not load objects") already exists in both locales.
+
+### i18n (en + sv parity — 5 new keys under `errors`)
+| key | en | sv |
+|-----|----|----|
+| `errors.forbidden` | You don't have permission to do that. | Du har inte behörighet att göra det. |
+| `errors.notFound` | That item no longer exists. | Objektet finns inte längre. |
+| `errors.conflict` | That conflicts with existing data. | Det står i konflikt med befintliga data. |
+| `errors.validation` | Some values weren't accepted. | Vissa värden godtogs inte. |
+| `errors.server` | The server had a problem. Please try again. | Servern hade ett problem. Försök igen. |
+
+## Data flow
+mutation rejects → `HttpError(status)` (or `InUseError`/`FieldRejection`) → caught by the component (or
+the `MutationCache` fallback) → `errorMessageKey(error)` → `{key, opts}` → `t(key, opts)` rendered inline
+via `` (or as a `formError` string / dialog message). One mapping, one component, every
+surface.
+
+## Error handling / edges
+- **All mutations suppress** after this change → the `MutationCache.onError` toast path is a dormant
+ fallback; kept (not deleted) so a future non-suppressed mutation still gets a sensible message.
+- **401** isn't in the status map → falls to `toast.error`; the `client.ts` middleware still redirects to
+ login (unchanged). A brief generic toast before redirect is pre-existing, not worsened.
+- **Network error with no response:** openapi-fetch may reject before a `response` exists; those paths
+ keep throwing a generic `Error` → `errorMessageKey` returns `toast.error`. (Only status-bearing
+ failures get differentiated.)
+- **Language reactivity:** `` and the object-form `formError` re-render with the active
+ locale because they call `t` at render (the `formError` string is recomputed on the next failed submit;
+ acceptable — error strings are transient).
+- **Stale row error:** cleared via `mutation.reset()` on re-edit.
+
+## Testing
+- **`web/src/api/error-message.test.ts`** (unit): each status → expected key (403/404/409/422/500/502);
+ `InUseError(3)` → `{ key: "actions.inUse", opts: { count: 3 } }`; a bare `Error`/unknown →
+ `{ key: "toast.error" }`.
+- **`web/src/components/mutation-error.test.tsx`**: renders the mapped text for an `HttpError(403)`;
+ renders nothing for `null`/`undefined`; renders the in-use count for `InUseError`.
+- **`web/src/api/mutation-feedback.test.tsx`** (rework): the "non-suppressed → catch-all toast" test is
+ obsolete (all mutations now suppress). Replace it with a test that a suppressed mutation failing adds
+ **no** toast (keep the existing delete case), plus assert the success-toast case still works. The
+ status→message behavior is covered by `error-message.test.ts`.
+- **`term-row` / `authority-row`** (extend existing or via `mutation-feedback`): a failed update renders
+ the inline `MutationError` text and the row stays editable; a successful update closes the editor;
+ re-entering edit after a failure shows no stale error.
+- **`object-edit-form`**: a `useObject` fetch error (mock `/api/admin/objects/{id}` → 500) renders
+ `objects.loadError`, not `objects.notFound`.
+- **Inline adoption**: at least one create-form test (e.g. authorities) asserts a failed create shows the
+ status-aware message via `MutationError` (e.g. 403 → `errors.forbidden`).
+- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (the #60
+ parity test guards the 5 new keys); no codename; no new dependency.
+
+## Acceptance criteria
+1. A shared `errorMessageKey(error)` maps `InUseError` and `HttpError` (by status: 403/404/409/422/≥500)
+ to i18n keys, with a `toast.error` fallback; unit-tested.
+2. A shared `` component renders the mapped inline alert (or nothing) and replaces
+ the duplicated `form.rejected` markup at every inline mutation-error site (delete dialog via the helper
+ directly, create/rename forms, edit rows, object-form `formError` via the helper).
+3. The 16 mutation throws use `HttpError(status)` (queries unchanged); `query-client.ts` routes the toast
+ fallback through `errorMessageKey`.
+4. `useUpdateTerm`/`useUpdateAuthority` suppress the toast and show an inline error at the row, staying
+ editable on failure and clearing stale errors on re-edit.
+5. `object-edit-form` distinguishes a fetch error (`objects.loadError`) from "not found."
+6. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (5 new
+ keys); no codename; no new dependency.
+
+## Out of scope → follow-ups
+- Structured field-level rejection on **core-object** update 422 (uncertain backend shape).
+- The broader `queries.ts` split + relocating the error classes to `api/errors.ts` (tracked in **#65** —
+ `error-message.ts` imports the classes from `queries.ts` for now).
+- Toast-based error surfacing (deliberately superseded by inline; the toast path remains only as a
+ fallback).
+- Retry affordances / optimistic updates.
diff --git a/web/src/api/error-message.test.ts b/web/src/api/error-message.test.ts
new file mode 100644
index 0000000..b5ede9b
--- /dev/null
+++ b/web/src/api/error-message.test.ts
@@ -0,0 +1,26 @@
+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" });
+});
diff --git a/web/src/api/error-message.ts b/web/src/api/error-message.ts
new file mode 100644
index 0000000..b4538a3
--- /dev/null
+++ b/web/src/api/error-message.ts
@@ -0,0 +1,18 @@
+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";
+}
diff --git a/web/src/api/mutation-feedback.test.tsx b/web/src/api/mutation-feedback.test.tsx
index 806fb9a..31a50a3 100644
--- a/web/src/api/mutation-feedback.test.tsx
+++ b/web/src/api/mutation-feedback.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "vitest";
import { renderHook, waitFor, within } from "@testing-library/react";
-import { QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider, useMutation } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
@@ -8,7 +8,7 @@ import i18n from "../i18n";
import { ToastRegion } from "../components/ui/toast";
import { server } from "../test/server";
import { makeQueryClient } from "./query-client";
-import { useDeleteVocabulary, useUpdateTerm } from "./queries";
+import { HttpError, useDeleteVocabulary, useUpdateTerm } from "./queries";
// The toast manager is a module-scope singleton shared across renders, so each
// test mounts a fresh region and tears it down afterwards to keep toasts from
@@ -59,30 +59,22 @@ describe("mutation feedback toasts", () => {
unmount();
});
- test("a non-suppressed mutation failing shows the catch-all error toast", async () => {
- server.use(
- http.patch(
- "/api/admin/vocabularies/:id/terms/:term_id",
- () => new HttpResponse(null, { status: 500 }),
- ),
+ 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() },
);
- const { result, unmount } = renderHook(() => useUpdateTerm(), {
- wrapper: makeWrapper(),
- });
-
- await expect(
- result.current.mutateAsync({
- vocabularyId: "v1",
- termId: "t1",
- external_uri: null,
- labels: [{ lang: "en", label: "Bronze" }],
- }),
- ).rejects.toThrow();
+ await expect(result.current.mutateAsync()).rejects.toThrow();
await waitFor(() => {
expect(
- within(document.body).getByText(i18n.t("toast.error")),
+ within(document.body).getByText(i18n.t("errors.server")),
).toBeInTheDocument();
});
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index 6be8c8b..b004e4f 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -179,9 +179,9 @@ export function useCreateObject() {
return useMutation({
mutationFn: async (body: ObjectCreateRequest) => {
- const { data, error } = await api.POST("/api/admin/objects", { body });
+ const { data, error, response } = await api.POST("/api/admin/objects", { body });
- if (error || !data) throw new Error("create failed");
+ if (error || !data) throw new HttpError(response.status);
return data;
},
@@ -200,7 +200,7 @@ export function useUpdateObject() {
body,
});
- if (response.status !== 204) throw new Error("update failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["objects"] });
@@ -227,7 +227,7 @@ export function useSetFields() {
throw new FieldRejection(detail.field, detail.code);
}
- throw new Error("set fields failed");
+ throw new HttpError(response.status);
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["object", id] });
@@ -245,7 +245,7 @@ export function useDeleteObject() {
params: { path: { id } },
});
- if (response.status !== 204) throw new Error("delete failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
@@ -274,9 +274,9 @@ export function useCreateVocabulary() {
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => {
- const { data, error } = await api.POST("/api/admin/vocabularies", { body });
+ const { data, error, response } = await api.POST("/api/admin/vocabularies", { body });
- if (error || !data) throw new Error("create vocabulary failed");
+ if (error || !data) throw new HttpError(response.status);
return data;
},
@@ -303,7 +303,7 @@ export function useAddTerm() {
body: { external_uri, labels },
});
- if (response.status !== 201) throw new Error("add term failed");
+ if (response.status !== 201) throw new HttpError(response.status);
},
onSuccess: (_result, { vocabularyId }) =>
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
@@ -328,7 +328,7 @@ export function useCreateAuthority() {
body: { kind, external_uri, labels },
});
- if (response.status !== 201) throw new Error("create authority failed");
+ if (response.status !== 201) throw new HttpError(response.status);
},
onSuccess: (_result, { kind }) =>
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
@@ -379,7 +379,7 @@ export function useCreateFieldDefinition() {
mutationFn: async (body: NewFieldDefinitionRequest) => {
const { data, response } = await api.POST("/api/admin/field-definitions", { body });
- if (response.status !== 201 || !data) throw new Error("failed to create field definition");
+ if (response.status !== 201 || !data) throw new HttpError(response.status);
return data;
},
@@ -438,10 +438,10 @@ export function useUpdateTerm() {
body: { external_uri, labels },
});
- if (response.status !== 204) throw new Error("update term failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
- meta: { successMessage: "toast.saved" },
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
@@ -455,7 +455,7 @@ export function useDeleteTerm() {
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new Error("delete term failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
@@ -472,7 +472,7 @@ export function useRenameVocabulary() {
body: { key },
});
- if (response.status !== 204) throw new Error("rename failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
meta: { successMessage: "toast.renamed", suppressErrorToast: true },
@@ -489,7 +489,7 @@ export function useDeleteVocabulary() {
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new Error("delete vocabulary failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
@@ -515,10 +515,10 @@ export function useUpdateAuthority() {
body: { external_uri, labels },
});
- if (response.status !== 204) throw new Error("update authority failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
- meta: { successMessage: "toast.saved" },
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
@@ -532,7 +532,7 @@ export function useDeleteAuthority() {
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new Error("delete authority failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
@@ -559,7 +559,7 @@ export function useUpdateFieldDefinition() {
body: { required, group, labels },
});
- if (response.status !== 204) throw new Error("update field failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
meta: { successMessage: "toast.saved", suppressErrorToast: true },
@@ -576,7 +576,7 @@ export function useDeleteFieldDefinition() {
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new Error("delete field failed");
+ if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
diff --git a/web/src/api/query-client.ts b/web/src/api/query-client.ts
index 66e4d58..925901c 100644
--- a/web/src/api/query-client.ts
+++ b/web/src/api/query-client.ts
@@ -6,20 +6,15 @@ import {
import i18n from "../i18n";
import { toastManager } from "../toast/toast-manager";
-import { HttpError, InUseError } from "./queries";
+import { errorMessageKey } from "./error-message";
function mutationErrorMessage(
error: unknown,
meta: MutationMeta | undefined,
): string {
if (meta?.errorMessage) return i18n.t(meta.errorMessage);
- if (error instanceof InUseError) {
- return i18n.t("actions.inUse", { count: error.count });
- }
- if (error instanceof HttpError && error.status === 503) {
- return i18n.t("search.unavailable");
- }
- return i18n.t("toast.error");
+ const { key, opts } = errorMessageKey(error);
+ return i18n.t(key, opts);
}
/** Builds the app's QueryClient, including the MutationCache that bridges every
diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx
index 1ce4268..97e18a3 100644
--- a/web/src/authorities/authorities-page.tsx
+++ b/web/src/authorities/authorities-page.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
+import { MutationError } from "../components/mutation-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -141,11 +142,7 @@ export function AuthoritiesPage() {
)}
- {create.isError && (
-
- {t("form.rejected")}
-
- )}
+
{t("authorities.create")}
diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx
index a9a5f60..04695db 100644
--- a/web/src/authorities/authority-row.tsx
+++ b/web/src/authorities/authority-row.tsx
@@ -5,6 +5,7 @@ import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
+import { MutationError } from "../components/mutation-error";
import { ExternalUriLink } from "../components/external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -56,6 +57,7 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
{t("form.cancel")}
+
);
}
@@ -71,6 +73,7 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
variant="ghost"
size="sm"
onClick={() => {
+ updateAuthority.reset();
setLabels(authority.labels as LabelInput[]);
setUri(authority.external_uri ?? "");
setEditing(true);
diff --git a/web/src/components/delete-confirm-dialog.tsx b/web/src/components/delete-confirm-dialog.tsx
index 43dcd62..5792b1f 100644
--- a/web/src/components/delete-confirm-dialog.tsx
+++ b/web/src/components/delete-confirm-dialog.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { InUseError } from "../api/queries";
+import { errorMessageKey } from "../api/error-message";
import {
AlertDialog,
AlertDialogTrigger,
@@ -37,7 +37,8 @@ export function DeleteConfirmDialog({
} catch (err) {
// Keep the dialog open; show the blocking reason. Never let the rejected
// mutation escape as an unhandled rejection.
- setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
+ const { key, opts } = errorMessageKey(err);
+ setMessage(t(key, opts));
return;
}
setOpen(false);
diff --git a/web/src/components/mutation-error.test.tsx b/web/src/components/mutation-error.test.tsx
new file mode 100644
index 0000000..c00c35e
--- /dev/null
+++ b/web/src/components/mutation-error.test.tsx
@@ -0,0 +1,21 @@
+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();
+});
diff --git a/web/src/components/mutation-error.tsx b/web/src/components/mutation-error.tsx
new file mode 100644
index 0000000..5c30259
--- /dev/null
+++ b/web/src/components/mutation-error.tsx
@@ -0,0 +1,16 @@
+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)}
+
+ );
+}
diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx
index 4bb86e5..60afd05 100644
--- a/web/src/fields/field-form.tsx
+++ b/web/src/fields/field-form.tsx
@@ -8,6 +8,7 @@ import {
useVocabularies,
} from "../api/queries";
import { LabelEditor } from "../components/label-editor";
+import { MutationError } from "../components/mutation-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -95,7 +96,6 @@ export function FieldForm({
};
const pending = isEdit ? update.isPending : create.isPending;
- const failed = isEdit ? update.isError : create.isError;
return (
)}
- {failed && (
-
- {t("form.rejected")}
-
- )}
+
{isEdit ? t("actions.save") : t("fields.create")}
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 02a74a1..1458c67 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -61,6 +61,13 @@
"editLink": "Edit the record",
"illegalError": "That visibility change isn't allowed."
},
+ "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."
+ },
"toast": {
"created": "Created",
"saved": "Saved",
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index f0ffe52..4a2bbab 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -61,6 +61,13 @@
"editLink": "Redigera posten",
"illegalError": "Den synlighetsändringen är inte tillåten."
},
+ "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."
+ },
"toast": {
"created": "Skapat",
"saved": "Sparat",
diff --git a/web/src/objects/object-edit-form.test.tsx b/web/src/objects/object-edit-form.test.tsx
index 18aed44..767bb4c 100644
--- a/web/src/objects/object-edit-form.test.tsx
+++ b/web/src/objects/object-edit-form.test.tsx
@@ -69,3 +69,15 @@ test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async (
expect((putCore as { object_name: string }).object_name).toBe("Big amphora");
expect((putFields as { inscription: string }).inscription).toBe("old");
});
+
+test("renders a load error (not 'not found') when the object fetch fails", async () => {
+ server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 500 })));
+ renderApp(
+
+ } />
+ ,
+ { route: "/objects/abc/edit" },
+ );
+ expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
+ expect(screen.queryByText(/not found/i)).toBeNull();
+});
diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx
index 0677ac1..12c936a 100644
--- a/web/src/objects/object-edit-form.tsx
+++ b/web/src/objects/object-edit-form.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useObject, useUpdateObject, useSetFields, FieldRejection } from "../api/queries";
+import { errorMessageKey } from "../api/error-message";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
import { FormSkeleton } from "@/components/ui/skeletons";
@@ -14,10 +15,12 @@ export function ObjectEditForm() {
const { t } = useTranslation();
const { id } = useParams();
- const { data: object, isLoading } = useObject(id!);
+ const { data: object, isLoading, isError } = useObject(id!);
if (isLoading) return ;
+ if (isError) return {t("objects.loadError")}
;
+
if (!object) return {t("objects.notFound")}
;
return ;
@@ -84,7 +87,8 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
setFieldErrorCode(e.code);
setError(t("form.fieldRejected", { field: e.field }));
} else {
- setError(t("form.rejected"));
+ const { key, opts } = errorMessageKey(e);
+ setError(t(key, opts));
}
return false;
diff --git a/web/src/objects/object-new-page.tsx b/web/src/objects/object-new-page.tsx
index 7940935..e25a580 100644
--- a/web/src/objects/object-new-page.tsx
+++ b/web/src/objects/object-new-page.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ObjectForm, type ObjectFormValues } from "./object-form";
import { useCreateObject, useSetFields, FieldRejection } from "../api/queries";
+import { errorMessageKey } from "../api/error-message";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
@@ -33,8 +34,9 @@ export function ObjectNewPage() {
});
id = created.id;
- } catch {
- setError(t("form.rejected"));
+ } catch (e) {
+ const { key, opts } = errorMessageKey(e);
+ setError(t(key, opts));
return false;
}
diff --git a/web/src/vocab/term-row.test.tsx b/web/src/vocab/term-row.test.tsx
new file mode 100644
index 0000000..8f99615
--- /dev/null
+++ b/web/src/vocab/term-row.test.tsx
@@ -0,0 +1,51 @@
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+
+import type { TermView } from "../test/fixtures";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { TermRow } from "./term-row";
+
+const term: TermView = {
+ id: "t1",
+ external_uri: null,
+ labels: [{ lang: "en", label: "Bronze" }],
+};
+
+test("a failed term update shows an inline error and keeps the row editable", async () => {
+ server.use(
+ http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
+ );
+ renderApp(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await userEvent.click(screen.getByRole("button", { name: /save/i }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/permission/i);
+ expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
+});
+
+test("re-entering edit after a failure clears the stale error", async () => {
+ server.use(
+ http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
+ );
+ renderApp(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await userEvent.click(screen.getByRole("button", { name: /save/i }));
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ await userEvent.click(screen.getByRole("button", { name: /edit/i }));
+
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/web/src/vocab/term-row.tsx b/web/src/vocab/term-row.tsx
index d1b5cd6..69261e1 100644
--- a/web/src/vocab/term-row.tsx
+++ b/web/src/vocab/term-row.tsx
@@ -5,6 +5,7 @@ import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
+import { MutationError } from "../components/mutation-error";
import { ExternalUriLink } from "../components/external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -50,6 +51,7 @@ export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; te
{t("form.cancel")}
+
);
}
@@ -65,6 +67,7 @@ export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; te
variant="ghost"
size="sm"
onClick={() => {
+ updateTerm.reset();
setLabels(term.labels as LabelInput[]);
setUri(term.external_uri ?? "");
setEditing(true);
diff --git a/web/src/vocab/vocabulary-list.tsx b/web/src/vocab/vocabulary-list.tsx
index 470dcd9..581d09b 100644
--- a/web/src/vocab/vocabulary-list.tsx
+++ b/web/src/vocab/vocabulary-list.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries";
import { byKey } from "../lib/sort";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
+import { MutationError } from "../components/mutation-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -54,11 +55,7 @@ export function VocabularyList() {
{t("vocab.create")}
- {create.isError && (
-
- {t("form.rejected")}
-
- )}
+
setEditingId(null)}>
{t("form.cancel")}
- {renameVocabulary.isError && (
-
- {t("form.rejected")}
-
- )}
+
) : (
<>
diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx
index 3707f29..d31cc32 100644
--- a/web/src/vocab/vocabulary-terms.tsx
+++ b/web/src/vocab/vocabulary-terms.tsx
@@ -8,6 +8,7 @@ import { byLabel } from "../lib/sort";
import { labelText } from "../lib/labels";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { LabelEditor } from "../components/label-editor";
+import { MutationError } from "../components/mutation-error";
import { TermRow } from "./term-row";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -116,11 +117,7 @@ export function VocabularyTerms() {
{t("form.required")}
)}
- {addTerm.isError && (
-
- {t("form.rejected")}
-
- )}
+
{t("vocab.addTerm")}