mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
feat(ui): sort every organizations table data column (#48106)
* feat(ui): sort every organizations table data column * fix(ui): normalize organization sorting and unlimited limits
This commit is contained in:
parent
1aa531767c
commit
ffe3b5e07e
4 changed files with 130 additions and 12 deletions
|
|
@ -66,6 +66,85 @@ const baseProps = {
|
|||
};
|
||||
|
||||
describe("OrganizationsTable", () => {
|
||||
it.each(["max_budget", "tpm_limit", "rpm_limit"])(
|
||||
"sorts %s numerically with Unlimited as infinity",
|
||||
async (field) => {
|
||||
const user = userEvent.setup();
|
||||
const organizations = [20, 3, null, 100, 0].map((value, index) =>
|
||||
makeOrganization({
|
||||
organization_id: `org-${index}`,
|
||||
organization_alias: `Value ${value}`,
|
||||
litellm_budget_table: { [field]: value },
|
||||
}),
|
||||
);
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={organizations} />, {
|
||||
searchParams: { sort_by: field, sort_order: "asc", page_size: "25" },
|
||||
});
|
||||
const names = () =>
|
||||
screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((row) => within(row).getByText(/^Value /).textContent);
|
||||
expect(names()).toEqual(["Value 0", "Value 3", "Value 20", "Value 100", "Value null"]);
|
||||
if (field === "max_budget") {
|
||||
await user.click(screen.getByTestId("sort-header-max_budget"));
|
||||
} else {
|
||||
await user.click(screen.getByRole("button", { name: "Sort options for TPM or RPM" }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: `${field === "tpm_limit" ? "TPM" : "RPM"} descending` }),
|
||||
);
|
||||
}
|
||||
await waitFor(() => expect(names()).toEqual(["Value null", "Value 100", "Value 20", "Value 3", "Value 0"]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["asc", "desc"])("groups both All Proxy Models representations when sorting %s", (direction) => {
|
||||
const organizations = [
|
||||
makeOrganization({ organization_id: "org-empty", organization_alias: "Name Empty", models: [] }),
|
||||
makeOrganization({ organization_id: "org-z", organization_alias: "Name Z", models: ["z"] }),
|
||||
makeOrganization({
|
||||
organization_id: "org-wildcard",
|
||||
organization_alias: "Name Wildcard",
|
||||
models: ["all-proxy-models"],
|
||||
}),
|
||||
makeOrganization({ organization_id: "org-a", organization_alias: "Name A", models: ["a"] }),
|
||||
];
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={organizations} />, {
|
||||
searchParams: { sort_by: "models", sort_order: direction },
|
||||
});
|
||||
const names = screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((row) => within(row).getByText(/^Name /).textContent);
|
||||
const models = direction === "asc" ? ["Name A", "Name Z"] : ["Name Z", "Name A"];
|
||||
expect(names).toEqual([...models, "Name Empty", "Name Wildcard"]);
|
||||
expect(screen.getAllByText("All Proxy Models")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["models", "members"])("sorts %s by its displayed data", async (field) => {
|
||||
const user = userEvent.setup();
|
||||
const organizations = [
|
||||
{ organization_id: "org-zed", organization_alias: "Zed", models: ["z"], members: [] },
|
||||
{
|
||||
organization_id: "org-ace",
|
||||
organization_alias: "Ace",
|
||||
models: ["a"],
|
||||
members: [{ user_id: "1" }, { user_id: "2" }],
|
||||
},
|
||||
{
|
||||
organization_id: "org-mid",
|
||||
organization_alias: "Mid",
|
||||
models: ["m"],
|
||||
members: [{ user_id: "3" }],
|
||||
},
|
||||
].map(makeOrganization);
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={organizations} />);
|
||||
await user.click(screen.getByTestId(`sort-header-${field}`));
|
||||
await waitFor(() => expect(bodyRowAliases()).toEqual(["Ace", "Mid", "Zed"]));
|
||||
await user.click(screen.getByTestId(`sort-header-${field}`));
|
||||
await waitFor(() => expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"]));
|
||||
});
|
||||
|
||||
it("renders every column header", () => {
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={[]} />);
|
||||
for (const header of [
|
||||
|
|
@ -75,7 +154,6 @@ describe("OrganizationsTable", () => {
|
|||
"Spend (USD)",
|
||||
"Budget (USD)",
|
||||
"Models",
|
||||
"TPM / RPM Limits",
|
||||
"Members",
|
||||
]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
|
|
@ -87,7 +165,7 @@ describe("OrganizationsTable", () => {
|
|||
for (const header of ["Spend (USD)", "Budget (USD)", "Members"]) {
|
||||
expect(screen.getByRole("columnheader", { name: header })).toHaveClass("text-right");
|
||||
}
|
||||
for (const header of ["Organization Name", "TPM / RPM Limits"]) {
|
||||
for (const header of ["Organization Name"]) {
|
||||
expect(screen.getByRole("columnheader", { name: header })).not.toHaveClass("text-right");
|
||||
}
|
||||
});
|
||||
|
|
@ -270,7 +348,7 @@ describe("OrganizationsTable URL state", () => {
|
|||
|
||||
it("falls back to sorting by creation date for a ?sort_by= column that cannot be sorted", () => {
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={sortableOrganizations} />, {
|
||||
searchParams: "?sort_by=members&sort_order=asc",
|
||||
searchParams: "?sort_by=actions&sort_order=asc",
|
||||
});
|
||||
|
||||
expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"]);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
|||
onPaginationChange={onPaginationChange}
|
||||
columns={columns}
|
||||
getRowId={(organization, index) => organization.organization_id || String(index)}
|
||||
defaultColumnVisibility={{ tpm_limit: false, rpm_limit: false }}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Organization } from "@/components/networking";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
|
@ -137,38 +137,67 @@ export const getOrganizationsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorFn: (organization) => getOrganizationBudget(organization).max_budget ?? Number.POSITIVE_INFINITY,
|
||||
sortUndefined: "last",
|
||||
sortDescFirst: true,
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" />,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<MoneyCell value={getOrganizationBudget(row.original).max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorFn: (organization) =>
|
||||
organization.models?.length && !organization.models.includes("all-proxy-models")
|
||||
? [...organization.models].sort().join(", ")
|
||||
: undefined,
|
||||
sortUndefined: "last",
|
||||
sortDescFirst: false,
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Models" />,
|
||||
size: 260,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
},
|
||||
{
|
||||
id: "limits",
|
||||
meta: { title: "TPM / RPM Limits" },
|
||||
header: "TPM / RPM Limits",
|
||||
header: ({ table }) => (
|
||||
<DataTableMultiSortHeader
|
||||
table={table}
|
||||
fields={[
|
||||
{ id: "tpm_limit", label: "TPM" },
|
||||
{ id: "rpm_limit", label: "RPM" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <OrganizationLimitsCell organization={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
accessorFn: (organization) => organization.members?.length ?? 0,
|
||||
sortUndefined: "last",
|
||||
sortDescFirst: true,
|
||||
meta: { title: "Members", numeric: true },
|
||||
header: "Members",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.members?.length ?? 0} Members</span>,
|
||||
},
|
||||
...(["tpm_limit", "rpm_limit"] as const).map(
|
||||
(field): ColumnDef<Organization> => ({
|
||||
id: field,
|
||||
accessorFn: (organization) => getOrganizationBudget(organization)[field] ?? Number.POSITIVE_INFINITY,
|
||||
sortUndefined: "last",
|
||||
sortDescFirst: true,
|
||||
enableHiding: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ const FILTER_COLUMNS = ["org_id"] as const;
|
|||
type FilterColumn = (typeof FILTER_COLUMNS)[number];
|
||||
|
||||
const TABLE_STATE_OPTIONS: UrlTableStateOptions<FilterColumn> = {
|
||||
sortFields: ["organization_id", "organization_alias", "created_at", "spend"],
|
||||
sortFields: [
|
||||
"organization_id",
|
||||
"organization_alias",
|
||||
"created_at",
|
||||
"spend",
|
||||
"max_budget",
|
||||
"models",
|
||||
"members",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
],
|
||||
defaultSort: { id: "created_at", desc: true },
|
||||
defaultPageSize: 25,
|
||||
filterColumns: FILTER_COLUMNS,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue