Skip to content

Commit 7868efb

Browse files
authored
Merge pull request #6591 from jaytbarimbao-collab/feat-miner-ui-run-history-redesign-6510
feat(miner-ui): redesign run-history route with StateBoundary, skeleton, and pagination
2 parents 4604090 + 0befbe8 commit 7868efb

2 files changed

Lines changed: 171 additions & 37 deletions

File tree

apps/loopover-miner-ui/src/routes/run-history.tsx

Lines changed: 130 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { createFileRoute } from "@tanstack/react-router";
2+
import { useState } from "react";
23

34
import { Badge } from "@loopover/ui-kit/components/badge";
45
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";
14+
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
15+
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
516
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
617

718
import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
@@ -12,8 +23,13 @@ export const Route = createFileRoute("/run-history")({
1223
});
1324

1425
// Read-only run-history table (#4305): one row per repo from the local `miner_run_state` store (repo, state,
15-
// last-updated), served by the dev server's local API. No writes, no new state — a fresh install renders the
16-
// empty state, an unreachable API renders an error message.
26+
// last-updated), served by the dev server's local API. No writes, no new state.
27+
//
28+
// #6510: the hand-rolled loading/error/empty `<p>` branches are replaced by the shared @loopover/ui-kit
29+
// `StateBoundary`, with a content-shaped `Skeleton` table for the loading state (so the layout doesn't jump when
30+
// the poll resolves), and the table paginates client-side once it exceeds PAGE_SIZE rows via the kit's
31+
// `Pagination`. Purely presentational — `lib/run-history.ts`'s fetch/poll is untouched, and the
32+
// Repository/State/Last-updated columns + data shown are unchanged.
1733

1834
const STATE_BADGE_VARIANT: Record<RunStateRow["state"], "secondary" | "outline"> = {
1935
idle: "secondary",
@@ -22,35 +38,57 @@ const STATE_BADGE_VARIANT: Record<RunStateRow["state"], "secondary" | "outline">
2238
preparing: "outline",
2339
};
2440

25-
export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
26-
if (result === null) {
27-
return <p className="text-token-sm text-muted-foreground">Loading local run state…</p>;
28-
}
29-
if (!result.ok) {
30-
return (
31-
<p role="alert" className="text-token-sm text-[var(--danger)]">
32-
Could not read local run state: {result.error}
33-
</p>
34-
);
35-
}
36-
if (result.rows.length === 0) {
37-
return (
38-
<p className="text-token-sm text-muted-foreground">
39-
No local run state yet — the table fills in once the miner records its first repo run.
40-
</p>
41-
);
42-
}
41+
/** Rows per page once the run-state table grows past this; below it the full table renders unpaginated. */
42+
const PAGE_SIZE = 20;
43+
44+
const TABLE_COLUMNS = ["Repository", "State", "Last updated"] as const;
45+
46+
function RunHistoryTableHeader() {
47+
return (
48+
<TableHeader>
49+
<TableRow>
50+
{TABLE_COLUMNS.map((column) => (
51+
<TableHead key={column}>{column}</TableHead>
52+
))}
53+
</TableRow>
54+
</TableHeader>
55+
);
56+
}
57+
58+
/** Table-shaped loading placeholder: header + `rows` shimmer rows matching the real column layout, so the table
59+
* keeps its shape and the content doesn't jump once the poll resolves. `role="status"` keeps the loading state
60+
* announced to assistive tech (as the flat "Loading…" text it replaces was). */
61+
function RunHistorySkeleton({ rows = 5 }: { rows?: number }) {
62+
return (
63+
<div role="status" aria-label="Loading local run state">
64+
<Table>
65+
<RunHistoryTableHeader />
66+
<TableBody>
67+
{Array.from({ length: rows }).map((_, index) => (
68+
<TableRow key={index}>
69+
<TableCell>
70+
<Skeleton className="h-4 w-48" />
71+
</TableCell>
72+
<TableCell>
73+
<Skeleton className="h-5 w-20" />
74+
</TableCell>
75+
<TableCell>
76+
<Skeleton className="h-4 w-32" />
77+
</TableCell>
78+
</TableRow>
79+
))}
80+
</TableBody>
81+
</Table>
82+
</div>
83+
);
84+
}
85+
86+
function RunStateTable({ rows }: { rows: RunStateRow[] }) {
4387
return (
4488
<Table>
45-
<TableHeader>
46-
<TableRow>
47-
<TableHead>Repository</TableHead>
48-
<TableHead>State</TableHead>
49-
<TableHead>Last updated</TableHead>
50-
</TableRow>
51-
</TableHeader>
89+
<RunHistoryTableHeader />
5290
<TableBody>
53-
{result.rows.map((row) => (
91+
{rows.map((row) => (
5492
<TableRow key={row.repoFullName}>
5593
<TableCell className="font-mono text-foreground">{row.repoFullName}</TableCell>
5694
<TableCell>
@@ -64,6 +102,70 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null })
64102
);
65103
}
66104

105+
export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
106+
const [page, setPage] = useState(0);
107+
const rows = result?.ok ? result.rows : [];
108+
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
109+
const isPaginated = rows.length > PAGE_SIZE;
110+
const safePage = Math.min(page, pageCount - 1);
111+
const visibleRows = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;
112+
113+
return (
114+
<StateBoundary
115+
isLoading={result === null}
116+
isError={result !== null && !result.ok}
117+
isEmpty={result !== null && result.ok && result.rows.length === 0}
118+
loadingSkeleton={<RunHistorySkeleton />}
119+
errorTitle="Couldn't read local run state"
120+
errorDescription="The local run-state API didn't respond. This refreshes automatically on the next poll."
121+
emptyTitle="No local run state yet"
122+
emptyDescription="The table fills in once the miner records its first repo run."
123+
>
124+
<RunStateTable rows={visibleRows} />
125+
{isPaginated && (
126+
<Pagination className="mt-4">
127+
<PaginationContent>
128+
<PaginationItem>
129+
<PaginationPrevious
130+
href="#"
131+
aria-disabled={safePage === 0}
132+
onClick={(event) => {
133+
event.preventDefault();
134+
setPage((current) => Math.max(0, current - 1));
135+
}}
136+
/>
137+
</PaginationItem>
138+
{Array.from({ length: pageCount }).map((_, index) => (
139+
<PaginationItem key={index}>
140+
<PaginationLink
141+
href="#"
142+
isActive={index === safePage}
143+
onClick={(event) => {
144+
event.preventDefault();
145+
setPage(index);
146+
}}
147+
>
148+
{index + 1}
149+
</PaginationLink>
150+
</PaginationItem>
151+
))}
152+
<PaginationItem>
153+
<PaginationNext
154+
href="#"
155+
aria-disabled={safePage >= pageCount - 1}
156+
onClick={(event) => {
157+
event.preventDefault();
158+
setPage((current) => Math.min(pageCount - 1, current + 1));
159+
}}
160+
/>
161+
</PaginationItem>
162+
</PaginationContent>
163+
</Pagination>
164+
)}
165+
</StateBoundary>
166+
);
167+
}
168+
67169
export function RunHistoryPage({
68170
loadRunStates = fetchRunStates,
69171
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,

apps/loopover-miner-ui/src/run-history.test.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, waitFor } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
22
import { afterEach, describe, expect, it, vi } from "vitest";
33

44
import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history";
@@ -9,7 +9,15 @@ const fixtureRows: RunStateRow[] = [
99
{ repoFullName: "acme/gadgets", state: "idle", updatedAt: "2026-07-10T05:00:00.000Z" },
1010
];
1111

12-
describe("RunHistoryView (#4305)", () => {
12+
function manyRows(count: number): RunStateRow[] {
13+
return Array.from({ length: count }, (_, index) => ({
14+
repoFullName: `acme/repo-${index}`,
15+
state: "idle" as const,
16+
updatedAt: "2026-07-10T05:00:00.000Z",
17+
}));
18+
}
19+
20+
describe("RunHistoryView (#4305, redesigned #6510)", () => {
1321
it("renders one table row per run-state fixture row with repo, state badge, and last-updated", () => {
1422
render(<RunHistoryView result={{ ok: true, rows: fixtureRows }} />);
1523
expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy();
@@ -20,20 +28,44 @@ describe("RunHistoryView (#4305)", () => {
2028
expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 fixture rows
2129
});
2230

23-
it("renders the fresh-install empty state without erroring", () => {
31+
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6510)", () => {
32+
render(<RunHistoryView result={null} />);
33+
expect(screen.getByRole("status", { name: /loading local run state/i })).toBeTruthy();
34+
expect(screen.queryByText("Loading local run state…")).toBeNull(); // the pre-#6510 sentence is gone
35+
});
36+
37+
it("renders the shared StateBoundary error surface on an unreachable API (#6510)", () => {
38+
render(<RunHistoryView result={{ ok: false, error: "connection refused" }} />);
39+
expect(screen.getByRole("alert")).toBeTruthy();
40+
expect(screen.getByText(/Couldn't read local run state/i)).toBeTruthy();
41+
});
42+
43+
it("renders the empty state via StateBoundary when there are no tracked repos (#6510)", () => {
2444
render(<RunHistoryView result={{ ok: true, rows: [] }} />);
2545
expect(screen.getByText(/No local run state yet/i)).toBeTruthy();
2646
expect(screen.queryByRole("table")).toBeNull();
2747
});
2848

29-
it("renders an error message when the local API is unreachable", () => {
30-
render(<RunHistoryView result={{ ok: false, error: "connection refused" }} />);
31-
expect(screen.getByRole("alert").textContent).toContain("connection refused");
49+
it("does not paginate at or below 20 rows — full table, no controls (#6510)", () => {
50+
render(<RunHistoryView result={{ ok: true, rows: manyRows(20) }} />);
51+
expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull();
52+
expect(screen.getAllByRole("row")).toHaveLength(21); // header + all 20 rows shown
3253
});
3354

34-
it("renders the loading state before the first result arrives", () => {
35-
render(<RunHistoryView result={null} />);
36-
expect(screen.getByText(/Loading local run state/i)).toBeTruthy();
55+
it("paginates client-side above 20 rows, paging without any refetch (#6510)", () => {
56+
render(<RunHistoryView result={{ ok: true, rows: manyRows(45) }} />);
57+
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
58+
// page 1: first 20 rows only
59+
expect(screen.getAllByRole("row")).toHaveLength(21);
60+
expect(screen.getByText("acme/repo-0")).toBeTruthy();
61+
expect(screen.queryByText("acme/repo-20")).toBeNull();
62+
// page 2
63+
fireEvent.click(screen.getByRole("link", { name: "2" }));
64+
expect(screen.getByText("acme/repo-20")).toBeTruthy();
65+
expect(screen.queryByText("acme/repo-0")).toBeNull();
66+
// page 3 holds the remaining 5 rows (header + 5)
67+
fireEvent.click(screen.getByRole("link", { name: "3" }));
68+
expect(screen.getAllByRole("row")).toHaveLength(6);
3769
});
3870
});
3971

0 commit comments

Comments
 (0)