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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user