From 3cbcb20ecd875b8aa14dbd846ece3d76cb4965d9 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 06:31:07 -0600 Subject: [PATCH] fix: cross-column drag-and-drop + suppress tooltip during drag --- web/src/components/board/KanbanBoard.tsx | 15 +++- web/src/components/board/KanbanColumn.tsx | 3 + web/src/components/task/TaskCard.tsx | 8 +- web/src/hooks/useBoardDragDrop.ts | 95 +++++++++++++++++++---- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index de919e81..ca88117d 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -5,7 +5,7 @@ import { BoardLoadingSkeleton } from './BoardLoadingSkeleton'; import { TaskDetailPanel } from '@/components/task/TaskDetailPanel'; import type { TaskStatus, Task } from '@veritas-kanban/shared'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; -import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core'; +import { DndContext, DragOverlay } from '@dnd-kit/core'; import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; import { TaskCard } from '@/components/task/TaskCard'; import { useKeyboard } from '@/hooks/useKeyboard'; @@ -99,7 +99,15 @@ export function KanbanBoard() { setOnMoveTask(handleMoveTask); // Drag and drop logic - const { activeTask, sensors, handleDragStart, handleDragOver, handleDragEnd } = useBoardDragDrop({ + const { + activeTask, + isDragActive, + sensors, + collisionDetection, + handleDragStart, + handleDragOver, + handleDragEnd, + } = useBoardDragDrop({ tasks: filteredTasks, tasksByStatus, columns: COLUMNS, @@ -152,7 +160,7 @@ export function KanbanBoard() { {featureSettings.board.enableDragAndDrop ? ( ))} diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx index f3449905..63c15067 100644 --- a/web/src/components/board/KanbanColumn.tsx +++ b/web/src/components/board/KanbanColumn.tsx @@ -16,6 +16,7 @@ interface KanbanColumnProps { allTasks: Task[]; onTaskClick?: (task: Task) => void; selectedTaskId?: string | null; + isDragActive?: boolean; } const columnColors: Record = { @@ -32,6 +33,7 @@ export function KanbanColumn({ allTasks, onTaskClick, selectedTaskId, + isDragActive, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); const { settings: featureSettings } = useFeatureSettings(); @@ -97,6 +99,7 @@ export function KanbanColumn({ isBlocked={blocked} blockerTitles={blockers.map((b) => b.title)} cardMetrics={taskMetrics} + isDragActive={isDragActive} /> ); diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 9e81d7a6..876ce457 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -56,6 +56,7 @@ const blockedCategoryInfo: Record< interface TaskCardProps { task: Task; isDragging?: boolean; + isDragActive?: boolean; onClick?: () => void; isSelected?: boolean; isBlocked?: boolean; @@ -72,6 +73,7 @@ interface TaskCardProps { function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolean { // Simple scalar/boolean props if (prev.isDragging !== next.isDragging) return false; + if (prev.isDragActive !== next.isDragActive) return false; if (prev.isSelected !== next.isSelected) return false; if (prev.isBlocked !== next.isBlocked) return false; // onClick is intentionally skipped — always a new closure but functionally equivalent @@ -140,6 +142,7 @@ const priorityColors: Record = { export const TaskCard = memo(function TaskCard({ task, isDragging, + isDragActive, onClick, isSelected, isBlocked, @@ -223,9 +226,12 @@ export const TaskCard = memo(function TaskCard({ }; }, [task.subtasks]); + // Suppress the outer card tooltip entirely during any drag operation + const suppressCardTooltip = isDragActive || isDragging || isCurrentlyDragging; + return ( - +
; + collisionDetection: CollisionDetection; handleDragStart: (event: DragStartEvent) => void; handleDragOver: (event: DragOverEvent) => void; handleDragEnd: (event: DragEndEvent) => void; @@ -34,6 +39,7 @@ export function useBoardDragDrop({ onReorder, }: UseBoardDragDropOptions): UseBoardDragDropReturn { const [activeTask, setActiveTask] = useState(null); + const lastOverColumnRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -43,6 +49,41 @@ export function useBoardDragDrop({ }) ); + const columnIds = columns.map((c) => c.id); + + // Custom collision detection for kanban cross-column support. + // pointerWithin alone misses when the pointer is between cards inside a column, + // so we fall back to rectIntersection which catches overlapping rects. + // We always prefer column droppables over task droppables for cross-container moves. + const collisionDetection: CollisionDetection = useCallback( + (args) => { + // First try pointerWithin — most accurate when pointer is directly over a droppable + const pointerCollisions = pointerWithin(args); + + if (pointerCollisions.length > 0) { + // If we hit a column droppable, prefer it for cross-column detection + const columnCollision = pointerCollisions.find((c) => + columnIds.includes(c.id as TaskStatus) + ); + // Also check for task collisions within the column + const taskCollision = pointerCollisions.find( + (c) => !columnIds.includes(c.id as TaskStatus) + ); + + // If we found a task inside the target column, prefer it (for precise positioning) + if (taskCollision) return [taskCollision]; + // Otherwise use the column (for drops into empty areas or between tasks) + if (columnCollision) return [columnCollision]; + + return pointerCollisions; + } + + // Fallback to rect intersection when pointer isn't directly within any droppable + return rectIntersection(args); + }, + [columnIds] + ); + // Find which column a task belongs to const findColumnForTask = useCallback( (taskId: string): TaskStatus | null => { @@ -61,32 +102,52 @@ export function useBoardDragDrop({ const task = tasks?.find((t) => t.id === event.active.id); if (task) { setActiveTask(task); + lastOverColumnRef.current = null; } }, [tasks] ); - const handleDragOver = useCallback((_event: DragOverEvent) => { - // We don't need real-time container switching since our columns - // are droppable targets and tasks are sortable within them. - // The visual reordering within a column is handled by SortableContext. - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - setActiveTask(null); - + const handleDragOver = useCallback( + (event: DragOverEvent) => { const { active, over } = event; if (!over) return; const activeId = active.id as string; const overId = over.id as string; + // Determine which column the active task is currently in + const activeColumn = findColumnForTask(activeId); + if (!activeColumn) return; + + // Determine the target column — either the column directly, or the column a task belongs to + const isOverColumn = columnIds.includes(overId as TaskStatus); + const overColumn = isOverColumn ? (overId as TaskStatus) : findColumnForTask(overId); + + if (!overColumn || activeColumn === overColumn) return; + + // Track which column we're over for handleDragEnd + lastOverColumnRef.current = overColumn; + }, + [columnIds, findColumnForTask] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveTask(null); + lastOverColumnRef.current = null; + + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + // Check if dropped on a column (status) directly - const isOverColumn = columns.some((c) => c.id === overId); + const isOverColumn = columnIds.includes(overId as TaskStatus); if (isOverColumn) { - // Dropped on empty column area — change status only + // Dropped on column area — change status const newStatus = overId as TaskStatus; const task = tasks?.find((t) => t.id === activeId); if (task && task.status !== newStatus) { @@ -116,21 +177,23 @@ export function useBoardDragDrop({ const destTasks = [...tasksByStatus[overColumn]]; const overIndex = destTasks.findIndex((t: Task) => t.id === overId); - // First update the task's status + // Update the task's status to the destination column onStatusChange(activeId, overColumn); - + // Build the new order for the destination column including the moved task const newOrder = destTasks.map((t: Task) => t.id); newOrder.splice(overIndex, 0, activeId); onReorder(newOrder); } }, - [columns, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus] + [columnIds, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus] ); return { activeTask, + isDragActive: activeTask !== null, sensors, + collisionDetection, handleDragStart, handleDragOver, handleDragEnd,