feat(nav): tabs, list views and name searches are named in the address

Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
This commit is contained in:
2026-10-26 01:13:39 +01:00
parent 91d36b3357
commit d4f24de4ba
34 changed files with 728 additions and 130 deletions
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
//
// The URL is the view: a page's name search is named in the address, so a reload or a bookmark opens
// the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only).
// The real navigation and the real stores, read through their hooks; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
import { NavigationProvider } from "./features/navigation/NavigationProvider";
import { ModuleStores } from "./features/navigation/ModuleStores";
import { useChannels } from "./modules/channels/channelsContext";
import { useFiles } from "./modules/files/filesContext";
import { usePlaylists } from "./modules/playlists/playlistsContext";
import { usePlex } from "./modules/plex/plexContext";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage";
const seen: { current: Record<string, string> } = { current: {} };
function Probe() {
const channels = useChannels();
const files = useFiles();
const playlists = usePlaylists();
const plex = usePlex();
useEffect(() => {
seen.current = {
channels: channels.search,
files: files.search,
playlists: playlists.search,
plex: plex.q,
plexField: plex.field,
};
});
return null;
}
beforeEach(() => {
localStorage.clear();
claimLocalData(3);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
function mountAt(url: string, page: string) {
window.history.replaceState({ sfPage: page }, "", url);
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<ModuleStores>
<Probe />
</ModuleStores>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
describe.each([
["channels", "cq", "tom"],
["files", "fq", "notes"],
["playlists", "plq", "jazz"],
["plex", "pq", "poirot"],
])("%s's name search", (page, key, value) => {
it("opens from the address, and stays in it", async () => {
mountAt(`/?page=${page}&${key}=${value}`, page);
await waitFor(() => expect(seen.current[page]).toBe(value));
expect(search().get(key)).toBe(value);
});
});
describe("Plex's search", () => {
it("is named in full, an empty one too: this browser remembers one a link must not inherit", async () => {
setAccountRaw(LS.plexQ, "poirot");
mountAt("/?page=plex&pq=", "plex");
await waitFor(() => expect(seen.current.plex).toBe(""));
expect(search().get("pq")).toBe("");
});
it("takes the field a link names, and remembers the link's search", async () => {
mountAt("/?page=plex&pq=hastings&pqf=people", "plex");
await waitFor(() => expect(seen.current.plexField).toBe("people"));
expect(seen.current.plex).toBe("hastings");
await waitFor(() => expect(getAccountRaw(LS.plexQ)).toBe("hastings"));
expect(search().get("pqf")).toBe("people");
});
});