From 8776586ca34750d781ed5d3b04f4fc5e537577fb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 23:46:39 +0200 Subject: [PATCH] fix(plex): a matched episode and a collection member offer Details in their menu Their titles open the item's page, a pointer-only door; the card menu is the keyboard's. --- .../components/PlexBrowse.playShow.dom.test.tsx | 15 +++++++++++++-- frontend/src/components/PlexBrowse.tsx | 3 ++- frontend/src/components/PlexInfo.dom.test.tsx | 14 ++++++++++++++ frontend/src/components/PlexInfo.tsx | 3 +++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/PlexBrowse.playShow.dom.test.tsx b/frontend/src/components/PlexBrowse.playShow.dom.test.tsx index 62d6374f..211c3374 100644 --- a/frontend/src/components/PlexBrowse.playShow.dom.test.tsx +++ b/frontend/src/components/PlexBrowse.playShow.dom.test.tsx @@ -21,6 +21,7 @@ vi.mock("./PlexPlayer", () => ({ vi.mock("./plexViews", async (importOriginal) => ({ ...(await importOriginal()), PlexShowView: ({ showId }: { showId: string }) =>
show page {showId}
, + PlexInfoView: ({ id }: { id: string }) =>
info page {id}
, })); import PlexBrowse from "./PlexBrowse"; @@ -53,12 +54,12 @@ const show: PlexCard = { resumable: true, }; -function mount() { +function mount(episodes: PlexCard[] = []) { vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [show], total: 1, offset: 0, - episodes: [], + episodes, } as never); const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" }, @@ -93,6 +94,16 @@ describe("PlexBrowse — a show's card in the grid", () => { expect(next).not.toHaveBeenCalled(); }); + // A matched episode's title opens its page, a pointer's door: its menu offers the same to the keyboard. + it("opens a matched episode's page from its menu, as its title does", async () => { + mount([{ id: "ep1", type: "episode", title: "Pilot", thumb: "", show_title: "Grimm" }]); + const section = (await screen.findByText("plex.unified.episodes")).parentElement!; + fireEvent.click(section.querySelector("button[aria-haspopup]")!); + const rows = await screen.findAllByRole("menuitem", { hidden: true }); + fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!); + expect(await screen.findByText("info page ep1")).toBeTruthy(); + }); + it("opens the show's page from its ⓘ, asking nothing", async () => { const { next } = mount(); fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" })); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 99d30288..c78ba193 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -392,8 +392,9 @@ export default function PlexBrowse() { { onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched }, menu.open, ); + // Details too: an episode's title opens its page, and the menu is that door for the keyboard. const epActions = usePlexCardActions( - { onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched }, + { onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched }, epMenu.open, ); // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 1f1cc806..5b10d258 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -411,4 +411,18 @@ describe("a collection strip's card", () => { fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!); await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true)); }); + + // Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard. + it("opens the member's page from its menu, as its title does", async () => { + const onOpenInfo = vi.fn(); + wrap( + , + ); + const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement! + .parentElement!; + fireEvent.click(strip.querySelector("button[aria-haspopup]")!); + const rows = await screen.findAllByRole("menuitem", { hidden: true }); + fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!); + await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2")); + }); }); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 260a8507..3256ea2f 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -285,10 +285,13 @@ export default function PlexInfo({ qc.invalidateQueries({ queryKey: qk.plexLibrary() }); }, }); + // Details wherever the member's title opens its page (not in the overlay): the menu is that door + // for the keyboard. const colActions = usePlexCardActions( { onOpen: (c) => onPlayItem(c.id), onRestart: (c) => onPlayItem(c.id, true), + onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined, onToggleWatched: toggleColWatched, }, colMenu.open,