FN-7177: color PR badges by GitHub status
Align dashboard PR badges with live GitHub PR state colors. - Add a shared PR badge modifier helper for open, draft, merged, closed, and conflict states.\n- Apply the helper to single and multi-PR task card badges so live payload changes repaint correctly.\n- Add token-backed merged/conflict styling, regression coverage, and a patch changeset. Files changed:\n .changeset/fn-7177-pr-badge-status-color.md | 7 ++++\n packages/dashboard/app/components/GitHubBadge.tsx | 8 +++--\n packages/dashboard/app/components/TaskCard.tsx | 9 +++--\n .../app/components/__tests__/GitHubBadge.test.tsx | 1 +\n .../app/components/__tests__/TaskCard.test.tsx | 41 +++++++++++++++++++---\n packages/dashboard/app/styles.css | 15 ++++++--\n .../app/utils/__tests__/prBadgeClass.test.ts | 22 ++++++++++++\n packages/dashboard/app/utils/prBadgeClass.ts | 28 +++++++++++++++\n 8 files changed, 119 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-7177 Fusion-Task-Lineage: 23954f1b-d184-49a6-9635-7d9c1d0aea11 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7177-pr-badge-status-color.md
Normal file
7
.changeset/fn-7177-pr-badge-status-color.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: PR badge color now follows GitHub status: green/gray/purple/red plus a conflict color.
|
||||
category: fix
|
||||
dev: Adds getPrBadgeModifierClass and a token-backed --color-merged badge modifier.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GitPullRequest, CircleDot, CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import type { IssueInfo, PrInfo } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||
|
||||
interface GitHubBadgeProps {
|
||||
prInfo?: PrInfo;
|
||||
@@ -17,19 +18,20 @@ function getIssueModifierClass(state: string, stateReason?: string): string {
|
||||
}
|
||||
|
||||
export function GitHubBadge({ prInfo, issueInfo, onIssueRefresh: _onIssueRefresh }: GitHubBadgeProps) {
|
||||
const prState = prInfo?.isDraft || prInfo?.status === "draft" ? "draft" : prInfo?.status;
|
||||
const prIsDraft = prInfo?.status === "draft" || (prInfo?.status === "open" && (prInfo.draft ?? prInfo.isDraft));
|
||||
const prModifierClass = prInfo ? getPrBadgeModifierClass(prInfo) : null;
|
||||
const checkRollup = prInfo?.checkRollup;
|
||||
const checkClass = checkRollup && checkRollup !== "none" ? `card-github-badge__check card-github-badge__check--${checkRollup}` : null;
|
||||
const checkTitle = checkRollup && checkRollup !== "none" ? ` — checks: ${checkRollup}` : "";
|
||||
const prTitle = prInfo
|
||||
? `PR #${prInfo.number}${prState === "draft" ? " (draft)" : ""}: ${prInfo.title}${checkTitle}`
|
||||
? `PR #${prInfo.number}${prIsDraft ? " (draft)" : ""}: ${prInfo.title}${checkTitle}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{prInfo && (
|
||||
<a
|
||||
className={`card-github-badge card-github-badge--${prState}`}
|
||||
className={`card-github-badge ${prModifierClass}`}
|
||||
title={prTitle}
|
||||
href={prInfo.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR
|
||||
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
||||
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
@@ -678,7 +679,11 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
((previousTask.prInfos?.length ?? 0) === (nextTask.prInfos?.length ?? 0)) &&
|
||||
(previousTask.prInfos ?? []).every((pr, index) => {
|
||||
const nextPr = nextTask.prInfos?.[index];
|
||||
return nextPr?.number === pr.number && nextPr?.status === pr.status;
|
||||
/*
|
||||
FNXC:PRBadgeStatusColor 2026-06-27-12:00:
|
||||
Multi-PR badge rendering depends on the same live PR fields as getPrBadgeModifierClass, so memoization must compare the full badge payload instead of only number/status to repaint draft and conflict color changes.
|
||||
*/
|
||||
return areTaskBadgeInfosEqual(pr, nextPr);
|
||||
}) &&
|
||||
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo)
|
||||
);
|
||||
@@ -1959,7 +1964,7 @@ function TaskCardComponent({
|
||||
{(livePrInfo || liveIssueInfo) && (
|
||||
<>
|
||||
{livePrInfo && (task.prInfos?.length ?? 0) >= 2 ? (
|
||||
<a className={`card-github-badge card-github-badge--${livePrInfo.status}`} title={t("tasks.prBadgeTitle", "PR #{{number}}: {{title}}", { number: livePrInfo.number, title: livePrInfo.title })} href={livePrInfo.url} target="_blank" rel="noopener noreferrer">
|
||||
<a className={`card-github-badge ${getPrBadgeModifierClass(livePrInfo)}`} title={t("tasks.prBadgeTitle", "PR #{{number}}: {{title}}", { number: livePrInfo.number, title: livePrInfo.title })} href={livePrInfo.url} target="_blank" rel="noopener noreferrer">
|
||||
<GitPullRequest size={10} />
|
||||
<span>{`${task.prInfos?.length}x #${livePrInfo.number}`}</span>
|
||||
</a>
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("GitHubBadge PR state + rollup", () => {
|
||||
it.each([
|
||||
{ name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" },
|
||||
{ name: "draft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" },
|
||||
{ name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
|
||||
{ name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
|
||||
{ name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" },
|
||||
])("applies $name modifier class", ({ prInfo, expectedClass }) => {
|
||||
|
||||
@@ -634,7 +634,10 @@ describe("TaskCard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders Nx PR badge label when multiple PRs are linked", () => {
|
||||
it.each([
|
||||
{ name: "merged", primaryPr: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
|
||||
{ name: "conflicting", primaryPr: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
|
||||
])("renders Nx PR badge label and resolver class for $name primary PR", ({ primaryPr, expectedClass }) => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
@@ -647,6 +650,7 @@ describe("TaskCard", () => {
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
...primaryPr,
|
||||
} as any,
|
||||
prInfos: [
|
||||
{
|
||||
@@ -657,6 +661,7 @@ describe("TaskCard", () => {
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
...primaryPr,
|
||||
},
|
||||
{
|
||||
url: "https://github.com/owner/repo/pull/99",
|
||||
@@ -674,7 +679,9 @@ describe("TaskCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /2x #42/i })).toBeDefined();
|
||||
const badge = screen.getByRole("link", { name: /2x #42/i });
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge).toHaveClass(expectedClass);
|
||||
});
|
||||
|
||||
it("clicking PR badge link does not open the task detail modal", () => {
|
||||
@@ -1946,7 +1953,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
// WS-003 has no result → name falls back to the humanized workflow id; all others resolve from result.workflowStepName.
|
||||
// WS-003 has no result → name falls back to the display-normalized id; all others resolve from result.workflowStepName.
|
||||
expect(stepNames).toEqual([
|
||||
"Step 0",
|
||||
"Step 1",
|
||||
@@ -2032,7 +2039,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
// Blank result name → fall back to the humanized id; WS-003 (no result) → humanized id.
|
||||
// Blank result name → display-normalized id; WS-003 (no result) → display-normalized id.
|
||||
expect(stepNames).toEqual(["WS 002", "WS 003"]);
|
||||
});
|
||||
|
||||
@@ -4548,6 +4555,32 @@ describe("TaskCard memo comparator provenance behavior", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "mergeable", patch: { mergeable: "conflicting" } },
|
||||
{ name: "draft", patch: { draft: true } },
|
||||
{ name: "isDraft", patch: { isDraft: true } },
|
||||
])("returns false when multi-PR badge $name color input changes", ({ patch }) => {
|
||||
const basePr = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
const secondPr = { ...basePr, url: "https://github.com/owner/repo/pull/99", number: 99, title: "PR 2" };
|
||||
const previousTask = makeTask({ prInfos: [basePr, secondPr] as any });
|
||||
const nextTask = makeTask({ prInfos: [{ ...basePr, ...patch }, secondPr] as any });
|
||||
|
||||
expect(
|
||||
__test_areTaskCardPropsEqual(
|
||||
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
|
||||
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips customFields JSON.stringify when both cardFieldDefs are absent", () => {
|
||||
// Without cardFieldDefs present, two tasks with different customFields should
|
||||
// compare equal (JSON.stringify is skipped — guard path).
|
||||
|
||||
@@ -299,6 +299,7 @@ svg.spinning {
|
||||
--in-progress-rgb: 0, 229, 255;
|
||||
--in-review: #3fb950;
|
||||
--done: #8b949e;
|
||||
--color-merged: #a371f7;
|
||||
|
||||
--color-success: #3fb950;
|
||||
--color-error: #f85149;
|
||||
@@ -548,6 +549,7 @@ svg.spinning {
|
||||
--in-progress-rgb: 0, 188, 212;
|
||||
--in-review: #1a7f37;
|
||||
--done: #6e7781;
|
||||
--color-merged: #8250df;
|
||||
--color-success: #1a7f37;
|
||||
--color-error: #cf222e;
|
||||
--color-muted: #6e7781;
|
||||
@@ -2171,7 +2173,10 @@ input[type="range"]:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* GitHub badge theme-aware colors */
|
||||
/*
|
||||
FNXC:PRBadgeStatusColor 2026-06-27-00:00:
|
||||
PR badge modifiers map live GitHub status to theme tokens in both dark and light roots: open=green, draft=gray, merged=purple, closed=red, and conflicting/blocked=red-caution via --color-error. The existing mobile .card-github-badge base rule controls badge sizing, so modifiers need no breakpoint-specific overrides.
|
||||
*/
|
||||
.card-github-badge--open {
|
||||
background: color-mix(in srgb, var(--in-review) 20%, transparent);
|
||||
color: var(--in-review);
|
||||
@@ -2181,8 +2186,12 @@ input[type="range"]:focus-visible {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.card-github-badge--merged {
|
||||
background: color-mix(in srgb, var(--in-progress) 20%, transparent);
|
||||
color: var(--in-progress);
|
||||
background: color-mix(in srgb, var(--color-merged) 20%, transparent);
|
||||
color: var(--color-merged);
|
||||
}
|
||||
.card-github-badge--conflicting {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.card-github-badge--draft {
|
||||
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
|
||||
22
packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts
Normal file
22
packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPrBadgeModifierClass } from "../prBadgeClass";
|
||||
|
||||
describe("getPrBadgeModifierClass", () => {
|
||||
it.each([
|
||||
{ name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" },
|
||||
{ name: "open isDraft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" },
|
||||
{ name: "open draft", prInfo: { status: "open" as const, draft: true }, expectedClass: "card-github-badge--draft" },
|
||||
{ name: "draft status", prInfo: { status: "draft" as const }, expectedClass: "card-github-badge--draft" },
|
||||
{ name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
|
||||
{ name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" },
|
||||
{ name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
|
||||
{ name: "blocked", prInfo: { status: "open" as const, mergeable: "blocked" as const }, expectedClass: "card-github-badge--conflicting" },
|
||||
])("maps $name PR state to $expectedClass", ({ prInfo, expectedClass }) => {
|
||||
expect(getPrBadgeModifierClass(prInfo)).toBe(expectedClass);
|
||||
});
|
||||
|
||||
it("prioritizes open conflicts over draft and open status colors", () => {
|
||||
expect(getPrBadgeModifierClass({ status: "open", isDraft: true, mergeable: "conflicting" })).toBe("card-github-badge--conflicting");
|
||||
expect(getPrBadgeModifierClass({ status: "open", draft: true, mergeable: "blocked" })).toBe("card-github-badge--conflicting");
|
||||
});
|
||||
});
|
||||
28
packages/dashboard/app/utils/prBadgeClass.ts
Normal file
28
packages/dashboard/app/utils/prBadgeClass.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PrInfo } from "@fusion/core";
|
||||
|
||||
type PrBadgeClassInput = Pick<PrInfo, "status" | "isDraft" | "draft" | "mergeable">;
|
||||
|
||||
type PrBadgeModifierClass =
|
||||
| "card-github-badge--conflicting"
|
||||
| "card-github-badge--draft"
|
||||
| "card-github-badge--open"
|
||||
| "card-github-badge--merged"
|
||||
| "card-github-badge--closed";
|
||||
|
||||
/**
|
||||
* FNXC:PRBadgeStatusColor 2026-06-27-00:00:
|
||||
* PR badges must use one status-color source of truth across TaskCard's multi-PR link and GitHubBadge's single-PR link. Match GitHub's live PR conventions: open=green, draft=gray, merged=purple, closed=red, and open conflicts/blocks=red-caution while checks stay in their separate sub-badge.
|
||||
*/
|
||||
export function getPrBadgeModifierClass(prInfo: PrBadgeClassInput): PrBadgeModifierClass {
|
||||
if (prInfo.status === "open" && (prInfo.mergeable === "conflicting" || prInfo.mergeable === "blocked")) {
|
||||
return "card-github-badge--conflicting";
|
||||
}
|
||||
|
||||
if (prInfo.status === "draft" || (prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft))) {
|
||||
return "card-github-badge--draft";
|
||||
}
|
||||
|
||||
if (prInfo.status === "merged") return "card-github-badge--merged";
|
||||
if (prInfo.status === "closed") return "card-github-badge--closed";
|
||||
return "card-github-badge--open";
|
||||
}
|
||||
Reference in New Issue
Block a user