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,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<typeof import("react-i18next")>()),
|
||||
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<typeof import("../lib/notifications")>()),
|
||||
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<typeof import("./ui/PageShell")>()),
|
||||
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<AppNotification> = {}) =>
|
||||
({
|
||||
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<ClientNotif> = {}) =>
|
||||
({
|
||||
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 = () => (
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationsPanel />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(tree());
|
||||
return { rerender: () => rerender(tree()) };
|
||||
}
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user