mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36897 from BerriAI/litellm_standard_page_header
feat(ui): standardize the Teams page header
This commit is contained in:
commit
a0f367fcd1
10 changed files with 215 additions and 76 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue