fix(ui): a page's sub-tab is remembered per browser tab, not forever
Settings, Configuration, Statistics and Users kept the last sub-tab in localStorage, so every fresh tab opened Configuration on "Plex" as if that were its home. useTabPersistedState keeps it in sessionStorage (account-scoped): a reload and a trip to another page keep it, a new tab starts on the first sub-tab. The pre-selects (a Review link, the Google-link return) write the same store; a value an older build left in localStorage is cleared on first read.
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
|||||||
type PanelLayout,
|
type PanelLayout,
|
||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||||
import { LS, setAccountRaw } from "./lib/storage";
|
import { LS, setTabRaw } from "./lib/storage";
|
||||||
import { usePref } from "./lib/usePref";
|
import { usePref } from "./lib/usePref";
|
||||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||||
@@ -249,7 +249,7 @@ export default function App() {
|
|||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void refetchMe();
|
void refetchMe();
|
||||||
setAccountRaw(LS.settingsTab, "account");
|
setTabRaw(LS.settingsTab, "account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import AccountPanel from "./AccountPanel";
|
|||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
import { useAccountPersistedState } from "../lib/storage";
|
import { useTabPersistedState } from "../lib/storage";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||||
@@ -69,7 +69,7 @@ const ROW_OPEN =
|
|||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
|
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles");
|
||||||
const panel = useTabPanel();
|
const panel = useTabPanel();
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "roles", label: t("users.tabs.roles") },
|
{ id: "roles", label: t("users.tabs.roles") },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Switch } from "./ui/form";
|
|||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useTabPersistedState } from "../lib/storage";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { CheckRow } from "./ui/NativeControls";
|
import { CheckRow } from "./ui/NativeControls";
|
||||||
@@ -56,7 +56,7 @@ export default function ConfigPanel() {
|
|||||||
const [reseedToken, setReseedToken] = useState(0);
|
const [reseedToken, setReseedToken] = useState(0);
|
||||||
// One tab per config group (persisted). Called before the loading guard so hook order is
|
// 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.
|
// stable; the active id is clamped to a real group at render time once data has loaded.
|
||||||
const [tab, setTab] = useAccountPersistedState(LS.configTab);
|
const [tab, setTab] = useTabPersistedState(LS.configTab);
|
||||||
|
|
||||||
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
|
// 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
|
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
import { displayName, roleKey } from "../lib/api/account";
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
@@ -68,7 +68,7 @@ export default function SettingsPanel() {
|
|||||||
const prefs = usePrefs();
|
const prefs = usePrefs();
|
||||||
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
|
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
|
||||||
const { openWizard } = useWizard();
|
const { openWizard } = useWizard();
|
||||||
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
|
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
|
||||||
const panel = useTabPanel();
|
const panel = useTabPanel();
|
||||||
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||||
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/fo
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { useWizard } from "./wizardContext";
|
import { useWizard } from "./wizardContext";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useTabPersistedState } from "../lib/storage";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||||
@@ -34,7 +34,7 @@ export default function Stats() {
|
|||||||
const me = useMe();
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isAdmin = me.role === "admin";
|
const isAdmin = me.role === "admin";
|
||||||
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
|
const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview");
|
||||||
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||||
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||||
const panel = useTabPanel();
|
const panel = useTabPanel();
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
|||||||
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
||||||
vi.mock("../lib/storage", async (importOriginal) => ({
|
vi.mock("../lib/storage", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/storage")>()),
|
...(await importOriginal<typeof import("../lib/storage")>()),
|
||||||
useAccountPersistedState: () => ["access", () => {}],
|
useTabPersistedState: () => ["access", () => {}],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const listMock = vi.fn();
|
const listMock = vi.fn();
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
|||||||
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
||||||
vi.mock("../lib/storage", async (importOriginal) => ({
|
vi.mock("../lib/storage", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/storage")>()),
|
...(await importOriginal<typeof import("../lib/storage")>()),
|
||||||
useAccountPersistedState: () => ["access", () => {}],
|
useTabPersistedState: () => ["access", () => {}],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pendingMock = vi.fn();
|
const pendingMock = vi.fn();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { LS, setAccountRaw } from "./storage";
|
import { LS, setTabRaw } from "./storage";
|
||||||
|
|
||||||
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
|
// 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 —
|
// pre-select that tab before navigating there (a notification's "Review" link uses it —
|
||||||
// useAccountPersistedState reads the key on mount, and the page mounts fresh on navigation).
|
// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation).
|
||||||
//
|
//
|
||||||
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
|
// 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
|
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
|
||||||
@@ -11,5 +11,5 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
|||||||
const ADMIN_USERS_ACCESS_TAB = "access";
|
const ADMIN_USERS_ACCESS_TAB = "access";
|
||||||
|
|
||||||
export function focusAccessRequestsTab(): void {
|
export function focusAccessRequestsTab(): void {
|
||||||
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
|
setTabRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const LS = {
|
|||||||
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
||||||
channelsTable: "siftlode.channelsTable",
|
channelsTable: "siftlode.channelsTable",
|
||||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||||
|
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||||||
|
// statsTab below.
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: "siftlode.settingsTab",
|
||||||
configTab: "siftlode.configTab",
|
configTab: "siftlode.configTab",
|
||||||
auditTable: "siftlode.auditTable",
|
auditTable: "siftlode.auditTable",
|
||||||
@@ -174,6 +176,61 @@ export function useAccountPersistedState(
|
|||||||
return [value, set];
|
return [value, set];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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, account-scoped: it survives a reload and a trip to another page, and a new
|
||||||
|
// tab opens the page on its first sub-tab (the owner, E31: a position remembered forever in
|
||||||
|
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
|
||||||
|
|
||||||
|
/** The sub-tab stored for this browser tab, or null. */
|
||||||
|
function getTabRaw(base: string): string | null {
|
||||||
|
const k = accountKey(base);
|
||||||
|
try {
|
||||||
|
return k ? sessionStorage.getItem(k) : null;
|
||||||
|
} catch {
|
||||||
|
return 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 {
|
||||||
|
const k = accountKey(base);
|
||||||
|
if (!k) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(k, value);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */
|
||||||
|
export function useTabPersistedState(
|
||||||
|
base: string,
|
||||||
|
fallback = "",
|
||||||
|
): [string, (value: string) => void] {
|
||||||
|
const [value, setValue] = useState<string>(() => {
|
||||||
|
// Before E31 the position lived in localStorage; a value left there would never be read again.
|
||||||
|
const old = accountKey(base);
|
||||||
|
if (old) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(old);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getTabRaw(base) ?? fallback;
|
||||||
|
});
|
||||||
|
const set = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
setValue(next);
|
||||||
|
setTabRaw(base, next);
|
||||||
|
},
|
||||||
|
[base],
|
||||||
|
);
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
|
|
||||||
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
|
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
|
||||||
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
|
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
|
||||||
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
|
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E31 (the owner): a page's sub-tab was remembered forever in localStorage, so every fresh browser tab
|
||||||
|
// opened Configuration on "Plex" as if that were its home. The position is now kept per browser tab
|
||||||
|
// (sessionStorage): it survives a reload and a trip to another page, and a new tab starts on the first.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
|
||||||
|
import { setTabRaw, useTabPersistedState } from "./storage";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "1");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a page's sub-tab, per browser tab", () => {
|
||||||
|
it("starts on the fallback, then keeps the picked tab for this browser tab only", () => {
|
||||||
|
const first = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
|
||||||
|
expect(first.result.current[0]).toBe("access");
|
||||||
|
act(() => first.result.current[1]("plex"));
|
||||||
|
expect(sessionStorage.getItem("siftlode.configTab.1")).toBe("plex");
|
||||||
|
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
|
||||||
|
// The page mounts again in this tab (a reload, or back from another page): it is where it was.
|
||||||
|
first.unmount();
|
||||||
|
expect(
|
||||||
|
renderHook(() => useTabPersistedState("siftlode.configTab", "access")).result.current[0],
|
||||||
|
).toBe("plex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a position left in localStorage by an older build, and clears it", () => {
|
||||||
|
localStorage.setItem("siftlode.configTab.1", "plex");
|
||||||
|
const { result } = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
|
||||||
|
expect(result.current[0]).toBe("access");
|
||||||
|
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pre-selected tab is the one the page opens on", () => {
|
||||||
|
setTabRaw("siftlode.adminUsersTab", "access");
|
||||||
|
const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles"));
|
||||||
|
expect(result.current[0]).toBe("access");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user