feat(web): shared status-aware error-message helper + MutationError component (#63)
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
@@ -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<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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<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();
|
||||
});
|
||||
@@ -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 `<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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user