[Feature] UI - Teams: Modernize teams table with AntD, server-side pagination, and v2 API

Migrate the OldTeams table from Tremor to Ant Design components, matching the
Access Groups page pattern. Switch from /team/list to /v2/team/list for
server-side pagination, filtering, and sorting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-19 22:42:15 -07:00
parent e5baa2232f
commit 883611aa91
6 changed files with 589 additions and 536 deletions

View file

@ -9,7 +9,7 @@ import AgentsPanel from "@/components/agents";
import BudgetPanel from "@/components/budgets/budget_panel"; import BudgetPanel from "@/components/budgets/budget_panel";
import CacheDashboard from "@/components/cache_dashboard"; import CacheDashboard from "@/components/cache_dashboard";
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
import { fetchTeams } from "@/components/common_components/fetch_teams"; import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
import LoadingScreen from "@/components/common_components/LoadingScreen"; import LoadingScreen from "@/components/common_components/LoadingScreen";
import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import { CostTrackingSettings } from "@/components/CostTrackingSettings";
import GeneralSettings from "@/components/general_settings"; import GeneralSettings from "@/components/general_settings";
@ -339,7 +339,9 @@ function CreateKeyPageContent() {
fetchUserModels(userID, userRole, accessToken, setUserModels); fetchUserModels(userID, userRole, accessToken, setUserModels);
} }
if (accessToken && userID && userRole) { if (accessToken && userID && userRole) {
fetchTeams(accessToken, userID, userRole, null, setTeams); v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
}).then((response) => setTeams(response.teams ?? [])).catch(console.error);
} }
if (accessToken) { if (accessToken) {
fetchOrganizations(accessToken, setOrganizations); fetchOrganizations(accessToken, setOrganizations);

View file

@ -18,8 +18,8 @@ vi.mock("./networking", () => ({
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
})); }));
vi.mock("./common_components/fetch_teams", () => ({ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
fetchTeams: vi.fn(), teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }),
})); }));
vi.mock("./molecules/notifications_manager", () => ({ vi.mock("./molecules/notifications_manager", () => ({
@ -375,6 +375,9 @@ describe("OldTeams - handleCreate organization handling", () => {
organizations={[]} organizations={[]}
/>, />,
); );
await waitFor(() => {
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
});
const deleteTeamButton = screen.getByTestId("delete-team-button"); const deleteTeamButton = screen.getByTestId("delete-team-button");
act(() => { act(() => {
fireEvent.click(deleteTeamButton); fireEvent.click(deleteTeamButton);
@ -389,7 +392,7 @@ describe("OldTeams - empty state", () => {
mockUseOrganizations.mockReturnValue({ data: [] }); mockUseOrganizations.mockReturnValue({ data: [] });
}); });
it("should display empty state message when teams array is empty", () => { it("should display empty state message when teams array is empty", async () => {
renderWithQueryClient( renderWithQueryClient(
<OldTeams <OldTeams
teams={[]} teams={[]}
@ -402,11 +405,13 @@ describe("OldTeams - empty state", () => {
/>, />,
); );
expect(screen.getByText("No teams found")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument(); expect(screen.getByText("No teams yet")).toBeInTheDocument();
});
expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument();
}); });
it("should display empty state message when teams is null", () => { it("should display empty state message when teams is null", async () => {
renderWithQueryClient( renderWithQueryClient(
<OldTeams <OldTeams
teams={null} teams={null}
@ -419,11 +424,13 @@ describe("OldTeams - empty state", () => {
/>, />,
); );
expect(screen.getByText("No teams found")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument(); expect(screen.getByText("No teams yet")).toBeInTheDocument();
});
expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument();
}); });
it("should not display empty state when teams array has items", () => { it("should not display empty state when teams array has items", async () => {
renderWithQueryClient( renderWithQueryClient(
<OldTeams <OldTeams
teams={[ teams={[
@ -451,9 +458,11 @@ describe("OldTeams - empty state", () => {
/>, />,
); );
expect(screen.queryByText("No teams found")).not.toBeInTheDocument(); await waitFor(() => {
expect(screen.queryByText("Adjust your filters or create a new team")).not.toBeInTheDocument(); expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getByText("Test Team")).toBeInTheDocument(); });
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
expect(screen.queryByText("Create your first team to organize members and manage access to models.")).not.toBeInTheDocument();
}); });
}); });
@ -621,12 +630,9 @@ describe("OldTeams - premium props", () => {
/>, />,
); );
const truncatedTeamId = "team-123456789".slice(0, 7); const teamIdElement = await screen.findByText("team-123456789");
const teamButton = await screen.findByRole("button", {
name: new RegExp(`${truncatedTeamId}\\.\\.\\.`),
});
act(() => { act(() => {
fireEvent.click(teamButton); fireEvent.click(teamIdElement);
}); });
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
@ -798,7 +804,7 @@ describe("OldTeams - access_group_ids in team create", () => {
/>, />,
); );
const createButton = screen.getByRole("button", { name: /create new team/i }); const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
act(() => { act(() => {
fireEvent.click(createButton); fireEvent.click(createButton);
}); });
@ -823,7 +829,8 @@ describe("OldTeams - access_group_ids in team create", () => {
const accessGroupInput = screen.getByTestId("access-group-selector"); const accessGroupInput = screen.getByTestId("access-group-selector");
fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } }); fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } });
const createTeamSubmitButton = screen.getByRole("button", { name: /create team/i }); const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1];
fireEvent.click(createTeamSubmitButton); fireEvent.click(createTeamSubmitButton);
await waitFor(() => { await waitFor(() => {
@ -865,7 +872,7 @@ describe("OldTeams - models dropdown options", () => {
expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled(); expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled();
}); });
const createButton = screen.getByRole("button", { name: /create new team/i }); const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
act(() => { act(() => {
fireEvent.click(createButton); fireEvent.click(createButton);
}); });
@ -884,7 +891,7 @@ describe("OldTeams - organization alias display", () => {
mockUseOrganizations.mockReturnValue({ data: [] }); mockUseOrganizations.mockReturnValue({ data: [] });
}); });
it("should display organization alias instead of organization id", () => { it("should display organization alias instead of organization id", async () => {
const mockOrganizations = [ const mockOrganizations = [
{ {
organization_id: "org-123", organization_id: "org-123",
@ -934,11 +941,13 @@ describe("OldTeams - organization alias display", () => {
/>, />,
); );
expect(screen.getByText("Test Organization")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText("Test Organization")).toBeInTheDocument();
});
expect(screen.queryByText("org-123")).not.toBeInTheDocument(); expect(screen.queryByText("org-123")).not.toBeInTheDocument();
}); });
it("should display organization id when alias is not found", () => { it("should display organization id when alias is not found", async () => {
mockUseOrganizations.mockReturnValue({ data: [] }); mockUseOrganizations.mockReturnValue({ data: [] });
renderWithQueryClient( renderWithQueryClient(
@ -968,10 +977,12 @@ describe("OldTeams - organization alias display", () => {
/>, />,
); );
expect(screen.getByText("org-unknown")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText("org-unknown")).toBeInTheDocument();
});
}); });
it("should display N/A when organization_id is null", () => { it("should display N/A when organization_id is null", async () => {
mockUseOrganizations.mockReturnValue({ data: [] }); mockUseOrganizations.mockReturnValue({ data: [] });
renderWithQueryClient( renderWithQueryClient(
@ -1001,6 +1012,9 @@ describe("OldTeams - organization alias display", () => {
/>, />,
); );
expect(screen.getByText("N/A")).toBeInTheDocument(); await waitFor(() => {
// When organization_id is null, the table shows "—" in the Organization column
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
});
}); });
}); });

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import {
ChevronUpIcon, ChevronUpIcon,
ChevronDownIcon, ChevronDownIcon,
ExternalLinkIcon, ExternalLinkIcon,
ClipboardCopyIcon,
} from "@heroicons/react/outline"; } from "@heroicons/react/outline";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import BaseActionButton from "../BaseActionButton"; import BaseActionButton from "../BaseActionButton";
@ -32,6 +33,7 @@ export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseP
Up: { icon: ChevronUpIcon, className: "hover:text-blue-600" }, Up: { icon: ChevronUpIcon, className: "hover:text-blue-600" },
Down: { icon: ChevronDownIcon, className: "hover:text-blue-600" }, Down: { icon: ChevronDownIcon, className: "hover:text-blue-600" },
Open: { icon: ExternalLinkIcon, className: "hover:text-green-600" }, Open: { icon: ExternalLinkIcon, className: "hover:text-green-600" },
Copy: { icon: ClipboardCopyIcon, className: "hover:text-blue-600" },
}; };
export default function TableIconActionButton({ export default function TableIconActionButton({

View file

@ -1,13 +1,16 @@
import React from "react"; import React from "react";
import { Select } from "antd"; import { Select, Typography } from "antd";
import { Organization } from "../networking"; import { Organization } from "../networking";
const { Text } = Typography;
interface OrganizationDropdownProps { interface OrganizationDropdownProps {
organizations?: Organization[] | null; organizations?: Organization[] | null;
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
style?: React.CSSProperties;
} }
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
@ -16,16 +19,18 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
onChange, onChange,
disabled, disabled,
loading, loading,
style,
}) => { }) => {
return ( return (
<Select <Select
showSearch showSearch
placeholder="Search or select an organization" placeholder="All Organizations"
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
loading={loading} loading={loading}
allowClear allowClear
style={{ minWidth: 280, ...style }}
filterOption={(input, option) => { filterOption={(input, option) => {
if (!option) return false; if (!option) return false;
const org = organizations?.find((o) => o.organization_id === option.key); const org = organizations?.find((o) => o.organization_id === option.key);
@ -37,12 +42,11 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm); return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
}} }}
optionFilterProp="children"
> >
{organizations?.map((org) => ( {organizations?.map((org) => (
<Select.Option key={org.organization_id} value={org.organization_id}> <Select.Option key={org.organization_id} value={org.organization_id}>
<span className="font-medium">{org.organization_alias}</span>{" "} <span className="font-medium">{org.organization_alias}</span>{" "}
<span className="text-gray-500">({org.organization_id})</span> <Text type="secondary">({org.organization_id})</Text>
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>

View file

@ -0,0 +1,12 @@
import { Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
interface AntDLoadingSpinnerProps {
size?: "small" | "default" | "large";
fontSize?: number;
}
export function AntDLoadingSpinner({ size, fontSize }: AntDLoadingSpinnerProps) {
const indicator = <LoadingOutlined style={fontSize ? { fontSize } : undefined} spin />;
return <Spin indicator={indicator} size={size} />;
}