- {createdBy || "Unknown"}
+
+ {isConfigModel ? "Defined in config" : createdBy || "Unknown"}
{/* Created At - Secondary */}
-
- {createdAt || "Unknown date"}
+
+ {isConfigModel ? "-" : createdAt || "Unknown date"}
);
diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx
new file mode 100644
index 0000000000..a56721787d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import TagTable from "./TagTable";
+import { Tag } from "./types";
+
+describe("TagTable", () => {
+ const mockOnEdit = vi.fn();
+ const mockOnDelete = vi.fn();
+ const mockOnSelectTag = vi.fn();
+
+ const mockTag: Tag = {
+ name: "test-tag",
+ description: "Test description",
+ models: ["model-1", "model-2"],
+ model_info: {
+ "model-1": "GPT-4",
+ "model-2": "Claude-3",
+ },
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ };
+
+ const mockDynamicSpendTag: Tag = {
+ name: "dynamic-spend-tag",
+ description:
+ "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.",
+ models: [],
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ };
+
+ const defaultProps = {
+ data: [],
+ onEdit: mockOnEdit,
+ onDelete: mockOnDelete,
+ onSelectTag: mockOnSelectTag,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render", () => {
+ render(
);
+ expect(screen.getByText("Tag Name")).toBeInTheDocument();
+ expect(screen.getByText("Description")).toBeInTheDocument();
+ expect(screen.getByText("Allowed Models")).toBeInTheDocument();
+ expect(screen.getByText("Created")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ it("should display no tags found message when data is empty", () => {
+ render(
);
+ expect(screen.getByText("No tags found")).toBeInTheDocument();
+ });
+
+ it("should display tag name", () => {
+ render(
);
+ expect(screen.getByText("test-tag")).toBeInTheDocument();
+ });
+
+ it("should display tag description", () => {
+ render(
);
+ expect(screen.getByText("Test description")).toBeInTheDocument();
+ });
+
+ it("should display All Models badge when models array is empty", () => {
+ const tagWithNoModels: Tag = {
+ ...mockTag,
+ models: [],
+ };
+ render(
);
+ expect(screen.getByText("All Models")).toBeInTheDocument();
+ });
+
+ it("should display formatted created date", () => {
+ render(
);
+ const formattedDate = new Date(mockTag.created_at).toLocaleDateString();
+ expect(screen.getByText(formattedDate)).toBeInTheDocument();
+ });
+
+ it("should disable tag name button for dynamic spend tags", () => {
+ render(
);
+ const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" });
+ expect(tagButton).toBeDisabled();
+ });
+
+ it("should disable edit icon for dynamic spend tags", () => {
+ render(
);
+ const editIcon = screen.getByLabelText("Edit tag (disabled)");
+ expect(editIcon).toBeInTheDocument();
+ expect(editIcon).toHaveClass("cursor-not-allowed");
+ });
+
+ it("should disable delete icon for dynamic spend tags", () => {
+ render(
);
+ const deleteIcon = screen.getByLabelText("Delete tag (disabled)");
+ expect(deleteIcon).toBeInTheDocument();
+ expect(deleteIcon).toHaveClass("cursor-not-allowed");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx
index aa43388893..ce28ac6e6f 100644
--- a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx
+++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx
@@ -1,18 +1,4 @@
-import React from "react";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeaderCell,
- TableRow,
- Icon,
- Button,
- Badge,
- Text,
-} from "@tremor/react";
-import { PencilAltIcon, TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
-import { Tooltip } from "antd";
+import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
@@ -21,6 +7,20 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
+import {
+ Badge,
+ Button,
+ Icon,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ Text,
+} from "@tremor/react";
+import { Tooltip } from "antd";
+import React from "react";
import { Tag } from "./types";
interface TagTableProps {
@@ -30,6 +30,9 @@ interface TagTableProps {
onSelectTag: (tagName: string) => void;
}
+const DYNAMIC_SPEND_TAG_DESCRIPTION =
+ "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.";
+
const TagTable: React.FC
= ({ data, onEdit, onDelete, onSelectTag }) => {
const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]);
@@ -39,14 +42,20 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag
accessorKey: "name",
cell: ({ row }) => {
const tag = row.original;
+ const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
-
+
@@ -68,7 +77,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag
},
},
{
- header: "Allowed LLMs",
+ header: "Allowed Models",
accessorKey: "models",
cell: ({ row }) => {
const tag = row.original;
@@ -102,13 +111,50 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag
},
{
id: "actions",
- header: "",
+ header: "Actions",
cell: ({ row }) => {
const tag = row.original;
+ const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
- onEdit(tag)} className="cursor-pointer" />
- onDelete(tag.name)} className="cursor-pointer" />
+ {isDynamicSpendTag ? (
+
+
+
+ ) : (
+
+ onEdit(tag)}
+ className="cursor-pointer hover:text-blue-500"
+ />
+
+ )}
+ {isDynamicSpendTag ? (
+
+
+
+ ) : (
+
+ onDelete(tag.name)}
+ className="cursor-pointer hover:text-red-500"
+ />
+
+ )}
);
},
diff --git a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx
new file mode 100644
index 0000000000..997faf4a00
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import CreateTagModal from "./CreateTagModal";
+
+describe("CreateTagModal", () => {
+ const mockOnCancel = vi.fn();
+ const mockOnSubmit = vi.fn();
+ const mockAvailableModels = [
+ {
+ model_name: "GPT-4",
+ litellm_params: { model: "gpt-4" },
+ model_info: { id: "model-1" },
+ },
+ {
+ model_name: "Claude-3",
+ litellm_params: { model: "claude-3" },
+ model_info: { id: "model-2" },
+ },
+ ];
+
+ const defaultProps = {
+ visible: true,
+ onCancel: mockOnCancel,
+ onSubmit: mockOnSubmit,
+ availableModels: mockAvailableModels,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render the modal", () => {
+ render();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("Create New Tag")).toBeInTheDocument();
+ });
+
+ it("should submit form with required tag name", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const tagNameInput = screen.getByLabelText("Tag Name");
+ await user.type(tagNameInput, "test-tag");
+
+ const submitButton = screen.getByRole("button", { name: /Create Tag/i });
+ await user.click(submitButton);
+
+ expect(mockOnSubmit).toHaveBeenCalledWith({
+ tag_name: "test-tag",
+ });
+ });
+
+ it("should not submit form when tag name is missing", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole("button", { name: /Create Tag/i });
+ await user.click(submitButton);
+
+ // Form validation should prevent submission
+ expect(mockOnSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx
index 4d1909abd9..3412d68452 100644
--- a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx
+++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx
@@ -1,9 +1,9 @@
-import React from "react";
-import { Button, TextInput, Accordion, AccordionHeader, AccordionBody, Title } from "@tremor/react";
-import { Modal, Form, Select as Select2, Tooltip, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
-import NumericalInput from "../../shared/numerical_input";
+import { Accordion, AccordionBody, AccordionHeader, Button, TextInput, Title } from "@tremor/react";
+import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
+import React from "react";
import BudgetDurationDropdown from "../../common_components/budget_duration_dropdown";
+import NumericalInput from "../../shared/numerical_input";
interface ModelInfo {
model_name: string;
@@ -22,12 +22,7 @@ interface CreateTagModalProps {
availableModels: ModelInfo[];
}
-const CreateTagModal: React.FC = ({
- visible,
- onCancel,
- onSubmit,
- availableModels,
-}) => {
+const CreateTagModal: React.FC = ({ visible, onCancel, onSubmit, availableModels }) => {
const [form] = Form.useForm();
const handleFinish = (values: any) => {
@@ -41,25 +36,9 @@ const CreateTagModal: React.FC = ({
};
return (
-
-
+
+
@@ -70,15 +49,15 @@ const CreateTagModal: React.FC = ({
- Allowed Models{" "}
-
+ Allowed Models
+
}
name="allowed_llms"
>
-
+
{availableModels.map((model) => (
@@ -150,4 +129,3 @@ const CreateTagModal: React.FC = ({
};
export default CreateTagModal;
-
diff --git a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
index 60cde134e6..1c66a107db 100644
--- a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
+++ b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
@@ -1,5 +1,15 @@
import React, { useState, useEffect } from "react";
-import { Card, Text, Title, Button, Badge, Accordion, AccordionHeader, AccordionBody, Title as TremorTitle } from "@tremor/react";
+import {
+ Card,
+ Text,
+ Title,
+ Button,
+ Badge,
+ Accordion,
+ AccordionHeader,
+ AccordionBody,
+ Title as TremorTitle,
+} from "@tremor/react";
import { Form, Input, Select as Select2, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { fetchUserModels } from "../organisms/create_key_button";
@@ -131,7 +141,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
-
+
@@ -141,15 +151,15 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
- Allowed LLMs{" "}
-
+ Allowed Models
+
}
name="models"
>
-
+
{userModels.map((modelId) => (
{getModelDisplayName(modelId)}
@@ -228,7 +238,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
{tagDetails.description || "-"}
-
Allowed LLMs
+
Allowed Models
{!tagDetails.models || tagDetails.models.length === 0 ? (
All Models
@@ -256,30 +266,33 @@ const TagInfoView: React.FC
= ({ tagId, onClose, accessToken,
Budget & Rate Limits
- {tagDetails.litellm_budget_table.max_budget !== undefined && tagDetails.litellm_budget_table.max_budget !== null && (
-
- Max Budget
- ${tagDetails.litellm_budget_table.max_budget}
-
- )}
+ {tagDetails.litellm_budget_table.max_budget !== undefined &&
+ tagDetails.litellm_budget_table.max_budget !== null && (
+
+ Max Budget
+ ${tagDetails.litellm_budget_table.max_budget}
+
+ )}
{tagDetails.litellm_budget_table.budget_duration && (
Budget Duration
{tagDetails.litellm_budget_table.budget_duration}
)}
- {tagDetails.litellm_budget_table.tpm_limit !== undefined && tagDetails.litellm_budget_table.tpm_limit !== null && (
-
- TPM Limit
- {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}
-
- )}
- {tagDetails.litellm_budget_table.rpm_limit !== undefined && tagDetails.litellm_budget_table.rpm_limit !== null && (
-
- RPM Limit
- {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}
-
- )}
+ {tagDetails.litellm_budget_table.tpm_limit !== undefined &&
+ tagDetails.litellm_budget_table.tpm_limit !== null && (
+
+ TPM Limit
+ {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}
+
+ )}
+ {tagDetails.litellm_budget_table.rpm_limit !== undefined &&
+ tagDetails.litellm_budget_table.rpm_limit !== null && (
+
+ RPM Limit
+ {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}
+
+ )}
)}
diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/team_info.test.tsx
index 526f0972d9..17041659ce 100644
--- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/team_info.test.tsx
@@ -1,7 +1,7 @@
+import * as networking from "@/components/networking";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import TeamInfoView from "./team_info";
-import { render, waitFor } from "@testing-library/react";
-import * as networking from "@/components/networking";
// Mock the networking module
vi.mock("@/components/networking", () => ({
@@ -61,7 +61,7 @@ describe("TeamInfoView", () => {
vi.mocked(networking.getGuardrailsList).mockResolvedValue([]);
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
- const { getByText } = render(
+ render(
{}}
@@ -75,7 +75,87 @@ describe("TeamInfoView", () => {
/>,
);
await waitFor(() => {
- expect(getByText("User ID")).toBeInTheDocument();
+ expect(screen.queryByText("User ID")).not.toBeNull();
});
});
+
+ it("should not show all-proxy-models option when user has no access to it", async () => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue({
+ team_id: "123",
+ team_info: {
+ team_alias: "Test Team",
+ team_id: "123",
+ organization_id: null,
+ admins: ["admin@test.com"],
+ members: ["user1@test.com", "user2@test.com"],
+ members_with_roles: [
+ {
+ user_id: "user1@test.com",
+ user_email: "user1@test.com",
+ role: "member",
+ spend: 0,
+ budget_id: "budget1",
+ },
+ ],
+ metadata: {},
+ tpm_limit: null,
+ rpm_limit: null,
+ max_budget: null,
+ budget_duration: null,
+ models: ["gpt-4"],
+ blocked: false,
+ spend: 0,
+ max_parallel_requests: null,
+ budget_reset_at: null,
+ model_id: null,
+ litellm_model_table: null,
+ created_at: "2024-01-01T00:00:00Z",
+ team_member_budget_table: null,
+ },
+ keys: [],
+ team_memberships: [],
+ });
+
+ vi.mocked(networking.getGuardrailsList).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
+
+ render(
+ {}}
+ onClose={() => {}}
+ accessToken="123"
+ is_team_admin={true}
+ is_proxy_admin={true}
+ userModels={["gpt-4", "gpt-3.5-turbo"]}
+ editTeam={false}
+ premiumUser={false}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Test Team")).not.toBeNull();
+ });
+
+ const settingsTab = screen.getByRole("tab", { name: "Settings" });
+ act(() => {
+ fireEvent.click(settingsTab);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Team Settings")).toBeInTheDocument();
+ });
+
+ const editButton = screen.getByRole("button", { name: "Edit Settings" });
+ act(() => {
+ fireEvent.click(editButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Models")).toBeInTheDocument();
+ });
+
+ const allProxyModelsOption = screen.queryByText("All Proxy Models");
+ expect(allProxyModelsOption).not.toBeInTheDocument();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx
index bd52b5aef4..1c6ba629ef 100644
--- a/ui/litellm-dashboard/src/components/team/team_info.tsx
+++ b/ui/litellm-dashboard/src/components/team/team_info.tsx
@@ -1,50 +1,50 @@
-import React, { useState, useEffect } from "react";
-import NumericalInput from "../shared/numerical_input";
+import UserSearchModal from "@/components/common_components/user_search_modal";
import {
- Card,
- Title,
- Text,
- Tab,
- TabList,
- TabGroup,
- TabPanel,
- TabPanels,
- Grid,
- Badge,
- Button as TremorButton,
- TextInput,
-} from "@tremor/react";
-import TeamMembersComponent from "./team_member_view";
-import MemberPermissions from "./member_permissions";
-import {
- teamInfoCall,
- teamMemberDeleteCall,
- teamMemberAddCall,
- teamMemberUpdateCall,
- Member,
- teamUpdateCall,
getGuardrailsList,
+ Member,
+ teamInfoCall,
+ teamMemberAddCall,
+ teamMemberDeleteCall,
+ teamMemberUpdateCall,
+ teamUpdateCall,
} from "@/components/networking";
-import { Button, Form, Input, Select, Switch, message, Tooltip } from "antd";
+import { formatNumberWithCommas } from "@/utils/dataUtils";
+import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
-import MemberModal from "./edit_membership";
-import UserSearchModal from "@/components/common_components/user_search_modal";
+import {
+ Badge,
+ Card,
+ Grid,
+ Tab,
+ TabGroup,
+ TabList,
+ TabPanel,
+ TabPanels,
+ Text,
+ TextInput,
+ Title,
+ Button as TremorButton,
+} from "@tremor/react";
+import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
+import DeleteResourceModal from "../common_components/DeleteResourceModal";
+import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
-import ObjectPermissionsView from "../object_permissions_view";
-import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import LoggingSettingsView from "../logging_settings_view";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
-import { formatNumberWithCommas } from "@/utils/dataUtils";
-import EditLoggingSettings from "./EditLoggingSettings";
-import LoggingSettingsView from "../logging_settings_view";
-import { fetchMCPAccessGroups } from "../networking";
-import { CheckIcon, CopyIcon } from "lucide-react";
-import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import NotificationsManager from "../molecules/notifications_manager";
-import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
-import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
-import DeleteResourceModal from "../common_components/DeleteResourceModal";
+import { fetchMCPAccessGroups } from "../networking";
+import ObjectPermissionsView from "../object_permissions_view";
+import NumericalInput from "../shared/numerical_input";
+import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import MemberModal from "./edit_membership";
+import EditLoggingSettings from "./EditLoggingSettings";
+import MemberPermissions from "./member_permissions";
+import TeamMembersComponent from "./team_member_view";
export interface TeamMembership {
user_id: string;
@@ -586,11 +586,17 @@ const TeamInfoView: React.FC = ({
-
+
),
accessorKey: "sso_user_id",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.sso_user_id !== null ? row.original.sso_user_id : "-"}
),
@@ -73,6 +80,7 @@ export const columns = (
{
header: "API Keys",
accessorKey: "key_count",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.key_count > 0 ? (
@@ -90,7 +98,7 @@ export const columns = (
{
header: "Created At",
accessorKey: "created_at",
- sortingFn: "datetime",
+ enableSorting: true,
cell: ({ row }) => (
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
@@ -100,7 +108,7 @@ export const columns = (
{
header: "Updated At",
accessorKey: "updated_at",
- sortingFn: "datetime",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
@@ -110,6 +118,7 @@ export const columns = (
{
id: "actions",
header: "Actions",
+ enableSorting: false,
cell: ({ row }) => (
@@ -148,6 +157,7 @@ export const columns = (
return [
{
id: "select",
+ enableSorting: false,
header: () => (
{
const updateFilters = vi.fn();
- const { getByText } = render(
+ render(
{
/>,
);
- expect(getByText("Filters")).toBeInTheDocument();
+ expect(screen.getByText("Filters")).toBeInTheDocument();
+ });
+
+ it("should call onSortChange when clicking a sortable header", () => {
+ const filters = {
+ email: "",
+ user_id: "",
+ user_role: "",
+ sso_user_id: "",
+ team: "",
+ model: "",
+ min_spend: null,
+ max_spend: null,
+ sort_by: "created_at",
+ sort_order: "desc" as const,
+ };
+
+ const updateFilters = vi.fn();
+ const onSortChange = vi.fn();
+
+ const possibleUIRoles = {
+ admin: { ui_label: "Admin" },
+ user: { ui_label: "User" },
+ };
+
+ render(
+ ,
+ );
+
+ const emailHeader = screen.getByRole("columnheader", { name: /email/i });
+ act(() => {
+ fireEvent.click(emailHeader);
+ });
+
+ expect(onSortChange).toHaveBeenCalledWith("user_email", "desc");
});
});
diff --git a/ui/litellm-dashboard/src/components/view_users/table.tsx b/ui/litellm-dashboard/src/components/view_users/table.tsx
index 7ce8f4ce65..f20eb2a6d1 100644
--- a/ui/litellm-dashboard/src/components/view_users/table.tsx
+++ b/ui/litellm-dashboard/src/components/view_users/table.tsx
@@ -1,11 +1,4 @@
-import {
- ColumnDef,
- flexRender,
- getCoreRowModel,
- getSortedRowModel,
- SortingState,
- useReactTable,
-} from "@tanstack/react-table";
+import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
import React from "react";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Select, SelectItem } from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
@@ -167,17 +160,23 @@ export function UserDataTable({
state: {
sorting,
},
- onSortingChange: (newSorting: any) => {
+ onSortingChange: (updaterOrValue: any) => {
+ const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
setSorting(newSorting);
- if (newSorting.length > 0) {
+ if (newSorting && Array.isArray(newSorting) && newSorting.length > 0 && newSorting[0]) {
const sortState = newSorting[0];
- const sortBy = sortState.id;
- const sortOrder = sortState.desc ? "desc" : "asc";
- onSortChange?.(sortBy, sortOrder);
+ if (sortState.id) {
+ const sortBy = sortState.id;
+ const sortOrder = sortState.desc ? "desc" : "asc";
+ onSortChange?.(sortBy, sortOrder);
+ }
+ } else {
+ // Reset to default sort when no sorting is selected
+ onSortChange?.("created_at", "desc");
}
},
getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
+ manualSorting: true,
enableSorting: true,
});
@@ -403,7 +402,7 @@ export function UserDataTable({
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
- }`}
+ } ${header.column.getCanSort() ? "cursor-pointer hover:bg-gray-50" : ""}`}
onClick={header.column.getToggleSortingHandler()}
>
@@ -412,7 +411,7 @@ export function UserDataTable({
? null
: flexRender(header.column.columnDef.header, header.getContext())}
- {header.id !== "actions" && (
+ {header.id !== "actions" && header.column.getCanSort() && (
{header.column.getIsSorted() ? (
{