fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic

Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
This commit is contained in:
2026-10-23 13:45:40 +02:00
parent ad05932217
commit b443ac0c84
51 changed files with 561 additions and 290 deletions
@@ -1,14 +1,20 @@
// @vitest-environment jsdom
//
// Plex's saved views: the same default-view seed as the feed's (parity — Plex once never applied its
// default at all), asked for only while the Plex page is on screen. Plex's filters are mocked so we can
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
// default at all), asked for only while the Plex page is on screen, and an applied view kept across a
// visit to another page (the store is Plex's own, mounted at the root). Plex's filters are mocked so we
// can assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
// QueryClient.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
const h = vi.hoisted(() => ({
page: "plex" as string,
filters: {} as Record<string, unknown>,
setPlexFilters: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -18,7 +24,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlex: () => ({ filters: h.filters }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
@@ -28,6 +34,7 @@ import { usePlexSavedViews } from "./plexSavedViews";
import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
import type { SavedViewsContext } from "../../features/filters/savedViewsContext";
const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({
@@ -39,9 +46,15 @@ const view = (over: Partial<SavedView>): SavedView => ({
...over,
});
// The store as the last commit left it, for the case that drives it directly.
let latest: SavedViewsContext | null = null;
const keep = (store: SavedViewsContext) => {
latest = store;
};
function Probe() {
const { loading } = usePlexSavedViews();
return <div data-testid="loading">{String(loading)}</div>;
const store = usePlexSavedViews();
useEffect(() => keep(store));
return <div data-testid="loading">{String(store.loading)}</div>;
}
function mount(views: SavedView[]) {
@@ -65,7 +78,9 @@ beforeEach(() => {
localStorage.clear();
claimLocalData(ID);
h.page = "plex";
h.filters = {};
h.setPlexFilters.mockClear();
latest = null;
});
afterEach(() => {
cleanup();
@@ -98,4 +113,22 @@ describe("Plex's saved views", () => {
rerender();
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
});
it("keeps the view you applied, edited since, when you come back from another page", async () => {
const { rerender } = mount([
view({ id: 5, name: "comedies", filters: { genres: ["Comedy"] } }),
]);
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
act(() => latest!.apply(latest!.views[0]!));
h.filters = { genres: ["Comedy", "Drama"] }; // an edit since: the view is now shown as changed
rerender();
expect(latest!.displayView?.id).toBe(5);
expect(latest!.isDirty).toBe(true);
h.page = "home";
rerender();
h.page = "plex";
rerender();
expect(latest!.displayView?.id).toBe(5);
expect(latest!.isDirty).toBe(true);
});
});
+2 -6
View File
@@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { type MediaSourceKind } from "../../lib/mediaSource";
import { useDebounced } from "../../lib/useDebounced";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
@@ -39,7 +38,7 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
export default function PlexSidebar() {
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("plex");
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("plex");
const { t, i18n } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
@@ -51,9 +50,6 @@ export default function PlexSidebar() {
// They reach the shared views UI in this rail through the scope below.
const savedViews = usePlexSavedViews();
const { openModal, modalOpen } = savedViews;
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
// never refetches. collectionMode stays IN the key: any/all changes the result set.
const { collectionTitles: _cts, ...facetKey } = filters;
@@ -298,7 +294,7 @@ export default function PlexSidebar() {
return (
<SavedViewsScope value={savedViews}>
<FilterRail
kind={source}
onResetLayout={onResetLayout}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
+1 -2
View File
@@ -1,5 +1,4 @@
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
// The Plex module's public face: its store, mounted at the root and read app-wide.
export { usePlexSavedViews } from "./plexSavedViews";
export { usePlex, usePlexActions } from "./plexContext";
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
export { plexModule } from "./module";
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { vocabI18nKey } from "../../lib/vocab";
import { type ActiveFilter } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../../lib/api";
import { PLEX_SORT_FALLBACK } from "../../lib/plexSort";
import { usePlex, usePlexActions } from "./plexContext";
@@ -12,10 +12,8 @@ import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
// the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse
// the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
// it's an always-visible control above the grid, like the feed. Lives here, not in lib, because
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
type TFn = (key: string, options?: Record<string, unknown>) => string;
// it's an always-visible control above the grid, like the feed. The hook lives here because it reads
// the Plex store; the pure builder it calls is lib/plexActiveFilters (lib stays store-free).
export function usePlexActiveFilters(): ActiveFilter[] {
const { show, filters } = usePlex();
const { setShow, setFilters } = usePlexActions();
@@ -35,126 +33,6 @@ export function usePlexActiveFilters(): ActiveFilter[] {
);
}
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured
// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row.
export function buildPlexActiveFilters(
filters: PlexFilters,
show: string,
patch: (p: Partial<PlexFilters>) => void,
setShow: (s: string) => void,
t: TFn,
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
* the saved-views manager, which describes a stored view and has only the captured titles. */
collectionTitles?: Record<string, string>,
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
collectionsPending = false,
): ActiveFilter[] {
const out: ActiveFilter[] = [];
const has = (x: number | null | undefined) => x != null;
for (const g of filters.genres) {
out.push({
key: `genre:${g}`,
label: g,
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
});
}
for (const r of filters.contentRatings) {
out.push({
key: `contentRating:${r}`,
label: vocabI18nKey("certificate", r)
? t(vocabI18nKey("certificate", r), { defaultValue: r })
: r,
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
});
}
if (has(filters.yearMin) || has(filters.yearMax)) {
const range =
has(filters.yearMin) && has(filters.yearMax)
? `${filters.yearMin}–${filters.yearMax}`
: has(filters.yearMin)
? `≥${filters.yearMin}`
: `≤${filters.yearMax}`;
out.push({
key: "year",
label: `${t("plex.filter.year")}: ${range}`,
remove: () => patch({ yearMin: null, yearMax: null }),
});
}
if (has(filters.ratingMin)) {
out.push({
key: "ratingMin",
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
remove: () => patch({ ratingMin: null }),
});
}
if (has(filters.durationMin) || has(filters.durationMax)) {
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
const u = t("plex.filter.durationUnit");
const range =
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
out.push({
key: "duration",
label: `${t("plex.filter.duration")}: ${range}`,
remove: () => patch({ durationMin: null, durationMax: null }),
});
}
if (filters.addedWithin) {
out.push({
key: "added",
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
remove: () => patch({ addedWithin: "" }),
});
}
for (const d of filters.directors) {
out.push({
key: `director:${d}`,
label: d,
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
});
}
for (const a of filters.actors) {
out.push({
key: `actor:${a}`,
label: a,
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
});
}
for (const s of filters.studios) {
out.push({
key: `studio:${s}`,
label: s,
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
});
}
for (const p of filters.playable) {
out.push({
key: `playable:${p}`,
label: t(`plex.filter.playableOpt.${p}`),
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
});
}
for (const k of filters.keywords) {
out.push({
key: `keyword:${k}`,
label: k,
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
});
}
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
if (show !== "all") {
out.push({
key: "show",
label: t("plex.filter.showOpt." + show),
remove: () => setShow("all"),
});
}
return out;
}
// The one "reset every Plex narrowing" callback, shared by the sidebar's Clear-filters and the chip
// row's Clear-all so the two can't drift: expanded filters + watch-state + sort all go back to default.
export function usePlexClearFilters(): () => void {
@@ -165,30 +43,3 @@ export function usePlexClearFilters(): () => void {
setSort(PLEX_SORT_FALLBACK);
};
}
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
// load), else by the ones the state carries, else the generic word.
function collectionChips(
filters: PlexFilters,
patch: (p: Partial<PlexFilters>) => void,
t: TFn,
collectionTitles: Record<string, string> | undefined,
pending: boolean,
): ActiveFilter[] {
return filters.collections.map((c) => ({
key: `collection:${c}`,
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
pending: pending && collectionTitles?.[c] === undefined,
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
// one (an empty `{}` still hashes differently from an absent key).
remove: () => {
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
patch({
collections: filters.collections.filter((x) => x !== c),
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
});
},
}));
}