mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge pull request #36924 from BerriAI/litellm_shadcn_dropdowns_0814
refactor(ui): move the shared dropdowns and selectors onto shadcn primitives
This commit is contained in:
commit
cb33d204f4
34 changed files with 394 additions and 491 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();
|
||||
|
|
|
|||
|
|
@ -2321,15 +2321,7 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/DefaultProxyAdminTag.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/DeleteResourceModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2378,17 +2370,11 @@
|
|||
}
|
||||
},
|
||||
"src/components/common_components/ModelAliasManager.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/ModelSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2398,14 +2384,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/OrganizationDropdown.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughGuardrailsSection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
@ -2414,29 +2392,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughRoutesSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughSecuritySection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PremiumLoggingSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/ProjectDropdown.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/RateLimitTypeFormItem.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -2447,22 +2407,9 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/RouterSettingsAccordion.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/budget_duration_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/chartUtils.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/chartUtils.tsx": {
|
||||
|
|
@ -2471,9 +2418,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/check_openapi_schema.tsx": {
|
||||
|
|
@ -2498,17 +2442,11 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/team_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/team_multi_select.tsx": {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => {
|
|||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onCancel when escape is pressed and no deletion is in flight", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(mockOnCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} requiredConfirmation="DELETE" />);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
interface DeleteResourceModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -8,12 +12,11 @@ interface DeleteResourceModalProps {
|
|||
alertMessage?: string;
|
||||
message: string;
|
||||
resourceInformationTitle?: string;
|
||||
resourceInformation?: Array<
|
||||
{
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
} & Omit<React.ComponentProps<typeof Typography.Text>, "children">
|
||||
>;
|
||||
resourceInformation?: Array<{
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
code?: boolean;
|
||||
}>;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
confirmLoading: boolean;
|
||||
|
|
@ -32,8 +35,6 @@ export default function DeleteResourceModal({
|
|||
confirmLoading,
|
||||
requiredConfirmation,
|
||||
}: DeleteResourceModalProps) {
|
||||
const { Text } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const [requiredConfirmationInput, setRequiredConfirmationInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -43,69 +44,69 @@ export default function DeleteResourceModal({
|
|||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={isOpen}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={confirmLoading}
|
||||
okText={confirmLoading ? "Deleting..." : "Delete"}
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: (!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading,
|
||||
}}
|
||||
cancelButtonProps={{ disabled: confirmLoading }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{alertMessage && <Alert message={alertMessage} type="warning" />}
|
||||
<Card
|
||||
title={resourceInformationTitle}
|
||||
className="mt-4"
|
||||
styles={{
|
||||
body: { padding: "16px" },
|
||||
header: {
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderColor: token.colorErrorBorder,
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderColor: token.colorErrorBorder,
|
||||
}}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
{resourceInformation &&
|
||||
resourceInformation.map(({ label, value, ...textProps }) => (
|
||||
<Descriptions.Item key={label} label={<span className="font-semibold">{label}</span>}>
|
||||
<Text {...textProps}>{value ?? "-"}</Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<div>
|
||||
<Text>{message}</Text>
|
||||
</div>
|
||||
{requiredConfirmation && (
|
||||
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Text className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Text>Type </Text>
|
||||
<Text strong type="danger">
|
||||
{requiredConfirmation}
|
||||
</Text>
|
||||
<Text> to confirm deletion:</Text>
|
||||
</Text>
|
||||
<Input
|
||||
value={requiredConfirmationInput}
|
||||
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
|
||||
placeholder={requiredConfirmation}
|
||||
className="rounded-md"
|
||||
prefix={<ExclamationCircleOutlined style={{ color: token.colorError }} />}
|
||||
autoFocus
|
||||
/>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && !confirmLoading && onCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{alertMessage && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{alertMessage}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
<Card size="sm" className="mt-4">
|
||||
{resourceInformationTitle && (
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>{resourceInformationTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1">
|
||||
{resourceInformation?.map(({ label, value, code }) => (
|
||||
<React.Fragment key={label}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="min-w-0 break-words">{code ? <code>{value ?? "-"}</code> : value ?? "-"}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
{requiredConfirmation && (
|
||||
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-semibold text-destructive">{requiredConfirmation}</span> to confirm deletion:
|
||||
</p>
|
||||
<InputGroup className="rounded-md">
|
||||
<InputGroupAddon>
|
||||
<CircleAlert className="size-3.5 text-destructive" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={requiredConfirmationInput}
|
||||
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
|
||||
placeholder={requiredConfirmation}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={confirmLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onOk}
|
||||
disabled={(!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading}
|
||||
>
|
||||
{confirmLoading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ describe("SearchSelect", () => {
|
|||
expect(screen.getByRole("combobox")).toHaveValue("Growth");
|
||||
});
|
||||
|
||||
it("shows a value the options do not carry yet instead of blanking the field", () => {
|
||||
const { rerender } = render(<SearchSelect options={[]} value="team-2" onValueChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("team-2");
|
||||
rerender(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Growth");
|
||||
});
|
||||
|
||||
it("shows a clear control only when a value is selected", () => {
|
||||
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface SearchSelectProps {
|
|||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
|
||||
|
|
@ -40,12 +41,18 @@ export function SearchSelect({
|
|||
emptyText = "No results",
|
||||
disabled = false,
|
||||
className,
|
||||
inputId,
|
||||
}: SearchSelectProps) {
|
||||
const selected = options.find((option) => option.value === value) ?? null;
|
||||
const selected =
|
||||
value === undefined || value === ""
|
||||
? null
|
||||
: options.find((option) => option.value === value) ?? { label: value, value };
|
||||
const items =
|
||||
selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={options}
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
|
|
@ -54,6 +61,7 @@ export function SearchSelect({
|
|||
disabled={disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
showClear={value != null && value !== ""}
|
||||
className={`h-8 w-full text-sm ${className ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ vi.mock("@heroicons/react/outline", async () => {
|
|||
return { ArrowLeftIcon, TrashIcon, RefreshIcon };
|
||||
});
|
||||
|
||||
vi.mock("lucide-react", async () => {
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
const React = await import("react");
|
||||
function CopyIcon() {
|
||||
return React.createElement("span");
|
||||
|
|
@ -192,7 +193,7 @@ vi.mock("lucide-react", async () => {
|
|||
return React.createElement("span");
|
||||
}
|
||||
(CheckIcon as any).displayName = "CheckIcon";
|
||||
return { CopyIcon, CheckIcon };
|
||||
return { ...actual, CopyIcon, CheckIcon };
|
||||
});
|
||||
|
||||
// Heavy children -> async factories & local React
|
||||
|
|
|
|||
|
|
@ -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