Merge pull request #36897 from BerriAI/litellm_standard_page_header

feat(ui): standardize the Teams page header
This commit is contained in:
yuneng-jiang 2026-08-19 18:52:55 -07:00 committed by GitHub
commit a0f367fcd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 215 additions and 76 deletions

View file

@ -3,7 +3,7 @@ import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDe
import { Plus, SearchIcon, X } from "lucide-react";
import { useMemo, useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { PageHeader } from "@/components/shared/PageHeader";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
@ -61,7 +61,7 @@ export function AccessGroupsPage() {
return (
<div className="p-6 px-12">
<div className="mb-4">
<PageHeader
<LegacyPageHeader
title="Access Groups"
subtitle="Manage resource permissions for your organization"
actions={

View file

@ -6,7 +6,7 @@
import { Plus, Wallet } from "lucide-react";
import React, { useCallback, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { PageHeader } from "@/components/shared/PageHeader";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -76,7 +76,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
return (
<div className="flex h-full flex-col gap-4 p-6 px-12">
<PageHeader
<LegacyPageHeader
icon={<Wallet className="size-5" />}
title="Budgets"
subtitle="Spend, TPM and RPM limits you can assign to customers."

View file

@ -3,7 +3,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Plus, SearchIcon, X } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
import { PageHeader } from "@/components/shared/PageHeader";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
@ -56,7 +56,7 @@ export function ProjectsPage() {
return (
<div className="p-6 px-12">
<div className="mb-4">
<PageHeader
<LegacyPageHeader
title="Projects"
subtitle="Manage projects within your teams"
actions={

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing";
import React from "react";
@ -504,6 +504,15 @@ describe("Teams - team detail deep link (?team=)", () => {
expect(onUrlUpdate.mock.calls.at(-1)![0].searchParams.has("team")).toBe(false);
await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument());
});
it("should preserve the legacy inset for the team detail view", async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
searchParams: "?team=team-from-url",
});
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
expect(screen.getByRole("main")).toHaveClass("px-12", "py-6");
});
});
describe("Teams - Create Team CTA is grouped with the tabs on the left", () => {
@ -512,21 +521,19 @@ describe("Teams - Create Team CTA is grouped with the tabs on the left", () => {
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("renders the Create Team button inside the tab bar, ahead of the tabs", () => {
it("should render the Create Team button inside the tab bar, ahead of the tabs", () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const createButton = screen.getByTestId("create-team-button");
const tabBar = screen.getByRole("tablist").parentElement!;
const tabNav = screen.getByRole("tablist");
const createButton = within(tabNav).getByTestId("create-team-button");
const firstTab = within(tabNav).getByRole("tab", { name: "Your Teams" });
// The CTA lives in the tab bar's left slot, not the standalone page header.
expect(tabBar.contains(createButton)).toBe(true);
// It reads as the left end of the cluster: it precedes the first tab in DOM order.
const firstTab = screen.getByRole("tab", { name: "Your Teams" });
expect(screen.getByRole("main")).toHaveClass("p-8");
expect(within(tabNav).getByRole("separator")).toBeInTheDocument();
expect(createButton.compareDocumentPosition(firstTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("omits the Create Team CTA for a role that cannot manage teams", () => {
it("should omit the Create Team CTA for a role that cannot manage teams", () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin Viewer" />);
expect(screen.queryByTestId("create-team-button")).not.toBeInTheDocument();
});

View file

@ -15,7 +15,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect";
import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint";
import { useZodForm } from "@/lib/forms/useZodForm";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { Layout, Tabs, theme } from "antd";
import { Layout, Tabs } from "antd";
import { ChevronDown, Plus, Users } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
@ -542,7 +542,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
return false;
};
const { token } = theme.useToken();
const { Content } = Layout;
const tabItems = [
@ -612,7 +611,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
];
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
<Content className={selectedTeamId ? "px-12 py-6" : "p-8"}>
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
@ -632,30 +631,26 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
premiumUser={premiumUser}
/>
) : (
<>
<div className="mb-4">
<PageHeader
icon={<Users className="size-5" />}
title="Teams"
subtitle="Manage teams, members, and their access to models and budgets"
<PageHeader
icon={<Users />}
title="Teams"
subtitle="Manage teams, members, and their access to models and budgets"
primaryAction={
canCreateOrManageTeams(userRole, userID, organizations) ? (
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
</UIButton>
) : undefined
}
tabs={({ leadingControls }) => (
<Tabs
items={tabItems}
tabBarExtraContent={{ left: leadingControls }}
className="[&>.ant-tabs-nav]:!mb-6 [&>.ant-tabs-nav]:before:!border-b-0 [&_.ant-tabs-ink-bar]:!h-0.5 [&_.ant-tabs-tab]:!py-[7px] [&_.ant-tabs-tab+_.ant-tabs-tab]:!ml-[22px] [&_.ant-tabs-tab-active]:font-semibold"
/>
</div>
<Tabs
items={tabItems}
tabBarExtraContent={{
left: canCreateOrManageTeams(userRole, userID, organizations) ? (
<div className="flex items-center gap-4 pr-4">
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
</UIButton>
<div className="h-6 w-px bg-gray-200" />
</div>
) : undefined,
}}
/>
</>
)}
/>
)}
{canCreateOrManageTeams(userRole, userID, organizations) && (

View file

@ -12,7 +12,7 @@ import {
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { PageHeader } from "@/components/shared/PageHeader";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { Input } from "@/components/ui/input";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
@ -172,7 +172,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
return (
<div className="flex h-full flex-col gap-4 overflow-hidden py-2">
<PageHeader
<LegacyPageHeader
icon={<KeyRound className="size-5" />}
title="Virtual Keys"
subtitle="Every key that authenticates requests to the gateway."

View file

@ -0,0 +1,33 @@
import { renderWithProviders, screen } from "@/../tests/test-utils";
import { describe, expect, it } from "vitest";
import { LegacyPageHeader } from "./LegacyPageHeader";
describe("LegacyPageHeader", () => {
it("should render the title as a heading", () => {
renderWithProviders(<LegacyPageHeader title="Virtual Keys" />);
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
});
it("should render the optional identity and actions", () => {
renderWithProviders(
<LegacyPageHeader
title="Virtual Keys"
subtitle="Every key that authenticates requests"
icon={<span>Key icon</span>}
actions={<button>Create New Key</button>}
/>,
);
expect(screen.getByText("Every key that authenticates requests")).toBeInTheDocument();
expect(screen.getByText("Key icon")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
});
it("should omit optional actions when none are provided", () => {
renderWithProviders(<LegacyPageHeader title="Virtual Keys" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
interface LegacyPageHeaderProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
actions?: React.ReactNode;
}
export function LegacyPageHeader({ title, subtitle, icon, actions }: LegacyPageHeaderProps) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-2.5">
{icon != null && <span className="flex flex-none items-center text-foreground">{icon}</span>}
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle != null && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions != null && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

View file

@ -1,31 +1,77 @@
import { render, screen } from "@testing-library/react";
import { renderWithProviders, screen, within } from "@/../tests/test-utils";
import { describe, expect, it } from "vitest";
import { PageHeader } from "./PageHeader";
const identity = {
icon: <span>Teams icon</span>,
title: "Teams",
subtitle: "Manage teams, members, and their access to models and budgets",
};
describe("PageHeader", () => {
it("renders the title as a heading", () => {
render(<PageHeader title="Virtual Keys" />);
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
it("should render the page identity", () => {
renderWithProviders(<PageHeader {...identity} />);
expect(screen.getByRole("heading", { name: "Teams" })).toBeInTheDocument();
expect(screen.getByText("Teams icon").parentElement).toHaveAttribute("aria-hidden", "true");
expect(screen.getByText(identity.subtitle)).toBeInTheDocument();
});
it("renders the subtitle, icon, and actions when provided", () => {
render(
it("should apply the standard title and subtext typography", () => {
renderWithProviders(<PageHeader {...identity} />);
const icon = screen.getByText("Teams icon").parentElement;
expect(screen.getByRole("heading", { name: "Teams" })).toHaveClass("text-2xl", "font-semibold", "tracking-tight");
expect(screen.getByText(identity.subtitle)).toHaveClass("mt-1.5", "text-sm", "text-muted-foreground");
expect(icon).toHaveClass("size-5", "[&_svg]:size-5", "[&_svg]:stroke-[1.75]");
expect(icon?.parentElement).toHaveClass("gap-2.5");
});
it("should render the primary action, divider, tabs, and utilities in the standard control row", () => {
renderWithProviders(
<PageHeader
title="Virtual Keys"
subtitle="Every key that authenticates requests"
icon={<svg data-testid="icon" />}
actions={<button>Create New Key</button>}
{...identity}
primaryAction={<button>Create Team</button>}
tabs={
<div role="tablist">
<button role="tab">Your Teams</button>
</div>
}
utilities={<button>Refresh</button>}
/>,
);
expect(screen.getByText("Every key that authenticates requests")).toBeInTheDocument();
expect(screen.getByTestId("icon")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
const controls = screen.getByRole("group", { name: "Page controls" });
expect(controls).toHaveClass("mt-5", "h-9");
expect(within(controls).getByRole("separator")).toHaveClass("mx-4", "h-6");
expect(controls).toHaveTextContent("Create TeamYour TeamsRefresh");
});
it("omits the optional slots when not provided", () => {
render(<PageHeader title="Virtual Keys" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(document.querySelector("p")).toBeNull();
it("should omit the divider when tabs are absent", () => {
renderWithProviders(<PageHeader {...identity} primaryAction={<button>Create Team</button>} />);
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
});
it("should provide standard controls to an embedded tab shell", () => {
renderWithProviders(
<PageHeader
{...identity}
primaryAction={<button>Create Team</button>}
tabs={({ leadingControls, utilities }) => (
<div role="tablist">
{leadingControls}
<button role="tab">Your Teams</button>
{utilities}
</div>
)}
utilities={<button>Refresh</button>}
/>,
);
const tabs = screen.getByRole("tablist");
expect(within(tabs).getByRole("separator")).toBeInTheDocument();
expect(tabs).toHaveTextContent("Create TeamYour TeamsRefresh");
});
});

View file

@ -2,24 +2,57 @@
import * as React from "react";
interface PageHeaderProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
actions?: React.ReactNode;
import { ToolbarSeparator } from "./ToolbarSeparator";
interface EmbeddedTabsSlots {
leadingControls: React.ReactNode;
utilities: React.ReactNode;
}
export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-2.5">
{icon != null && <span className="flex flex-none items-center text-foreground">{icon}</span>}
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle != null && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
</div>
interface PageHeaderProps {
title: React.ReactNode;
subtitle: React.ReactNode;
icon: React.ReactNode;
primaryAction?: React.ReactNode;
tabs?: React.ReactNode | ((slots: EmbeddedTabsSlots) => React.ReactNode);
utilities?: React.ReactNode;
}
export function PageHeader({ title, subtitle, icon, primaryAction, tabs, utilities }: PageHeaderProps) {
const leadingControls =
primaryAction == null ? null : (
<div className="flex h-9 items-center">
{primaryAction}
{tabs != null && <ToolbarSeparator className="mx-4 h-6" />}
</div>
{actions != null && <div className="flex items-center gap-2">{actions}</div>}
);
const utilityControls = utilities == null ? null : <div className="flex items-center gap-2">{utilities}</div>;
const hasControlRow = primaryAction != null || tabs != null || utilities != null;
return (
<div>
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex size-5 flex-none items-center justify-center text-foreground [&_svg]:size-5 [&_svg]:stroke-[1.75]"
>
{icon}
</span>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
{typeof tabs === "function" ? (
<div className="mt-5">{tabs({ leadingControls, utilities: utilityControls })}</div>
) : (
hasControlRow && (
<div className="mt-5 flex h-9 items-center" role="group" aria-label="Page controls">
{leadingControls}
{tabs}
{utilityControls != null && <div className="ml-auto">{utilityControls}</div>}
</div>
)
)}
</div>
);
}