fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced. - `req` marked gateway (502/503/504) and network failures "already reported" because they raise the connection notice. But that notice is about connectivity and removes itself on the very next answer — which every caller's own resync refetch fetches at once — so a delete that hit a keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed. - A connection notice the user closed swallowed every later one for the rest of the visit; it is raised again. - `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a 502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true. - A taken Plex collection name is a structured 409, localized, and the create/rename calls are quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone. - The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve / deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes a level now). The user role / suspend / delete actions had no onError at all: a user deleted in another tab failed silently and stayed listed — now said, and the list refetched.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
|
||||
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
|
||||
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
|
||||
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/useMe", () => ({
|
||||
useMe: () => ({
|
||||
id: 1,
|
||||
email: "admin@x",
|
||||
role: "admin",
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
import { api, type AdminUserRow } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const row = {
|
||||
id: 2,
|
||||
email: "gone@x",
|
||||
role: "user",
|
||||
is_suspended: false,
|
||||
is_demo: false,
|
||||
email_verified: true,
|
||||
is_active: true,
|
||||
} as unknown as AdminUserRow;
|
||||
|
||||
function answer(status: number, detail: unknown) {
|
||||
return new Response(JSON.stringify({ detail }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notify.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
describe("a user action that fails", () => {
|
||||
it("says a user who is gone, and refetches the list", async () => {
|
||||
const users = mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
answer(404, { code: "not_found", reason: "user" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("is not toasted on top of the error dialog the request already opened", async () => {
|
||||
mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
||||
const { reportError } = await import("../lib/errorDialog");
|
||||
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
||||
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -120,6 +121,13 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
// A failed user action: said once (notifyActionError skips what `req` already put in its dialog),
|
||||
// and the list refetched — a user deleted in another tab would otherwise stay listed, every click
|
||||
// on the row doing nothing.
|
||||
const onGoneOrFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
const setRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
||||
api.setUserRole(id, role),
|
||||
@@ -129,7 +137,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
|
||||
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
|
||||
// it does not show (a user deleted meanwhile, a 404) is said here — see onGoneOrFailed.
|
||||
onError: (e) => onGoneOrFailed(e, t("users.roles.failed")),
|
||||
});
|
||||
|
||||
const suspend = useMutation({
|
||||
@@ -146,7 +156,8 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
});
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// Guards (demo / self / last admin) surface via the global error dialog.
|
||||
// Guards (demo / self / last admin) surface via the global error dialog; the rest, as above.
|
||||
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
||||
@@ -156,6 +167,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
onError: (e) => onGoneOrFailed(e, t("users.delete.failed")),
|
||||
});
|
||||
|
||||
const onToggle = async (u: AdminUserRow) => {
|
||||
@@ -518,7 +530,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.approveFailed")),
|
||||
});
|
||||
const deny = useMutation({
|
||||
mutationFn: (id: number) => api.denyInvite(id),
|
||||
@@ -526,7 +538,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.denied") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.denyFailed")),
|
||||
});
|
||||
const onDeny = async (i: { id: number; email: string }) => {
|
||||
const ok = await confirm({
|
||||
@@ -630,7 +642,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.addFailed")),
|
||||
});
|
||||
|
||||
const list = invites.data ?? [];
|
||||
@@ -780,7 +792,7 @@ function AdminDemo() {
|
||||
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.addFailed")),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
||||
@@ -788,7 +800,7 @@ function AdminDemo() {
|
||||
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||
notify({ level: "success", message: t("settings.demo.removed") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.removeFailed")),
|
||||
});
|
||||
const onRemove = async (r: { id: number; email: string }) => {
|
||||
const ok = await confirm({
|
||||
@@ -806,7 +818,7 @@ function AdminDemo() {
|
||||
level: "success",
|
||||
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
||||
}),
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.resetFailed")),
|
||||
});
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
@@ -36,7 +37,7 @@ export default function AuditLog() {
|
||||
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -182,10 +183,10 @@ export default function Channels() {
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: () => {
|
||||
onError: (e) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notify({ level: "error", message: t("channels.tags.updateFailed") });
|
||||
notifyActionError(e, t("channels.tags.updateFailed"));
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
@@ -125,7 +126,7 @@ export default function ConfigPanel() {
|
||||
const testEmail = useMutation({
|
||||
mutationFn: () => api.testEmail(),
|
||||
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||
onError: () => notify({ level: "error", message: t("config.testFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("config.testFailed")),
|
||||
});
|
||||
|
||||
// Plex "Test connection": verifies the server URL + token and returns the library sections,
|
||||
@@ -138,9 +139,9 @@ export default function ConfigPanel() {
|
||||
setPlexResult(r);
|
||||
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
|
||||
},
|
||||
onError: () => {
|
||||
onError: (e) => {
|
||||
setPlexResult(null);
|
||||
notify({ level: "error", message: t("config.plexTestFailed") });
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
const plexLibs = (draft["plex_libraries"] ?? "")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -103,9 +103,9 @@ export default function PlexCollectionEditor({
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
|
||||
// nameTaken rather than surfacing the server's raw English detail.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
|
||||
// it reads in the reader's language (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../lib/plexSort";
|
||||
@@ -187,8 +187,8 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -375,14 +376,14 @@ export default function Scheduler() {
|
||||
const pauseResume = useMutation({
|
||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")),
|
||||
});
|
||||
|
||||
const intervalMut = useMutation({
|
||||
mutationFn: (v: { jobId: string; minutes: number }) =>
|
||||
api.updateSchedulerJob(v.jobId, v.minutes),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("scheduler.intervalFailed")),
|
||||
});
|
||||
|
||||
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
|
||||
|
||||
@@ -7,6 +7,7 @@ import clsx from "clsx";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
@@ -39,7 +40,7 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("downloads.share.failed") }),
|
||||
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type StatsTab = "overview" | "system";
|
||||
@@ -85,7 +86,7 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -322,7 +323,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||
onClose();
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("editor.failed") }),
|
||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||
});
|
||||
|
||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
@@ -95,8 +95,9 @@ function NewCollectionPanel({
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend is authoritative on per-library name uniqueness — a taken name comes back
|
||||
// structured, and so localized (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { alreadyReported, api, HttpError, type Playlist } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useChoice } from "./ConfirmProvider";
|
||||
@@ -71,14 +71,12 @@ export function usePlaylistActions() {
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side — unless
|
||||
// `req` already put it on screen (the error dialog, the connection notice): not twice.
|
||||
if (!alreadyReported(err))
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
// `req` already put it in its error dialog: not twice (notifyActionError checks).
|
||||
notifyActionError(
|
||||
err,
|
||||
onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(pl.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
|
||||
Reference in New Issue
Block a user