docs(plans): mutation error feedback — 4-task plan (#63)

This commit is contained in:
2026-06-08 16:43:10 +02:00
parent d90aa75468
commit 17bfd3e9d8
@@ -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 `<MutationError error>` 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 && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>}`): `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 + `<MutationError>` + 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<string, unknown> } {
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 `<p role="alert" className="text-xs text-destructive">` markup. */
export function MutationError({ error }: { error: unknown }) {
const { t } = useTranslation();
if (!error) return null;
const { key, opts } = errorMessageKey(error);
return (
<p role="alert" className="text-xs text-destructive">
{t(key, opts)}
</p>
);
}
```
- [ ] **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(<MutationError error={new HttpError(403)} />);
expect(screen.getByRole("alert")).toHaveTextContent(/permission/i);
});
test("renders the in-use count for an InUseError", () => {
render(<MutationError error={new InUseError(2)} />);
expect(screen.getByRole("alert")).toHaveTextContent(/2/);
});
test("renders nothing when there is no error", () => {
const { container } = render(<MutationError error={null} />);
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 `<MutationError error={updateTerm.error} />` immediately after the save/cancel `<div className="flex gap-2">…</div>` (still inside the `<li>`). In the non-editing view's Edit `<Button>` `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 `<MutationError error={updateAuthority.error} />` after the save/cancel `<div className="flex gap-2">…</div>`; 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(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);
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(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);
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 `<MutationError>` 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 && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>}` block with `<MutationError error={X.error} />`:
- `authorities-page.tsx:144-148``<MutationError error={create.error} />`
- `vocabulary-terms.tsx:119-123``<MutationError error={addTerm.error} />`
- `vocabulary-list.tsx:57-61``<MutationError error={create.error} />`; and `:109-113``<MutationError error={renameVocabulary.error} />`
- `field-form.tsx:201-205``<MutationError error={isEdit ? update.error : create.error} />`; 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 <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
```
- [ ] **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(<Routes><Route path="/objects/:id/edit" element={<ObjectEditForm />} /></Routes>, {
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 `<MutationError>` + 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.