feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)

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.
This commit is contained in:
2026-09-26 07:10:02 +02:00
parent c4917619ee
commit a2d17d693c
16 changed files with 1455 additions and 324 deletions
@@ -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<typeof import("react-i18next")>();
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<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import DownloadCenter from "./DownloadCenter";
const job = (
id: number,
status: DownloadJob["status"],
extra: Partial<DownloadJob> = {},
): 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(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
if (tab)
fireEvent.click(screen.getByRole("button", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('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<HTMLElement>("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<HTMLAnchorElement>(
'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],
]);
});
});