From 417cc5c4fb61dc244cb5da905a3b53afc7ff65d6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 09:58:14 -0700 Subject: [PATCH 1/4] feat(ui): keep organizations list and detail tab state in the URL The organizations list now reads its search (org_search), org ID filter (filter_org_id), sort (sort_by, sort_order) and pagination (page, page_size) from the URL through useUrlTableState. The detail view tabs are controlled by ?org_tab=, and the Edit row action opens ?org=&org_tab=settings in one history entry instead of passing an editOrg flag --- .../_components/OrganizationsPanel.test.tsx | 109 ++++++++++++-- .../_components/OrganizationsPanel.tsx | 47 +++--- .../_components/OrganizationsTable.test.tsx | 134 +++++++++++++++--- .../_components/OrganizationsTable.tsx | 12 +- .../_components/useOrganizationsTableState.ts | 19 +++ .../organization/organizationTabs.ts | 3 + .../organization/organization_view.test.tsx | 112 +++++++++++++-- .../organization/organization_view.tsx | 14 +- 8 files changed, 380 insertions(+), 70 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts create mode 100644 ui/litellm-dashboard/src/components/organization/organizationTabs.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx index c15b9fcaddb..3e87492778a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -1,10 +1,23 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type OrganizationsTableComponent from "./OrganizationsTable"; import type OrganizationInfoViewComponent from "@/components/organization/organization_view"; +import type { OrganizationListFilters } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; + +const useOrganizationsSpy = vi.hoisted(() => vi.fn<(filters?: OrganizationListFilters) => void>()); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useOrganizations: (filters?: OrganizationListFilters) => { + useOrganizationsSpy(filters); + return actual.useOrganizations(filters); + }, + }; +}); vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ __esModule: true, @@ -79,10 +92,13 @@ const renderPanel = ({ premiumUser = true, searchParams = "" }: RenderPanelOptio const expectQueryString = (queryString: string) => waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString }))); +const lastSearchParams = () => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + beforeEach(() => { capturedTableProps = null; mockOrgInfoView.mockClear(); onUrlUpdate.mockClear(); + useOrganizationsSpy.mockClear(); }); describe("OrganizationsPanel", () => { @@ -123,9 +139,7 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { it("opens the org detail directly from a ?org= deep link", () => { renderPanel({ searchParams: "?org=org-from-url" }); - expect(mockOrgInfoView).toHaveBeenLastCalledWith( - expect.objectContaining({ organizationId: "org-from-url", editOrg: false }), - ); + expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-from-url" })); expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument(); }); @@ -139,23 +153,24 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); }); - it("the edit action opens the detail in edit mode with ?org= set", async () => { + it("the edit action pushes ?org= with ?org_tab=settings in one history entry", async () => { renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); - await expectQueryString("?org=org-edit"); - expect(mockOrgInfoView).toHaveBeenLastCalledWith( - expect.objectContaining({ organizationId: "org-edit", editOrg: true }), + await expectQueryString("?org=org-edit&org_tab=settings"); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }), ); + expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-edit" })); }); - it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", async () => { + it("a plain row click after leaving an edit view via browser history opens the detail without the settings tab", async () => { const { navigate } = renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); - await expectQueryString("?org=org-edit"); - expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ editOrg: true })); + await expectQueryString("?org=org-edit&org_tab=settings"); navigate(""); expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); @@ -163,8 +178,76 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { act(() => capturedTableProps?.onOrganizationClick("org-plain")); await expectQueryString("?org=org-plain"); - expect(mockOrgInfoView).toHaveBeenLastCalledWith( - expect.objectContaining({ organizationId: "org-plain", editOrg: false }), + expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-plain" })); + }); + + it("a row click drops a leftover ?org_tab= so the detail opens on its default tab", async () => { + renderPanel({ searchParams: "?org_tab=settings" }); + + act(() => capturedTableProps?.onOrganizationClick("org-plain")); + + await expectQueryString("?org=org-plain"); + }); + + it("closing the org detail drops ?org_tab= together with ?org=", async () => { + renderPanel({ searchParams: "?org=org-from-url&org_tab=members" }); + + act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose()); + + await expectQueryString(""); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }), ); }); }); + +describe("OrganizationsPanel - list filters in the URL", () => { + it("restores the name search and org ID filter from the URL and fetches with both", () => { + renderPanel({ searchParams: "?org_search=Acme&filter_org_id=org-7" }); + + expect(screen.getByPlaceholderText("Search by Organization Name")).toHaveValue("Acme"); + expect(screen.getByPlaceholderText("Search by Organization ID")).toHaveValue("org-7"); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-7", org_alias: "Acme" }); + expect(capturedTableProps?.searchActive).toBe(true); + }); + + it("keeps the org ID filter panel collapsed when the URL has no org ID filter", () => { + renderPanel({ searchParams: "?org_search=Acme" }); + + expect(screen.queryByPlaceholderText("Search by Organization ID")).not.toBeInTheDocument(); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "Acme" }); + }); + + it("writes the name search to ?org_search= and returns the list to the first page", async () => { + renderPanel({ searchParams: "?page=3" }); + + fireEvent.change(screen.getByPlaceholderText("Search by Organization Name"), { target: { value: "Acme" } }); + + await waitFor(() => expect(lastSearchParams()?.get("org_search")).toBe("Acme")); + expect(lastSearchParams()?.has("page")).toBe(false); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "Acme" }); + }); + + it("writes the org ID filter to ?filter_org_id= and returns the list to the first page", async () => { + renderPanel({ searchParams: "?page=3" }); + + fireEvent.click(screen.getByRole("button", { name: "Filters" })); + fireEvent.change(screen.getByPlaceholderText("Search by Organization ID"), { target: { value: "org-9" } }); + + await waitFor(() => expect(lastSearchParams()?.get("filter_org_id")).toBe("org-9")); + expect(lastSearchParams()?.has("page")).toBe(false); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-9", org_alias: "" }); + }); + + it("clears the search, the org ID filter and the page in one update on reset", async () => { + renderPanel({ searchParams: "?org_search=Acme&filter_org_id=org-7&page=2" }); + + fireEvent.click(screen.getByRole("button", { name: "Reset Filters" })); + + await expectQueryString(""); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "" }); + expect(capturedTableProps?.searchActive).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx index 4fe4cf47b9c..2810902bef6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -2,16 +2,18 @@ import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/orga import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; import { useQueryClient } from "@tanstack/react-query"; -import { parseAsString, useQueryState } from "nuqs"; +import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; import React, { useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { toast } from "@/lib/toast"; import { organizationDeleteCall } from "@/components/networking"; import { OrgCreateDialog } from "@/components/organization/org-create/OrgCreateDialog"; import OrganizationInfoView from "@/components/organization/organization_view"; +import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS } from "@/components/organization/organizationTabs"; import { Button } from "@/components/ui/button"; import OrganizationsTable from "./OrganizationsTable"; +import { organizationIdFilter, useOrganizationsTableState } from "./useOrganizationsTableState"; interface OrganizationsPanelProps { userRole: string; @@ -19,15 +21,25 @@ interface OrganizationsPanelProps { premiumUser: boolean; } +const ORGANIZATION_DETAIL_STATE = { + org: parseAsString, + tab: parseAsStringLiteral(ORGANIZATION_TABS), +}; +const ORGANIZATION_DETAIL_URL_KEYS = { tab: ORGANIZATION_TAB_URL_KEY }; + const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { - const [selectedOrgId, setSelectedOrgId] = useQueryState("org", parseAsString.withOptions({ history: "push" })); - const [editOrg, setEditOrg] = useState(false); + const [{ org: selectedOrgId }, setOrganizationDetail] = useQueryStates(ORGANIZATION_DETAIL_STATE, { + history: "push", + urlKeys: ORGANIZATION_DETAIL_URL_KEYS, + }); + const tableState = useOrganizationsTableState(); + const { setSearch, onColumnFiltersChange } = tableState; + const filters: FilterState = { org_id: organizationIdFilter(tableState), org_alias: tableState.search }; const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [orgToDelete, setOrgToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [isOrgModalVisible, setIsOrgModalVisible] = useState(false); - const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ org_id: "", org_alias: "" }); + const [showFilters, setShowFilters] = useState(() => filters.org_id !== ""); const queryClient = useQueryClient(); const { data: organizations = [], isLoading } = useOrganizations({ @@ -41,11 +53,16 @@ const OrganizationsPanel: React.FC = ({ userRole, acces const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }); const handleFilterChange = (key: keyof FilterState, value: string) => { - setFilters((previousFilters) => ({ ...previousFilters, [key]: value })); + if (key === "org_alias") { + setSearch(value); + return; + } + onColumnFiltersChange(value ? [{ id: "org_id", value }] : []); }; const handleFilterReset = () => { - setFilters({ org_id: "", org_alias: "" }); + setSearch(""); + onColumnFiltersChange([]); }; const handleDelete = (orgId: string | null) => { @@ -108,15 +125,11 @@ const OrganizationsPanel: React.FC = ({ userRole, acces {selectedOrgId ? ( { - void setSelectedOrgId(null); - setEditOrg(false); - }} + onClose={() => void setOrganizationDetail(null)} accessToken={accessToken} is_org_admin={true} is_proxy_admin={userRole === "Admin"} userModels={userModels} - editOrg={editOrg} /> ) : ( <> @@ -133,14 +146,8 @@ const OrganizationsPanel: React.FC = ({ userRole, acces isLoading={isLoading} userRole={userRole} searchActive={searchActive} - onOrganizationClick={(organizationId) => { - setEditOrg(false); - void setSelectedOrgId(organizationId); - }} - onEditClick={(organizationId) => { - void setSelectedOrgId(organizationId); - setEditOrg(true); - }} + onOrganizationClick={(organizationId) => void setOrganizationDetail({ org: organizationId, tab: null })} + onEditClick={(organizationId) => void setOrganizationDetail({ org: organizationId, tab: "settings" })} onDeleteClick={handleDelete} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx index 4bf465b847b..9d163fe2c08 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx @@ -1,7 +1,9 @@ -import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import React from "react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, type Mock } from "vitest"; + +import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils"; import { Organization } from "@/components/networking"; @@ -26,6 +28,34 @@ const makeOrganization = (overrides: Partial = {}): Organization = ...overrides, }); +const thirtyOrganizations = Array.from({ length: 30 }, (_, index) => + makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }), +); + +const sortableOrganization = (alias: string, createdAt: string, spend: number): Organization => { + const overrides: Partial = { + organization_id: `org-${alias.toLowerCase()}`, + organization_alias: alias, + created_at: createdAt, + spend, + }; + return makeOrganization(overrides); +}; + +const sortableOrganizations = [ + sortableOrganization("Mid", "2024-03-01T00:00:00Z", 5), + sortableOrganization("Zed", "2023-01-01T00:00:00Z", 1), + sortableOrganization("Ace", "2025-01-01T00:00:00Z", 3), +]; + +const bodyRowAliases = () => + screen + .getAllByRole("row") + .slice(1) + .map((row) => ["Ace", "Mid", "Zed"].find((alias) => within(row).queryByText(alias) !== null)); + +const lastSearchParams = (onUrlUpdate: Mock) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + const baseProps = { isLoading: false, userRole: "Admin", @@ -37,7 +67,7 @@ const baseProps = { describe("OrganizationsTable", () => { it("renders every column header", () => { - render(); + renderWithProviders(); for (const header of [ "Organization ID", "Organization Name", @@ -55,7 +85,7 @@ describe("OrganizationsTable", () => { it("opens the detail view when the organization ID cell is clicked", async () => { const user = userEvent.setup(); const onOrganizationClick = vi.fn(); - render( + renderWithProviders( { const user = userEvent.setup(); const onEditClick = vi.fn(); const onDeleteClick = vi.fn(); - render( + renderWithProviders( { }); it("hides the row actions menu from non-admins", () => { - render( + renderWithProviders( { }); it("sorts by created_at descending by default", () => { - render( + renderWithProviders( { }); it("renders budget, limits, members, and models for a fully-populated organization", () => { - render( + renderWithProviders( { }); it("shows Unlimited budget and All Proxy Models when unset", () => { - render( + renderWithProviders( { }); it("renders a tpm/rpm limit of 0 as 0, never as Unlimited", () => { - render( + renderWithProviders( { }); it("renders loading skeletons instead of rows while loading", () => { - render( + renderWithProviders( { it("pages long lists client-side with the shared size selector and footer", async () => { const user = userEvent.setup(); - const organizations = Array.from({ length: 30 }, (_, index) => - makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }), - ); - render(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); expect(screen.getAllByRole("row")).toHaveLength(26); expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30"); @@ -207,13 +235,87 @@ describe("OrganizationsTable", () => { expect(screen.getAllByRole("row")).toHaveLength(31); expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-30 of 30"); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("page_size")).toBe("50")); }); it("uses a search-aware empty state", () => { - const { rerender } = render(); + const { rerender } = renderWithProviders( + , + ); expect(screen.getByText("No organizations yet")).toBeInTheDocument(); rerender(); expect(screen.getByText("No matching organizations")).toBeInTheDocument(); }); }); + +describe("OrganizationsTable URL state", () => { + it("restores the sort column and direction from ?sort_by=&sort_order=", () => { + renderWithProviders(, { + searchParams: "?sort_by=spend&sort_order=desc", + }); + + expect(bodyRowAliases()).toEqual(["Mid", "Ace", "Zed"]); + }); + + it("falls back to sorting by creation date for a ?sort_by= column that cannot be sorted", () => { + renderWithProviders(, { + searchParams: "?sort_by=members&sort_order=asc", + }); + + expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"]); + }); + + it("writes the clicked sort column to the URL and returns to the first page", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?page=2", + onUrlUpdate, + }); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30"); + + await user.click(screen.getByTestId("sort-header-organization_alias")); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("sort_by")).toBe("organization_alias")); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + expect(lastSearchParams(onUrlUpdate)?.get("sort_order")).toBe("asc"); + expect(lastSearchParams(onUrlUpdate)?.has("page")).toBe(false); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30"); + expect(within(screen.getAllByRole("row")[1]).getByText("Org 0")).toBeInTheDocument(); + }); + + it("opens the page named by ?page= and writes page changes back to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?page=2", + onUrlUpdate, + }); + + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30"); + expect(screen.getByText("org-29")).toBeInTheDocument(); + + await user.click(screen.getByTestId("pagination-prev")); + + await waitFor(() => expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30")); + expect(lastSearchParams(onUrlUpdate)?.has("page")).toBe(false); + + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("2")); + }); + + it("keeps a deep-linked ?page= while the organization list is still loading", async () => { + const onUrlUpdate = vi.fn(); + const { rerender } = renderWithProviders(, { + searchParams: "?page=2", + onUrlUpdate, + }); + + rerender(); + + await waitFor(() => expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30")); + expect(onUrlUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx index dbf516d75ae..a9ac0b7e699 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx @@ -1,13 +1,13 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; import { Building2, SearchX } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { DataTable } from "@/components/shared/DataTable"; import { Organization } from "@/components/networking"; import { getOrganizationsTableColumns } from "./OrganizationsTableColumns"; +import { useOrganizationsTableState } from "./useOrganizationsTableState"; interface OrganizationsTableProps { organizations: Organization[]; @@ -19,8 +19,6 @@ interface OrganizationsTableProps { onDeleteClick: (organizationId: string) => void; } -const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; - function EmptyState({ searchActive }: { searchActive: boolean }) { const Icon = searchActive ? SearchX : Building2; return ( @@ -49,7 +47,7 @@ const OrganizationsTable: React.FC = ({ onEditClick, onDeleteClick, }) => { - const [sorting, setSorting] = useState(DEFAULT_SORTING); + const { sorting, onSortingChange, pagination, onPaginationChange } = useOrganizationsTableState(); const columns = useMemo(() => { const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick }; @@ -60,11 +58,13 @@ const OrganizationsTable: React.FC = ({ organization.organization_id || String(index)} sortingMode="client" sorting={sorting} - onSortingChange={setSorting} + onSortingChange={onSortingChange} isLoading={isLoading} loadingMessage="Loading organizations…" noDataMessage={} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts new file mode 100644 index 00000000000..20a54a25ba1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts @@ -0,0 +1,19 @@ +import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable"; + +const FILTER_COLUMNS = ["org_id"] as const; +type FilterColumn = (typeof FILTER_COLUMNS)[number]; + +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: ["organization_id", "organization_alias", "created_at", "spend"], + defaultSort: { id: "created_at", desc: true }, + defaultPageSize: 25, + filterColumns: FILTER_COLUMNS, + urlKeys: { search: "org_search" }, +}; + +export const useOrganizationsTableState = (): UrlTableState => useUrlTableState(TABLE_STATE_OPTIONS); + +export const organizationIdFilter = ({ columnFilters }: Pick): string => { + const value = columnFilters.find((filter) => filter.id === "org_id")?.value; + return typeof value === "string" ? value : ""; +}; diff --git a/ui/litellm-dashboard/src/components/organization/organizationTabs.ts b/ui/litellm-dashboard/src/components/organization/organizationTabs.ts new file mode 100644 index 00000000000..db0a2692356 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organization/organizationTabs.ts @@ -0,0 +1,3 @@ +export const ORGANIZATION_TABS = ["overview", "members", "settings"] as const; +export type OrganizationTab = (typeof ORGANIZATION_TABS)[number]; +export const ORGANIZATION_TAB_URL_KEY = "org_tab"; diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 799cd1adffe..d609b657717 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -1,8 +1,10 @@ import React from "react"; -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { vi, test, expect, beforeEach } from "vitest"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { vi, test, expect, beforeEach, describe, type Mock } from "vitest"; +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import OrganizationInfoView from "./organization_view"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; @@ -115,7 +117,6 @@ test("renders organization view after loading data", async () => { is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -135,7 +136,6 @@ test("should display empty state when organization has no members", async () => is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -165,7 +165,6 @@ test("should display team aliases when teams are available", async () => { is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -199,7 +198,6 @@ test("should display team ID as fallback when alias is not found", async () => { is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -223,7 +221,6 @@ test("links each team badge to that team's detail page", async () => { is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -250,7 +247,6 @@ test("model badges stay non-clickable", async () => { is_org_admin={false} is_proxy_admin={false} userModels={[]} - editOrg={false} />, ); @@ -272,7 +268,6 @@ test("should keep unsaved settings edits when switching tabs and back", async () is_org_admin={false} is_proxy_admin={true} userModels={[]} - editOrg={false} />, ); @@ -308,7 +303,6 @@ test("renders a tpm/rpm limit of 0 as 0 in the overview and settings tabs, never is_org_admin={false} is_proxy_admin={true} userModels={[]} - editOrg={false} />, ); @@ -323,3 +317,99 @@ test("renders a tpm/rpm limit of 0 as 0 in the overview and settings tabs, never expect(screen.queryByText("TPM: Unlimited")).not.toBeInTheDocument(); expect(screen.queryByText("RPM: Unlimited")).not.toBeInTheDocument(); }); + +const renderOrgView = (props: { is_proxy_admin?: boolean } = {}) => ( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={props.is_proxy_admin ?? false} + userModels={[]} + /> +); + +const lastSearchParams = (onUrlUpdate: Mock) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + +describe("organization detail tab in the URL (?org_tab=)", () => { + beforeEach(() => { + mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as unknown as ReturnType< + typeof useOrganization + >); + }); + + test("opens on the tab named by ?org_tab=", () => { + renderWithProviders(renderOrgView(), { searchParams: "?org=org_123&org_tab=members" }); + + expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("No members found")).toBeInTheDocument(); + }); + + test("the settings deep link used by the list's Edit action opens the Settings tab", () => { + renderWithProviders(renderOrgView({ is_proxy_admin: true }), { searchParams: "?org=org_123&org_tab=settings" }); + + expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + test("opens on Overview when the URL names no tab", () => { + renderWithProviders(renderOrgView(), { searchParams: "?org=org_123" }); + + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); + }); + + test("writes the selected tab to ?org_tab= and drops it again for Overview", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(renderOrgView(), { searchParams: "?org=org_123", onUrlUpdate }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("org_tab")).toBe("settings")); + expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123"); + expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true"); + + await user.click(screen.getByRole("tab", { name: "Overview" })); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.has("org_tab")).toBe(false)); + expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123"); + }); + + test("falls back to Overview for an unknown ?org_tab= and removes it from the URL", async () => { + const onUrlUpdate = vi.fn(); + render(renderOrgView(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(lastSearchParams(onUrlUpdate)?.has("org_tab")).toBe(false); + expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123"); + }); + + test("follows back and forward navigation between tabs while the detail view stays open", () => { + const atUrl = (searchParams: string) => ( + + {renderOrgView()} + + ); + const { rerender } = render(atUrl("?org=org_123&org_tab=members")); + expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true"); + + rerender(atUrl("?org=org_123")); + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); + + rerender(atUrl("?org=org_123&org_tab=members")); + expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("No members found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 096e736b493..c800d12ad62 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -1,6 +1,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useQueryClient } from "@tanstack/react-query"; +import { useUrlTab } from "@/hooks/useUrlTab"; import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import { MoneyCell } from "@/components/shared/table_cells"; import CopyButton from "@/components/shared/CopyButton"; @@ -25,6 +26,7 @@ import { import ObjectPermissionsView from "../object_permissions_view"; import MemberModal from "../team/EditMembership"; import { OrgSettingsForm } from "./org-settings/OrgSettingsForm"; +import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS, type OrganizationTab } from "./organizationTabs"; interface OrganizationInfoProps { organizationId: string; @@ -33,7 +35,6 @@ interface OrganizationInfoProps { is_org_admin: boolean; is_proxy_admin: boolean; userModels: string[]; - editOrg: boolean; } const OrganizationInfoView: React.FC = ({ @@ -43,7 +44,6 @@ const OrganizationInfoView: React.FC = ({ is_org_admin, is_proxy_admin, userModels, - editOrg, }) => { const queryClient = useQueryClient(); const { data: orgData, isLoading: loading } = useOrganization(organizationId); @@ -53,10 +53,16 @@ const OrganizationInfoView: React.FC = ({ const [selectedEditMember, setSelectedEditMember] = useState(null); const canEditOrg = is_org_admin || is_proxy_admin; const { data: teams } = useTeams(); - const { onTabChange, hasVisited } = useVisitedTabs(editOrg ? "settings" : "overview"); + const [tab, setTab] = useUrlTab(ORGANIZATION_TABS, "overview", ORGANIZATION_TAB_URL_KEY); + const { onTabChange, hasVisited } = useVisitedTabs(tab); const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); + const handleTabChange = (value: OrganizationTab) => { + setTab(value); + onTabChange(value); + }; + const handleMemberAdd = async (values: any) => { try { if (accessToken == null) { @@ -158,7 +164,7 @@ const OrganizationInfoView: React.FC = ({ - + Overview From df2dd9b7f25ed30f147087033ad652cf22ff380c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 09:58:22 -0700 Subject: [PATCH 2/4] feat(ui): keep projects search and project key table state in the URL The projects list search lives in ?project_search= and its pagination now goes through useUrlTableState, keeping the page and page_size keys. The key table inside a project reads keys_search, keys_page and keys_page_size, resets to its first page on a new search, and no longer snaps a deep-linked page while the key fetch is failing. Closing a project drops its keys_ params so they do not leak into the next project --- .../_components/ProjectKeysSection.test.tsx | 111 +++++++++++++++++- .../_components/ProjectKeysSection.tsx | 20 ++-- .../projects/_components/ProjectKeysTable.tsx | 6 +- .../_components/ProjectsPage.test.tsx | 44 ++++++- .../projects/_components/ProjectsPage.tsx | 16 +-- .../_components/ProjectsTable.test.tsx | 4 +- .../projects/_components/ProjectsTable.tsx | 18 ++- .../_components/useProjectsUrlState.ts | 39 ++++++ 8 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx index 0ba1dcab155..470cee475ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx @@ -1,5 +1,7 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderWithProviders, screen } from "../../../../../tests/test-utils"; +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils"; import { ProjectKeysSection } from "./ProjectKeysSection"; const mockUseKeys = vi.fn(); @@ -70,3 +72,108 @@ describe("ProjectKeysSection", () => { ); }); }); + +describe("ProjectKeysSection URL state (keys_ prefix)", () => { + const fortyTwoKeys = { + data: { keys: [], total_count: 42, current_page: 1, total_pages: 9 }, + isLoading: false, + isError: false, + }; + const lastSearchParams = (onUrlUpdate: Mock) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + + beforeEach(() => { + mockUseKeys.mockReset(); + }); + + it("should fetch the page, page size and key name filter named by the keys_ params", () => { + mockUseKeys.mockReturnValue(fortyTwoKeys); + renderWithProviders(, { + searchParams: "?page=4&keys_page=2&keys_page_size=10&keys_search=prod", + }); + + expect(mockUseKeys).toHaveBeenLastCalledWith( + 2, + 10, + expect.objectContaining({ projectID: "proj-1", selectedKeyAlias: "prod" }), + ); + expect(screen.getByPlaceholderText("Filter by key name...")).toHaveValue("prod"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 5"); + }); + + it("should write the key name filter to ?keys_search= and return the keys to their first page", async () => { + mockUseKeys.mockReturnValue(fortyTwoKeys); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?page=4&keys_page=3", + onUrlUpdate, + }); + + fireEvent.change(screen.getByPlaceholderText("Filter by key name..."), { target: { value: "prod" } }); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_search")).toBe("prod")); + expect(lastSearchParams(onUrlUpdate)?.has("keys_page")).toBe(false); + expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4"); + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: "prod" })); + }); + + it("should remove ?keys_search= when the key filter is cleared", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue(fortyTwoKeys); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: "?keys_search=prod", onUrlUpdate }); + + await user.click(screen.getByRole("button", { name: /clear key filter/i })); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.has("keys_search")).toBe(false)); + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: null })); + }); + + it("should write key pages to ?keys_page= without touching the projects list's ?page=", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue(fortyTwoKeys); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: "?page=4", onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2")); + expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4"); + expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything()); + }); + + it("should snap a ?keys_page= past the last page back to the last page once the keys load", async () => { + mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + const onUrlUpdate = vi.fn(); + const { rerender } = renderWithProviders(, { + searchParams: "?keys_page=9", + onUrlUpdate, + }); + expect(mockUseKeys).toHaveBeenLastCalledWith(9, 5, expect.anything()); + + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 6, current_page: 9, total_pages: 2 }, + isLoading: false, + isError: false, + }); + rerender(); + + await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2")); + expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything()); + }); + + it("should keep a deep-linked ?keys_page= when the key fetch fails", async () => { + mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + const onUrlUpdate = vi.fn(); + const { rerender } = renderWithProviders(, { + searchParams: "?keys_page=3", + onUrlUpdate, + }); + + mockUseKeys.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + rerender(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onUrlUpdate).not.toHaveBeenCalled(); + expect(mockUseKeys).toHaveBeenLastCalledWith(3, 5, expect.anything()); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx index c618dd6b105..61c8346bce1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx @@ -1,30 +1,27 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { PaginationState } from "@tanstack/react-table"; import { KeyIcon, SearchIcon, X } from "lucide-react"; -import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { ProjectKeysTable } from "./ProjectKeysTable"; +import { useProjectKeysTableState } from "./useProjectsUrlState"; interface ProjectKeysSectionProps { projectId: string; } -const PAGE_SIZE = 5; - export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) { - const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); - const [keyAlias, setKeyAlias] = useState(""); + const { + search: keyAlias, + setSearch: setKeyAlias, + pagination, + onPaginationChange: setPagination, + } = useProjectKeysTableState(); - const { data, isLoading } = useKeys(pagination.pageIndex + 1, pagination.pageSize, { + const { data, isLoading, isError } = useKeys(pagination.pageIndex + 1, pagination.pageSize, { projectID: projectId, selectedKeyAlias: keyAlias || null, }); - useEffect(() => { - setPagination((current) => ({ ...current, pageIndex: 0 })); - }, [keyAlias]); - const keys = data?.keys ?? []; const totalCount = data?.total_count ?? 0; @@ -60,6 +57,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) { keys={keys} totalCount={totalCount} isLoading={isLoading} + isError={isError} pagination={pagination} onPaginationChange={setPagination} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx index 080aad7b26d..53f3898a30f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx @@ -8,16 +8,18 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list"; import { DataTable } from "@/components/shared/DataTable"; import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns"; +import { PROJECT_KEYS_DEFAULT_PAGE_SIZE } from "./useProjectsUrlState"; interface ProjectKeysTableProps { keys: KeyResponse[]; totalCount: number; isLoading: boolean; + isError?: boolean; pagination: PaginationState; onPaginationChange: OnChangeFn; } -const PAGE_SIZE_OPTIONS = [5, 10, 25]; +const PAGE_SIZE_OPTIONS = [PROJECT_KEYS_DEFAULT_PAGE_SIZE, 10, 25]; function EmptyState() { return ( @@ -35,6 +37,7 @@ export function ProjectKeysTable({ keys, totalCount, isLoading, + isError = false, pagination, onPaginationChange, }: ProjectKeysTableProps) { @@ -51,6 +54,7 @@ export function ProjectKeysTable({ rowCount={totalCount} pageSizeOptions={PAGE_SIZE_OPTIONS} isLoading={isLoading} + isError={isError} loadingMessage="Loading keys…" noDataMessage={} size="compact" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx index 66d7413f500..2a8f7d11d8f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx @@ -190,22 +190,47 @@ describe("ProjectsPage", () => { it("should reset to the first page when the search text changes", async () => { const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); const manyProjects = Array.from({ length: 12 }, (_, i) => ({ ...mockProjects[0], project_id: `proj-${i + 1}`, project_alias: `Project ${String(i + 1).padStart(2, "0")}`, })); mockUseProjects.mockReturnValue({ data: manyProjects, isLoading: false }); - renderWithProviders(); + renderWithProviders(, { onUrlUpdate }); await user.click(screen.getByTestId("pagination-next")); expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("page")).toBe("2")); fireEvent.change(screen.getByPlaceholderText(/search projects/i), { target: { value: "Project 01" } }); await waitFor(() => { expect(screen.getByText("Project 01")).toBeInTheDocument(); expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1"); }); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?project_search=Project+01")); + }); + + it("should restore the search box and filtered list from a ?project_search= deep link", () => { + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { searchParams: "?project_search=Beta" }); + + expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue("Beta"); + expect(screen.getByText("Beta Project")).toBeInTheDocument(); + expect(screen.queryByText("Alpha Project")).not.toBeInTheDocument(); + }); + + it("should remove ?project_search= when the search is cleared", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { searchParams: "?project_search=Beta", onUrlUpdate }); + + await user.click(screen.getByRole("button", { name: /clear search/i })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" }))); + expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue(""); + expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); it("should open the detail view directly from a ?project= deep link", () => { @@ -250,6 +275,23 @@ describe("ProjectsPage", () => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); + it("should drop the project's key table state but keep the list's search and page when the detail view is closed", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { + searchParams: "?page=2&project_search=Project&project=proj-1&keys_page=3&keys_page_size=10&keys_search=prod", + onUrlUpdate, + }); + + await user.click(screen.getByRole("button", { name: /back to projects/i })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledTimes(1)); + const [update] = onUrlUpdate.mock.calls[0]; + expect(update.queryString).toBe("?page=2&project_search=Project"); + expect(update.options.history).toBe("replace"); + }); + it("should resolve team alias from the teams list in the Team column", () => { mockUseTeams.mockReturnValue({ data: [{ team_id: "team-1", team_alias: "Engineering", models: [] }], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx index 4aba2bb627d..2d3c1acf75e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx @@ -9,6 +9,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from " import { CreateProjectModal } from "./ProjectModals/CreateProjectModal"; import { ProjectDetail } from "./ProjectDetailsPage"; import { ProjectsTable } from "./ProjectsTable"; +import { useClearProjectKeysTableState, useProjectsTableState } from "./useProjectsUrlState"; export function ProjectsPage() { const { data: projects, isLoading } = useProjects(); @@ -18,8 +19,9 @@ export function ProjectsPage() { "project", parseAsString.withOptions({ history: "push" }), ); + const clearProjectKeysTableState = useClearProjectKeysTableState(); + const { search: searchText, setSearch: setSearchText } = useProjectsTableState(); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); - const [searchText, setSearchText] = useState(""); const teamAliasMap = useMemo(() => { const map = new Map(); @@ -44,13 +46,13 @@ export function ProjectsPage() { }); }, [projects, searchText, teamAliasMap]); + const closeProject = () => { + void setSelectedProjectId(null, { history: "replace" }); + clearProjectKeysTableState(); + }; + if (selectedProjectId) { - return ( - void setSelectedProjectId(null, { history: "replace" })} - /> - ); + return ; } return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx index a1b59f6035c..523932007fa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx @@ -73,7 +73,7 @@ describe("ProjectsTable pagination URL state", () => { expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-14 of 14"); }); - it("should push ?page=2 onto history when the next page control is clicked", async () => { + it("should write ?page=2 to the URL when the next page control is clicked", async () => { const user = userEvent.setup(); const onUrlUpdate = vi.fn(); renderTable({ onUrlUpdate }); @@ -83,7 +83,7 @@ describe("ProjectsTable pagination URL state", () => { await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); const [update] = onUrlUpdate.mock.calls[0]; expect(update.searchParams.get("page")).toBe("2"); - expect(update.options.history).toBe("push"); + expect(update.searchParams.has("page_size")).toBe(false); expect(firstDataRow().getByText("Project 11")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx index 74242f3ed45..1d63958faed 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx @@ -2,13 +2,13 @@ import { SortingState } from "@tanstack/react-table"; import { FolderKanban } from "lucide-react"; -import { parseAsInteger, useQueryStates } from "nuqs"; import { useMemo, useState } from "react"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; import { DataTable, DataTablePagination } from "@/components/shared/DataTable"; import { getProjectsTableColumns } from "./ProjectsTableColumns"; +import { PROJECTS_DEFAULT_PAGE_SIZE, useProjectsTableState } from "./useProjectsUrlState"; interface ProjectsTableProps { projects: ProjectResponse[]; @@ -19,8 +19,7 @@ interface ProjectsTableProps { isTeamsLoading: boolean; } -const DEFAULT_PAGE_SIZE = 10; -const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 25, 50]; +const PAGE_SIZE_OPTIONS = [PROJECTS_DEFAULT_PAGE_SIZE, 25, 50]; function EmptyState({ isFiltered }: { isFiltered: boolean }) { return ( @@ -47,11 +46,8 @@ export function ProjectsTable({ isTeamsLoading, }: ProjectsTableProps) { const [sorting, setSorting] = useState([]); - const [{ page, page_size }, setPagination] = useQueryStates( - { page: parseAsInteger.withDefault(1), page_size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE) }, - { history: "push" }, - ); - const pageSize = PAGE_SIZE_OPTIONS.includes(page_size) ? page_size : DEFAULT_PAGE_SIZE; + const { pagination, onPaginationChange } = useProjectsTableState(); + const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE; const columns = useMemo(() => { const deps = { onProjectClick, teamAliasMap, isTeamsLoading }; @@ -59,7 +55,7 @@ export function ProjectsTable({ }, [onProjectClick, teamAliasMap, isTeamsLoading]); const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1); - const pageIndex = page >= 1 && page <= pageCount ? page - 1 : 0; + const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0; return ( void setPagination({ page: nextPageIndex + 1 })} - onPageSizeChange={(nextPageSize) => void setPagination({ page_size: nextPageSize, page: null })} + onPageChange={(nextPageIndex) => onPaginationChange({ pageIndex: nextPageIndex, pageSize })} + onPageSizeChange={(nextPageSize) => onPaginationChange({ pageIndex: 0, pageSize: nextPageSize })} pageSizeOptions={PAGE_SIZE_OPTIONS} isLoading={isLoading} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts new file mode 100644 index 00000000000..db88ad7c3a9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts @@ -0,0 +1,39 @@ +import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable"; +import { parseAsString, useQueryStates } from "nuqs"; +import { useCallback } from "react"; + +export const PROJECTS_DEFAULT_PAGE_SIZE = 10; +export const PROJECT_KEYS_DEFAULT_PAGE_SIZE = 5; + +const PROJECT_KEYS_URL_PREFIX = "keys_"; +const TABLE_STATE_URL_KEYS = ["search", "sort_by", "sort_order", "page", "page_size"] as const; + +const PROJECTS_TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: [], + defaultSort: { id: "created_at", desc: true }, + defaultPageSize: PROJECTS_DEFAULT_PAGE_SIZE, + filterColumns: [], + urlKeys: { search: "project_search" }, +}; + +const PROJECT_KEYS_TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: [], + defaultSort: { id: "created_at", desc: true }, + defaultPageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE, + maxPageSize: 25, + filterColumns: [], + keyPrefix: PROJECT_KEYS_URL_PREFIX, +}; + +const PROJECT_KEYS_URL_STATE = Object.fromEntries( + TABLE_STATE_URL_KEYS.map((key) => [`${PROJECT_KEYS_URL_PREFIX}${key}`, parseAsString]), +); + +export const useProjectsTableState = (): UrlTableState => useUrlTableState(PROJECTS_TABLE_STATE_OPTIONS); + +export const useProjectKeysTableState = (): UrlTableState => useUrlTableState(PROJECT_KEYS_TABLE_STATE_OPTIONS); + +export function useClearProjectKeysTableState(): () => void { + const [, setProjectKeysUrlState] = useQueryStates(PROJECT_KEYS_URL_STATE); + return useCallback(() => void setProjectKeysUrlState(null), [setProjectKeysUrlState]); +} From ef8e066c7794bffa25311941a4c8718a53769bfd Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 10:25:27 -0700 Subject: [PATCH 3/4] fix(ui): address review on orgs-projects url state Keep /projects list paging as a pushed history entry, validate the project key table page size against its offered options, and clear the key table params through the table-state setters instead of a copied key list. --- .../_components/OrganizationsPanel.test.tsx | 14 ++++ .../_components/ProjectKeysSection.test.tsx | 28 ++++++++ .../projects/_components/ProjectKeysTable.tsx | 6 +- .../_components/ProjectsPage.test.tsx | 4 +- .../_components/ProjectsTable.test.tsx | 4 +- .../_components/useProjectsUrlState.ts | 68 +++++++++++++++---- 6 files changed, 104 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx index 3e87492778a..bda9f3fbd6c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -189,6 +189,20 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { await expectQueryString("?org=org-plain"); }); + it("closing the org detail keeps the list's search, filter, sort and page in the URL", async () => { + renderPanel({ + searchParams: + "?org_search=Acme&filter_org_id=org-7&sort_by=spend&sort_order=asc&page=2&org=org-x&org_tab=members", + }); + + act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose()); + + await expectQueryString("?org_search=Acme&filter_org_id=org-7&sort_by=spend&sort_order=asc&page=2"); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByPlaceholderText("Search by Organization Name")).toHaveValue("Acme"); + expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-7", org_alias: "Acme" }); + }); + it("closing the org detail drops ?org_tab= together with ?org=", async () => { renderPanel({ searchParams: "?org=org-from-url&org_tab=members" }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx index 470cee475ee..382c34abcca 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx @@ -100,6 +100,34 @@ describe("ProjectKeysSection URL state (keys_ prefix)", () => { expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 5"); }); + it("should cap an oversized ?keys_page_size= at the largest offered page size", () => { + mockUseKeys.mockReturnValue(fortyTwoKeys); + renderWithProviders(, { searchParams: "?keys_page_size=500" }); + + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 25, expect.anything()); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); + + it("should fall back to the default page size for a ?keys_page_size= outside the offered options", () => { + mockUseKeys.mockReturnValue(fortyTwoKeys); + renderWithProviders(, { searchParams: "?keys_page_size=7" }); + + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.anything()); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 9"); + }); + + it("should drop an unsupported ?keys_page_size= when the user pages forward", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue(fortyTwoKeys); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: "?keys_page_size=7", onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?keys_page=2")); + expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything()); + }); + it("should write the key name filter to ?keys_search= and return the keys to their first page", async () => { mockUseKeys.mockReturnValue(fortyTwoKeys); const onUrlUpdate = vi.fn(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx index 53f3898a30f..50f40057ec2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx @@ -8,7 +8,7 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list"; import { DataTable } from "@/components/shared/DataTable"; import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns"; -import { PROJECT_KEYS_DEFAULT_PAGE_SIZE } from "./useProjectsUrlState"; +import { PROJECT_KEYS_PAGE_SIZE_OPTIONS } from "./useProjectsUrlState"; interface ProjectKeysTableProps { keys: KeyResponse[]; @@ -19,8 +19,6 @@ interface ProjectKeysTableProps { onPaginationChange: OnChangeFn; } -const PAGE_SIZE_OPTIONS = [PROJECT_KEYS_DEFAULT_PAGE_SIZE, 10, 25]; - function EmptyState() { return (
@@ -52,7 +50,7 @@ export function ProjectKeysTable({ pagination={pagination} onPaginationChange={onPaginationChange} rowCount={totalCount} - pageSizeOptions={PAGE_SIZE_OPTIONS} + pageSizeOptions={PROJECT_KEYS_PAGE_SIZE_OPTIONS} isLoading={isLoading} isError={isError} loadingMessage="Loading keys…" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx index 2a8f7d11d8f..309da01b295 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx @@ -209,6 +209,7 @@ describe("ProjectsPage", () => { expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1"); }); await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?project_search=Project+01")); + expect(onUrlUpdate).toHaveBeenCalledTimes(2); }); it("should restore the search box and filtered list from a ?project_search= deep link", () => { @@ -280,7 +281,8 @@ describe("ProjectsPage", () => { const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); renderWithProviders(, { - searchParams: "?page=2&project_search=Project&project=proj-1&keys_page=3&keys_page_size=10&keys_search=prod", + searchParams: + "?page=2&project_search=Project&project=proj-1&keys_page=3&keys_page_size=10&keys_search=prod&keys_sort_by=spend&keys_sort_order=asc", onUrlUpdate, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx index 523932007fa..aaecf98d4ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx @@ -73,7 +73,7 @@ describe("ProjectsTable pagination URL state", () => { expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-14 of 14"); }); - it("should write ?page=2 to the URL when the next page control is clicked", async () => { + it("should push ?page=2 onto history when the next page control is clicked", async () => { const user = userEvent.setup(); const onUrlUpdate = vi.fn(); renderTable({ onUrlUpdate }); @@ -84,6 +84,7 @@ describe("ProjectsTable pagination URL state", () => { const [update] = onUrlUpdate.mock.calls[0]; expect(update.searchParams.get("page")).toBe("2"); expect(update.searchParams.has("page_size")).toBe(false); + expect(update.options.history).toBe("push"); expect(firstDataRow().getByText("Project 11")).toBeInTheDocument(); }); @@ -147,6 +148,7 @@ describe("ProjectsTable pagination URL state", () => { const lastUpdate = onUrlUpdate.mock.calls.at(-1)?.[0]; expect(lastUpdate.searchParams.get("page")).toBeNull(); expect(lastUpdate.searchParams.get("page_size")).toBe("25"); + expect(lastUpdate.options.history).toBe("push"); }); it("should apply both params from a ?page=2&page_size=25 deep link so the restored view matches", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts index db88ad7c3a9..57c1a8fd167 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/useProjectsUrlState.ts @@ -1,12 +1,11 @@ +import { functionalUpdate, type OnChangeFn, type PaginationState } from "@tanstack/react-table"; import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable"; -import { parseAsString, useQueryStates } from "nuqs"; -import { useCallback } from "react"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; export const PROJECTS_DEFAULT_PAGE_SIZE = 10; export const PROJECT_KEYS_DEFAULT_PAGE_SIZE = 5; - -const PROJECT_KEYS_URL_PREFIX = "keys_"; -const TABLE_STATE_URL_KEYS = ["search", "sort_by", "sort_order", "page", "page_size"] as const; +export const PROJECT_KEYS_PAGE_SIZE_OPTIONS = [PROJECT_KEYS_DEFAULT_PAGE_SIZE, 10, 25]; const PROJECTS_TABLE_STATE_OPTIONS: UrlTableStateOptions = { sortFields: [], @@ -16,24 +15,65 @@ const PROJECTS_TABLE_STATE_OPTIONS: UrlTableStateOptions = { urlKeys: { search: "project_search" }, }; +const PROJECTS_PAGE_PARAMS = { + page: parseAsInteger.withDefault(1), + page_size: parseAsInteger.withDefault(PROJECTS_DEFAULT_PAGE_SIZE), +}; + const PROJECT_KEYS_TABLE_STATE_OPTIONS: UrlTableStateOptions = { sortFields: [], defaultSort: { id: "created_at", desc: true }, defaultPageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE, - maxPageSize: 25, + maxPageSize: Math.max(...PROJECT_KEYS_PAGE_SIZE_OPTIONS), filterColumns: [], - keyPrefix: PROJECT_KEYS_URL_PREFIX, + keyPrefix: "keys_", }; -const PROJECT_KEYS_URL_STATE = Object.fromEntries( - TABLE_STATE_URL_KEYS.map((key) => [`${PROJECT_KEYS_URL_PREFIX}${key}`, parseAsString]), -); +export function useProjectsTableState(): UrlTableState { + const tableState = useUrlTableState(PROJECTS_TABLE_STATE_OPTIONS); + const [, setPageParams] = useQueryStates(PROJECTS_PAGE_PARAMS, { history: "push" }); + const { pagination } = tableState; -export const useProjectsTableState = (): UrlTableState => useUrlTableState(PROJECTS_TABLE_STATE_OPTIONS); + const onPaginationChange = useCallback>( + (updaterOrValue) => { + const next = functionalUpdate(updaterOrValue, pagination); + void setPageParams({ page: next.pageIndex + 1, page_size: next.pageSize }); + }, + [pagination, setPageParams], + ); -export const useProjectKeysTableState = (): UrlTableState => useUrlTableState(PROJECT_KEYS_TABLE_STATE_OPTIONS); + return useMemo(() => ({ ...tableState, onPaginationChange }), [tableState, onPaginationChange]); +} + +export function useProjectKeysTableState(): UrlTableState { + const tableState = useUrlTableState(PROJECT_KEYS_TABLE_STATE_OPTIONS); + const { pagination: urlPagination, onPaginationChange: writePagination } = tableState; + const pageSize = PROJECT_KEYS_PAGE_SIZE_OPTIONS.includes(urlPagination.pageSize) + ? urlPagination.pageSize + : PROJECT_KEYS_DEFAULT_PAGE_SIZE; + + const pagination = useMemo( + () => ({ pageIndex: urlPagination.pageIndex, pageSize }), + [urlPagination.pageIndex, pageSize], + ); + + const onPaginationChange = useCallback>( + (updaterOrValue) => writePagination(functionalUpdate(updaterOrValue, pagination)), + [pagination, writePagination], + ); + + return useMemo( + () => ({ ...tableState, pagination, onPaginationChange }), + [tableState, pagination, onPaginationChange], + ); +} export function useClearProjectKeysTableState(): () => void { - const [, setProjectKeysUrlState] = useQueryStates(PROJECT_KEYS_URL_STATE); - return useCallback(() => void setProjectKeysUrlState(null), [setProjectKeysUrlState]); + const { setSearch, onSortingChange, onColumnFiltersChange, onPaginationChange } = useProjectKeysTableState(); + return useCallback(() => { + setSearch(""); + onSortingChange([]); + onColumnFiltersChange([]); + onPaginationChange({ pageIndex: 0, pageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE }); + }, [setSearch, onSortingChange, onColumnFiltersChange, onPaginationChange]); } From 249a23b09cd63277f905cac86a8808595c77cff2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 11:06:55 -0700 Subject: [PATCH 4/4] chore(ui): prune stale eslint suppressions for projects page --- ui/litellm-dashboard/eslint-suppressions.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 773854d29e6..51a7a196d0a 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -972,11 +972,6 @@ "count": 2 } }, - "src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": { "react-hooks/set-state-in-effect": { "count": 2