feat(channels): shareable manager view in the URL — tab+sort+column-filters (S8)

The Channels-manager content state now round-trips through the address bar,
so a manager view is bookmarkable/shareable — the S4 pattern applied to the
last content page the audit flagged.

- New lib/channelsUrlState.ts (channelsParamUpdates / channelsParamsToState /
  hasChannelsParams) under NAMESPACED keys ctab/csort/cf — channels-unique
  like ?v=/?pl/pscope, so no other provider reads or clears them and the
  feed's URL-stripping can't delete them. The mixed scalar/array FilterMap
  (sync/priority scalars + tags multi) rides in `cf` as a JSON blob so both
  shapes round-trip exactly. Only non-default values emit.
- ChannelsProvider mirrors {view, sort, filters} via useUrlMirror, page-gated
  on channels (off-page the keys clear; localStorage still holds the state and
  it re-mirrors on return). A shared link wins over stored state: readChannels
  UrlSeed captured once, applied before paint through the slice setters (so the
  account-pin re-hydrate sees them dirty and won't reload storage over the
  shared view), then persisted to the account once pinned so it survives a
  later plain reload — mirrors PlexProvider's seed/rehydrate. The name search
  stays ephemeral (out of the shared contract).
- NavigationProvider's page-switch nulls ctab/csort/cf alongside the plex keys
  so switching modules resets to the manager root.

Tests: channelsUrlState.dom.test.ts (9 round-trip) + ChannelsProvider.dom
seed/mirror (2); the existing tab-history test's beforeEach now resets the
query string too (the mirror writes it). FE vitest 503→514. FE-only.
This commit is contained in:
2026-08-27 04:37:53 +02:00
parent 9cd4ae09d4
commit 7fbb996297
5 changed files with 320 additions and 5 deletions
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
//
// S8 — 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 S4/plexUrlState
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
import { describe, expect, it } from "vitest";
import {
channelsParamsToState,
channelsParamUpdates,
hasChannelsParams,
type ChannelsUrlState,
} from "./channelsUrlState";
import type { FilterMap } from "../components/tableHeader";
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
// an array (a multi column).
const RICH_FILTERS: FilterMap = {
sync: "needsFull",
priority: "3",
tags: ["12", "34"],
};
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(updates)) {
if (v != null) p.set(k, v);
}
return p;
}
describe("channelsParamUpdates", () => {
it("emits nothing for the all-default state", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
});
it("clears every key when passed null (off-manager page)", () => {
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
});
it("emits only non-default keys", () => {
const u = channelsParamUpdates({
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" },
});
expect(u.ctab).toBe("hidden");
expect(u.csort).toBe("subscribers:desc");
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
});
it("drops empty filter entries so a cleared funnel emits no cf", () => {
expect(
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
).toBeNull();
});
});
describe("round-trip", () => {
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
const state: ChannelsUrlState = {
view: "blocked",
sort: { key: "priority", dir: "asc" },
filters: RICH_FILTERS,
};
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);
// the array shape survives the JSON round-trip, not collapsed to a scalar
expect(Array.isArray(back.filters.tags)).toBe(true);
});
it("keeps base values for absent keys (partial link)", () => {
const params = new URLSearchParams("csort=title:asc");
const back = channelsParamsToState(params, {
view: "discovery",
sort: null,
filters: { sync: "needsFull" },
});
expect(back.view).toBe("discovery"); // from base, no ctab
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
expect(back.sort).toEqual({ key: "title", dir: "asc" });
});
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: {} });
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
});
});
describe("hasChannelsParams", () => {
it("detects any namespaced key", () => {
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
});
it("ignores the feed's and plex's keys", () => {
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
false,
);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
// rail search) and stays OUT of the shared contract.
import { parseSortState, type SortState } from "./columnSort";
import { type FilterMap } from "../components/tableHeader";
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
// raw provider SortState (healed against live columns only at the point of use), so the user's real
// preference round-trips through the URL.
export interface ChannelsUrlState {
view: string;
sort: SortState;
filters: FilterMap;
}
const KEYS = ["ctab", "csort", "cf"] as const;
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`). Column sort keys are simple words (no colon), so a plain
// split is safe. There is no default sort (fallback null), so any sort emits.
function encodeSort(s: SortState): string | null {
return s ? `${s.key}:${s.dir}` : null;
}
function decodeSort(raw: string): SortState {
const i = raw.indexOf(":");
const key = i === -1 ? raw : raw.slice(0, i);
const dir = i === -1 ? "asc" : raw.slice(i + 1);
return parseSortState({ key, dir });
}
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
// no `cf`.
function encodeFilters(map: FilterMap): string | null {
const clean: FilterMap = {};
for (const [k, v] of Object.entries(map)) {
if (Array.isArray(v) ? v.length : v) clean[k] = v;
}
return Object.keys(clean).length ? JSON.stringify(clean) : null;
}
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
// heals to empty (same discipline as useFilterState's storage hydrate).
function decodeFilters(raw: string): FilterMap {
try {
const v = JSON.parse(raw);
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
const out: FilterMap = {};
for (const [k, val] of Object.entries(v)) {
if (typeof val === "string") out[k] = val;
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
out[k] = val as string[];
}
return out;
} catch {
return {};
}
}
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
* re-mirrors on return). Feed the result into urlWithParams(). */
export function channelsParamUpdates(
state: ChannelsUrlState | null,
): Record<string, string | null> {
if (!state) return { ctab: null, csort: null, cf: null };
return {
ctab: state.view && state.view !== "subscribed" ? state.view : null,
csort: encodeSort(state.sort),
cf: encodeFilters(state.filters),
};
}
/** True if the query string carries any manager content key we recognize (a shared manager link). */
export function hasChannelsParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
export function channelsParamsToState(
params: URLSearchParams,
base: ChannelsUrlState,
): ChannelsUrlState {
const tab = params.get("ctab");
const csort = params.get("csort");
return {
view: tab && VIEWS.includes(tab) ? tab : base.view,
sort: csort != null ? (decodeSort(csort) ?? base.sort) : base.sort,
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
};
}