diff --git a/ui/changelog.d/table-page-size-selector.fixed.md b/ui/changelog.d/table-page-size-selector.fixed.md
new file mode 100644
index 0000000000..a0d8137b00
--- /dev/null
+++ b/ui/changelog.d/table-page-size-selector.fixed.md
@@ -0,0 +1 @@
+Rows-per-page selector no longer disappears when the chosen page size collapses a table to a single page
diff --git a/ui/components/shadcn/table/data-table-pagination.test.tsx b/ui/components/shadcn/table/data-table-pagination.test.tsx
index 33140f0712..88e606cdf1 100644
--- a/ui/components/shadcn/table/data-table-pagination.test.tsx
+++ b/ui/components/shadcn/table/data-table-pagination.test.tsx
@@ -1,12 +1,14 @@
import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
+const mocks = vi.hoisted(() => ({ searchParams: new URLSearchParams() }));
+
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({ push: vi.fn() }),
- useSearchParams: () => new URLSearchParams(),
+ useSearchParams: () => mocks.searchParams,
}));
vi.mock("@/lib", () => ({
@@ -19,8 +21,17 @@ vi.mock("@/lib", () => ({
}));
vi.mock("@/components/shadcn/select/select", () => ({
- Select: ({ children }: { children: React.ReactNode }) => (
-
{children}
+ // data-value stands in for the trigger label
+ Select: ({
+ value,
+ children,
+ }: {
+ value: string;
+ children: React.ReactNode;
+ }) => (
+
+ {children}
+
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -35,7 +46,7 @@ vi.mock("@/components/shadcn/select/select", () => ({
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
),
- SelectValue: () => 10,
+ SelectValue: () => null,
}));
import { DataTablePagination } from "./data-table-pagination";
@@ -51,6 +62,10 @@ const metadata: MetaDataProps = {
};
describe("DataTablePagination", () => {
+ beforeEach(() => {
+ mocks.searchParams = new URLSearchParams();
+ });
+
it("keeps navigation arrows visible on hover in light theme", () => {
render();
@@ -85,4 +100,80 @@ describe("DataTablePagination", () => {
// Then - No wrapper remains to add extra DataTable gap
expect(container).toBeEmptyDOMElement();
});
+
+ it("keeps the rows-per-page selector when the chosen page size collapses the table to a single page", () => {
+ const collapsedByPageSize: MetaDataProps = {
+ pagination: {
+ page: 1,
+ pages: 1,
+ count: 85,
+ itemsPerPage: [10, 20, 30, 50, 100],
+ },
+ version: "latest",
+ };
+ mocks.searchParams = new URLSearchParams("pageSize=100");
+
+ render();
+
+ expect(screen.getByText("Rows per page")).toBeInTheDocument();
+ expect(screen.getByTestId("page-size-select")).toHaveAttribute(
+ "data-value",
+ "100",
+ );
+ expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
+ expect(screen.queryByText(/^Page \d+ of \d+$/)).not.toBeInTheDocument();
+ });
+
+ it("keeps the selector when the smallest configured page size would still paginate", () => {
+ const smallestOptionPaginates: MetaDataProps = {
+ pagination: {
+ page: 1,
+ pages: 1,
+ count: 7,
+ itemsPerPage: [5, 10, 25],
+ },
+ version: "latest",
+ };
+
+ render();
+
+ expect(screen.getByText("Rows per page")).toBeInTheDocument();
+ });
+
+ it("hides the selector when no page size option would split the table", () => {
+ const nothingToPaginate: MetaDataProps = {
+ pagination: {
+ page: 1,
+ pages: 1,
+ count: 7,
+ itemsPerPage: [10, 20, 50],
+ },
+ version: "latest",
+ };
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("follows the page size in the URL when it changes under the mounted selector", () => {
+ mocks.searchParams = new URLSearchParams("pageSize=100");
+ const { rerender } = render();
+
+ expect(screen.getByTestId("page-size-select")).toHaveAttribute(
+ "data-value",
+ "100",
+ );
+
+ // URL changes without remounting: back/forward, filter reset
+ mocks.searchParams = new URLSearchParams("pageSize=20");
+ rerender();
+
+ expect(screen.getByTestId("page-size-select")).toHaveAttribute(
+ "data-value",
+ "20",
+ );
+ });
});
diff --git a/ui/components/shadcn/table/data-table-pagination.tsx b/ui/components/shadcn/table/data-table-pagination.tsx
index be0b1e2676..9e19395a0e 100644
--- a/ui/components/shadcn/table/data-table-pagination.tsx
+++ b/ui/components/shadcn/table/data-table-pagination.tsx
@@ -8,7 +8,6 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useState } from "react";
import {
Select,
@@ -65,12 +64,11 @@ export function DataTablePagination({
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
- const initialPageSize = isControlled
+ // Derived, not stored, so it survives back/forward and filter resets
+ const selectedPageSize = isControlled
? String(controlledPageSize ?? 10)
: (searchParams.get(pageSizeParam) ?? "10");
- const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
-
if (!metadata) return null;
const {
@@ -80,7 +78,16 @@ export function DataTablePagination({
itemsPerPageOptions,
} = getPaginationInfo(metadata);
- if (totalPages <= 1) return null;
+ // Gated separately: a large page size collapses the table to one page, and the
+ // selector is the only way back to a smaller one.
+ const smallestPageSize = itemsPerPageOptions.length
+ ? Math.min(...itemsPerPageOptions)
+ : 10;
+ const showPageSizeSelector = totalEntries > smallestPageSize;
+ const showPageNavigation = totalPages > 1;
+
+ // No wrapper when empty: it would add a stray gap to DataTable's flex column
+ if (!showPageSizeSelector && !showPageNavigation) return null;
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
const currentPage = isControlled
@@ -130,216 +137,213 @@ export function DataTablePagination({
return (
- {totalEntries > 10 && (
- <>
- {/* Rows per page selector */}
-
-
- Rows per page
-
-
+
+ )}
+ {/* Page info and navigation */}
+ {showPageNavigation && (
+
+
+ Page {currentPage} of {totalPages}
+
-
- Page {currentPage} of {totalPages}
-
-
- {isControlled ? (
- <>
-
-
-
-
- >
- ) : (
- <>
- isFirstPage && e.preventDefault()}
- >
-
-
- isFirstPage && e.preventDefault()}
- >
-
-
- isLastPage && e.preventDefault()}
- >
-
-
- isLastPage && e.preventDefault()}
- >
-
-
- >
- )}
-
+ {isControlled ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ <>
+
isFirstPage && e.preventDefault()}
+ >
+
+
+
isFirstPage && e.preventDefault()}
+ >
+
+
+
isLastPage && e.preventDefault()}
+ >
+
+
+
isLastPage && e.preventDefault()}
+ >
+
+
+ >
+ )}
- >
+
)}
);