diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 7fed639c491..ec7baecad2d 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -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( + {}} + 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( + {}} + 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); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 44b67765850..10af5bc1a07 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -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 = ({

Models

{orgData.models.length === 0 ? ( - All proxy models + All proxy models ) : ( - orgData.models.map((model, index) => ( - - {model} - - )) + orgData.models.map((model, index) => {model}) )}
@@ -237,9 +234,9 @@ const OrganizationInfoView: React.FC = ({

Teams

{orgData.teams?.map((team, index) => ( - + {teamAliasMap[team.team_id] || team.team_id} - + ))}
@@ -309,9 +306,7 @@ const OrganizationInfoView: React.FC = ({

Models

{orgData.models.map((model, index) => ( - - {model} - + {model} ))}
diff --git a/ui/litellm-dashboard/src/components/shared/BadgeLink.test.tsx b/ui/litellm-dashboard/src/components/shared/BadgeLink.test.tsx new file mode 100644 index 00000000000..10a192c8af0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/BadgeLink.test.tsx @@ -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(My Team); + 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(My Team); + 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(My Team); + 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(all-proxy-models); + expect(screen.getByText("all-proxy-models")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "all-proxy-models" })).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx b/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx new file mode 100644 index 00000000000..444d2acdcfe --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx @@ -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["variant"]; + className?: string; + children: React.ReactNode; +} + +export function BadgeLink({ href, variant = "secondary", className, children }: BadgeLinkProps) { + const router = useRouter(); + + if (!href) { + return ( + + {children} + + ); + } + + 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 ( + } + > + {children} + + ); +} diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts new file mode 100644 index 00000000000..2659a307866 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -0,0 +1,5 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export function teamDetailHref(teamId: string): string { + return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`; +}