mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
fd00b98f64
commit
3d76dfc72e
6 changed files with 207 additions and 96 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
49
ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx
Normal file
49
ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export {
|
|||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue