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