FN-6052: move hold-task promote action into task cards

Move hold-column promotion into the shared task card action row.

- pass hold-column promote handlers and loading state through Column into TaskCard
- render a card-level Promote button with shared action styling and loading behavior
- remove the separate hold-column wrapper/button styling in Lane.css
- update Column and Lane tests to target the card-level promote control and add TaskCard coverage

Files changed:
 packages/dashboard/app/components/Column.tsx       | 64 ++++++++------------
 packages/dashboard/app/components/Lane.css         |  9 ---
 packages/dashboard/app/components/TaskCard.css     | 37 ++++++++++++
 packages/dashboard/app/components/TaskCard.tsx     | 32 +++++++++-
 .../app/components/__tests__/Column.test.tsx       | 16 +++--
 .../app/components/__tests__/Lane.test.tsx         | 16 +++--
 .../app/components/__tests__/TaskCard.test.tsx     | 70 ++++++++++++++++++++++
 7 files changed, 187 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-6052

Fusion-Task-Lineage: b2f98e06-cd3f-4f49-b540-68b56753c1fb
This commit is contained in:
gsxdsm
2026-06-09 01:29:47 -07:00
parent 61cbc5c3db
commit 59c613cf38
7 changed files with 187 additions and 57 deletions

View File

@@ -732,44 +732,32 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
) : (
<>
{visibleTasks.map((task) => (
<div key={task.id} className={isHoldColumn ? "column-hold-card" : undefined}>
<TaskCard
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
{isHoldColumn && onPromote && (
<button
type="button"
className="btn btn-secondary btn-sm column-promote-btn"
onClick={() => void handlePromote(task.id)}
disabled={promotingIds.has(task.id)}
data-testid={`promote-${task.id}`}
>
{promotingIds.has(task.id)
? t("column.promoting", "Promoting…")
: t("column.promote", "Promote")}
</button>
)}
</div>
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
onPromote={isHoldColumn && onPromote ? handlePromote : undefined}
isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (
<button

View File

@@ -138,15 +138,6 @@
border-radius: var(--radius-sm, 6px);
}
.column-promote-btn {
margin-top: 4px;
width: 100%;
}
.column-hold-card {
display: flex;
flex-direction: column;
}
@media (max-width: 1024px) {
.board.board-lanes {

View File

@@ -794,6 +794,20 @@
flex-shrink: 0;
}
.card-promote-action {
display: inline-flex;
align-items: center;
flex-shrink: 0;
height: var(--card-chip-height);
min-height: var(--card-chip-height);
white-space: nowrap;
}
.card-promote-action svg {
display: block;
flex-shrink: 0;
}
.card-github-tracking-chip {
border-color: color-mix(in srgb, var(--text-muted) 30%, transparent);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
@@ -1279,6 +1293,29 @@
outline-offset: 1px;
}
.card-promote-action.card-send-back-btn {
border: var(--btn-border-width) solid color-mix(in srgb, var(--text-muted) 30%, transparent);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
color: var(--text);
}
.card-promote-action.card-send-back-btn:hover:not(:disabled) {
background: var(--card-hover);
color: var(--text);
}
.card-promote-action.card-send-back-btn:focus-visible {
box-shadow: var(--focus-ring-strong);
outline: none;
}
.card-promote-action.card-send-back-btn:disabled {
cursor: not-allowed;
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 24%, transparent);
}
.card-send-back-menu {
position: absolute;
top: calc(100% + var(--space-xs));

View File

@@ -2,7 +2,7 @@ import "./TaskCard.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
@@ -393,6 +393,10 @@ interface TaskCardProps {
onOpenMission?: (missionId: string) => void;
/** Called when user moves a task to a different column from the card. */
onMoveTask?: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
/** Called when user promotes a held task out of a hold column. */
onPromote?: (taskId: string) => Promise<void>;
/** True while this task's promote action is in flight. */
isPromoting?: boolean;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
@@ -586,6 +590,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
previous.onOpenMission === next.onOpenMission &&
previous.onMoveTask === next.onMoveTask &&
previous.onPromote === next.onPromote &&
previous.isPromoting === next.isPromoting &&
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
previous.disableDrag === next.disableDrag &&
previous.fanout?.totalCount === next.fanout?.totalCount &&
@@ -682,6 +688,8 @@ function TaskCardComponent({
taskStuckTimeoutMs,
onOpenMission,
onMoveTask,
onPromote,
isPromoting = false,
lastFetchTimeMs,
workflowStepNameLookup,
disableDrag,
@@ -1281,7 +1289,7 @@ function TaskCardComponent({
|| Boolean(task.blockedBy)
|| Boolean(task.overlapBlockedBy)
|| Boolean(fanout && fanout.totalCount > 0);
const shouldRenderActionRow = showCreatePrQuickAction || (showInReviewMoveControl && !metaRowVisible);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || (showInReviewMoveControl && !metaRowVisible);
const renderInReviewMoveControl = () => (
<div className="card-send-back" ref={sendBackRef}>
@@ -1671,6 +1679,12 @@ function TaskCardComponent({
}
}, [addToast, confirm, onMoveTask, task.id, task.steps]);
const handlePromoteClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onPromote || isPromoting) return;
void onPromote(task.id);
}, [isPromoting, onPromote, task.id]);
const handleRetryTask = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onRetryTask || isRetrying) return;
@@ -2421,6 +2435,20 @@ function TaskCardComponent({
{t("tasks.createPr", "Create PR")}
</button>
)}
{onPromote && (
<button
type="button"
className="card-promote-action card-send-back-btn"
data-testid={`card-promote-${task.id}`}
title={t("tasks.promoteTask", "Promote task")}
aria-label={t("tasks.promoteTask", "Promote task")}
disabled={isPromoting}
onClick={handlePromoteClick}
>
<ArrowUpRight size={12} />
{isPromoting ? t("tasks.promoting", "Promoting…") : t("tasks.promote", "Promote")}
</button>
)}
{showInReviewMoveControl && !metaRowVisible && renderInReviewMoveControl()}
</div>
)}

View File

@@ -9,9 +9,17 @@ import type { Task, Column as ColumnType } from "@fusion/core";
const taskCardRenderSpy = vi.fn();
vi.mock("../TaskCard", () => ({
TaskCard: React.memo(({ task, workflowStepNameLookup }: { task: Task; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
TaskCard: React.memo(({ task, workflowStepNameLookup, onPromote, isPromoting }: { task: Task; workflowStepNameLookup?: ReadonlyMap<string, string>; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => {
taskCardRenderSpy(task.id);
return <div data-testid={`task-${task.id}`} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)} />;
return (
<div data-testid={`task-${task.id}`} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
{onPromote && (
<button type="button" data-testid={`card-promote-${task.id}`} disabled={isPromoting} onClick={() => void onPromote(task.id)}>
{isPromoting ? "Promoting…" : "Promote"}
</button>
)}
</div>
);
}),
}));
vi.mock("../WorktreeGroup", () => ({
@@ -192,7 +200,7 @@ describe("Column workflow mode (U9)", () => {
tasks={[{ ...makeTask("FN-7"), column: "hold-col" as ColumnType }]}
/>,
);
expect(screen.getByTestId("promote-FN-7")).toBeDefined();
expect(screen.getByTestId("card-promote-FN-7")).toBeDefined();
});
it("#1410: clears the inline capacity banner when the task list changes via SSE", async () => {
@@ -213,7 +221,7 @@ describe("Column workflow mode (U9)", () => {
);
// Trigger a capacity-exhausted promote → inline banner appears.
fireEvent.click(screen.getByTestId("promote-FN-7"));
fireEvent.click(screen.getByTestId("card-promote-FN-7"));
await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined());
expect(screen.getByTestId("column-inline-feedback").textContent).toContain("capacity");

View File

@@ -8,7 +8,15 @@ import type { BoardWorkflowDefinition } from "../../api";
// Keep the test focused on Lane + Column (real) — mock the leaf TaskCard and
// the confirm hook, matching the Column test harness.
vi.mock("../TaskCard", () => ({
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`} data-id={task.id} />,
TaskCard: ({ task, onPromote, isPromoting }: { task: Task; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => (
<div data-testid={`task-${task.id}`} data-id={task.id}>
{onPromote && (
<button type="button" data-testid={`card-promote-${task.id}`} disabled={isPromoting} onClick={() => void onPromote(task.id)}>
{isPromoting ? "Promoting…" : "Promote"}
</button>
)}
</div>
),
}));
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div data-testid="worktree-group" /> }));
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> }));
@@ -122,7 +130,7 @@ describe("Lane", () => {
it("shows a Promote button on hold-column cards and calls onPromote", async () => {
const props = baseProps();
render(<Lane {...props} tasks={[mkTask({ id: "FN-7", column: "todo" })]} />);
const promoteBtn = screen.getByTestId("promote-FN-7");
const promoteBtn = screen.getByTestId("card-promote-FN-7");
expect(promoteBtn).toBeDefined();
fireEvent.click(promoteBtn);
await waitFor(() => expect(props.onPromote).toHaveBeenCalledWith("FN-7"));
@@ -134,12 +142,12 @@ describe("Lane", () => {
details: { code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true },
});
render(<Lane {...props} tasks={[mkTask({ id: "FN-8", column: "todo" })]} />);
fireEvent.click(screen.getByTestId("promote-FN-8"));
fireEvent.click(screen.getByTestId("card-promote-FN-8"));
await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined());
// No toast was used for the inline capacity feedback.
expect(props.addToast).not.toHaveBeenCalled();
// Button re-enabled after the call resolves.
await waitFor(() => expect((screen.getByTestId("promote-FN-8") as HTMLButtonElement).disabled).toBe(false));
await waitFor(() => expect((screen.getByTestId("card-promote-FN-8") as HTMLButtonElement).disabled).toBe(false));
});
it("prevents the drop (no-move) when canDropTask returns a rejection key", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("lucide-react", () => ({
RotateCw: () => null,
Zap: () => <svg data-testid="icon-zap" />,
AlertTriangle: () => null,
ArrowUpRight: () => null,
}));
vi.mock("../ProviderIcon", () => ({
@@ -4336,6 +4337,75 @@ describe("TaskCard mission badge", () => {
});
});
it("renders a promote action when onPromote is provided", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
render(
<TaskCard
task={makeTask({ id: "FN-777", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
/>,
);
const promoteButton = screen.getByTestId("card-promote-FN-777");
expect(promoteButton).toBeDefined();
expect(promoteButton.textContent).toContain("Promote");
});
it("calls onPromote without opening the card when promote is clicked", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({ id: "FN-778", column: "todo" })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPromote={onPromote}
/>,
);
fireEvent.click(screen.getByTestId("card-promote-FN-778"));
expect(onPromote).toHaveBeenCalledWith("FN-778");
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("disables the promote action and shows loading copy while promoting", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
render(
<TaskCard
task={makeTask({ id: "FN-779", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
isPromoting
/>,
);
const promoteButton = screen.getByTestId("card-promote-FN-779") as HTMLButtonElement;
expect(promoteButton.disabled).toBe(true);
expect(promoteButton.textContent).toContain("Promoting…");
fireEvent.click(promoteButton);
expect(onPromote).not.toHaveBeenCalled();
});
it("does not render a promote action when onPromote is omitted", () => {
render(
<TaskCard
task={makeTask({ id: "FN-780", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-promote-FN-780")).toBeNull();
});
it("shows mission title in title attribute", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-TITLE",