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
@@ -20,6 +20,7 @@ import {
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type FileFolder, type LibraryFile } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { qk } from "../../lib/queryKeys";
import {
ALL_FILES,
FilesActionsContext,
@@ -172,6 +173,8 @@ const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// The page names its sort in the address: one case's sort must not open the next.
window.history.replaceState(null, "", "/");
mocks.confirm.mockClear();
mocks.keys = [];
mocks.fetching = false;
@@ -513,6 +516,8 @@ describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
fireEvent.click(nameHeader());
expect(screen.getByText("files.countFirst")).toBeTruthy();
cleanup();
// A fresh page: the address names the sort picked above.
window.history.replaceState(null, "", "/");
// A search with no sort of the server's: it answers the newest matches first.
mount(ALL_FILES, vi.fn(), "clip");
@@ -806,6 +811,67 @@ describe("Files · the view", () => {
});
});
// The URL is the view: the sort is named in the address, so a bookmark opens the list in its order.
// Always written, the list's own order too: a page opened without one takes the account's sort.
describe("Files · the sort in the address", () => {
const fsort = () => new URLSearchParams(window.location.search).get("fsort");
const order = () =>
[...document.querySelectorAll<HTMLElement>("[data-nav-index]")].map(
(r) => r.textContent?.match(/Clip \d/)?.[0],
);
const three = () => [
file(2, { ready_at: "2026-10-05T10:00:00Z" }),
file(3, { ready_at: "2026-10-12T10:00:00Z" }),
file(4, { ready_at: "2026-10-03T10:00:00Z" }),
];
// The account's sort, as the server says it.
function mountWithSort(sort: unknown) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { preferences: { filesSort: sort } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{ setSearch: vi.fn(), setView: vi.fn(), openFile: vi.fn(), consumeFocus: vi.fn() }}
>
<FilesStateContext.Provider
value={{ search: "", view: ALL_FILES, focus: null, arrived: null }}
>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
}
it("names the sort picked, and the list's own order", () => {
vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
mocks.files = three();
mocks.folders = [YT];
mount();
expect(fsort()).toBe("none");
fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ }));
expect(fsort()).toBe("added:asc");
});
it("opens in the order a link names, over the account's", () => {
mocks.files = three();
mocks.folders = [YT];
window.history.replaceState(null, "", "/?page=files&fsort=added:asc");
mountWithSort({ key: "added", dir: "desc" });
expect(order()).toEqual(["Clip 4", "Clip 2", "Clip 3"]);
expect(fsort()).toBe("added:asc");
});
it("a link to the list's own order is not sorted by the account's", () => {
mocks.files = three();
mocks.folders = [YT];
window.history.replaceState(null, "", "/?page=files&fsort=none");
mountWithSort({ key: "added", dir: "asc" });
expect(order()).not.toEqual(["Clip 4", "Clip 2", "Clip 3"]);
expect(fsort()).toBe("none");
});
});
describe("Files · the grid", () => {
// The layout is a pref, saved to the server; nothing here is about that.
const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);