mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): link organization teams to their team detail pages (#35120)
* feat(ui): link organization teams to their team detail pages On the organization info page the teams shown for an org were plain badges, so walking to a team meant copying its id and finding it by hand on the teams page Team badges now link to /teams?team=<team_id>, which opens that team's detail page directly since #35112. Adds a shared BadgeLink (a badge rendered as a real anchor with modifier-aware client-side navigation, so cmd-click opens a new tab) and a teamDetailHref builder for reuse by future entity links * fix(ui): format BadgeLink, split its modifier-click chain, and size it up prettier wanted the Badge props wrapped, and local/no-long-condition-chain flagged the four-way modifier-click guard; the guard is now two named conditions. Linked badges also render slightly larger (text-sm, roomier padding) than plain badges so clickable entries stand out * feat(ui): size org model badges to match the linked team badges BadgeLink's href is now optional; without one it renders the same enlarged plain badge (no pointer, no hover), so the org page's model badges share the component and the size while staying non-clickable
This commit is contained in:
parent
551e5d097c
commit
0a6b372126
5 changed files with 161 additions and 13 deletions
|
|
@ -6,7 +6,14 @@ import { renderWithProviders } from "../../../tests/test-utils";
|
|||
import OrganizationInfoView from "./organization_view";
|
||||
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
|
||||
// Mock networking calls used by the component's mutation handlers
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/organizations",
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}));
|
||||
|
||||
// Mock networking calls used by the component's mutation handlers. entityLinks -> migratedPages
|
||||
// imports serverRootPath from the same module, so the mock must export it too.
|
||||
vi.mock("../networking", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
|
|
@ -14,6 +21,7 @@ vi.mock("../networking", () => {
|
|||
organizationMemberUpdateCall: vi.fn(),
|
||||
organizationMemberDeleteCall: vi.fn(),
|
||||
organizationUpdateCall: vi.fn(),
|
||||
serverRootPath: "",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -206,6 +214,58 @@ test("should display team ID as fallback when alias is not found", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("links each team badge to that team's detail page", async () => {
|
||||
const orgWithTeams = {
|
||||
...mockOrg,
|
||||
teams: [{ team_id: "team_123" }, { team_id: "team_456" }],
|
||||
};
|
||||
mockUseOrganization.mockReturnValue({ data: orgWithTeams, isLoading: false } as any);
|
||||
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={false}
|
||||
is_proxy_admin={false}
|
||||
userModels={[]}
|
||||
editOrg={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: "Engineering Team" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/teams?team=team_123"),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Marketing Team" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/teams?team=team_456"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("model badges stay non-clickable", async () => {
|
||||
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
|
||||
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={false}
|
||||
is_proxy_admin={false}
|
||||
userModels={[]}
|
||||
editOrg={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("link", { name: "gpt-4o-mini" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should keep unsaved settings edits when switching tabs and back", async () => {
|
||||
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { teamDetailHref } from "@/utils/entityLinks";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
|
@ -220,13 +221,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<p className="text-sm text-muted-foreground">Models</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.models.length === 0 ? (
|
||||
<Badge variant="secondary">All proxy models</Badge>
|
||||
<BadgeLink>All proxy models</BadgeLink>
|
||||
) : (
|
||||
orgData.models.map((model, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
))
|
||||
orgData.models.map((model, index) => <BadgeLink key={index}>{model}</BadgeLink>)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -237,9 +234,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<p className="text-sm text-muted-foreground">Teams</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<BadgeLink key={index} href={teamDetailHref(team.team_id)}>
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</Badge>
|
||||
</BadgeLink>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -309,9 +306,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<p className="font-medium text-foreground">Models</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{orgData.models.map((model, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
<BadgeLink key={index}>{model}</BadgeLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BadgeLink } from "./BadgeLink";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
describe("BadgeLink", () => {
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
});
|
||||
|
||||
it("renders an anchor pointing at the target href", () => {
|
||||
render(<BadgeLink href="/ui/teams?team=t1">My Team</BadgeLink>);
|
||||
expect(screen.getByRole("link", { name: "My Team" })).toHaveAttribute("href", "/ui/teams?team=t1");
|
||||
});
|
||||
|
||||
it("navigates client-side on plain click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BadgeLink href="/ui/teams?team=t1">My Team</BadgeLink>);
|
||||
await user.click(screen.getByRole("link", { name: "My Team" }));
|
||||
expect(push).toHaveBeenCalledWith("/ui/teams?team=t1");
|
||||
});
|
||||
|
||||
it("leaves modified clicks to the browser so new-tab shortcuts keep working", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BadgeLink href="/ui/teams?team=t1">My Team</BadgeLink>);
|
||||
await user.keyboard("{Meta>}");
|
||||
await user.click(screen.getByRole("link", { name: "My Team" }));
|
||||
await user.keyboard("{/Meta}");
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders a plain same-sized badge when no href is given", () => {
|
||||
render(<BadgeLink>all-proxy-models</BadgeLink>);
|
||||
expect(screen.getByText("all-proxy-models")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "all-proxy-models" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
ui/litellm-dashboard/src/components/shared/BadgeLink.tsx
Normal file
46
ui/litellm-dashboard/src/components/shared/BadgeLink.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const ENTITY_BADGE_SIZE = "px-2.5 py-1 text-sm";
|
||||
|
||||
interface BadgeLinkProps {
|
||||
href?: string;
|
||||
variant?: React.ComponentProps<typeof Badge>["variant"];
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BadgeLink({ href, variant = "secondary", className, children }: BadgeLinkProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<Badge variant={variant} className={cn(ENTITY_BADGE_SIZE, className)}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const hasModifierKey = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
const isNativeNewTabClick = hasModifierKey || e.button === 1;
|
||||
if (isNativeNewTabClick) return;
|
||||
e.preventDefault();
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn("cursor-pointer", ENTITY_BADGE_SIZE, className)}
|
||||
render={<a href={href} onClick={handleClick} />}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
5
ui/litellm-dashboard/src/utils/entityLinks.ts
Normal file
5
ui/litellm-dashboard/src/utils/entityLinks.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
export function teamDetailHref(teamId: string): string {
|
||||
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue