From def8d7192707b85e28b736cbbba8b17ae7b246d2 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 06:34:04 -0600 Subject: [PATCH] fix: real-time cross-column drag with local state management --- web/src/components/board/KanbanBoard.tsx | 3 +- web/src/hooks/useBoardDragDrop.ts | 161 +++++++++++++---------- 2 files changed, 93 insertions(+), 71 deletions(-) diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index ca88117d..7a91c6ad 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -102,6 +102,7 @@ export function KanbanBoard() { const { activeTask, isDragActive, + liveTasksByStatus, sensors, collisionDetection, handleDragStart, @@ -171,7 +172,7 @@ export function KanbanBoard() { key={column.id} id={column.id} title={column.title} - tasks={tasksByStatus[column.id]} + tasks={liveTasksByStatus[column.id]} allTasks={filteredTasks} onTaskClick={handleTaskClick} selectedTaskId={selectedTaskId} diff --git a/web/src/hooks/useBoardDragDrop.ts b/web/src/hooks/useBoardDragDrop.ts index 42b4f367..1f7cc2cf 100644 --- a/web/src/hooks/useBoardDragDrop.ts +++ b/web/src/hooks/useBoardDragDrop.ts @@ -24,6 +24,8 @@ interface UseBoardDragDropOptions { interface UseBoardDragDropReturn { activeTask: Task | null; isDragActive: boolean; + /** Use this for rendering columns — reflects real-time drag state */ + liveTasksByStatus: Record; sensors: ReturnType; collisionDetection: CollisionDetection; handleDragStart: (event: DragStartEvent) => void; @@ -39,7 +41,10 @@ export function useBoardDragDrop({ onReorder, }: UseBoardDragDropOptions): UseBoardDragDropReturn { const [activeTask, setActiveTask] = useState(null); - const lastOverColumnRef = useRef(null); + // Local copy of tasksByStatus that updates in real-time during drag. + // null = not dragging, use server state; non-null = mid-drag, use local state + const [dragState, setDragState] = useState | null>(null); + const activeIdRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -51,50 +56,45 @@ 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. + // Custom collision detection: pointerWithin for accuracy, rectIntersection as fallback. + // When over a column, prefer a task collision for precise positioning; fall back to + // the column droppable for empty areas. 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) ); + const columnCollision = 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 => { + // The live state columns should render from — either mid-drag local state or server state + const liveTasksByStatus = dragState ?? tasksByStatus; + + // Find which column a task belongs to in the given state + const findColumn = useCallback( + (taskId: string, state: Record): TaskStatus | null => { for (const col of columns) { - if (tasksByStatus[col.id]?.some((t: Task) => t.id === taskId)) { + if (state[col.id]?.some((t: Task) => t.id === taskId)) { return col.id; } } return null; }, - [columns, tasksByStatus] + [columns] ); const handleDragStart = useCallback( @@ -102,10 +102,12 @@ export function useBoardDragDrop({ const task = tasks?.find((t) => t.id === event.active.id); if (task) { setActiveTask(task); - lastOverColumnRef.current = null; + activeIdRef.current = event.active.id as string; + // Snapshot current server state into local drag state + setDragState({ ...tasksByStatus }); } }, - [tasks] + [tasks, tasksByStatus] ); const handleDragOver = useCallback( @@ -115,83 +117,102 @@ export function useBoardDragDrop({ const activeId = active.id as string; const overId = over.id as string; + if (activeId === overId) return; - // Determine which column the active task is currently in - const activeColumn = findColumnForTask(activeId); - if (!activeColumn) return; + setDragState((prev) => { + if (!prev) return prev; - // 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); + const activeColumn = findColumn(activeId, prev); + if (!activeColumn) return prev; - if (!overColumn || activeColumn === overColumn) return; + // Determine destination column + const isOverColumn = columnIds.includes(overId as TaskStatus); + const overColumn = isOverColumn ? (overId as TaskStatus) : findColumn(overId, prev); - // Track which column we're over for handleDragEnd - lastOverColumnRef.current = overColumn; + if (!overColumn || activeColumn === overColumn) return prev; + + // Move the task from source to destination + const sourceTasks = prev[activeColumn]; + const destTasks = prev[overColumn]; + const activeIndex = sourceTasks.findIndex((t) => t.id === activeId); + if (activeIndex === -1) return prev; + + const movedTask = sourceTasks[activeIndex]; + const newSource = [...sourceTasks]; + newSource.splice(activeIndex, 1); + + const newDest = [...destTasks]; + if (isOverColumn) { + // Dropped on column itself — append to end + newDest.push(movedTask); + } else { + // Dropped on a task — insert at that position + const overIndex = newDest.findIndex((t) => t.id === overId); + if (overIndex >= 0) { + newDest.splice(overIndex, 0, movedTask); + } else { + newDest.push(movedTask); + } + } + + return { + ...prev, + [activeColumn]: newSource, + [overColumn]: newDest, + }; + }); }, - [columnIds, findColumnForTask] + [columnIds, findColumn] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; - setActiveTask(null); - lastOverColumnRef.current = null; + const finalState = dragState; - if (!over) return; + // Clear drag UI state + setActiveTask(null); + setDragState(null); + activeIdRef.current = null; + + if (!over || !finalState) return; const activeId = active.id as string; const overId = over.id as string; - // Check if dropped on a column (status) directly - const isOverColumn = columnIds.includes(overId as TaskStatus); + // Find where the task ended up in our local drag state + const originalColumn = findColumn(activeId, tasksByStatus); + const finalColumn = findColumn(activeId, finalState); - if (isOverColumn) { - // Dropped on column area — change status - const newStatus = overId as TaskStatus; - const task = tasks?.find((t) => t.id === activeId); - if (task && task.status !== newStatus) { - onStatusChange(activeId, newStatus); - } - return; - } + if (!originalColumn || !finalColumn) return; - // Dropped on another task — figure out source/destination columns - const activeColumn = findColumnForTask(activeId); - const overColumn = findColumnForTask(overId); + if (originalColumn === finalColumn && !columnIds.includes(overId as TaskStatus)) { + // Same column — check for reorder + const columnTasks = finalState[finalColumn]; + const origColumnTasks = tasksByStatus[originalColumn]; + const oldIndex = origColumnTasks.findIndex((t: Task) => t.id === activeId); + const newIndex = columnTasks.findIndex((t: Task) => t.id === activeId); - if (!activeColumn || !overColumn) return; - - if (activeColumn === overColumn) { - // Same column — reorder - const columnTasks = tasksByStatus[activeColumn]; - const oldIndex = columnTasks.findIndex((t: Task) => t.id === activeId); - const newIndex = columnTasks.findIndex((t: Task) => t.id === overId); - - if (oldIndex !== newIndex) { - const reordered = arrayMove(columnTasks, oldIndex, newIndex); + if (oldIndex !== newIndex && oldIndex >= 0 && newIndex >= 0) { + const reordered = arrayMove(origColumnTasks, oldIndex, newIndex); onReorder(reordered.map((t: Task) => t.id)); } - } else { - // Cross-column: change status, then insert at the target position - const destTasks = [...tasksByStatus[overColumn]]; - const overIndex = destTasks.findIndex((t: Task) => t.id === overId); + } else if (originalColumn !== finalColumn) { + // Cross-column: commit the status change and new order + onStatusChange(activeId, finalColumn); - // 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); + // Send the new order for the destination column + const newOrder = finalState[finalColumn].map((t: Task) => t.id); onReorder(newOrder); } }, - [columnIds, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus] + [columnIds, dragState, findColumn, onReorder, onStatusChange, tasksByStatus] ); return { activeTask, isDragActive: activeTask !== null, + liveTasksByStatus, sensors, collisionDetection, handleDragStart,