feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)
Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails) now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…": play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions + PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two columns on a phone. The unused .reveal-idle rule is gone.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1: a Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
|
||||
// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles;
|
||||
// the card's hover "add" and the menu's "Add to playlist" open the same dialog.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { PlexCard } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
comfortPins: () => Promise.resolve({ items: [] }),
|
||||
plexPlaylists: () => Promise.resolve({ playlists: [] }),
|
||||
},
|
||||
};
|
||||
});
|
||||
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
||||
vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
||||
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePlexCardActions, type PlexActionHandlers } from "./usePlexCardActions";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const card = (extra: Partial<PlexCard>): PlexCard => ({
|
||||
id: "m1",
|
||||
type: "movie",
|
||||
title: "A film",
|
||||
thumb: "",
|
||||
status: "new",
|
||||
...extra,
|
||||
});
|
||||
|
||||
function Grid({
|
||||
c,
|
||||
handlers,
|
||||
pin,
|
||||
extra,
|
||||
}: {
|
||||
c: PlexCard;
|
||||
handlers: PlexActionHandlers;
|
||||
pin?: boolean;
|
||||
extra?: Parameters<typeof PlexMenu>[0]["extra"];
|
||||
}) {
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const actions = usePlexCardActions(handlers, menu.open);
|
||||
return (
|
||||
<>
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => handlers.onOpen(c)}
|
||||
onToggleWatched={handlers.onToggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
<PlexMenu menu={menu} actions={actions} pin={pin} extra={extra} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
||||
const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn() };
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FloatingTree>
|
||||
<Grid c={c} handlers={handlers} {...opts} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers };
|
||||
}
|
||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||
const rightClick = () =>
|
||||
fireEvent.contextMenu(screen.getByText("A film", { selector: ".line-clamp-2" }));
|
||||
const moreButtons = () => screen.getAllByRole("button", { name: "card.menu.more" });
|
||||
|
||||
describe("a Plex title's menu", () => {
|
||||
it("lists a movie's whole action list, in its groups", () => {
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
expect(labels()).toEqual([
|
||||
"plex.play",
|
||||
"plex.markWatched",
|
||||
"plex.playlistAdd.manage",
|
||||
"plex.info.title",
|
||||
"common.copy.title",
|
||||
"home.pin.add",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a show opens its page and has no watched, add or details rows", () => {
|
||||
setup(card({ type: "show" }));
|
||||
rightClick();
|
||||
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
});
|
||||
|
||||
it("the card's '…' opens the same menu, and says so", () => {
|
||||
setup(card({}));
|
||||
const hoverMore = moreButtons()[0]!;
|
||||
expect(hoverMore.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(hoverMore);
|
||||
expect(labels()).toContain("plex.info.title");
|
||||
expect(moreButtons()[0]!.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("each row fires the handler the card's own controls do", async () => {
|
||||
const { handlers } = setup(card({ status: "watched" }));
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.markUnwatched"));
|
||||
// A row acts a task after the menu closes (closeThenAct), so the assertions wait for it.
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onToggleWatched).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.info.title"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.play"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the title inside the click", () => {
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("common.copy.title"));
|
||||
expect(copyText).toHaveBeenCalledWith("A film");
|
||||
});
|
||||
|
||||
it("the hover 'add' and the menu row open the same playlist dialog", async () => {
|
||||
setup(card({}));
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlistAdd.manage" }));
|
||||
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
||||
cleanup();
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.playlistAdd.manage"));
|
||||
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an episode list carries no pin, and a host's own rows come last", async () => {
|
||||
const remove = vi.fn();
|
||||
setup(card({ type: "episode" }), {
|
||||
pin: false,
|
||||
extra: (c) => [{ id: "x", label: "remove", danger: true, onSelect: () => remove(c.id) }],
|
||||
});
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("home.pin.add");
|
||||
// The row names what it adds, as the episode card's own hover button does.
|
||||
expect(labels()).toContain("plex.playlist.addEpisode");
|
||||
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
|
||||
expect(labels()[labels().length - 1]).toBe("remove");
|
||||
fireEvent.click(item("remove"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user