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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user