diff --git a/apps/loopover-miner-ui/src/components/table-pagination.test.tsx b/apps/loopover-miner-ui/src/components/table-pagination.test.tsx
new file mode 100644
index 0000000000..bde76154bb
--- /dev/null
+++ b/apps/loopover-miner-ui/src/components/table-pagination.test.tsx
@@ -0,0 +1,49 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { TablePagination } from "./table-pagination";
+
+describe("TablePagination (#8306)", () => {
+ it("renders a numbered link per page and disables Previous on the first page", () => {
+ const onPageChange = vi.fn();
+ render();
+
+ expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "1" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "3" })).toBeTruthy();
+
+ // On the first page Previous is aria-disabled; Next is not.
+ expect(screen.getByRole("link", { name: /go to previous page/i }).getAttribute("aria-disabled")).toBe("true");
+ expect(screen.getByRole("link", { name: /go to next page/i }).getAttribute("aria-disabled")).toBe("false");
+ });
+
+ it("invokes onPageChange for numbered, Next and Previous clicks (clamping at the low boundary)", () => {
+ const onPageChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole("link", { name: "2" }));
+ expect(onPageChange).toHaveBeenLastCalledWith(1);
+
+ fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
+ expect(onPageChange).toHaveBeenLastCalledWith(1);
+
+ // Previous from page 0 clamps to 0 rather than going negative.
+ fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
+ expect(onPageChange).toHaveBeenLastCalledWith(0);
+ });
+
+ it("disables Next on the last page and clamps Next at the high boundary", () => {
+ const onPageChange = vi.fn();
+ render();
+
+ expect(screen.getByRole("link", { name: /go to previous page/i }).getAttribute("aria-disabled")).toBe("false");
+ expect(screen.getByRole("link", { name: /go to next page/i }).getAttribute("aria-disabled")).toBe("true");
+
+ // Next from the last page clamps to the last page rather than overshooting pageCount - 1.
+ fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
+ expect(onPageChange).toHaveBeenLastCalledWith(2);
+
+ fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
+ expect(onPageChange).toHaveBeenLastCalledWith(1);
+ });
+});
diff --git a/apps/loopover-miner-ui/src/components/table-pagination.tsx b/apps/loopover-miner-ui/src/components/table-pagination.tsx
new file mode 100644
index 0000000000..4cfb136cc7
--- /dev/null
+++ b/apps/loopover-miner-ui/src/components/table-pagination.tsx
@@ -0,0 +1,65 @@
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@loopover/ui-kit/components/pagination";
+
+/**
+ * Shared presentational pager for the miner-ui tables (#8306): numbered page links plus boundary
+ * Previous/Next controls, with `aria-disabled` on the first/last page. Pair it with `usePagedRows`,
+ * rendering it only when `isPaginated` is true. Built on the existing `@loopover/ui-kit`
+ * `Pagination` primitives — the primitive itself is intentionally left unchanged here.
+ */
+export function TablePagination({
+ page,
+ pageCount,
+ onPageChange,
+}: {
+ page: number;
+ pageCount: number;
+ onPageChange: (next: number) => void;
+}) {
+ return (
+
+
+
+ {
+ event.preventDefault();
+ onPageChange(Math.max(0, page - 1));
+ }}
+ />
+
+ {Array.from({ length: pageCount }).map((_, index) => (
+
+ {
+ event.preventDefault();
+ onPageChange(index);
+ }}
+ >
+ {index + 1}
+
+
+ ))}
+
+ = pageCount - 1}
+ onClick={(event) => {
+ event.preventDefault();
+ onPageChange(Math.min(pageCount - 1, page + 1));
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/loopover-miner-ui/src/lib/paged-rows.test.ts b/apps/loopover-miner-ui/src/lib/paged-rows.test.ts
new file mode 100644
index 0000000000..35b14b4541
--- /dev/null
+++ b/apps/loopover-miner-ui/src/lib/paged-rows.test.ts
@@ -0,0 +1,62 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { PAGE_SIZE, usePagedRows } from "./paged-rows";
+
+describe("usePagedRows (#8306)", () => {
+ it("does not paginate an empty list — one page, empty slice, no clamping", () => {
+ const { result } = renderHook(() => usePagedRows([], 2));
+ expect(result.current.isPaginated).toBe(false);
+ expect(result.current.pageCount).toBe(1);
+ expect(result.current.page).toBe(0);
+ expect(result.current.visible).toEqual([]);
+ });
+
+ it("returns the full list unpaginated when rows fit within a single page (at or below the size)", () => {
+ // Exactly `pageSize` rows is still a single page: the `rows.length > pageSize` boundary is exclusive.
+ const rows = [0, 1, 2, 3];
+ const { result } = renderHook(() => usePagedRows(rows, 4));
+ expect(result.current.isPaginated).toBe(false);
+ expect(result.current.pageCount).toBe(1);
+ expect(result.current.visible).toBe(rows);
+ });
+
+ it("slices rows across multiple pages and follows setPage", () => {
+ const rows = [0, 1, 2, 3, 4];
+ const { result } = renderHook(() => usePagedRows(rows, 2));
+ expect(result.current.isPaginated).toBe(true);
+ expect(result.current.pageCount).toBe(3);
+ expect(result.current.page).toBe(0);
+ expect(result.current.visible).toEqual([0, 1]);
+
+ act(() => result.current.setPage(2));
+ expect(result.current.page).toBe(2);
+ expect(result.current.visible).toEqual([4]);
+ });
+
+ it("clamps the active page when rows shrink below the current page's start index", () => {
+ const { result, rerender } = renderHook(({ rows }: { rows: number[] }) => usePagedRows(rows, 2), {
+ initialProps: { rows: [0, 1, 2, 3, 4, 5] },
+ });
+ act(() => result.current.setPage(2));
+ expect(result.current.page).toBe(2);
+ expect(result.current.visible).toEqual([4, 5]);
+
+ // The list shrinks to a single page's worth of rows: the stale page-2 index clamps back to the last page.
+ rerender({ rows: [0, 1, 2] });
+ expect(result.current.pageCount).toBe(2);
+ expect(result.current.page).toBe(1);
+ expect(result.current.visible).toEqual([2]);
+ });
+
+ it("defaults the page size to PAGE_SIZE (20) when no size is passed", () => {
+ const rows = Array.from({ length: 25 }, (_, index) => index);
+ const { result } = renderHook(() => usePagedRows(rows));
+ expect(PAGE_SIZE).toBe(20);
+ expect(result.current.isPaginated).toBe(true);
+ expect(result.current.pageCount).toBe(2);
+ expect(result.current.visible).toHaveLength(20);
+ expect(result.current.visible[0]).toBe(0);
+ expect(result.current.visible[19]).toBe(19);
+ });
+});
diff --git a/apps/loopover-miner-ui/src/lib/paged-rows.ts b/apps/loopover-miner-ui/src/lib/paged-rows.ts
new file mode 100644
index 0000000000..c183353778
--- /dev/null
+++ b/apps/loopover-miner-ui/src/lib/paged-rows.ts
@@ -0,0 +1,33 @@
+import { useState } from "react";
+
+/** Rows per page a table shows once it grows past this many rows; below it the full list renders unpaginated. */
+export const PAGE_SIZE = 20;
+
+export interface PagedRows {
+ /** The current page's slice of `rows` (the full list when `isPaginated` is false). */
+ visible: T[];
+ /** True once `rows` exceeds `pageSize`; the pager is only meant to render in this case. */
+ isPaginated: boolean;
+ /** The active page index, always clamped into `[0, pageCount - 1]`. */
+ page: number;
+ /** Total number of pages (at least 1). */
+ pageCount: number;
+ /** Sets the desired page index; it is clamped on the next render. */
+ setPage: (n: number) => void;
+}
+
+/**
+ * Generic client-side pager shared across the miner-ui tables (#8306): slices `rows` into
+ * `pageSize`-sized pages and exposes only the current page's `visible` slice plus the state a pager needs.
+ * Below `pageSize` rows the full list renders unpaginated (`isPaginated === false`). The returned `page`
+ * is clamped via `Math.min(page, pageCount - 1)`, so it stays valid even after `rows` shrinks below the
+ * current page's start index.
+ */
+export function usePagedRows(rows: T[], pageSize: number = PAGE_SIZE): PagedRows {
+ const [page, setPage] = useState(0);
+ const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
+ const isPaginated = rows.length > pageSize;
+ const safePage = Math.min(page, pageCount - 1);
+ const visible = isPaginated ? rows.slice(safePage * pageSize, safePage * pageSize + pageSize) : rows;
+ return { visible, isPaginated, page: safePage, pageCount, setPage };
+}
diff --git a/apps/loopover-miner-ui/src/routes/attempts.tsx b/apps/loopover-miner-ui/src/routes/attempts.tsx
index 8622bfc79d..840ac43f99 100644
--- a/apps/loopover-miner-ui/src/routes/attempts.tsx
+++ b/apps/loopover-miner-ui/src/routes/attempts.tsx
@@ -1,20 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
-import { useState } from "react";
import { Badge } from "@loopover/ui-kit/components/badge";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
+import { TablePagination } from "../components/table-pagination";
+import { usePagedRows } from "../lib/paged-rows";
import {
fetchAttemptLog,
type AttemptFeedEntry,
@@ -44,79 +37,9 @@ const DECISION_VARIANT: Record = {
closed: "outline",
};
-/** Rows per page once a count/feed table grows past this; below it the full table renders unpaginated. */
-const PAGE_SIZE = 20;
-
const dashIfNull = (value: string | number | null): string | number => (value === null ? "—" : value);
const formatCost = (costUsd: number | null): string => (costUsd === null ? "—" : `$${costUsd.toFixed(4)}`);
-function TablePagination({
- page,
- pageCount,
- onPageChange,
-}: {
- page: number;
- pageCount: number;
- onPageChange: (next: number) => void;
-}) {
- return (
-
-
-
- {
- event.preventDefault();
- onPageChange(Math.max(0, page - 1));
- }}
- />
-
- {Array.from({ length: pageCount }).map((_, index) => (
-
- {
- event.preventDefault();
- onPageChange(index);
- }}
- >
- {index + 1}
-
-
- ))}
-
- = pageCount - 1}
- onClick={(event) => {
- event.preventDefault();
- onPageChange(Math.min(pageCount - 1, page + 1));
- }}
- />
-
-
-
- );
-}
-
-/** Generic pageable helper: slices a list to PAGE_SIZE-sized pages, rendering the pager only past the first page. */
-function usePagedRows(rows: T[]): {
- visible: T[];
- isPaginated: boolean;
- page: number;
- pageCount: number;
- setPage: (n: number) => void;
-} {
- const [page, setPage] = useState(0);
- const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
- const isPaginated = rows.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visible = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;
- return { visible, isPaginated, page: safePage, pageCount, setPage };
-}
-
function CountTable({ counts, keyLabel }: { counts: Record; keyLabel: string }) {
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries);
diff --git a/apps/loopover-miner-ui/src/routes/ledgers.tsx b/apps/loopover-miner-ui/src/routes/ledgers.tsx
index 883b2468b6..9567c34ab7 100644
--- a/apps/loopover-miner-ui/src/routes/ledgers.tsx
+++ b/apps/loopover-miner-ui/src/routes/ledgers.tsx
@@ -6,18 +6,12 @@ import { Button } from "@loopover/ui-kit/components/button";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@loopover/ui-kit/components/chart";
import { Input } from "@loopover/ui-kit/components/input";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
+import { TablePagination } from "../components/table-pagination";
+import { usePagedRows } from "../lib/paged-rows";
import {
CLAIM_STATUSES,
fetchLedgers,
@@ -66,9 +60,6 @@ const CLAIM_STATUS_TONE: Record = {
expired: "text-warning",
};
-/** Rows per page once a count/feed table grows past this; below it the full table renders unpaginated. */
-const PAGE_SIZE = 20;
-
const CLAIMS_CHART_CONFIG = {
count: { label: "Claims" },
active: { label: "Active", color: "var(--success)" },
@@ -76,64 +67,9 @@ const CLAIMS_CHART_CONFIG = {
expired: { label: "Expired", color: "var(--warning)" },
} satisfies ChartConfig;
-function TablePagination({
- page,
- pageCount,
- onPageChange,
-}: {
- page: number;
- pageCount: number;
- onPageChange: (next: number) => void;
-}) {
- return (
-
-
-
- {
- event.preventDefault();
- onPageChange(Math.max(0, page - 1));
- }}
- />
-
- {Array.from({ length: pageCount }).map((_, index) => (
-
- {
- event.preventDefault();
- onPageChange(index);
- }}
- >
- {index + 1}
-
-
- ))}
-
- = pageCount - 1}
- onClick={(event) => {
- event.preventDefault();
- onPageChange(Math.min(pageCount - 1, page + 1));
- }}
- />
-
-
-
- );
-}
-
function CountTable({ counts, keyLabel }: { counts: Record; keyLabel: string }) {
- const [page, setPage] = useState(0);
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
- const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
- const isPaginated = entries.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visible = isPaginated ? entries.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : entries;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries);
return (
@@ -152,17 +88,13 @@ function CountTable({ counts, keyLabel }: { counts: Record; keyL
))}
- {isPaginated &&
}
+ {isPaginated &&
}
);
}
function RecentEventsTable({ entries }: { entries: EventFeedEntry[] }) {
- const [page, setPage] = useState(0);
- const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
- const isPaginated = entries.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visible = isPaginated ? entries.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : entries;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries);
return (
@@ -183,7 +115,7 @@ function RecentEventsTable({ entries }: { entries: EventFeedEntry[] }) {
))}
- {isPaginated &&
}
+ {isPaginated &&
}
);
}
diff --git a/apps/loopover-miner-ui/src/routes/portfolio.tsx b/apps/loopover-miner-ui/src/routes/portfolio.tsx
index 66d84879c1..973a8e1f23 100644
--- a/apps/loopover-miner-ui/src/routes/portfolio.tsx
+++ b/apps/loopover-miner-ui/src/routes/portfolio.tsx
@@ -5,18 +5,12 @@ import { Bar, BarChart, Cell, XAxis, YAxis } from "recharts";
import { Button } from "@loopover/ui-kit/components/button";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@loopover/ui-kit/components/chart";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
+import { TablePagination } from "../components/table-pagination";
+import { usePagedRows } from "../lib/paged-rows";
import {
fetchPortfolioQueueItems,
requeuePortfolioQueueItem,
@@ -65,9 +59,6 @@ const STATUS_TONE: Record = {
done: "text-success",
};
-/** Rows per page once a repos/actions table grows past this; below it the full table renders unpaginated. */
-const PAGE_SIZE = 20;
-
const QUEUE_CHART_CONFIG = {
count: { label: "Queue items" },
queued: { label: "Queued", color: "var(--muted-foreground)" },
@@ -75,57 +66,6 @@ const QUEUE_CHART_CONFIG = {
done: { label: "Done", color: "var(--success)" },
} satisfies ChartConfig;
-function TablePagination({
- page,
- pageCount,
- onPageChange,
-}: {
- page: number;
- pageCount: number;
- onPageChange: (next: number) => void;
-}) {
- return (
-
-
-
- {
- event.preventDefault();
- onPageChange(Math.max(0, page - 1));
- }}
- />
-
- {Array.from({ length: pageCount }).map((_, index) => (
-
- {
- event.preventDefault();
- onPageChange(index);
- }}
- >
- {index + 1}
-
-
- ))}
-
- = pageCount - 1}
- onClick={(event) => {
- event.preventDefault();
- onPageChange(Math.min(pageCount - 1, page + 1));
- }}
- />
-
-
-
- );
-}
-
/** Horizontal bar chart of queue status counts — the chart.tsx adoption for the status cards section (#6831).
* Cards still show the exact numbers; the chart is the glanceable breakdown the bare ``s alone weren't. */
function QueueStatusChart({ byStatus }: { byStatus: QueueStatusCounts }) {
@@ -158,14 +98,10 @@ function QueueStatusChart({ byStatus }: { byStatus: QueueStatusCounts }) {
}
function ReposTable({ repos }: { repos: PortfolioRepoSummary[] }) {
- const [page, setPage] = useState(0);
// Sorted by total desc (then name) so the busiest repos surface first — same "sort then page" shape as the
// ledgers CountTable (#6832), without inventing interactive column headers.
const sorted = [...repos].sort((a, b) => b.total - a.total || a.repoFullName.localeCompare(b.repoFullName));
- const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
- const isPaginated = sorted.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visible = isPaginated ? sorted.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : sorted;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(sorted);
return (
@@ -190,7 +126,7 @@ function ReposTable({ repos }: { repos: PortfolioRepoSummary[] }) {
))}
- {isPaginated &&
}
+ {isPaginated &&
}
);
}
@@ -206,7 +142,6 @@ function QueueActionsTable({
onRelease: (item: PortfolioQueueActionItem) => void;
onRequeue: (item: PortfolioQueueActionItem) => void;
}) {
- const [page, setPage] = useState(0);
// in_progress before done, then repo/identifier — actionable release rows float to the top of page 1.
const sorted = [...items].sort((a, b) => {
const statusOrder = (status: PortfolioQueueActionItem["status"]) => (status === "in_progress" ? 0 : 1);
@@ -216,10 +151,7 @@ function QueueActionsTable({
a.identifier.localeCompare(b.identifier)
);
});
- const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
- const isPaginated = sorted.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visible = isPaginated ? sorted.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : sorted;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(sorted);
return (
@@ -252,7 +184,7 @@ function QueueActionsTable({
))}
- {isPaginated &&
}
+ {isPaginated &&
}
);
}
diff --git a/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx b/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx
index 2865062337..d70eab07e1 100644
--- a/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx
+++ b/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx
@@ -1,19 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
-import { useState } from "react";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
+import { TablePagination } from "../components/table-pagination";
+import { usePagedRows } from "../lib/paged-rows";
import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import {
fetchRankedCandidates,
@@ -34,10 +27,6 @@ export const Route = createFileRoute("/ranked-candidates")({
// -- this route is the first miner-ui dashboard consumer of it. Purely presentational: `lib/ranked-candidates.ts`'s
// fetch/poll and `vite-ranked-candidates-api.ts`'s ranking data are both untouched.
-/** Rows per page once the ranked-candidates table grows past this; below it the full table renders unpaginated.
- * Same threshold as run-history / ledgers / portfolio (#6510/#6832/#6511). */
-const PAGE_SIZE = 20;
-
const TABLE_COLUMNS = [
"Issue",
"Rank score",
@@ -127,12 +116,8 @@ function RankedCandidatesTable({ rows }: { rows: RankedCandidateRow[] }) {
}
export function RankedCandidatesView({ result }: { result: RankedCandidatesResult | null }) {
- const [page, setPage] = useState(0);
const rows = result?.ok ? result.candidates : [];
- const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
- const isPaginated = rows.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visibleRows = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(rows);
return (
-
- {isPaginated && (
-
-
-
- {
- event.preventDefault();
- setPage((current) => Math.max(0, current - 1));
- }}
- />
-
- {Array.from({ length: pageCount }).map((_, index) => (
-
- {
- event.preventDefault();
- setPage(index);
- }}
- >
- {index + 1}
-
-
- ))}
-
- = pageCount - 1}
- onClick={(event) => {
- event.preventDefault();
- setPage((current) => Math.min(pageCount - 1, current + 1));
- }}
- />
-
-
-
- )}
+
+ {isPaginated && }
);
}
diff --git a/apps/loopover-miner-ui/src/routes/run-history.tsx b/apps/loopover-miner-ui/src/routes/run-history.tsx
index eb51e4013a..7af7eef1d9 100644
--- a/apps/loopover-miner-ui/src/routes/run-history.tsx
+++ b/apps/loopover-miner-ui/src/routes/run-history.tsx
@@ -1,20 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
-import { useState } from "react";
import { Badge } from "@loopover/ui-kit/components/badge";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
-import {
- Pagination,
- PaginationContent,
- PaginationItem,
- PaginationLink,
- PaginationNext,
- PaginationPrevious,
-} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
+import { TablePagination } from "../components/table-pagination";
+import { usePagedRows } from "../lib/paged-rows";
import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import {
fetchRunStates,
@@ -46,9 +39,6 @@ const STATE_BADGE_VARIANT: Record
preparing: "outline",
};
-/** Rows per page once the run-state table grows past this; below it the full table renders unpaginated. */
-const PAGE_SIZE = 20;
-
const TABLE_COLUMNS = ["Repository", "Forge", "State", "Last updated"] as const;
function RunHistoryTableHeader() {
@@ -115,12 +105,8 @@ function RunStateTable({ rows }: { rows: RunStateRow[] }) {
}
export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
- const [page, setPage] = useState(0);
const rows = result?.ok ? result.rows : [];
- const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
- const isPaginated = rows.length > PAGE_SIZE;
- const safePage = Math.min(page, pageCount - 1);
- const visibleRows = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;
+ const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(rows);
return (
-
- {isPaginated && (
-
-
-
- {
- event.preventDefault();
- setPage((current) => Math.max(0, current - 1));
- }}
- />
-
- {Array.from({ length: pageCount }).map((_, index) => (
-
- {
- event.preventDefault();
- setPage(index);
- }}
- >
- {index + 1}
-
-
- ))}
-
- = pageCount - 1}
- onClick={(event) => {
- event.preventDefault();
- setPage((current) => Math.min(pageCount - 1, current + 1));
- }}
- />
-
-
-
- )}
+
+ {isPaginated && }
);
}