From a2d17d693c6057b1e8dc7c42c0bfcbcfa02ce7cb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 07:10:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(design):=20the=20download,=20notification?= =?UTF-8?q?=20and=20Scheduler=20action=20lists=20(C1)=20=E2=80=94=20the=20?= =?UTF-8?q?rows'=20"=E2=80=A6",=20the=20band's=20and=20the=20strip's=20"?= =?UTF-8?q?=E2=80=A6"=20(E28=20S3e3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A download row keeps play (pause / resume / retry in the queue) and the save link; the editors, sharing, re-download, the copies and cancel / delete / remove moved into its "…" (useDownloadActions). A link inside the row keeps the browser's own right-click menu. A notification row keeps its link and its unread mark and shows its dismiss on hover; an activity notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a rule only between two non-empty groups. Find in feed also drops the tag, search and age filters that could hide the video it looks for. --- .../DownloadCenter.confirm.dom.test.tsx | 27 +- .../DownloadCenter.menu.dom.test.tsx | 301 +++++++++++++ frontend/src/components/DownloadCenter.tsx | 231 +++------- .../NotificationsPanel.menu.dom.test.tsx | 269 ++++++++++++ .../src/components/NotificationsPanel.tsx | 413 +++++++++++++----- .../components/Scheduler.menu.dom.test.tsx | 123 ++++++ frontend/src/components/Scheduler.tsx | 73 +++- frontend/src/components/downloadRow.tsx | 43 +- frontend/src/components/ui/menuGroups.ts | 12 + .../src/components/useDownloadActions.tsx | 265 +++++++++++ frontend/src/i18n/locales/en/downloads.json | 6 +- frontend/src/i18n/locales/en/inbox.json | 4 + frontend/src/i18n/locales/en/scheduler.json | 1 + frontend/src/i18n/locales/hu/downloads.json | 6 +- frontend/src/i18n/locales/hu/inbox.json | 4 + frontend/src/i18n/locales/hu/scheduler.json | 1 + 16 files changed, 1455 insertions(+), 324 deletions(-) create mode 100644 frontend/src/components/DownloadCenter.menu.dom.test.tsx create mode 100644 frontend/src/components/NotificationsPanel.menu.dom.test.tsx create mode 100644 frontend/src/components/Scheduler.menu.dom.test.tsx create mode 100644 frontend/src/components/ui/menuGroups.ts create mode 100644 frontend/src/components/useDownloadActions.tsx diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index 43e37ab8..3a486cfa 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -77,6 +77,24 @@ afterEach(() => { mocks.confirm.mockClear(); }); +/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom + * (floating-ui never positions it), so the row is matched on its text. */ +async function pick(row: string, label: string) { + const more = screen + .getByText(row) + .closest("[data-nav-index]")! + .querySelector('button[aria-label="downloads.rowMenu"]')!; + fireEvent.click(more); + const item = await waitFor(() => { + const found = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === label); + expect(found).toBeTruthy(); + return found!; + }); + fireEvent.click(item); +} + describe("DownloadCenter confirm dialogs", () => { it("label the confirm button with the action, not the dialog's question", async () => { mocks.jobs = [job(1, "running"), job(2, "done")]; @@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => { , ); - fireEvent.click(screen.getByTitle("downloads.actions.cancel")); + // E28 C1: the destructive and rare row buttons live in the row's "…". + await pick("Clip 1", "downloads.actions.cancel"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); // The tab's label shares its button with a count badge. fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ })); - fireEvent.click(await screen.findByTitle("downloads.actions.redownload")); - fireEvent.click(screen.getByTitle("downloads.actions.delete")); + await screen.findByText("Clip 2"); + await pick("Clip 2", "downloads.actions.redownload"); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); + await pick("Clip 2", "downloads.actions.delete"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3)); expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([ diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx new file mode 100644 index 00000000..a98a0b87 --- /dev/null +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -0,0 +1,301 @@ +// @vitest-environment jsdom +// +// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume / +// retry in the queue) and the save link in sight, and everything else — the editors, sharing, +// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it +// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own +// menu. i18n is key-passthrough, so labels read as keys. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api, type DownloadJob } from "../lib/api"; +import { menuGroups } from "./ui/menuGroups"; + +const mocks = vi.hoisted(() => ({ + jobs: [] as unknown[], + confirm: vi.fn(async (_o?: unknown) => true), +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: (key: readonly unknown[]) => ({ + data: key[0] === "downloads" ? mocks.jobs : undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import DownloadCenter from "./DownloadCenter"; + +const job = ( + id: number, + status: DownloadJob["status"], + extra: Partial = {}, +): DownloadJob => + ({ + id, + status, + progress: 0, + speed_bps: null, + eta_s: null, + phase: null, + error: null, + error_code: null, + error_detail: null, + queue_pos: 0, + created_at: null, + source_kind: "youtube", + source_ref: `v${id}`, + source_url: null, + poster_url: null, + profile_id: null, + spec: { mode: "av" }, + display_name: `Clip ${id}`, + display_uploader: null, + display_uploader_url: null, + extra_links: [], + can_download: status === "done", + expired: false, + job_kind: "download", + asset: { media_kind: "video", duration_s: 60 }, + ...extra, + }) as unknown as DownloadJob; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mocks.confirm.mockClear(); +}); + +function mount(tab?: string) { + render( + + + , + ); + if (tab) + fireEvent.click(screen.getByRole("button", { name: new RegExp(`downloads\\.tabs\\.${tab}`) })); +} + +const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; +const moreOf = (text: string) => + rowOf(text).querySelector('button[aria-label="downloads.rowMenu"]')!; +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +async function openMenu(open: () => void) { + act(open); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} +const rowButtons = (text: string) => + [...rowOf(text).querySelectorAll("button, a[aria-label]")].map((b) => + b.getAttribute("aria-label"), + ); + +describe("DownloadCenter · the queue row", () => { + it("shows pause and its '…' only; the '…' hides until hover or focus", async () => { + mocks.jobs = [job(1, "running")]; + mount(); + expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]); + expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true); + expect(rowOf("Clip 1").classList.contains("group")).toBe(true); + }); + + it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => { + mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")]; + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 1"))); + expect(menuRows()).toEqual([ + "downloads.actions.pause", + "common.copy.title", + "common.copy.link", + "downloads.actions.cancel", + ]); + expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true"); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows()).toEqual([ + "downloads.actions.retry", + "common.copy.title", + "downloads.actions.cancel", + ]); + }); + + it("closes on a second press of the same '…'", async () => { + mocks.jobs = [job(1, "running")]; + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 1"))); + // The press lands outside the panel, which floating-ui's dismiss closes on; the click after + // it must not open it again. + act(() => { + fireEvent.pointerDown(moreOf("Clip 1")); + fireEvent.mouseDown(moreOf("Clip 1")); + fireEvent.click(moreOf("Clip 1"), { detail: 1 }); + }); + await waitFor(() => + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), + ); + expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false"); + }); + + it("still closes when a poll replaced the job under the open menu", async () => { + mocks.jobs = [job(1, "running")]; + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 1"))); + // The queue polls while a job runs: the next render lists a NEW object for the same job. + mocks.jobs = [job(1, "running", { progress: 40 })]; + fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), { + target: { value: "x" }, + }); + act(() => { + fireEvent.pointerDown(moreOf("Clip 1")); + fireEvent.mouseDown(moreOf("Clip 1")); + fireEvent.click(moreOf("Clip 1"), { detail: 1 }); + }); + await waitFor(() => + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), + ); + }); + + it("cancels only through the confirm", async () => { + mocks.jobs = [job(1, "queued")]; + const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never); + mount(); + const pickCancel = async () => { + await openMenu(() => fireEvent.click(moreOf("Clip 1"))); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "downloads.actions.cancel")!, + ); + }; + mocks.confirm.mockResolvedValueOnce(false); + await pickCancel(); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); + expect(cancel).not.toHaveBeenCalled(); + await pickCancel(); + await waitFor(() => expect(cancel).toHaveBeenCalledWith(1)); + }); +}); + +describe("DownloadCenter · the library row", () => { + it("shows play, save and its '…'; the editors, sharing and delete are in the menu", async () => { + mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; + mount("done"); + await screen.findByText("Clip 2"); + expect(rowButtons("Clip 2")).toEqual([ + "downloads.actions.play", + "downloads.actions.download", + "downloads.rowMenu", + ]); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows()).toEqual([ + "downloads.actions.play", + "downloads.actions.download", + "downloads.actions.edit", + "downloads.actions.editDetails", + "downloads.actions.redownload", + "downloads.actions.share", + "downloads.actions.shareManage", + "common.copy.title", + "common.copy.link", + "downloads.actions.delete", + ]); + }); + + it("saves the file through the same link as the row's save button", async () => { + mocks.jobs = [job(2, "done")]; + const followed: string[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + followed.push(this.href); + }); + mount("done"); + await screen.findByText("Clip 2"); + await openMenu(() => fireEvent.click(moreOf("Clip 2"))); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "downloads.actions.download")!, + ); + const button = rowOf("Clip 2").querySelector( + 'a[aria-label="downloads.actions.download"]', + )!; + expect(followed).toEqual([button.href]); + }); + + it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => { + mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)]; + mount("done"); + await screen.findByText("Clip 3"); + await openMenu(() => fireEvent.click(moreOf("Clip 3"))); + expect(menuRows()).toEqual([ + "downloads.actions.download", + "downloads.actions.share", + "downloads.actions.shareManage", + "common.copy.title", + "downloads.actions.delete", + ]); + }); + + it("gives a link inside the row the browser's own menu", async () => { + mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; + mount("done"); + const link = await screen.findByText("youtu.be/x"); + const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + act(() => void link.dispatchEvent(ev)); + expect(ev.defaultPrevented).toBe(false); + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0); + }); +}); + +describe("DownloadCenter · the shared row", () => { + it("holds play, save, edit, the copies and Remove from my list", async () => { + mocks.jobs = []; + vi.spyOn(api, "downloadsShared").mockResolvedValue([ + job(4, "done", { can_download: true }), + ] as never); + const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never); + mount("shared"); + await screen.findByText("Clip 4"); + await openMenu(() => fireEvent.click(moreOf("Clip 4"))); + expect(menuRows()).toEqual([ + "downloads.actions.play", + "downloads.actions.download", + "downloads.actions.edit", + "common.copy.title", + "downloads.actions.removeShared", + ]); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "downloads.actions.removeShared")!, + ); + await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); + }); +}); + +describe("menuGroups", () => { + const row = (id: string) => ({ id, label: id, onSelect: () => {} }); + it("rules between non-empty groups only", () => { + const out = menuGroups([], [row("a"), row("b")], [], [row("c")]); + expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([ + ["a", false], + ["b", false], + ["c", true], + ]); + }); +}); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index d02bb336..3c87c25d 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,22 +1,15 @@ import { lazy, Suspense, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { - Ban, ClipboardPaste, Download, - Link2, Pause, Play, PlayCircle, Plus, - RotateCcw, - Scissors, Settings2, - Share2, - Pencil, - Trash2, UploadCloud, } from "lucide-react"; import Tabs from "./ui/Tabs"; @@ -30,12 +23,9 @@ const ProfileEditor = lazy(() => import("./ProfileEditor")); const VideoEditor = lazy(() => import("./VideoEditor")); const ShareDialog = lazy(() => import("./ShareDialog")); const LibraryPlayer = lazy(() => import("./LibraryPlayer")); -import { useConfirm } from "./confirmContext"; 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 { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; @@ -45,12 +35,16 @@ import { AdminSystem } from "./downloadAdmin"; import NavGrid from "./ui/NavGrid"; import { Input } from "./ui/Input"; import { IconLink } from "./ui/LinkButton"; +import { useContextMenu } from "./ui/useContextMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { MoreButton } from "./ui/MoreButton"; +import { useDownloadActions, type DownloadTab } from "./useDownloadActions"; + +const jobKey = (job: DownloadJob) => job.id; export default function DownloadCenter() { const me = useMe(); const { t } = useTranslation(); - const qc = useQueryClient(); - const confirm = useConfirm(); // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and // any failures are, so it's the useful default every visit. const [tab, setTab] = useState("queue"); @@ -80,56 +74,35 @@ export default function DownloadCenter() { const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); const library = jobs.filter((j) => j.status === "done"); - const invalidate = () => invalidateDownloads(qc); - const act = useMutation({ - mutationFn: ({ - id, - op, - }: { - id: number; - op: "pause" | "resume" | "cancel" | "delete" | "redownload"; - }) => - op === "pause" - ? api.pauseDownload(id) - : op === "resume" - ? api.resumeDownload(id) - : op === "cancel" - ? api.cancelDownload(id) - : op === "redownload" - ? api.redownloadDownload(id) - : api.deleteDownload(id), - onSuccess: invalidate, - // 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}`)), + // --- The rows' ONE menu (E28 C1) ------------------------------------------------------------- + // Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an + // open menu, and its "…" must still close it. The tab is the one on screen. + const rowMenu = useContextMenu({ keyOf: jobKey }); + const actions = useDownloadActions({ + onPlay: setPlayJob, + onEdit: setEditJob, + onEditDetails: setMetaJob, + onManageShares: setShareJob, }); - - // 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" }, - }); - - // The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title - // it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel". - const confirmThen = async (title: string, body: string, action: string, fn: () => void) => { - if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn(); - }; - - // One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab" - // (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a - // double-click from racing two POSTs into two links before the cache is primed. Advanced controls - // (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button. - const [sharingId, setSharingId] = useState(null); - const quickShare = async (job: DownloadJob) => { - if (sharingId !== null) return; - setSharingId(job.id); - try { - await quickShareLink(qc, job, t); - } finally { - setSharingId(null); - } + const menuTarget = rowMenu.target; + const listTab: DownloadTab | null = + tab === "queue" || tab === "done" || tab === "shared" ? tab : null; + const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : []; + // The row's menu triggers and its "…". + const rowMenuProps = (job: DownloadJob) => { + const mine = rowMenu.open && rowMenu.target?.id === job.id; + return { + rowProps: rowMenu.triggerProps(job), + more: ( + + ), + }; }; // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + @@ -226,18 +199,24 @@ export default function DownloadCenter() { {queue.map((job, i) => ( - - {(job.status === "queued" || job.status === "running") && ( + + {/* The queue's primary action stays in sight; cancel moved into the "…". */} + {job.status === "queued" || job.status === "running" ? ( act.mutate({ id: job.id, op: "pause" })} + onClick={() => actions.run(job, "pause")} title={t("downloads.actions.pause")} > - )} - {(job.status === "paused" || job.status === "error") && ( + ) : ( act.mutate({ id: job.id, op: "resume" })} + onClick={() => actions.run(job, "resume")} title={t( job.status === "error" ? "downloads.actions.retry" @@ -247,20 +226,6 @@ export default function DownloadCenter() { )} - - confirmThen( - t("downloads.confirm.cancelTitle"), - t("downloads.confirm.cancelBody"), - t("downloads.confirm.cancelAction"), - () => act.mutate({ id: job.id, op: "cancel" }), - ) - } - title={t("downloads.actions.cancel")} - danger - > - - ))} {queue.length === 0 && @@ -283,75 +248,20 @@ export default function DownloadCenter() { <> - {library.map((job, i) => { - // Row actions gated on the stored media_kind + job_kind (see downloadActions). - const a = libraryRowActions(job); - return ( - - {a.play && ( - setPlayJob(job)} - title={t("downloads.actions.play")} - > - - - )} - {saveBtn(job)} - {a.edit && ( - setEditJob(job)} title={t("downloads.actions.edit")}> - - - )} - {a.editDetails && ( - setMetaJob(job)} - title={t("downloads.actions.editDetails")} - > - - - )} - quickShare(job)} title={t("downloads.actions.share")}> - - + {library.map((job, i) => ( + + {libraryRowActions(job).play && ( setShareJob(job)} - title={t("downloads.actions.shareManage")} + open + onClick={() => setPlayJob(job)} + title={t("downloads.actions.play")} > - + - {a.redownload && ( - - confirmThen( - t("downloads.confirm.redownloadTitle"), - t("downloads.confirm.redownloadBody"), - t("downloads.confirm.redownloadAction"), - () => act.mutate({ id: job.id, op: "redownload" }), - ) - } - title={t("downloads.actions.redownload")} - > - - - )} - - confirmThen( - t("downloads.confirm.deleteTitle"), - t("downloads.confirm.deleteBody"), - t("downloads.actions.delete"), - () => act.mutate({ id: job.id, op: "delete" }), - ) - } - title={t("downloads.actions.delete")} - danger - > - - - - ); - })} + )} + {saveBtn(job)} + + ))} {library.length === 0 && (jobsQ.isError && !jobsQ.data ? ( jobsQ.refetch()}> @@ -374,7 +284,7 @@ export default function DownloadCenter() { // Same media_kind + can_download gate as the library, via the shared helper (S5b). const a = sharedRowActions(job); return ( - + {a.play && ( )} {a.save && saveBtn(job)} - {a.edit && ( - setEditJob(job)} title={t("downloads.actions.edit")}> - - - )} - - confirmThen( - t("downloads.confirm.removeSharedTitle"), - t("downloads.confirm.removeSharedBody"), - t("downloads.actions.removeShared"), - () => removeShared.mutate(job.id), - ) - } - title={t("downloads.actions.removeShared")} - danger - > - - ); })} @@ -424,6 +315,8 @@ export default function DownloadCenter() { {tab === "system" && me.role === "admin" && } + + {addSource && ( act.mutate({ id: errorJob.id, op: "resume" })} + onRetry={() => actions.run(errorJob, "resume")} onClose={() => setErrorJob(null)} /> )} diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx new file mode 100644 index 00000000..f641f62d --- /dev/null +++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +// +// E28 C1 on the notification center: a row keeps its link and its unread mark in sight, its dismiss +// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by +// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there. +// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first. +// i18n is key-passthrough, so labels read as keys. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api, type AppNotification } from "../lib/api"; +import type { Notification as ClientNotif } from "../lib/notifications"; + +const h = vi.hoisted(() => ({ + server: [] as unknown[], + client: [] as unknown[], + setPage: vi.fn(), + setFilters: vi.fn(), + remove: vi.fn(), + clearAll: vi.fn(), + confirm: vi.fn(async () => true), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: () => ({ + data: { items: h.server, total: h.server.length }, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + subscribe: () => () => {}, + getNotifications: () => h.client, + getUnreadCount: () => 0, + markAllRead: () => {}, + remove: (id: number) => h.remove(id), + clearAll: () => h.clearAll(), +})); +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) })); +vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ tags: [54], untagged: true, q: "rust", maxAgeDays: 30, sort: "newest" }), + useFeedFiltersActions: () => ({ setFilters: h.setFilters }), +})); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import NotificationsPanel from "./NotificationsPanel"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const f of [h.setPage, h.setFilters, h.remove, h.clearAll, h.confirm]) f.mockClear(); +}); + +const server = (id: number, extra: Partial = {}) => + ({ + id, + type: "download_done", + title: `Server ${id}`, + body: `Body ${id}`, + data: { title: `Ready ${id}` }, + read: false, + created_at: new Date().toISOString(), + ...extra, + }) as unknown as AppNotification; +const client = (id: number, extra: Partial = {}) => + ({ + id, + level: "success", + message: `Activity ${id}`, + requiresInteraction: false, + ts: Date.now(), + read: true, + dismissed: true, + transient: false, + repeat: 1, + ...extra, + }) as unknown as ClientNotif; + +function mount() { + const client = new QueryClient(); + const tree = () => ( + + + + ); + const { rerender } = render(tree()); + return { rerender: () => rerender(tree()) }; +} +const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; +const moreOf = (text: string) => + rowOf(text).querySelector('button[aria-label="inbox.rowMenu"]')!; +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +async function openMenu(open: () => void) { + act(open); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} +const choose = (label: string) => + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, + ); + +describe("NotificationsPanel · a server row", () => { + it("keeps its link and mark read in sight; dismiss shows on hover, then its '…'", () => { + h.server = [server(1)]; + h.client = []; + mount(); + const row = rowOf("Ready 1"); + expect(row.classList.contains("group")).toBe(true); + const buttons = [...row.querySelectorAll("button")]; + expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([ + "inbox.openDownloads", + "inbox.markRead", + "inbox.dismiss", + "inbox.rowMenu", + ]); + // Hover-only on a mouse (and gone on touch, where the menu holds it). + expect(buttons[2]!.className).toContain("reveal-hover"); + expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true); + }); + + it("holds the link, mark read and dismiss", async () => { + h.server = [server(1)]; + h.client = []; + const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never); + mount(); + await openMenu(() => fireEvent.click(moreOf("Ready 1"))); + expect(menuRows()).toEqual(["inbox.openDownloads", "inbox.markRead", "inbox.dismiss"]); + choose("inbox.dismiss"); + await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything())); + }); + + it("closes on a second press of the same '…', on a server and an activity row", async () => { + h.server = [server(1)]; + h.client = [client(8)]; + mount(); + for (const name of ["Ready 1", "Activity 8"]) { + await openMenu(() => fireEvent.click(moreOf(name))); + act(() => { + fireEvent.pointerDown(moreOf(name)); + fireEvent.mouseDown(moreOf(name)); + fireEvent.click(moreOf(name), { detail: 1 }); + }); + await waitFor(() => + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), + ); + } + }); + + it("still closes when a poll replaced the notification under the open menu", async () => { + h.server = [server(1)]; + h.client = []; + const { rerender } = mount(); + await openMenu(() => fireEvent.click(moreOf("Ready 1"))); + h.server = [server(1)]; // the next poll: a NEW object for the same notification + rerender(); + act(() => { + fireEvent.pointerDown(moreOf("Ready 1")); + fireEvent.mouseDown(moreOf("Ready 1")); + fireEvent.click(moreOf("Ready 1"), { detail: 1 }); + }); + await waitFor(() => + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), + ); + }); + + it("leaves mark read out of a read row's menu", async () => { + h.server = [server(2, { read: true, type: "maintenance", data: {} } as never)]; + h.client = []; + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Server 2"))); + expect(menuRows()).toEqual(["inbox.dismiss"]); + }); +}); + +describe("NotificationsPanel · an activity row", () => { + it("puts a hidden notice's Find in feed and Undo into its menu only", async () => { + h.server = []; + // Its toast is still up (not dismissed), so it carries the toast's Undo too: the row shows + // neither that nor Find in feed — only its dismiss and its "…". + h.client = [ + client(7, { + dismissed: false, + action: { label: "feed.undo", onClick: () => {} }, + meta: { kind: "video-hidden", videoId: "v", channelId: "c" }, + } as never), + ]; + const setState = vi.spyOn(api, "setState").mockResolvedValue({} as never); + mount(); + expect( + [...rowOf("Activity 7").querySelectorAll("button")].map((b) => b.getAttribute("aria-label")), + ).toEqual(["inbox.dismiss", "inbox.rowMenu"]); + await openMenu(() => fireEvent.contextMenu(rowOf("Activity 7"))); + expect(menuRows()).toEqual([ + "notifications.findInFeed", + "notifications.unhide", + "inbox.dismiss", + ]); + choose("notifications.unhide"); + await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new")); + }); + + it("finds a watched notice's video with nothing narrowing the feed past it", async () => { + h.server = []; + h.client = [ + client(9, { meta: { kind: "video-watched", videoId: "v", channelId: "c" } } as never), + ]; + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Activity 9"))); + choose("notifications.findInFeed"); + await waitFor(() => expect(h.setFilters).toHaveBeenCalled()); + expect(h.setFilters).toHaveBeenCalledWith({ + tags: [], + untagged: false, + q: "", + maxAgeDays: undefined, + sort: "newest", + show: "watched", + channelIds: ["c"], + }); + expect(h.setPage).toHaveBeenCalledWith("feed"); + }); + + it("dismisses a plain activity row from its menu", async () => { + h.server = []; + h.client = [client(8)]; + mount(); + await openMenu(() => fireEvent.click(moreOf("Activity 8"))); + expect(menuRows()).toEqual(["inbox.dismiss"]); + choose("inbox.dismiss"); + await waitFor(() => expect(h.remove).toHaveBeenCalledWith(8)); + }); +}); + +describe("NotificationsPanel · the header's '…'", () => { + it("keeps Mark all read in the band and asks before Clear everything", async () => { + h.server = [server(1)]; + h.client = [client(8)]; + const clear = vi.spyOn(api, "clearNotifications").mockResolvedValue({} as never); + mount(); + expect(screen.getByRole("button", { name: "inbox.markAllRead" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "inbox.clearAll" })).toBeNull(); + await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" }))); + expect(menuRows()).toEqual(["inbox.markAllRead", "inbox.clearAll"]); + h.confirm.mockResolvedValueOnce(false); + choose("inbox.clearAll"); + await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1)); + expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); + expect(clear).not.toHaveBeenCalled(); + expect(h.clearAll).not.toHaveBeenCalled(); + await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" }))); + choose("inbox.clearAll"); + await waitFor(() => expect(clear).toHaveBeenCalled()); + expect(h.clearAll).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 2093cc20..96627807 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -47,6 +47,15 @@ import { useChannelsActions } from "./channelsContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { SURFACE } from "./ui/surfaces"; import { SectionLabel } from "./ui/Headings"; +import NavGrid from "./ui/NavGrid"; +import { navItemAttrs } from "../lib/listNav"; +import { useContextMenu, type ContextMenuController } from "./ui/useContextMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { MoreButton } from "./ui/MoreButton"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { menuGroups } from "./ui/menuGroups"; +import { HOVER_REVEAL } from "./ui/reveal"; +import { useConfirm } from "./confirmContext"; // The single notification center. "System" = durable, server-backed notifications (inbox // phase 1). "Activity" = the client-side transient events (action confirmations, errors, with @@ -54,6 +63,8 @@ import { SectionLabel } from "./ui/Headings"; // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; +const byId = (n: { id: number }) => n.id; + export default function NotificationsPanel() { const { t } = useTranslation(); const { setPage } = useNavigationActions(); @@ -63,6 +74,7 @@ export default function NotificationsPanel() { // ChannelsProvider — read it here and pass it down to the notification rows. const { focusChannel: onFocusChannel } = useChannelsActions(); const qc = useQueryClient(); + const confirm = useConfirm(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( qk.notifications(), () => api.notifications(), @@ -112,16 +124,31 @@ export default function NotificationsPanel() { if (serverUnread) readAllMut.mutate(); else refresh(); } - function clearEverything() { + // It empties both lists for good (the server's are deleted), so it asks first — it moved from the + // band into the header's "…" (E28 C1), where a destructive row keeps its confirm. + async function clearEverything() { + const ok = await confirm({ + title: t("inbox.clearConfirmTitle"), + message: t("inbox.clearConfirmBody"), + confirmLabel: t("inbox.clearAll"), + danger: true, + }); + if (!ok) return; clearClient(); clearMut.mutate(); } // "Find in feed" / undo, ported from the old bell. Works for both hidden and watched // notices — jump to the feed filtered to that state and the video's channel. + // The narrowing filters that could leave the video out (a tag, "no tag", a search, an age cap) are + // dropped: kept, a channel outside the reader's tag filter found an empty feed. function locate(meta: VideoHiddenMeta | VideoWatchedMeta) { setFilters({ ...filters, + tags: [], + untagged: false, + q: "", + maxAgeDays: undefined, show: meta.kind === "video-hidden" ? "hidden" : "watched", channelIds: meta.channelId ? [meta.channelId] : [], }); @@ -139,13 +166,165 @@ export default function NotificationsPanel() { .catch((e) => notifyActionError(e, t("inbox.revertFailed"))); } + // --- The rows' ONE menu (E28 C1) ------------------------------------------------------------- + // A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can + // do — the link again, find / undo on an activity row, mark read, dismiss — is its menu. + // One menu per list, keyed by the notification's id: the server list polls, and the "…" must still + // close the menu a poll replaced its row under. + const serverMenu = useContextMenu({ keyOf: byId }); + const clientMenu = useContextMenu({ keyOf: byId }); + const openScheduler = () => setPage("scheduler"); + const openDownloads = () => setPage("downloads"); + const reviewAccess = () => { + focusAccessRequestsTab(); + setPage("users"); + }; + const icon = (I: typeof Check) => ; + const dismissRow = (onSelect: () => void): ActionMenuItem => ({ + id: "dismiss", + label: t("inbox.dismiss"), + icon: icon(X), + onSelect, + }); + function serverRows(n: AppNotification): ActionMenuItem[] { + const kind = serverKind(n); + return menuGroups( + [ + ...(kind === "scheduler" + ? [ + { + id: "open", + label: t("inbox.openScheduler"), + icon: icon(Activity), + onSelect: openScheduler, + }, + ] + : kind === "download" + ? [ + { + id: "open", + label: t("inbox.openDownloads"), + icon: icon(Download), + onSelect: openDownloads, + }, + ] + : []), + ...(!n.read + ? [ + { + id: "read", + label: t("inbox.markRead"), + icon: icon(Check), + onSelect: () => readMut.mutate(n.id), + }, + ] + : []), + ], + [dismissRow(() => dismissMut.mutate(n.id))], + ); + } + function clientRows(n: ClientNotif): ActionMenuItem[] { + const m = n.meta; + const own: ActionMenuItem[] = + m?.kind === "video-hidden" || m?.kind === "video-watched" + ? [ + { + id: "find", + label: t("notifications.findInFeed"), + icon: icon(Search), + onSelect: () => locate(m), + }, + { + id: "revert", + label: t( + m.kind === "video-hidden" ? "notifications.unhide" : "notifications.unwatch", + ), + icon: icon(m.kind === "video-hidden" ? Eye : RotateCcw), + onSelect: () => revertState(m), + }, + ] + : m?.kind === "channel-subscribed" + ? [ + { + id: "manager", + label: t("notifications.openInManager"), + icon: icon(Tv), + onSelect: () => onFocusChannel(m.channelName), + }, + { + id: "youtube", + label: t("notifications.openOnYouTube"), + icon: icon(ExternalLink), + // A new tab only opens inside the click (a popup blocker refuses one a task later). + inGesture: true, + onSelect: () => + void window.open(channelYouTubeUrl(m.channelId), "_blank", "noopener,noreferrer"), + }, + ] + : m?.kind === "access-requests" + ? [ + { + id: "review", + label: t("common.accessRequestsReview"), + icon: icon(UserPlus), + onSelect: reviewAccess, + }, + ] + : n.action && !n.dismissed + ? [ + { + id: "action", + label: n.action.label, + onSelect: () => { + n.action!.onClick(); + dismissClient(n.id); + }, + }, + ] + : []; + return menuGroups(own, [dismissRow(() => removeClient(n.id))]); + } + const more = (menu: ContextMenuController, n: T) => { + const mine = menu.open && menu.target?.id === n.id; + return ( + + ); + }; + + // --- The page's own menu: the header's "…" ------------------------------------------------------ + const headerMenu = useContextMenu<"inbox">(); + const headerItems: ActionMenuItem[] = [ + { + id: "read-all", + label: t("inbox.markAllRead"), + icon: icon(CheckCheck), + disabled: !hasUnread || readAllMut.isPending, + onSelect: markAll, + }, + { + id: "clear", + label: t("inbox.clearAll"), + icon: icon(Trash2), + danger: true, + separatorBefore: true, + disabled: clearMut.isPending, + onSelect: () => void clearEverything(), + }, + ]; + return ( <> - {/* The inbox band (B4): its two actions, fixed while the list scrolls. No title card: the nav - rail and the tab title name the page, and the shell's hidden h1 names it to a screen reader. */} + {/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed + while the list scrolls. No title card: the nav rail and the tab title name the page, and the + shell's hidden h1 names it to a screen reader. */} {hasAny && ( -
+
- +
)} @@ -185,53 +360,112 @@ export default function NotificationsPanel() { {serverItems.length > 0 && (
{t("inbox.sectionSystem")} -
- {serverItems.map((n) => ( - readMut.mutate(n.id)} - onDismiss={() => dismissMut.mutate(n.id)} - onOpenScheduler={() => setPage("scheduler")} - onOpenDownloads={() => setPage("downloads")} - /> - ))} -
+ + {serverItems.map((n, i) => { + return ( + readMut.mutate(n.id)} + onDismiss={() => dismissMut.mutate(n.id)} + onOpenScheduler={openScheduler} + onOpenDownloads={openDownloads} + /> + ); + })} +
)} {clientItems.length > 0 && (
{t("inbox.sectionActivity")} -
- {clientItems.map((n) => ( - { - focusAccessRequestsTab(); - setPage("users"); - }} - onRemove={() => removeClient(n.id)} - /> - ))} -
+ + {clientItems.map((n, i) => { + return ( + removeClient(n.id)} + /> + ); + })} +
)}
)} + + + ); } +/** A server notification's link, from its type and typed payload (the row's button and its menu). */ +function serverKind(n: AppNotification): "scheduler" | "download" | null { + if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler"; + if (n.type === "download_done") return "download"; + return null; +} + +/** A row's end: its unread mark (server rows), its dismiss on hover (mouse; on touch it is in the + * "…"), then its "…". */ +function RowEnd({ + onRead, + onDismiss, + more, +}: { + onRead?: () => void; + onDismiss: () => void; + more: ReactNode; +}) { + const { t } = useTranslation(); + return ( +
+ {onRead && ( + + + + )} + + + + {more} +
+ ); +} + function NotificationRow({ n, t, + navIndex, + rowProps, + more, onRead, onDismiss, onOpenScheduler, @@ -239,6 +473,9 @@ function NotificationRow({ }: { n: AppNotification; t: TFunction; + navIndex: number; + rowProps: Record; + more: ReactNode; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; @@ -251,8 +488,8 @@ function NotificationRow({ // Known notification types are rendered from i18n (so they're trilingual) using the typed // payload; the server-stored title/body are an English fallback for any unknown type. const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; - const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string"; - const isDownloadDone = n.type === "download_done"; + const isScheduler = serverKind(n) === "scheduler"; + const isDownloadDone = serverKind(n) === "download"; const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string"; let title = n.title; let body = n.body; @@ -289,7 +526,9 @@ function NotificationRow({ } return (
@@ -318,6 +557,7 @@ function NotificationRow({ )}
-
- {!n.read && ( - - - - )} - - - -
+ ); } @@ -352,30 +584,34 @@ function NotificationRow({ function ClientActivityRow({ n, t, - onFind, - onRevert, + navIndex, + rowProps, + more, onFocusChannel, onReviewAccess, onRemove, }: { n: ClientNotif; t: TFunction; - onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; - onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; + navIndex: number; + rowProps: Record; + more: ReactNode; onFocusChannel: (name: string) => void; onReviewAccess: () => void; onRemove: () => void; }) { const { icon: Icon, color } = LEVEL_STYLE[n.level]; const needsAction = n.requiresInteraction && !n.dismissed; - const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; - const watched = n.meta?.kind === "video-watched" ? n.meta : null; + // A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads. + const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched"; const subscribed: ChannelSubscribedMeta | null = n.meta?.kind === "channel-subscribed" ? n.meta : null; const access = n.meta?.kind === "access-requests" ? n.meta : null; return (
@@ -396,49 +632,12 @@ function ClientActivityRow({ )}
- {hidden ? ( -
- - -
- ) : watched ? ( -
- - -
- ) : subscribed ? ( + {quiet ? null : subscribed ? (
- - - +
); } diff --git a/frontend/src/components/Scheduler.menu.dom.test.tsx b/frontend/src/components/Scheduler.menu.dom.test.tsx new file mode 100644 index 00000000..2207ef19 --- /dev/null +++ b/frontend/src/components/Scheduler.menu.dom.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +// +// E28 C1 on the Scheduler page: Run all and Pause stay in the status strip; the destructive Purge +// discovery left it for the strip's "…", which holds the page's whole list and asks before purging. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) })); +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); +vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); + +import Scheduler from "./Scheduler"; +import { api, type SchedulerStatus } from "../lib/api"; + +const status = (over: Partial = {}): SchedulerStatus => ({ + running_here: true, + enabled: true, + paused: false, + jobs: [ + { + id: "sync", + interval_minutes: 30, + next_run: null, + running: false, + status: "ok", + last_started: null, + last_finished: null, + last_result: null, + last_error: null, + progress: null, + }, + ], + queue: { + channels_recent_pending: 0, + channels_deep_pending: 0, + deep_eta_seconds: 0, + videos_pending_enrich: 0, + videos_pending_shorts: 0, + videos_live_refresh: 0, + }, + quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 }, + maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 }, + ...over, +}); + +let qc: QueryClient; +let current: SchedulerStatus; + +async function mount(initial = status()) { + current = initial; + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await screen.findByText("scheduler.maintenance.batchLabel"); +} + +beforeEach(() => { + vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current)); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + h.confirm.mockClear(); +}); + +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +async function openMenu() { + act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }))); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} +const choose = (label: string) => + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, + ); + +describe("Scheduler · the status strip's '…'", () => { + it("keeps Run all and Pause in sight and Purge discovery only in the menu", async () => { + await mount(); + expect(screen.getByRole("button", { name: /scheduler\.runAll$/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /scheduler\.pause$/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /scheduler\.purgeDiscovery/ })).toBeNull(); + await openMenu(); + expect(menuRows()).toEqual(["scheduler.runAll", "scheduler.pause", "scheduler.purgeDiscovery"]); + }); + + it("purges only after the confirm says yes", async () => { + const purge = vi.spyOn(api, "purgeDiscovery").mockResolvedValue({ + search_videos_deleted: 1, + videos_deleted: 0, + channels_deleted: 0, + } as never); + await mount(); + h.confirm.mockResolvedValueOnce(false); + await openMenu(); + choose("scheduler.purgeDiscovery"); + await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1)); + expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); + expect(purge).not.toHaveBeenCalled(); + await openMenu(); + choose("scheduler.purgeDiscovery"); + await waitFor(() => expect(purge).toHaveBeenCalledTimes(1)); + }); + + it("says why Run all is off while the scheduler is paused", async () => { + await mount(status({ paused: true })); + await openMenu(); + expect(menuRows()).toEqual([ + "scheduler.runAllscheduler.runAllPausedHint", + "scheduler.resume", + "scheduler.purgeDiscovery", + ]); + }); +}); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 840e982d..2b473b46 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -31,6 +31,10 @@ import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; import { SectionLabel } from "./ui/Headings"; +import { useContextMenu } from "./ui/useContextMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { MoreButton } from "./ui/MoreButton"; +import { type ActionMenuItem } from "./ui/ActionMenu"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -487,6 +491,10 @@ export default function Scheduler() { }, }); + // The page's own menu: the status strip's "…" (E28 C1). Run all and Pause stay in sight; the + // destructive Purge discovery lives only here, behind its confirm. + const pageMenu = useContextMenu<"scheduler">(); + if (q.isError && !data) return ( q.refetch()}> @@ -496,6 +504,45 @@ export default function Scheduler() { if (q.isLoading && !data) return ; if (!data) return
{t("common.somethingWrong")}
; + async function purge() { + const ok = await confirm({ + title: t("scheduler.purgeConfirmTitle"), + message: t("scheduler.purgeDiscoveryHint"), + confirmLabel: t("scheduler.purgeDiscovery"), + danger: true, + }); + if (ok) purgeMut.mutate(); + } + const paused = data.paused; + const pageItems: ActionMenuItem[] = [ + { + id: "run-all", + label: t("scheduler.runAll"), + icon: , + disabled: paused || runAllMut.isPending, + note: paused ? t("scheduler.runAllPausedHint") : undefined, + onSelect: () => runAllMut.mutate(), + }, + { + id: "pause", + label: paused ? t("scheduler.resume") : t("scheduler.pause"), + title: t("scheduler.pauseHint"), + icon: paused ? : , + disabled: pauseResume.isPending, + onSelect: () => pauseResume.mutate(paused), + }, + { + id: "purge", + label: t("scheduler.purgeDiscovery"), + title: t("scheduler.purgeDiscoveryHint"), + icon: , + danger: true, + separatorBefore: true, + disabled: purgeMut.isPending, + onSelect: () => void purge(), + }, + ]; + const quotaPct = data.quota.daily_budget ? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100)) : 0; @@ -539,25 +586,6 @@ export default function Scheduler() { {t("scheduler.runAll")} - - -