diff --git a/src/components/layout/sidebar-scope-href.test.ts b/src/components/layout/sidebar-scope-href.test.ts new file mode 100644 index 0000000..3b1a5eb --- /dev/null +++ b/src/components/layout/sidebar-scope-href.test.ts @@ -0,0 +1,49 @@ +// Unit test for the sidebar's scopeAwareHref helper. The behaviour +// itself lives in sidebar.tsx (a "use client" component), but the +// pure helper is exported here so it can be tested without React. +// +// The contract: a sidebar nav link to /foo must carry the current +// `?site=` forward so the URL stays the single source of truth for +// the topbar pill. Without this, picking a site in the switcher +// updates URL+cookie, but clicking any sidebar link would strip +// `?site=` and leave the pill showing "All sites · N" while the +// page's data still filtered through the cookie. + +import { describe, expect, it } from "vitest"; +import { scopeAwareHref } from "./sidebar-scope-href"; + +describe("scopeAwareHref", () => { + it("returns the bare path when no scope is active", () => { + expect(scopeAwareHref("/dashboard/sites", new URLSearchParams())).toBe( + "/dashboard/sites", + ); + }); + + it("preserves ?site= when one is active", () => { + const params = new URLSearchParams("site=abc123"); + expect(scopeAwareHref("/dashboard/sites", params)).toBe( + "/dashboard/sites?site=abc123", + ); + }); + + it("preserves only ?site= and drops unrelated params", () => { + const params = new URLSearchParams("site=abc123&foo=bar&baz=qux"); + expect(scopeAwareHref("/dashboard/financial/purchase-orders", params)).toBe( + "/dashboard/financial/purchase-orders?site=abc123", + ); + }); + + it("url-encodes the site id so paths with special chars still work", () => { + const params = new URLSearchParams("site=has%20space"); + expect(scopeAwareHref("/dashboard/sites", params)).toBe( + "/dashboard/sites?site=has%20space", + ); + }); + + it("treats 'all' as no scope — bare path, no query string", () => { + const params = new URLSearchParams("site=all"); + expect(scopeAwareHref("/dashboard/sites", params)).toBe( + "/dashboard/sites", + ); + }); +}); diff --git a/src/components/layout/sidebar-scope-href.ts b/src/components/layout/sidebar-scope-href.ts new file mode 100644 index 0000000..fc1b8fc --- /dev/null +++ b/src/components/layout/sidebar-scope-href.ts @@ -0,0 +1,27 @@ +/** + * Build a sidebar nav href that preserves the current `?site=` scope. + * + * The pill reads its active value from `useSearchParams()` and a + * server-rendered cookie fallback — and Next.js layouts do NOT + * re-render on navigation, so the cookie fallback is frozen at the + * value it had when the user first hit the dashboard. Without this + * preservation, picking a site would update both the URL and the + * cookie, then clicking any sidebar link would strip the URL to a + * bare path: the page's `getSiteScope()` would fall back to the + * (still-set) cookie and filter the data correctly, but the pill + * would resolve to `mode: "all"` and display "All sites · N" — + * pill and data disagreeing is the user-visible bug. + * + * Only the `?site=` param is preserved. Every other query string on + * the current URL is dropped because the sidebar links are coarse + * top-level routes that don't reference them. The literal `all` + * sentinel is treated as "no scope" so a sidebar link from an + * already-"All sites" URL stays clean. + */ +import { SITE_SCOPE_ALL, SITE_SCOPE_PARAM } from "@/lib/site-scope"; + +export function scopeAwareHref(path: string, currentParams: URLSearchParams): string { + const site = currentParams.get(SITE_SCOPE_PARAM); + if (!site || site === SITE_SCOPE_ALL) return path; + return `${path}?${SITE_SCOPE_PARAM}=${encodeURIComponent(site)}`; +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index adb9dae..f46e8b1 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { Building2, Coins, @@ -21,6 +21,7 @@ import { X, } from "lucide-react"; import { SiteScopeSwitcher } from "@/components/layout/site-scope-switcher"; +import { scopeAwareHref } from "@/components/layout/sidebar-scope-href"; import { Logo } from "@/components/logo"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; @@ -106,6 +107,7 @@ function groupIsActive(group: NavGroup, pathname: string): boolean { export function Sidebar() { const pathname = usePathname(); + const params = useSearchParams(); const { lock } = useAuth(); const [mobileOpen, setMobileOpen] = React.useState(false); @@ -129,7 +131,7 @@ export function Sidebar() { const content = (
- +