feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)

- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
This commit is contained in:
2026-09-26 05:56:07 +02:00
parent b0e51c6f89
commit 1b946113c6
27 changed files with 1354 additions and 293 deletions
@@ -0,0 +1,190 @@
// @vitest-environment jsdom
//
// A subscribed channel's ONE action list (E28 C1): what the manager row's "…" (and its right-click,
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const h = vi.hoisted(() => ({
notify: vi.fn(),
confirm: vi.fn(async () => true),
role: "user",
canWrite: true,
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel } from "../lib/api";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.notify.mockReset();
h.confirm.mockReset();
h.confirm.mockImplementation(async () => true);
h.role = "user";
h.canWrite = true;
});
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
id: "UC1",
title: "Chan",
handle: "chan",
thumbnail_url: null,
subscriber_count: null,
video_count: null,
description: null,
stored_videos: 0,
last_video_at: null,
total_duration_seconds: 0,
priority: 0,
hidden: false,
deep_requested: false,
deep_in_queue: false,
tag_ids: [],
details_synced: false,
recent_synced: false,
backfill_done: false,
...over,
});
const onView = vi.fn();
function mount() {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(() => useChannelActions({ onView, onOpenWizard: () => {} }), { wrapper });
}
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
const ids = (items: { id: string }[]) => items.map((i) => i.id);
const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
describe("useChannelActions — the menu", () => {
it("lists open, pin, the YouTube + copy group, full history, hide and unsubscribe", () => {
const items = rowsFor(ch());
expect(ids(items)).toEqual([
"open",
"pin",
"open-youtube",
"copy-text",
"copy-link",
"backfill",
"hide",
"unsubscribe",
]);
// Groups: YouTube/copy, the channel's own settings, then the destructive row alone.
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
"open-youtube",
"backfill",
"unsubscribe",
]);
expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true });
expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name");
});
it("has no unsubscribe without the YouTube write scope", () => {
h.canWrite = false;
expect(ids(rowsFor(ch()))).not.toContain("unsubscribe");
});
it("opens the channel page, and YouTube in a new tab inside the click", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
row(ch(), "open").onSelect!();
expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }));
const yt = row(ch(), "open-youtube");
expect("inGesture" in yt && yt.inGesture).toBe(true);
yt.onSelect!();
expect(open).toHaveBeenCalledWith(
"https://www.youtube.com/@chan",
"_blank",
"noopener,noreferrer",
);
});
});
describe("useChannelActions — full history", () => {
it("asks for it, and takes a pending ask back", async () => {
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
const ask = row(ch(), "backfill");
expect(ask.label).toBe("channels.row.backfillThis");
act(() => ask.onSelect!());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true }));
const cancel = row(ch({ deep_requested: true }), "backfill");
expect(cancel.label).toBe("channels.row.cancelBackfill");
act(() => cancel.onSelect!());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false }));
});
it("stays in place, off, with the reason, once it is in or on its way for everyone", () => {
expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({
disabled: true,
note: "channels.row.fullHint",
});
expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({
disabled: true,
note: "channels.row.queuedByOtherHint",
});
});
it("gives an admin the reset, behind a confirm that names the reset", async () => {
h.role = "admin";
const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never);
const it_ = row(ch({ backfill_done: true }), "backfill");
expect(it_).toMatchObject({ label: "channels.row.resetBackfill" });
expect(it_.disabled).toBeFalsy();
await act(async () => it_.onSelect!());
expect(h.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: "channels.row.resetBackfill",
confirmLabel: "channels.row.resetBackfill",
}),
);
await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1"));
});
});
describe("useChannelActions — hide and unsubscribe", () => {
it("hides without a confirm, and the toast's Undo shows the channel again", async () => {
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
const { result } = mount();
act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!());
await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(h.confirm).not.toHaveBeenCalled();
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
const toast = h.notify.mock.calls[0]![0] as {
message: string;
action: { onClick: () => void };
};
expect(toast.message).toBe("channels.notify.hidden");
act(() => toast.action.onClick());
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
});
it("unsubscribes only after the danger confirm says yes", async () => {
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
const { result } = mount();
const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!;
h.confirm.mockImplementation(async () => false);
await act(async () => unsubRow().onSelect!());
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(unsub).not.toHaveBeenCalled();
h.confirm.mockImplementation(async () => true);
await act(async () => unsubRow().onSelect!());
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
});
});