Reworks the always-expanded filter chip walls into compact, Seerr-style "+ Add" → searchable-popover pickers (the user's space-saving ask), and adds the missing studio filter. - New MultiSelectChips: selected values as removable chips + a "+ Add" trigger opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox, arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary renders a 50-row cap + "N more — refine with search" footer (studios run to ~1500). Replaces the flat Genre + Age-rating chip lists. - New Studio filter via the same picker. Backend: /facets now aggregates the studio vocabulary (distinct studio + count, self-excluded like genres). The studio filter itself already existed (only addable from the detail page before). - Collection converted to the same "+ Add" pattern (CollectionPicker): a single-select popover with server-side search + the 50-cap; the selected collection still shows as a removable chip. Retires the always-inline search+list. New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults (en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) + test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402), Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search. Gate: vitest 342, backend lane 403.
89 lines
3.9 KiB
TypeScript
89 lines
3.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the
|
|
// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is
|
|
// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a
|
|
// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it).
|
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
import { FloatingTree } from "@floating-ui/react";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
|
});
|
|
|
|
import MultiSelectChips from "./MultiSelectChips";
|
|
|
|
afterEach(cleanup);
|
|
|
|
const OPTIONS = [
|
|
{ value: "Drama", count: 120 },
|
|
{ value: "Action", count: 90 },
|
|
{ value: "Comedy", count: 60 },
|
|
];
|
|
|
|
function wrap(ui: React.ReactElement) {
|
|
return render(<FloatingTree>{ui}</FloatingTree>);
|
|
}
|
|
|
|
describe("MultiSelectChips", () => {
|
|
it("renders selected values as removable chips and removes on ✕", () => {
|
|
const onChange = vi.fn();
|
|
wrap(
|
|
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
|
);
|
|
expect(screen.getByText("Drama")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
|
|
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
|
|
});
|
|
|
|
it("keeps the option list unmounted until the trigger is clicked", () => {
|
|
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
|
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull();
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
|
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
|
|
});
|
|
|
|
it("filters the options by the search query", () => {
|
|
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
|
fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } });
|
|
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
|
|
expect(items.length).toBe(1);
|
|
expect(items[0]?.textContent).toContain("Comedy");
|
|
});
|
|
|
|
it("toggles an option on with its value added to the selection", () => {
|
|
const onChange = vi.fn();
|
|
wrap(
|
|
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
|
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
|
|
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
|
|
});
|
|
|
|
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
|
|
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
|
|
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
|
|
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
|
|
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
|
|
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
|
});
|
|
});
|