From d4f24de4ba37fc3af0bb8a2cc0e230ddcad2b18e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 26 Oct 2026 01:13:39 +0100 Subject: [PATCH] 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. --- frontend/src/components/ui/Tabs.tsx | 5 +- .../navigation/channelOverPage.dom.test.tsx | 31 ++++-- frontend/src/lib/adminUsersTab.ts | 4 +- frontend/src/lib/channelsUrlState.dom.test.ts | 25 +++-- frontend/src/lib/channelsUrlState.ts | 48 +++------- frontend/src/lib/configTab.ts | 3 + frontend/src/lib/filesUrlState.ts | 30 +++++- frontend/src/lib/filterMapParam.ts | 32 +++++++ frontend/src/lib/playlistsUrlState.ts | 43 +++++++++ frontend/src/lib/plexUrlState.ts | 26 +++++ frontend/src/lib/settingsTab.ts | 7 +- frontend/src/lib/storage.ts | 44 +++++++-- frontend/src/lib/tabState.dom.test.ts | 17 ++++ frontend/src/moduleSearch.url.dom.test.tsx | 95 +++++++++++++++++++ frontend/src/moduleUrl.contract.test.ts | 18 +++- .../src/modules/channels/ChannelsProvider.tsx | 32 ++++--- .../config/ConfigPanel.sections.dom.test.tsx | 23 +++++ frontend/src/modules/config/ConfigPanel.tsx | 22 +++-- frontend/src/modules/config/module.ts | 3 +- .../src/modules/files/Files.menu.dom.test.tsx | 66 +++++++++++++ frontend/src/modules/files/Files.tsx | 15 ++- frontend/src/modules/files/FilesProvider.tsx | 18 +++- .../modules/playlists/Playlists.dom.test.tsx | 50 ++++++++++ frontend/src/modules/playlists/Playlists.tsx | 28 ++++-- .../modules/playlists/PlaylistsProvider.tsx | 17 +++- frontend/src/modules/playlists/module.ts | 3 +- frontend/src/modules/plex/PlexProvider.tsx | 29 ++++-- frontend/src/modules/plex/module.ts | 4 +- .../src/modules/settings/SettingsPanel.tsx | 7 +- .../settings/SettingsPanel.url.dom.test.tsx | 70 ++++++++++++++ frontend/src/modules/settings/module.ts | 3 +- .../src/modules/users/AdminUsers.dom.test.tsx | 30 ++++++ frontend/src/modules/users/AdminUsers.tsx | 7 +- frontend/src/modules/users/module.ts | 3 +- 34 files changed, 728 insertions(+), 130 deletions(-) create mode 100644 frontend/src/lib/configTab.ts create mode 100644 frontend/src/lib/filterMapParam.ts create mode 100644 frontend/src/lib/playlistsUrlState.ts create mode 100644 frontend/src/moduleSearch.url.dom.test.tsx create mode 100644 frontend/src/modules/settings/SettingsPanel.url.dom.test.tsx diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index bc4b8abc..15afb988 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome"; // Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px: // the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there // it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with -// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats): -// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every +// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in +// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on +// the first sub-tab. Not every // caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page // and the player settings start on their first tab each time. // diff --git a/frontend/src/features/navigation/channelOverPage.dom.test.tsx b/frontend/src/features/navigation/channelOverPage.dom.test.tsx index 7b76ea1a..b8cfb360 100644 --- a/frontend/src/features/navigation/channelOverPage.dom.test.tsx +++ b/frontend/src/features/navigation/channelOverPage.dom.test.tsx @@ -49,20 +49,30 @@ function Probe() { channels: () => channels.setView("hidden"), files: () => files.setView({ kind: "folder", id: 3 }), playlists: () => playlists.setSelectedId(5), + "plex search": () => plex.setQ("poirot"), + "channels search": () => channels.setSearch("tom"), + "files search": () => files.setSearch("notes"), + "playlists search": () => playlists.setSearch("jazz"), }, }; }); return null; } -// What each page's change above writes, once the page is what the address shows. -const WRITTEN: Record = { - feed: ["sort", "oldest"], - plex: ["pscope", "movie"], - channels: ["ctab", "hidden"], - files: ["folder", "3"], - playlists: ["pl", "5"], +// What each change above writes, once its page is what the address shows: [page, key, value]. A +// search is written once the typing pauses. +const WRITTEN: Record = { + feed: ["feed", "sort", "oldest"], + plex: ["plex", "pscope", "movie"], + channels: ["channels", "ctab", "hidden"], + files: ["files", "folder", "3"], + playlists: ["playlists", "pl", "5"], + "plex search": ["plex", "pq", "poirot"], + "channels search": ["channels", "cq", "tom"], + "files search": ["files", "fq", "notes"], + "playlists search": ["playlists", "plq", "jazz"], }; +const PAUSE = () => new Promise((r) => setTimeout(r, 400)); beforeEach(() => { localStorage.clear(); @@ -75,8 +85,9 @@ afterEach(() => { const search = () => new URLSearchParams(window.location.search); -describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => { +describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => { it("keeps the page's keys off its address, and they come back when it closes", async () => { + const [page, key, value] = WRITTEN[change]!; // The page named even for the feed: a bare address opens on Home (the account's default). window.history.replaceState({ sfPage: page }, "", `/?page=${page}`); render( @@ -89,8 +100,8 @@ describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => { , ); act(() => probe.current!.nav.openChannel("UC1", "One")); - act(() => probe.current!.change[page]!()); - const [key, value] = WRITTEN[page]!; + act(() => probe.current!.change[change]!()); + await act(PAUSE); expect(search().get(key)).toBeNull(); expect(search().get("chan")).toBe("UC1"); act(() => probe.current!.nav.closeChannel()); diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts index 1aa799e6..5c93cd33 100644 --- a/frontend/src/lib/adminUsersTab.ts +++ b/frontend/src/lib/adminUsersTab.ts @@ -2,12 +2,14 @@ import { LS, setTabRaw } from "./storage"; // The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to // pre-select that tab before navigating there (a notification's "Review" link uses it — -// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation). +// useTabPersistedState reads the key on mount, and the page on screen hears it). // // Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull // it back into the main bundle and defeat the code-split. export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; +// The address-bar key the page mirrors its tab to. +export const USERS_TAB_URL_KEY = "utab"; const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { diff --git a/frontend/src/lib/channelsUrlState.dom.test.ts b/frontend/src/lib/channelsUrlState.dom.test.ts index 852a2d4c..83fad6f1 100644 --- a/frontend/src/lib/channelsUrlState.dom.test.ts +++ b/frontend/src/lib/channelsUrlState.dom.test.ts @@ -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): URLSearchParams { @@ -32,12 +32,16 @@ function applyUpdates(updates: Record): 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 diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts index e9e64c3a..1bec548f 100644 --- a/frontend/src/lib/channelsUrlState.ts +++ b/frontend/src/lib/channelsUrlState.ts @@ -3,10 +3,11 @@ // urlState and Plex's plexUrlState. 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. +// emitted, so URLs stay clean. The name search (`cq`) is part of the view like the rest: a bookmark of a +// searched list opens searched. import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; import { type FilterMap } from "./tableColumns"; +import { decodeFilterMap, encodeFilterMap } from "./filterMapParam"; // The content state this 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 @@ -15,43 +16,16 @@ export interface ChannelsUrlState { view: string; sort: SortState; filters: FilterMap; + search: string; } -export const CHANNELS_KEYS = ["ctab", "csort", "cf"] as const; +export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const; const KEYS = CHANNELS_KEYS; const VIEWS = ["subscribed", "discovery", "hidden", "blocked"]; // Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default -// sort here (fallback null), so any sort emits — no default short-circuit needed. -// 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 {}; - } -} +// sort here (fallback null), so any sort emits — no default short-circuit needed. The filter map rides +// as one JSON value (lib/filterMapParam). /** 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 @@ -60,11 +34,12 @@ function decodeFilters(raw: string): FilterMap { export function channelsParamUpdates( state: ChannelsUrlState | null, ): Record { - if (!state) return { ctab: null, csort: null, cf: null }; + if (!state) return { ctab: null, csort: null, cf: null, cq: null }; return { ctab: state.view && state.view !== "subscribed" ? state.view : null, csort: encodeSortParam(state.sort), - cf: encodeFilters(state.filters), + cf: encodeFilterMap(state.filters), + cq: state.search.trim() || null, }; } @@ -84,6 +59,7 @@ export function channelsParamsToState( return { view: tab && VIEWS.includes(tab) ? tab : base.view, sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort, - filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters, + filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters, + search: params.get("cq") ?? base.search, }; } diff --git a/frontend/src/lib/configTab.ts b/frontend/src/lib/configTab.ts new file mode 100644 index 00000000..897fc28f --- /dev/null +++ b/frontend/src/lib/configTab.ts @@ -0,0 +1,3 @@ +// The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other +// pages' keys, so the page's definition can declare it without importing the lazy page. +export const CONFIG_TAB_KEY = "cfgtab"; diff --git a/frontend/src/lib/filesUrlState.ts b/frontend/src/lib/filesUrlState.ts index 4e964e01..9735b8ed 100644 --- a/frontend/src/lib/filesUrlState.ts +++ b/frontend/src/lib/filesUrlState.ts @@ -1,6 +1,30 @@ +import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; + // The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in -// modules/files/filesContext) and the file it turns to once (`?item=`). Here, beside the other pages' -// key lists, so the page's definition can declare them without importing its own folder. +// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the +// sort (`?fsort=`). Here, beside the other pages' key lists, so the page's definition can declare them +// without importing its own folder. export const FILES_FOLDER_KEY = "folder"; export const FILES_ITEM_KEY = "item"; -export const FILES_KEYS = [FILES_FOLDER_KEY, FILES_ITEM_KEY] as const; +export const FILES_SEARCH_KEY = "fq"; +export const FILES_SORT_KEY = "fsort"; +export const FILES_KEYS = [ + FILES_FOLDER_KEY, + FILES_ITEM_KEY, + FILES_SEARCH_KEY, + FILES_SORT_KEY, +] as const; + +// The list's own order (no column sort) has a word of its own: the sort is always written, because a +// page opened without one takes the account's remembered sort, which may not be the one bookmarked. +const UNSORTED = "none"; + +export function encodeFilesSort(sort: SortState): string { + return encodeSortParam(sort) ?? UNSORTED; +} + +/** The sort an address names; `undefined` when it names none (the remembered one applies). */ +export function decodeFilesSort(raw: string | null): SortState | undefined { + if (raw == null) return undefined; + return raw === UNSORTED ? null : decodeSortParam(raw); +} diff --git a/frontend/src/lib/filterMapParam.ts b/frontend/src/lib/filterMapParam.ts new file mode 100644 index 00000000..4431a559 --- /dev/null +++ b/frontend/src/lib/filterMapParam.ts @@ -0,0 +1,32 @@ +import { type FilterMap } from "./tableColumns"; + +// A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`). +// The map mixes scalar column filters (select, text, the sync chip) with array ones (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 nothing. +export function encodeFilterMap(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). +export function decodeFilterMap(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 {}; + } +} diff --git a/frontend/src/lib/playlistsUrlState.ts b/frontend/src/lib/playlistsUrlState.ts new file mode 100644 index 00000000..553cf16f --- /dev/null +++ b/frontend/src/lib/playlistsUrlState.ts @@ -0,0 +1,43 @@ +import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; +import { decodeFilterMap, encodeFilterMap } from "./filterMapParam"; +import { type FilterMap } from "./tableColumns"; + +// The Playlists page's address-bar keys: the playlist shown (`pl`), the rail's name search (`plq`), and +// the open playlist's view — its track sort, column filters and grouping (`plsort`/`plf`/`plgroup`). +// The playlist is remembered per account too (PlaylistsProvider); the search and the view are +// remembered nowhere else (the view starts over on every playlist), so each of those is written only +// when it differs from the default: its key's absence IS the default. +export const PLAYLISTS_KEYS = ["pl", "plq", "plsort", "plf", "plgroup"] as const; + +export type PlaylistView = { sort: SortState; filters: FilterMap; groupBy: boolean }; + +// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it +// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored +// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel). +export const PLAYLIST_STORED_SORT: SortState = { key: "pos", dir: "asc" }; + +export function isStoredSort(s: SortState): boolean { + return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir; +} + +/** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */ +export function playlistViewParamUpdates(v: PlaylistView | null): Record { + if (!v) return { plsort: null, plf: null, plgroup: null }; + return { + // An unsorted view (a header clicked through to "none") is the stored order too. + plsort: v.sort && !isStoredSort(v.sort) ? encodeSortParam(v.sort) : null, + plf: encodeFilterMap(v.filters), + plgroup: v.groupBy ? "1" : null, + }; +} + +/** The view an address names; what it leaves out is the default. */ +export function playlistViewFrom(params: URLSearchParams): PlaylistView { + const sort = params.get("plsort"); + const filters = params.get("plf"); + return { + sort: (sort != null && decodeSortParam(sort)) || PLAYLIST_STORED_SORT, + filters: filters != null ? decodeFilterMap(filters) : {}, + groupBy: params.get("plgroup") === "1", + }; +} diff --git a/frontend/src/lib/plexUrlState.ts b/frontend/src/lib/plexUrlState.ts index c40bc375..8965a47f 100644 --- a/frontend/src/lib/plexUrlState.ts +++ b/frontend/src/lib/plexUrlState.ts @@ -8,6 +8,7 @@ import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api"; import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; import { PLEX_SORT_FALLBACK } from "./plexSort"; +import { parsePlexField, type PlexSearchField } from "./plexSearch"; // The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of // use), so the user's real preference round-trips through the URL. @@ -20,6 +21,10 @@ export interface PlexUrlState { export const PLEX_KEYS = ["pscope", "psort", "pshow", "pf"] as const; const KEYS = PLEX_KEYS; +// The header search: its term and the field it searches. Both remembered per account, so both are +// always written on the page (an empty term too): a link without them would open with whatever this +// browser searched last. The field means something only beside a term. +export const PLEX_SEARCH_KEYS = ["pq", "pqf"] as const; // The Plex sub-view's key (show, season, info, person… — modules/plex/plexSubUrl encodes it). export const PLEX_SUB_KEY = "v"; const SCOPES = ["movie", "show", "both"]; @@ -68,3 +73,24 @@ export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): : base.filters, }; } + +export type PlexSearchUrlState = { q: string; field: PlexSearchField }; + +/** The address-bar delta for the header search; `null` clears it (off the Plex page). */ +export function plexSearchParamUpdates( + state: PlexSearchUrlState | null, +): Record { + if (!state) return { pq: null, pqf: null }; + const q = state.q.trim(); + return { pq: q, pqf: q ? state.field : null }; +} + +/** What of the header search an address names (absent: the remembered value applies). */ +export function plexSearchFrom(params: URLSearchParams): Partial { + const q = params.get("pq"); + const field = params.get("pqf"); + return { + ...(q != null && { q }), + ...(field != null && { field: parsePlexField(field) }), + }; +} diff --git a/frontend/src/lib/settingsTab.ts b/frontend/src/lib/settingsTab.ts index 00bb801d..74045e15 100644 --- a/frontend/src/lib/settingsTab.ts +++ b/frontend/src/lib/settingsTab.ts @@ -1,8 +1,11 @@ import { LS, setTabRaw } from "./storage"; // The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link, -// a return from Google). useTabPersistedState reads the key when the page mounts, and the page mounts -// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it. +// a return from Google). useTabPersistedState reads the key when the page mounts, and the page on +// screen hears it. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it. The +// address-bar key the page mirrors its tab to is here too, beside the other pages' keys. +export const SETTINGS_TAB_KEY = "stab"; + export type SettingsTabId = | "appearance" | "notifications" diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index d94e6474..90e509c3 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; // Central registry of every key the app keeps in the browser (localStorage, and a tab's // sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of @@ -307,10 +307,11 @@ export function useAccountPersistedState( // --- a page's sub-tab, per browser tab --------------------------------------------------------- -// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser -// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new +// Which sub-tab a page was left on (Settings, Configuration, Users) is kept for THIS browser tab only — +// sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new // tab opens the page on its first sub-tab (the owner's call: a position remembered forever in -// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). +// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). The +// address names the tab too (the page mirrors it): a link or a bookmark that carries one wins. /** The sub-tab stored for this browser tab, or null. */ function getTabRaw(base: string): string | null { @@ -322,8 +323,7 @@ function getTabRaw(base: string): string | null { } } -/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */ -export function setTabRaw(base: string, value: string): void { +function writeTabRaw(base: string, value: string): void { const k = ownKey(base); if (!k) return; try { @@ -333,10 +333,23 @@ export function setTabRaw(base: string, value: string): void { } } -/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */ +// A pre-selection made while the page is already on screen (a notice's link opened over Settings) has +// no mount to be read at, and the navigation to the same page is a no-op: the page's hook hears it. +const TAB_SELECT_EVENT = "siftlode:tab-select"; +type TabSelect = { base: string; value: string }; + +/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */ +export function setTabRaw(base: string, value: string): void { + writeTabRaw(base, value); + window.dispatchEvent(new CustomEvent(TAB_SELECT_EVENT, { detail: { base, value } })); +} + +/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). `urlKey`: the + * address-bar key the page mirrors its tab to — a tab it names is the one the page opens on. */ export function useTabPersistedState( base: string, fallback = "", + urlKey?: string, ): [string, (value: string) => void] { const [value, setValue] = useState(() => { // The position used to live in localStorage; a value left there would never be read again. @@ -348,12 +361,27 @@ export function useTabPersistedState( /* ignore */ } } + const linked = urlKey ? new URLSearchParams(window.location.search).get(urlKey) : null; + if (linked) { + // Kept for this browser tab too, so a later trip back to the page (its address cleared by the + // page switch) still opens it here. + writeTabRaw(base, linked); + return linked; + } return getTabRaw(base) ?? fallback; }); + useEffect(() => { + function onSelect(e: Event) { + const { detail } = e as CustomEvent; + if (detail.base === base) setValue(detail.value); + } + window.addEventListener(TAB_SELECT_EVENT, onSelect); + return () => window.removeEventListener(TAB_SELECT_EVENT, onSelect); + }, [base]); const set = useCallback( (next: string) => { setValue(next); - setTabRaw(base, next); + writeTabRaw(base, next); }, [base], ); diff --git a/frontend/src/lib/tabState.dom.test.ts b/frontend/src/lib/tabState.dom.test.ts index 03063083..961daee7 100644 --- a/frontend/src/lib/tabState.dom.test.ts +++ b/frontend/src/lib/tabState.dom.test.ts @@ -41,4 +41,21 @@ describe("a page's sub-tab, per browser tab", () => { const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles")); expect(result.current[0]).toBe("access"); }); + + it("opens on the tab the address names, and keeps it for this browser tab", () => { + setTabRaw("siftlode.settingsTab", "account"); + window.history.replaceState(null, "", "/?page=settings&stab=tokens"); + const { result, unmount } = renderHook(() => + useTabPersistedState("siftlode.settingsTab", "appearance", "stab"), + ); + expect(result.current[0]).toBe("tokens"); + unmount(); + // Back on the page later, through the rail (the page switch cleared its address). + window.history.replaceState(null, "", "/?page=settings"); + expect( + renderHook(() => useTabPersistedState("siftlode.settingsTab", "appearance", "stab")).result + .current[0], + ).toBe("tokens"); + window.history.replaceState(null, "", "/"); + }); }); diff --git a/frontend/src/moduleSearch.url.dom.test.tsx b/frontend/src/moduleSearch.url.dom.test.tsx new file mode 100644 index 00000000..ba1a4342 --- /dev/null +++ b/frontend/src/moduleSearch.url.dom.test.tsx @@ -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()), + 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 } = { 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( + + + + + + + , + ); +} + +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"); + }); +}); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 5b66a82b..7e048ce1 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -12,10 +12,14 @@ import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistr import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api"; import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState"; import { channelsParamUpdates } from "./lib/channelsUrlState"; -import { plexParamUpdates } from "./lib/plexUrlState"; +import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { FILES_KEYS } from "./lib/filesUrlState"; +import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; +import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; +import { CONFIG_TAB_KEY } from "./lib/configTab"; +import { USERS_TAB_URL_KEY } from "./lib/adminUsersTab"; import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl"; import { MODE_URL } from "./shell/welcomeMode"; import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState"; @@ -40,15 +44,23 @@ const FEED: FeedFilters = { const WRITES: Partial string[]>> = { feed: () => Object.keys(filterParamUpdates(FEED)), channels: () => - Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {} })), + Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })), plex: () => [ ...Object.keys( plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }), ), + ...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })), PLEX_SUB_URL.key, ], files: () => [...FILES_KEYS], - playlists: () => ["pl"], + settings: () => [SETTINGS_TAB_KEY], + config: () => [CONFIG_TAB_KEY], + users: () => [USERS_TAB_URL_KEY], + playlists: () => [ + "pl", + "plq", + ...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })), + ], activity: () => [ ...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)), ...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)), diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 36cc90fa..87d9c0cf 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -15,6 +15,7 @@ import { useSortState } from "../../lib/useSortState"; import { usePref } from "../../lib/usePref"; import { useFilterState } from "../../lib/useFilterState"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { useDebounced } from "../../lib/useDebounced"; import { rememberNavPosition } from "../../lib/history"; import { channelsParamsToState, @@ -43,7 +44,8 @@ import { // // Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and // goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are -// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account +// per-account persisted (localStorage); the search lives only in the address (below); focus and the +// reset token are ephemeral. An account // switch does a full reload, so init-from-storage is all the hydration the persisted ones need. // // The contexts, their value types and the hooks live in channelsContext.ts. @@ -93,6 +95,7 @@ function readChannelsUrlSeed(): ChannelsUrlState | null { view: clampView(getAccountRaw(LS.channelsView)), sort: hydrateSort(getAccountRaw(LS.channelSort), [], null), filters: readStoredFilters(), + search: "", }); } @@ -101,7 +104,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const { page } = nav; const { setPage } = useNavigationActions(); - const [search, setSearchState] = useState(""); + // Shareable manager view. Captured once at mount (or null when no manager URL keys are present). + const [urlSeed] = useState(readChannelsUrlSeed); + const seededRef = useRef(false); + + const [search, setSearchState] = useState(() => urlSeed?.search ?? ""); // The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it. const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip); @@ -122,10 +129,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const [focusNameRaw, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); - // Shareable manager view. Captured once at mount (or null when no manager URL keys are present). - const [urlSeed] = useState(readChannelsUrlSeed); - const seededRef = useRef(false); - // Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the // shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab // without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op. @@ -247,12 +250,17 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { ], ); - // Mirror the content state (tab + sort + column filters) into the address bar under the namespaced - // keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or under a channel - // page) the keys are cleared - // (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a - // deliberately transient rail-search, like the feed's and plex's. - useUrlMirror(channelsParamUpdates(urlPage(nav) === "channels" ? { view, sort, filters } : null)); + // Mirror the content state (tab + sort + column filters + the name search) into the address bar under + // the namespaced keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or + // under a channel page) the keys are cleared (localStorage still holds the rest; it re-mirrors on + // return; the search is this visit's). The search is written once the typing pauses: one address-bar + // write per keystroke is history churn a browser throttles. + const typed = useDebounced(search, 300); + useUrlMirror( + channelsParamUpdates( + urlPage(nav) === "channels" ? { view, sort, filters, search: typed } : null, + ), + ); return ( diff --git a/frontend/src/modules/config/ConfigPanel.sections.dom.test.tsx b/frontend/src/modules/config/ConfigPanel.sections.dom.test.tsx index d8129571..5a437755 100644 --- a/frontend/src/modules/config/ConfigPanel.sections.dom.test.tsx +++ b/frontend/src/modules/config/ConfigPanel.sections.dom.test.tsx @@ -55,6 +55,8 @@ import ConfigPanel from "./ConfigPanel"; afterEach(() => { cleanup(); localStorage.clear(); + sessionStorage.clear(); + window.history.replaceState(null, "", "/"); }); const renderPanel = () => @@ -90,3 +92,24 @@ describe("Configuration sections", () => { expect(screen.queryByText("config.fields.ka.label")).toBeNull(); }); }); + +// The URL is the view: the group on screen is named in the address, so a bookmark opens it. +describe("the group in the address", () => { + const cfgtab = () => new URLSearchParams(window.location.search).get("cfgtab"); + + it("opens on the group a link names, and names the one picked", async () => { + window.history.replaceState(null, "", "/?page=config&cfgtab=c"); + renderPanel(); + expect(await screen.findByText("config.fields.kc.label")).toBeTruthy(); + expect(cfgtab()).toBe("c"); + fireEvent.click(screen.getByRole("tab", { name: "config.groups.b" })); + expect(cfgtab()).toBe("b"); + }); + + it("names the group shown when the link's is gone from the registry", async () => { + window.history.replaceState(null, "", "/?page=config&cfgtab=retired"); + renderPanel(); + expect(await screen.findByText("config.fields.ka.label")).toBeTruthy(); + expect(cfgtab()).toBe("a"); + }); +}); diff --git a/frontend/src/modules/config/ConfigPanel.tsx b/frontend/src/modules/config/ConfigPanel.tsx index 151a4d1e..3727db4a 100644 --- a/frontend/src/modules/config/ConfigPanel.tsx +++ b/frontend/src/modules/config/ConfigPanel.tsx @@ -16,6 +16,8 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle"; import FilterSelect from "../../components/ui/FilterSelect"; import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar"; import { LS, useTabPersistedState } from "../../lib/storage"; +import { CONFIG_TAB_KEY } from "../../lib/configTab"; +import { useUrlMirror } from "../../lib/useUrlMirror"; import { notifyActionError } from "../../lib/actionErrors"; import { Button } from "../../components/ui/Button"; import { CheckRow } from "../../components/ui/NativeControls"; @@ -95,7 +97,7 @@ export default function ConfigPanel() { const [reseedToken, setReseedToken] = useState(0); // One tab per config group (persisted). Called before the loading guard so hook order is // stable; the active id is clamped to a real group at render time once data has loaded. - const [tab, setTab] = useTabPersistedState(LS.configTab); + const [tab, setTab] = useTabPersistedState(LS.configTab, "", CONFIG_TAB_KEY); // A stable signature of the config SHAPE — changes only when keys are added/removed, not when an // unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial @@ -181,6 +183,17 @@ export default function ConfigPanel() { setDraft((d) => ({ ...d, plex_libraries: next.join(",") })); }; + // The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133% + // zoom, so they are a side list under four headings, as Settings has). + const groupKeys = data?.sections.flatMap((s) => s.groups) ?? []; + // Clamp the persisted tab to a real group (the registry can change between sessions). The + // tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is + // never silently lost behind the global Save bar. + const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; + // The address names the group on screen (always: see SettingsPanel); until the registry is in, the + // one it was opened on. + useUrlMirror({ [CONFIG_TAB_KEY]: (data ? activeGroup : tab) || null }); + if (q.isError && !data) { return ( q.refetch()}> @@ -192,13 +205,6 @@ export default function ConfigPanel() { return ; } - // The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133% - // zoom, so they are a side list under four headings, as Settings has). - const groupKeys = data.sections.flatMap((s) => s.groups); - // Clamp the persisted tab to a real group (the registry can change between sessions). The - // tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is - // never silently lost behind the global Save bar. - const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean)); const groupTabs = data.sections.flatMap((s) => s.groups.map((g) => ({ diff --git a/frontend/src/modules/config/module.ts b/frontend/src/modules/config/module.ts index 5d0d4b17..0ce7c752 100644 --- a/frontend/src/modules/config/module.ts +++ b/frontend/src/modules/config/module.ts @@ -1,6 +1,7 @@ import { SlidersHorizontal } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { CONFIG_TAB_KEY } from "../../lib/configTab"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const configModule: ModuleDef = { @@ -12,5 +13,5 @@ export const configModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./ConfigPanel")), - urlKeys: [], + urlKeys: [CONFIG_TAB_KEY], }; diff --git a/frontend/src/modules/files/Files.menu.dom.test.tsx b/frontend/src/modules/files/Files.menu.dom.test.tsx index a0640840..5c33a11a 100644 --- a/frontend/src/modules/files/Files.menu.dom.test.tsx +++ b/frontend/src/modules/files/Files.menu.dom.test.tsx @@ -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("[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( + + + + + + + , + ); + } + + 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); diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index e34f6b52..74ae0221 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -19,6 +19,8 @@ import { qk } from "../../lib/queryKeys"; import { LS, ownKey } from "../../lib/storage"; import { useCardPager } from "../../lib/useCardPager"; import { useDebounced } from "../../lib/useDebounced"; +import { useUrlMirror } from "../../lib/useUrlMirror"; +import { decodeFilesSort, encodeFilesSort, FILES_SORT_KEY } from "../../lib/filesUrlState"; import { useLiveQuery } from "../../lib/useLiveQuery"; import { usePref } from "../../lib/usePref"; import { JobThumb } from "../../features/downloads/downloadRow"; @@ -207,7 +209,18 @@ export default function Files() { }, [gone, setView]); // One sort for both paints: the table's headers and the grid's dropdown move the same state. - const [sort, setSort] = usePref("filesSort", null, { localKey: LS.filesSort }); + const [prefSort, setPrefSort] = usePref("filesSort", null, { localKey: LS.filesSort }); + // A sort the address names wins until one is picked here (which is then the account's again), and + // the address names the sort on screen. + const [linkedSort, setLinkedSort] = useState(() => + decodeFilesSort(new URLSearchParams(window.location.search).get(FILES_SORT_KEY)), + ); + const sort = linkedSort === undefined ? prefSort : linkedSort; + const setSort = (next: SortState) => { + setLinkedSort(undefined); + setPrefSort(next); + }; + useUrlMirror({ [FILES_SORT_KEY]: encodeFilesSort(sort) }); const params = listParams(shown); // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index c6f294b7..7e9d5b7a 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -1,7 +1,8 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { useUrlMirror } from "../../lib/useUrlMirror"; -import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState"; +import { useDebounced } from "../../lib/useDebounced"; +import { FILES_FOLDER_KEY, FILES_ITEM_KEY, FILES_SEARCH_KEY } from "../../lib/filesUrlState"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { urlPage } from "../../features/navigation/urlPage"; import { @@ -23,9 +24,10 @@ import { // read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in // main.tsx around . // -// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per -// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. -// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the +// The search lives in the address alone (`?fq=`): a reload or a bookmark opens it searched, and a trip +// back to the page through the rail opens it unsearched. The view is remembered per account and +// mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A +// `?folder=` in the address wins over the remembered one. A folder that no longer exists is the // page's to fall back from (it knows the folder list). // // An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it @@ -47,7 +49,9 @@ function initialFocus(): FilesFocus | null { } export function FilesProvider({ children }: { children: ReactNode }) { - const [search, setSearchState] = useState(""); + const [search, setSearchState] = useState( + () => new URLSearchParams(window.location.search).get(FILES_SEARCH_KEY) ?? "", + ); const [view, setViewState] = useState(initialView); const [focusRaw, setFocusRaw] = useState(initialFocus); const [focusApplied, setFocusApplied] = useState(0); @@ -90,7 +94,11 @@ export function FilesProvider({ children }: { children: ReactNode }) { const arrived = page === "files" ? arrivedRaw : null; const shown = urlPage(nav) === "files"; + // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); + // an arrival's cleared box at once. + const typed = useDebounced(search.trim(), 300, focusRaw?.token); useUrlMirror({ + [FILES_SEARCH_KEY]: shown && typed ? typed : null, [FILES_FOLDER_KEY]: shown ? viewParam(view) : null, [FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null, }); diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 8a3e2730..6254bad0 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -183,6 +183,8 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); document.body.replaceChildren(); + // The page names its view in the address: one case's sort must not open the next. + window.history.replaceState(null, "", "/"); }); describe("Playlists — the row the player is launched with", () => { @@ -794,3 +796,51 @@ describe("Playlists · the page's background menu", () => { expect(rows).not.toContain("playlists.revert"); }); }); + +// The URL is the view: the open playlist's sort, filters and grouping are named in the address, so a +// reload or a bookmark opens the list arranged as it was left (they lived in the page's memory alone). +describe("Playlists · the view in the address", () => { + const param = (k: string) => new URLSearchParams(window.location.search).get(k); + const titles = () => + screen + .getAllByRole("button", { name: /^card\.play — / }) + .map((b) => b.getAttribute("aria-label")!.replace("card.play — ", "")); + + it("names the sort and the grouping picked", async () => { + mount({ 1: [vid("A", 0), vid("B", 0)] }); + await screen.findByText("Video A"); + expect(param("plsort")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ })); + await waitFor(() => expect(param("plsort")).toBe("title:asc")); + fireEvent.click(screen.getByText("playlists.groupByChannel")); + await waitFor(() => expect(param("plgroup")).toBe("1")); + }); + + it("opens on the view a link names", async () => { + window.history.replaceState( + null, + "", + `/?page=playlists&pl=1&plsort=title:desc&plgroup=1&plf=${encodeURIComponent('{"title":"Video"}')}`, + ); + mount({ 1: [vid("A", 0), vid("C", 0), vid("B", 0)] }); + await screen.findByText("Video A"); + expect(titles()).toEqual(["Video C", "Video B", "Video A"]); + expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toHaveProperty( + "checked", + true, + ); + expect(param("plf")).toBe('{"title":"Video"}'); + }); + + it("starts over on another playlist, and its address with it", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc&plgroup=1"); + const { rerender } = mount({ 1: [vid("A", 0), vid("B", 0)], 2: [vid("D", 0), vid("E", 0)] }); + await screen.findByText("Video A"); + h.selectedId = 2; + rerender(); + await screen.findByText("Video D"); + expect(titles()).toEqual(["Video D", "Video E"]); + await waitFor(() => expect(param("plsort")).toBeNull()); + expect(param("plgroup")).toBeNull(); + }); +}); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 97bb3a58..896d2242 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -41,6 +41,13 @@ import { playlistName } from "../../lib/playlistName"; import { reinsertAt } from "../../lib/reinsert"; import { useUndoable } from "../../lib/useUndoable"; import { cycleSort, type SortState } from "../../lib/columnSort"; +import { useUrlMirror } from "../../lib/useUrlMirror"; +import { + isStoredSort, + PLAYLIST_STORED_SORT, + playlistViewFrom, + playlistViewParamUpdates, +} from "../../lib/playlistsUrlState"; import { formatRuntime } from "../../lib/format"; import { applyView, sameOrder } from "../../lib/playlistView"; const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); @@ -95,11 +102,8 @@ import PlaylistsHeader from "./PlaylistsHeader"; // It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns // its own filter/sort/page state and the stored order needs a single owner. -// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it -// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored -// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel). -const STORED_SORT: SortState = { key: "pos", dir: "asc" }; -const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc"; +// The neutral view ("#" ascending, the stored order) lives beside the address-bar codec of the view. +const STORED_SORT = PLAYLIST_STORED_SORT; const idsKey = (items: Video[]) => items @@ -232,10 +236,13 @@ function PlaylistDetail() { }, }); const items = order.value; - // VIEW state — never written to the server on its own. - const [viewSort, setViewSort] = useState(STORED_SORT); - const [filters, setFilters] = useState({}); - const [groupBy, setGroupBy] = useState(false); + // VIEW state — never written to the server on its own. The address names it, so a reload or a + // bookmark opens the playlist sorted, filtered and grouped as it was left (the view it opens on is + // the address's; a switch to another playlist starts over, below). + const [opened] = useState(() => playlistViewFrom(new URLSearchParams(window.location.search))); + const [viewSort, setViewSort] = useState(opened.sort); + const [filters, setFilters] = useState(opened.filters); + const [groupBy, setGroupBy] = useState(opened.groupBy); // Tracks the membership (id set) currently loaded into `order`, so a refetch that only // re-confirms our own reorder doesn't wipe the undo history — we reset history only when // the actual set of items changes (different playlist, or an add/remove). @@ -354,6 +361,9 @@ function PlaylistDetail() { setFilters({}); setGroupBy(false); } + useUrlMirror( + playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null), + ); const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a diff --git a/frontend/src/modules/playlists/PlaylistsProvider.tsx b/frontend/src/modules/playlists/PlaylistsProvider.tsx index a9e5654c..5e90fa42 100644 --- a/frontend/src/modules/playlists/PlaylistsProvider.tsx +++ b/frontend/src/modules/playlists/PlaylistsProvider.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, setAccountRaw } from "../../lib/storage"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { useDebounced } from "../../lib/useDebounced"; import { useNavigation } from "../../features/navigation/navigationContext"; import { urlPage } from "../../features/navigation/urlPage"; import { readSelectedId } from "./playlistsInit"; @@ -17,15 +18,17 @@ import { // FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed // in main.tsx around ). // -// The `search` term is ephemeral (resets on reload, like the other list filters); the selected -// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is -// server-synced, and an account switch does a full reload, so init-from-storage is all the +// The `search` term lives in the address alone (`?plq=`, a reload or a bookmark keeps it, a trip back +// through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so +// a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the // hydration these need — no per-account load effect (unlike the server-backed feed filters). // // The contexts + hooks live in playlistsContext.ts. export function PlaylistsProvider({ children }: { children: ReactNode }) { - const [search, setSearchState] = useState(""); + const [search, setSearchState] = useState( + () => new URLSearchParams(window.location.search).get("plq") ?? "", + ); const [selectedId, setSelectedIdState] = useState(readSelectedId); const setSearch = useCallback((q: string) => setSearchState(q), []); @@ -37,9 +40,13 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { // Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere, // and under a channel page (urlPage; the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav // params intact. + // The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles. const nav = useNavigation(); + const shown = urlPage(nav) === "playlists"; + const typed = useDebounced(search.trim(), 300); useUrlMirror({ - pl: urlPage(nav) === "playlists" && selectedId != null ? String(selectedId) : null, + pl: shown && selectedId != null ? String(selectedId) : null, + plq: shown && typed ? typed : null, }); const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index fc69335c..8ad7ad0e 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -1,6 +1,7 @@ import { ListVideo } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { PLAYLISTS_KEYS } from "../../lib/playlistsUrlState"; import { PlaylistsProvider } from "./PlaylistsProvider"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -17,5 +18,5 @@ export const playlistsModule: ModuleDef = { maxWidth: "64rem", Store: PlaylistsProvider, // The playlist shown (PlaylistsProvider, playlistsInit). - urlKeys: ["pl"], + urlKeys: PLAYLISTS_KEYS, }; diff --git a/frontend/src/modules/plex/PlexProvider.tsx b/frontend/src/modules/plex/PlexProvider.tsx index 22c2bf7f..f868ce2b 100644 --- a/frontend/src/modules/plex/PlexProvider.tsx +++ b/frontend/src/modules/plex/PlexProvider.tsx @@ -7,11 +7,14 @@ import { hasPlexParams, plexParamsToState, plexParamUpdates, + plexSearchFrom, + plexSearchParamUpdates, type PlexUrlState, } from "../../lib/plexUrlState"; import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage"; import { logFilterChange } from "../../lib/filterLog"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { useDebounced } from "../../lib/useDebounced"; import { useNavigation } from "../../features/navigation/navigationContext"; import { urlPage } from "../../features/navigation/urlPage"; import { @@ -28,7 +31,7 @@ import { // instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, // stable useCallback setters, one-line hooks; composed in main.tsx around ). // -// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount +// scope / show / sort / filters / q / field are per-account persisted (localStorage) and seeded from it at mount // (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral // hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex // search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks @@ -132,9 +135,13 @@ export function PlexProvider({ children }: { children: ReactNode }) { return EMPTY_PLEX_FILTERS; } }, [filtersRaw]); - // Survives F5 (persisted), unlike the feed search — see the scope note above. - const [q, setQState] = useState(readPlexQ); - const [field, setFieldState] = useState(readPlexField); + // Remembered per account (a trip back through the rail finds it), and named in the address: a link's + // search wins and is remembered too. + const [urlSearch] = useState(() => plexSearchFrom(new URLSearchParams(window.location.search))); + const [q, setQState] = useState(() => urlSearch.q ?? readPlexQ()); + const [field, setFieldState] = useState( + () => urlSearch.field ?? readPlexField(), + ); const [playlistOpen, setPlaylistOpen] = useState(null); // Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to; // PlexBrowse consumes it into an info/show history subview then clears it. @@ -153,6 +160,10 @@ export function PlexProvider({ children }: { children: ReactNode }) { if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort)); if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters)); }, [urlSeed]); + useEffect(() => { + if (urlSearch.q != null) setAccountRaw(LS.plexQ, urlSearch.q); + if (urlSearch.field != null) setAccountRaw(LS.plexField, urlSearch.field); + }, [urlSearch]); const setScope = useCallback((v: string) => { logFilterChange("plex", { scope: v }); @@ -197,10 +208,14 @@ export function PlexProvider({ children }: { children: ReactNode }) { // Mirror the content state (scope + sort + watch-state + filters) into the address bar under the // namespaced keys so a Plex-browse view is shareable. Gated on urlPage===plex: off the Plex page (or - // under a channel page) the keys are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the - // search term is deliberately transient-per-view and not part of the shared four-key contract. + // under a channel page) the keys are cleared (localStorage still holds the state; it re-mirrors on + // return). The search beside them, once the typing pauses: one address-bar write per keystroke is + // churn a browser throttles. const nav = useNavigation(); - useUrlMirror(plexParamUpdates(urlPage(nav) === "plex" ? { scope, sort, show, filters } : null)); + const shown = urlPage(nav) === "plex"; + useUrlMirror(plexParamUpdates(shown ? { scope, sort, show, filters } : null)); + const typed = useDebounced(q, 300); + useUrlMirror(plexSearchParamUpdates(shown ? { q: typed, field } : null)); return ( diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts index 70d36358..697c8126 100644 --- a/frontend/src/modules/plex/module.ts +++ b/frontend/src/modules/plex/module.ts @@ -1,7 +1,7 @@ import { Clapperboard } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; -import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState"; +import { PLEX_KEYS, PLEX_SEARCH_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState"; import { PlexStore } from "./PlexStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -18,5 +18,5 @@ export const plexModule: ModuleDef = { Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail), maxWidth: "100rem", Store: PlexStore, - urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY], + urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY], }; diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index 5a825d49..564c4f79 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -26,6 +26,8 @@ import { api, HttpError, type Me } from "../../lib/api"; import { displayName, roleKey } from "../../lib/api/account"; import { notifyActionError } from "../../lib/actionErrors"; import { LS, useTabPersistedState } from "../../lib/storage"; +import { SETTINGS_TAB_KEY } from "../../lib/settingsTab"; +import { useUrlMirror } from "../../lib/useUrlMirror"; import Avatar from "../../components/ui/Avatar"; import { notify, type NotifSettings } from "../../lib/notifications"; import Tooltip from "../../components/ui/Tooltip"; @@ -70,12 +72,15 @@ export default function SettingsPanel() { const prefs = usePrefs(); // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). const { openWizard } = useWizard(); - const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance"); + const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance", SETTINGS_TAB_KEY); const panel = useTabPanel(); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) ? (tabRaw as TabId) : "appearance"; + // The address names the tab on screen, so a bookmark opens it (always written: without it, the page + // opens on the tab this browser tab last showed). + useUrlMirror({ [SETTINGS_TAB_KEY]: tab }); return (
diff --git a/frontend/src/modules/settings/SettingsPanel.url.dom.test.tsx b/frontend/src/modules/settings/SettingsPanel.url.dom.test.tsx new file mode 100644 index 00000000..dbd98adf --- /dev/null +++ b/frontend/src/modules/settings/SettingsPanel.url.dom.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +// +// The URL is the view: Settings' tab is named in the address, so a reload or a bookmark opens the tab +// it showed (it lived in sessionStorage alone: a bookmark opened Appearance). And a notice's link to a +// tab, followed while Settings is already on screen, turns to it — the navigation to the page it is on +// is a no-op, so the pre-selection used to wait for the next visit. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); +vi.mock("../../lib/useMe", () => ({ useMe: () => ({ id: 1, email: "a@x", role: "user" }) })); +vi.mock("../../features/prefs/prefsContext", () => ({ usePrefs: () => ({}) })); +vi.mock("../../features/wizard/wizardContext", () => ({ + useWizard: () => ({ openWizard: () => {} }), +})); +// The tabs' own content is not what is under test. +vi.mock("./ApiTokens", () => ({ default: () =>

tokens-tab

})); +vi.mock("./DiagnosticsSettings", () => ({ default: () =>

diagnostics-tab

})); + +import SettingsPanel from "./SettingsPanel"; +import { focusSettingsTab } from "../../lib/settingsTab"; +import { claimLocalData } from "../../lib/storage"; + +beforeEach(() => { + claimLocalData(1); +}); +afterEach(() => { + cleanup(); + sessionStorage.clear(); + localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +const search = () => new URLSearchParams(window.location.search); +const mount = () => + render( + + + , + ); + +describe("Settings' tab in the address", () => { + it("opens on the tab a link names, over the one this browser tab last showed", () => { + focusSettingsTab("diagnostics"); + window.history.replaceState(null, "", "/?page=settings&stab=tokens"); + mount(); + expect(screen.getByText("tokens-tab")).toBeTruthy(); + expect(search().get("stab")).toBe("tokens"); + }); + + it("names the tab picked", () => { + window.history.replaceState(null, "", "/?page=settings&stab=tokens"); + mount(); + fireEvent.click(screen.getByTestId("settings-tab-diagnostics")); + expect(screen.getByText("diagnostics-tab")).toBeTruthy(); + expect(search().get("stab")).toBe("diagnostics"); + }); + + it("turns to a tab pre-selected while the page is on screen", () => { + window.history.replaceState(null, "", "/?page=settings&stab=tokens"); + mount(); + act(() => focusSettingsTab("diagnostics")); + expect(screen.getByText("diagnostics-tab")).toBeTruthy(); + expect(search().get("stab")).toBe("diagnostics"); + }); +}); diff --git a/frontend/src/modules/settings/module.ts b/frontend/src/modules/settings/module.ts index 793c1de8..3b63ea86 100644 --- a/frontend/src/modules/settings/module.ts +++ b/frontend/src/modules/settings/module.ts @@ -1,6 +1,7 @@ import { Settings } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { SETTINGS_TAB_KEY } from "../../lib/settingsTab"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const settingsModule: ModuleDef = { @@ -10,5 +11,5 @@ export const settingsModule: ModuleDef = { titleKey: "settings.title", icon: Settings, Page: lazyWithPreload(() => import("./SettingsPanel")), - urlKeys: [], + urlKeys: [SETTINGS_TAB_KEY], }; diff --git a/frontend/src/modules/users/AdminUsers.dom.test.tsx b/frontend/src/modules/users/AdminUsers.dom.test.tsx index bb8b449f..b1506036 100644 --- a/frontend/src/modules/users/AdminUsers.dom.test.tsx +++ b/frontend/src/modules/users/AdminUsers.dom.test.tsx @@ -35,6 +35,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({ vi.mock("../../lib/errorDialog", () => ({ reportError: vi.fn() })); import AdminUsers from "./AdminUsers"; +import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; import { api, type AdminUserRow } from "../../lib/api"; import { relativeTime } from "../../lib/format"; import i18n from "../../i18n"; @@ -190,3 +191,32 @@ describe("a user action that fails", () => { expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]); }); }); + +// The URL is the view: the tab on screen is named in the address, so a bookmark opens it. +describe("the tab in the address", () => { + afterEach(() => window.history.replaceState(null, "", "/")); + const utab = () => new URLSearchParams(window.location.search).get("utab"); + const selected = () => + screen.getAllByRole("tab").find((el) => el.getAttribute("aria-selected") === "true") + ?.textContent; + + it("opens on the tab a link names, and names the one picked", () => { + window.history.replaceState(null, "", "/?page=users&utab=storage"); + vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never); + // The tab bar portals into the shell's toolbar band. + const slot = document.body.appendChild(document.createElement("div")); + render( + + + + + , + ); + expect(selected()).toBe("users.tabs.storage"); + expect(utab()).toBe("storage"); + fireEvent.click(screen.getByRole("tab", { name: "users.tabs.roles" })); + expect(utab()).toBe("roles"); + }); +}); diff --git a/frontend/src/modules/users/AdminUsers.tsx b/frontend/src/modules/users/AdminUsers.tsx index 812e8343..811ffc19 100644 --- a/frontend/src/modules/users/AdminUsers.tsx +++ b/frontend/src/modules/users/AdminUsers.tsx @@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account"; import { notify } from "../../lib/notifications"; import { formatDateTime, relativeTime } from "../../lib/format"; import { invalidateUserAccess } from "../../lib/adminUsers"; -import { ADMIN_USERS_TAB_KEY } from "../../lib/adminUsersTab"; +import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; @@ -42,6 +42,7 @@ import { useConfirm } from "../../components/ui/confirmContext"; import Tabs from "../../components/ui/Tabs"; import { useTabPanel } from "../../components/ui/tabPanel"; import { useTabPersistedState } from "../../lib/storage"; +import { useUrlMirror } from "../../lib/useUrlMirror"; import { PageToolbar } from "../../components/ui/PageShell"; import { useMe } from "../../lib/useMe"; import { notifyActionError, notifyKnownFailure } from "../../lib/actionErrors"; @@ -71,7 +72,7 @@ const ROW_OPEN = export default function AdminUsers() { const me = useMe(); const { t } = useTranslation(); - const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles"); + const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles", USERS_TAB_URL_KEY); const panel = useTabPanel(); const tabs = [ { id: "roles", label: t("users.tabs.roles") }, @@ -87,6 +88,8 @@ export default function AdminUsers() { { id: "storage", label: t("users.tabs.storage") }, ]; const active = tabs.some((x) => x.id === tab) ? tab : "roles"; + // The address names the tab on screen (always: see SettingsPanel). + useUrlMirror({ [USERS_TAB_URL_KEY]: active }); return ( <> {/* The tab bar is fixed chrome; the tab content scrolls under it. */} diff --git a/frontend/src/modules/users/module.ts b/frontend/src/modules/users/module.ts index 5f34c253..2b9b7ea5 100644 --- a/frontend/src/modules/users/module.ts +++ b/frontend/src/modules/users/module.ts @@ -1,6 +1,7 @@ import { Users } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { USERS_TAB_URL_KEY } from "../../lib/adminUsersTab"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const usersModule: ModuleDef = { @@ -13,5 +14,5 @@ export const usersModule: ModuleDef = { gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./AdminUsers")), maxWidth: "48rem", - urlKeys: [], + urlKeys: [USERS_TAB_URL_KEY], };