refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,481 @@
// @vitest-environment jsdom
//
// The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
//
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
// bookkeeping.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, render as rtlRender, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
// import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a
// wholesale mock throws before any test runs.
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import { api } from "../../lib/api";
import { PAGE_KEY } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// The provider reads the signed-in account (the tab title) from the `me` cache.
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
const settle = () =>
act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
* land its popstate at once. */
async function openOverlay() {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
const holder: { unmount?: () => void } = {};
const onClose = vi.fn(() => holder.unmount?.());
const layer = renderHook(() => useBackToClose(onClose));
holder.unmount = layer.unmount;
await settle();
return { back, onClose };
}
beforeEach(() => {
qc = new QueryClient();
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
localStorage.clear();
claimLocalData(1); // the account's page memory
window.history.replaceState({ sfPage: "feed" }, "", "/");
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("NavigationProvider · the tab title", () => {
// Prod UAT 2026-09-19: signed out, the tab read the Home page's question over the sign-in form.
it("is the brand alone while nobody is signed in, the page's name once someone is", async () => {
window.history.replaceState({ sfPage: "home" }, "", "/?page=home");
qc.setQueryData(qk.me(), null);
render();
await settle();
expect(document.title).toBe("Siftlode");
act(() => {
qc.setQueryData(qk.me(), { id: 1 });
});
await settle();
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
});
// The Feed's tab read a bare "Siftlode" — the one page that named no place.
it("names the Feed like every other page", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
render();
await settle();
expect(document.title).toBe("header.account.feed · Siftlode");
});
// A channel opened from a link was "Channels · Siftlode", the same channel opened in the
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
function View({ name }: { name: string | null }) {
return name === null ? null : <PageHeading>{name}</PageHeading>;
}
const view = rtlRender(<View name="" />, { wrapper });
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
view.rerender(<View name="Nicolas Cage" />);
await settle();
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
view.rerender(<View name={null} />);
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode");
});
it("takes a heading's tabTitle over its text", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
rtlRender(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { wrapper });
await settle();
expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
});
});
describe("NavigationProvider · setPage's same-page guard", () => {
it("pushes an entry for a real page change", () => {
const { result } = render();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
});
it("leaves every page's own URL keys behind, the activity page's included", () => {
window.history.replaceState(
{ sfPage: "activity" },
"",
"/?page=activity&at=logs&aw=24h&au=none&aq=x&rjob=backfill&rst=error&aact=config_set" +
"&lgrp=error&lreq=req-abcdef12&lrun=7&pscope=movie&ctab=hidden&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(push).not.toHaveBeenCalled();
});
it("still navigates to the current page when a channel page is over it", () => {
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render();
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
// false and this never reaches the clause it is named for — the mistake a review caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's default), not "feed", so
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page;
expect(current).toBe("home");
act(() => result.current.openChannel("UC1", "A channel"));
expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(current));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toBeNull();
});
});
describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them.
it("lands on the feed when the entry names a page this build does not have", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "messages" }, "", "/?page=messages&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "messages" } }));
});
expect(result.current.page).toBe("feed");
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.location.search).toBe("?keep=1");
});
it("lands where a retired page's content went, and the entry names that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } }));
});
expect(result.current.page).toBe("files");
expect(window.history.state).toMatchObject({ sfPage: "files" });
expect(window.location.search).toBe("?page=files&keep=1");
});
// The audit log and the server log became the activity page's tabs: an old link, bookmark or entry
// opens that tab, with the filters it carried.
it("opens a retired page's tab on the page that replaced it", () => {
window.history.replaceState(null, "", "/?page=audit&keep=1");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&keep=1&at=admin");
});
it("keeps a retired page's own filter as it opens its tab", () => {
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("at"), url.get("lreq")]).toEqual([
"activity",
"logs",
"req-00000009",
]);
});
it("carries an old server-log link's account, text and range onto the page's own filters", () => {
window.history.replaceState(null, "", "/?page=logs&lu=3&lq=boom&lwin=1h");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("at"), url.get("au"), url.get("aq"), url.get("aw")]).toEqual([
"logs",
"3",
"boom",
"1h",
]);
// …and the dead keys leave the address bar instead of riding along to every page after.
expect(["lu", "lq", "lwin"].filter((k) => url.has(k))).toEqual([]);
});
it("an old server-log link without a range keeps the one it meant", () => {
window.history.replaceState(null, "", "/?page=logs");
render();
expect(new URLSearchParams(window.location.search).get("aw")).toBe("24h");
});
it("a remembered retired page opens its tab", () => {
qc.setQueryData(qk.me(), {
id: 1,
role: "admin",
plex_enabled: true,
preferences: { startOnHome: false },
});
setAccountRaw(PAGE_KEY, "logs");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
});
// Start-on-Home is the account's preference. An opt-out this browser kept before the server held it
// (the string "0") still counts, and reaches the server for the account's other devices.
it("takes this browser's earlier opt-out of Home, and saves it to the account", () => {
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true, preferences: {} });
setAccountRaw(LS.startOnHome, "0");
setAccountRaw(PAGE_KEY, "plex");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(save).toHaveBeenCalledWith({ startOnHome: false });
});
it("opens a retired page's tab when Back lands on its entry", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "audit" }, "", "/?page=audit");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "audit" } }));
});
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&at=admin");
});
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
it("does not put back the focus stamp the landing spent", () => {
render();
const st = { sfPage: "messages", sfNav: { list: 0, lists: 1 } };
act(() => {
window.history.replaceState(st, "", "/?page=messages");
window.dispatchEvent(new PopStateEvent("popstate", { state: st }));
});
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.history.state).not.toHaveProperty("sfNav");
});
});
describe("NavigationProvider · a page the account cannot open", () => {
const member = { id: 1, role: "user", plex_enabled: true };
// A member following a link to an admin page saw the feed with no rail row current: the page
// stayed "scheduler" while only the content fell back.
it("lands on the feed, and the address bar says so", () => {
qc.setQueryData(qk.me(), member);
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render();
expect(result.current.page).toBe("feed");
expect(window.location.search).toBe("?keep=1");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
});
it("lands on the feed when Back reaches an entry naming one", () => {
qc.setQueryData(qk.me(), member);
const { result } = render();
act(() => result.current.setPage("files"));
act(() => {
window.history.replaceState({ sfPage: "config" }, "", "/?page=config");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "config" } }));
});
expect(result.current.page).toBe("feed");
});
it("leaves the page it is on once the account loses it", async () => {
window.history.replaceState(null, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(result.current.page).toBe("feed");
});
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
// feed was already on screen), and a later grant switched the page unasked.
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(window.location.search).toBe("");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => {
qc.setQueryData(qk.me(), { ...member, role: "admin" });
});
await settle();
expect(result.current.page).toBe("feed");
});
it("closes a channel page open over the lost page, on screen and in the entry", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toContain("chan=UC1");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe("");
expect(window.history.state?._chan).toBeUndefined();
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
// `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so
// over an open player it must wait for the push, and with nothing to push it still runs.
it("runs setPage's `then` after the push, on the entry the push made", async () => {
const { result } = render();
await openOverlay();
const seen: unknown[] = [];
expect(result.current.page).not.toBe("plex");
act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage)));
expect(seen).toEqual([]);
await settle();
await settle();
expect(seen).toEqual(["plex"]);
act(() => result.current.setPage("plex", () => seen.push("again")));
expect(seen).toEqual(["plex", "again"]);
});
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(result.current.page));
await settle();
await settle();
expect(onClose).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
// The player's channel link and the palette's channel rows.
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.openChannel("UC1", "A channel"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
expect(window.history.state).toMatchObject({ _chan: "UC1" });
});
});