Skip to content

Commit cc142df

Browse files
fix(miner-ui): extract shared usePagedRows hook and TablePagination (#8306) (#8492)
The client-side table pagination logic was independently duplicated three ways across five miner-ui routes: run-history and ranked-candidates inlined the page-state computation plus the full Pagination JSX, ledgers and portfolio each defined their own local TablePagination and repeated the page-state twice per file, and attempts had already generalized it into a local usePagedRows hook. Extract attempts.tsx's usePagedRows shape into a shared lib/paged-rows.ts module and a shared components/table-pagination.tsx built on the existing @loopover/ui-kit pagination primitives, then convert all five routes to consume them. Behavior is preserved exactly (PAGE_SIZE = 20 default, page clamping via Math.min(page, pageCount - 1), aria-disabled on the boundary controls). Adds dedicated unit tests for the hook (empty, single-page, multi-page, and page-clamping when rows shrink) and the pager component. Co-authored-by: rsnetworkinginc <272110387+rsnetworkinginc@users.noreply.github.com>
1 parent c096106 commit cc142df

9 files changed

Lines changed: 233 additions & 344 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { TablePagination } from "./table-pagination";
5+
6+
describe("TablePagination (#8306)", () => {
7+
it("renders a numbered link per page and disables Previous on the first page", () => {
8+
const onPageChange = vi.fn();
9+
render(<TablePagination page={0} pageCount={3} onPageChange={onPageChange} />);
10+
11+
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
12+
expect(screen.getByRole("link", { name: "1" })).toBeTruthy();
13+
expect(screen.getByRole("link", { name: "3" })).toBeTruthy();
14+
15+
// On the first page Previous is aria-disabled; Next is not.
16+
expect(screen.getByRole("link", { name: /go to previous page/i }).getAttribute("aria-disabled")).toBe("true");
17+
expect(screen.getByRole("link", { name: /go to next page/i }).getAttribute("aria-disabled")).toBe("false");
18+
});
19+
20+
it("invokes onPageChange for numbered, Next and Previous clicks (clamping at the low boundary)", () => {
21+
const onPageChange = vi.fn();
22+
render(<TablePagination page={0} pageCount={3} onPageChange={onPageChange} />);
23+
24+
fireEvent.click(screen.getByRole("link", { name: "2" }));
25+
expect(onPageChange).toHaveBeenLastCalledWith(1);
26+
27+
fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
28+
expect(onPageChange).toHaveBeenLastCalledWith(1);
29+
30+
// Previous from page 0 clamps to 0 rather than going negative.
31+
fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
32+
expect(onPageChange).toHaveBeenLastCalledWith(0);
33+
});
34+
35+
it("disables Next on the last page and clamps Next at the high boundary", () => {
36+
const onPageChange = vi.fn();
37+
render(<TablePagination page={2} pageCount={3} onPageChange={onPageChange} />);
38+
39+
expect(screen.getByRole("link", { name: /go to previous page/i }).getAttribute("aria-disabled")).toBe("false");
40+
expect(screen.getByRole("link", { name: /go to next page/i }).getAttribute("aria-disabled")).toBe("true");
41+
42+
// Next from the last page clamps to the last page rather than overshooting pageCount - 1.
43+
fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
44+
expect(onPageChange).toHaveBeenLastCalledWith(2);
45+
46+
fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
47+
expect(onPageChange).toHaveBeenLastCalledWith(1);
48+
});
49+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
Pagination,
3+
PaginationContent,
4+
PaginationItem,
5+
PaginationLink,
6+
PaginationNext,
7+
PaginationPrevious,
8+
} from "@loopover/ui-kit/components/pagination";
9+
10+
/**
11+
* Shared presentational pager for the miner-ui tables (#8306): numbered page links plus boundary
12+
* Previous/Next controls, with `aria-disabled` on the first/last page. Pair it with `usePagedRows`,
13+
* rendering it only when `isPaginated` is true. Built on the existing `@loopover/ui-kit`
14+
* `Pagination` primitives — the primitive itself is intentionally left unchanged here.
15+
*/
16+
export function TablePagination({
17+
page,
18+
pageCount,
19+
onPageChange,
20+
}: {
21+
page: number;
22+
pageCount: number;
23+
onPageChange: (next: number) => void;
24+
}) {
25+
return (
26+
<Pagination className="mt-4">
27+
<PaginationContent>
28+
<PaginationItem>
29+
<PaginationPrevious
30+
href="#"
31+
aria-disabled={page === 0}
32+
onClick={(event) => {
33+
event.preventDefault();
34+
onPageChange(Math.max(0, page - 1));
35+
}}
36+
/>
37+
</PaginationItem>
38+
{Array.from({ length: pageCount }).map((_, index) => (
39+
<PaginationItem key={index}>
40+
<PaginationLink
41+
href="#"
42+
isActive={index === page}
43+
onClick={(event) => {
44+
event.preventDefault();
45+
onPageChange(index);
46+
}}
47+
>
48+
{index + 1}
49+
</PaginationLink>
50+
</PaginationItem>
51+
))}
52+
<PaginationItem>
53+
<PaginationNext
54+
href="#"
55+
aria-disabled={page >= pageCount - 1}
56+
onClick={(event) => {
57+
event.preventDefault();
58+
onPageChange(Math.min(pageCount - 1, page + 1));
59+
}}
60+
/>
61+
</PaginationItem>
62+
</PaginationContent>
63+
</Pagination>
64+
);
65+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { PAGE_SIZE, usePagedRows } from "./paged-rows";
5+
6+
describe("usePagedRows (#8306)", () => {
7+
it("does not paginate an empty list — one page, empty slice, no clamping", () => {
8+
const { result } = renderHook(() => usePagedRows<number>([], 2));
9+
expect(result.current.isPaginated).toBe(false);
10+
expect(result.current.pageCount).toBe(1);
11+
expect(result.current.page).toBe(0);
12+
expect(result.current.visible).toEqual([]);
13+
});
14+
15+
it("returns the full list unpaginated when rows fit within a single page (at or below the size)", () => {
16+
// Exactly `pageSize` rows is still a single page: the `rows.length > pageSize` boundary is exclusive.
17+
const rows = [0, 1, 2, 3];
18+
const { result } = renderHook(() => usePagedRows(rows, 4));
19+
expect(result.current.isPaginated).toBe(false);
20+
expect(result.current.pageCount).toBe(1);
21+
expect(result.current.visible).toBe(rows);
22+
});
23+
24+
it("slices rows across multiple pages and follows setPage", () => {
25+
const rows = [0, 1, 2, 3, 4];
26+
const { result } = renderHook(() => usePagedRows(rows, 2));
27+
expect(result.current.isPaginated).toBe(true);
28+
expect(result.current.pageCount).toBe(3);
29+
expect(result.current.page).toBe(0);
30+
expect(result.current.visible).toEqual([0, 1]);
31+
32+
act(() => result.current.setPage(2));
33+
expect(result.current.page).toBe(2);
34+
expect(result.current.visible).toEqual([4]);
35+
});
36+
37+
it("clamps the active page when rows shrink below the current page's start index", () => {
38+
const { result, rerender } = renderHook(({ rows }: { rows: number[] }) => usePagedRows(rows, 2), {
39+
initialProps: { rows: [0, 1, 2, 3, 4, 5] },
40+
});
41+
act(() => result.current.setPage(2));
42+
expect(result.current.page).toBe(2);
43+
expect(result.current.visible).toEqual([4, 5]);
44+
45+
// The list shrinks to a single page's worth of rows: the stale page-2 index clamps back to the last page.
46+
rerender({ rows: [0, 1, 2] });
47+
expect(result.current.pageCount).toBe(2);
48+
expect(result.current.page).toBe(1);
49+
expect(result.current.visible).toEqual([2]);
50+
});
51+
52+
it("defaults the page size to PAGE_SIZE (20) when no size is passed", () => {
53+
const rows = Array.from({ length: 25 }, (_, index) => index);
54+
const { result } = renderHook(() => usePagedRows(rows));
55+
expect(PAGE_SIZE).toBe(20);
56+
expect(result.current.isPaginated).toBe(true);
57+
expect(result.current.pageCount).toBe(2);
58+
expect(result.current.visible).toHaveLength(20);
59+
expect(result.current.visible[0]).toBe(0);
60+
expect(result.current.visible[19]).toBe(19);
61+
});
62+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useState } from "react";
2+
3+
/** Rows per page a table shows once it grows past this many rows; below it the full list renders unpaginated. */
4+
export const PAGE_SIZE = 20;
5+
6+
export interface PagedRows<T> {
7+
/** The current page's slice of `rows` (the full list when `isPaginated` is false). */
8+
visible: T[];
9+
/** True once `rows` exceeds `pageSize`; the pager is only meant to render in this case. */
10+
isPaginated: boolean;
11+
/** The active page index, always clamped into `[0, pageCount - 1]`. */
12+
page: number;
13+
/** Total number of pages (at least 1). */
14+
pageCount: number;
15+
/** Sets the desired page index; it is clamped on the next render. */
16+
setPage: (n: number) => void;
17+
}
18+
19+
/**
20+
* Generic client-side pager shared across the miner-ui tables (#8306): slices `rows` into
21+
* `pageSize`-sized pages and exposes only the current page's `visible` slice plus the state a pager needs.
22+
* Below `pageSize` rows the full list renders unpaginated (`isPaginated === false`). The returned `page`
23+
* is clamped via `Math.min(page, pageCount - 1)`, so it stays valid even after `rows` shrinks below the
24+
* current page's start index.
25+
*/
26+
export function usePagedRows<T>(rows: T[], pageSize: number = PAGE_SIZE): PagedRows<T> {
27+
const [page, setPage] = useState(0);
28+
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
29+
const isPaginated = rows.length > pageSize;
30+
const safePage = Math.min(page, pageCount - 1);
31+
const visible = isPaginated ? rows.slice(safePage * pageSize, safePage * pageSize + pageSize) : rows;
32+
return { visible, isPaginated, page: safePage, pageCount, setPage };
33+
}

apps/loopover-miner-ui/src/routes/attempts.tsx

Lines changed: 2 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
import { createFileRoute } from "@tanstack/react-router";
2-
import { useState } from "react";
32

43
import { Badge } from "@loopover/ui-kit/components/badge";
54
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
6-
import {
7-
Pagination,
8-
PaginationContent,
9-
PaginationItem,
10-
PaginationLink,
11-
PaginationNext,
12-
PaginationPrevious,
13-
} from "@loopover/ui-kit/components/pagination";
145
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
156
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
167
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
178

9+
import { TablePagination } from "../components/table-pagination";
10+
import { usePagedRows } from "../lib/paged-rows";
1811
import {
1912
fetchAttemptLog,
2013
type AttemptFeedEntry,
@@ -44,79 +37,9 @@ const DECISION_VARIANT: Record<PrOutcomeDecision, "secondary" | "outline"> = {
4437
closed: "outline",
4538
};
4639

47-
/** Rows per page once a count/feed table grows past this; below it the full table renders unpaginated. */
48-
const PAGE_SIZE = 20;
49-
5040
const dashIfNull = (value: string | number | null): string | number => (value === null ? "—" : value);
5141
const formatCost = (costUsd: number | null): string => (costUsd === null ? "—" : `$${costUsd.toFixed(4)}`);
5242

53-
function TablePagination({
54-
page,
55-
pageCount,
56-
onPageChange,
57-
}: {
58-
page: number;
59-
pageCount: number;
60-
onPageChange: (next: number) => void;
61-
}) {
62-
return (
63-
<Pagination className="mt-4">
64-
<PaginationContent>
65-
<PaginationItem>
66-
<PaginationPrevious
67-
href="#"
68-
aria-disabled={page === 0}
69-
onClick={(event) => {
70-
event.preventDefault();
71-
onPageChange(Math.max(0, page - 1));
72-
}}
73-
/>
74-
</PaginationItem>
75-
{Array.from({ length: pageCount }).map((_, index) => (
76-
<PaginationItem key={index}>
77-
<PaginationLink
78-
href="#"
79-
isActive={index === page}
80-
onClick={(event) => {
81-
event.preventDefault();
82-
onPageChange(index);
83-
}}
84-
>
85-
{index + 1}
86-
</PaginationLink>
87-
</PaginationItem>
88-
))}
89-
<PaginationItem>
90-
<PaginationNext
91-
href="#"
92-
aria-disabled={page >= pageCount - 1}
93-
onClick={(event) => {
94-
event.preventDefault();
95-
onPageChange(Math.min(pageCount - 1, page + 1));
96-
}}
97-
/>
98-
</PaginationItem>
99-
</PaginationContent>
100-
</Pagination>
101-
);
102-
}
103-
104-
/** Generic pageable helper: slices a list to PAGE_SIZE-sized pages, rendering the pager only past the first page. */
105-
function usePagedRows<T>(rows: T[]): {
106-
visible: T[];
107-
isPaginated: boolean;
108-
page: number;
109-
pageCount: number;
110-
setPage: (n: number) => void;
111-
} {
112-
const [page, setPage] = useState(0);
113-
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
114-
const isPaginated = rows.length > PAGE_SIZE;
115-
const safePage = Math.min(page, pageCount - 1);
116-
const visible = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;
117-
return { visible, isPaginated, page: safePage, pageCount, setPage };
118-
}
119-
12043
function CountTable({ counts, keyLabel }: { counts: Record<string, number>; keyLabel: string }) {
12144
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
12245
const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries);

0 commit comments

Comments
 (0)