diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 32a19dd4..58066068 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useCallback, useState } from 'react'; -import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types import { ChevronDownIcon, @@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC = ({ t, ]); - // Make the group header droppable - const { isOver, setNodeRef } = useDroppable({ - id: group.id, - data: { - type: 'group', - group, - }, - }); - return (
string; -}> = ({ groupId, visibleColumns, t }) => { - const { setNodeRef, isOver, active } = useDroppable({ - id: `empty-group-${groupId}`, - data: { - type: 'group', - groupId: groupId, - isEmpty: true, - }, - }); - - return ( -
-
- {visibleColumns.map((column, index) => { - const emptyColumnStyle = { - width: column.width, - flexShrink: 0, - }; - - // Show text in the title column - if (column.id === 'title') { - return ( -
- - No tasks in this group - -
- ); - } - - return ( -
- ); - })} -
- {isOver && active && ( -
- )} -
- ); -}; - -// Placeholder Drop Indicator Component -const PlaceholderDropIndicator: React.FC<{ - isVisible: boolean; - visibleColumns: any[]; -}> = ({ isVisible, visibleColumns }) => { +// Drop Spacer Component - creates space between tasks when dragging +const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ isVisible, visibleColumns }) => { if (!isVisible) return null; return (
- {visibleColumns.map((column, index) => { + {visibleColumns.map((column) => { const columnStyle = { width: column.width, flexShrink: 0, }; + + if (column.id === 'title') { + return ( +
+ + Drop here + +
+ ); + } + return (
- {/* Show "Drop task here" message in the title column */} - {column.id === 'title' && ( -
- Drop task here -
- )} - {/* Show subtle placeholder content in other columns */} - {column.id !== 'title' && column.id !== 'dragHandle' && ( -
- )} -
+ /> ); })}
); }; +// Empty Group Message Component +const EmptyGroupMessage: React.FC<{ visibleColumns: any[] }> = ({ visibleColumns }) => { + return ( +
+ {visibleColumns.map((column) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + }; + + // Show text in the title column + if (column.id === 'title') { + return ( +
+ + No tasks in this group + +
+ ); + } + + return ( +
+ ); + })} +
+ ); +}; + + // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; @@ -229,7 +215,7 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); @@ -564,16 +550,12 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( - + )}
); }, - [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] + [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode] ); const renderTask = useCallback( @@ -797,6 +779,7 @@ const TaskListV2Section: React.FC = () => { onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} + modifiers={[restrictToVerticalAxis]} >
{/* Table Container */} @@ -850,41 +833,21 @@ const TaskListV2Section: React.FC = () => { // Check if this is the first actual task in the group (not AddTaskRow) const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); - - // Check if we should show drop indicators - const isTaskBeingDraggedOver = overId === task.id; - const isGroupBeingDraggedOver = overId === group.id; - const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver; + + // Check if we should show drop spacer + const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task); + const showDropSpacerBefore = isOverThisTask && dropPosition === 'before'; + const showDropSpacerAfter = isOverThisTask && dropPosition === 'after'; return (
- {/* Placeholder drop indicator before first task in group */} - {isFirstTaskInGroupBeingDraggedOver && ( - - )} - - {/* Placeholder drop indicator between tasks */} - {isTaskBeingDraggedOver && !isFirstTaskInGroup && ( - - )} - + {showDropSpacerBefore && } {renderTask(globalTaskIndex, isFirstTaskInGroup)} - - {/* Placeholder drop indicator at end of group when dragging over group */} - {isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && ( - - )} + {showDropSpacerAfter && }
); }) - ) : ( - // Handle empty groups with placeholder drop indicator - overId === group.id && ( -
- -
- ) - ) + ) : null )}
))} @@ -894,15 +857,15 @@ const TaskListV2Section: React.FC = () => {
{/* Drag Overlay */} - + {activeId ? (
col.id === 'title')?.width || '300px' }} > -
-
- +
+
+
{allTasks.find(task => task.id === activeId)?.name || allTasks.find(task => task.id === activeId)?.title || diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index d03082ef..c2cbdd96 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -75,7 +75,7 @@ const TaskRow: React.FC = memo(({ }); // Drag and drop functionality - only enable for parent tasks - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ id: task.id, data: { type: 'task', @@ -116,17 +116,19 @@ const TaskRow: React.FC = memo(({ const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging + opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging }), [transform, transition, isDragging]); return (
{visibleColumns.map((column, index) => ( diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 4a5b8848..f451931c 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const currentSession = useAuthService().getCurrentSession(); const [activeId, setActiveId] = useState(null); const [overId, setOverId] = useState(null); + const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null); - // Helper function to emit socket event for persistence + // Helper function to emit socket event for persistence (within-group only) const emitTaskSortChange = useCallback( - (taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { + (taskId: string, group: TaskGroup, insertIndex: number) => { if (!socket || !connected || !projectId) { logger.warning('Socket not connected or missing project ID'); return; @@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Use new bulk update approach - recalculate ALL task orders to prevent duplicates const taskUpdates: any[] = []; - // Create a copy of all groups and perform the move operation - const updatedGroups = groups.map(group => ({ - ...group, - taskIds: [...group.taskIds] + // Create a copy of all groups + const updatedGroups = groups.map(g => ({ + ...g, + taskIds: [...g.taskIds] })); - // Find the source and target groups in our copy - const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!; - const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!; + // Find the group in our copy + const groupCopy = updatedGroups.find(g => g.id === group.id)!; - if (sourceGroup.id === targetGroup.id) { - // Same group - reorder within the group - const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); - // Remove task from old position - sourceGroupCopy.taskIds.splice(sourceIndex, 1); - // Insert at new position - sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId); - } else { - // Different groups - move task between groups - // Remove from source group - const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); - sourceGroupCopy.taskIds.splice(sourceIndex, 1); - - // Add to target group - targetGroupCopy.taskIds.splice(insertIndex, 0, taskId); - } + // Reorder within the group + const sourceIndex = groupCopy.taskIds.indexOf(taskId); + // Remove task from old position + groupCopy.taskIds.splice(sourceIndex, 1); + // Insert at new position + groupCopy.taskIds.splice(insertIndex, 0, taskId); // Now assign sequential sort orders to ALL tasks across ALL groups let currentSortOrder = 0; - updatedGroups.forEach(group => { - group.taskIds.forEach(id => { - const update: any = { + updatedGroups.forEach(grp => { + grp.taskIds.forEach(id => { + taskUpdates.push({ task_id: id, sort_order: currentSortOrder - }; - - // Add group-specific fields for the moved task if it changed groups - if (id === taskId && sourceGroup.id !== targetGroup.id) { - if (currentGrouping === 'status') { - update.status_id = targetGroup.id; - } else if (currentGrouping === 'priority') { - update.priority_id = targetGroup.id; - } else if (currentGrouping === 'phase') { - update.phase_id = targetGroup.id; - } - } - - taskUpdates.push(update); + }); currentSortOrder++; }); }); @@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { project_id: projectId, group_by: currentGrouping || 'status', task_updates: taskUpdates, - from_group: sourceGroup.id, - to_group: targetGroup.id, + from_group: group.id, + to_group: group.id, task: { id: task.id, project_id: projectId, @@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { }; socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); - - // Also emit the specific grouping field change event for the moved task - if (sourceGroup.id !== targetGroup.id) { - if (currentGrouping === 'phase') { - // Emit phase change event - socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { - task_id: taskId, - phase_id: targetGroup.id, - parent_task: task.parent_task_id || null, - }); - } else if (currentGrouping === 'priority') { - // Emit priority change event - socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({ - task_id: taskId, - priority_id: targetGroup.id, - team_id: teamId, - })); - } else if (currentGrouping === 'status') { - // Emit status change event - socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({ - task_id: taskId, - status_id: targetGroup.id, - team_id: teamId, - })); - } - } }, [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] ); @@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { if (!over) { setOverId(null); + setDropPosition(null); return; } - const activeId = active.id; - const overId = over.id; - - // Set the overId for drop indicators - setOverId(overId as string); - - // Find the active task and the item being dragged over - const activeTask = allTasks.find(task => task.id === activeId); - if (!activeTask) return; - - // Check if we're dragging over a task or a group - const overTask = allTasks.find(task => task.id === overId); - const overGroup = groups.find(group => group.id === overId); - - // Find the groups - const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); - let targetGroup = overGroup; - - if (overTask) { - targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); + const activeTask = allTasks.find(task => task.id === active.id); + const overTask = allTasks.find(task => task.id === over.id); + + if (activeTask && overTask) { + const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); + const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); + + // Only set overId if both tasks are in the same group + if (activeGroup && overGroup && activeGroup.id === overGroup.id) { + setOverId(over.id as string); + + // Calculate drop position based on task indices + const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); + const overIndex = activeGroup.taskIds.indexOf(overTask.id); + + if (activeIndex < overIndex) { + setDropPosition('after'); + } else { + setDropPosition('before'); + } + } else { + setOverId(null); + setDropPosition(null); + } + } else { + setOverId(null); + setDropPosition(null); } - - if (!activeGroup || !targetGroup) return; }, [allTasks, groups] ); @@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const { active, over } = event; setActiveId(null); setOverId(null); + setDropPosition(null); if (!over || active.id === over.id) { return; @@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return; } - // Find the groups + // Find the active task's group const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); if (!activeGroup) { logger.error('Could not find active group for task:', activeId); return; } - // Check if we're dropping on a task, group, or empty group + // Only allow dropping on tasks in the same group const overTask = allTasks.find(task => task.id === overId); - const overGroup = groups.find(group => group.id === overId); - - // Check if dropping on empty group drop zone - const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-'); - const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null; - const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null; - - let targetGroup = overGroup || emptyGroup; - let insertIndex = 0; - - if (overTask) { - // Dropping on a task - targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); - if (targetGroup) { - insertIndex = targetGroup.taskIds.indexOf(overTask.id); - } - } else if (overGroup) { - // Dropping on a group (at the end) - targetGroup = overGroup; - insertIndex = targetGroup.taskIds.length; - } else if (emptyGroup) { - // Dropping on an empty group - targetGroup = emptyGroup; - insertIndex = 0; // First position in empty group - } - - if (!targetGroup) { - logger.error('Could not find target group'); + if (!overTask) { + return; + } + + const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); + if (!overGroup || overGroup.id !== activeGroup.id) { return; } - const isCrossGroup = activeGroup.id !== targetGroup.id; const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); + const overIndex = activeGroup.taskIds.indexOf(overTask.id); - if (isCrossGroup) { - // Moving task between groups - console.log('Moving task between groups:', { - task: activeTask.name || activeTask.title, - from: activeGroup.title, - to: targetGroup.title, - newPosition: insertIndex, - }); - - // reorderTasksInGroup handles both same-group and cross-group moves - // No need for separate moveTaskBetweenGroups call + if (activeIndex !== overIndex) { + // Reorder task within same group dispatch( reorderTasksInGroup({ sourceTaskId: activeId as string, - destinationTaskId: over.id as string, + destinationTaskId: overId as string, sourceGroupId: activeGroup.id, - destinationGroupId: targetGroup.id, + destinationGroupId: activeGroup.id, }) ); - // Emit socket event for persistence - emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); - } else { - if (activeIndex !== insertIndex) { - // Reorder task within same group at drop position - dispatch( - reorderTasksInGroup({ - sourceTaskId: activeId as string, - destinationTaskId: over.id as string, - sourceGroupId: activeGroup.id, - destinationGroupId: activeGroup.id, - }) - ); - - // Emit socket event for persistence - emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); + // Calculate the final index after reordering for socket emission + let finalIndex = overIndex; + if (activeIndex < overIndex) { + // When dragging down, the task ends up just after the destination + finalIndex = overIndex; + } else { + // When dragging up, the task ends up at the destination position + finalIndex = overIndex; } + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, finalIndex); } }, [allTasks, groups, dispatch, emitTaskSortChange] @@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return { activeId, overId, + dropPosition, handleDragStart, handleDragOver, handleDragEnd, diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index c72dc392..1db46e4c 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({ const group = state.groups.find(g => g.id === sourceGroupId); if (group) { const newTasks = Array.from(group.taskIds); - const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); - newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); + const sourceIndex = newTasks.indexOf(sourceTaskId); + const destinationIndex = newTasks.indexOf(destinationTaskId); + + // Remove the task from its current position + const [removed] = newTasks.splice(sourceIndex, 1); + + // Calculate the insertion index + let insertIndex = destinationIndex; + if (sourceIndex < destinationIndex) { + // When dragging down, we need to insert after the destination + insertIndex = destinationIndex; + } else { + // When dragging up, we insert before the destination + insertIndex = destinationIndex; + } + + newTasks.splice(insertIndex, 0, removed); group.taskIds = newTasks; // Update order for affected tasks using the appropriate sort field