Merge pull request #17168 from BerriAI/litellm_ui_users_loading

[Feature] UI - Better Loading State for Internal User Page
This commit is contained in:
yuneng-jiang
2025-11-26 17:28:40 -08:00
committed by GitHub
4 changed files with 169 additions and 58 deletions
@@ -11,6 +11,10 @@ test.describe("User Info View", () => {
test("should display user info when clicking on user ID", async ({
page,
}) => {
// Wait for loading state to disappear
await page.waitForSelector('text="🚅 Loading users..."', {
state: "hidden",
});
// Wait for users table to load
await page.waitForSelector("table");
@@ -27,6 +27,7 @@ import SSOSettings from "./SSOSettings";
import { columns } from "./view_users/columns";
import { UserDataTable } from "./view_users/table";
import { UserInfo } from "./view_users/types";
import { Skeleton } from "antd";
const { Text, Title } = Typography;
@@ -277,14 +278,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
});
const possibleUIRoles = userRolesQuery.data;
if (userListQuery.isLoading) {
return <div>Loading...</div>;
}
if (!accessToken || !token || !userRole || !userID) {
return <div>Loading...</div>;
}
const tableColumns = columns(
possibleUIRoles,
(user) => {
@@ -300,21 +293,31 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
<div className="w-full p-8 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex space-x-3">
<CreateUser userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
{userListQuery.isLoading ? (
<>
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
<Skeleton.Button active size="default" shape="default" style={{ width: 145, height: 36 }} />
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
</>
) : userID && accessToken ? (
<>
<CreateUser userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<Button
onClick={handleToggleSelectionMode}
variant={selectionMode ? "primary" : "secondary"}
className="flex items-center"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
</Button>
<Button
onClick={handleToggleSelectionMode}
variant={selectionMode ? "primary" : "secondary"}
className="flex items-center"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
</Button>
{selectionMode && (
<Button onClick={handleBulkEdit} disabled={selectedUsers.length === 0} className="flex items-center">
Bulk Edit ({selectedUsers.length} selected)
</Button>
)}
{selectionMode && (
<Button onClick={handleBulkEdit} disabled={selectedUsers.length === 0} className="flex items-center">
Bulk Edit ({selectedUsers.length} selected)
</Button>
)}
</>
) : null}
</div>
</div>
@@ -358,12 +361,18 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
</TabPanel>
<TabPanel>
<SSOSettings
accessToken={accessToken}
possibleUIRoles={possibleUIRoles}
userID={userID}
userRole={userRole}
/>
{!userID || !userRole || !accessToken ? (
<div className="flex justify-center items-center h-64">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
) : (
<SSOSettings
accessToken={accessToken}
possibleUIRoles={possibleUIRoles}
userID={userID}
userRole={userRole}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
@@ -94,4 +94,88 @@ describe("UserDataTable", () => {
expect(onSortChange).toHaveBeenCalledWith("user_email", "desc");
});
it("should show skeleton loaders when isLoading is true", () => {
const filters = {
email: "",
user_id: "",
user_role: "",
sso_user_id: "",
team: "",
model: "",
min_spend: null,
max_spend: null,
sort_by: "",
sort_order: "asc" as const,
};
const updateFilters = vi.fn();
render(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
possibleUIRoles={null}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
handleEdit={vi.fn()}
handleDelete={vi.fn()}
handleResetPassword={vi.fn()}
userListResponse={{ users: [], total: 0, page: 1, page_size: 25, total_pages: 1 }}
currentPage={1}
handlePageChange={vi.fn()}
isLoading={true}
/>,
);
expect(screen.queryByText(/Showing/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Previous/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Next/i })).not.toBeInTheDocument();
});
it("should show actual content when isLoading is false", () => {
const filters = {
email: "",
user_id: "",
user_role: "",
sso_user_id: "",
team: "",
model: "",
min_spend: null,
max_spend: null,
sort_by: "",
sort_order: "asc" as const,
};
const updateFilters = vi.fn();
render(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
possibleUIRoles={null}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
handleEdit={vi.fn()}
handleDelete={vi.fn()}
handleResetPassword={vi.fn()}
userListResponse={{ users: [], total: 0, page: 1, page_size: 25, total_pages: 1 }}
currentPage={1}
handlePageChange={vi.fn()}
isLoading={false}
/>,
);
expect(screen.getByText(/Showing/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Previous/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument();
});
});
@@ -2,6 +2,7 @@ import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } f
import React from "react";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Select, SelectItem } from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
import { Skeleton } from "antd";
import { UserInfo } from "./types";
import UserInfoView from "./user_info_view";
import { columns as createColumns } from "./columns";
@@ -348,40 +349,53 @@ export function UserDataTable({
{/* Results Count and Pagination */}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700">
Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0}{" "}
-{" "}
{userListResponse && userListResponse.users
? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total)
: 0}{" "}
of {userListResponse ? userListResponse.total : 0} results
</span>
{isLoading ? (
<Skeleton.Input active style={{ width: 192, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0}{" "}
-{" "}
{userListResponse && userListResponse.users
? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total)
: 0}{" "}
of {userListResponse ? userListResponse.total : 0} results
</span>
)}
{/* Pagination Buttons */}
<div className="flex space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Previous
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!userListResponse || currentPage >= userListResponse.total_pages}
className={`px-3 py-1 text-sm border rounded-md ${
!userListResponse || currentPage >= userListResponse.total_pages
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Next
</button>
{isLoading ? (
<>
<Skeleton.Button active size="small" style={{ width: 80, height: 30 }} />
<Skeleton.Button active size="small" style={{ width: 60, height: 30 }} />
</>
) : (
<>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`px-3 py-1 text-sm border rounded-md ${
currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Previous
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!userListResponse || currentPage >= userListResponse.total_pages}
className={`px-3 py-1 text-sm border rounded-md ${
!userListResponse || currentPage >= userListResponse.total_pages
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Next
</button>
</>
)}
</div>
</div>
</div>