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,
|
||||
} from "./lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { LS, setTabRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||
@@ -249,7 +249,7 @@ export default function App() {
|
||||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void refetchMe();
|
||||
setAccountRaw(LS.settingsTab, "account");
|
||||
setTabRaw(LS.settingsTab, "account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
|
||||
@@ -41,7 +41,7 @@ import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { useAccountPersistedState } from "../lib/storage";
|
||||
import { useTabPersistedState } from "../lib/storage";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
@@ -69,7 +69,7 @@ const ROW_OPEN =
|
||||
export default function AdminUsers() {
|
||||
const me = useMe();
|
||||
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 tabs = [
|
||||
{ id: "roles", label: t("users.tabs.roles") },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Switch } from "./ui/form";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
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 { Button } from "./ui/Button";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
@@ -56,7 +56,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] = 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
|
||||
// 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 { displayName, roleKey } from "../lib/api/account";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
@@ -68,7 +68,7 @@ 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] = useAccountPersistedState(LS.settingsTab, "appearance");
|
||||
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/fo
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./wizardContext";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { LS, useTabPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
@@ -34,7 +34,7 @@ export default function Stats() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
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).
|
||||
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||
const panel = useTabPanel();
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
||||
vi.mock("../lib/storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/storage")>()),
|
||||
useAccountPersistedState: () => ["access", () => {}],
|
||||
useTabPersistedState: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const listMock = vi.fn();
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
vi.mock("./ui/Tabs", () => ({ default: () => null }));
|
||||
vi.mock("../lib/storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/storage")>()),
|
||||
useAccountPersistedState: () => ["access", () => {}],
|
||||
useTabPersistedState: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
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
|
||||
// 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
|
||||
// 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";
|
||||
|
||||
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",
|
||||
channelsTable: "siftlode.channelsTable",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||||
// statsTab below.
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
configTab: "siftlode.configTab",
|
||||
auditTable: "siftlode.auditTable",
|
||||
@@ -174,6 +176,61 @@ export function useAccountPersistedState(
|
||||
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
|
||||
* 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). */
|
||||
|
||||
@@ -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