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:
2026-10-26 01:13:39 +01:00
parent 91d36b3357
commit d4f24de4ba
34 changed files with 728 additions and 130 deletions
@@ -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 (
<div className="p-4 max-w-3xl w-full mx-auto">
@@ -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<typeof import("react-i18next")>()),
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: () => <p>tokens-tab</p> }));
vi.mock("./DiagnosticsSettings", () => ({ default: () => <p>diagnostics-tab</p> }));
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(
<QueryClientProvider client={new QueryClient()}>
<SettingsPanel />
</QueryClientProvider>,
);
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");
});
});
+2 -1
View File
@@ -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],
};