mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Merge pull request #657 from fabro-sh/feat/runs-list-size-tooltip
Show run size chips consistently across list, board, and detail
This commit is contained in:
commit
6bd6712a5f
6 changed files with 87 additions and 12 deletions
|
|
@ -115,8 +115,10 @@ export function RunTableRow({
|
|||
</td>
|
||||
)}
|
||||
{show("size") && (
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-center">
|
||||
{run.size != null && <SizeChip size={run.size} />}
|
||||
<td className="relative z-10 px-3 py-2.5 text-center whitespace-nowrap">
|
||||
{run.size != null && (
|
||||
<SizeChip size={run.size} totalUsdMicros={run.totalUsdMicros} />
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{show("changes") && (
|
||||
|
|
|
|||
58
apps/fabro-web/app/components/size-chip.test.tsx
Normal file
58
apps/fabro-web/app/components/size-chip.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import { setupReactTestEnv } from "../lib/test-utils";
|
||||
import { SizeChip } from "./size-chip";
|
||||
import { Tooltip } from "./ui";
|
||||
|
||||
let teardownReactTestEnv: (() => void) | undefined;
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
||||
function render(element: React.ReactElement): TestRenderer.ReactTestRenderer {
|
||||
let renderer: TestRenderer.ReactTestRenderer | undefined;
|
||||
act(() => {
|
||||
renderer = TestRenderer.create(element);
|
||||
});
|
||||
mountedRenderers.push(renderer!);
|
||||
return renderer!;
|
||||
}
|
||||
|
||||
function tooltipLabel(element: React.ReactElement): string {
|
||||
return render(element).root.findByType(Tooltip).props.label as string;
|
||||
}
|
||||
|
||||
describe("SizeChip", () => {
|
||||
beforeEach(() => {
|
||||
teardownReactTestEnv = setupReactTestEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of mountedRenderers.splice(0)) {
|
||||
renderer.unmount();
|
||||
}
|
||||
});
|
||||
teardownReactTestEnv?.();
|
||||
teardownReactTestEnv = undefined;
|
||||
});
|
||||
|
||||
test("renders the size letter", () => {
|
||||
expect(JSON.stringify(render(<SizeChip size="M" />).toJSON())).toContain("M");
|
||||
});
|
||||
|
||||
test("appends the cost to the tooltip", () => {
|
||||
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={12_340_000} />))
|
||||
.toBe("Size M · $12.34");
|
||||
});
|
||||
|
||||
test("omits the cost when the run has no billing yet", () => {
|
||||
expect(tooltipLabel(<SizeChip size="M" />)).toBe("Size M");
|
||||
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={null} />)).toBe("Size M");
|
||||
});
|
||||
|
||||
test("calls out the tiers that warrant attention", () => {
|
||||
expect(tooltipLabel(<SizeChip size="L" totalUsdMicros={150_000_000} />))
|
||||
.toBe("Size L (risky) · $150.00");
|
||||
expect(tooltipLabel(<SizeChip size="XL" />)).toBe("Size XL (unhealthy)");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { memo } from "react";
|
||||
import type { RunSize } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { formatUsdMicros } from "../lib/format";
|
||||
|
|
@ -11,7 +12,7 @@ const SIZE_TONE: Record<RunSize, { className: string; note: string | null }> = {
|
|||
XL: { className: "bg-coral/15 text-coral", note: "unhealthy" },
|
||||
};
|
||||
|
||||
export function SizeChip({
|
||||
export const SizeChip = memo(function SizeChip({
|
||||
size,
|
||||
totalUsdMicros,
|
||||
}: {
|
||||
|
|
@ -19,10 +20,10 @@ export function SizeChip({
|
|||
totalUsdMicros?: number | null;
|
||||
}) {
|
||||
const tone = SIZE_TONE[size];
|
||||
const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : "";
|
||||
const amount = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)}` : "";
|
||||
const tooltip = tone.note != null
|
||||
? `Size ${size} (${tone.note})${billed}`
|
||||
: `Size ${size}${billed}`;
|
||||
? `Size ${size} (${tone.note})${amount}`
|
||||
: `Size ${size}${amount}`;
|
||||
return (
|
||||
<Tooltip label={tooltip}>
|
||||
<span className={`rounded px-1.5 py-0.5 font-mono text-xs font-bold tabular-nums ${tone.className}`}>
|
||||
|
|
@ -30,4 +31,4 @@ export function SizeChip({
|
|||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,17 @@ describe("mapRunListItem", () => {
|
|||
|
||||
expect(mapRunListItem(summary).title).toBe("Untitled run");
|
||||
});
|
||||
|
||||
test("carries the billed total so the size chip can show it on hover", () => {
|
||||
expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000);
|
||||
});
|
||||
|
||||
test("leaves the billed total undefined for runs without terminal billing", () => {
|
||||
expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined();
|
||||
expect(
|
||||
mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRunToRunItem", () => {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface RunItem {
|
|||
createdBy: Principal;
|
||||
lastEventAt?: string;
|
||||
size?: RunSize;
|
||||
totalUsdMicros?: number;
|
||||
}
|
||||
|
||||
export const columnStatuses = [
|
||||
|
|
@ -119,6 +120,7 @@ export function mapRunListItem(item: Run): RunItem {
|
|||
additions: item.diff?.additions,
|
||||
deletions: item.diff?.deletions,
|
||||
size: item.size,
|
||||
totalUsdMicros: item.billing?.total_usd_micros ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiSt
|
|||
import type { CiStatus, CheckRun, CheckStatus, RunItem } from "../data/runs";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { SizeChip } from "../components/size-chip";
|
||||
import {
|
||||
summarizeBatchLifecycleAction,
|
||||
} from "../components/runs-list/batch-lifecycle";
|
||||
|
|
@ -345,7 +346,7 @@ function PrCard({
|
|||
|
||||
// All inline footer metadata on PrCard belongs in this one row. Adding a new
|
||||
// piece as a sibling `<div>` below the card body recreates a recurring bug
|
||||
// where stats stack onto separate lines instead of sitting next to elapsed/actions.
|
||||
// where stats stack onto separate lines instead of sitting next to size/actions.
|
||||
function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
|
||||
const hasActions = actions != null && actions.length > 0;
|
||||
const hasStats =
|
||||
|
|
@ -354,7 +355,7 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
|
|||
(pr.additions != null && pr.additions !== 0) ||
|
||||
(pr.deletions != null && pr.deletions !== 0);
|
||||
|
||||
if (!hasStats && !hasActions && pr.elapsed == null) return null;
|
||||
if (!hasStats && !hasActions && pr.size == null) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
|
||||
|
|
@ -416,9 +417,9 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{pr.elapsed != null && (
|
||||
<span className={`text-fg-muted ${hasActions ? "" : "ml-auto"}`}>
|
||||
{pr.elapsed}
|
||||
{pr.size != null && (
|
||||
<span className={hasActions ? "inline-flex" : "ml-auto inline-flex"}>
|
||||
<SizeChip size={pr.size} totalUsdMicros={pr.totalUsdMicros} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue