From cad5a980c5d42665443566658f2458acd57204ed Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 17:17:14 +0200 Subject: [PATCH] feat(web): shared status-aware error-message helper + MutationError component (#63) --- web/src/api/error-message.test.ts | 26 +++++++++++++++++ web/src/api/error-message.ts | 18 ++++++++++++ web/src/api/mutation-feedback.test.tsx | 34 +++++++++------------- web/src/api/query-client.ts | 11 ++----- web/src/components/mutation-error.test.tsx | 21 +++++++++++++ web/src/components/mutation-error.tsx | 16 ++++++++++ web/src/i18n/en.json | 7 +++++ web/src/i18n/sv.json | 7 +++++ 8 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 web/src/api/error-message.test.ts create mode 100644 web/src/api/error-message.ts create mode 100644 web/src/components/mutation-error.test.tsx create mode 100644 web/src/components/mutation-error.tsx 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/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/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/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",