fix(ui): keep page-size selector when table fits one page (#12299)

This commit is contained in:
Pablo Fernandez Guerra (PFE) authored and GitHub committed 2026-08-05 14:13:11 +02:00
1 parent ae8c86ecb5
commit 2fdd46336c
3 files changed
+308 -212

No files matched your search

@@ -0,0 +1 @@
Rows-per-page selector no longer disappears when the chosen page size collapses a table to a single page
@@ -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 }) => (
<div>{children}</div>
// data-value stands in for the trigger label
Select: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => (
<div data-testid="page-size-select" data-value={value}>
{children}
</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
@@ -35,7 +46,7 @@ vi.mock("@/components/shadcn/select/select", () => ({
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectValue: () => <span>10</span>,
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(<DataTablePagination metadata={metadata} />);
@@ -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(<DataTablePagination metadata={collapsedByPageSize} />);
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(<DataTablePagination metadata={smallestOptionPaginates} />);
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(
<DataTablePagination metadata={nothingToPaginate} />,
);
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(<DataTablePagination metadata={metadata} />);
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(<DataTablePagination metadata={metadata} />);
expect(screen.getByTestId("page-size-select")).toHaveAttribute(
"data-value",
"20",
);
});
});
@@ -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 (
<div className="flex w-full items-center justify-end gap-6 py-1.5">
{totalEntries > 10 && (
<>
{/* Rows per page selector */}
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
Rows per page
</span>
<Select
value={selectedPageSize}
onValueChange={(value) => {
setSelectedPageSize(value);
{/* Rows per page selector */}
{showPageSizeSelector && (
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
Rows per page
</span>
<Select
value={selectedPageSize}
onValueChange={(value) => {
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()}`);
}
}}
>
<SelectTrigger
iconSize="sm"
className="bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"
>
<SelectTrigger
iconSize="sm"
className="bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{itemsPerPageOptions.map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
className="cursor-pointer"
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Page info and navigation */}
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{itemsPerPageOptions.map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
className="cursor-pointer"
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Page info and navigation */}
{showPageNavigation && (
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary hidden text-xs font-medium sm:inline">
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary hidden text-xs font-medium sm:inline">
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-3">
{isControlled ? (
<>
<button
type="button"
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(1)}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(currentPage + 1)}
>
<ChevronRight className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(totalPages)}
>
<ChevronLast className="size-6" aria-hidden="true" />
</button>
</>
) : (
<>
<Link
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronLast className="size-6" aria-hidden="true" />
</Link>
</>
)}
</div>
{isControlled ? (
<>
<button
type="button"
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(1)}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(currentPage + 1)}
>
<ChevronRight className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(totalPages)}
>
<ChevronLast className="size-6" aria-hidden="true" />
</button>
</>
) : (
<>
<Link
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronLast className="size-6" aria-hidden="true" />
</Link>
</>
)}
</div>
</>
</div>
)}
</div>
);