From d90aa75468b2fd98e66a63ed2e421c1e62c14583 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 16:22:45 +0200 Subject: [PATCH 1/6] docs(specs): consistent status-aware mutation error feedback (#63) --- ...26-06-08-mutation-error-feedback-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md 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. From 17bfd3e9d830e5915954ad6695f1eb5910d7cf15 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 16:43:10 +0200 Subject: [PATCH 2/6] =?UTF-8?q?docs(plans):=20mutation=20error=20feedback?= =?UTF-8?q?=20=E2=80=94=204-task=20plan=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-08-mutation-error-feedback.md | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-mutation-error-feedback.md 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 ` +
  • ); } @@ -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/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); From aeb1b084d9e2117cc423ac72ee886ba377cc7de2 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 17:32:36 +0200 Subject: [PATCH 6/6] feat(web): adopt MutationError across create/object forms; distinguish edit-form fetch error (#63) --- web/src/authorities/authorities-page.tsx | 7 ++----- web/src/fields/field-form.tsx | 8 ++------ web/src/objects/object-edit-form.test.tsx | 12 ++++++++++++ web/src/objects/object-edit-form.tsx | 8 ++++++-- web/src/objects/object-new-page.tsx | 6 ++++-- web/src/vocab/vocabulary-list.tsx | 13 +++---------- web/src/vocab/vocabulary-terms.tsx | 7 ++----- 7 files changed, 31 insertions(+), 30 deletions(-) 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")} -

    - )} + - {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")} -

    - )} +