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
|
||||
|
||||
Reference in New Issue
Block a user