fix(plex): address S3 review nits — tests + narrow-header wrap
- Add PlexScopeToggle.dom.test.tsx (renders the three scopes, current one pressed, click writes scope back to the provider) and usePlexActiveFilters.dom.test.tsx (emits filter + watch-state chips but never a sort chip, even with a non-default sort active) — the S3 relocation was untested. - Narrow-width header: make the header row flex-wrap so the scope toggle drops to a second line instead of crowding the flex-1 search box to zero width, and let the field-scope pill shrink/truncate. Desktop stays a single line (verified: 1280px header unchanged, search 237px, no wrap). The removed one-click sort-reset chip is intended (sort is an always-visible control now, like the Feed) — no change. FE suite 174 green; typecheck + eslint clean.
This commit is contained in:
@@ -94,7 +94,7 @@ export default function Header({
|
||||
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
|
||||
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<ModuleName
|
||||
label={t(moduleLabelKey[page])}
|
||||
labels={moduleLabels}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
|
||||
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
|
||||
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
|
||||
// floating-ui), so no observer stubs are needed.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const plexState = { scope: "both" } as { scope: string };
|
||||
const setScope = vi.fn();
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlex: () => plexState,
|
||||
usePlexActions: () => ({ setScope }),
|
||||
}));
|
||||
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
setScope.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlexScopeToggle", () => {
|
||||
it("renders the three library scopes, current one pressed", () => {
|
||||
plexState.scope = "movie";
|
||||
render(<PlexScopeToggle />);
|
||||
const group = screen.getByRole("group", { name: "plex.filter.scope" });
|
||||
const btns = [...group.querySelectorAll("button")];
|
||||
expect(btns.map((b) => b.textContent)).toEqual([
|
||||
"plex.filter.scopeOpt.both",
|
||||
"plex.filter.scopeOpt.movie",
|
||||
"plex.filter.scopeOpt.show",
|
||||
]);
|
||||
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
});
|
||||
|
||||
it("writes the picked scope back to the provider", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||
expect(setScope).toHaveBeenCalledWith("show");
|
||||
});
|
||||
});
|
||||
@@ -117,10 +117,10 @@ export default function PlexSearchBox() {
|
||||
leading={
|
||||
<ActionMenu
|
||||
ariaLabel={t("plex.searchField.aria")}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||
trigger={
|
||||
<>
|
||||
<span className="max-w-28 truncate">{pillLabel}</span>
|
||||
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Guards the S3 behavior change: the active-filter chip row (usePlexActiveFilters) still emits chips
|
||||
// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip —
|
||||
// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and
|
||||
// the provider is mocked so the hook can be driven with a non-default sort present.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { EMPTY_PLEX_FILTERS } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const plexState = {
|
||||
scope: "both",
|
||||
show: "watched",
|
||||
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
|
||||
sort: { key: "year", dir: "asc" },
|
||||
filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] },
|
||||
};
|
||||
const setShow = vi.fn();
|
||||
const setFilters = vi.fn();
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlex: () => plexState,
|
||||
usePlexActions: () => ({ setShow, setFilters }),
|
||||
}));
|
||||
|
||||
import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||
|
||||
describe("usePlexActiveFilters", () => {
|
||||
it("emits filter + watch-state chips but never a sort chip", () => {
|
||||
const { result } = renderHook(() => usePlexActiveFilters());
|
||||
const keys = result.current.map((c) => c.key);
|
||||
// The in-panel narrowings still surface...
|
||||
expect(keys).toContain("genre:Action");
|
||||
expect(keys).toContain("show");
|
||||
// ...but sort does not, even though a non-default sort is active in the shared state.
|
||||
expect(keys).not.toContain("sort");
|
||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user