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
This commit is contained in:
ryan-crabbe-berri 2026-08-05 17:25:42 -07:00 • committed by GitHub
parent eabcafc1df
commit 6a2e4e6c36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 191 additions and 2 deletions

View file

@ -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) => (
<ProjectsTable
projects={projectList}
isLoading={loading}
isFiltered={filtered}
onProjectClick={vi.fn()}
teamAliasMap={new Map()}
isTeamsLoading={false}
/>
);
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");
});
});

View file

@ -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<SortingState>([]);
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 (
<DataTable
data={projects}
@ -60,7 +70,19 @@ export function ProjectsTable({
sorting={sorting}
onSortingChange={setSorting}
paginationMode="client"
pagination={{ pageIndex, pageSize }}
pageSizeOptions={PAGE_SIZE_OPTIONS}
paginationSlot={() => (
<DataTablePagination
page={pageIndex}
pageSize={pageSize}
rowCount={projects.length}
onPageChange={(nextPageIndex) => 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={<EmptyState isFiltered={isFiltered} />}