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 && } ); }