mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-12 06:24:14 +00:00
fix(ui): keep page-size selector when table fits one page (#12299)
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user