diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 5e727ea..b004e4f 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -441,7 +441,7 @@ export function useUpdateTerm() { if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), - meta: { successMessage: "toast.saved" }, + meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -518,7 +518,7 @@ export function useUpdateAuthority() { if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), - meta: { successMessage: "toast.saved" }, + meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx index a9a5f60..04695db 100644 --- a/web/src/authorities/authority-row.tsx +++ b/web/src/authorities/authority-row.tsx @@ -5,6 +5,7 @@ import type { components } from "../api/schema"; import { useUpdateAuthority, useDeleteAuthority } 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"; @@ -56,6 +57,7 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi {t("form.cancel")} + ); } @@ -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);