Merge pull request #18619 from BerriAI/litellm_ui_keys_loading_2

[Feature] UI - Virtual Keys Table Loading State Between Pages
This commit is contained in:
yuneng-jiang 2026-01-03 19:30:32 -08:00 • committed by GitHub
commit 359b8df8b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 142 additions and 11 deletions

View file

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, fireEvent } from "@testing-library/react";
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { VirtualKeysTable } from "./VirtualKeysTable";
@ -264,3 +264,133 @@ it("should show skeleton loaders when isLoading is true", () => {
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
expect(screen.queryByText("Test Team")).not.toBeInTheDocument();
});
it("should show 'No keys found' message when filteredKeys is empty", () => {
// Mock empty filteredKeys
mockUseFilterLogic.mockReturnValue({
filters: {
"Team ID": "",
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
},
filteredKeys: [],
allKeyAliases: [],
allTeams: [mockTeam],
allOrganizations: [mockOrganization],
handleFilterChange: vi.fn(),
handleFilterReset: vi.fn(),
});
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
expect(screen.getByText("No keys found")).toBeInTheDocument();
});
it("should handle models with more than 3 entries to trigger expansion UI", () => {
const keyWithManyModels = {
...mockKey,
models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"],
};
mockUseFilterLogic.mockReturnValue({
filters: {
"Team ID": "",
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
},
filteredKeys: [keyWithManyModels],
allKeyAliases: ["test-key-alias"],
allTeams: [mockTeam],
allOrganizations: [mockOrganization],
handleFilterChange: vi.fn(),
handleFilterReset: vi.fn(),
});
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
// This test ensures the ChevronDownIcon import (line 6) is used
// by having a key with > 3 models which triggers the expansion logic
// that uses ChevronDownIcon and ChevronRightIcon
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
});
it("should render table headers correctly", () => {
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
// Check that main headers are rendered (testing the header.isPlaceholder condition path)
expect(screen.getByText("Key ID")).toBeInTheDocument();
expect(screen.getByText("Key Alias")).toBeInTheDocument();
expect(screen.getByText("Team Alias")).toBeInTheDocument();
expect(screen.getByText("Models")).toBeInTheDocument();
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
});
it("should handle column resizing hover events", () => {
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
// Find a header cell with data-header-id attribute
const headerCell = document.querySelector("[data-header-id]") as HTMLElement;
expect(headerCell).toBeInTheDocument();
// Check that the resizer element exists within the header
const resizer = headerCell?.querySelector(".resizer") as HTMLElement;
expect(resizer).toBeInTheDocument();
// Initially, resizer should have opacity 0
expect(resizer.style.opacity).toBe("0");
// Simulate mouse enter using fireEvent - should set opacity to 0.5 (lines 612-616)
fireEvent.mouseEnter(headerCell);
expect(resizer.style.opacity).toBe("0.5");
// Simulate mouse leave using fireEvent - should set opacity back to 0 (lines 618-622)
fireEvent.mouseLeave(headerCell);
expect(resizer.style.opacity).toBe("0");
});

View file

@ -68,12 +68,13 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
});
const [tablePagination, setTablePagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
pageSize: 50,
});
const {
data: keys,
isPending: isLoading,
isFetching,
refetch,
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize);
const totalCount = keys?.total_count || 0;
@ -545,8 +546,8 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
</div>
<div className="flex items-center justify-between w-full mb-4">
{isLoading ? (
<Skeleton.Input active style={{ width: 200, height: 20 }} />
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
@ -554,32 +555,32 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
)}
<div className="inline-flex items-center gap-2">
{isLoading ? (
<Skeleton.Input active size="small" style={{ width: 50, height: 20 }} />
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Page {pageIndex + 1} of {table.getPageCount()}
</span>
)}
{isLoading ? (
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => table.previousPage()}
disabled={isLoading || !table.getCanPreviousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
)}
{isLoading ? (
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
) : (
<button
onClick={() => table.nextPage()}
disabled={isLoading || !table.getCanNextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
@ -664,7 +665,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
))}
</TableHead>
<TableBody>
{isLoading ? (
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">