refactor(ui): move the shared dropdowns and selectors onto shadcn primitives

Rebuilds the thirteen form-free components under common_components on
the in-repo shadcn layer, so they inherit the dashboard's design tokens
instead of styling themselves through Ant Design and Tremor.

SearchSelect and the three dropdowns that wrap it now forward an
optional input id, so an antd Form.Item label still resolves to its
control. The e2e steps that reached into antd's Select and Modal
internals now go through the test id, role and data-slot.
This commit is contained in:
Yuneng Jiang 2026-08-14 06:09:10 -07:00
parent 423b791ee0
commit afff1b08fa
No known key found for this signature in database
31 changed files with 363 additions and 487 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

@ -2377,15 +2377,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
}
@ -2434,17 +2426,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
}
@ -2454,14 +2440,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
@ -2470,29 +2448,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
@ -2503,22 +2463,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": {
@ -2527,9 +2474,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/check_openapi_schema.tsx": {
@ -2554,17 +2498,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

@ -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 && 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

@ -24,6 +24,7 @@ interface SearchSelectProps {
emptyText?: string;
disabled?: boolean;
className?: string;
inputId?: string;
}
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
@ -40,6 +41,7 @@ export function SearchSelect({
emptyText = "No results",
disabled = false,
className,
inputId,
}: SearchSelectProps) {
const selected = options.find((option) => option.value === value) ?? null;
@ -54,6 +56,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

@ -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");
});
});
});