refactor(ui): adopt shared tab-routing helpers + anchor TabRouteBar in Caching

Now that createTabRoutes and useTabRouting are on staging, replace
Caching's hand-written tabRoutes.ts and layout routing engine with them,
and introduce the shared <TabRouteBar>: a shadcn tab bar whose triggers
render as real anchors (via Base UI's render prop). A plain left click
soft-navigates through the router; a modifier-click falls through to the
browser so open-in-new-tab / new-window work, mirroring the sidebar's
own anchor convention. tabRoutes.ts collapses to one createTabRoutes
call and the per-page tabRoutes.test.ts is dropped (the factory is
covered centrally by utils/tabRoutes.test.ts).
This commit is contained in:
ryan-crabbe-berri 2026-07-23 16:47:39 -07:00
parent 6a69d20b5d
commit ab7a3175cd
6 changed files with 132 additions and 107 deletions

View file

@ -33,30 +33,24 @@ describe("CachingLayout", () => {
};
});
it("renders the tab bar and the active tab's page content", () => {
it("renders the four tabs and the active tab's page content", () => {
const { getByRole, getByTestId } = renderLayout();
expect(getByRole("tab", { name: "Cache Analytics" })).toBeInTheDocument();
expect(getByRole("tab", { name: "Cache Health" })).toBeInTheDocument();
expect(getByRole("tab", { name: "Cache Settings" })).toBeInTheDocument();
expect(getByRole("tab", { name: "Coordination Redis" })).toBeInTheDocument();
for (const name of ["Cache Analytics", "Cache Health", "Cache Settings", "Coordination Redis"]) {
expect(getByRole("tab", { name })).toBeInTheDocument();
}
expect(getByTestId("tab-content")).toHaveTextContent("CHILD");
});
it("navigates to a tab's path when its tab is clicked", async () => {
it("marks the base route's Cache Analytics tab active", () => {
const { getByRole } = renderLayout();
await act(async () => {
getByRole("tab", { name: "Cache Health" }).click();
});
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/caching\/health\/$/));
expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("aria-selected", "true");
});
it("routes the base tab back to the caching root (no slug)", async () => {
navState.pathname = "/caching/health";
it("derives the active tab from a nested pathname", () => {
navState.pathname = "/ui/caching/settings";
const { getByRole } = renderLayout();
await act(async () => {
getByRole("tab", { name: "Cache Analytics" }).click();
});
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/caching\/$/));
expect(getByRole("tab", { name: "Cache Settings" })).toHaveAttribute("aria-selected", "true");
expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("aria-selected", "false");
});
it("redirects to the base caching path when the tab slug is unknown", async () => {

View file

@ -1,48 +1,29 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cacheTabHref, slugFromPathname, type CacheTabSlug } from "@/app/(dashboard)/caching/tabRoutes";
import { cachingRoutes } from "@/app/(dashboard)/caching/tabRoutes";
import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting";
import { TabRouteBar } from "@/app/(dashboard)/components/TabRouteBar";
const BASE_TAB_KEY = "cache-analytics";
const ORDERED_KEYS: Array<"" | CacheTabSlug> = ["", "health", "settings", "coordination-redis"];
const TAB_LABELS: Record<"" | CacheTabSlug, string> = {
"": "Cache Analytics",
health: "Cache Health",
settings: "Cache Settings",
"coordination-redis": "Coordination Redis",
};
const TABS = [
{ key: BASE_TAB_KEY, label: "Cache Analytics" },
{ key: "health", label: "Cache Health" },
{ key: "settings", label: "Cache Settings" },
{ key: "coordination-redis", label: "Coordination Redis" },
] as const;
export default function CachingLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const activeSlug = slugFromPathname(pathname);
const isKnownSlug = ORDERED_KEYS.some((slug) => slug === activeSlug);
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
useEffect(() => {
if (activeSlug !== "" && !isKnownSlug) {
window.location.replace(cacheTabHref(""));
}
}, [activeSlug, isKnownSlug]);
const { activeKey } = useTabRouting({
routes: cachingRoutes,
baseTabKey: BASE_TAB_KEY,
visibleKeys: cachingRoutes.slugs,
});
return (
<div className="p-8 w-full mt-2 mb-8">
<Tabs value={activeKey} onValueChange={(key) => router.push(cacheTabHref(key === BASE_TAB_KEY ? "" : key))}>
<TabsList variant="line">
{ORDERED_KEYS.map((slug) => (
<TabsTrigger key={slug || BASE_TAB_KEY} value={slug || BASE_TAB_KEY}>
{TAB_LABELS[slug]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<TabRouteBar routes={cachingRoutes} baseTabKey={BASE_TAB_KEY} activeKey={activeKey} tabs={TABS} />
<div className="mt-4">{children}</div>
</div>
);

View file

@ -1,38 +0,0 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { CACHE_TAB_SLUGS, cacheTabHref, slugFromPathname } from "./tabRoutes";
describe("slugFromPathname", () => {
it("returns empty string for the base path with or without a trailing slash", () => {
expect(slugFromPathname("/caching")).toBe("");
expect(slugFromPathname("/caching/")).toBe("");
});
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
expect(slugFromPathname("/caching/health")).toBe("health");
expect(slugFromPathname("/ui/caching/coordination-redis/")).toBe("coordination-redis");
});
it("returns the raw segment for an unknown tab so the layout can redirect to base", () => {
expect(slugFromPathname("/ui/caching/bogus")).toBe("bogus");
});
it("returns empty string when the caching base segment is not in the path", () => {
expect(slugFromPathname("/teams")).toBe("");
});
});
describe("cacheTabHref", () => {
it("builds the trailing-slash base href for the empty slug", () => {
expect(cacheTabHref("")).toBe("/ui/caching/");
});
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
for (const slug of CACHE_TAB_SLUGS) {
expect(cacheTabHref(slug)).toBe(`/ui/caching/${slug}/`);
}
});
});

View file

@ -1,21 +1,5 @@
import { migratedHref } from "@/utils/migratedPages";
import { createTabRoutes } from "@/utils/tabRoutes";
export const CACHING_BASE_SEGMENT = "caching";
export const cachingRoutes = createTabRoutes("caching", ["health", "settings", "coordination-redis"] as const);
export const CACHE_TAB_SLUGS = ["health", "settings", "coordination-redis"] as const;
export type CacheTabSlug = (typeof CACHE_TAB_SLUGS)[number];
export function cacheTabHref(slug: string): string {
const base = migratedHref(CACHING_BASE_SEGMENT);
return slug ? `${base}/${slug}/` : `${base}/`;
}
export function slugFromPathname(pathname: string): string {
const parts = pathname.split("/").filter(Boolean);
const idx = parts.indexOf(CACHING_BASE_SEGMENT);
if (idx === -1) {
return "";
}
return parts[idx + 1] ?? "";
}
export type CacheTabSlug = (typeof cachingRoutes.slugs)[number];

View file

@ -0,0 +1,56 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockPush } = vi.hoisted(() => ({ mockPush: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { createTabRoutes } from "@/utils/tabRoutes";
import { TabRouteBar } from "./TabRouteBar";
const routes = createTabRoutes("caching", ["health", "settings"] as const);
const TABS = [
{ key: "analytics", label: "Cache Analytics" },
{ key: "health", label: "Cache Health" },
{ key: "settings", label: "Cache Settings" },
];
const renderBar = (activeKey = "analytics") =>
render(<TabRouteBar routes={routes} baseTabKey="analytics" activeKey={activeKey} tabs={TABS} />);
describe("TabRouteBar", () => {
beforeEach(() => {
mockPush.mockClear();
});
it("renders each tab as an anchor with its trailing-slash href (base tab maps to the root)", () => {
const { getByRole } = renderBar();
expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("href", "/ui/caching/");
expect(getByRole("tab", { name: "Cache Health" })).toHaveAttribute("href", "/ui/caching/health/");
expect(getByRole("tab", { name: "Cache Settings" })).toHaveAttribute("href", "/ui/caching/settings/");
});
it("marks the active tab selected from activeKey", () => {
const { getByRole } = renderBar("health");
expect(getByRole("tab", { name: "Cache Health" })).toHaveAttribute("aria-selected", "true");
expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("aria-selected", "false");
});
it("soft-navigates on a plain left click (preventing the full-page anchor load)", async () => {
const user = userEvent.setup();
const { getByRole } = renderBar();
await user.click(getByRole("tab", { name: "Cache Health" }));
expect(mockPush).toHaveBeenCalledWith("/ui/caching/health/");
});
it("lets the browser handle a modifier-click so open-in-new-tab works", async () => {
const user = userEvent.setup();
const { getByRole } = renderBar();
await user.keyboard("[ControlLeft>]");
await user.click(getByRole("tab", { name: "Cache Health" }));
await user.keyboard("[/ControlLeft]");
expect(mockPush).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,48 @@
"use client";
import type { MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { TabRoutes } from "@/utils/tabRoutes";
export interface TabRouteItem {
key: string;
label: string;
}
interface TabRouteBarProps {
routes: Pick<TabRoutes<string>, "tabHref">;
baseTabKey: string;
activeKey: string;
tabs: readonly TabRouteItem[];
className?: string;
}
export function TabRouteBar({ routes, baseTabKey, activeKey, tabs, className }: TabRouteBarProps) {
const router = useRouter();
const navigate = (href: string) => (event: MouseEvent<HTMLAnchorElement>) => {
const commandModifier = event.metaKey || event.ctrlKey;
const otherModifier = event.shiftKey || event.altKey;
if (commandModifier || otherModifier) {
return;
}
event.preventDefault();
router.push(href);
};
return (
<Tabs value={activeKey} className={className}>
<TabsList variant="line">
{tabs.map(({ key, label }) => {
const href = routes.tabHref(key === baseTabKey ? "" : key);
return (
<TabsTrigger key={key} value={key} render={<a href={href} onClick={navigate(href)} />}>
{label}
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
);
}