FN-8149: consolidate task-card move actions in three-dot menu

Consolidate task-card movement controls into the shared three-dot action menu.

- Remove standalone Send back and Move dropdown controls and their styles.
- Preserve all in-review move targets, including Triage and Done (no merge), in the action menu.
- Update task-card desktop and mobile coverage for the consolidated menu.

Files changed:
 packages/dashboard/app/components/TaskCard.css     |  63 +--------
 packages/dashboard/app/components/TaskCard.tsx     | 150 +++------------------
 .../__tests__/TaskCard.badge-wrap.test.tsx         |  39 ++----
 .../app/components/__tests__/TaskCard.test.tsx     | 134 +++++-------------
 .../app/components/__tests__/board-mobile.test.tsx |   6 +-
 5 files changed, 69 insertions(+), 323 deletions(-)

Fusion-Task-Id: FN-8149

Fusion-Task-Lineage: 650e20f6-c660-4c29-aa65-eb156bed7a0e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 20:04:22 -07:00
parent aaa95305cb
commit e10ba2826f
5 changed files with 72 additions and 326 deletions

View File

@@ -1686,17 +1686,9 @@ too so future divergence doesn't have to rediscover the shared selector group.
outline-offset: 1px;
}
/* Send Back button and dropdown */
.card-send-back {
position: relative;
}
/*
FNXC:TaskCardLayout 2026-07-12-00:00:
FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge to share one optical vertical center inside .card-header-actions. Normalize their line boxes while preserving FN-7889's cluster↔id transform nudge, FN-7862's flex-start header anchor, FN-7837's middle-badge wrap/size-chip-not-orphaned contract, and FN-4351's mobile no-min-height rule.
FNXC:TaskCardLayout 2026-07-13-01:03:
The Actions/Send-back text+chevron chip still reads a little low against the ⋯ icon and single-letter size badge even after the locked chip-height row. Use a quarter-space-xs optical raise (and box-sizing so the 1px border does not inflate the flex cross size) so the three controls share one centerline.
FNXC:TaskCardLayout 2026-07-16-00:00 (FN-8149):
The three-dot menu is the sole card move/action entry point. Keep this shared button treatment because Start and Promote still use .card-send-back-btn; their hover-reveal and optical alignment must remain intact after menu consolidation.
*/
.card-send-back-btn {
display: inline-flex;
@@ -1762,49 +1754,6 @@ The Actions/Send-back text+chevron chip still reads a little low against the ⋯
border-color: color-mix(in srgb, var(--text-muted) 24%, transparent);
}
.card-send-back-menu {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 50;
min-width: 100px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.card-action-row .card-send-back {
margin-left: auto;
}
.card-meta .card-send-back {
margin-left: auto;
}
.card-send-back-menu-item {
display: block;
width: 100%;
padding: calc(var(--space-sm) - (var(--space-xs) / 4)) var(--space-md);
font-size: 0.75rem;
font-weight: 400;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--transition-instant);
}
.card-send-back-menu-item:hover {
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
}
.card-send-back-menu-item:focus {
outline: none;
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
}
/* Loading state during save */
.card-edit-loading {
@@ -1896,12 +1845,6 @@ The Actions/Send-back text+chevron chip still reads a little low against the ⋯
line-height: 1;
}
/* Keep Actions/Send-back on the locked row centerline (no extra vertical box growth). */
.card-send-back {
display: inline-flex;
align-items: center;
height: 100%;
}
/* FN-4351/FN-3965: keep secondary actions visible on touch, but compact per WCAG 2.5.8 because the card tap surface is the primary target for opening task detail. */
.card-archive-btn,
@@ -1915,7 +1858,7 @@ The Actions/Send-back text+chevron chip still reads a little low against the ⋯
line-height: 1;
margin-top: 0;
margin-bottom: 0;
/* Keep the base optical raise so Actions does not sit below ⋯ / size on the locked mobile row. */
/* Keep the base optical raise for Start/Promote while they share this compact mobile treatment. */
transform: translateY(calc(var(--space-xs) / -4));
}

View File

@@ -11,7 +11,6 @@ import {
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
PLANNER_OVERSIGHT_LEVELS,
TASK_PRIORITIES,
VALID_TRANSITIONS,
getErrorMessage,
} from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
@@ -920,7 +919,6 @@ function TaskCardComponent({
);
const [missionTitle, setMissionTitle] = useState<string | null>(null);
const [agentName, setAgentName] = useState<string | null>(null);
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [isRetrying, setIsRetrying] = useState(false);
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
@@ -939,7 +937,6 @@ function TaskCardComponent({
click would immediately reopen it, breaking the toggle affordance.
*/
const menuButtonRef = useRef<HTMLButtonElement>(null);
const sendBackRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
const { agentsMap } = useAgentsMapCache(projectId);
@@ -972,18 +969,6 @@ function TaskCardComponent({
setEditDescription(task.description || "");
}, [task.id, task.description]);
// Close send-back menu on outside click
useEffect(() => {
if (!showSendBackMenu) return;
const handleClick = (e: MouseEvent) => {
if (sendBackRef.current && !sendBackRef.current.contains(e.target as Node)) {
setShowSendBackMenu(false);
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [showSendBackMenu]);
// Fetch mission title when missionId is set
useEffect(() => {
@@ -1655,7 +1640,6 @@ function TaskCardComponent({
return bestData;
}, [liveBadgeData, batchData, task.issueInfo, task.updatedAt]);
const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask);
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled ?? false });
/*
* FNXC:PlannerOversight 2026-07-04-12:30:
@@ -1748,37 +1732,7 @@ function TaskCardComponent({
);
return (next?.id ?? "todo") as ColumnId;
}, [taskMoveColumns, task.column]);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction || (showInReviewMoveControl && !metaRowVisible);
const renderInReviewMoveControl = () => (
<div className="card-send-back" ref={sendBackRef}>
<button
className="card-send-back-btn"
onClick={handleSendBackClick}
title={t("tasks.moveTask", "Move task")}
aria-label={t("tasks.moveTask", "Move task")}
aria-haspopup="menu"
aria-expanded={showSendBackMenu}
>
{t("tasks.move", "Move")}
<ChevronDown size={10} />
</button>
{showSendBackMenu && (
<div className="card-send-back-menu" role="menu">
{VALID_TRANSITIONS["in-review"].map((col) => (
<button
key={col}
className="card-send-back-menu-item"
role="menuitem"
onClick={(e) => handleSendBackOptionClick(e, col)}
>
{col === "done" ? t("tasks.doneNoMerge", "Done (no merge)") : columnLabel(col)}
</button>
))}
</div>
)}
</div>
);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction;
const enterEditMode = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -2429,7 +2383,24 @@ function TaskCardComponent({
actions.push({ id: taskActionMenuModel.reviewAction.id, label: taskActionMenuModel.reviewAction.label, disabled: taskActionMenuModel.reviewAction.disabled, onSelect: taskActionMenuModel.reviewAction.onSelect });
}
if (onMoveTask) {
for (const transition of taskActionMenuModel.moveTransitions) {
const moveTransitions = [...taskActionMenuModel.moveTransitions];
/*
FNXC:BoardCardActions 2026-07-16-00:00 (FN-8149):
The retired in-review Move dropdown offered Done (no merge) and Triage in addition to the shared menu model's Todo/In Progress defaults. Fold those targets into this TaskCard-only menu so card consolidation retains every move capability without changing ListView or TaskDetail menus.
*/
if (task.column === "in-review") {
for (const column of ["done", "triage"] as const) {
if (moveTransitions.some((transition) => transition.column === column)) continue;
moveTransitions.push({
column,
label: column === "done"
? t("tasks.doneNoMerge", "Done (no merge)")
: t("taskDetail.move.moveTo", "Move to {{column}}", { column: taskActionColumnLabel(column) }),
primaryLabel: t("taskDetail.move.moveTo", "Move to {{column}}", { column: taskActionColumnLabel(column) }),
});
}
}
for (const transition of moveTransitions) {
actions.push({
id: `move-${transition.column}`,
label: transition.label,
@@ -2438,7 +2409,7 @@ function TaskCardComponent({
}
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
const hasContextMenuActions = contextMenuActions.length > 0;
const closeContextMenu = useCallback(() => {
@@ -2459,7 +2430,6 @@ function TaskCardComponent({
const openContextMenuAt = useCallback((clientX: number, clientY: number) => {
if (!hasContextMenuActions || isEditing) return;
setShowSendBackMenu(false);
setContextMenuPosition({
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX, window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN)),
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY, window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN)),
@@ -2609,53 +2579,6 @@ function TaskCardComponent({
}
}, [task.missionId, onOpenMission]);
const handleSendBackClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowSendBackMenu((current) => !current);
}, []);
const handleSendBackOptionClick = useCallback(async (e: React.MouseEvent, column: Column) => {
e.stopPropagation();
setShowSendBackMenu(false);
if (!onMoveTask) return;
try {
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
const keepProgress = await confirm({
title: t("tasks.preserveProgressTitle", "Preserve Progress?"),
message: t("tasks.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
confirmLabel: t("tasks.keepProgress", "Keep Progress"),
cancelLabel: t("tasks.resetProgress", "Reset Progress"),
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: t("tasks.resetProgressTitle", "Reset Progress?"),
message: t("tasks.resetProgressMessage", "Reset all step progress before moving this task?"),
confirmLabel: t("tasks.resetProgress", "Reset Progress"),
cancelLabel: t("tasks.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
return;
}
}
}
await onMoveTask(task.id, column, moveOptions);
addToast(t("tasks.moved", "Moved {{taskId}} to {{column}}", { taskId: task.id, column: columnLabel(column) }), "success");
} catch (err) {
addToast(t("tasks.moveFailed", "Failed to move {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error");
}
}, [addToast, confirm, onMoveTask, task.id, task.steps]);
const handlePromoteClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onPromote || isPromoting) return;
@@ -3392,37 +3315,6 @@ function TaskCardComponent({
{t("tasks.revert", "Revert")}
</button>
)}
{task.column === "in-progress" && onMoveTask && (
<div className="card-send-back" ref={sendBackRef}>
<button
className="card-send-back-btn"
onClick={handleSendBackClick}
title={t("tasks.sendBack", "Send back")}
aria-label={t("tasks.sendBack", "Send back")}
aria-haspopup="menu"
aria-expanded={showSendBackMenu}
>
{t("tasks.sendBack", "Send back")}
<ChevronDown size={10} />
</button>
{showSendBackMenu && (
<div className="card-send-back-menu" role="menu">
{VALID_TRANSITIONS["in-progress"]
.filter((col) => col !== "in-review")
.map((col) => (
<button
key={col}
className="card-send-back-menu-item"
role="menuitem"
onClick={(e) => handleSendBackOptionClick(e, col)}
>
{columnLabel(col)}
</button>
))}
</div>
)}
</div>
)}
{/*
FNXC:BoardCardActions 2026-07-15-00:00 (FN-8035):
Done-card Archive and Revert are consolidated into this single three-dot TaskContextMenu;
@@ -3685,7 +3577,6 @@ function TaskCardComponent({
</span>
)}
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> {t("tasks.queued", "Queued")}</span>}
{showInReviewMoveControl && renderInReviewMoveControl()}
{placeFooterRightInMeta && footerRightCluster}
</div>
)}
@@ -3775,7 +3666,6 @@ function TaskCardComponent({
{isPromoting ? t("tasks.promoting", "Promoting…") : t("tasks.promote", "Promote")}
</button>
)}
{showInReviewMoveControl && !metaRowVisible && renderInReviewMoveControl()}
</div>
)}
{isAgentCreated && (

View File

@@ -146,7 +146,6 @@ function expectCssRuleNotToContain(section: string, selectorFragment: string, de
}
function expectHeaderActionsControlCenterline(container: HTMLElement, expected: {
sendBack?: boolean;
menu?: boolean;
size?: boolean;
}) {
@@ -154,23 +153,9 @@ function expectHeaderActionsControlCenterline(container: HTMLElement, expected:
expect(actions).toBeTruthy();
expect(getComputedStyle(actions).alignItems).toBe("center");
const sendBack = actions.querySelector(".card-send-back-btn") as HTMLElement | null;
const menu = actions.querySelector(".card-menu-btn") as HTMLElement | null;
const sizeBadge = actions.querySelector(".card-size-badge") as HTMLElement | null;
if (expected.sendBack) {
expect(sendBack).toBeTruthy();
const sendBackStyles = getComputedStyle(sendBack!);
expect(sendBackStyles.display).toBe("inline-flex");
expect(sendBackStyles.alignItems).toBe("center");
expect(sendBackStyles.lineHeight).toBe("1");
expect(sendBackStyles.minHeight).toBe("");
// Text+chevron Actions chip reads optically low vs ⋯ / size; tokenized 1px raise keeps the three on one centerline.
expect(sendBackStyles.transform).toMatch(/^translateY\(calc\(var\(--space-xs\) \/ -4\)\)$/);
} else {
expect(sendBack).toBeNull();
}
if (expected.menu) {
expect(menu).toBeTruthy();
const menuStyles = getComputedStyle(menu!);
@@ -321,7 +306,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
expectSharedHeaderBaseline(sizedContainer);
});
it("aligns an in-progress card id with Send back and size actions while badges are present", () => {
it("aligns an in-progress card id with three-dot and size actions while badges are present", () => {
const { container: alignedContainer } = render(
<TaskCard
task={makeTask({
@@ -342,13 +327,12 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
const headerBadges = alignedContainer.querySelector(".card-header-badges") as HTMLElement;
const actions = alignedContainer.querySelector(".card-header-actions") as HTMLElement;
const sizeBadge = alignedContainer.querySelector(".card-size-badge") as HTMLElement;
const sendBack = alignedContainer.querySelector(".card-send-back") as HTMLElement;
expect(headerBadges).toBeTruthy();
expect(getComputedStyle(headerBadges).alignItems).toBe("center");
expect(getComputedStyle(headerBadges).minHeight).toMatch(resolvedChipHeightPattern);
expect(sendBack).toBeTruthy();
expect(actions.contains(sendBack)).toBe(true);
expect(alignedContainer.querySelector(".card-send-back")).toBeNull();
expect(actions.querySelector(".card-menu-btn")).toBeTruthy();
expect(actions.contains(sizeBadge)).toBe(true);
expect(sizeBadge.closest(".card-header-badges")).toBeNull();
expectSharedHeaderBaseline(alignedContainer);
@@ -384,7 +368,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
expectSharedHeaderBaseline(triageContainer);
});
it("keeps Send back, menu, and size controls on one header-actions centerline across card states", () => {
it("keeps three-dot menu and size controls on one header-actions centerline across card states", () => {
const { container: inProgressContainer } = render(
<TaskCard
task={makeTask({
@@ -400,12 +384,12 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
);
expectSharedHeaderBaseline(inProgressContainer);
expectHeaderActionsControlCenterline(inProgressContainer, { sendBack: true, menu: true, size: true });
expectHeaderActionsControlCenterline(inProgressContainer, { menu: true, size: true });
/*
* FNXC:BoardCardActions 2026-07-16-02:24:
* FN-8080 preserves the FN-8035 done-card contract: Archive/Revert live in the three-dot
* card-menu-btn TaskContextMenu, so header actions expose menu + size and no Send back chip.
* card-menu-btn TaskContextMenu, so header actions expose menu + size only.
*/
const { container: doneContainer } = render(
<TaskCard
@@ -473,7 +457,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
);
expectSharedHeaderBaseline(sizeAbsentContainer);
expectHeaderActionsControlCenterline(sizeAbsentContainer, { sendBack: true, menu: true });
expectHeaderActionsControlCenterline(sizeAbsentContainer, { menu: true });
const { container: awaitingInputContainer } = render(
<TaskCard
@@ -492,7 +476,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
expect(awaitingInputContainer.querySelector(".card-answer-questions-btn")).toBeTruthy();
expectSharedHeaderBaseline(awaitingInputContainer);
expectHeaderActionsControlCenterline(awaitingInputContainer, { sendBack: true, menu: true, size: true });
expectHeaderActionsControlCenterline(awaitingInputContainer, { menu: true, size: true });
});
it("keeps the centered-id nudge and mobile header rhythm tokenized with the badge-wrap contract", () => {
@@ -516,7 +500,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
expect(loadedCss).toContain("min-height: var(--card-chip-height-mobile);");
});
it("locks the mobile Send back, menu, and size controls to one header-actions centerline", () => {
it("locks the mobile three-dot menu, size, and Promote controls to the card rhythm", () => {
const mobileSection = getCssBlocks(loadedCss, "max-width: 768px").join("\n");
const menuTouchSection = getCssBlocks(loadedCss, "max-height: 480px").join("\n");
@@ -526,11 +510,10 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
expectCssRuleToContain(mobileSection, ".card-header-actions", "overflow: visible;");
expectCssRuleToContain(mobileSection, ".card-header-actions", "align-items: center;");
expectCssRuleToContain(mobileSection, ".card-header-actions", "gap: calc(var(--space-xs) / 2);");
// Task id and right cluster share the same locked mobile chip row so Actions/⋯/size sit on the FN-#### baseline.
// Task id and right cluster share the same locked mobile chip row so ⋯/size sit on the FN-#### baseline.
expectCssRuleToContain(mobileSection, ".card-id", "height: var(--card-chip-height-mobile);");
expectCssRuleToContain(mobileSection, ".card-id", "max-height: var(--card-chip-height-mobile);");
expectCssRuleToContain(mobileSection, ".card-send-back", "height: 100%;");
expectCssRuleToContain(mobileSection, ".card-send-back", "align-items: center;");
expect(mobileSection).not.toMatch(/\.card-send-back\s*\{/);
expectCssRuleToContain(mobileSection, ".card-send-back-btn", "line-height: 1;");
expectCssRuleToContain(mobileSection, ".card-send-back-btn", "transform: translateY(calc(var(--space-xs) / -4));");
expectCssRuleToContain(mobileSection, ".card-menu-btn", "line-height: 1;");

View File

@@ -3849,126 +3849,39 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(menuButton)).toBe(true);
});
it("renders in-review Move control inline in card-meta for overlap-blocked tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", overlapBlockedBy: "FN-OVER", blockedBy: undefined })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
const moveControl = container.querySelector(".card-send-back");
const metaRow = container.querySelector(".card-meta");
expect(metaRow).not.toBeNull();
expect(moveControl).not.toBeNull();
expect(metaRow?.contains(moveControl as HTMLElement)).toBe(true);
expect(container.querySelector(".card-action-row")).toBeNull();
});
it("renders in-review Move control after queued badge in card-meta", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", status: "queued" as any, dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined })}
queued={true}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
const metaRow = container.querySelector(".card-meta");
const queuedBadge = container.querySelector(".queued-badge");
const moveControl = container.querySelector(".card-send-back");
expect(metaRow).not.toBeNull();
expect(queuedBadge).not.toBeNull();
expect(moveControl).not.toBeNull();
expect(metaRow?.contains(moveControl as HTMLElement)).toBe(true);
expect(queuedBadge?.compareDocumentPosition(moveControl as HTMLElement) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(container.querySelector(".card-action-row")).toBeNull();
});
it("keeps in-review Move control in card-action-row when meta row is not visible", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
const moveButton = screen.getByRole("button", { name: "Move task" });
const actionRow = container.querySelector(".card-action-row");
expect(actionRow).not.toBeNull();
expect(actionRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).toBeNull();
});
it("renders Create PR before Move inside card-action-row", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
prAuthAvailable={true}
autoMergeEnabled={false}
/>,
);
const createPrButton = screen.getByRole("button", { name: "Create pull request" });
const moveButton = screen.getByRole("button", { name: "Move task" });
const actionRow = createPrButton.closest(".card-action-row");
expect(actionRow).not.toBeNull();
expect(moveButton.closest(".card-action-row")).toBe(actionRow);
expect(createPrButton.compareDocumentPosition(moveButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const moveControl = moveButton.closest(".card-send-back") as HTMLElement | null;
expect(moveControl).not.toBeNull();
expect(getComputedStyle(moveControl as HTMLElement).marginLeft).toBe("auto");
fireEvent.click(moveButton);
const menu = screen.getByRole("menu");
expect(moveControl?.contains(menu)).toBe(true);
const menuStyle = getComputedStyle(menu);
expect(menuStyle.right).toBe("0px");
expect(menuStyle.left).not.toBe("0px");
});
it.each([
{ name: "meta-row-visible variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }), expectedContainer: ".card-meta" },
{ name: "no-meta variant", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }), expectedContainer: ".card-action-row" },
])("keeps Move dropdown behavior for $name", ({ task, expectedContainer }) => {
{ name: "meta-row overlap badge", task: makeTask({ column: "in-review", overlapBlockedBy: "FN-OVER", blockedBy: undefined }), queued: false },
{ name: "meta-row queued badge", task: makeTask({ column: "in-review", status: "queued" as any, dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined }), queued: true },
{ name: "no-meta action-row placement", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }), queued: false },
])("uses the three-dot menu as the only in-review move entry point for $name", ({ task, queued }) => {
const onMoveTask = vi.fn();
const { container } = render(
<TaskCard
task={task}
queued={queued}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
const host = container.querySelector(expectedContainer);
const moveButton = screen.getByRole("button", { name: "Move task" });
expect(host?.contains(moveButton)).toBe(true);
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Move task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
fireEvent.click(moveButton);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0);
expect(screen.getByRole("menuitem", { name: "Done (no merge)" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Done (no merge)" }));
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);
});
it("FN-4540 keeps in-progress Send back control in card-header-actions", () => {
it("uses the three-dot menu for every in-progress move target without a Send back shell", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
@@ -3978,11 +3891,28 @@ describe("TaskCard", () => {
/>,
);
const sendBackButton = screen.getByRole("button", { name: "Send back" });
const actionsContainer = container.querySelector(".card-header-actions");
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
expect(actionsContainer).not.toBeNull();
expect(actionsContainer?.contains(sendBackButton)).toBe(true);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
});
it("does not render a move shell when onMoveTask is absent", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Move task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
});
it("shows timer chip for in-progress cards summing workflow runtime + timed events", () => {

View File

@@ -287,7 +287,7 @@ describe("TaskCard mobile", () => {
expectRuleToContain(mobileSection, ".card-archive-btn", "opacity: 1;");
});
it("keeps archive/unarchive/send-back visible without min-height overrides in the mobile media block", () => {
it("keeps archive/unarchive/Promote controls visible without min-height overrides in the mobile media block", () => {
const css = loadAllAppCss();
const mobileSection = getMainMobileSection(css);
@@ -312,7 +312,7 @@ describe("TaskCard mobile", () => {
}
});
it("FN-4351: archive/unarchive/send-back buttons have no min-height in the mobile media block", () => {
it("FN-4351: archive/unarchive/Promote buttons have no min-height in the mobile media block", () => {
const css = loadAllAppCss();
const mobileSection = getMainMobileSection(css);
@@ -456,7 +456,7 @@ describe("TaskCard mobile", () => {
);
expect(container.querySelector(".card-revert-btn")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
fireEvent.click(screen.getByRole("button", { name: "Task actions" }));
expect(screen.getByRole("menuitem", { name: "Revert" })).toBeTruthy();
});