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:
@@ -91,6 +91,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
@@ -97,7 +98,9 @@ export default function App() {
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
|
||||
api
|
||||
.savePrefs({ [PREF_KEY[panel]]: next })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
@@ -287,7 +290,11 @@ export default function App() {
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
setLanguage(code);
|
||||
api.savePrefs({ language: code }).catch(() => {});
|
||||
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
||||
// the reporter stays quiet there.
|
||||
api
|
||||
.savePrefs({ language: code })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,58 @@
|
||||
"recipient": "There's no user with that email address.",
|
||||
"job": "This job doesn't exist any more."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "The server couldn't be reached, so the change didn't go through. Try again.",
|
||||
"forbidden": "You don't have permission to do that.",
|
||||
"rateLimited": "Too many requests at once — wait a moment and try again.",
|
||||
"unexpected": "Something went wrong in the app. Try again."
|
||||
},
|
||||
"writeFailed": {
|
||||
"hideChannel": "Couldn't change whether the channel is hidden",
|
||||
"blockChannel": "Couldn't change the channel's block",
|
||||
"unblockChannel": "Couldn't unblock the channel",
|
||||
"channelSettings": "Couldn't save the channel's settings",
|
||||
"channelPriority": "Couldn't save the channel's priority",
|
||||
"exploreChannel": "Couldn't load the channel's videos",
|
||||
"downloadQuota": "Couldn't save the download quota",
|
||||
"downloadQuotaReset": "Couldn't reset the download quota",
|
||||
"job": {
|
||||
"pause": "Couldn't pause the download",
|
||||
"resume": "Couldn't resume the download",
|
||||
"cancel": "Couldn't cancel the download",
|
||||
"delete": "Couldn't delete the download",
|
||||
"redownload": "Couldn't start the download again"
|
||||
},
|
||||
"removeShared": "Couldn't remove the shared item",
|
||||
"downloadDetails": "Couldn't save the download's details",
|
||||
"enqueueDownload": "Couldn't queue the download",
|
||||
"downloadProfileSave": "Couldn't save the download profile",
|
||||
"downloadProfileDelete": "Couldn't delete the download profile",
|
||||
"downloadCookiesRemove": "Couldn't remove those cookies",
|
||||
"notifRead": "Couldn't mark the notification as read",
|
||||
"notifReadAll": "Couldn't mark the notifications as read",
|
||||
"notifDismiss": "Couldn't dismiss the notification",
|
||||
"notifClear": "Couldn't clear the notifications",
|
||||
"createPlaylist": "Couldn't create the playlist",
|
||||
"schedulerRun": "Couldn't start the job",
|
||||
"schedulerRunAll": "Couldn't start the jobs",
|
||||
"maintenanceBatch": "Couldn't save the maintenance batch size",
|
||||
"purgeDiscovery": "Couldn't clear the discovery data",
|
||||
"plexWatchLink": "Couldn't change the Plex watch-state sync",
|
||||
"plexWatchImport": "Couldn't import the watch state from Plex",
|
||||
"shareLinkDownload": "Couldn't change whether the link allows downloading",
|
||||
"shareLinkRevoke": "Couldn't revoke the link",
|
||||
"shareLinkPassword": "Couldn't change the link's password",
|
||||
"syncPause": "Couldn't pause syncing",
|
||||
"syncResume": "Couldn't resume syncing",
|
||||
"prefs": "Couldn't save your settings",
|
||||
"deleteAccount": "Couldn't delete your account",
|
||||
"videoState": "Couldn't change the video's state",
|
||||
"videoReset": "Couldn't reset the video",
|
||||
"watchLaterAdd": "Couldn't add it to Watch later",
|
||||
"watchLaterRemove": "Couldn't remove it from Watch later",
|
||||
"clearSearch": "Couldn't clear the search results"
|
||||
},
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Connection lost",
|
||||
|
||||
@@ -20,6 +20,58 @@
|
||||
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
|
||||
"job": "Ez a feladat már nem létezik."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "A művelet nem ment végbe: a szerver nem volt elérhető. Próbáld újra.",
|
||||
"forbidden": "Ehhez nincs jogosultságod.",
|
||||
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra.",
|
||||
"unexpected": "Váratlan hiba történt az alkalmazásban. Próbáld újra."
|
||||
},
|
||||
"writeFailed": {
|
||||
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
|
||||
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
|
||||
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
|
||||
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
|
||||
"channelPriority": "Nem sikerült menteni a csatorna prioritását",
|
||||
"exploreChannel": "Nem sikerült betölteni a csatorna videóit",
|
||||
"downloadQuota": "Nem sikerült menteni a letöltési kvótát",
|
||||
"downloadQuotaReset": "Nem sikerült visszaállítani a letöltési kvótát",
|
||||
"job": {
|
||||
"pause": "Nem sikerült szüneteltetni a letöltést",
|
||||
"resume": "Nem sikerült folytatni a letöltést",
|
||||
"cancel": "Nem sikerült megszakítani a letöltést",
|
||||
"delete": "Nem sikerült törölni a letöltést",
|
||||
"redownload": "Nem sikerült újraindítani a letöltést"
|
||||
},
|
||||
"removeShared": "Nem sikerült eltávolítani a megosztott elemet",
|
||||
"downloadDetails": "Nem sikerült menteni a letöltés adatait",
|
||||
"enqueueDownload": "Nem sikerült sorba állítani a letöltést",
|
||||
"downloadProfileSave": "Nem sikerült menteni a letöltési profilt",
|
||||
"downloadProfileDelete": "Nem sikerült törölni a letöltési profilt",
|
||||
"downloadCookiesRemove": "Nem sikerült eltávolítani ezeket a sütiket",
|
||||
"notifRead": "Nem sikerült olvasottnak jelölni az értesítést",
|
||||
"notifReadAll": "Nem sikerült olvasottnak jelölni az értesítéseket",
|
||||
"notifDismiss": "Nem sikerült elvetni az értesítést",
|
||||
"notifClear": "Nem sikerült törölni az értesítéseket",
|
||||
"createPlaylist": "Nem sikerült létrehozni a lejátszási listát",
|
||||
"schedulerRun": "Nem sikerült elindítani a feladatot",
|
||||
"schedulerRunAll": "Nem sikerült elindítani a feladatokat",
|
||||
"maintenanceBatch": "Nem sikerült menteni a karbantartási köteg méretét",
|
||||
"purgeDiscovery": "Nem sikerült törölni a felfedezési adatokat",
|
||||
"plexWatchLink": "Nem sikerült módosítani a Plex megtekintésiállapot-szinkront",
|
||||
"plexWatchImport": "Nem sikerült importálni a megtekintési állapotot a Plexből",
|
||||
"shareLinkDownload": "Nem sikerült módosítani, hogy a link engedi-e a letöltést",
|
||||
"shareLinkRevoke": "Nem sikerült visszavonni a linket",
|
||||
"shareLinkPassword": "Nem sikerült módosítani a link jelszavát",
|
||||
"syncPause": "Nem sikerült szüneteltetni a szinkronizálást",
|
||||
"syncResume": "Nem sikerült folytatni a szinkronizálást",
|
||||
"prefs": "Nem sikerült menteni a beállításaidat",
|
||||
"deleteAccount": "Nem sikerült törölni a fiókodat",
|
||||
"videoState": "Nem sikerült módosítani a videó állapotát",
|
||||
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
|
||||
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
||||
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
|
||||
"clearSearch": "Nem sikerült törölni a keresési találatokat"
|
||||
},
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Megszakadt a kapcsolat",
|
||||
|
||||
@@ -8,9 +8,10 @@ vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import { HttpError, api } from "./api";
|
||||
import { alreadyReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
@@ -33,7 +34,7 @@ describe("a Plex collection name that is already taken", () => {
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "EDIT_FAILED");
|
||||
expect(notifyMock.mock.calls).toEqual([
|
||||
[{ level: "error", message: i18n.t("plex.collection.nameTaken") }],
|
||||
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
|
||||
]);
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -60,12 +61,30 @@ describe("notifyActionError", () => {
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one failure once, however many callers try", () => {
|
||||
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
|
||||
const err = new HttpError(404, "gone");
|
||||
notifyActionError(err, "FIRST");
|
||||
notifyActionError(err, "SECOND");
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("says nothing about an upload the user cancelled", () => {
|
||||
notifyActionError(new HttpError(0, "aborted"), "FAILED");
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says a bare-English server reason in the reader's language, not the server's", () => {
|
||||
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
|
||||
// English a route hand-wrote went straight into the toast — "No user with that email." in an
|
||||
// otherwise Hungarian UI, while the caller's translated fallback sat unused (round 5).
|
||||
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "TRANSLATED" });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED",
|
||||
message: i18n.t("errors.notFound.generic"),
|
||||
});
|
||||
});
|
||||
|
||||
it("tells a demo user that the demo account is the reason", async () => {
|
||||
@@ -84,13 +103,14 @@ describe("notifyActionError", () => {
|
||||
notifyActionError(err, "GENERIC FALLBACK");
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "GENERIC FALLBACK",
|
||||
message: i18n.t("errors.demo"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than the caller's generic fallback — quota numbers, which row is gone.
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
|
||||
code: "not_found",
|
||||
reason: "channel",
|
||||
@@ -98,7 +118,54 @@ describe("notifyActionError", () => {
|
||||
notifyActionError(err, "TRANSLATED FALLBACK");
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED FALLBACK",
|
||||
message: "Ez a csatorna már nincs a könyvtárban.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("failureReason — why a write failed, when the server did not say", () => {
|
||||
it.each([
|
||||
[403, "errors.reason.forbidden"],
|
||||
[404, "errors.notFound.generic"],
|
||||
[429, "errors.reason.rateLimited"],
|
||||
[502, "errors.reason.unreachable"],
|
||||
[503, "errors.reason.unreachable"],
|
||||
[504, "errors.reason.unreachable"],
|
||||
[0, "errors.reason.unreachable"], // uploadReq's network failure
|
||||
[500, "errors.generic"],
|
||||
])("a %s reads %s", (status, key) => {
|
||||
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
|
||||
});
|
||||
|
||||
it("a request that never reached the server reads as unreachable", () => {
|
||||
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
||||
i18n.t("errors.reason.unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
it("anything else is the app's own fault, not the server's", () => {
|
||||
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
|
||||
});
|
||||
|
||||
it("every reason exists in both languages", () => {
|
||||
for (const lng of ["en", "hu"])
|
||||
for (const key of [
|
||||
"errors.reason.forbidden",
|
||||
"errors.reason.rateLimited",
|
||||
"errors.reason.unreachable",
|
||||
"errors.reason.unexpected",
|
||||
])
|
||||
expect(i18n.exists(key, { lng }), `${lng}:${key}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyKnownFailure", () => {
|
||||
it("shows the caller's specific sentence and marks the failure reported", () => {
|
||||
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
||||
notifyKnownFailure(err, "EMAIL IS OFF");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
||||
notifyActionError(err, "GENERIC");
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,62 @@
|
||||
// From the core, not the `./api` barrel: these two are pure (no request), and a test that mocks the
|
||||
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
|
||||
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
|
||||
import { alreadyReported, HttpError } from "./api/core";
|
||||
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const GATEWAY = new Set([502, 503, 504]);
|
||||
|
||||
/**
|
||||
* Surface a failed write action as an error toast instead of the click silently doing nothing.
|
||||
* Prefers the server's own reason over the caller's already-translated generic fallback — but only
|
||||
* when the server sent a STRUCTURED detail (`{code, reason}`), which `req` has already run through
|
||||
* `localizeDetail`. A plain-string `detail` is whatever English the route hand-wrote, and
|
||||
* `reportFailure` deliberately leaves 403/404/429 to the caller, so exactly those statuses used to
|
||||
* put the route's English sentence into an otherwise Hungarian UI — "No user with that email.",
|
||||
* "Not subscribed to this channel" — where the caller's own translated fallback was right there
|
||||
* (deep-review round 5). Domain-agnostic: used by the Plex watched-toggles and the saved-view ops,
|
||||
* and delegated to by `notifyYouTubeActionError` for its non-403 case.
|
||||
* WHY a write failed, in the reader's language — the line under the toast's title.
|
||||
*
|
||||
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through
|
||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and
|
||||
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that
|
||||
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it.
|
||||
*/
|
||||
export function failureReason(err: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
if (err instanceof HttpError) {
|
||||
if (err.detailData != null && err.detail) return err.detail;
|
||||
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
|
||||
// change did not go through, which the connection notice alone does not say.
|
||||
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
|
||||
if (err.status === 403) return t("errors.reason.forbidden");
|
||||
if (err.status === 404) return t("errors.notFound.generic");
|
||||
if (err.status === 429) return t("errors.reason.rateLimited");
|
||||
return t("errors.generic");
|
||||
}
|
||||
// `fetch` rejects with a TypeError when the request never reached the server.
|
||||
if (err instanceof TypeError) return t("errors.reason.unreachable");
|
||||
return t("errors.reason.unexpected");
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
|
||||
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
|
||||
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
|
||||
* the app-wide mutation reporter in lib/queryClient.
|
||||
*/
|
||||
export function notifyActionError(
|
||||
err: unknown,
|
||||
fallbackMessage: string,
|
||||
title: string,
|
||||
level: "error" | "warning" = "error",
|
||||
): void {
|
||||
// Already on screen (`req` opened the error dialog, or the tab is being signed out): a toast on
|
||||
// top reported one fault twice. Playlists filtered this by status by hand; the watched toggles,
|
||||
// the saved views and a playlist delete did not.
|
||||
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
|
||||
// `onError` got there first): a second toast reported one fault twice. A cancel is not a failure.
|
||||
if (alreadyReported(err) || isAborted(err)) return;
|
||||
notify({ level, title, message: failureReason(err) });
|
||||
markReported(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this
|
||||
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
|
||||
* so the app-wide mutation reporter does not put a second, generic toast next to it.
|
||||
*/
|
||||
export function notifyKnownFailure(err: unknown, message: string): void {
|
||||
if (alreadyReported(err)) return;
|
||||
const localized = err instanceof HttpError && err.detailData != null ? err.detail : null;
|
||||
notify({ level, message: localized || fallbackMessage });
|
||||
notify({ level: "error", message });
|
||||
markReported(err);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,18 @@ export const uiLang = (): string => i18n.language.split("-")[0] || "en";
|
||||
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
|
||||
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
|
||||
// then reported nowhere at all, the list still there (deep-review round 4).
|
||||
//
|
||||
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
|
||||
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
|
||||
// already said something, and speak for every one that did not.
|
||||
const reported = new WeakSet<object>();
|
||||
export function alreadyReported(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && reported.has(err);
|
||||
}
|
||||
/** Record that this failure is on screen now, so nothing reports it a second time. */
|
||||
export function markReported(err: unknown): void {
|
||||
if (typeof err === "object" && err !== null) reported.add(err);
|
||||
}
|
||||
|
||||
/** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the
|
||||
* only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The two shared channel write flows report their own failures, with the real error (E11 S2b).
|
||||
// Before: the channel detail page's tag toggle and both pages' priority stepper only refetched, so a
|
||||
// refused write just snapped back; and the manager reported a failed tag toggle by handing the
|
||||
// CHANNEL ID to the reporter as though it were the error.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
|
||||
import { api, HttpError } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("useChannelTagActions", () => {
|
||||
it("names a refused tag toggle with its real reason, then lets the caller revert", async () => {
|
||||
vi.spyOn(api, "attachChannelTag").mockRejectedValue(new HttpError(403, "nope"));
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => useChannelTagActions({ optimisticToggle: () => {}, onError }),
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => result.current.toggleTag("UC1", 7, false));
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.updateFailed"),
|
||||
message: i18n.t("errors.reason.forbidden"),
|
||||
});
|
||||
});
|
||||
|
||||
it("names a refused create-and-attach", async () => {
|
||||
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
|
||||
const { result } = renderHook(
|
||||
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => result.current.createAndAttachTag("UC1", "Music"));
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.createFailed"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelPriority", () => {
|
||||
it("names a refused priority save, then lets the caller revert", async () => {
|
||||
vi.spyOn(api, "updateChannel").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const onError = vi.fn();
|
||||
let priority = 1;
|
||||
const { result } = renderHook(() =>
|
||||
useChannelPriority({
|
||||
read: () => priority,
|
||||
optimistic: (_id, next) => void (priority = next),
|
||||
onError,
|
||||
delay: 0,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.bumpPriority("UC1", 1));
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.writeFailed.channelPriority"),
|
||||
message: i18n.t("errors.reason.unreachable"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,13 +139,21 @@ describe("closeOverlaysThenNavigate", () => {
|
||||
expect(closed).toEqual(["palette", "player"]);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
await settle(); // the reconcile reclaims both entries in one go
|
||||
expect(back).toHaveBeenCalledTimes(2);
|
||||
// Not inside the popstate dispatch that settled it — a later listener would still apply the
|
||||
// OLD entry's page over the push.
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
await tick();
|
||||
// OLD entry's page over the push. Asserted FROM such a later listener (registered after the
|
||||
// helper's own), not after `settle()`: that async act can flush the unmounts on a macrotask,
|
||||
// and the navigate task sometimes ran first — a 1-in-5 false failure (E11 S2b gate).
|
||||
const navigatedByPopstate: number[] = [];
|
||||
const laterListener = () => navigatedByPopstate.push(navigate.mock.calls.length);
|
||||
window.addEventListener("popstate", laterListener);
|
||||
try {
|
||||
await settle(); // the reconcile reclaims both entries in one go
|
||||
expect(back).toHaveBeenCalledTimes(2);
|
||||
await tick();
|
||||
} finally {
|
||||
window.removeEventListener("popstate", laterListener);
|
||||
}
|
||||
expect(navigatedByPopstate).toEqual([0, 0]);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
await tick();
|
||||
expect(back).toHaveBeenCalledTimes(2); // nothing reclaims over the entry navigate pushed
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Every `useMutation` NAMES what failed (E11 S2b, owner decision: the toast's title says what, the
|
||||
// line under it why). The app-wide reporter (lib/queryClient) reports a mutation that names nothing
|
||||
// too — so nothing is silent any more — but under the generic "Couldn't complete that", which is the
|
||||
// very toast that says nothing about what it is about when it lands a few seconds after the click.
|
||||
//
|
||||
// A mutation passes when it carries one of:
|
||||
// - `meta: { failTitle }` the reporter's title for it,
|
||||
// - `meta: { errorShownInline }` the page itself renders the failure,
|
||||
// - an `onError` which reports (or rolls back — the reporter then speaks for it,
|
||||
// under the title from `meta` if there is one).
|
||||
//
|
||||
// The assertion pins the SET of unnamed mutations, never a count, and checks the glob matched at
|
||||
// all: a guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
|
||||
const NAMES = /\bfailTitle\b|\berrorShownInline\b|\bonError\b/;
|
||||
|
||||
/** The source of each `useMutation(...)` call, found by paren balance with comments stripped —
|
||||
* a comment with a stray "(" would otherwise run one block into the next one's `failTitle`. */
|
||||
export function mutationCalls(source: string): string[] {
|
||||
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
const calls: string[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = code.indexOf("useMutation(", from);
|
||||
if (at < 0) return calls;
|
||||
let depth = 0;
|
||||
let i = at + "useMutation".length;
|
||||
for (; i < code.length; i++) {
|
||||
if (code[i] === "(") depth++;
|
||||
else if (code[i] === ")" && --depth === 0) break;
|
||||
}
|
||||
calls.push(code.slice(at, i + 1));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function unnamed(): { hits: string[]; mutations: number } {
|
||||
const files = import.meta.glob("../**/*.{ts,tsx}", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const hits: string[] = [];
|
||||
let mutations = 0;
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
|
||||
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
|
||||
mutationCalls(String(source)).forEach((call, n) => {
|
||||
mutations++;
|
||||
if (!NAMES.test(call)) hits.push(`${path}#${n + 1}`);
|
||||
});
|
||||
}
|
||||
return { hits: hits.sort(), mutations };
|
||||
}
|
||||
|
||||
describe("the failed-write contract", () => {
|
||||
it("has no mutation that leaves its failure unnamed", () => {
|
||||
const { hits, mutations } = unnamed();
|
||||
expect(mutations).toBeGreaterThan(50);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a mutation call to its end, whatever its body holds", () => {
|
||||
const src = `
|
||||
const a = useMutation({
|
||||
mutationFn: () => api.x(fn(1), (2)),
|
||||
// a stray "(" in a comment must not swallow the next call
|
||||
});
|
||||
const b = useMutation({ mutationFn: api.y, meta: { failTitle: "k" } });
|
||||
`;
|
||||
const calls = mutationCalls(src);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(NAMES.test(calls[0] ?? "")).toBe(false);
|
||||
expect(NAMES.test(calls[1] ?? "")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The ONE app-wide reporter for a failed mutation (E11 S2b). `req` shows its blocking dialog for
|
||||
// 400/409/422/5xx only, so before this every mutation without a reporting `onError` failed in
|
||||
// silence on a 403/404/429 or a lost connection — 34 of them.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "./queryClient";
|
||||
import { HttpError, req } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
vi.mocked(reportError).mockClear();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
async function fail(options: MutationObserverOptions<unknown, unknown, void>): Promise<void> {
|
||||
const observer = new MutationObserver(createQueryClient(), options);
|
||||
await observer.mutate().catch(() => {});
|
||||
// The reporter waits a task for the mutation's own onError; give it that task.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
const TITLE = "errors.writeFailed.hideChannel";
|
||||
|
||||
describe("a failed mutation", () => {
|
||||
it("is named, with the reason under it, when the mutation did not report it itself", async () => {
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new HttpError(404, "Not subscribed")),
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
expect(notifyMock.mock.calls).toEqual([
|
||||
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("is still reported under the generic title when the mutation names nothing", async () => {
|
||||
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.title"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
});
|
||||
});
|
||||
|
||||
it("says a lost connection is why the change did not go through", async () => {
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new TypeError("Failed to fetch")),
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gets ONE toast when the mutation's own onError reported it — react-query runs that one second", async () => {
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
|
||||
meta: { failTitle: TITLE },
|
||||
onError: (e) => notifyActionError(e, "OWN TITLE"),
|
||||
});
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]);
|
||||
});
|
||||
|
||||
it("is still reported when the mutation's onError only rolls back", async () => {
|
||||
const rollback = vi.fn();
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new HttpError(403, "nope")),
|
||||
meta: { failTitle: TITLE },
|
||||
onError: rollback,
|
||||
});
|
||||
expect(rollback).toHaveBeenCalled();
|
||||
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]);
|
||||
});
|
||||
|
||||
it("gets no toast when the page shows the failure itself", async () => {
|
||||
await fail({
|
||||
mutationFn: () => Promise.reject(new HttpError(403, "nope")),
|
||||
meta: { errorShownInline: true },
|
||||
});
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gets no toast on top of the error dialog the request layer already opened", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "conflict" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await fail({ mutationFn: () => req("/api/x", { method: "POST" }), meta: { failTitle: TITLE } });
|
||||
expect(reportError).toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
mutationMeta: {
|
||||
/** i18n key of the toast title that names what failed ("Couldn't hide the channel"). Without
|
||||
* one the failure is still reported, under the generic "Couldn't complete that". */
|
||||
failTitle?: string;
|
||||
/** The component renders this mutation's failure on the page itself — no toast. */
|
||||
errorShownInline?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
|
||||
|
||||
/**
|
||||
* The ONE reporter for a failed mutation. `req` shows its blocking dialog for 400/409/422/5xx only;
|
||||
* a 403/404/429 was left to the caller and a 502–504 or network failure got just the connection
|
||||
* notice — so every `useMutation` without a reporting `onError` failed in silence: a channel hide
|
||||
* that snapped back, a share link that never revoked, a scheduler run that never started (E11 S2b
|
||||
* counted 34 such mutations). Reporting centrally means a new mutation cannot be silent.
|
||||
*
|
||||
* react-query awaits this BEFORE the mutation's own `onError`, so it waits a task: a site that
|
||||
* reported the failure itself (through `notifyActionError`, which marks it) has had its turn, and
|
||||
* `notifyActionError` skips what is already on screen.
|
||||
*/
|
||||
function reportMutationFailure(err: unknown, meta: FailMeta): void {
|
||||
if (meta?.errorShownInline) return;
|
||||
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0);
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err, _vars, _onMutateResult, mutation) => reportMutationFailure(err, mutation.meta),
|
||||
}),
|
||||
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
|
||||
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
|
||||
@@ -34,7 +36,12 @@ export function useChannelPriority(opts: {
|
||||
api
|
||||
.updateChannel(channelId, { priority: latest })
|
||||
.then(() => optsRef.current.onSuccess?.(channelId))
|
||||
.catch(() => optsRef.current.onError(channelId));
|
||||
.catch((err: unknown) => {
|
||||
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
|
||||
// used to just snap back — often after the stepper had already been let go (E11 S2b).
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail
|
||||
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
|
||||
* optimistic update injected (the manager maps over a ManagedChannel[]; the detail patches one
|
||||
* ChannelDetail). Keeps the two call sites from drifting. */
|
||||
* ChannelDetail). Keeps the two call sites from drifting.
|
||||
*
|
||||
* A failure is REPORTED here, once, with the real error: the manager used to do it in its `onError`
|
||||
* and hand the channel id to the reporter as though it were the error, and the detail page did not
|
||||
* do it at all — a tag toggled there just snapped back (E11 S2b). */
|
||||
export function useChannelTagActions(opts: {
|
||||
/** Apply the optimistic tag change to whichever cache the caller owns. `attach` true = add. */
|
||||
optimisticToggle: (channelId: string, tagId: number, attach: boolean) => void;
|
||||
@@ -24,7 +28,12 @@ export function useChannelTagActions(opts: {
|
||||
const call = hasTag
|
||||
? api.detachChannelTag(channelId, tagId)
|
||||
: api.attachChannelTag(channelId, tagId);
|
||||
call.then(() => opts.onSuccess?.(channelId)).catch(() => opts.onError(channelId));
|
||||
call
|
||||
.then(() => opts.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
notifyActionError(err, t("channels.tags.updateFailed"));
|
||||
opts.onError(channelId);
|
||||
});
|
||||
};
|
||||
|
||||
// Create a brand-new personal tag and attach it in one step (so tagging doesn't require the
|
||||
@@ -36,7 +45,7 @@ export function useChannelTagActions(opts: {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
toggleTag(channelId, tag.id, false);
|
||||
})
|
||||
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
|
||||
.catch((err: unknown) => notifyActionError(err, t("channels.tags.createFailed")));
|
||||
};
|
||||
|
||||
return { toggleTag, createAndAttachTag };
|
||||
|
||||
@@ -14,7 +14,9 @@ const { store, savePrefs } = vi.hoisted(() => ({
|
||||
savePrefs: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("./storage", () => ({
|
||||
// Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`.
|
||||
vi.mock("./storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./storage")>()),
|
||||
readAccount: (base: string, fallback: unknown) => {
|
||||
const raw = store.get(base);
|
||||
return raw == null ? fallback : JSON.parse(raw);
|
||||
@@ -24,6 +26,9 @@ vi.mock("./storage", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||
// The failure path (usePref.saveFailure.dom.test.ts) is not this file's subject; stubbed so the
|
||||
// reporter's notifications → storage chain does not meet the storage mock above.
|
||||
vi.mock("./actionErrors", () => ({ notifyActionError: vi.fn() }));
|
||||
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A preference save the server refuses is REPORTED (E11 S2b). The local state and the `me` cache
|
||||
// already show the new value, so a swallowed failure was invisible until the next load quietly put
|
||||
// the old value back — and the comment above the swallow claimed `req` toasted it, which it does not
|
||||
// for a 403/404/429 or a lost connection.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
|
||||
const { savePrefs, notifyActionError } = vi.hoisted(() => ({
|
||||
savePrefs: vi.fn(),
|
||||
notifyActionError: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||
vi.mock("./actionErrors", () => ({ notifyActionError }));
|
||||
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import i18n from "../i18n";
|
||||
|
||||
afterEach(() => {
|
||||
savePrefs.mockReset();
|
||||
notifyActionError.mockReset();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: new QueryClient() }, children);
|
||||
|
||||
describe("a refused preference save", () => {
|
||||
it("is reported from the usePref setter", async () => {
|
||||
const refusal = new TypeError("Failed to fetch");
|
||||
savePrefs.mockRejectedValue(refusal);
|
||||
const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper });
|
||||
act(() => result.current[1](false));
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")),
|
||||
);
|
||||
expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save
|
||||
});
|
||||
|
||||
it("is reported from the raw useSavePref persister", async () => {
|
||||
const refusal = new Error("403");
|
||||
savePrefs.mockRejectedValue(refusal);
|
||||
const { result } = renderHook(() => useSavePref(), { wrapper });
|
||||
act(() => result.current({ stripSources: { c1: false } }));
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,14 @@ import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import type { Me } from "./api/account";
|
||||
import { readAccount, writeAccount } from "./storage";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
// The local state + cache already show the new value, so a refused save is otherwise invisible until
|
||||
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
|
||||
// connection notice for a lost connection — say it (E11 S2b).
|
||||
const reportPrefSaveFailure = (err: unknown) =>
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
||||
|
||||
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
|
||||
@@ -55,7 +63,7 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
api.savePrefs(patch).catch(reportPrefSaveFailure);
|
||||
mergePrefsCache(qc, patch);
|
||||
},
|
||||
[qc],
|
||||
@@ -116,9 +124,9 @@ export function usePref<T>(
|
||||
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||
const payload = { [serverKey]: next };
|
||||
mergePrefsCache(qc, payload);
|
||||
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
|
||||
// cache already reflect the change (idempotent PUT retries a transient blip).
|
||||
const flush = () => api.savePrefs(payload).catch(() => {});
|
||||
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a
|
||||
// failure that survives that is reported, not swallowed.
|
||||
const flush = () => api.savePrefs(payload).catch(reportPrefSaveFailure);
|
||||
if (debounceMs) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
pendingSave.current = flush;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpError } from "./api";
|
||||
import { markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
@@ -22,6 +23,7 @@ export function notifyYouTubeActionError(
|
||||
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
|
||||
: undefined,
|
||||
});
|
||||
markReported(err);
|
||||
return;
|
||||
}
|
||||
// Any other error: prefer the server's own reason (e.g. "Not enough quota left today") over the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "./lib/queryClient";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
@@ -28,9 +29,7 @@ const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
|
||||
const Terms = lazy(() => import("./components/legal/Terms"));
|
||||
const WatchPage = lazy(() => import("./components/WatchPage"));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||
});
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
// Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including
|
||||
// the standalone public pages (/watch, /privacy, /terms) that render outside <App> and never call
|
||||
|
||||
Reference in New Issue
Block a user