diff --git a/apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx b/apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx index 47cd55a9c8..37b23555fe 100644 --- a/apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx +++ b/apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx @@ -42,7 +42,9 @@ const doneItem: PortfolioQueueActionItem = { }; describe("PortfolioQueueActionsSection (#4857)", () => { - it("renders the loading state before the first result arrives", () => { + it("renders a content-shaped skeleton before the first result arrives", () => { + // #6511: StateBoundary renders the skeleton INSTEAD of a loading title, so the old + // "Loading actionable queue items…" text is intentionally gone; assert the placeholder instead. render( { onRequeue={() => undefined} />, ); - expect(screen.getByText(/Loading actionable queue items/i)).toBeTruthy(); + expect(screen.getByTestId("queue-actions-skeleton")).toBeTruthy(); + // Shaped like the real content, not one generic bar: the real table is not rendered yet. + expect(screen.queryByRole("table")).toBeNull(); + }); + + it("renders the empty-state sentence verbatim, with no extra copy from the shared boundary", () => { + // #6511: the whole original sentence is the EmptyState title and the description is suppressed, so the + // rendered copy is byte-identical to the

it replaced -- not a reworded title/description split, and + // none of StateBoundary's own default "This view has no records to show." boilerplate. + render( + undefined} + onRequeue={() => undefined} + />, + ); + expect(screen.getByText("No in-progress or completed items to release or requeue right now.")).toBeTruthy(); + expect(screen.queryByText(/This view has no records to show/i)).toBeNull(); + expect(screen.queryByRole("table")).toBeNull(); }); it("renders an error message when the local API is unreachable", () => { diff --git a/apps/loopover-miner-ui/src/portfolio-queue.test.tsx b/apps/loopover-miner-ui/src/portfolio-queue.test.tsx index 885240657c..e683090248 100644 --- a/apps/loopover-miner-ui/src/portfolio-queue.test.tsx +++ b/apps/loopover-miner-ui/src/portfolio-queue.test.tsx @@ -90,7 +90,13 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => { it("renders the fresh-install empty state without erroring", () => { render(); - expect(screen.getByText(/No queued work yet/i)).toBeTruthy(); + // #6511: asserted as the exact sentence, not a loose regex -- the whole original string is the EmptyState + // title with the description suppressed, so the rendered copy is byte-identical to the

it replaced. + expect( + screen.getByText("No queued work yet — the cards fill in once the miner enqueues its first portfolio item."), + ).toBeTruthy(); + // And none of StateBoundary's own default empty boilerplate leaks in alongside it. + expect(screen.queryByText(/This view has no records to show/i)).toBeNull(); expect(screen.queryByRole("table")).toBeNull(); }); @@ -99,9 +105,23 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => { expect(screen.getByRole("alert").textContent).toContain("connection refused"); }); - it("renders the loading state before the first result arrives", () => { + it("renders a content-shaped skeleton before the first result arrives", () => { + // #6511: StateBoundary renders the skeleton INSTEAD of a loading title, so the old + // "Loading local portfolio queue…" text is intentionally gone; assert the placeholder instead. render(); - expect(screen.getByText(/Loading local portfolio queue/i)).toBeTruthy(); + expect(screen.getByTestId("portfolio-queue-skeleton")).toBeTruthy(); + // Shaped like the real content, not one generic bar: the real table is not rendered yet. + expect(screen.queryByRole("table")).toBeNull(); + }); + + it("renders the error sentence verbatim, with no extra copy from the shared boundary", () => { + // #6511: same whole-sentence treatment on the error path -- one string, description suppressed, so none of + // ErrorState's own "Something went wrong fetching this data." default appears next to it. + render(); + expect(screen.getByRole("alert").textContent).toContain( + "Could not read the local portfolio queue: connection refused", + ); + expect(screen.queryByText(/Something went wrong fetching this data/i)).toBeNull(); }); }); diff --git a/apps/loopover-miner-ui/src/routes/portfolio.tsx b/apps/loopover-miner-ui/src/routes/portfolio.tsx index f017c75b3c..5188fc4f64 100644 --- a/apps/loopover-miner-ui/src/routes/portfolio.tsx +++ b/apps/loopover-miner-ui/src/routes/portfolio.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@loopover/ui-kit/components/button"; import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; +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 { @@ -35,61 +37,100 @@ const STATUS_TONE: Record = { done: "text-[var(--success)]", }; -export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | null }) { - if (result === null) { - return

Loading local portfolio queue…

; - } - if (!result.ok) { - return ( -

- Could not read the local portfolio queue: {result.error} -

- ); - } - const summary = result.summary; - if (summary.total === 0) { - return ( -

- No queued work yet — the cards fill in once the miner enqueues its first portfolio item. -

- ); - } +/** Placeholder shaped like the real summary -- three status cards over the repo table -- so the layout doesn't + * jump when the 10s poll lands. A single generic bar would just move the jump later. */ +function PortfolioQueueSkeleton() { return ( -
+
{(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => ( -
{STATUS_LABELS[status]}
-
- {summary.byStatus[status]} -
+ +
))}
- - - - Repository - Queued - In progress - Done - Total - - - - {summary.repos.map((repo) => ( - - {repo.repoFullName} - {repo.byStatus.queued} - {repo.byStatus.in_progress} - {repo.byStatus.done} - {repo.total} - - ))} - -
+
+ {[0, 1, 2].map((row) => ( + + ))} +
+
+ ); +} + +export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | null }) { + const summary = result?.ok ? result.summary : null; + return ( + } + // Each message is passed as the WHOLE original sentence with the description suppressed, rather than + // split across title/description: the issue requires the user-visible strings not be reworded, and Shell + // renders `{description && ...}` so an empty one adds nothing. The rendered text is byte-identical to the + //

tags this replaces. ErrorState emits role="alert" itself, so failures still announce the same way. + errorTitle={ + result !== null && !result.ok ? `Could not read the local portfolio queue: ${result.error}` : undefined + } + errorDescription="" + emptyTitle="No queued work yet — the cards fill in once the miner enqueues its first portfolio item." + emptyDescription={null} + > + {summary === null ? null : ( +

+
+ {(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => ( + + +
+ {STATUS_LABELS[status]} +
+
+ {summary.byStatus[status]} +
+
+
+ ))} +
+ + + + Repository + Queued + In progress + Done + Total + + + + {summary.repos.map((repo) => ( + + {repo.repoFullName} + {repo.byStatus.queued} + {repo.byStatus.in_progress} + {repo.byStatus.done} + {repo.total} + + ))} + +
+
+ )} +
+ ); +} + +/** Placeholder shaped like the queue-actions table's rows, for the same reason as the summary's. */ +function QueueActionsSkeleton() { + return ( +
+ {[0, 1, 2].map((row) => ( + + ))}
); } @@ -115,48 +156,54 @@ export function PortfolioQueueActionsSection({ Queue action failed: {actionResult.error}

) : null} - {result === null ? ( -

Loading actionable queue items…

- ) : !result.ok ? ( -

- Could not read actionable queue items: {result.error} -

- ) : result.items.length === 0 ? ( -

- No in-progress or completed items to release or requeue right now. -

- ) : ( - - - - Repository - Identifier - Status - Action - - - - {result.items.map((item) => ( - - {item.repoFullName} - {item.identifier} - {STATUS_LABELS[item.status]} - - {item.status === "in_progress" ? ( - - ) : ( - - )} - + {/* Its own boundary, deliberately: this fetch is independent of the summary above, so a failure here + must not blank the summary -- and a summary failure must not hide the actions. Same whole-sentence + treatment as above, so the empty/error copy stays byte-identical to the

tags it replaces. */} + } + errorTitle={ + result !== null && !result.ok ? `Could not read actionable queue items: ${result.error}` : undefined + } + errorDescription="" + emptyTitle="No in-progress or completed items to release or requeue right now." + emptyDescription={null} + > + {result === null || !result.ok || result.items.length === 0 ? null : ( +

+ + + Repository + Identifier + Status + Action - ))} - -
- )} + + + {result.items.map((item) => ( + + {item.repoFullName} + {item.identifier} + {STATUS_LABELS[item.status]} + + {item.status === "in_progress" ? ( + + ) : ( + + )} + + + ))} + + + )} + ); }