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 - - { + if (isControlled) { + onPageSizeChange?.(parseInt(value, 10)); + onPageChange(1); // Reset to first page + return; + } - if (isControlled) { - onPageSizeChange?.(parseInt(value, 10)); - onPageChange(1); // Reset to first page - return; - } + const params = new URLSearchParams(searchParams); - const params = new URLSearchParams(searchParams); + // Preserve all important parameters + const scanId = searchParams.get("scanId"); + const id = searchParams.get("id"); + const version = searchParams.get("version"); - // Preserve all important parameters - const scanId = searchParams.get("scanId"); - const id = searchParams.get("id"); - const version = searchParams.get("version"); + params.set(pageSizeParam, value); + params.set(pageParam, "1"); - params.set(pageSizeParam, value); - params.set(pageParam, "1"); + // Ensure that scanId, id and version are preserved + if (scanId) params.set("scanId", scanId); + if (id) params.set("id", id); + if (version) params.set("version", version); - // Ensure that scanId, id and version are preserved - if (scanId) params.set("scanId", scanId); - if (id) params.set("id", id); - if (version) params.set("version", version); - - // This pushes the URL without reloading the page - if (disableScroll) { - const url = `${pathname}?${params.toString()}`; - router.push(url, { scroll: false }); - } else { - router.push(`${pathname}?${params.toString()}`); - } - }} + // This pushes the URL without reloading the page + if (disableScroll) { + const url = `${pathname}?${params.toString()}`; + router.push(url, { scroll: false }); + } else { + router.push(`${pathname}?${params.toString()}`); + } + }} + > + - - - - - {itemsPerPageOptions.map((pageSize) => ( - - {pageSize} - - ))} - - -
- - {/* Page info and navigation */} + + + + {itemsPerPageOptions.map((pageSize) => ( + + {pageSize} + + ))} + + +
+ )} + {/* Page info and navigation */} + {showPageNavigation && ( +
+ + Page {currentPage} of {totalPages} +
- - Page {currentPage} of {totalPages} - -
- {isControlled ? ( - <> - - - - - - ) : ( - <> - isFirstPage && e.preventDefault()} - > -
+ {isControlled ? ( + <> + + + + + + ) : ( + <> + isFirstPage && e.preventDefault()} + > +
- +
)} );