From 267fd7f70f169644fa1368690bd139ae5db41ff6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 23:34:33 +0200 Subject: [PATCH] fix(playlists): a refused edit is taken back on screen, and a gone list or tag is said, not swallowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review round 4, the playlist surfaces. - A failed move or reorder relied on the resync to put the rows back — but the server's list is unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on screen. Both restore from the cached detail now. - A failed removal puts its row back itself: when the server is unreachable the resync fails too, and round 3's "keep the list on a failed refetch" guard then kept showing it as removed. - The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a structured `not_found` the request layer leaves to the caller) failed in silence and an optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is already gone just leaves the page. - Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading line had no live region). - The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up. --- frontend/src/components/AddToPlaylist.tsx | 12 +- .../PlaylistEditFailures.dom.test.tsx | 127 ++++++++++++++++++ .../src/components/Playlists.dom.test.tsx | 76 ++++++++++- frontend/src/components/Playlists.tsx | 55 +++++--- frontend/src/components/PlexPlaylistView.tsx | 41 +++++- frontend/src/components/TagManager.tsx | 9 ++ frontend/src/i18n/locales/en/playlists.json | 3 +- frontend/src/i18n/locales/en/plex.json | 6 +- frontend/src/i18n/locales/en/tagManager.json | 4 +- frontend/src/i18n/locales/hu/playlists.json | 3 +- frontend/src/i18n/locales/hu/plex.json | 6 +- frontend/src/i18n/locales/hu/tagManager.json | 4 +- 12 files changed, 309 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/PlaylistEditFailures.dom.test.tsx diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 716279ef..e795b38a 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; @@ -49,14 +50,19 @@ export default function AddToPlaylist({ qc.invalidateQueries({ queryKey: qk.playlist() }); } + // A failure is said, and the list refreshed either way. With no catch, a playlist deleted on + // another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an + // unhandled rejection: the tick did not change, nothing was said, the gone row stayed. async function toggle(pl: Playlist) { if (busy) return; setBusy(true); try { if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId); - refresh(); + } catch (e) { + notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); } finally { + refresh(); setBusy(false); } } @@ -69,8 +75,10 @@ export default function AddToPlaylist({ const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); setNewName(""); - refresh(); + } catch (e) { + notifyActionError(e, t("playlists.addFailed")); } finally { + refresh(); setBusy(false); } } diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx new file mode 100644 index 00000000..dec28ea6 --- /dev/null +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +// +// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true +// (deep-review round 4). The Plex playlist page, the add-to-playlist popover and the tag manager had +// no failure handling at all: a 404 for a list or tag deleted elsewhere — now a structured +// `not_found`, which the request layer leaves to the caller — failed in silence, and an optimistic +// removal stayed removed. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); +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()), + notify, +})); + +import PlexPlaylistView from "./PlexPlaylistView"; +import AddToPlaylist from "./AddToPlaylist"; +import TagManager from "./TagManager"; +import { api, HttpError, type PlexCard } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +beforeEach(() => notify.mockClear()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const gone = (what: string) => + new HttpError(404, `gone ${what}`, { code: "not_found", reason: what }); +const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message); +const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const movie = (id: string) => + ({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; + +describe("the Plex playlist page", () => { + it("puts back a row whose removal failed, and says why", async () => { + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ + id: 5, + title: "Films", + items: [movie("1"), movie("2")], + }); + vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist")); + render( + + {}} onPlay={() => {}} /> + , + ); + await screen.findByText("Movie 1"); + fireEvent.click(screen.getAllByLabelText("remove")[0]!); + await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); + expect(screen.getByText("Movie 1")).toBeTruthy(); + }); + + it("leaves a playlist that is already gone when asked to delete it", async () => { + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] }); + vi.spyOn(api, "plexDeletePlaylist").mockRejectedValue(gone("playlist")); + const onBack = vi.fn(); + render( + + {}} /> + , + ); + fireEvent.click(await screen.findByTitle("plex.playlist.delete")); + await waitFor(() => expect(onBack).toHaveBeenCalled()); + }); +}); + +describe("the add-to-playlist popover", () => { + it("says so when the playlist ticked is gone, and refreshes the list", async () => { + const lists = vi + .spyOn(api, "playlists") + .mockResolvedValue([{ id: 3, name: "Gone", kind: "user", has_video: false }] as never); + vi.spyOn(api, "addToPlaylist").mockRejectedValue(gone("playlist")); + render( + + + , + ); + fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); + fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); + await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); + }); +}); + +describe("the tag manager", () => { + it("says so when the tag deleted is gone, and refreshes the list", async () => { + const tags = vi + .spyOn(api, "tags") + .mockResolvedValue([{ id: 4, name: "Gone", system: false, channel_count: 0 }] as never); + vi.spyOn(api, "channels").mockResolvedValue([] as never); + vi.spyOn(api, "deleteTag").mockRejectedValue(gone("tag")); + render( + + {}} onFocusChannel={() => {}} /> + , + ); + fireEvent.click(await screen.findByLabelText("tagManager.delete")); + await waitFor(() => expect(errors()).toEqual(["gone tag"])); + await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1)); + }); +}); diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index 28a5860f..c3b7262d 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({ notify: vi.fn(), selectedId: 1 as number | null, mounted: false, + mounts: 0, player: null as null | { video: { id: string; position_seconds: number }; onClose: () => void; @@ -43,6 +44,7 @@ vi.mock("./PlayerModal", async () => { h.player = props; useEffect(() => { h.mounted = true; + h.mounts++; return () => { h.mounted = false; }; @@ -95,7 +97,7 @@ const list = (id: number, items: Video[]) => ({ let qc: QueryClient; -function mount(lists: Record) { +function mount(lists: Record, prefs: Record = {}) { vi.spyOn(api, "playlists").mockResolvedValue( Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, ); @@ -103,7 +105,7 @@ function mount(lists: Record) { async (id: number) => list(id, lists[id] ?? []) as never, ); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs }); const tree = () => ( @@ -120,6 +122,7 @@ beforeEach(() => { h.selectedId = 1; h.player = null; h.mounted = false; + h.mounts = 0; h.notify.mockClear(); }); afterEach(() => { @@ -248,8 +251,12 @@ describe("Playlists — the player outlives a change of selection", () => { vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); h.selectedId = 2; rerender(); - await screen.findByText("playlists.loading"); + // The shared loading state, which a screen reader hears (the old bare line had no live region). + expect( + (await screen.findByText("playlists.loading")).closest('[role="status"]'), + ).not.toBeNull(); expect(h.mounted).toBe(true); + expect(h.mounts).toBe(1); // never went down — a remount would also end `mounted` }); it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => { @@ -261,6 +268,7 @@ describe("Playlists — the player outlives a change of selection", () => { rerender(); await screen.findByText("playlists.loadFailed"); expect(h.mounted).toBe(true); + expect(h.mounts).toBe(1); }); it("stays mounted when nothing is selected any more", async () => { @@ -271,6 +279,7 @@ describe("Playlists — the player outlives a change of selection", () => { rerender(); await screen.findByText("playlists.pickOne"); expect(h.mounted).toBe(true); + expect(h.mounts).toBe(1); }); it("refreshes the list it was launched from when the viewer marks the video in it", async () => { @@ -289,3 +298,64 @@ describe("Playlists — the player outlives a change of selection", () => { expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true); }); }); + +const rowsOnScreen = () => + screen + .getAllByRole("button", { name: /^card\.play — / }) + .map((b) => b.getAttribute("aria-label")?.replace("card.play — ", "")); + +async function rowMenu(title: string, item: string) { + const row = (await screen.findByRole("button", { name: `card.play — ${title}` })).closest("tr")!; + fireEvent.contextMenu(row); + await waitFor(() => + expect( + screen.getAllByRole("menuitem", { hidden: true }).some((b) => b.textContent === item), + ).toBe(true), + ); + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === item)!, + ); +} + +describe("Playlists — a change the server refused is taken back on screen", () => { + // The resync refetch returns the SAME list the server had, react-query keeps the same `data` + // object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the + // optimistic change used to stay on screen for good. + it("puts back a row whose move failed", async () => { + vi.spyOn(api, "moveToPlaylist").mockRejectedValue( + new HttpError(404, "gone", { code: "not_found", reason: "move_target" }), + ); + mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 }); + await rowMenu("Video V", "playlists.moveToRecent"); + await waitFor(() => + expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })), + ); + await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); + expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); + }); + + it("puts back the order when saving a reorder failed", async () => { + vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom")); + mount({ 1: [vid("V", 0), vid("W", 0)] }); + await rowMenu("Video V", "playlists.orderDown"); + await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled()); + await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); + expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); + }); + + it("puts back a row whose removal failed, even when the resync fails too", async () => { + // A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row + // back — the list kept showing it as removed while it was still in the playlist. + vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch")); + mount({ 1: [vid("V", 0), vid("W", 0)] }); + await screen.findByRole("button", { name: "card.play — Video V" }); + vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch")); + await rowMenu("Video V", "playlists.removeItem"); + await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled()); + await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); + expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); + expect(h.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "error", message: "playlists.removeFailed" }), + ); + }); +}); diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 9f7439aa..583b3175 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -20,7 +20,6 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { - AlertTriangle, ArrowDown, ArrowDownToLine, ArrowUp, @@ -51,6 +50,7 @@ import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); import { Button } from "./ui/Button"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { useComfortPin, pinVideo } from "./comfortPin"; import { ContextMenu } from "./ui/ContextMenu"; @@ -184,9 +184,10 @@ export default function Playlists() { const order = useUndoable([], { onApply: (next) => { if (selectedId == null) return; + const listId = selectedId; api .reorderPlaylist( - selectedId, + listId, next.map((v) => v.id), ) .then(() => { @@ -198,13 +199,11 @@ export default function Playlists() { }) .catch((e) => { // Was a bare .then(): a failed reorder left the UI showing an order the server had - // rejected, and produced an unhandled rejection. Say so and resync from the server. + // rejected, and produced an unhandled rejection. Say so, and put back the order the server + // still has — or the rejected one becomes the base for the next drag. notifyYouTubeActionError(e, t("playlists.reorderFailed")); - // Clear the membership guard first: a reorder never changes the id SET, so without this - // the arriving detail is judged "same list" and the rejected order stays on screen — and - // becomes the base for the next drag. - lastSetRef.current = ""; - qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); + restoreFromCache(listId); + qc.invalidateQueries({ queryKey: qk.playlist(listId) }); }); }, }); @@ -220,6 +219,18 @@ export default function Playlists() { // The selection as of NOW, for code that runs after an await or on behalf of the open player. const selectedRef = useRef(selectedId); selectedRef.current = selectedId; + // Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed + // reorder or move must not wait for the resync to do it: the server's list is unchanged, so the + // refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below + // (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen + // (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile. + function restoreFromCache(listId: number) { + if (listId !== selectedRef.current) return; + const cur = qc.getQueryData(qk.playlist(listId)); + if (!cur) return; + lastSetRef.current = `${listId}:${idsKey(cur.items)}`; + order.reset(cur.items); + } // What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index // inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns // its own cursor from mount — so deriving the mount from `shown` every render meant anything that @@ -568,6 +579,7 @@ export default function Playlists() { // player's own progress checkpoint does exactly that when a removal steps it to the next item — // so the guard reset the order: the removed row came back and the undo history the rebase had // kept went with it. (A failure resyncs below, which puts the row back where it belongs.) + const before = qc.getQueryData(qk.playlist(listId)); qc.setQueryData(qk.playlist(listId), (old) => old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old, ); @@ -578,9 +590,13 @@ export default function Playlists() { } catch (e) { // Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the // full-screen player there is not even a row snapping back to show it) — unless `req` already - // said it (the error dialog, the connection notice), which `notifyActionError` checks. + // put it in its error dialog, which `notifyActionError` checks. notifyActionError(e, t("playlists.removeFailed")); - refreshAll(listId); // …and resync, which restores any row the optimistic removal took out + // …and the row goes back NOW, not when the resync lands: a removal that failed because the + // server was unreachable has a resync that fails too, and the list kept showing the row as + // removed while it was still in the playlist. + if (before) qc.setQueryData(qk.playlist(listId), before); + refreshAll(listId); return false; } } @@ -611,9 +627,10 @@ export default function Playlists() { // every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already // impossible — the submenu never offers the current list). So the row used to snap back with no // explanation and the shipped moveErrors strings were unreachable (E10 review round 1). - // A fault `req` already reported (the error dialog, the connection notice) is not toasted - // again — `notifyActionError` checks (E10 review round 2 found it reported twice). + // A fault `req` already put in its error dialog is not toasted again — `notifyActionError` + // checks (E10 review round 2 found it reported twice). notifyActionError(e, t("playlists.moveFailed")); + restoreFromCache(selectedId); // the row is still in this list } // BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact // key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as @@ -819,15 +836,9 @@ export default function Playlists() { if (detailQuery.isError && !detail) { return ( <> -
- - - {t("playlists.loadFailed")} - - -
+ detailQuery.refetch()}> + {t("playlists.loadFailed")} + {player} ); @@ -835,7 +846,7 @@ export default function Playlists() { if (!detail) { return ( <> -
{t("playlists.loading")}
+ {player} ); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 00505e67..ffc97290 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -31,7 +31,8 @@ import { Trash2, X, } from "lucide-react"; -import { api, type PlexCard } from "../lib/api"; +import { api, HttpError, type PlexCard } from "../lib/api"; +import { notifyActionError } from "../lib/actionErrors"; import { formatRuntime } from "../lib/format"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; @@ -401,6 +402,17 @@ export default function PlexPlaylistView({ qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); }; + // Every edit here is optimistic on the local copy, and none of them used to handle a failure: a + // removal that failed stayed removed on screen (the resync never ran), and a 404 — the playlist + // deleted on another device, now a structured `not_found` — was an unhandled rejection that said + // nothing at all (deep-review round 4; the YouTube side was fixed in round 3). A failure is said, + // and the rows are put back from what the server last sent — explicitly, because a resync that + // returns the same list keeps the same `q.data` and the re-sync effect above never runs. + const putBack = () => { + if (q.data) setItems(q.data.items); + invalidate(); + }; + function commit(next: PlexCard[]) { setItems(next); api @@ -408,7 +420,11 @@ export default function PlexPlaylistView({ playlistId, next.map((i) => i.id), ) - .then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() })); + .then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() })) + .catch((e: unknown) => { + notifyActionError(e, t("plex.playlist.reorderFailed")); + putBack(); + }); } async function onRemove(keys: string[]) { @@ -420,6 +436,9 @@ export default function PlexPlaylistView({ if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!); else await api.plexPlaylistRemoveBulk(playlistId, keys); invalidate(); + } catch (e) { + notifyActionError(e, t("plex.playlist.removeFailed")); + putBack(); } finally { setBusy(false); } @@ -428,7 +447,12 @@ export default function PlexPlaylistView({ async function rename() { const title = name.trim(); if (!title) return; - await api.plexRenamePlaylist(playlistId, title); + try { + await api.plexRenamePlaylist(playlistId, title); + } catch (e) { + notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again + return; + } setRenaming(false); invalidate(); } @@ -440,7 +464,16 @@ export default function PlexPlaylistView({ })) ) return; - await api.plexDeletePlaylist(playlistId); + try { + await api.plexDeletePlaylist(playlistId); + } catch (e) { + // Gone already (deleted on another device): what was asked for is true — leave the dead page. + // Anything else is a real failure, and the page stays. + if (!(e instanceof HttpError && e.status === 404)) { + notifyActionError(e, t("plex.playlist.deleteFailed")); + return; + } + } qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); onBack(); } diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 3870887c..a377abd4 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys"; import { Plus, Trash2 } from "lucide-react"; import { api, type Tag } from "../lib/api"; import { notify } from "../lib/notifications"; +import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; @@ -133,9 +134,16 @@ export default function TagManager({ invalidate(); }, }); + // A failure is said and the list refetched — a tag deleted in another tab used to fail in silence + // (a 404 `not_found/tag` gets no global dialog) and stay listed. + const onFailed = (e: unknown, fallback: string) => { + notifyActionError(e, fallback); + invalidate(); + }; const rename = useMutation({ mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), onSuccess: invalidate, + onError: (e) => onFailed(e, t("tagManager.renameFailed")), }); const del = useMutation({ mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), @@ -143,6 +151,7 @@ export default function TagManager({ invalidate(); notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) }); }, + onError: (e) => onFailed(e, t("tagManager.deleteFailed")), }); // Picking a channel from a tag's popover: close the dialog, then focus it in the manager. diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 2b2d6ddb..b29ed906 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -124,5 +124,6 @@ "viewGroupedAnd": "grouped by channel, {{sort}}", "viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.", "viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.", - "viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on." + "viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on.", + "addFailed": "Couldn't add it to the playlist." } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 12136fb0..7970e9de 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -320,7 +320,11 @@ "seasonUnknown": "Episodes", "addShow": "Add whole show to a playlist", "addSeason": "Add season to a playlist", - "addEpisode": "Add episode to a playlist" + "addEpisode": "Add episode to a playlist", + "removeFailed": "Couldn't remove it from the playlist.", + "renameFailed": "Couldn't rename the playlist.", + "deleteFailed": "Couldn't delete the playlist.", + "reorderFailed": "Couldn't save the new order." }, "people": { "count_one": "{{count}} title", diff --git a/frontend/src/i18n/locales/en/tagManager.json b/frontend/src/i18n/locales/en/tagManager.json index 64b33aef..d6f2709f 100644 --- a/frontend/src/i18n/locales/en/tagManager.json +++ b/frontend/src/i18n/locales/en/tagManager.json @@ -8,5 +8,7 @@ "confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.", "empty": "No tags yet — add one below.", "newPlaceholder": "New tag name…", - "add": "Add" + "add": "Add", + "renameFailed": "Couldn't rename the tag.", + "deleteFailed": "Couldn't delete the tag." } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 44f0b30a..def08c4a 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -124,5 +124,6 @@ "viewGroupedAnd": "csatorna szerint csoportosítva, {{sort}}", "viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.", "viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.", - "viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető." + "viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető.", + "addFailed": "Nem sikerült hozzáadni a lejátszási listához." } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 4edd3cde..e85ab902 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -320,7 +320,11 @@ "seasonUnknown": "Epizódok", "addShow": "Egész sorozat listához adása", "addSeason": "Évad listához adása", - "addEpisode": "Epizód listához adása" + "addEpisode": "Epizód listához adása", + "removeFailed": "Nem sikerült eltávolítani a lejátszási listáról.", + "renameFailed": "Nem sikerült átnevezni a lejátszási listát.", + "deleteFailed": "Nem sikerült törölni a lejátszási listát.", + "reorderFailed": "Nem sikerült menteni az új sorrendet." }, "people": { "count_one": "{{count}} cím", diff --git a/frontend/src/i18n/locales/hu/tagManager.json b/frontend/src/i18n/locales/hu/tagManager.json index 4a57382a..543fc1cc 100644 --- a/frontend/src/i18n/locales/hu/tagManager.json +++ b/frontend/src/i18n/locales/hu/tagManager.json @@ -8,5 +8,7 @@ "confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.", "empty": "Még nincs címke — adj hozzá lentebb.", "newPlaceholder": "Új címke neve…", - "add": "Hozzáad" + "add": "Hozzáad", + "renameFailed": "Nem sikerült átnevezni a címkét.", + "deleteFailed": "Nem sikerült törölni a címkét." }