Merge pull request #36924 from BerriAI/litellm_shadcn_dropdowns_0814

refactor(ui): move the shared dropdowns and selectors onto shadcn primitives
This commit is contained in:
yuneng-jiang 2026-08-14 09:40:49 -07:00 • committed by GitHub
commit cb33d204f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 394 additions and 491 deletions

View file

@ -19,12 +19,11 @@ test.describe("Internal User", () => {
// Open the team dropdown — seeded internal user is a member of
// e2e-team-crud and e2e-team-org, so we expect at least the CRUD alias.
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
await teamSelect.click();
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
await expect(page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({
timeout: 5_000,
});
const dropdown = page.locator('[data-slot="combobox-content"]:visible');
await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ timeout: 5_000 });
});
test("Team info page omits the Settings tab for non-admin members", async ({ page }) => {

View file

@ -27,18 +27,18 @@ test.describe("Internal User with no team memberships", () => {
await page.getByRole("button", { name: /Create New Key/i }).click();
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
await teamSelect.click();
const dropdown = page.locator(".ant-select-dropdown:visible").first();
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
await expect(dropdown).toBeVisible({ timeout: 5_000 });
// Wait for the settled-empty state, not a transient one. The dropdown shows
// a spinner while teams load and only swaps in "No teams found" once the
// request resolves with nothing (team_dropdown.tsx renders the spinner when
// isLoading and this copy otherwise). Asserting on it means a regression
// where teams DO load for this user fails here instead of racing a one-shot
// count() against an in-flight request.
// "Loading teams…" while teams load and only swaps in "No teams found" once
// the request resolves with nothing (team_dropdown.tsx passes both copies to
// PaginatedSearchSelect). Asserting on it means a regression where teams DO
// load for this user fails here instead of racing a one-shot count() against
// an in-flight request.
await expect(dropdown.getByText("No teams found")).toBeVisible({ timeout: 10_000 });
await expect(dropdown.getByRole("option")).toHaveCount(0);
});

View file

@ -18,10 +18,10 @@ test.describe("Internal User with team memberships", () => {
await page.getByRole("button", { name: /Create New Key/i }).click();
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
await teamSelect.click();
const dropdown = page.locator(".ant-select-dropdown:visible").first();
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
await expect(dropdown).toBeVisible({ timeout: 5_000 });
// Both seeded memberships render, and nothing else does — proving the

View file

@ -328,11 +328,11 @@ test.describe("Add Model", () => {
const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" });
await teamByokRow.getByRole("switch").click();
// TeamDropdown's options carry custom markup and no role="option", so match by text.
const teamDropdown = page.getByTestId("team-dropdown");
// TeamDropdown options show the alias above the team id, so match on the id line by text.
const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox");
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
await teamDropdown.click();
const teamOption = page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ID).first();
const teamOption = page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ID).first();
await expect(teamOption).toBeVisible({ timeout: 5_000 });
await teamOption.click();

View file

@ -40,11 +40,11 @@ test.describe("Proxy Admin - Keys", () => {
const keyName = `e2e-admin-key-${Date.now()}`;
await page.getByTestId("base-input").fill(keyName);
// Select team — the team dropdown has placeholder "Search or select a team"
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
// Select team
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
await teamSelect.click();
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
// Select models
await page.locator(".ant-select-selection-overflow").click();
@ -157,7 +157,7 @@ test.describe("Proxy Admin - Keys", () => {
await page.getByRole("button", { name: "More key actions" }).click();
await page.getByRole("menuitem", { name: "Delete Key" }).click();
const modal = page.locator(".ant-modal:visible");
const modal = page.getByRole("dialog", { name: "Delete Key" });
await expect(modal).toBeVisible({ timeout: 5_000 });
await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS);

View file

@ -129,7 +129,7 @@ test.describe("Proxy Admin - Teams", () => {
await teamRow.locator('[data-testid^="team-actions-"]').click();
await page.getByTestId("team-action-delete").click();
const modal = page.locator(".ant-modal:visible");
const modal = page.getByRole("dialog", { name: "Delete Team?" });
await expect(modal).toBeVisible({ timeout: 5_000 });
await modal.locator("input").fill(E2E_TEAM_DELETE_ALIAS);
await modal.getByRole("button", { name: /Force Delete|Delete/i }).click();

View file

@ -105,7 +105,7 @@ test.describe("Team Admin", () => {
await expect(row).toBeVisible({ timeout: 10_000 });
await row.getByTestId("delete-member").click();
const modal = page.locator(".ant-modal:visible");
const modal = page.getByRole("dialog", { name: "Delete Team Member" });
await expect(modal).toBeVisible({ timeout: 5_000 });
const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => {
@ -139,10 +139,10 @@ test.describe("Team Admin", () => {
await page.getByTestId("base-input").fill(keyName);
// Team selector — same locator pattern as the proxy-admin keys test.
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
await teamSelect.click();
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
// Models — pick "All Team Models"
await page.locator(".ant-select-selection-overflow").click();

View file

@ -2321,15 +2321,7 @@
"count": 1
}
},
"src/components/common_components/DefaultProxyAdminTag.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/DeleteResourceModal.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2378,17 +2370,11 @@
}
},
"src/components/common_components/ModelAliasManager.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/common_components/ModelSelector.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2398,14 +2384,6 @@
"count": 1
}
},
"src/components/common_components/OrganizationDropdown.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/PassThroughGuardrailsSection.tsx": {
"no-restricted-imports": {
"count": 2
@ -2414,29 +2392,11 @@
"count": 1
}
},
"src/components/common_components/PassThroughRoutesSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/PassThroughSecuritySection.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/common_components/PremiumLoggingSettings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/ProjectDropdown.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/RateLimitTypeFormItem.test.tsx": {
"no-restricted-imports": {
"count": 1
@ -2447,22 +2407,9 @@
"count": 1
}
},
"src/components/common_components/RouterSettingsAccordion.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/budget_duration_dropdown.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/chartUtils.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/chartUtils.tsx": {
@ -2471,9 +2418,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/check_openapi_schema.tsx": {
@ -2498,17 +2442,11 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/team_dropdown.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/team_multi_select.tsx": {

View file

@ -1,4 +1,5 @@
import { renderWithProviders, screen, within } from "@/../tests/test-utils";
import { waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AccessGroupsPage } from "./AccessGroupsPage";
@ -215,7 +216,9 @@ describe("AccessGroupsPage", () => {
await user.click(await openRowMenu(user, "ag-1"));
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument();
});
expect(mockMutate).not.toHaveBeenCalled();
});

View file

@ -244,7 +244,7 @@ describe("UserInfoView", () => {
});
// The DeleteResourceModal's OK button has text "Delete" - find it within the modal
const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement;
const modal = screen.getByRole("dialog", { name: "Remove from Team" });
const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i });
await user.click(deleteConfirmButton);

View file

@ -1,6 +1,4 @@
import { Tag, Typography } from "antd";
const { Text } = Typography;
import { Badge } from "@/components/ui/badge";
const DEFAULT_USER_ID = "default_user_id";
@ -8,15 +6,10 @@ interface DefaultProxyAdminTagProps {
userId: string | null | undefined;
}
/**
* Renders "Default Proxy Admin" as a blue Tag when the given userId is
* the well-known `default_user_id`, otherwise renders the raw value as
* plain text.
*/
export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) {
if (userId === DEFAULT_USER_ID) {
return <Tag color="blue">Default Proxy Admin</Tag>;
return <Badge variant="secondary">Default Proxy Admin</Badge>;
}
return <Text>{userId}</Text>;
return <span>{userId}</span>;
}

View file

@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => {
expect(cancelButton).toBeDisabled();
});
it("should call onCancel when escape is pressed and no deletion is in flight", async () => {
const user = userEvent.setup();
renderWithProviders(<DeleteResourceModal {...defaultProps} />);
await user.keyboard("{Escape}");
expect(mockOnCancel).toHaveBeenCalled();
});
it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => {
const user = userEvent.setup();
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} />);
await user.keyboard("{Escape}");
expect(mockOnCancel).not.toHaveBeenCalled();
});
it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => {
const user = userEvent.setup();
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} requiredConfirmation="DELETE" />);

View file

@ -1,6 +1,10 @@
import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { CircleAlert } from "lucide-react";
import React, { useState, useEffect } from "react";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
interface DeleteResourceModalProps {
isOpen: boolean;
@ -8,12 +12,11 @@ interface DeleteResourceModalProps {
alertMessage?: string;
message: string;
resourceInformationTitle?: string;
resourceInformation?: Array<
{
label: string;
value: string | number | undefined | null;
} & Omit<React.ComponentProps<typeof Typography.Text>, "children">
>;
resourceInformation?: Array<{
label: string;
value: string | number | undefined | null;
code?: boolean;
}>;
onCancel: () => void;
onOk: () => void;
confirmLoading: boolean;
@ -32,8 +35,6 @@ export default function DeleteResourceModal({
confirmLoading,
requiredConfirmation,
}: DeleteResourceModalProps) {
const { Text } = Typography;
const { token } = theme.useToken();
const [requiredConfirmationInput, setRequiredConfirmationInput] = useState("");
useEffect(() => {
@ -43,69 +44,69 @@ export default function DeleteResourceModal({
}, [isOpen]);
return (
<Modal
title={title}
open={isOpen}
onOk={onOk}
onCancel={onCancel}
confirmLoading={confirmLoading}
okText={confirmLoading ? "Deleting..." : "Delete"}
cancelText="Cancel"
okButtonProps={{
danger: true,
disabled: (!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading,
}}
cancelButtonProps={{ disabled: confirmLoading }}
>
<div className="space-y-4">
{alertMessage && <Alert message={alertMessage} type="warning" />}
<Card
title={resourceInformationTitle}
className="mt-4"
styles={{
body: { padding: "16px" },
header: {
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
},
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
}}
>
<Descriptions column={1} size="small">
{resourceInformation &&
resourceInformation.map(({ label, value, ...textProps }) => (
<Descriptions.Item key={label} label={<span className="font-semibold">{label}</span>}>
<Text {...textProps}>{value ?? "-"}</Text>
</Descriptions.Item>
))}
</Descriptions>
</Card>
<div>
<Text>{message}</Text>
</div>
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Text className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
<Text>Type </Text>
<Text strong type="danger">
{requiredConfirmation}
</Text>
<Text> to confirm deletion:</Text>
</Text>
<Input
value={requiredConfirmationInput}
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
placeholder={requiredConfirmation}
className="rounded-md"
prefix={<ExclamationCircleOutlined style={{ color: token.colorError }} />}
autoFocus
/>
<Dialog open={isOpen} onOpenChange={(open) => !open && !confirmLoading && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{alertMessage && (
<Alert variant="warning">
<AlertTitle>{alertMessage}</AlertTitle>
</Alert>
)}
<Card size="sm" className="mt-4">
{resourceInformationTitle && (
<CardHeader className="border-b">
<CardTitle>{resourceInformationTitle}</CardTitle>
</CardHeader>
)}
<CardContent>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1">
{resourceInformation?.map(({ label, value, code }) => (
<React.Fragment key={label}>
<dt className="font-semibold">{label}</dt>
<dd className="min-w-0 break-words">{code ? <code>{value ?? "-"}</code> : value ?? "-"}</dd>
</React.Fragment>
))}
</dl>
</CardContent>
</Card>
<div>
<span>{message}</span>
</div>
)}
</div>
</Modal>
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-semibold text-destructive">{requiredConfirmation}</span> to confirm deletion:
</p>
<InputGroup className="rounded-md">
<InputGroupAddon>
<CircleAlert className="size-3.5 text-destructive" />
</InputGroupAddon>
<InputGroupInput
value={requiredConfirmationInput}
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
placeholder={requiredConfirmation}
autoFocus
/>
</InputGroup>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={confirmLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onOk}
disabled={(!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading}
>
{confirmLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline";
import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { Card, CardTitle } from "@/components/ui/card";
import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table";
import ModelSelector from "./ModelSelector";
import NotificationsManager from "../molecules/notifications_manager";
@ -141,7 +142,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
return (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Alias</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Add New Alias</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Alias Name</label>
@ -186,17 +187,17 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
</div>
</div>
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</p>
<div className="rounded-lg custom-border relative mb-6">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell className="py-1 h-8">Alias Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Target Model</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
<TableHead className="py-1 h-8">Alias Name</TableHead>
<TableHead className="py-1 h-8">Target Model</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{aliases.map((alias) => (
<TableRow key={alias.id} className="h-8">
@ -284,9 +285,9 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
{/* Configuration Example */}
{showExampleConfig && (
<Card>
<Title className="mb-4">Configuration Example</Title>
<Text className="text-gray-600 mb-4">Here&apos;s how your current aliases would look in the config:</Text>
<Card className="px-6">
<CardTitle className="mb-4">Configuration Example</CardTitle>
<p className="text-gray-600 mb-4">Here&apos;s how your current aliases would look in the config:</p>
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm">
<div className="text-gray-700">
model_aliases:

View file

@ -1,28 +1,20 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import ModelSelector from "./ModelSelector";
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
const openCustomModelInput = () => {
const selector = document.querySelector(".ant-select-selector");
expect(selector).toBeTruthy();
act(() => {
fireEvent.mouseDown(selector!);
});
act(() => {
fireEvent.click(screen.getByText("Enter custom model"));
});
const openCustomModelInput = async () => {
const user = userEvent.setup();
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Enter custom model"));
return screen.getByPlaceholderText("Enter custom model name");
};
describe("ModelSelector custom model debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
act(() => {
vi.runOnlyPendingTimers();
@ -30,11 +22,12 @@ describe("ModelSelector custom model debounce", () => {
vi.useRealTimers();
});
it("does not call onChange before the debounce wait elapses", () => {
it("does not call onChange before the debounce wait elapses", async () => {
const onChange = vi.fn();
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
const input = await openCustomModelInput();
vi.useFakeTimers();
act(() => {
fireEvent.change(input, { target: { value: "gpt-4o" } });
@ -49,11 +42,12 @@ describe("ModelSelector custom model debounce", () => {
expect(onChange).not.toHaveBeenCalled();
});
it("calls onChange exactly once with the last typed value after the wait", () => {
it("calls onChange exactly once with the last typed value after the wait", async () => {
const onChange = vi.fn();
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
const input = await openCustomModelInput();
vi.useFakeTimers();
act(() => {
fireEvent.change(input, { target: { value: "g" } });
@ -71,11 +65,12 @@ describe("ModelSelector custom model debounce", () => {
expect(onChange).toHaveBeenCalledWith("gpt-5.2");
});
it("does not call onChange when unmounted mid-wait", () => {
it("does not call onChange when unmounted mid-wait", async () => {
const onChange = vi.fn();
const { unmount } = render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
const input = await openCustomModelInput();
vi.useFakeTimers();
act(() => {
fireEvent.change(input, { target: { value: "gpt-4o" } });

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from "react";
import { TextInput, Text } from "@tremor/react";
import { Select } from "antd";
import { RobotOutlined } from "@ant-design/icons";
import { Bot } from "lucide-react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Input } from "@/components/ui/input";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
const MODEL_SELECT_DEBOUNCE_MS = 500;
@ -80,32 +80,30 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
return (
<div>
{showLabel && (
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<RobotOutlined className="mr-2" /> {labelText}
</Text>
<p className="font-medium block mb-2 text-gray-700 flex items-center">
<Bot className="mr-2 size-3.5" /> {labelText}
</p>
)}
<Select
value={selectedModel}
placeholder={placeholder}
onChange={onModelChange}
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group, index) => ({
value: model_group,
label: model_group,
key: index,
})),
{ value: "custom", label: "Enter custom model", key: "custom" },
]}
style={{ width: "100%", ...style }}
showSearch={true}
className={`rounded-md ${className || ""}`}
disabled={disabled}
/>
<div style={{ width: "100%", ...style }} className={`rounded-md ${className || ""}`}>
<SearchSelect
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
})),
{ value: "custom", label: "Enter custom model" },
]}
value={selectedModel}
placeholder={placeholder}
onValueChange={onModelChange}
disabled={disabled}
/>
</div>
{showCustomModelInput && (
<TextInput
<Input
className="mt-2"
placeholder="Enter custom model name"
onValueChange={debouncedSelect}
onChange={(e) => debouncedSelect(e.target.value)}
disabled={disabled}
/>
)}

View file

@ -54,12 +54,28 @@ describe("OrganizationDropdown", () => {
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Engineering"));
expect(onChange).toHaveBeenCalledWith("org-1", expect.anything());
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toBe("org-1");
});
it("should add ant-select-disabled class when disabled prop is true", () => {
const { container } = render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
expect(container.querySelector(".ant-select-disabled")).toBeTruthy();
it("should filter options by organization id", async () => {
const user = userEvent.setup();
render(<OrganizationDropdown organizations={MOCK_ORGS} />);
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByRole("combobox"), "org-2");
expect(await screen.findByText("Sales")).toBeInTheDocument();
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("should not open the option list when disabled prop is true", async () => {
const user = userEvent.setup();
render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
await user.click(screen.getByRole("combobox"));
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("should render with empty organizations list", () => {

View file

@ -1,9 +1,7 @@
import React from "react";
import { Select, Typography } from "antd";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Organization } from "../networking";
const { Text } = Typography;
interface OrganizationDropdownProps {
organizations?: Organization[] | null;
value?: string;
@ -12,6 +10,7 @@ interface OrganizationDropdownProps {
loading?: boolean;
style?: React.CSSProperties;
placeholder?: string;
id?: string;
}
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
@ -22,36 +21,24 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
loading,
style,
placeholder = "All Organizations",
id,
}) => {
return (
<Select
showSearch
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
loading={loading}
allowClear
style={{ minWidth: 280, ...style }}
filterOption={(input, option) => {
if (!option) return false;
const org = organizations?.find((o) => o.organization_id === option.key);
if (!org) return false;
const searchTerm = input.toLowerCase().trim();
const orgAlias = (org.organization_alias || "").toLowerCase();
const orgId = (org.organization_id || "").toLowerCase();
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
}}
>
{organizations?.map((org) => (
<Select.Option key={org.organization_id} value={org.organization_id}>
<span className="font-medium">{org.organization_alias}</span>{" "}
<Text type="secondary">({org.organization_id})</Text>
</Select.Option>
))}
</Select>
<div style={{ minWidth: 280, ...style }}>
<SearchSelect
options={(organizations ?? []).map((org) => ({
label: org.organization_alias || org.organization_id,
value: org.organization_id,
sublabel: org.organization_id,
}))}
value={value}
onValueChange={(organizationId) => onChange?.(organizationId)}
placeholder={placeholder}
emptyText={loading ? "Loading organizations…" : "No organizations found"}
disabled={disabled}
inputId={id}
/>
</div>
);
};

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import { getPassThroughEndpointsCall } from "../networking";
interface PassThroughRoutesSelectorProps {
@ -17,6 +17,11 @@ interface PassThroughEndpoint {
methods?: string[];
}
const routeOption = (endpoint: PassThroughEndpoint): MultiSelectOption => ({
label: endpoint.methods?.length ? `${endpoint.methods.join(", ")} ${endpoint.path}` : endpoint.path,
value: endpoint.path,
});
const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
onChange,
value,
@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
disabled = false,
teamId,
}) => {
const [passThroughRoutes, setPassThroughRoutes] = useState<Array<{ label: string; value: string }>>([]);
const [passThroughRoutes, setPassThroughRoutes] = useState<MultiSelectOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
try {
const response = await getPassThroughEndpointsCall(accessToken, teamId);
if (response.endpoints) {
const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => {
const path = endpoint.path;
const methods = endpoint.methods;
// If methods are specified, create one entry per method
if (methods && methods.length > 0) {
return methods.map((method) => ({
label: `${method} ${path}`,
value: path, // Keep value as path for backward compatibility
}));
}
// If no methods specified, show just the path (all methods supported)
return [
{
label: path,
value: path,
},
];
});
setPassThroughRoutes(routes);
setPassThroughRoutes(response.endpoints.map(routeOption));
}
} catch (error) {
console.error("Error fetching pass through routes:", error);
@ -70,19 +55,16 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
}, [accessToken, teamId]);
return (
<Select
mode="tags"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
allowClear
<MultiSelect
options={passThroughRoutes}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
value={value}
onValueChange={(routes) => onChange?.(routes)}
placeholder={placeholder}
emptyText="No pass through routes found"
loading={loading}
allowCustomValues
disabled={disabled}
className={className}
/>
);
};

View file

@ -1,5 +1,4 @@
import React from "react";
import { Text } from "@tremor/react";
import LoggingSettings from "../team/LoggingSettings";
interface PremiumLoggingSettingsProps {
@ -29,14 +28,14 @@ export function PremiumLoggingSettings({
</div>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
all free users. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
here
</a>
.
</Text>
</p>
</div>
</div>
);

View file

@ -1,6 +1,5 @@
import React from "react";
import { Select, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
interface ProjectDropdownProps {
@ -11,42 +10,38 @@ interface ProjectDropdownProps {
loading?: boolean;
/** When set, only show projects belonging to this team */
teamId?: string | null;
id?: string;
}
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({ projects, value, onChange, disabled, loading, teamId }) => {
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
projects,
value,
onChange,
disabled,
loading,
teamId,
id,
}) => {
const filtered = teamId ? projects?.filter((p) => p.team_id === teamId) : projects;
return (
<Select
showSearch
placeholder="Search or select a project"
<SearchSelect
options={
loading
? []
: (filtered ?? []).map((project) => ({
label: project.project_alias || project.project_id,
value: project.project_id,
sublabel: project.project_id,
}))
}
value={value}
onChange={onChange}
onValueChange={(projectId) => onChange?.(projectId)}
placeholder="Search or select a project"
emptyText={loading ? "Loading projects…" : "No projects found"}
disabled={disabled}
loading={loading}
allowClear
notFoundContent={loading ? <Spin indicator={<LoadingOutlined spin />} size="small" /> : undefined}
filterOption={(input, option) => {
if (!option) return false;
const project = filtered?.find((p) => p.project_id === option.key);
if (!project) return false;
const searchTerm = input.toLowerCase().trim();
const alias = (project.project_alias || "").toLowerCase();
const id = (project.project_id || "").toLowerCase();
return alias.includes(searchTerm) || id.includes(searchTerm);
}}
optionFilterProp="children"
>
{!loading &&
filtered?.map((project) => (
<Select.Option key={project.project_id} value={project.project_id}>
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
<span className="text-gray-500">({project.project_id})</span>
</Select.Option>
))}
</Select>
inputId={id}
/>
);
};

View file

@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
),
}));
vi.mock("@tremor/react", () => ({
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../router_settings/RouterSettingsForm", () => ({
default: ({
value,

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { getRouterSettingsCall } from "../networking";
@ -344,13 +344,13 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
return (
<div className="w-full">
<TabGroup className="w-full">
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
<Tab value="1">Loadbalancing</Tab>
<Tab value="2">Fallbacks</Tab>
</TabList>
<TabPanels className="px-8 py-6">
<TabPanel>
<Tabs defaultValue="1" className="w-full">
<TabsList variant="line" className="px-8 pt-4">
<TabsTrigger value="1">Loadbalancing</TabsTrigger>
<TabsTrigger value="2">Fallbacks</TabsTrigger>
</TabsList>
<div className="px-8 py-6">
<TabsContent value="1" keepMounted>
<RouterSettingsForm
value={formValue}
onChange={setFormValue}
@ -358,17 +358,17 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
availableRoutingStrategies={availableRoutingStrategies}
routingStrategyDescriptions={routingStrategyDescriptions}
/>
</TabPanel>
<TabPanel>
</TabsContent>
<TabsContent value="2" keepMounted>
<FallbackSelectionForm
groups={fallbackGroups}
onGroupsChange={handleFallbackGroupsChange}
availableModels={availableModels}
maxGroups={5}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</div>
</Tabs>
</div>
);
},

View file

@ -1,10 +1,16 @@
import React from "react";
import { Select } from "antd";
const { Option } = Select;
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export const NEVER_RESETS_BUDGET_DURATION = "none";
const DURATION_LABELS: Record<string, string> = {
[NEVER_RESETS_BUDGET_DURATION]: "Never resets",
"1h": "hourly",
"24h": "daily",
"7d": "weekly",
"30d": "monthly",
};
interface BudgetDurationDropdownProps {
value?: string | null;
onChange?: (value: string | undefined) => void;
@ -24,18 +30,21 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
}) => {
return (
<Select
style={{ width: "100%", ...style }}
value={value || undefined}
onChange={onChange}
className={className}
placeholder={placeholder}
allowClear
items={DURATION_LABELS}
value={value || null}
onValueChange={(next: string | null) => onChange?.(next ?? undefined)}
>
{showNeverResets ? <Option value={NEVER_RESETS_BUDGET_DURATION}>Never resets</Option> : null}
<Option value="1h">hourly</Option>
<Option value="24h">daily</Option>
<Option value="7d">weekly</Option>
<Option value="30d">monthly</Option>
<SelectTrigger className={`w-full ${className}`} style={style}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>{placeholder}</SelectItem>
{showNeverResets ? <SelectItem value={NEVER_RESETS_BUDGET_DURATION}>Never resets</SelectItem> : null}
<SelectItem value="1h">hourly</SelectItem>
<SelectItem value="24h">daily</SelectItem>
<SelectItem value="7d">weekly</SelectItem>
<SelectItem value="30d">monthly</SelectItem>
</SelectContent>
</Select>
);
};

View file

@ -1,9 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CustomLegend, CustomTooltip } from "./chartUtils";
import type { CustomTooltipProps } from "@tremor/react";
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
import { SpendMetrics } from "../UsagePage/types";
type TooltipPayload = NonNullable<ChartTooltipProps["payload"]>;
describe("CustomTooltip", () => {
const mockPayload = [
{
@ -28,9 +30,9 @@ describe("CustomTooltip", () => {
];
it("should render", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -38,9 +40,9 @@ describe("CustomTooltip", () => {
});
it("should return null when not active", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: false,
payload: mockPayload,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
@ -48,9 +50,9 @@ describe("CustomTooltip", () => {
});
it("should return null when payload is empty", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: [],
payload: [] as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
@ -58,9 +60,9 @@ describe("CustomTooltip", () => {
});
it("should display formatted category names", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -89,9 +91,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: payloadWithUnderscores,
payload: payloadWithUnderscores as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -120,9 +122,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: spendPayload,
payload: spendPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -130,9 +132,9 @@ describe("CustomTooltip", () => {
});
it("should format non-spend numeric values with locale string", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -161,9 +163,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: payloadWithUndefined,
payload: payloadWithUndefined as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -211,9 +213,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: multiplePayload,
payload: multiplePayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -222,9 +224,9 @@ describe("CustomTooltip", () => {
});
it("should convert color names to hex values", () => {
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
@ -254,9 +256,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: payloadWithHexColor,
payload: payloadWithHexColor as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
@ -286,9 +288,9 @@ describe("CustomTooltip", () => {
},
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: payloadWithoutDataKey as any,
payload: payloadWithoutDataKey as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
@ -304,9 +306,9 @@ describe("CustomTooltip", () => {
payload: undefined,
},
];
const props: CustomTooltipProps = {
const props: ChartTooltipProps = {
active: true,
payload: payloadWithoutPayload as any,
payload: payloadWithoutPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);

View file

@ -1,4 +1,4 @@
import type { CustomTooltipProps } from "@tremor/react";
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
import { SpendMetrics } from "../UsagePage/types";
interface ChartDataPoint {
@ -16,7 +16,7 @@ const colorNameToHex: { [key: string]: string } = {
emerald: "#37bc7d",
};
export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => {
if (active && payload && payload.length) {
const formatCategoryName = (name: string): string => {
return name

View file

@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import type { ReactElement } from "react";
import { describe, expect, it, vi } from "vitest";
import type { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig";
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
@ -16,14 +16,6 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([]),
}));
vi.mock("@tremor/react", () => ({
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../router_settings/RouterSettingsForm", () => ({
default: ({ value }: { value: RouterSettingsFormValue }) => (
<div data-testid="loadbalancing">{JSON.stringify(value.routerSettings)}</div>

View file

@ -1,5 +1,5 @@
import React from "react";
import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
export interface SimpleTableColumn<T> {
header: string;
@ -31,20 +31,20 @@ export function SimpleTable<T>({
}: SimpleTableProps<T>) {
return (
<Table>
<TableHead>
<TableHeader>
<TableRow>
{columns.map((column, index) => (
<TableHeaderCell key={index} style={{ width: column.width }}>
<TableHead key={index} style={{ width: column.width }}>
{column.header}
</TableHeaderCell>
</TableHead>
))}
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<Text className="text-gray-500">{loadingMessage}</Text>
<span className="text-gray-500">{loadingMessage}</span>
</TableCell>
</TableRow>
) : data.length > 0 ? (
@ -60,7 +60,7 @@ export function SimpleTable<T>({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<Text className="text-gray-500">{emptyMessage}</Text>
<span className="text-gray-500">{emptyMessage}</span>
</TableCell>
</TableRow>
)}

View file

@ -1,13 +1,8 @@
import React, { useMemo, useState, type UIEvent } from "react";
import { Select, Typography } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import React, { useMemo, useState } from "react";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { Team } from "../key_team_helpers/key_list";
const { Text } = Typography;
interface TeamDropdownProps {
value?: string;
onChange?: (value: string) => void;
@ -17,10 +12,9 @@ interface TeamDropdownProps {
/** Filter teams by organization. */
organizationId?: string | null;
pageSize?: number;
id?: string;
}
const SCROLL_THRESHOLD = 0.8;
const TeamDropdown: React.FC<TeamDropdownProps> = ({
value,
onChange,
@ -28,15 +22,13 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
disabled,
organizationId,
pageSize = 20,
id,
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_WAIT_MS,
});
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
pageSize,
debouncedSearch || undefined,
search || undefined,
organizationId,
);
@ -54,59 +46,35 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
return result;
}, [data]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
const handleChange = (teamId: string | undefined) => {
onChange?.(teamId ?? "");
const handleChange = (teamId: string) => {
onChange?.(teamId);
if (onTeamSelect) {
const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null;
onTeamSelect(team);
onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null);
}
};
return (
<Select
showSearch
placeholder="Search or select a team"
value={value || undefined}
onChange={handleChange}
disabled={disabled}
allowClear
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
data-testid="team-dropdown"
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
>
{teams.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias}</span> <Text type="secondary">({team.team_id})</Text>
</Select.Option>
))}
</Select>
<div data-testid="team-dropdown">
<PaginatedSearchSelect
options={teams.map((team) => ({
label: team.team_alias || team.team_id,
value: team.team_id,
sublabel: team.team_id,
}))}
value={value || undefined}
onValueChange={handleChange}
onSearchChange={setSearch}
onLoadMore={fetchNextPage}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search or select a team"
emptyText="No teams found"
loadingText="Loading teams…"
disabled={disabled}
inputId={id}
/>
</div>
);
};

View file

@ -21,6 +21,13 @@ describe("SearchSelect", () => {
expect(screen.getByRole("combobox")).toHaveValue("Growth");
});
it("shows a value the options do not carry yet instead of blanking the field", () => {
const { rerender } = render(<SearchSelect options={[]} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("team-2");
rerender(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("Growth");
});
it("shows a clear control only when a value is selected", () => {
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();

View file

@ -24,6 +24,7 @@ interface SearchSelectProps {
emptyText?: string;
disabled?: boolean;
className?: string;
inputId?: string;
}
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
@ -40,12 +41,18 @@ export function SearchSelect({
emptyText = "No results",
disabled = false,
className,
inputId,
}: SearchSelectProps) {
const selected = options.find((option) => option.value === value) ?? null;
const selected =
value === undefined || value === ""
? null
: options.find((option) => option.value === value) ?? { label: value, value };
const items =
selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options;
return (
<Combobox
items={options}
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
@ -54,6 +61,7 @@ export function SearchSelect({
disabled={disabled}
>
<ComboboxInput
id={inputId}
placeholder={placeholder}
showClear={value != null && value !== ""}
className={`h-8 w-full text-sm ${className ?? ""}`}

View file

@ -989,9 +989,8 @@ describe("TeamInfoView", () => {
const user = userEvent.setup({ delay: null });
const resetBudgetItem = await openSettingsEditorForTeam(user, { budget_duration: "30d" });
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
expect(clearIcon).not.toBeNull();
fireEvent.mouseDown(clearIcon as Element);
await user.click(within(resetBudgetItem).getByRole("combobox"));
await user.click(await screen.findByText("Never resets"));
await waitFor(() => {
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
@ -1554,13 +1553,14 @@ describe("TeamInfoView", () => {
await user.click(within(routesFormItem).getByRole("combobox"));
const option = await screen.findByTitle("POST /bedrock-passthrough");
const option = await screen.findByText("POST /bedrock-passthrough");
await user.click(option);
await waitFor(() => {
expect(within(routesFormItem).getByText(/\/bedrock-passthrough/)).toBeInTheDocument();
});
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {

View file

@ -182,7 +182,8 @@ vi.mock("@heroicons/react/outline", async () => {
return { ArrowLeftIcon, TrashIcon, RefreshIcon };
});
vi.mock("lucide-react", async () => {
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
const React = await import("react");
function CopyIcon() {
return React.createElement("span");
@ -192,7 +193,7 @@ vi.mock("lucide-react", async () => {
return React.createElement("span");
}
(CheckIcon as any).displayName = "CheckIcon";
return { CopyIcon, CheckIcon };
return { ...actual, CopyIcon, CheckIcon };
});
// Heavy children -> async factories & local React

View file

@ -961,9 +961,8 @@ describe("KeyEditView", () => {
);
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
expect(clearIcon).not.toBeNull();
fireEvent.mouseDown(clearIcon as Element);
await userEvent.click(within(resetBudgetItem).getByRole("combobox"));
await userEvent.click(await screen.findByText("Never resets"));
await waitFor(() => {
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
@ -995,7 +994,8 @@ describe("KeyEditView", () => {
);
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element);
await userEvent.click(within(resetBudgetItem).getByRole("combobox"));
await userEvent.click(await screen.findByText("Never resets"));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
@ -1251,9 +1251,10 @@ describe("KeyEditView", () => {
expect(screen.getByText("Organization")).toBeInTheDocument();
});
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item");
const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled");
expect(disabledSelect).toBeTruthy();
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement;
await userEvent.click(within(orgFormItem).getByRole("combobox"));
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("should not disable the organization dropdown for admin users", async () => {
@ -1273,9 +1274,10 @@ describe("KeyEditView", () => {
expect(screen.getByText("Organization")).toBeInTheDocument();
});
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item");
const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled");
expect(disabledSelect).toBeFalsy();
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement;
await userEvent.click(within(orgFormItem).getByRole("combobox"));
expect(await screen.findByText("Engineering")).toBeInTheDocument();
});
it("should initialize organization from keyData", async () => {
@ -1296,8 +1298,9 @@ describe("KeyEditView", () => {
/>,
);
const orgFormItem = (await screen.findByText("Organization")).closest(".ant-form-item") as HTMLElement;
await waitFor(() => {
expect(screen.getByText("Engineering")).toBeInTheDocument();
expect(within(orgFormItem).getByRole("combobox")).toHaveValue("Engineering");
});
});
});