feat(ui): link organization teams to their team detail pages (#35120)
Some checks failed
CodSpeed Benchmarks / benchmarks (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled

* 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:
ryan-crabbe-berri 2026-07-29 17:20:59 -07:00 committed by GitHub
parent 551e5d097c
commit 0a6b372126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 161 additions and 13 deletions

View file

@ -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);

View file

@ -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>

View file

@ -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();
});
});

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { migratedHref } from "@/utils/migratedPages";
export function teamDetailHref(teamId: string): string {
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
}