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:
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user