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
@@ -35,6 +35,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
vi.mock("../../lib/errorDialog", () => ({ reportError: vi.fn() }));
import AdminUsers from "./AdminUsers";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { api, type AdminUserRow } from "../../lib/api";
import { relativeTime } from "../../lib/format";
import i18n from "../../i18n";
@@ -190,3 +191,32 @@ describe("a user action that fails", () => {
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
});
});
// The URL is the view: the tab on screen is named in the address, so a bookmark opens it.
describe("the tab in the address", () => {
afterEach(() => window.history.replaceState(null, "", "/"));
const utab = () => new URLSearchParams(window.location.search).get("utab");
const selected = () =>
screen.getAllByRole("tab").find((el) => el.getAttribute("aria-selected") === "true")
?.textContent;
it("opens on the tab a link names, and names the one picked", () => {
window.history.replaceState(null, "", "/?page=users&utab=storage");
vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
// The tab bar portals into the shell's toolbar band.
const slot = document.body.appendChild(document.createElement("div"));
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PageToolbarSlot.Provider value={slot}>
<AdminUsers />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
expect(selected()).toBe("users.tabs.storage");
expect(utab()).toBe("storage");
fireEvent.click(screen.getByRole("tab", { name: "users.tabs.roles" }));
expect(utab()).toBe("roles");
});
});
+5 -2
View File
@@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account";
import { notify } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../../lib/adminUsersTab";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
@@ -42,6 +42,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { useTabPersistedState } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { PageToolbar } from "../../components/ui/PageShell";
import { useMe } from "../../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../../lib/actionErrors";
@@ -71,7 +72,7 @@ const ROW_OPEN =
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles", USERS_TAB_URL_KEY);
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
@@ -87,6 +88,8 @@ export default function AdminUsers() {
{ id: "storage", label: t("users.tabs.storage") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
// The address names the tab on screen (always: see SettingsPanel).
useUrlMirror({ [USERS_TAB_URL_KEY]: active });
return (
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Users } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { USERS_TAB_URL_KEY } from "../../lib/adminUsersTab";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const usersModule: ModuleDef = {
@@ -13,5 +14,5 @@ export const usersModule: ModuleDef = {
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./AdminUsers")),
maxWidth: "48rem",
urlKeys: [],
urlKeys: [USERS_TAB_URL_KEY],
};