FN-051: disable native task dragging
Remove native task drag-and-drop while preserving board panning and task actions. - Remove draggable task movement and drop handling from board, columns, lanes, lists, and cards. - Add task context-menu movement actions and update related documentation and release metadata. - Update component tests to cover non-draggable cards and board-pan exclusions. Files changed: .changeset/fn-051-task-card-moves.md | 7 + docs/dashboard-guide.md | 5 +- docs/task-management.md | 6 + packages/dashboard/app/components/Board.tsx | 63 +----- packages/dashboard/app/components/Column.tsx | 140 +------------- packages/dashboard/app/components/DockTaskList.tsx | 3 +- packages/dashboard/app/components/Lane.tsx | 9 - packages/dashboard/app/components/ListView.css | 12 +- packages/dashboard/app/components/ListView.tsx | 132 ++----------- packages/dashboard/app/components/TaskCard.css | 8 - packages/dashboard/app/components/TaskCard.tsx | 58 ++---- .../dashboard/app/components/TaskContextMenu.css | 37 ++++ .../dashboard/app/components/TaskContextMenu.tsx | 145 ++++++++++++-- .../__tests__/Board.canDropTask.test.tsx | 212 --------------------- .../app/components/__tests__/Board.test.tsx | 8 +- .../app/components/__tests__/Column.test.tsx | 101 +--------- .../app/components/__tests__/DockTaskList.test.tsx | 5 +- .../app/components/__tests__/Lane.test.tsx | 26 --- .../app/components/__tests__/ListView.test.tsx | 178 +---------------- .../app/components/__tests__/TaskCard.test.tsx | 60 ++---- .../components/__tests__/TaskContextMenu.test.tsx | 55 +++++- .../dashboard/app/components/boardCanDropTask.ts | 56 ----- .../app/components/dashboard/MainContent.tsx | 1 - .../app/components/useRightDockController.tsx | 1 - packages/dashboard/app/styles.css | 14 -- 25 files changed, 297 insertions(+), 1045 deletions(-) Fusion-Task-Id: FN-051 Fusion-Task-Lineage: b5614cb1-75f3-4307-a595-123f903adc99 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-051-task-card-moves.md
Normal file
7
.changeset/fn-051-task-card-moves.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Move Board and List tasks from their contextual Move to menu.
|
||||
category: fix
|
||||
dev: Native task drag-and-drop is removed; multiple legal destinations are grouped in one accessible submenu.
|
||||
@@ -324,9 +324,10 @@ Board view is the kanban surface for day-to-day operation.
|
||||
|
||||
Features:
|
||||
|
||||
- Drag-and-drop between lifecycle columns
|
||||
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
|
||||
- Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only—subtasks still use drag-to-reorder.
|
||||
<!-- FNXC:BoardNavigation 2026-08-18-18:18: Desktop operators can traverse the live workflow columns by dragging a safe Board surface horizontally; card drag-and-drop, card/control clicks, and mobile touch paging remain separate interactions. -->
|
||||
- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for native drag-and-drop, clicks, and context menus; phones continue to use native touch scrolling and column snapping.
|
||||
- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for clicks and context menus; phones continue to use native touch scrolling and column snapping.
|
||||
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
|
||||
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
|
||||
- Column visibility controls
|
||||
|
||||
@@ -377,6 +377,12 @@ Board ordering behavior:
|
||||
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
|
||||
- The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults).
|
||||
|
||||
<!-- FNXC:TaskCardMovement 2026-08-19-18:35: Task movement is contextual so Board and List retain one accessible, validated transition path without native task drag-and-drop. -->
|
||||
|
||||
### Moving tasks on Board and List
|
||||
|
||||
Use a task card or List row's context menu (**right-click**, **Shift+F10** / Context Menu key, the visible overflow control, or touch long-press), then choose **Move to**. When more than one legal destination is available, **Move to** opens one submenu containing each destination; a single destination remains a direct action. This changes only task movement: subtask drag-to-reorder remains available in the Subtask Breakdown dialog.
|
||||
|
||||
### Lifecycle commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
|
||||
import { useBoardMousePan } from "../hooks/useBoardMousePan";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
@@ -412,7 +411,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
refreshBoardWorkflows,
|
||||
setBoardWorkflowsState,
|
||||
} = useBoardWorkflows({ projectId });
|
||||
const draggingTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-23:15 (the board's fan-out read the LEGACY lanes):
|
||||
@@ -449,7 +447,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
}
|
||||
}, [onToggleAutoMerge]);
|
||||
|
||||
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
|
||||
|
||||
const workflowStatusCounts = useMemo(() => {
|
||||
/*
|
||||
@@ -888,52 +885,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return grouped;
|
||||
}, [aggregateBoardColumns, aggregateQuickCreateTarget, boardWorkflows, getColumnSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
|
||||
|
||||
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
|
||||
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
|
||||
// messageKey (no-move); null = allowed.
|
||||
const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => (
|
||||
getBoardCanDropTaskRejection({
|
||||
boardWorkflows,
|
||||
tasks,
|
||||
maxConcurrent,
|
||||
taskId,
|
||||
targetColumnId,
|
||||
laneWorkflowId,
|
||||
})
|
||||
), [boardWorkflows, tasks, maxConcurrent]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-07-29-00:00 (U12 — measured perf fix):
|
||||
Bind `canDropTask` per (lane, column) ONCE per dependency change instead of creating
|
||||
a new arrow inline in the render. `Column` is `React.memo`, and a fresh function
|
||||
identity on any prop defeats that entirely — so before this, ANY Board state change
|
||||
(collapsing the archived column, changing Done sort, opening the switcher)
|
||||
re-rendered EVERY column and every card beneath it, not just the affected one.
|
||||
|
||||
This was invisible for a long time: the "keeps unaffected columns stable" regression
|
||||
test measured the LEGACY single-lane board, whose props were all stable. Deleting
|
||||
that board (U12 part 1) repointed the test at the real board, where it failed. I
|
||||
instrumented `React.memo`'s comparator to list which props actually change identity
|
||||
on a collapse toggle, and the answer was exactly one: `canDropTask`.
|
||||
|
||||
A `useRef` cache invalidated by `useEffect` does NOT work here, which is why my first
|
||||
attempt at this failed and was reverted: the effect runs AFTER the render that
|
||||
populated the cache, so it wipes the very bindings that render created and the next
|
||||
render allocates fresh ones. `useMemo` keyed on the resolver has no such window —
|
||||
the map lives exactly as long as the closure it belongs to.
|
||||
*/
|
||||
const canDropTaskBinder = useMemo(() => {
|
||||
const bindings = new Map<string, (taskId: string) => string | null>();
|
||||
return (columnId: string, laneWorkflowId: string) => {
|
||||
const key = `${laneWorkflowId}::${columnId}`;
|
||||
let bound = bindings.get(key);
|
||||
if (!bound) {
|
||||
bound = (taskId: string) => canDropTask(taskId, columnId, laneWorkflowId);
|
||||
bindings.set(key, bound);
|
||||
}
|
||||
return bound;
|
||||
};
|
||||
}, [canDropTask]);
|
||||
|
||||
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
|
||||
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
|
||||
@@ -1076,7 +1027,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
onDeleteTask={onDeleteTask}
|
||||
onReviseTask={onReviseTask}
|
||||
addToast={addToast}
|
||||
disableDrag
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -1094,13 +1044,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
id="board"
|
||||
ref={setBoardRef}
|
||||
{...boardMousePanHandlers}
|
||||
onDragStart={(e) => {
|
||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||
if (id) draggingTaskIdRef.current = id;
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggingTaskIdRef.current = null;
|
||||
}}
|
||||
>
|
||||
{selectedWorkflowColumns.map((columnDef) => {
|
||||
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
||||
@@ -1125,8 +1068,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
showWorktreeGrouping={showWorktreeGrouping}
|
||||
onMoveTask={onMoveTask}
|
||||
onPromote={handlePromote}
|
||||
canDropTask={canDropTaskBinder(columnDef.id, selectedWorkflow.id)}
|
||||
getDraggingTaskId={getDraggingTaskId}
|
||||
onPauseTask={onPauseTask}
|
||||
onUnpauseTask={onUnpauseTask}
|
||||
onResetTask={onResetTask}
|
||||
@@ -1170,7 +1111,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
{partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && (
|
||||
<section className="reverted-tasks-section" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="board-reverted-tasks">
|
||||
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
|
||||
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
|
||||
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
|
||||
</section>
|
||||
)}
|
||||
{selectedWorkflowArchivedColumn && (
|
||||
@@ -1191,8 +1132,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
showWorktreeGrouping={showWorktreeGrouping}
|
||||
onMoveTask={onMoveTask}
|
||||
onPromote={handlePromote}
|
||||
canDropTask={canDropTaskBinder(selectedWorkflowArchivedColumn.id, selectedWorkflow.id)}
|
||||
getDraggingTaskId={getDraggingTaskId}
|
||||
onPauseTask={onPauseTask}
|
||||
onUnpauseTask={onUnpauseTask}
|
||||
onResetTask={onResetTask}
|
||||
|
||||
@@ -229,20 +229,9 @@ interface ColumnProps {
|
||||
/** Manually promote a held card out of this hold column (workflow mode). */
|
||||
/** `force` waives the unplanned-for-execution gate after operator confirmation. */
|
||||
onPromote?: (taskId: string, options?: { force?: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Pre-check whether a drop into THIS column is allowed for the dragged task.
|
||||
* Returns null for "allowed", or an i18n messageKey for a deterministic
|
||||
* rejection (guard/capacity/unknown-column/workflow-mismatch). When a
|
||||
* rejection is returned, dragover is NOT prevented, so the card never renders
|
||||
* in this column (no-move semantics, R17). The dragged task id is read from a
|
||||
* board-level ref set on dragstart.
|
||||
*/
|
||||
canDropTask?: (taskId: string) => string | null;
|
||||
/** Read the id of the task currently being dragged (board-level ref). */
|
||||
getDraggingTaskId?: () => string | null;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||
@@ -256,7 +245,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."),
|
||||
}), [t]);
|
||||
void rejectionCopy;
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isReplanning, setIsReplanning] = useState(false);
|
||||
@@ -430,127 +418,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
setVisibleTaskCount(VISIBLE_TASKS_INITIAL);
|
||||
}, [isSearchActive, searchResultSignature]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
// Don't allow dropping into archived column via drag-drop
|
||||
if (isArchived) return;
|
||||
// Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT
|
||||
// call preventDefault, so the browser refuses the drop and the card never
|
||||
// renders in this column. A null result means the drop is allowed.
|
||||
if (workflowMode && canDropTask && getDraggingTaskId) {
|
||||
const draggingId = getDraggingTaskId();
|
||||
if (draggingId) {
|
||||
const rejectionKey = canDropTask(draggingId);
|
||||
if (rejectionKey) {
|
||||
setInlineFeedback(translateRejectionKey(t, rejectionKey));
|
||||
return; // no preventDefault → no-move
|
||||
}
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (!el.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
setInlineFeedback(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
// Check if task is already in this column - if so, skip the API call
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task && task.column === column) {
|
||||
return; // No-op: task is already in this column
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceTask = allTasks?.find((t) => t.id === taskId) ?? task;
|
||||
const hasStepProgress = sourceTask?.steps.some((step) => step.status !== "pending") ?? false;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
This component's `column` IS the drop target, so its own `columnFlags` are the
|
||||
target's traits — no lookup needed, unlike the same prompt in TaskCard/ListView
|
||||
where the card and the destination differ. Ids remain the fallback for the
|
||||
no-metadata window.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (Phase B — consolidated, semantics verified):
|
||||
Routed through `isPreImplementationColumnRole`. This is the SAME preserve-progress prompt that
|
||||
helper was written for — ListView asks it about a move target, this component asks it about
|
||||
itself — and the degraded id sets are identical (`{todo, triage}`), so the consolidation is
|
||||
exact rather than approximately right.
|
||||
|
||||
Verified before consolidating, because the sibling case in TaskContextMenu is NOT
|
||||
interchangeable: `isPreExecutionHoldColumn` drives the Plan affordance and its degraded set is
|
||||
`{triage}` alone, so routing THAT through this helper added `plan` to flagless `todo` cards.
|
||||
Same shape, different degraded answer — matched here, kept separate there.
|
||||
*/
|
||||
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column);
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
|
||||
if (shouldPrompt) {
|
||||
const keepProgress = await confirm({
|
||||
title: t("column.preserveProgressTitle", "Preserve Progress?"),
|
||||
message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
|
||||
confirmLabel: t("column.keepProgress", "Keep Progress"),
|
||||
cancelLabel: t("column.resetProgress", "Reset Progress"),
|
||||
});
|
||||
|
||||
if (keepProgress) {
|
||||
moveOptions = { preserveProgress: true };
|
||||
} else {
|
||||
const resetProgress = await confirm({
|
||||
title: t("column.resetProgressTitle", "Reset Progress?"),
|
||||
message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"),
|
||||
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
|
||||
cancelLabel: t("column.cancelMove", "Cancel Move"),
|
||||
danger: true,
|
||||
});
|
||||
if (!resetProgress) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onMoveTask(taskId, column, moveOptions);
|
||||
} catch (err) {
|
||||
// Workflow mode (R17): a structured 409 carries a typed rejection. The
|
||||
// optimistic move snaps back automatically (the next SSE/refresh restores
|
||||
// the card's real column); surface the translated rejection messageKey.
|
||||
const rejection = extractTransitionRejection(err);
|
||||
if (rejection) {
|
||||
addToast(translateRejection(t, rejection), "error");
|
||||
} else {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-00:40:
|
||||
`columnFlags` BELONGS IN THIS LIST — the drop handler asks it whether this lane is pre-implementation.
|
||||
|
||||
`shouldPrompt` gates the "Preserve Progress?" confirmation on
|
||||
`isPreImplementationColumnRole(columnFlags, column)`. The flags arrive after first paint, and
|
||||
`useCallback` without them in its deps hands the DOM the closure built during the pre-load render.
|
||||
In that closure the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`, which does not
|
||||
contain a renamed intake/hold lane — so `shouldPrompt` is false and a card with completed steps is
|
||||
moved WITHOUT asking, silently resetting progress the user was meant to be offered a choice about.
|
||||
|
||||
SEVERITY, STATED HONESTLY: `allTasks` and `tasks` are in this list and change identity on any
|
||||
task-list refresh, so the stale closure is rebuilt within seconds on an active board — a window,
|
||||
not a permanent wrong answer, like the near-duplicate chip and unlike the TaskCard ticker whose
|
||||
refreshing dependency fired only at local midnight. The window is exactly the quiet gap after the
|
||||
traits land, and a drop inside it loses work without a prompt.
|
||||
*/
|
||||
}, [addToast, allTasks, column, columnFlags, confirm, onMoveTask, tasks, t]);
|
||||
|
||||
/*
|
||||
FNXC:BoardPromote 2026-07-25-04:55:
|
||||
Promote is a two-attempt flow for the `unplanned-for-execution` rejection only.
|
||||
@@ -902,11 +769,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
className={`column${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
data-column={column}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className={`column-dot dot-${column}`} />
|
||||
|
||||
@@ -113,7 +113,7 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
{revertedTasks.length > 0 && (
|
||||
<section className="dock-task-list__reverted" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="dock-reverted-tasks">
|
||||
<h3>{t("tasks.revertedTasks", "Reverted Tasks")}</h3>
|
||||
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
|
||||
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
|
||||
</section>
|
||||
)}
|
||||
{isEmpty ? (
|
||||
@@ -139,7 +139,6 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
*/
|
||||
onDeleteTask={onDeleteTask}
|
||||
addToast={addToast}
|
||||
disableDrag={true}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
/>
|
||||
|
||||
@@ -33,9 +33,6 @@ export interface LaneProps {
|
||||
showWorktreeGrouping?: boolean;
|
||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onPromote: (taskId: string) => Promise<void>;
|
||||
/** Drag pre-check: null = allowed, else an i18n messageKey (R17). */
|
||||
canDropTask: (taskId: string, targetColumnId: string, workflowId: string) => string | null;
|
||||
getDraggingTaskId: () => string | null;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
@@ -148,10 +145,6 @@ function LaneComponent(props: LaneProps) {
|
||||
handleToggle();
|
||||
}, [handleToggle]);
|
||||
|
||||
const makeCanDrop = useCallback(
|
||||
(targetColumnId: string) => (taskId: string) => props.canDropTask(taskId, targetColumnId, workflow.id),
|
||||
[props, workflow.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="lane" data-lane={workflow.id} aria-label={workflow.name}>
|
||||
@@ -190,8 +183,6 @@ function LaneComponent(props: LaneProps) {
|
||||
showWorktreeGrouping={props.showWorktreeGrouping === true}
|
||||
onMoveTask={props.onMoveTask}
|
||||
onPromote={props.onPromote}
|
||||
canDropTask={makeCanDrop(col.id)}
|
||||
getDraggingTaskId={props.getDraggingTaskId}
|
||||
onPauseTask={props.onPauseTask}
|
||||
onOpenDetail={props.onOpenDetail}
|
||||
onOpenGroupModal={props.onOpenGroupModal}
|
||||
|
||||
@@ -367,7 +367,7 @@ List rows and mobile cards expose the same task lifecycle menu as Board cards fr
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Drop zones for drag and drop */
|
||||
/* Clickable workflow column filters. */
|
||||
.list-drop-zones {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
@@ -402,12 +402,6 @@ List rows and mobile cards expose the same task lifecycle menu as Board cards fr
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.list-drop-zone.drag-over {
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||
}
|
||||
|
||||
.drop-zone-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -599,10 +593,6 @@ No border-left on the detail pane. Keeping a border-left here would produce a se
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.list-row.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.list-row.failed {
|
||||
border-left: 3px solid var(--color-error-dark);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuItemDescriptor } from "./TaskContextMenu";
|
||||
import type { DetailTaskOpenOptions, DetailTaskTab } from "../hooks/useModalManager";
|
||||
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
|
||||
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
|
||||
@@ -405,8 +405,6 @@ export function ListView({
|
||||
const columnLabel = useColumnLabel();
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
||||
const [contextMenuState, setContextMenuState] = useState<ListContextMenuState>(null);
|
||||
const [prCreateState, setPrCreateState] = useState<ListPrCreateState>(null);
|
||||
@@ -979,19 +977,6 @@ export function ListView({
|
||||
return isIntakeColumnRole(getTaskColumnFlags(task), task.column);
|
||||
}, [getTaskColumnFlags]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-00:10 (fleet — same change as Column.tsx):
|
||||
`workflowMode` is a BOARD-level boolean answering a PER-COLUMN question. In workflow mode with a
|
||||
column that has no resolved traits, the old form returned false for every role rather than falling
|
||||
back to the id — so the archive and revert affordances silently vanished for a card sitting in a
|
||||
column its workflow no longer declares. The shared helpers ask per column and degrade to the
|
||||
legacy id only when the flags are truly absent, which also covers the pre-load window the old form
|
||||
handled via `workflowMode === false`.
|
||||
*/
|
||||
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
|
||||
return isArchivedColumnRole(columnFlagsById.get(column), column);
|
||||
}, [columnFlagsById]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-14:00 (PR #2738 review — greptile P1):
|
||||
PER-TASK twins of the two column-level predicates above.
|
||||
@@ -2136,7 +2121,7 @@ export function ListView({
|
||||
addToast(t("tasks.createdPr", "Created PR #{{number}}", { number: prInfo.number }), "success");
|
||||
}, [addToast, onTasksUpdated, t]);
|
||||
|
||||
const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => {
|
||||
const buildListContextMenuActions = useCallback((task: Task): TaskMenuItemDescriptor[] => {
|
||||
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
|
||||
const isTaskPaused = Boolean(task.paused || task.userPaused);
|
||||
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false });
|
||||
@@ -2242,7 +2227,7 @@ export function ListView({
|
||||
onEnableGithubTracking: onTasksUpdated ? () => void handleListContextEnableGithubTracking(task) : undefined,
|
||||
});
|
||||
|
||||
const actions = [...model.actions];
|
||||
const actions: TaskMenuItemDescriptor[] = [...model.actions];
|
||||
const taskColumnFlags = getTaskColumnFlags(task);
|
||||
if (isCompleteColumnRole(taskColumnFlags, task.column) && onArchiveTask) {
|
||||
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: () => void handleListTaskArchive(task) });
|
||||
@@ -2262,17 +2247,15 @@ export function ListView({
|
||||
onSelect: isRevertable ? () => void handleListTaskRevert(task) : undefined,
|
||||
});
|
||||
}
|
||||
for (const transition of model.moveTransitions) {
|
||||
actions.push({
|
||||
id: `move-${transition.column}`,
|
||||
label: transition.label,
|
||||
onSelect: () => void handleListContextMove(task, transition.column),
|
||||
});
|
||||
}
|
||||
actions.push(...buildTaskMoveMenuItems(
|
||||
model.moveTransitions,
|
||||
(column) => void handleListContextMove(task, column),
|
||||
t("taskDetail.move.moveToParent", "Move to"),
|
||||
));
|
||||
if (model.reviewAction) {
|
||||
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
||||
}
|
||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
return actions.filter((action) => "items" in action || action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
}, [addToast, autoMerge, columnFlagsById, getTaskColumnFlags, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, taskContextMenuColumnsByTaskId, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
||||
|
||||
const contextMenuActions = useMemo(
|
||||
@@ -2498,24 +2481,6 @@ export function ListView({
|
||||
}, 200);
|
||||
}, [projectId]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, task: Task) => {
|
||||
if (task.paused) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDraggingTaskId(task.id);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverColumn(null);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:ListView 2026-06-22-18:00:
|
||||
Pointer-based split resize. setPointerCapture keeps move/up events flowing to the handle even when
|
||||
@@ -2598,72 +2563,6 @@ export function ListView({
|
||||
}
|
||||
}, [sidebarWidth, useSinglePaneList]);
|
||||
|
||||
const handleColumnDragOver = useCallback(
|
||||
(e: React.DragEvent, column: ColumnId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverColumn(column);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleColumnDragLeave = useCallback(() => {
|
||||
setDragOverColumn(null);
|
||||
}, []);
|
||||
|
||||
const handleColumnDrop = useCallback(
|
||||
async (e: React.DragEvent, column: ColumnId) => {
|
||||
e.preventDefault();
|
||||
setDragOverColumn(null);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (isArchivedColumn(column)) {
|
||||
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
|
||||
const targetFlags = columnFlagsById.get(column);
|
||||
// Same rule as the context-menu move above, and now literally the same function.
|
||||
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(targetFlags, column);
|
||||
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
if (shouldPrompt) {
|
||||
const keepProgress = await confirm({
|
||||
title: t("listView.preserveProgressTitle", "Preserve Progress?"),
|
||||
message: t("listView.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
|
||||
confirmLabel: t("listView.keepProgress", "Keep Progress"),
|
||||
cancelLabel: t("listView.resetProgress", "Reset Progress"),
|
||||
});
|
||||
|
||||
if (keepProgress) {
|
||||
moveOptions = { preserveProgress: true };
|
||||
} else {
|
||||
const resetProgress = await confirm({
|
||||
title: t("listView.resetProgressTitle", "Reset Progress?"),
|
||||
message: t("listView.resetProgressMessage", "Reset all step progress before moving this task?"),
|
||||
confirmLabel: t("listView.resetProgress", "Reset Progress"),
|
||||
cancelLabel: t("listView.cancelMove", "Cancel Move"),
|
||||
danger: true,
|
||||
});
|
||||
if (!resetProgress) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onMoveTask(taskId, column, moveOptions);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
[addToast, columnFlagsById, confirm, isArchivedColumn, onMoveTask, tasks, t]
|
||||
);
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (!sortField || sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
|
||||
return sortDirection === "asc" ? (
|
||||
@@ -2765,11 +2664,8 @@ export function ListView({
|
||||
return (
|
||||
<div
|
||||
key={column}
|
||||
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
|
||||
className={`list-drop-zone${selectedColumn === column ? " active" : ""}`}
|
||||
onClick={() => handleColumnFilter(column)}
|
||||
onDragOver={(e) => handleColumnDragOver(e, column)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
|
||||
@@ -3475,20 +3371,14 @@ export function ListView({
|
||||
? t("tasks.statusPlanning", "Planning")
|
||||
: wipLifecycleBadgeLabel
|
||||
?? getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAgentActive ? " agent-active" : ""}${
|
||||
isDragging ? " dragging" : ""
|
||||
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
|
||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAgentActive ? " agent-active" : ""}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
|
||||
onClick={() => handleRowClick(task)}
|
||||
onContextMenu={(event) => handleListContextMenu(event, task)}
|
||||
onKeyDown={(event) => handleListKeyDown(event, task)}
|
||||
draggable={!isPaused}
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-id={task.id}
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--card-padding);
|
||||
cursor: grab;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
@@ -32,13 +31,6 @@
|
||||
border-color: var(--todo);
|
||||
outline: none;
|
||||
}
|
||||
.card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.card.dragging {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.card.file-drop-target {
|
||||
border: 2px dashed var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 8%, transparent);
|
||||
|
||||
@@ -27,7 +27,6 @@ import { PrCreateModal } from "./PrCreateModal";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { useCoarsePointer } from "../hooks/useCoarsePointer";
|
||||
import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./plannerOverseerBadge";
|
||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||
@@ -75,7 +74,7 @@ import { useColumnLabel } from "../i18n/labels";
|
||||
import { formatCompactLifecycleDate, useLocaleFormat } from "../i18n/format";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuItemDescriptor } from "./TaskContextMenu";
|
||||
import { formatCost, hasTaskCost, taskTotalCost } from "../utils/taskTokenCost";
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
|
||||
@@ -662,8 +661,6 @@ interface TaskCardProps {
|
||||
isPromoting?: boolean;
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. */
|
||||
lastFetchTimeMs?: number;
|
||||
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
||||
disableDrag?: boolean;
|
||||
/** Downstream fan-out entry for this task, computed at board-level. */
|
||||
fanout?: BlockerFanoutEntry;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
@@ -876,7 +873,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
previous.onPromote === next.onPromote &&
|
||||
previous.isPromoting === next.isPromoting &&
|
||||
previous.disableDrag === next.disableDrag &&
|
||||
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
||||
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
||||
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
|
||||
@@ -1026,7 +1022,6 @@ function TaskCardComponent({
|
||||
onPromote,
|
||||
isPromoting = false,
|
||||
lastFetchTimeMs,
|
||||
disableDrag,
|
||||
fanout,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
@@ -1041,7 +1036,6 @@ function TaskCardComponent({
|
||||
const { t } = useTranslation("app");
|
||||
const { locale } = useLocaleFormat();
|
||||
const columnLabel = useColumnLabel();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editDescription, setEditDescription] = useState(task.description || "");
|
||||
@@ -1346,21 +1340,6 @@ function TaskCardComponent({
|
||||
return () => observer.disconnect();
|
||||
}, [isEditing, task.id]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
longPressStartRef.current = null;
|
||||
}
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragging(true);
|
||||
}, [task.id]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const isFileDrag = useCallback((e: React.DragEvent) => {
|
||||
return e.dataTransfer.types.includes("Files");
|
||||
}, []);
|
||||
@@ -1560,7 +1539,6 @@ function TaskCardComponent({
|
||||
const isAwaitingApproval = isTaskAwaitingPlanApproval(task, isIntakeColumn);
|
||||
const isBlockedOnApprovalHold = isTaskBlockedOnApprovalHold(task);
|
||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||
const isArchived = isArchivedColumn;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
|
||||
Pass the card's column traits. Without them the planner-lane clause falls back to the
|
||||
@@ -1639,12 +1617,11 @@ function TaskCardComponent({
|
||||
structural rather than a property of the step count that two independent conditions had to agree on.
|
||||
*/
|
||||
const showQueuedToPlanBadge = showIdleTodoBadge && !queued && awaitingPlanning;
|
||||
// Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch.
|
||||
// On touch-primary devices the `draggable` attribute still arms the browser's
|
||||
// touch-drag heuristic, which intermittently hijacks horizontal swipes meant
|
||||
// to scroll the board. Drop drag on coarse pointers so panning stays reliable.
|
||||
const isCoarsePointer = useCoarsePointer();
|
||||
const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived && !isCoarsePointer; // Disable drag during edit/archived, host embedding, or touch
|
||||
/*
|
||||
FNXC:TaskCardMovement 2026-08-19-18:32:
|
||||
Native task movement is intentionally absent from cards. File drops remain below because
|
||||
attaching files is a separate card interaction, while task transitions stay in the context menu.
|
||||
*/
|
||||
|
||||
// Check if this card can be edited inline
|
||||
/*
|
||||
@@ -2846,11 +2823,11 @@ function TaskCardComponent({
|
||||
task.column,
|
||||
task.prInfo,
|
||||
]);
|
||||
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
|
||||
const contextMenuActions = useMemo<TaskMenuItemDescriptor[]>(() => {
|
||||
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onRevertTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onPlanningMode && !onOpenRefine && !onUpdateTask) {
|
||||
return [];
|
||||
}
|
||||
const actions = [...taskActionMenuModel.actions];
|
||||
const actions: TaskMenuItemDescriptor[] = [...taskActionMenuModel.actions];
|
||||
if (isCompleteColumn && onArchiveTask) {
|
||||
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: handleTaskActionArchive });
|
||||
}
|
||||
@@ -2906,15 +2883,13 @@ function TaskCardComponent({
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const transition of moveTransitions) {
|
||||
actions.push({
|
||||
id: `move-${transition.column}`,
|
||||
label: transition.label,
|
||||
onSelect: () => handleTaskActionMove(transition.column),
|
||||
});
|
||||
}
|
||||
actions.push(...buildTaskMoveMenuItems(
|
||||
moveTransitions,
|
||||
handleTaskActionMove,
|
||||
t("taskDetail.move.moveToParent", "Move to"),
|
||||
));
|
||||
}
|
||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
return actions.filter((action) => "items" in 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, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction, taskMoveColumns]);
|
||||
const hasContextMenuActions = contextMenuActions.length > 0;
|
||||
|
||||
@@ -3140,7 +3115,7 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [addToast, isRetrying, onRetryTask, task.id]);
|
||||
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||
const cardClass = `card${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||
|
||||
const filesChangedButton = (() => {
|
||||
if (isWipColumn) {
|
||||
@@ -3552,9 +3527,6 @@ function TaskCardComponent({
|
||||
className={cardClass}
|
||||
data-id={task.id}
|
||||
data-column={task.column}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
|
||||
@@ -46,6 +46,43 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.task-context-menu__submenu-parent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-context-menu__submenu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-context-menu__submenu-toggle::after {
|
||||
content: "›";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-context-menu__submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% - var(--space-xs));
|
||||
min-width: var(--task-context-menu-min-width);
|
||||
padding: var(--space-xs) 0;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.task-context-menu__submenu--opens-left {
|
||||
right: calc(100% - var(--space-xs));
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.task-context-menu__submenu-item {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-context-menu {
|
||||
--task-context-menu-min-width: min(calc(100vw - var(--space-xl)), calc(var(--space-xl) * 6));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskContextMenu.css";
|
||||
import type { KeyboardEvent, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef } from "react";
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ColumnId, Task, TaskDetail, WorkflowStepResult } from "@fusion/core";
|
||||
import { VALID_TRANSITIONS, isColumn } from "@fusion/core";
|
||||
@@ -37,6 +37,15 @@ export interface TaskMenuActionDescriptor {
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
/** A non-action menu parent whose children are the selectable menu items. */
|
||||
export interface TaskMenuSubmenuDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
items: TaskMenuActionDescriptor[];
|
||||
}
|
||||
|
||||
export type TaskMenuItemDescriptor = TaskMenuActionDescriptor | TaskMenuSubmenuDescriptor;
|
||||
|
||||
export interface TaskMoveActionDescriptor {
|
||||
column: ColumnId;
|
||||
label: string;
|
||||
@@ -386,6 +395,32 @@ export function getTaskMoveTransitions(
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardMovement 2026-08-19-18:35:
|
||||
Task movement is contextual rather than drag-and-drop. Group only multiple legal destinations so
|
||||
one-target menus remain direct, and de-duplicate by column before rendering to prevent supplemental
|
||||
review targets from creating repeated Move to entries.
|
||||
*/
|
||||
export function buildTaskMoveMenuItems(
|
||||
transitions: readonly TaskMoveActionDescriptor[],
|
||||
onSelect: (column: ColumnId) => void,
|
||||
parentLabel: string,
|
||||
): TaskMenuItemDescriptor[] {
|
||||
const uniqueByColumn = new Map<ColumnId, TaskMoveActionDescriptor>();
|
||||
for (const transition of transitions) {
|
||||
if (!uniqueByColumn.has(transition.column)) uniqueByColumn.set(transition.column, transition);
|
||||
}
|
||||
const uniqueTransitions = Array.from(uniqueByColumn.values());
|
||||
const items = uniqueTransitions.map((transition) => ({
|
||||
id: `move-${transition.column}`,
|
||||
label: transition.label,
|
||||
onSelect: () => onSelect(transition.column),
|
||||
}));
|
||||
return items.length > 1
|
||||
? [{ id: "move-to", label: parentLabel, items }]
|
||||
: items;
|
||||
}
|
||||
|
||||
export function getTaskReviewAction(
|
||||
task: Task | TaskDetail,
|
||||
options: Pick<BuildTaskActionMenuModelOptions, "t" | "currentColumnFlags" | "mergeStrategy" | "autoMergeEnabled" | "prAutomationLabel" | "isCheckingPrStatus" | "onMerge" | "onStartPrReview" | "onCheckPrStatus">,
|
||||
@@ -571,7 +606,7 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption
|
||||
}
|
||||
|
||||
export interface TaskContextMenuProps {
|
||||
actions: TaskMenuActionDescriptor[];
|
||||
actions: TaskMenuItemDescriptor[];
|
||||
role?: "menu" | "list";
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
@@ -599,6 +634,9 @@ export function TaskContextMenu({
|
||||
}: TaskContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const touchSelectedActionRef = useRef<{ id: string; at: number } | null>(null);
|
||||
const submenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
|
||||
const [submenuOpensLeft, setSubmenuOpensLeft] = useState(false);
|
||||
|
||||
const selectAction = useCallback((action: TaskMenuActionDescriptor) => {
|
||||
if (action.disabled || action.tone === "note" || !action.onSelect) return;
|
||||
@@ -643,7 +681,40 @@ export function TaskContextMenu({
|
||||
firstItem?.focus({ preventScroll: true });
|
||||
}, [actions, autoFocusFirstItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openSubmenuId) return;
|
||||
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu="${openSubmenuId}"] button:not(:disabled)`)?.focus({ preventScroll: true });
|
||||
}, [openSubmenuId]);
|
||||
|
||||
/*
|
||||
FNXC:TaskCardMovement 2026-08-19-18:52:
|
||||
The root menu is clamped to the viewport, but a nested Move to menu can still overflow from a
|
||||
rightmost Board lane or dock. Measure its rendered edge before paint and flip it left so every
|
||||
legal destination remains reachable with mouse, keyboard, and touch.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!openSubmenuId) {
|
||||
setSubmenuOpensLeft(false);
|
||||
return;
|
||||
}
|
||||
setSubmenuOpensLeft((submenuRef.current?.getBoundingClientRect().right ?? 0) > window.innerWidth);
|
||||
}, [openSubmenuId]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const activeSubmenu = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>("[data-task-submenu]");
|
||||
if (event.key === "Escape" && activeSubmenu) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpenSubmenuId(null);
|
||||
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu-toggle="${activeSubmenu.dataset.taskSubmenu}"]`)?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" && activeSubmenu) {
|
||||
event.preventDefault();
|
||||
setOpenSubmenuId(null);
|
||||
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu-toggle="${activeSubmenu.dataset.taskSubmenu}"]`)?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
|
||||
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
|
||||
if (items.length === 0) return;
|
||||
@@ -662,29 +733,65 @@ export function TaskContextMenu({
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={className} role={role} onKeyDown={handleKeyDown}>
|
||||
{actions.map((action) => {
|
||||
{actions.map((item) => {
|
||||
if ("items" in item) {
|
||||
const isOpen = openSubmenuId === item.id;
|
||||
return (
|
||||
<div className="task-context-menu__submenu-parent" key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${itemClassName} task-context-menu__submenu-toggle`}
|
||||
role={role === "menu" ? "menuitem" : undefined}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
data-task-submenu-toggle={item.id}
|
||||
onClick={() => setOpenSubmenuId((current) => current === item.id ? null : item.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowRight" && event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
setOpenSubmenuId(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={submenuRef}
|
||||
className={`task-context-menu__submenu${submenuOpensLeft ? " task-context-menu__submenu--opens-left" : ""}`}
|
||||
role="menu"
|
||||
data-task-submenu={item.id}
|
||||
>
|
||||
{item.items.map((action) => {
|
||||
const classes = [itemClassName, "task-context-menu__submenu-item"];
|
||||
if (action.tone === "danger") classes.push(dangerItemClassName);
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={classes.join(" ")}
|
||||
role={role === "menu" ? "menuitem" : undefined}
|
||||
disabled={action.disabled}
|
||||
onPointerUp={(event) => handleActionPointerUp(event, action)}
|
||||
onClick={(event) => handleActionClick(event, action)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const action = item;
|
||||
const classes = [itemClassName];
|
||||
if (action.tone === "danger") classes.push(dangerItemClassName);
|
||||
if (action.tone === "note") classes.push(noteItemClassName);
|
||||
|
||||
const defaultNode = action.tone === "note" ? (
|
||||
<span key={action.id} className={classes.join(" ")} role="note">
|
||||
{action.label}
|
||||
</span>
|
||||
<span key={action.id} className={classes.join(" ")} role="note">{action.label}</span>
|
||||
) : (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={classes.join(" ")}
|
||||
role={role === "menu" ? "menuitem" : undefined}
|
||||
disabled={action.disabled}
|
||||
onPointerUp={(event) => handleActionPointerUp(event, action)}
|
||||
onClick={(event) => handleActionClick(event, action)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
<button key={action.id} type="button" className={classes.join(" ")} role={role === "menu" ? "menuitem" : undefined} disabled={action.disabled} onPointerUp={(event) => handleActionPointerUp(event, action)} onClick={(event) => handleActionClick(event, action)}>{action.label}</button>
|
||||
);
|
||||
|
||||
return <Fragment key={action.id}>{renderAction ? renderAction(action, defaultNode) : defaultNode}</Fragment>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// FN-1416/FN-6029: Board-level coverage of the canDropTask drag pre-check (R17).
|
||||
//
|
||||
// Board now passes one-argument per-column wrappers directly to <Column> in the
|
||||
// selected-workflow rendering path, while <Lane> still adapts the canonical
|
||||
// three-argument decision for multi-lane rendering. These tests exercise the
|
||||
// pure Board decision seam directly so unrendered-column branches (especially
|
||||
// unknownColumn) remain covered without stale Lane mocking.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import { getBoardCanDropTaskRejection } from "../boardCanDropTask";
|
||||
|
||||
const DEFAULT_WORKFLOW = "builtin:coding";
|
||||
const CUSTOM_WORKFLOW = "WF-001";
|
||||
|
||||
// builtin:coding columns (in-progress counts toward wip; todo does not).
|
||||
const defaultColumns = [
|
||||
{ id: "triage", name: "Triage", flags: {} },
|
||||
{ id: "todo", name: "Todo", flags: {} },
|
||||
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
|
||||
{ id: "in-review", name: "In Review", flags: {} },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
];
|
||||
const customColumns = [
|
||||
{ id: "c-intake", name: "Intake", flags: { intake: true } },
|
||||
{ id: "c-run", name: "Run", flags: { countsTowardWip: true } },
|
||||
{ id: "c-done", name: "Done", flags: { complete: true } },
|
||||
];
|
||||
|
||||
function makeTask(id: string, column: string): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
description: id,
|
||||
column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function boardWorkflows(taskWorkflowIds: Record<string, string> = {}): BoardWorkflowsPayload {
|
||||
return {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: DEFAULT_WORKFLOW,
|
||||
workflows: [
|
||||
{ id: DEFAULT_WORKFLOW, name: "Coding", columns: defaultColumns },
|
||||
{ id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns },
|
||||
],
|
||||
taskWorkflowIds,
|
||||
};
|
||||
}
|
||||
|
||||
function canDrop({
|
||||
workflows = boardWorkflows(),
|
||||
tasks,
|
||||
maxConcurrent = 2,
|
||||
taskId = "FN-1",
|
||||
targetColumnId,
|
||||
laneWorkflowId = DEFAULT_WORKFLOW,
|
||||
}: {
|
||||
workflows?: BoardWorkflowsPayload | null | undefined;
|
||||
tasks: Task[];
|
||||
maxConcurrent?: number;
|
||||
taskId?: string;
|
||||
targetColumnId: string;
|
||||
laneWorkflowId?: string;
|
||||
}) {
|
||||
return getBoardCanDropTaskRejection({
|
||||
boardWorkflows: workflows,
|
||||
tasks,
|
||||
maxConcurrent,
|
||||
taskId,
|
||||
targetColumnId,
|
||||
laneWorkflowId,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Board canDropTask pre-check (FN-1416/FN-6029)", () => {
|
||||
it("cross-workflow drag returns workflowMismatch", () => {
|
||||
const tasks = [makeTask("FN-1", "todo")];
|
||||
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
|
||||
tasks,
|
||||
targetColumnId: "c-run",
|
||||
laneWorkflowId: CUSTOM_WORKFLOW,
|
||||
})).toBe("board.rejection.workflowMismatch");
|
||||
});
|
||||
|
||||
it("unknown target column in the source workflow returns unknownColumn", () => {
|
||||
const tasks = [makeTask("FN-1", "todo")];
|
||||
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
|
||||
tasks,
|
||||
targetColumnId: "does-not-exist",
|
||||
})).toBe("board.rejection.unknownColumn");
|
||||
});
|
||||
|
||||
it("full wip column returns capacityExhausted", () => {
|
||||
// maxConcurrent: 2; two cards already occupy in-progress in the default
|
||||
// workflow. Dragging a third from todo into in-progress must reject.
|
||||
const tasks = [
|
||||
makeTask("FN-1", "todo"),
|
||||
makeTask("FN-2", "in-progress"),
|
||||
makeTask("FN-3", "in-progress"),
|
||||
];
|
||||
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({
|
||||
"FN-1": DEFAULT_WORKFLOW,
|
||||
"FN-2": DEFAULT_WORKFLOW,
|
||||
"FN-3": DEFAULT_WORKFLOW,
|
||||
}),
|
||||
tasks,
|
||||
targetColumnId: "in-progress",
|
||||
})).toBe("board.rejection.capacityExhausted");
|
||||
});
|
||||
|
||||
it("valid same-workflow drops under capacity return null", () => {
|
||||
const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")];
|
||||
const workflows = boardWorkflows({ "FN-1": DEFAULT_WORKFLOW, "FN-2": DEFAULT_WORKFLOW });
|
||||
|
||||
expect(canDrop({ workflows, tasks, targetColumnId: "in-progress" })).toBeNull();
|
||||
expect(canDrop({ workflows, tasks, targetColumnId: "in-review" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when boardWorkflows is undefined", () => {
|
||||
expect(canDrop({
|
||||
workflows: undefined,
|
||||
tasks: [makeTask("FN-1", "todo")],
|
||||
targetColumnId: "in-progress",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the source task is missing", () => {
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
|
||||
tasks: [makeTask("FN-2", "todo")],
|
||||
taskId: "FN-1",
|
||||
targetColumnId: "in-progress",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the source workflow is missing from boardWorkflows", () => {
|
||||
expect(canDrop({
|
||||
workflows: {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: DEFAULT_WORKFLOW,
|
||||
workflows: [{ id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns }],
|
||||
taskWorkflowIds: { "FN-1": DEFAULT_WORKFLOW },
|
||||
},
|
||||
tasks: [makeTask("FN-1", "todo")],
|
||||
targetColumnId: "in-progress",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the default workflow when a task has no workflow id", () => {
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({}),
|
||||
tasks: [makeTask("FN-1", "todo")],
|
||||
targetColumnId: "does-not-exist",
|
||||
laneWorkflowId: DEFAULT_WORKFLOW,
|
||||
})).toBe("board.rejection.unknownColumn");
|
||||
});
|
||||
|
||||
it("allows same-column wip drops even when the column is at capacity", () => {
|
||||
const tasks = [
|
||||
makeTask("FN-1", "in-progress"),
|
||||
makeTask("FN-2", "in-progress"),
|
||||
makeTask("FN-3", "in-progress"),
|
||||
];
|
||||
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({
|
||||
"FN-1": DEFAULT_WORKFLOW,
|
||||
"FN-2": DEFAULT_WORKFLOW,
|
||||
"FN-3": DEFAULT_WORKFLOW,
|
||||
}),
|
||||
tasks,
|
||||
targetColumnId: "in-progress",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("does not count occupants from other workflows toward this workflow's capacity", () => {
|
||||
const tasks = [
|
||||
makeTask("FN-1", "todo"),
|
||||
makeTask("FN-2", "in-progress"),
|
||||
makeTask("FN-3", "in-progress"),
|
||||
makeTask("FN-4", "in-progress"),
|
||||
];
|
||||
|
||||
expect(canDrop({
|
||||
workflows: boardWorkflows({
|
||||
"FN-1": DEFAULT_WORKFLOW,
|
||||
"FN-2": DEFAULT_WORKFLOW,
|
||||
"FN-3": CUSTOM_WORKFLOW,
|
||||
"FN-4": CUSTOM_WORKFLOW,
|
||||
}),
|
||||
tasks,
|
||||
targetColumnId: "in-progress",
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ vi.mock("../Column", () => ({
|
||||
</button>
|
||||
) : null}
|
||||
{tasks.map((task) => (
|
||||
<article key={task.id} data-id={task.id} draggable data-testid={`board-task-card-${task.id}`}>
|
||||
<article key={task.id} data-id={task.id} data-testid={`board-task-card-${task.id}`}>
|
||||
{task.title ?? task.description ?? task.id}
|
||||
</article>
|
||||
))}
|
||||
@@ -2074,7 +2074,11 @@ describe("Board", () => {
|
||||
expect(board).not.toHaveClass("is-mouse-panning");
|
||||
|
||||
const card = screen.getByTestId("board-task-card-FN-1");
|
||||
expect(card).toHaveAttribute("draggable", "true");
|
||||
/*
|
||||
FNXC:TaskCardInteraction 2026-08-19-19:07:
|
||||
Native task dragging is retired; cards remain ordinary pointer targets so board panning excludes them without preserving the drag contract.
|
||||
*/
|
||||
expect(card).not.toHaveAttribute("draggable");
|
||||
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" });
|
||||
|
||||
@@ -344,30 +344,7 @@ describe("Column workflow mode (U9)", () => {
|
||||
expect(document.querySelector(".column-menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a translated rejection messageKey on a failed drop (snap-back)", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onMoveTask = vi.fn().mockRejectedValue({
|
||||
details: { code: "merge-blocked", messageKey: "board.rejection.mergeBlocked", retryable: false },
|
||||
});
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column={"done" as ColumnType}
|
||||
workflowMode
|
||||
columnDisplayName="Done"
|
||||
columnFlags={{ complete: true }}
|
||||
addToast={addToast}
|
||||
onMoveTask={onMoveTask}
|
||||
tasks={[]}
|
||||
/>,
|
||||
);
|
||||
const columnEl = document.querySelector('[data-column="done"]') as HTMLElement;
|
||||
fireEvent.drop(columnEl, { dataTransfer: { getData: () => "FN-99" } });
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalled());
|
||||
// The toast surfaces the translated merge-blocked copy (not the raw key).
|
||||
expect(addToast.mock.calls[0][0]).toContain("merge step");
|
||||
expect(addToast.mock.calls[0][1]).toBe("error");
|
||||
});
|
||||
|
||||
|
||||
it("renders a Promote affordance on hold-column cards", () => {
|
||||
render(
|
||||
@@ -687,21 +664,7 @@ describe("Column pagination", () => {
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(60);
|
||||
});
|
||||
|
||||
it("still handles drops when pagination is enabled", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} />);
|
||||
|
||||
const column = screen.getByText("110").closest(".column") as HTMLElement;
|
||||
const dataTransfer = {
|
||||
getData: vi.fn().mockReturnValue("KB-999"),
|
||||
dropEffect: "move",
|
||||
};
|
||||
|
||||
fireEvent.drop(column, { dataTransfer });
|
||||
|
||||
expect(onMoveTask).toHaveBeenCalledWith("KB-999", "todo", undefined);
|
||||
});
|
||||
|
||||
it("does not paginate at the threshold boundary", () => {
|
||||
const tasks = Array.from({ length: 100 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
@@ -1299,67 +1262,6 @@ describe("Column Done action menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column same-column drop", () => {
|
||||
it("does not call onMoveTask when dropping task into its current column", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
const addToast = vi.fn();
|
||||
const tasks = [{ ...makeTask("FN-001"), column: "todo" as ColumnType }];
|
||||
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} addToast={addToast} />);
|
||||
|
||||
const columnEl = screen.getByRole("heading", { name: "Todo" }).closest(".column") as HTMLElement;
|
||||
const dataTransfer = {
|
||||
getData: vi.fn().mockReturnValue("FN-001"),
|
||||
dropEffect: "move",
|
||||
};
|
||||
|
||||
fireEvent.drop(columnEl, { dataTransfer });
|
||||
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes drag-over styling after drop even on same column", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
const tasks = [{ ...makeTask("FN-001"), column: "todo" as ColumnType }];
|
||||
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} />);
|
||||
|
||||
const columnEl = screen.getByRole("heading", { name: "Todo" }).closest(".column") as HTMLElement;
|
||||
const dataTransfer = {
|
||||
getData: vi.fn().mockReturnValue("FN-001"),
|
||||
dropEffect: "move",
|
||||
};
|
||||
|
||||
// First trigger dragOver to set drag-over state
|
||||
fireEvent.dragOver(columnEl, { dataTransfer });
|
||||
expect(columnEl.className).toContain("drag-over");
|
||||
|
||||
// Then drop - should remove drag-over class even for same-column drop
|
||||
fireEvent.drop(columnEl, { dataTransfer });
|
||||
expect(columnEl.className).not.toContain("drag-over");
|
||||
});
|
||||
|
||||
it("calls onMoveTask when dropping task into a different column", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
const addToast = vi.fn();
|
||||
// Task is in "todo" column - but we're dropping it onto "in-review" column
|
||||
// The "in-review" column should have 0 tasks initially
|
||||
const tasksInTargetColumn: Task[] = [];
|
||||
|
||||
// Dropping into "in-review" column (which has 0 tasks)
|
||||
render(<Column {...defaultProps} column="in-review" tasks={tasksInTargetColumn} onMoveTask={onMoveTask} addToast={addToast} />);
|
||||
|
||||
const columnEl = screen.getAllByText("0")[0].closest(".column") as HTMLElement;
|
||||
const dataTransfer = {
|
||||
getData: vi.fn().mockReturnValue("FN-001"),
|
||||
dropEffect: "move",
|
||||
};
|
||||
|
||||
fireEvent.drop(columnEl, { dataTransfer });
|
||||
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "in-review", undefined);
|
||||
});
|
||||
|
||||
describe("favorite model prop forwarding (FN-770)", () => {
|
||||
it("forwards favoriteProviders, favoriteModels, and toggle callbacks to QuickEntryBox", () => {
|
||||
@@ -1407,7 +1309,6 @@ describe("Column same-column drop", () => {
|
||||
expect(quickEntry.getAttribute("data-has-toggle-model-favorite")).toBe("no");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column PluginSlot integration", () => {
|
||||
it("renders PluginSlot for board-column-footer", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ The mock records each TaskCard mount so tests can assert row identity stability:
|
||||
const { taskCardMountLog } = vi.hoisted(() => ({ taskCardMountLog: [] as string[] }));
|
||||
|
||||
vi.mock("../TaskCard", () => ({
|
||||
TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask, disableDrag }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; onReviseTask?: (task: Task) => void; disableDrag?: boolean }) => {
|
||||
TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; onReviseTask?: (task: Task) => void }) => {
|
||||
useEffect(() => {
|
||||
taskCardMountLog.push(task.id);
|
||||
}, []);
|
||||
@@ -19,7 +19,6 @@ vi.mock("../TaskCard", () => ({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`mock-task-card-${task.id}`}
|
||||
data-disable-drag={String(disableDrag)}
|
||||
data-has-delete={String(Boolean(onDeleteTask))}
|
||||
data-complete={String(taskColumnFlags?.complete === true)}
|
||||
onClick={() => onOpenDetail(task)}
|
||||
@@ -100,8 +99,6 @@ describe("DockTaskList", () => {
|
||||
expect(screen.getByTestId("dock-task-list")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dock-task-list-row-FN-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dock-task-list-row-FN-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-task-card-FN-1")).toHaveAttribute("data-disable-drag", "true");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mock-task-card-FN-2"));
|
||||
expect(onOpenTask).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenTask).toHaveBeenCalledWith(second);
|
||||
|
||||
@@ -74,8 +74,6 @@ const baseProps = () => ({
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: vi.fn().mockResolvedValue({} as Task),
|
||||
onPromote: vi.fn().mockResolvedValue(undefined),
|
||||
canDropTask: vi.fn().mockReturnValue(null),
|
||||
getDraggingTaskId: vi.fn().mockReturnValue(null),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
});
|
||||
@@ -202,31 +200,7 @@ describe("Lane", () => {
|
||||
await waitFor(() => expect((screen.getByTestId("card-promote-FN-8") as HTMLButtonElement).disabled).toBe(false));
|
||||
});
|
||||
|
||||
it("prevents the drop (no-move) when canDropTask returns a rejection key", () => {
|
||||
const props = baseProps();
|
||||
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
|
||||
props.canDropTask = vi.fn().mockReturnValue("board.rejection.workflowMismatch");
|
||||
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
|
||||
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
|
||||
const preventDefault = vi.fn();
|
||||
fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" }, preventDefault });
|
||||
// Rejection → preventDefault NOT called → the browser refuses the drop.
|
||||
expect(props.canDropTask).toHaveBeenCalledWith("FN-DRAG", "in-progress", "builtin:coding");
|
||||
// Inline feedback surfaces the translated rejection.
|
||||
expect(screen.getByTestId("column-inline-feedback")).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows the drop (preventDefault) when canDropTask returns null", () => {
|
||||
const props = baseProps();
|
||||
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
|
||||
props.canDropTask = vi.fn().mockReturnValue(null);
|
||||
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
|
||||
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
|
||||
// fireEvent.dragOver returns false when a handler called preventDefault.
|
||||
const notPrevented = fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" } });
|
||||
expect(notPrevented).toBe(false);
|
||||
expect(screen.queryByTestId("column-inline-feedback")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -1207,6 +1207,14 @@ describe("ListView", () => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument();
|
||||
/*
|
||||
FNXC:TaskMovementContextMenu 2026-08-19-18:37:
|
||||
When a task has several legal destinations, list movement is grouped under one
|
||||
accessible Move to entry instead of presenting a noisy run of sibling actions.
|
||||
*/
|
||||
expect(screen.getByRole("menuitem", { name: "Move to" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Move to In progress" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Move to In progress" })).toBeInTheDocument();
|
||||
expect(failedRow).not.toHaveClass("list-row--selected");
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
@@ -1228,7 +1236,7 @@ describe("ListView", () => {
|
||||
read "Back to In Progress" — they go through the no-metadata fallback, which uses
|
||||
the legacy column label map.
|
||||
*/
|
||||
expect(screen.getByRole("menuitem", { name: "Back to In progress" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Move to", exact: true })).toBeInTheDocument();
|
||||
|
||||
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-006"]') as HTMLElement, { clientX: 40, clientY: 50 });
|
||||
expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument();
|
||||
@@ -1257,6 +1265,7 @@ describe("ListView", () => {
|
||||
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-007"]') as HTMLElement, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" }));
|
||||
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-007", "todo", { preserveProgress: true }));
|
||||
|
||||
@@ -3343,174 +3352,7 @@ describe("ListView", () => {
|
||||
expect(todoZone?.textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("handles drag and drop to move tasks between columns", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "triage" })];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
|
||||
// Simulate drag start
|
||||
fireEvent.dragStart(row, {
|
||||
dataTransfer: {
|
||||
setData: vi.fn(),
|
||||
effectAllowed: "move",
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate drop on todo column drop zone (use querySelector for specificity)
|
||||
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
||||
fireEvent.dragOver(todoZone, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: { dropEffect: "move" },
|
||||
});
|
||||
|
||||
fireEvent.drop(todoZone, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "todo", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts to preserve progress when dropping task with completed steps to todo", async () => {
|
||||
const tasks = [createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress",
|
||||
steps: [
|
||||
{ title: "Step 1", status: "done" },
|
||||
{ title: "Step 2", status: "pending" },
|
||||
],
|
||||
})];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
fireEvent.dragStart(row, {
|
||||
dataTransfer: {
|
||||
setData: vi.fn(),
|
||||
effectAllowed: "move",
|
||||
},
|
||||
});
|
||||
|
||||
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
||||
fireEvent.drop(todoZone, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Preserve Progress?",
|
||||
cancelLabel: "Reset Progress",
|
||||
}));
|
||||
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts to preserve progress when dropping task with completed steps to a workflow hold column", async () => {
|
||||
const tasks = [createMockTask({
|
||||
id: "FN-001",
|
||||
column: "doing",
|
||||
steps: [
|
||||
{ title: "Step 1", status: "done" },
|
||||
{ title: "Step 2", status: "pending" },
|
||||
],
|
||||
})];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "queue", name: "Queue", flags: { hold: true } },
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
await waitFor(() => expect(document.querySelector('[data-column="queue"].list-drop-zone')).toBeTruthy());
|
||||
|
||||
fireEvent.drop(document.querySelector('[data-column="queue"].list-drop-zone')!, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Preserve Progress?",
|
||||
}));
|
||||
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "queue", { preserveProgress: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set draggable for paused tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: true })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
// Paused tasks should have draggable="false"
|
||||
expect(row.getAttribute("draggable")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets draggable for non-paused tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: false })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
// Non-paused tasks should have draggable="true"
|
||||
expect(row.getAttribute("draggable")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows error toast when onMoveTask fails during drag and drop", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "triage" })];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.reject(new Error("Move failed")));
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
|
||||
fireEvent.dragStart(row, {
|
||||
dataTransfer: {
|
||||
setData: vi.fn(),
|
||||
effectAllowed: "move",
|
||||
},
|
||||
});
|
||||
|
||||
// Use querySelector to find the specific drop zone
|
||||
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
||||
fireEvent.drop(todoZone, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Move failed", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays full description in title cell when no title exists", () => {
|
||||
const longDescription = "A".repeat(100);
|
||||
|
||||
@@ -999,6 +999,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" }));
|
||||
|
||||
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }));
|
||||
@@ -1945,43 +1946,14 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByTestId("branch-progress-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps native card dragging enabled by default", () => {
|
||||
/*
|
||||
FNXC:TaskMovementContextMenu 2026-08-19-18:37:
|
||||
Task cards must never expose native HTML drag behavior. Movement is intentional
|
||||
context-menu work, which keeps board and touch input paths consistent.
|
||||
*/
|
||||
it("does not expose native card dragging", () => {
|
||||
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||
const card = container.querySelector(".card") as HTMLElement;
|
||||
expect(card.getAttribute("draggable")).toBe("true");
|
||||
});
|
||||
|
||||
it("disables native card dragging when disableDrag is true", () => {
|
||||
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} disableDrag={true} />);
|
||||
const card = container.querySelector(".card") as HTMLElement;
|
||||
expect(card.getAttribute("draggable")).toBe("false");
|
||||
});
|
||||
|
||||
// FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move
|
||||
// cards via touch, but a `draggable` element still arms the browser's touch-drag
|
||||
// heuristic, which intermittently hijacks horizontal swipes meant to scroll the
|
||||
// mobile board. On touch-primary (coarse pointer) devices we drop `draggable`.
|
||||
it("disables native card dragging on touch-primary (coarse pointer) devices", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
try {
|
||||
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||
const card = container.querySelector(".card") as HTMLElement;
|
||||
expect(card.getAttribute("draggable")).toBe("false");
|
||||
// No drag-start handler should be wired on touch (would arm the heuristic).
|
||||
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true });
|
||||
const prevented = !card.dispatchEvent(dragStart);
|
||||
expect(prevented).toBe(false);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
expect(container.querySelector(".card")).not.toHaveAttribute("draggable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -4815,6 +4787,7 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Done (no merge)" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
|
||||
@@ -4846,6 +4819,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
|
||||
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
|
||||
expect(planningMoves).toHaveLength(1);
|
||||
@@ -4856,6 +4830,7 @@ describe("TaskCard", () => {
|
||||
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Move to", exact: true }));
|
||||
await waitFor(() => expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1));
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
|
||||
});
|
||||
@@ -4880,6 +4855,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
|
||||
expect(planningMoves).toHaveLength(1);
|
||||
fireEvent.click(planningMoves[0]);
|
||||
@@ -4895,6 +4871,7 @@ describe("TaskCard", () => {
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
|
||||
expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1);
|
||||
});
|
||||
@@ -4916,6 +4893,7 @@ describe("TaskCard", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
|
||||
@@ -4935,6 +4913,7 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
|
||||
@@ -7653,16 +7632,7 @@ describe("TaskCard memo comparator provenance behavior", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when disableDrag changes", () => {
|
||||
const task = makeTask();
|
||||
|
||||
expect(
|
||||
__test_areTaskCardPropsEqual(
|
||||
{ task, onOpenDetail: noop, addToast: noop, disableDrag: false } as any,
|
||||
{ task, onOpenDetail: noop, addToast: noop, disableDrag: true } as any,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when board context-menu action handlers change", () => {
|
||||
const task = makeTask();
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel } from "../TaskContextMenu";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems } from "../TaskContextMenu";
|
||||
|
||||
const t = ((key: string, fallback: string, vars?: Record<string, string>) => {
|
||||
if (!vars) return fallback;
|
||||
@@ -332,6 +332,59 @@ describe("TaskContextMenu shared task action model", () => {
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" });
|
||||
expect(pause).toHaveFocus();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardMovement 2026-08-19-18:35:
|
||||
Multiple contextual transitions must use one expandable parent rather than flattening repeated
|
||||
Move to actions. The hierarchy is tested at the shared menu seam so Board and List stay aligned.
|
||||
*/
|
||||
it("groups unique multiple moves in one accessible submenu and dispatches a child once", () => {
|
||||
const onMove = vi.fn();
|
||||
const items = buildTaskMoveMenuItems([
|
||||
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
|
||||
{ column: "in-review", label: "Move to Review", primaryLabel: "Move to Review" },
|
||||
{ column: "todo", label: "Duplicate should be ignored", primaryLabel: "Duplicate should be ignored" },
|
||||
], onMove, "Move to");
|
||||
expect(items).toHaveLength(1);
|
||||
expect("items" in items[0] && items[0].items.map((item) => item.id)).toEqual(["move-todo", "move-in-review"]);
|
||||
|
||||
render(<TaskContextMenu actions={items} />);
|
||||
const parent = screen.getByRole("menuitem", { name: "Move to", exact: true });
|
||||
fireEvent.keyDown(parent, { key: "ArrowRight" });
|
||||
const todo = screen.getByRole("menuitem", { name: "Move to Todo" });
|
||||
expect(todo).toHaveFocus();
|
||||
fireEvent.pointerUp(todo, { pointerType: "touch", pointerId: 1 });
|
||||
fireEvent.click(todo);
|
||||
expect(onMove).toHaveBeenCalledTimes(1);
|
||||
expect(onMove).toHaveBeenCalledWith("todo");
|
||||
});
|
||||
|
||||
it("opens a Move to submenu left when its right edge would overflow the viewport", () => {
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
right: window.innerWidth + 1,
|
||||
} as DOMRect);
|
||||
const items = buildTaskMoveMenuItems([
|
||||
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
|
||||
{ column: "in-review", label: "Move to Review", primaryLabel: "Move to Review" },
|
||||
], vi.fn(), "Move to");
|
||||
|
||||
render(<TaskContextMenu actions={items} />);
|
||||
const parent = screen.getByRole("menuitem", { name: "Move to", exact: true });
|
||||
fireEvent.click(parent);
|
||||
|
||||
expect(parent.parentElement?.querySelector(".task-context-menu__submenu"))
|
||||
.toHaveClass("task-context-menu__submenu--opens-left");
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps zero moves absent and a single move direct", () => {
|
||||
expect(buildTaskMoveMenuItems([], vi.fn(), "Move to")).toEqual([]);
|
||||
const [item] = buildTaskMoveMenuItems([
|
||||
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
|
||||
], vi.fn(), "Move to");
|
||||
expect(item).toMatchObject({ id: "move-todo", label: "Move to Todo" });
|
||||
expect("items" in item).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { BoardWorkflowsPayload } from "../api";
|
||||
|
||||
export interface BoardCanDropTaskInput {
|
||||
boardWorkflows: BoardWorkflowsPayload | null | undefined;
|
||||
tasks: Task[];
|
||||
maxConcurrent: number;
|
||||
taskId: string;
|
||||
targetColumnId: string;
|
||||
laneWorkflowId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical Board drag pre-check (R17). Deterministic rejections return a stable
|
||||
* i18n message key; `null` means the pre-check allows the drop or cannot decide.
|
||||
*/
|
||||
export function getBoardCanDropTaskRejection({
|
||||
boardWorkflows,
|
||||
tasks,
|
||||
maxConcurrent,
|
||||
taskId,
|
||||
targetColumnId,
|
||||
laneWorkflowId,
|
||||
}: BoardCanDropTaskInput): string | null {
|
||||
if (!boardWorkflows) return null;
|
||||
|
||||
const sourceTask = tasks.find((task) => task.id === taskId);
|
||||
if (!sourceTask) return null;
|
||||
|
||||
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
|
||||
// Cross-lane drag never switches workflows (R17).
|
||||
if (sourceWorkflowId !== laneWorkflowId) {
|
||||
return "board.rejection.workflowMismatch";
|
||||
}
|
||||
|
||||
const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === laneWorkflowId);
|
||||
if (!workflow) return null;
|
||||
|
||||
const targetColumn = workflow.columns.find((column) => column.id === targetColumnId);
|
||||
if (!targetColumn) return "board.rejection.unknownColumn";
|
||||
|
||||
// Capacity pre-check: a wip-flagged column that is already full rejects.
|
||||
if (targetColumn.flags.countsTowardWip) {
|
||||
const occupants = tasks.filter(
|
||||
(task) => task.column === targetColumnId
|
||||
&& (boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
|
||||
).length;
|
||||
// The default workflow's in-progress limit is maxConcurrent; custom limits
|
||||
// are enforced authoritatively server-side (the 409 fallback still snaps back).
|
||||
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
|
||||
return "board.rejection.capacityExhausted";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -447,7 +447,6 @@ export function MainContent({
|
||||
projectId={currentProject?.id}
|
||||
onOpenDetail={openPluginTaskDetail}
|
||||
addToast={addToast}
|
||||
disableDrag={true}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMerge}
|
||||
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
|
||||
|
||||
@@ -186,7 +186,6 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
|
||||
onDeleteTask={input.onDeleteTask}
|
||||
addToast={input.addToast}
|
||||
disableDrag={true}
|
||||
prAuthAvailable={input.prAuthAvailable}
|
||||
autoMergeEnabled={input.autoMerge}
|
||||
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
|
||||
|
||||
@@ -707,15 +707,6 @@ html *::after {
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
html .card.dragging,
|
||||
html .card.dragging *,
|
||||
html .list-row.dragging,
|
||||
html .list-row.dragging *,
|
||||
html .column.drag-over,
|
||||
html .column.drag-over * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
[data-theme="light"] .agent-dialog-overlay,
|
||||
[data-theme="light"] .chat-new-dialog-backdrop {
|
||||
background: transparent;
|
||||
@@ -1188,11 +1179,6 @@ rather than relying on the absence of a declaration.
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.column.drag-over {
|
||||
border-color: var(--todo);
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user