From e10ba2826f39e6faf768334f167bf41e751bf60c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 20:04:22 -0700 Subject: [PATCH] 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) --- .../dashboard/app/components/TaskCard.css | 63 +------- .../dashboard/app/components/TaskCard.tsx | 150 +++--------------- .../__tests__/TaskCard.badge-wrap.test.tsx | 39 ++--- .../components/__tests__/TaskCard.test.tsx | 140 ++++------------ .../__tests__/board-mobile.test.tsx | 6 +- 5 files changed, 72 insertions(+), 326 deletions(-) diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 2391cfbacc..86dbe0d8af 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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)); } diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index f6c2d58231..5f43cdb759 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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(null); const [agentName, setAgentName] = useState(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(null); - const sendBackRef = useRef(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 = () => ( -
- - {showSendBackMenu && ( -
- {VALID_TRANSITIONS["in-review"].map((col) => ( - - ))} -
- )} -
- ); + 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) => { e.stopPropagation(); if (!onPromote || isPromoting) return; @@ -3392,37 +3315,6 @@ function TaskCardComponent({ {t("tasks.revert", "Revert")} )} - {task.column === "in-progress" && onMoveTask && ( -
- - {showSendBackMenu && ( -
- {VALID_TRANSITIONS["in-progress"] - .filter((col) => col !== "in-review") - .map((col) => ( - - ))} -
- )} -
- )} {/* 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({ )} {(queued || task.status === "queued") && task.column !== "in-progress" && {t("tasks.queued", "Queued")}} - {showInReviewMoveControl && renderInReviewMoveControl()} {placeFooterRightInMeta && footerRightCluster} )} @@ -3775,7 +3666,6 @@ function TaskCardComponent({ {isPromoting ? t("tasks.promoting", "Promoting…") : t("tasks.promote", "Promote")} )} - {showInReviewMoveControl && !metaRowVisible && renderInReviewMoveControl()} )} {isAgentCreated && ( diff --git a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx index 2ee8abdde1..2ae8909e34 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx @@ -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( { 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( { ); 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( { ); expectSharedHeaderBaseline(sizeAbsentContainer); - expectHeaderActionsControlCenterline(sizeAbsentContainer, { sendBack: true, menu: true }); + expectHeaderActionsControlCenterline(sizeAbsentContainer, { menu: true }); const { container: awaitingInputContainer } = render( { 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;"); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index bd83784e04..759925d763 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( , ); - 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( { />, ); - 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( + , + ); + + 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", () => { diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index bafbb5b673..3df8c229fe 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -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(); });