fix(notifications): a notice keeps what to say and says it in the reader's language when shown
This commit is contained in:
@@ -393,7 +393,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
);
|
||||
}
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => optimisticStatus(card.id, next),
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// Admin-only "manage this movie's collections" dialog. Lists the library's EDITABLE
|
||||
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
|
||||
@@ -85,7 +86,7 @@ export default function PlexCollectionEditor({
|
||||
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
|
||||
// simply never appeared and the user clicked again — a collection deleted on the Plex server
|
||||
// or flipped read-only failed in silence.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export default function PlexCollectionEditor({
|
||||
await api.plexSetCollectionEditable(id, true);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export default function PlexCollectionEditor({
|
||||
} catch (e) {
|
||||
// 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"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export default function PlexCollectionEditor({
|
||||
} catch (e) {
|
||||
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
|
||||
// row stayed where it was with nothing said.
|
||||
notifyActionError(e, t("plex.collection.deleteFailed"));
|
||||
notifyActionError(e, msg("plex.collection.deleteFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
@@ -135,7 +136,7 @@ describe("PlexCollectionView — Play-all", () => {
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
|
||||
expect.objectContaining({ level: "info", message: msg("plex.collection.playEmpty") }),
|
||||
),
|
||||
);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { plexLang } from "../../i18n";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { showEpisodeKeys } from "../../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../../lib/plexSort";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
@@ -154,9 +154,9 @@ export default function PlexCollectionView({
|
||||
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
||||
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
||||
if (queue.length > 0) onPlay(queue[0]!, queue);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
else notify({ level: "info", message: msg("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
notifyActionError(e, msg("plex.collection.playFailed"));
|
||||
}
|
||||
setQueueBusy(false);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export default function PlexCollectionView({
|
||||
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
|
||||
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => {
|
||||
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
|
||||
old
|
||||
@@ -211,7 +211,7 @@ export default function PlexCollectionView({
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
onBack();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export default function PlexCollectionView({
|
||||
await api.plexCollectionRemoveItem(collectionId, card.id);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export default function PlexCollectionView({
|
||||
await api.plexSetCollectionEditable(collectionId, true);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -495,7 +495,7 @@ function AddItemPanel({
|
||||
setAdded((s) => new Set(s).add(card.id));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function PlexDiscover({
|
||||
: old,
|
||||
);
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) => {
|
||||
const flip = (cards: PlexCard[]) =>
|
||||
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
||||
|
||||
@@ -36,6 +36,7 @@ import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -254,7 +255,7 @@ export default function PlexInfo({
|
||||
try {
|
||||
await api.plexSetState(detail.id, status);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
@@ -267,7 +268,7 @@ export default function PlexInfo({
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
const toggleColWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
||||
old
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, HttpError, type PlexItemDetail } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notify, raw } from "../../lib/notifications";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
@@ -285,7 +285,7 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
act(() => {
|
||||
notify({ level: "error", message: "Couldn't mark it watched." });
|
||||
notify({ level: "error", message: raw("Couldn't mark it watched.") });
|
||||
});
|
||||
const toast = await screen.findByText("Couldn't mark it watched.");
|
||||
let host: Element | null = video();
|
||||
|
||||
@@ -103,6 +103,7 @@ import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPo
|
||||
import { Slider } from "../../components/ui/NativeControls";
|
||||
import { formatUnit } from "../../lib/format";
|
||||
import { trackLabel } from "../../lib/vocab";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -716,8 +717,8 @@ export default function PlexPlayer({
|
||||
});
|
||||
|
||||
const reportWatchedError = useCallback(
|
||||
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")),
|
||||
[t],
|
||||
(e: unknown) => notifyActionError(e, msg("plex.watchedFailed")),
|
||||
[],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confi
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -98,8 +99,8 @@ const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
|
||||
const toast = () =>
|
||||
h.notify.mock.calls
|
||||
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => n.message?.startsWith("plex.playlist.removedToast"))
|
||||
.map((c) => c[0] as { message: NotifText; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => "key" in n.message && n.message.key === "plex.playlist.removedToast")
|
||||
.pop();
|
||||
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
|
||||
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
|
||||
@@ -181,7 +182,9 @@ describe("PlexPlaylistView · a row's menu", () => {
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
|
||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
|
||||
await waitFor(() =>
|
||||
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "Movie 2" })),
|
||||
);
|
||||
act(() => toast()!.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
|
||||
});
|
||||
@@ -208,7 +211,9 @@ describe("PlexPlaylistView · a row's menu", () => {
|
||||
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
|
||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
|
||||
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show"));
|
||||
await waitFor(() =>
|
||||
expect(toast()?.message).toEqual(msg("plex.playlist.removedToast", { title: "The Show" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes one season from its label's menu", async () => {
|
||||
@@ -349,7 +354,7 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("plex.watchedFailed") }),
|
||||
),
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
@@ -58,6 +58,7 @@ import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
|
||||
import { seasonTitle } from "../../lib/plexDetail";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -459,7 +460,7 @@ export default function PlexPlaylistView({
|
||||
try {
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
|
||||
notifyActionError(e, msg("plex.playlist.renameFailed")); // the field stays open to try again
|
||||
return;
|
||||
}
|
||||
setRenaming(false);
|
||||
@@ -479,7 +480,7 @@ export default function PlexPlaylistView({
|
||||
// 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"));
|
||||
notifyActionError(e, msg("plex.playlist.deleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -569,7 +570,7 @@ export default function PlexPlaylistView({
|
||||
onRestart: (c) => onPlay(c.id, order, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (c) =>
|
||||
void toggleWatchState(c, t, {
|
||||
void toggleWatchState(c, {
|
||||
optimistic: (next) => setStatus(c.id, next),
|
||||
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
|
||||
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
|
||||
|
||||
@@ -22,6 +22,7 @@ import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
@@ -350,7 +351,7 @@ export function PlaylistPicker() {
|
||||
onOpenPlaylist(pl.id);
|
||||
} catch (e) {
|
||||
// Called without an await from the picker, so a refusal was an unhandled rejection.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -103,7 +104,7 @@ function NewCollectionPanel({
|
||||
} catch (e) {
|
||||
// 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"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { PageHeading } from "../../components/ui/Headings";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../../lib/format";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -209,7 +210,7 @@ export function PlexPersonView({
|
||||
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||
// helper; also invalidate the library so its grid agrees.
|
||||
const toggle = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
toggleWatchState(card, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
|
||||
old
|
||||
@@ -500,7 +501,7 @@ export function PlexShowView({
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
@@ -510,7 +511,7 @@ export function PlexShowView({
|
||||
try {
|
||||
await api.plexSeasonState(seasonRk, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
@@ -836,7 +837,7 @@ export function PlexSeasonView({
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
@@ -847,7 +848,7 @@ export function PlexSeasonView({
|
||||
try {
|
||||
await api.plexSetState(ep.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -106,7 +107,7 @@ describe("usePlayShow", () => {
|
||||
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, false));
|
||||
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
||||
expect(notified).toHaveBeenCalledWith(expect.any(Error), msg("plex.playShowFailed"));
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { qk } from "../../lib/queryKeys";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||
@@ -25,7 +26,7 @@ export function usePlayShow(
|
||||
const asked = useRef(0);
|
||||
// Unmounted, nothing of it may navigate any more.
|
||||
useEffect(() => () => void ++asked.current, []);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
// `undefined` when the question failed (said in the toast).
|
||||
const next = (id: string) =>
|
||||
@@ -37,7 +38,7 @@ export function usePlayShow(
|
||||
})
|
||||
.then(
|
||||
(r) => r.next,
|
||||
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||
(e: unknown) => void notifyActionError(e, msg("plex.playShowFailed")),
|
||||
);
|
||||
return async (show: PlexCard, fromStart: boolean) => {
|
||||
const ask = ++asked.current;
|
||||
|
||||
@@ -15,7 +15,8 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn(
|
||||
(_n: { level: string; message: string; action?: { label: string; onClick: () => void } }) => 0,
|
||||
(_n: { level: string; message: NotifText; action?: { label: string; onClick: () => void } }) =>
|
||||
0,
|
||||
),
|
||||
}));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -39,7 +41,7 @@ const ids = (items: PlexCard[]) => items.map((i) => i.id);
|
||||
const errors = () =>
|
||||
notify.mock.calls
|
||||
.filter(([n]) => n.level === "error")
|
||||
.map(([n]) => (n as { title?: string }).title);
|
||||
.map(([n]) => (n as { title?: NotifText }).title);
|
||||
|
||||
function setup(items: PlexCard[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -61,7 +63,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
|
||||
|
||||
act(() => hook.result.current.commit([movie("3"), movie("1"), movie("2")]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["3", "1", "2"]); // optimistic
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.reorderFailed")]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
@@ -112,7 +114,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
|
||||
await waitFor(() => expect(ids(hook.result.current.items)).toEqual(["1", "3"]));
|
||||
await act(async () => fail(new Error("offline")));
|
||||
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.reorderFailed")]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "3"]);
|
||||
});
|
||||
});
|
||||
@@ -124,7 +126,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
|
||||
|
||||
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
|
||||
expect(errors()).toEqual(["plex.playlist.removeFailed"]);
|
||||
expect(errors()).toEqual([msg("plex.playlist.removeFailed")]);
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
|
||||
expect(hook.result.current.busy).toBe(false);
|
||||
// A failed removal offers no Undo: there is nothing to put back.
|
||||
@@ -144,7 +146,7 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
// A season whose episodes do not sit together: rows 2 and 4.
|
||||
await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
expect(toast.message).toBe("plex.playlist.removedToast");
|
||||
expect(toast.message).toEqual(msg("plex.playlist.removedToast", { title: "Show · Season 1" }));
|
||||
expect(toast.action!.label).toBe("common.undo");
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
||||
@@ -180,6 +182,6 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.undoRemoveFailed"]));
|
||||
await waitFor(() => expect(errors()).toEqual([msg("plex.playlist.undoRemoveFailed")]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { reinsertAt } from "../../lib/reinsert";
|
||||
|
||||
@@ -79,7 +79,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("plex.playlist.reorderFailed"));
|
||||
notifyActionError(e, msg("plex.playlist.reorderFailed"));
|
||||
putBack();
|
||||
});
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
notifyActionError(e, msg("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
removed = false;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
if (!removed) return;
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("plex.playlist.removedToast", { title }),
|
||||
message: msg("plex.playlist.removedToast", { title }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
@@ -129,7 +129,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
if (back.keys.length)
|
||||
return api.plexPlaylistAddBulk(playlistId, back.keys, back.positions);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
|
||||
.catch((e: unknown) => notifyActionError(e, msg("plex.playlist.undoRemoveFailed")))
|
||||
.finally(invalidate),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user