refactor(ui): migrate search and user controls to shadcn (#36694)

* test(ui): characterize shared migration surfaces

* refactor(ui): migrate search and user controls

* fix(ui): restore search tool clear action
This commit is contained in:
yuneng-jiang 2026-08-12 23:43:30 -07:00 committed by GitHub
parent fd00b98f64
commit 3d76dfc72e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 207 additions and 96 deletions

View file

@ -3296,9 +3296,6 @@
"src/components/search_tools/SearchToolSelector.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/settings.test.tsx": {
@ -3468,11 +3465,6 @@
"count": 2
}
},
"src/components/team/MyUserTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/team/TeamInfo.tsx": {
"max-lines": {
"count": 1

View file

@ -0,0 +1,44 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { fetchSearchTools } from "../networking";
import SearchToolSelector from "./SearchToolSelector";
vi.mock("../networking", () => ({
fetchSearchTools: vi.fn(),
}));
describe("SearchToolSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchSearchTools).mockResolvedValue({
search_tools: [{ search_tool_name: "search-one" }, { search_tool_name: "search-two" }],
});
});
it("should render", () => {
renderWithProviders(<SearchToolSelector accessToken="" onChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should load and display available search tools", async () => {
const user = userEvent.setup();
renderWithProviders(<SearchToolSelector accessToken="token" onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
expect(await screen.findByRole("option", { name: "search-one" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "search-two" })).toBeInTheDocument();
});
it("should clear all selected search tools", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(<SearchToolSelector accessToken="" value={["search-one", "search-two"]} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "Clear all search tools" }));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View file

@ -1,5 +1,17 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { cn } from "@/lib/cva.config";
import { fetchSearchTools } from "../networking";
export interface SearchToolSelectorProps {
@ -19,7 +31,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
placeholder = "Select search tools (optional)",
disabled = false,
}) => {
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
const [options, setOptions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
@ -36,8 +48,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
setOptions(
tools
.map((tool: { search_tool_name?: string }) => tool?.search_tool_name)
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0)
.map((name: string) => ({ label: name, value: name })),
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0),
);
} catch (e) {
console.error("Failed to load search tools:", e);
@ -49,20 +60,42 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
}, [accessToken]);
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
options={options}
style={{ width: "100%" }}
<Combobox
multiple
items={options}
value={value ?? []}
onValueChange={(selected: string[]) => onChange(selected)}
disabled={disabled}
/>
>
<ComboboxChips className={cn("w-full", className)} aria-busy={loading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((tool) => (
<ComboboxChip key={tool} aria-label={tool}>
{tool}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
className="border-0 bg-transparent"
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
/>
{value && value.length > 0 && <ComboboxClear aria-label="Clear all search tools" disabled={disabled} />}
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>{loading ? "Loading search tools…" : "No search tools found"}</ComboboxEmpty>
<ComboboxList>
{(tool: string) => (
<ComboboxItem key={tool} value={tool}>
{tool}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};

View file

@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import MyUserTab from "./MyUserTab";
import { useMyTeamMember } from "./useMyTeamMember";
vi.mock("./useMyTeamMember", () => ({
useMyTeamMember: vi.fn(),
}));
describe("MyUserTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
vi.mocked(useMyTeamMember).mockReturnValue({ isLoading: true } as ReturnType<typeof useMyTeamMember>);
renderWithProviders(<MyUserTab teamId="team-1" />);
expect(screen.getByText("Loading your membership info…")).toBeInTheDocument();
});
it("should display the current member budget and model scope", () => {
vi.mocked(useMyTeamMember).mockReturnValue({
data: {
user_id: "user-1",
user_email: "member@example.com",
team_id: "team-1",
role: "admin",
spend: 12.5,
total_spend: 30,
litellm_budget_table: {
max_budget: 100,
tpm_limit: 1000,
rpm_limit: 10,
allowed_models: ["model-one"],
},
},
isLoading: false,
error: null,
} as ReturnType<typeof useMyTeamMember>);
renderWithProviders(<MyUserTab teamId="team-1" />);
expect(screen.getByText("member@example.com")).toBeInTheDocument();
expect(screen.getByText("model-one")).toBeInTheDocument();
expect(screen.getByText("TPM: 1,000")).toBeInTheDocument();
});
});

View file

@ -1,7 +1,9 @@
import { formatBudgetReset } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import { Tooltip } from "@/components/atoms/Tooltip";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { CircleHelp } from "lucide-react";
import React from "react";
import { useMyTeamMember } from "./useMyTeamMember";
@ -10,12 +12,12 @@ interface MyUserTabProps {
}
const labelWithTooltip = (label: string, tooltip: string) => (
<Space size={4}>
<Typography.Text type="secondary">{label}</Typography.Text>
<Tooltip title={tooltip}>
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
<span className="flex items-center gap-1 text-muted-foreground">
{label}
<Tooltip content={tooltip}>
<CircleHelp className="size-4" aria-label={`${label} information`} />
</Tooltip>
</Space>
</span>
);
const formatNumber = (value: number | null | undefined, digits = 4): string => {
@ -34,7 +36,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
if (isLoading) {
return (
<Card>
<Typography.Text type="secondary">Loading your membership info</Typography.Text>
<CardContent className="text-muted-foreground">Loading your membership info</CardContent>
</Card>
);
}
@ -42,9 +44,9 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
if (error) {
return (
<Card>
<Typography.Text type="danger">
<CardContent className="text-destructive">
{error instanceof Error ? error.message : "Failed to load your membership info for this team."}
</Typography.Text>
</CardContent>
</Card>
);
}
@ -52,9 +54,9 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
if (!data) {
return (
<Card>
<Typography.Text type="secondary">
<CardContent className="text-muted-foreground">
No membership info available for the current user in this team.
</Typography.Text>
</CardContent>
</Card>
);
}
@ -69,89 +71,79 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
const allowedModels = budgetTable?.allowed_models ?? null;
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div className="flex w-full flex-col gap-4">
<Card>
<Row gutter={[24, 16]}>
<Col xs={24} sm={12} md={8}>
<Typography.Text type="secondary">User</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text strong>{data.user_email || data.user_id}</Typography.Text>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
<div>
<span className="text-muted-foreground">User</span>
<div className="mt-1 font-semibold">{data.user_email || data.user_id}</div>
<span className="font-mono text-xs text-muted-foreground">{data.user_id}</span>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, fontFamily: "monospace" }}>
{data.user_id}
</Typography.Text>
</Col>
<Col xs={24} sm={12} md={8}>
<Typography.Text type="secondary">Team Role</Typography.Text>
<div style={{ marginTop: 4 }}>
<Tag color={data.role === "admin" ? "blue" : "default"}>{data.role || "user"}</Tag>
<div>
<span className="text-muted-foreground">Team Role</span>
<div className="mt-1">
<Badge variant={data.role === "admin" ? "default" : "secondary"}>{data.role || "user"}</Badge>
</div>
</div>
</Col>
</Row>
</div>
</CardContent>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardContent>
{labelWithTooltip(
"Current Cycle Spend (USD)",
"Spend for the current budget cycle. Resets to $0 when the budget window rolls over.",
)}
<div style={{ marginTop: 8 }}>
<Typography.Title level={3} style={{ margin: 0 }}>
${formatNumber(spend, 4)}
</Typography.Title>
<Typography.Text type="secondary">
<div className="mt-2">
<h3 className="text-2xl font-semibold">${formatNumber(spend, 4)}</h3>
<span className="text-muted-foreground">
of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
</Typography.Text>
</span>
</div>
{budgetReset && (
<div style={{ marginTop: 4 }}>
<Typography.Text type="secondary">Resets {budgetReset}</Typography.Text>
</div>
)}
</Card>
</Col>
{budgetReset && <div className="mt-1 text-muted-foreground">Resets {budgetReset}</div>}
</CardContent>
</Card>
<Col xs={24} md={12}>
<Card>
<Card>
<CardContent>
{labelWithTooltip("Rate Limits", "Your per-member rate limits within this team.")}
<div style={{ marginTop: 8 }}>
<Typography.Text>TPM: {formatRateLimit(tpmLimit)}</Typography.Text>
<div className="mt-2">
<span>TPM: {formatRateLimit(tpmLimit)}</span>
<br />
<Typography.Text>RPM: {formatRateLimit(rpmLimit)}</Typography.Text>
<span>RPM: {formatRateLimit(rpmLimit)}</span>
</div>
</Card>
</Col>
</CardContent>
</Card>
<Col xs={24} md={12}>
<Card>
<Card>
<CardContent>
{labelWithTooltip("Total Spend (USD)", "Cumulative spend across all budget cycles within this team.")}
<div style={{ marginTop: 8 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
${formatNumber(totalSpend, 4)}
</Typography.Title>
</div>
</Card>
</Col>
<h4 className="mt-2 text-xl font-semibold">${formatNumber(totalSpend, 4)}</h4>
</CardContent>
</Card>
<Col xs={24} md={12}>
<Card>
<Card>
<CardContent>
{labelWithTooltip("Model Scope", "Models you can access within this team.")}
<div style={{ marginTop: 8 }}>
<div className="mt-2">
{allowedModels && allowedModels.length > 0 ? (
<Space wrap>
<div className="flex flex-wrap gap-1">
{allowedModels.map((m) => (
<Tag key={m}>{m}</Tag>
<Badge key={m} variant="secondary">
{m}
</Badge>
))}
</Space>
</div>
) : (
<Typography.Text>All Team Models</Typography.Text>
<span>All Team Models</span>
)}
</div>
</Card>
</Col>
</Row>
</Space>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -260,6 +260,7 @@ export {
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxClear,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,