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
26 changes: 24 additions & 2 deletions apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PortfolioQueueActionsSection
result={null}
Expand All @@ -52,7 +54,27 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
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 <p> it replaced -- not a reworded title/description split, and
// none of StateBoundary's own default "This view has no records to show." boilerplate.
render(
<PortfolioQueueActionsSection
result={{ ok: true, items: [] }}
actionResult={null}
pending={false}
onRelease={() => 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", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/loopover-miner-ui/src/portfolio-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => {

it("renders the fresh-install empty state without erroring", () => {
render(<PortfolioQueueView result={{ ok: true, summary: emptyPortfolioQueueSummary() }} />);
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 <p> 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();
});

Expand All @@ -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(<PortfolioQueueView result={null} />);
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(<PortfolioQueueView result={{ ok: false, error: "connection refused" }} />);
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();
});
});

Expand Down
221 changes: 134 additions & 87 deletions apps/loopover-miner-ui/src/routes/portfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -35,61 +37,100 @@ const STATUS_TONE: Record<QueueStatus, string> = {
done: "text-[var(--success)]",
};

export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | null }) {
if (result === null) {
return <p className="text-token-sm text-muted-foreground">Loading local portfolio queue…</p>;
}
if (!result.ok) {
return (
<p role="alert" className="text-token-sm text-[var(--danger)]">
Could not read the local portfolio queue: {result.error}
</p>
);
}
const summary = result.summary;
if (summary.total === 0) {
return (
<p className="text-token-sm text-muted-foreground">
No queued work yet — the cards fill in once the miner enqueues its first portfolio item.
</p>
);
}
/** 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 (
<div className="grid gap-6">
<div className="grid gap-6" data-testid="portfolio-queue-skeleton">
<dl className="grid gap-4 sm:grid-cols-3">
{(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => (
<Card key={status}>
<CardContent className="p-4">
<dt className="text-token-2xs uppercase tracking-wider text-muted-foreground">{STATUS_LABELS[status]}</dt>
<dd className={`mt-1 text-token-3xl font-display font-semibold ${STATUS_TONE[status]}`}>
{summary.byStatus[status]}
</dd>
<Skeleton className="h-3 w-24" />
<Skeleton className="mt-2 h-8 w-12" />
</CardContent>
</Card>
))}
</dl>
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Queued</TableHead>
<TableHead>In progress</TableHead>
<TableHead>Done</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.repos.map((repo) => (
<TableRow key={repo.repoFullName}>
<TableCell className="font-mono text-foreground">{repo.repoFullName}</TableCell>
<TableCell>{repo.byStatus.queued}</TableCell>
<TableCell>{repo.byStatus.in_progress}</TableCell>
<TableCell>{repo.byStatus.done}</TableCell>
<TableCell>{repo.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="grid gap-2">
{[0, 1, 2].map((row) => (
<Skeleton key={`repo-row-${row}`} className="h-8 w-full" />
))}
</div>
</div>
);
}

export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | null }) {
const summary = result?.ok ? result.summary : null;
return (
<StateBoundary
isLoading={result === null}
isError={result !== null && !result.ok}
isEmpty={summary !== null && summary.total === 0}
loadingSkeleton={<PortfolioQueueSkeleton />}
// 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
// <p> 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 : (
<div className="grid gap-6">
<dl className="grid gap-4 sm:grid-cols-3">
{(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => (
<Card key={status}>
<CardContent className="p-4">
<dt className="text-token-2xs uppercase tracking-wider text-muted-foreground">
{STATUS_LABELS[status]}
</dt>
<dd className={`mt-1 text-token-3xl font-display font-semibold ${STATUS_TONE[status]}`}>
{summary.byStatus[status]}
</dd>
</CardContent>
</Card>
))}
</dl>
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Queued</TableHead>
<TableHead>In progress</TableHead>
<TableHead>Done</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.repos.map((repo) => (
<TableRow key={repo.repoFullName}>
<TableCell className="font-mono text-foreground">{repo.repoFullName}</TableCell>
<TableCell>{repo.byStatus.queued}</TableCell>
<TableCell>{repo.byStatus.in_progress}</TableCell>
<TableCell>{repo.byStatus.done}</TableCell>
<TableCell>{repo.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</StateBoundary>
);
}

/** Placeholder shaped like the queue-actions table's rows, for the same reason as the summary's. */
function QueueActionsSkeleton() {
return (
<div className="grid gap-2" data-testid="queue-actions-skeleton">
{[0, 1, 2].map((row) => (
<Skeleton key={`action-row-${row}`} className="h-8 w-full" />
))}
</div>
);
}
Expand All @@ -115,48 +156,54 @@ export function PortfolioQueueActionsSection({
Queue action failed: {actionResult.error}
</p>
) : null}
{result === null ? (
<p className="text-token-sm text-muted-foreground">Loading actionable queue items…</p>
) : !result.ok ? (
<p role="alert" className="text-token-sm text-[var(--danger)]">
Could not read actionable queue items: {result.error}
</p>
) : result.items.length === 0 ? (
<p className="text-token-sm text-muted-foreground">
No in-progress or completed items to release or requeue right now.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Identifier</TableHead>
<TableHead>Status</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.items.map((item) => (
<TableRow key={`${item.apiBaseUrl}:${item.repoFullName}:${item.identifier}`}>
<TableCell className="font-mono text-foreground">{item.repoFullName}</TableCell>
<TableCell className="font-mono">{item.identifier}</TableCell>
<TableCell>{STATUS_LABELS[item.status]}</TableCell>
<TableCell>
{item.status === "in_progress" ? (
<Button size="sm" variant="outline" disabled={pending} onClick={() => onRelease(item)}>
Release
</Button>
) : (
<Button size="sm" variant="outline" disabled={pending} onClick={() => onRequeue(item)}>
Requeue
</Button>
)}
</TableCell>
{/* 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 <p> tags it replaces. */}
<StateBoundary
isLoading={result === null}
isError={result !== null && !result.ok}
isEmpty={result !== null && result.ok && result.items.length === 0}
loadingSkeleton={<QueueActionsSkeleton />}
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 : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Identifier</TableHead>
<TableHead>Status</TableHead>
<TableHead>Action</TableHead>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableHeader>
<TableBody>
{result.items.map((item) => (
<TableRow key={`${item.apiBaseUrl}:${item.repoFullName}:${item.identifier}`}>
<TableCell className="font-mono text-foreground">{item.repoFullName}</TableCell>
<TableCell className="font-mono">{item.identifier}</TableCell>
<TableCell>{STATUS_LABELS[item.status]}</TableCell>
<TableCell>
{item.status === "in_progress" ? (
<Button size="sm" variant="outline" disabled={pending} onClick={() => onRelease(item)}>
Release
</Button>
) : (
<Button size="sm" variant="outline" disabled={pending} onClick={() => onRequeue(item)}>
Requeue
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</StateBoundary>
</section>
);
}
Expand Down
Loading