diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 18dd7c949ec..590aeb3507e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx new file mode 100644 index 00000000000..b8895718df0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx @@ -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(); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should load and display available search tools", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + 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(); + + await user.click(screen.getByRole("button", { name: "Clear all search tools" })); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx index c93ff35e7de..56f5954345d 100644 --- a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx @@ -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 = ({ placeholder = "Select search tools (optional)", disabled = false, }) => { - const [options, setOptions] = useState<{ label: string; value: string }[]>([]); + const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -36,8 +48,7 @@ const SearchToolSelector: React.FC = ({ 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 = ({ }, [accessToken]); return ( - onChange(selected)} disabled={disabled} - /> + > + + + {(selected: string[]) => + selected.map((tool) => ( + + {tool} + + )) + } + + + {value && value.length > 0 && } + + + {loading ? "Loading search tools…" : "No search tools found"} + + {(tool: string) => ( + + {tool} + + )} + + + ); }; diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx new file mode 100644 index 00000000000..d9ba3d82230 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.test.tsx @@ -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); + + renderWithProviders(); + + 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); + + renderWithProviders(); + + expect(screen.getByText("member@example.com")).toBeInTheDocument(); + expect(screen.getByText("model-one")).toBeInTheDocument(); + expect(screen.getByText("TPM: 1,000")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx index e215f687df4..fd40c30e93b 100644 --- a/ui/litellm-dashboard/src/components/team/MyUserTab.tsx +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx @@ -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) => ( - - {label} - - + + {label} + + - + ); const formatNumber = (value: number | null | undefined, digits = 4): string => { @@ -34,7 +36,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) { if (isLoading) { return ( - Loading your membership info… + Loading your membership info… ); } @@ -42,9 +44,9 @@ export default function MyUserTab({ teamId }: MyUserTabProps) { if (error) { return ( - + {error instanceof Error ? error.message : "Failed to load your membership info for this team."} - + ); } @@ -52,9 +54,9 @@ export default function MyUserTab({ teamId }: MyUserTabProps) { if (!data) { return ( - + No membership info available for the current user in this team. - + ); } @@ -69,89 +71,79 @@ export default function MyUserTab({ teamId }: MyUserTabProps) { const allowedModels = budgetTable?.allowed_models ?? null; return ( - + - - - User - - {data.user_email || data.user_id} + + + + User + {data.user_email || data.user_id} + {data.user_id} - - {data.user_id} - - - - Team Role - - {data.role || "user"} + + Team Role + + {data.role || "user"} + - - + + - - - + + + {labelWithTooltip( "Current Cycle Spend (USD)", "Spend for the current budget cycle. Resets to $0 when the budget window rolls over.", )} - - - ${formatNumber(spend, 4)} - - + + ${formatNumber(spend, 4)} + of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`} - + - {budgetReset && ( - - Resets {budgetReset} - - )} - - + {budgetReset && Resets {budgetReset}} + + - - + + {labelWithTooltip("Rate Limits", "Your per-member rate limits within this team.")} - - TPM: {formatRateLimit(tpmLimit)} + + TPM: {formatRateLimit(tpmLimit)} - RPM: {formatRateLimit(rpmLimit)} + RPM: {formatRateLimit(rpmLimit)} - - + + - - + + {labelWithTooltip("Total Spend (USD)", "Cumulative spend across all budget cycles within this team.")} - - - ${formatNumber(totalSpend, 4)} - - - - + ${formatNumber(totalSpend, 4)} + + - - + + {labelWithTooltip("Model Scope", "Models you can access within this team.")} - + {allowedModels && allowedModels.length > 0 ? ( - + {allowedModels.map((m) => ( - {m} + + {m} + ))} - + ) : ( - All Team Models + All Team Models )} - - - - + + + + ); } diff --git a/ui/litellm-dashboard/src/components/ui/combobox.tsx b/ui/litellm-dashboard/src/components/ui/combobox.tsx index 2854928140e..906b898cfbe 100644 --- a/ui/litellm-dashboard/src/components/ui/combobox.tsx +++ b/ui/litellm-dashboard/src/components/ui/combobox.tsx @@ -260,6 +260,7 @@ export { ComboboxChips, ComboboxChip, ComboboxChipsInput, + ComboboxClear, ComboboxTrigger, ComboboxValue, useComboboxAnchor,