FN-7362: Fix board task context menu clipping
Render Board task context menus as viewport overlays so column clipping no longer hides actions. - Portal TaskCard context menus to document.body and position them with viewport coordinates. - Clamp context menus after render and close them when the task identity or column changes. - Update TaskCard coverage for overflow escape, keyboard/touch placement, and menu lifecycle behavior. - Document the overlay behavior and add a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-7362-board-context-menu-overlay.md | 7 + docs/dashboard-guide.md | 5 +- packages/dashboard/app/components/TaskCard.css | 6 +- packages/dashboard/app/components/TaskCard.tsx | 38 ++-- .../app/components/__tests__/TaskCard.test.tsx | 242 ++++++++++++++++----- 5 files changed, 211 insertions(+), 87 deletions(-) Fusion-Task-Id: FN-7362 Fusion-Task-Lineage: 8f2fc0b1-ad63-402e-a960-6a09cbe9c893 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7362-board-context-menu-overlay.md
Normal file
7
.changeset/fn-7362-board-context-menu-overlay.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Board task context menus so they are not clipped by columns.
|
||||
category: fix
|
||||
dev: Board TaskCard menus are portaled to document.body and clamped in viewport coordinates.
|
||||
@@ -139,10 +139,11 @@ Features:
|
||||
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
|
||||
- Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
|
||||
- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
|
||||
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call.
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation. -->
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation.
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment. -->
|
||||
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
|
||||
|
||||
@@ -38,10 +38,8 @@
|
||||
}
|
||||
|
||||
.task-card-context-menu-popover {
|
||||
position: absolute;
|
||||
left: var(--task-card-context-menu-x);
|
||||
top: var(--task-card-context-menu-y);
|
||||
z-index: 30;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.task-card-context-menu-popover .task-context-menu {
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./TaskCard.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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, MergeResult } from "@fusion/core";
|
||||
import {
|
||||
@@ -1972,6 +1973,10 @@ function TaskCardComponent({
|
||||
setContextMenuPosition(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
closeContextMenu();
|
||||
}, [closeContextMenu, task.column, task.id]);
|
||||
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
@@ -1982,12 +1987,10 @@ function TaskCardComponent({
|
||||
|
||||
const openContextMenuAt = useCallback((clientX: number, clientY: number) => {
|
||||
if (!hasContextMenuActions || isEditing) return;
|
||||
const rect = cardRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setShowSendBackMenu(false);
|
||||
setContextMenuPosition({
|
||||
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX - rect.left, rect.width - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY - rect.top, rect.height - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
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)),
|
||||
});
|
||||
}, [hasContextMenuActions, isEditing]);
|
||||
|
||||
@@ -2039,27 +2042,17 @@ function TaskCardComponent({
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-06-30-00:15:
|
||||
Board card context menus open from pointer and keyboard coordinates, so clamp after render using the measured menu size. This keeps long action lists inside the viewport without changing normal card click or drag behavior.
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00:
|
||||
Board columns intentionally clip and scroll their bodies, so card context menus must be portaled to document.body and positioned in viewport coordinates. Clamp after render using the measured menu size so right-click, keyboard, and long-press menus escape column borders without weakening board overflow containment.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!contextMenuPosition) return;
|
||||
const menu = contextMenuRef.current;
|
||||
const card = cardRef.current;
|
||||
if (!menu || !card) return;
|
||||
if (!menu) return;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.min(cardRect.width - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerWidth - cardRect.left - menuRect.width - CONTEXT_MENU_VIEWPORT_MARGIN),
|
||||
);
|
||||
const maxY = Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.min(cardRect.height - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerHeight - cardRect.top - menuRect.height - CONTEXT_MENU_VIEWPORT_MARGIN),
|
||||
);
|
||||
const nextPosition = {
|
||||
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.x, maxX)),
|
||||
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.y, maxY)),
|
||||
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.x, window.innerWidth - menuRect.width - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.y, window.innerHeight - menuRect.height - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
};
|
||||
if (nextPosition.x !== contextMenuPosition.x || nextPosition.y !== contextMenuPosition.y) {
|
||||
setContextMenuPosition(nextPosition);
|
||||
@@ -2352,11 +2345,11 @@ function TaskCardComponent({
|
||||
tabIndex={hasContextMenuActions ? 0 : undefined}
|
||||
aria-haspopup={hasContextMenuActions ? "menu" : undefined}
|
||||
>
|
||||
{contextMenuPosition && hasContextMenuActions && (
|
||||
{contextMenuPosition && hasContextMenuActions && createPortal(
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="task-card-context-menu-popover"
|
||||
style={{ "--task-card-context-menu-x": `${contextMenuPosition.x}px`, "--task-card-context-menu-y": `${contextMenuPosition.y}px` } as CSSProperties}
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y } as CSSProperties}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
@@ -2364,7 +2357,8 @@ function TaskCardComponent({
|
||||
actions={contextMenuActions}
|
||||
onActionSelect={closeContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<div className="card-header">
|
||||
<span className="card-id">{task.id}</span>
|
||||
|
||||
@@ -150,6 +150,42 @@ function expectTimerInFooterRight(container: HTMLElement) {
|
||||
expect(timer?.closest(".card-meta-badges")).toBeNull();
|
||||
}
|
||||
|
||||
function mockBoardContextMenuGeometry() {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getMockRect(this: HTMLElement) {
|
||||
if (this.classList.contains("card")) {
|
||||
return { x: 520, y: 340, left: 520, top: 340, right: 760, bottom: 520, width: 240, height: 180, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
if (this.classList.contains("task-card-context-menu-popover")) {
|
||||
const left = Number.parseFloat(this.style.left || "0");
|
||||
const top = Number.parseFloat(this.style.top || "0");
|
||||
return { x: left, y: top, left, top, right: left + 180, bottom: top + 220, width: 180, height: 220, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
return () => {
|
||||
rectSpy.mockRestore();
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
|
||||
};
|
||||
}
|
||||
|
||||
function expectBoardContextMenuPortaled() {
|
||||
const menu = screen.getByRole("menu");
|
||||
const popover = menu.closest(".task-card-context-menu-popover") as HTMLElement | null;
|
||||
expect(popover).not.toBeNull();
|
||||
expect(popover?.parentElement).toBe(document.body);
|
||||
expect(popover?.closest(".card")).toBeNull();
|
||||
expect(popover?.closest(".column")).toBeNull();
|
||||
expect(popover?.closest(".column-body")).toBeNull();
|
||||
expect(popover?.style.left).not.toBe("");
|
||||
expect(popover?.style.top).not.toBe("");
|
||||
return popover!;
|
||||
}
|
||||
|
||||
const highFanout = {
|
||||
totalCount: 7,
|
||||
activeTodoCount: 3,
|
||||
@@ -192,52 +228,73 @@ describe("TaskCard", () => {
|
||||
expect(onOpenDetailWithTab.mock.calls[0][1]).toBe("workflow");
|
||||
});
|
||||
|
||||
it("opens the board card context menu on right-click without opening detail", async () => {
|
||||
it("opens the board card context menu as a viewport portal on right-click without opening detail", async () => {
|
||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||
const onOpenDetail = vi.fn();
|
||||
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress", status: "executing" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onPauseTask={onPauseTask}
|
||||
/>,
|
||||
);
|
||||
try {
|
||||
render(
|
||||
<div className="column" style={{ overflow: "hidden" }}>
|
||||
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress", status: "executing" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onPauseTask={onPauseTask}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 790, clientY: 590 });
|
||||
const popover = await waitFor(() => expectBoardContextMenuPortaled());
|
||||
expect(popover.style.left).toBe("612px");
|
||||
expect(popover.style.top).toBe("372px");
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
|
||||
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
|
||||
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
cleanupGeometry();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the board card context menu from keyboard, selects an action, and closes", async () => {
|
||||
it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => {
|
||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||
const onOpenDetail = vi.fn();
|
||||
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", status: "done" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onArchiveTask={onArchiveTask}
|
||||
/>,
|
||||
);
|
||||
try {
|
||||
render(
|
||||
<div className="column" style={{ overflow: "hidden" }}>
|
||||
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", status: "done" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onArchiveTask={onArchiveTask}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const card = document.querySelector(".card") as HTMLElement;
|
||||
card.focus();
|
||||
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
|
||||
const card = document.querySelector(".card") as HTMLElement;
|
||||
card.focus();
|
||||
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
|
||||
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
expectBoardContextMenuPortaled();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
|
||||
|
||||
await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
cleanupGeometry();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows refine for a done card context menu and routes to the refinement opener", () => {
|
||||
@@ -364,35 +421,44 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the board card context menu on touch long-press, selects the tapped action, and suppresses detail click", async () => {
|
||||
it("opens the board card context menu on touch long-press as a viewport portal, selects the tapped action, and suppresses detail click", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||
const onOpenDetail = vi.fn();
|
||||
const onUnpauseTask = vi.fn(async () => makeTask());
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ paused: true, userPaused: true })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onUnpauseTask={onUnpauseTask}
|
||||
/>,
|
||||
);
|
||||
try {
|
||||
render(
|
||||
<div className="column" style={{ overflow: "hidden" }}>
|
||||
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
|
||||
<TaskCard
|
||||
task={makeTask({ paused: true, userPaused: true })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onUnpauseTask={onUnpauseTask}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const card = document.querySelector(".card") as HTMLElement;
|
||||
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
|
||||
act(() => vi.advanceTimersByTime(550));
|
||||
const card = document.querySelector(".card") as HTMLElement;
|
||||
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
|
||||
act(() => vi.advanceTimersByTime(550));
|
||||
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
|
||||
fireEvent.click(card);
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
expectBoardContextMenuPortaled();
|
||||
fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
|
||||
fireEvent.click(card);
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Unpause" }), { pointerType: "touch", pointerId: 2 });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Unpause" }), { pointerType: "touch", pointerId: 2 });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
cleanupGeometry();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels board card long-press when touch moves before the delay", () => {
|
||||
@@ -414,6 +480,64 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("dispatches portaled board menu actions for the interacted duplicate card", async () => {
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
const onDuplicateTask = vi.fn(async () => makeTask({ id: "FN-002-copy" }));
|
||||
render(
|
||||
<div className="column" style={{ overflow: "hidden" }}>
|
||||
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
|
||||
<TaskCard
|
||||
task={makeTask({ id: "FN-001", title: "Duplicate title", column: "todo" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onDuplicateTask={onDuplicateTask}
|
||||
/>
|
||||
<TaskCard
|
||||
task={makeTask({ id: "FN-002", title: "Duplicate title", column: "todo" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onDuplicateTask={onDuplicateTask}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const secondCard = document.querySelectorAll(".card")[1] as HTMLElement;
|
||||
fireEvent.contextMenu(secondCard, { clientX: 64, clientY: 72 });
|
||||
expectBoardContextMenuPortaled();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
|
||||
|
||||
await waitFor(() => expect(onDuplicateTask).toHaveBeenCalledWith("FN-002"));
|
||||
expect(onDuplicateTask).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cleans up the portaled board menu when the task column changes", () => {
|
||||
const { rerender } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ id: "FN-001", column: "in-progress", status: "executing" as any })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onPauseTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
|
||||
expectBoardContextMenuPortaled();
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={makeTask({ id: "FN-001", column: "todo", status: "pending" as any })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onPauseTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show the Answer-questions button when not awaiting input", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
|
||||
Reference in New Issue
Block a user