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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | null> {
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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<string, string | null> {
|
||||
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<PlexSearchUrlState> {
|
||||
const q = params.get("pq");
|
||||
const field = params.get("pqf");
|
||||
return {
|
||||
...(q != null && { q }),
|
||||
...(field != null && { field: parsePlexField(field) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<TabSelect>(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<string>(() => {
|
||||
// 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<TabSelect>;
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -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, "", "/");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user