From 6a2e4e6c36656a84fd2592f3f93ec75f87d4c9e6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 5 Aug 2026 17:25:42 -0700 Subject: [PATCH] fix(ui): sync projects list page index to ?page= so back and reload keep the page (#36003) * fix(ui): sync projects list page index to ?page= so back and reload keep the page Paging the Projects list only moved TanStack's internal page index, so the URL never changed: reload dropped you on page 1, browser Back left the page entirely, and the page could not be shared. The page index now comes from a nuqs ?page= query state with history: "push". Pagination stays controlled off that value and the footer writes the URL directly, because TanStack resets its page index whenever the data array identity changes; letting it own the state would clear a deep-linked page as soon as the projects query resolved. A page outside the current row set falls back to page 1, which covers both a hand-typed ?page=99 and a search that narrows the list below the current page. * fix(ui): carry page_size in the url so restored history entries show the same rows Greptile flagged that a history entry restoring ?page=N under a changed local page size displays different projects than it originally showed. Page size now rides the same query string via useQueryStates, size changes reset the page inside a single history entry, and values outside the offered options fall back to the default --- .../_components/ProjectsTable.test.tsx | 167 ++++++++++++++++++ .../projects/_components/ProjectsTable.tsx | 26 ++- 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx 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 new file mode 100644 index 00000000000..a1b59f6035c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils"; +import { ProjectsTable } from "./ProjectsTable"; +import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; + +const makeProject = (index: number): ProjectResponse => ({ + project_id: `proj-${String(index).padStart(2, "0")}`, + project_alias: `Project ${String(index).padStart(2, "0")}`, + description: null, + team_id: "team-1", + budget_id: null, + metadata: null, + models: [], + spend: 0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, +}); + +const allProjects = Array.from({ length: 14 }, (_, index) => makeProject(index + 1)); + +interface RenderOptions { + projects?: ProjectResponse[]; + isLoading?: boolean; + isFiltered?: boolean; + searchParams?: string; + onUrlUpdate?: OnUrlUpdateFunction; +} + +const renderTable = ({ + projects = allProjects, + isLoading = false, + isFiltered = false, + ...providers +}: RenderOptions) => { + const table = (projectList: ProjectResponse[], loading: boolean, filtered: boolean) => ( + + ); + const view = renderWithProviders(table(projects, isLoading, isFiltered), providers); + return { + ...view, + rerenderWith: (next: ProjectResponse[], filtered = false) => view.rerender(table(next, false, filtered)), + }; +}; + +const firstDataRow = () => within(screen.getAllByRole("row")[1]); + +const dataRowCount = () => screen.getAllByRole("row").length - 1; + +describe("ProjectsTable pagination URL state", () => { + it("should render the second page of projects for a ?page=2 deep link", () => { + renderTable({ searchParams: "?page=2" }); + + expect(firstDataRow().getByText("Project 11")).toBeInTheDocument(); + expect(screen.queryByText("Project 01")).not.toBeInTheDocument(); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + 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 () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderTable({ onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-next")); + + 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(firstDataRow().getByText("Project 11")).toBeInTheDocument(); + }); + + it("should clear the page param when returning to the first page", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderTable({ searchParams: "?page=2", onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-prev")); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const [update] = onUrlUpdate.mock.calls[0]; + expect(update.searchParams.get("page")).toBeNull(); + expect(firstDataRow().getByText("Project 01")).toBeInTheDocument(); + }); + + it("should keep the deep-linked page when the project list arrives after the first render", async () => { + const onUrlUpdate = vi.fn(); + const stillLoading: RenderOptions = { projects: [], isLoading: true, searchParams: "?page=2", onUrlUpdate }; + const { rerenderWith } = renderTable(stillLoading); + await waitFor(() => expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0)); + + rerenderWith(allProjects); + + await waitFor(() => expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2")); + expect(firstDataRow().getByText("Project 11")).toBeInTheDocument(); + expect(onUrlUpdate).not.toHaveBeenCalled(); + }); + + it("should fall back to the first page, not the last remaining page, when a filter leaves fewer pages", async () => { + const manyProjects = Array.from({ length: 44 }, (_, index) => makeProject(index + 1)); + const { rerenderWith } = renderTable({ projects: manyProjects, searchParams: "?page=5" }); + expect(firstDataRow().getByText("Project 41")).toBeInTheDocument(); + + rerenderWith(allProjects.slice(0, 12), true); + + await waitFor(() => expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2")); + expect(firstDataRow().getByText("Project 01")).toBeInTheDocument(); + expect(dataRowCount()).toBe(10); + }); + + it("should show the first page instead of an empty table for an out-of-range ?page=99", () => { + renderTable({ searchParams: "?page=99" }); + + expect(firstDataRow().getByText("Project 01")).toBeInTheDocument(); + expect(dataRowCount()).toBe(10); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); + + it("should return to the first page and write ?page_size= in one history entry when the page size changes", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderTable({ searchParams: "?page=2", onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-page-size")); + await user.click(await screen.findByRole("option", { name: "25" })); + + await waitFor(() => expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-14 of 14")); + expect(firstDataRow().getByText("Project 01")).toBeInTheDocument(); + expect(onUrlUpdate).toHaveBeenCalledTimes(1); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(lastUpdate.searchParams.get("page")).toBeNull(); + expect(lastUpdate.searchParams.get("page_size")).toBe("25"); + }); + + it("should apply both params from a ?page=2&page_size=25 deep link so the restored view matches", () => { + const manyProjects = Array.from({ length: 44 }, (_, index) => makeProject(index + 1)); + renderTable({ projects: manyProjects, searchParams: "?page=2&page_size=25" }); + + expect(firstDataRow().getByText("Project 26")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-44 of 44"); + }); + + it("should fall back to the default page size for a ?page_size= value outside the offered options", () => { + renderTable({ searchParams: "?page_size=7" }); + + expect(dataRowCount()).toBe(10); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); +}); 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 ad85019ca6d..74242f3ed45 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx @@ -2,10 +2,11 @@ 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 } from "@/components/shared/DataTable"; +import { DataTable, DataTablePagination } from "@/components/shared/DataTable"; import { getProjectsTableColumns } from "./ProjectsTableColumns"; @@ -18,7 +19,8 @@ interface ProjectsTableProps { isTeamsLoading: boolean; } -const PAGE_SIZE_OPTIONS = [10, 25, 50]; +const DEFAULT_PAGE_SIZE = 10; +const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 25, 50]; function EmptyState({ isFiltered }: { isFiltered: boolean }) { return ( @@ -45,12 +47,20 @@ 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 columns = useMemo(() => { const deps = { onProjectClick, teamAliasMap, isTeamsLoading }; return getProjectsTableColumns(deps); }, [onProjectClick, teamAliasMap, isTeamsLoading]); + const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1); + const pageIndex = page >= 1 && page <= pageCount ? page - 1 : 0; + return ( ( + void setPagination({ page: nextPageIndex + 1 })} + onPageSizeChange={(nextPageSize) => void setPagination({ page_size: nextPageSize, page: null })} + pageSizeOptions={PAGE_SIZE_OPTIONS} + isLoading={isLoading} + /> + )} isLoading={isLoading} loadingMessage="Loading projects…" noDataMessage={}