fix(ui): a write that fails says what failed and why, wherever it fails

`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.

- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
  onError, so it waits a task and skips a failure the site already reported; the toast title comes
  from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
  structured localized detail, else the status). It marks the error reported (`markReported`), so
  nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
  comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
  account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
  now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
  macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
This commit is contained in:
2026-09-13 01:45:28 +02:00
parent b4aea7fa5b
commit 8a50538275
45 changed files with 851 additions and 132 deletions
+6 -8
View File
@@ -7,6 +7,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
@@ -66,25 +67,22 @@ export default function AccountPanel({
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
isEmailOff(err)
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, user.id),
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const copy = async () => {
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
title: "users.roles.failed", // what failed…
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
}),
);
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
+10 -21
View File
@@ -38,7 +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";
import { notifyActionError, notifyKnownFailure } 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
@@ -351,7 +351,7 @@ function PendingAccess() {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
},
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const resend = useMutation({
@@ -363,12 +363,9 @@ function PendingAccess() {
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
isEmailOff(err)
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const list = rows.data ?? [];
@@ -563,12 +560,8 @@ function AdminInvites() {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
else notifyActionError(e, t("settings.invites.deleteFailed"));
if (conflict) refresh();
},
});
@@ -599,13 +592,9 @@ function AdminInvites() {
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
},
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
err instanceof HttpError && inviteEmailOff(err)
? notifyKnownFailure(err, t("settings.invites.inviteEmailOff"))
: notifyActionError(err, t("settings.invites.inviteFailed")),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
+22 -15
View File
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
@@ -106,18 +107,23 @@ export default function ChannelPage({
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
.finally(() => setExploring(false));
return (
api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos (E11 S2b).
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc],
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
@@ -129,7 +135,7 @@ export default function ChannelPage({
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
runExplore().catch(() => {});
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
const block = useMutation({
@@ -139,15 +145,16 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
// a caller toast here would just double it.
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
meta: { failTitle: "errors.writeFailed.blockChannel" },
});
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
+4 -4
View File
@@ -6,7 +6,6 @@ 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";
@@ -110,6 +109,7 @@ export default function Channels() {
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id),
meta: { failTitle: "errors.writeFailed.unblockChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() });
@@ -133,6 +133,7 @@ export default function Channels() {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
meta: { failTitle: "errors.writeFailed.channelSettings" },
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
@@ -182,11 +183,10 @@ export default function Channels() {
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
// 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: (e) => {
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
notifyActionError(e, t("channels.tags.updateFailed"));
},
});
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
+5 -2
View File
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
@@ -95,14 +96,16 @@ export default function DownloadCenter() {
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, fn: () => void) => {
+6 -2
View File
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
@@ -51,8 +52,11 @@ export default function DownloadDialog({
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch {
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said (E11 S2b).
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
+23 -9
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import {
@@ -308,15 +309,17 @@ export default function Feed({
resolveVideo(id);
}
})
.catch(() =>
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden.
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -330,13 +333,14 @@ export default function Feed({
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch(() =>
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -349,7 +353,15 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
});
},
[qc],
);
@@ -466,7 +478,9 @@ export default function Feed({
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch(() => {});
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
+20 -4
View File
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
const readMut = useMutation({
mutationFn: api.markNotificationRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifRead" },
});
const readAllMut = useMutation({
mutationFn: api.markAllNotificationsRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifReadAll" },
});
const dismissMut = useMutation({
mutationFn: api.dismissNotification,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifDismiss" },
});
const clearMut = useMutation({
mutationFn: api.clearNotifications,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifClear" },
});
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
},
// The step itself says the import failed and offers a retry button — no toast on top.
meta: { errorShownInline: true },
});
// First time we see read access with zero imported channels, pull the user's
@@ -380,7 +380,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
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" }),
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
);
});
});
+11 -7
View File
@@ -676,13 +676,17 @@ export default function Playlists() {
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
});
api
.setState(id, status)
.then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
}
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
meta: { failTitle: "errors.writeFailed.createPlaylist" },
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() });
+9 -3
View File
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
@@ -377,9 +378,14 @@ function PlaylistPicker() {
];
async function createPlaylist(title: string) {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
try {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
} catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b).
notifyActionError(e, t("plex.playlistAdd.createFailed"));
}
}
return (
@@ -77,10 +77,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
invalidate();
setFormOpen(false);
},
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
});
const onDelete = async (p: DownloadProfile) => {
+4
View File
@@ -390,6 +390,7 @@ export default function Scheduler() {
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
meta: { failTitle: "errors.writeFailed.schedulerRun" },
onSuccess: (_d, jobId) => {
notify({
level: "success",
@@ -400,6 +401,7 @@ export default function Scheduler() {
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
onSuccess: (res) => {
notify({
level: "success",
@@ -410,10 +412,12 @@ export default function Scheduler() {
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
});
const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(),
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
onSuccess: (res) => {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
+13 -5
View File
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
@@ -553,10 +554,11 @@ function PlexWatchSync() {
};
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -564,6 +566,7 @@ function PlexWatchSync() {
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -637,10 +640,13 @@ export function DownloadAuth() {
notify({ level: "success", message: t("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
// statuses it does not cover go to the app-wide reporter.
meta: { failTitle: "settings.downloadAuth.failed" },
});
const remove = useMutation({
mutationFn: (host: string) => api.deleteDownloadCredential(host),
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
}
};
return (
+4
View File
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
});
const revoke = useMutation({
mutationFn: () => api.revokeDownloadLink(link.id),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
});
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
// password_version server-side, which invalidates any watch grant issued under the old password.
const savePass = useMutation({
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
onSuccess: () => {
setEditPass(false);
setNewPass("");
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
setExpiryDays(null);
setPassword("");
},
meta: { failTitle: "downloads.share.linkFailed" },
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
+5
View File
@@ -210,6 +210,11 @@ function SystemStats() {
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e, paused) =>
notifyActionError(
e,
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+6
View File
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
@@ -53,6 +54,11 @@ export default function SyncStatus({
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
if (!data) return null;
+1
View File
@@ -129,6 +129,7 @@ export default function TagManager({
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
meta: { failTitle: "channels.tags.createFailed" },
onSuccess: () => {
setNewName("");
invalidate();
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api";
beforeAll(() => {
@@ -48,7 +49,9 @@ afterEach(() => {
vi.restoreAllMocks();
});
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
// What failed, as the toast names it: the title (E11 S2b — the title says WHAT, the line under it WHY).
const errors = () =>
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
describe("the Plex collection editor", () => {
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
expect(errors()).toEqual([]);
});
});
describe("the download dialog", () => {
it("names a queueing the server refused with no dialog of its own", async () => {
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
// not for a 403/404/429 or a lost connection, where the dialog just stayed open (E11 S2b).
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
render(
<QueryClientProvider client={client()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
});
});
@@ -87,11 +87,13 @@ beforeEach(() => {
});
afterEach(cleanup);
// What each error toast names: its title when it has one (a failure reported with its reason under
// it — E11 S2b), else the specific sentence (the no-mail case).
const errorMessages = () =>
notifyMock.mock.calls
.map(([a]) => a as { level?: string; message?: string })
.map(([a]) => a as { level?: string; title?: string; message?: string })
.filter((a) => a.level === "error")
.map((a) => a.message);
.map((a) => a.title ?? a.message);
describe("the account panel reports a refused recovery action", () => {
it("says the link could not be minted instead of leaving the button inert", async () => {
@@ -62,10 +62,12 @@ function QuotaModal({
unlimited: state!.unlimited,
}),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuota" },
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
@@ -41,6 +41,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
});
return (
@@ -61,13 +61,16 @@ describe("usePlaylistActions — deleting a list", () => {
});
it("does say one it was left to handle — in the reader's language", async () => {
// A plain-string `detail` is whatever English the route hand-wrote; the caller's own translated
// fallback is the one a Hungarian reader can read (deep-review round 5).
// A plain-string `detail` is whatever English the route hand-wrote, so it is never shown: the
// caller's translated title says what failed, and the status says why (deep-review round 5, S2b).
vi.spyOn(api, "deletePlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: "playlists.deleteYtFailed" }),
expect.objectContaining({ title: "playlists.deleteYtFailed" }),
);
expect(h.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Unknown playlist" }),
);
});