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
+16 -9
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// The Channels-manager content state (active tab + column sort + the column-filter map) round-trips
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the plexUrlState
// The Channels-manager content state (active tab + column sort + the column-filter map + the name
// search) round-trips through namespaced URL keys (ctab/csort/cf/cq) so a manager view is shareable, the plexUrlState
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
import { describe, expect, it } from "vitest";
import {
@@ -20,7 +20,7 @@ const RICH_FILTERS: FilterMap = {
tags: ["12", "34"],
};
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {}, search: "" };
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
@@ -32,12 +32,16 @@ function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
}
describe("channelsParamUpdates", () => {
it("writes no search for a blank one", () => {
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
});
it("emits nothing for the all-default state", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
});
it("clears every key when passed null (off-manager page)", () => {
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
});
it("emits only non-default keys", () => {
@@ -45,16 +49,16 @@ describe("channelsParamUpdates", () => {
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" },
search: "tom",
});
expect(u.ctab).toBe("hidden");
expect(u.csort).toBe("subscribers:desc");
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
expect(u.cq).toBe("tom");
});
it("drops empty filter entries so a cleared funnel emits no cf", () => {
expect(
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
).toBeNull();
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBeNull();
});
});
@@ -64,12 +68,14 @@ describe("round-trip", () => {
view: "blocked",
sort: { key: "priority", dir: "asc" },
filters: RICH_FILTERS,
search: "jazz",
};
const params = applyUpdates(channelsParamUpdates(state));
const back = channelsParamsToState(params, BASE);
expect(back.view).toBe("blocked");
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
expect(back.filters).toEqual(RICH_FILTERS);
expect(back.search).toBe("jazz");
// the array shape survives the JSON round-trip, not collapsed to a scalar
expect(Array.isArray(back.filters.tags)).toBe(true);
});
@@ -80,6 +86,7 @@ describe("round-trip", () => {
view: "discovery",
sort: null,
filters: { sync: "needsFull" },
search: "",
});
expect(back.view).toBe("discovery"); // from base, no ctab
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
@@ -88,7 +95,7 @@ describe("round-trip", () => {
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
const back = channelsParamsToState(params, BASE);
expect(back.view).toBe("subscribed"); // bogus tab → base
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty