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