mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
423b791ee0
commit
afff1b08fa
31 changed files with 363 additions and 487 deletions
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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'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:
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue