fix(ui): one membership menu for playlists, collections and tags

Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
This commit is contained in:
2026-10-08 21:18:48 +02:00
parent fadb4f17a9
commit 7e94da72b9
7 changed files with 416 additions and 220 deletions
+98 -2
View File
@@ -113,7 +113,7 @@ function setup(
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
const qc = new QueryClient();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={qc}>
@@ -122,7 +122,7 @@ function setup(
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
return { handlers, qc };
}
// The card root holds the menu's trigger props (right-click anywhere on it).
const rightClick = () =>
@@ -288,4 +288,100 @@ describe("a Plex title's menu — Add to collection", () => {
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
});
const checkRow = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
const idle = () =>
vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
const list = vi.spyOn(api, "plexCollectionsFor");
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
// The refetch after the write never answers: the tick must not wait for it.
list.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
await idle();
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("refreshes a show's own page too, whose collections come with the show", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cB", "Beta", false)],
});
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
const invalidate = vi.spyOn(qc, "invalidateQueries");
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await idle();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
});
it("is a named menu that says it is loading, not that there are no collections", async () => {
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
const menu = await vi.waitFor(() => {
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
expect(screen.getByText("common.loading")).toBeTruthy();
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
});
it("says a failed load as one, with a retry — not as no collections", async () => {
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
list.mockResolvedValue({ collections: [coll("cB", "Beta", false)] });
fireEvent.click(await screen.findByText("common.retry"));
await checkRow("Beta");
});
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
() => new Promise((resolve) => (answer = resolve)),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
name.focus();
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
const alpha = await checkRow("Alpha");
fireEvent.keyDown(name, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
name.focus();
fireEvent.keyDown(name, { key: "ArrowUp" });
expect(document.activeElement).toBe(await checkRow("Beta"));
});
});