feat(FN-4760): complete Step 4 — add PR checks list UI
Fusion-Task-Id: FN-4760 Fusion-Task-Lineage: 71db4a53-ee59-49f0-a1f7-232954b86660
This commit is contained in:
committed by
gsxdsm
parent
76c5142f4f
commit
a58c5df697
116
packages/dashboard/app/components/PrChecksList.css
Normal file
116
packages/dashboard/app/components/PrChecksList.css
Normal file
@@ -0,0 +1,116 @@
|
||||
.pr-checks {
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.pr-checks__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pr-checks__summary {
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pr-checks__header-actions {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__updated {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-checks__error {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-error);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pr-checks__list {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__item {
|
||||
align-items: center;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__icon {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.pr-checks__name-wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pr-checks__required,
|
||||
.pr-checks__duration {
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-checks__details-link {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
gap: var(--space-xs);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pr-checks__details-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pr-checks__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pr-checks__item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pr-checks__item .btn,
|
||||
.pr-checks__details-link {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
117
packages/dashboard/app/components/PrChecksList.tsx
Normal file
117
packages/dashboard/app/components/PrChecksList.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useMemo } from "react";
|
||||
import { CheckCircle2, ExternalLink, Loader2, MinusCircle, RefreshCw, XCircle } from "lucide-react";
|
||||
import type { PrCheckStatus } from "../api";
|
||||
import "./PrChecksList.css";
|
||||
|
||||
interface PrChecksListProps {
|
||||
checks: PrCheckStatus[];
|
||||
rollup: string;
|
||||
lastCheckedAt?: string;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const FAILING_STATES = new Set(["failure", "cancelled", "timed_out", "action_required", "startup_failure"]);
|
||||
const PENDING_STATES = new Set(["pending", "stale"]);
|
||||
|
||||
function getCheckPriority(check: PrCheckStatus): number {
|
||||
if (FAILING_STATES.has(check.state)) return check.required ? 0 : 1;
|
||||
if (PENDING_STATES.has(check.state)) return 2;
|
||||
if (check.state === "success" || check.state === "neutral" || check.state === "skipped") return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt?: string, completedAt?: string): string | null {
|
||||
if (!startedAt || !completedAt) return null;
|
||||
const start = Date.parse(startedAt);
|
||||
const end = Date.parse(completedAt);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
|
||||
const seconds = Math.floor((end - start) / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const rem = seconds % 60;
|
||||
return `${String(mins).padStart(2, "0")}:${String(rem).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function relativeTime(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
const ts = Date.parse(value);
|
||||
if (!Number.isFinite(ts)) return null;
|
||||
const delta = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
return `updated ${delta}s ago`;
|
||||
}
|
||||
|
||||
export function PrChecksList({ checks, rollup: _rollup, lastCheckedAt, loading, error, onRefresh }: PrChecksListProps) {
|
||||
const sortedChecks = useMemo(() => [...checks].sort((a, b) => {
|
||||
const byPriority = getCheckPriority(a) - getCheckPriority(b);
|
||||
if (byPriority !== 0) return byPriority;
|
||||
return a.name.localeCompare(b.name);
|
||||
}), [checks]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return checks.reduce(
|
||||
(acc, check) => {
|
||||
if (FAILING_STATES.has(check.state)) acc.failing += 1;
|
||||
else if (PENDING_STATES.has(check.state)) acc.pending += 1;
|
||||
else acc.passing += 1;
|
||||
return acc;
|
||||
},
|
||||
{ passing: 0, failing: 0, pending: 0 },
|
||||
);
|
||||
}, [checks]);
|
||||
|
||||
return (
|
||||
<section className="pr-checks" aria-live="polite">
|
||||
<div className="pr-checks__header">
|
||||
<div className="pr-checks__summary">{summary.passing} passing, {summary.failing} failing, {summary.pending} pending</div>
|
||||
<div className="pr-checks__header-actions">
|
||||
{lastCheckedAt ? <span className="pr-checks__updated">{relativeTime(lastCheckedAt)}</span> : null}
|
||||
<button className="btn btn-sm btn-icon" aria-label="Refresh checks" onClick={onRefresh}>
|
||||
{loading ? <Loader2 className="spin" /> : <RefreshCw />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="pr-checks__error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button className="btn btn-sm" onClick={onRefresh}>Retry</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedChecks.length === 0 ? (
|
||||
<div className="pr-checks__empty">No checks reported yet</div>
|
||||
) : (
|
||||
<div className="pr-checks__list" role="list">
|
||||
{sortedChecks.map((check) => {
|
||||
const failing = FAILING_STATES.has(check.state);
|
||||
const pending = PENDING_STATES.has(check.state);
|
||||
const duration = formatDuration(check.startedAt, check.completedAt);
|
||||
return (
|
||||
<div
|
||||
key={`${check.name}-${check.state}-${check.required ? "required" : "optional"}`}
|
||||
className="pr-checks__item"
|
||||
role="listitem"
|
||||
aria-label={`${check.state} check ${check.name}`}
|
||||
>
|
||||
<span className="pr-checks__icon" aria-hidden="true">
|
||||
{failing ? <XCircle /> : pending ? <Loader2 className="spin" /> : check.state === "success" ? <CheckCircle2 /> : <MinusCircle />}
|
||||
</span>
|
||||
<div className="pr-checks__name-wrap">
|
||||
<span className="pr-checks__name">{check.name}</span>
|
||||
{check.required ? <span className="pr-checks__required">Required</span> : null}
|
||||
{duration ? <span className="pr-checks__duration">{duration}</span> : null}
|
||||
</div>
|
||||
{check.detailsUrl ? (
|
||||
<a href={check.detailsUrl} target="_blank" rel="noreferrer noopener" className={failing ? "btn btn-sm" : "pr-checks__details-link"}>
|
||||
View details <ExternalLink />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PrChecksList } from "../PrChecksList";
|
||||
|
||||
describe("PrChecksList", () => {
|
||||
it("orders failing checks first", () => {
|
||||
render(
|
||||
<PrChecksList
|
||||
checks={[
|
||||
{ name: "pass", required: true, state: "success" },
|
||||
{ name: "pending", required: true, state: "pending" },
|
||||
{ name: "fail", required: true, state: "failure" },
|
||||
]}
|
||||
rollup="failure"
|
||||
loading={false}
|
||||
onRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
expect(items[0]).toHaveTextContent("fail");
|
||||
});
|
||||
|
||||
it("renders summary and details links", () => {
|
||||
render(
|
||||
<PrChecksList
|
||||
checks={[{ name: "fail", required: true, state: "failure", detailsUrl: "https://example.com/details" }]}
|
||||
rollup="failure"
|
||||
loading={false}
|
||||
onRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0 passing, 1 failing, 0 pending")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /View details/i })).toHaveAttribute("href", "https://example.com/details");
|
||||
});
|
||||
|
||||
it("shows empty state", () => {
|
||||
render(<PrChecksList checks={[]} rollup="unknown" loading={false} onRefresh={() => {}} />);
|
||||
expect(screen.getByText("No checks reported yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state and retry", () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<PrChecksList checks={[]} rollup="unknown" loading={false} error="nope" onRefresh={onRefresh} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Retry/i }));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh button triggers onRefresh", () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<PrChecksList checks={[]} rollup="unknown" loading={false} onRefresh={onRefresh} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh checks" }));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user