Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/loopover-miner-ui/src/components/table-pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TablePagination page={0} pageCount={3} onPageChange={onPageChange} />);

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(<TablePagination page={0} pageCount={3} onPageChange={onPageChange} />);

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(<TablePagination page={2} pageCount={3} onPageChange={onPageChange} />);

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);
});
});
65 changes: 65 additions & 0 deletions apps/loopover-miner-ui/src/components/table-pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={page === 0}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.max(0, page - 1));
}}
/>
</PaginationItem>
{Array.from({ length: pageCount }).map((_, index) => (
<PaginationItem key={index}>
<PaginationLink
href="#"
isActive={index === page}
onClick={(event) => {
event.preventDefault();
onPageChange(index);
}}
>
{index + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={page >= pageCount - 1}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.min(pageCount - 1, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
62 changes: 62 additions & 0 deletions apps/loopover-miner-ui/src/lib/paged-rows.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>([], 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);
});
});
33 changes: 33 additions & 0 deletions apps/loopover-miner-ui/src/lib/paged-rows.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/** 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<T>(rows: T[], pageSize: number = PAGE_SIZE): PagedRows<T> {
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 };
}
81 changes: 2 additions & 79 deletions apps/loopover-miner-ui/src/routes/attempts.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,7 +18,7 @@
} from "../lib/attempt-log";
import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";

export const Route = createFileRoute("/attempts")({

Check warning on line 21 in apps/loopover-miner-ui/src/routes/attempts.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
component: AttemptsPage,
});

Expand All @@ -44,79 +37,9 @@
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 (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={page === 0}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.max(0, page - 1));
}}
/>
</PaginationItem>
{Array.from({ length: pageCount }).map((_, index) => (
<PaginationItem key={index}>
<PaginationLink
href="#"
isActive={index === page}
onClick={(event) => {
event.preventDefault();
onPageChange(index);
}}
>
{index + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={page >= pageCount - 1}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.min(pageCount - 1, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

/** Generic pageable helper: slices a list to PAGE_SIZE-sized pages, rendering the pager only past the first page. */
function usePagedRows<T>(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<string, number>; keyLabel: string }) {
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries);
Expand Down
Loading
Loading