refactor(task-list): simplify drag-and-drop functionality and enhance task rendering
- Removed droppable functionality from TaskGroupHeader and replaced it with a more streamlined approach in TaskListV2Table. - Introduced DropSpacer component to improve visual feedback during task dragging. - Updated task rendering logic in TaskRow to enhance user experience with clearer drop indicators. - Refactored useDragAndDrop hook to manage drop positions more effectively, ensuring tasks can only be reordered within the same group. - Improved socket event handling for task sorting to ensure accurate updates during drag-and-drop operations.
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
// @ts-ignore: Heroicons module types
|
// @ts-ignore: Heroicons module types
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Make the group header droppable
|
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
|
||||||
id: group.id,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
group,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
|
||||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
backgroundColor: headerBackgroundColor,
|
||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
useDroppable,
|
Modifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
@@ -67,106 +68,91 @@ import TaskListSkeleton from './components/TaskListSkeleton';
|
|||||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||||
|
|
||||||
// Empty Group Drop Zone Component
|
// Drop Spacer Component - creates space between tasks when dragging
|
||||||
const EmptyGroupDropZone: React.FC<{
|
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ isVisible, visibleColumns }) => {
|
||||||
groupId: string;
|
|
||||||
visibleColumns: any[];
|
|
||||||
t: (key: string) => string;
|
|
||||||
}> = ({ groupId, visibleColumns, t }) => {
|
|
||||||
const { setNodeRef, isOver, active } = useDroppable({
|
|
||||||
id: `empty-group-${groupId}`,
|
|
||||||
data: {
|
|
||||||
type: 'group',
|
|
||||||
groupId: groupId,
|
|
||||||
isEmpty: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`relative w-full transition-colors duration-200 ${
|
|
||||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
|
||||||
{visibleColumns.map((column, index) => {
|
|
||||||
const emptyColumnStyle = {
|
|
||||||
width: column.width,
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show text in the title column
|
|
||||||
if (column.id === 'title') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="flex items-center pl-1"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No tasks in this group
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
className="border-r border-gray-200 dark:border-gray-700"
|
|
||||||
style={emptyColumnStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{isOver && active && (
|
|
||||||
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Placeholder Drop Indicator Component
|
|
||||||
const PlaceholderDropIndicator: React.FC<{
|
|
||||||
isVisible: boolean;
|
|
||||||
visibleColumns: any[];
|
|
||||||
}> = ({ isVisible, visibleColumns }) => {
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
|
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
|
||||||
style={{ minWidth: 'max-content', height: '40px' }}
|
style={{
|
||||||
|
height: isVisible ? '40px' : '0px',
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
marginTop: isVisible ? '2px' : '0px',
|
||||||
|
marginBottom: isVisible ? '2px' : '0px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column) => {
|
||||||
const columnStyle = {
|
const columnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`spacer-${column.id}`}
|
||||||
|
className="flex items-center pl-1"
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
|
Drop here
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`placeholder-${column.id}`}
|
key={`spacer-${column.id}`}
|
||||||
className="flex items-center justify-center h-full"
|
className="border-r border-blue-300 dark:border-blue-600"
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
/>
|
||||||
{/* Show "Drop task here" message in the title column */}
|
|
||||||
{column.id === 'title' && (
|
|
||||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
|
|
||||||
Drop task here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Show subtle placeholder content in other columns */}
|
|
||||||
{column.id !== 'title' && column.id !== 'dragHandle' && (
|
|
||||||
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Empty Group Message Component
|
||||||
|
const EmptyGroupMessage: React.FC<{ visibleColumns: any[] }> = ({ visibleColumns }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||||
|
{visibleColumns.map((column) => {
|
||||||
|
const emptyColumnStyle = {
|
||||||
|
width: column.width,
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show text in the title column
|
||||||
|
if (column.id === 'title') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="flex items-center pl-1"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No tasks in this group
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
className="border-r border-gray-200 dark:border-gray-700"
|
||||||
|
style={emptyColumnStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Hooks and utilities
|
// Hooks and utilities
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
@@ -229,7 +215,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||||
allTasks,
|
allTasks,
|
||||||
groups
|
groups
|
||||||
);
|
);
|
||||||
@@ -564,16 +550,12 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
projectId={urlProjectId || ''}
|
projectId={urlProjectId || ''}
|
||||||
/>
|
/>
|
||||||
{isGroupEmpty && !isGroupCollapsed && (
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
<EmptyGroupDropZone
|
<EmptyGroupMessage visibleColumns={visibleColumns} />
|
||||||
groupId={group.id}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTask = useCallback(
|
const renderTask = useCallback(
|
||||||
@@ -797,6 +779,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
@@ -850,41 +833,21 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
|
|
||||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||||
|
|
||||||
// Check if we should show drop indicators
|
// Check if we should show drop spacer
|
||||||
const isTaskBeingDraggedOver = overId === task.id;
|
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
|
||||||
const isGroupBeingDraggedOver = overId === group.id;
|
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
|
||||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||||
{/* Placeholder drop indicator before first task in group */}
|
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} />}
|
||||||
{isFirstTaskInGroupBeingDraggedOver && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder drop indicator between tasks */}
|
|
||||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||||
|
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} />}
|
||||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
|
||||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : null
|
||||||
// Handle empty groups with placeholder drop indicator
|
|
||||||
overId === group.id && (
|
|
||||||
<div style={{ minWidth: 'max-content' }}>
|
|
||||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -894,15 +857,15 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<div
|
<div
|
||||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
|
||||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
|
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
allTasks.find(task => task.id === activeId)?.title ||
|
allTasks.find(task => task.id === activeId)?.title ||
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drag and drop functionality - only enable for parent tasks
|
// 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,
|
id: task.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -116,17 +116,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
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]);
|
}), [transform, transition, isDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, height: '40px' }}
|
style={{ ...style, height: '40px' }}
|
||||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||||
} ${
|
} ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) => (
|
{visibleColumns.map((column, index) => (
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(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(
|
const emitTaskSortChange = useCallback(
|
||||||
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
|
(taskId: string, group: TaskGroup, insertIndex: number) => {
|
||||||
if (!socket || !connected || !projectId) {
|
if (!socket || !connected || !projectId) {
|
||||||
logger.warning('Socket not connected or missing project ID');
|
logger.warning('Socket not connected or missing project ID');
|
||||||
return;
|
return;
|
||||||
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||||
const taskUpdates: any[] = [];
|
const taskUpdates: any[] = [];
|
||||||
|
|
||||||
// Create a copy of all groups and perform the move operation
|
// Create a copy of all groups
|
||||||
const updatedGroups = groups.map(group => ({
|
const updatedGroups = groups.map(g => ({
|
||||||
...group,
|
...g,
|
||||||
taskIds: [...group.taskIds]
|
taskIds: [...g.taskIds]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Find the source and target groups in our copy
|
// Find the group in our copy
|
||||||
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
|
||||||
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
|
||||||
|
|
||||||
if (sourceGroup.id === targetGroup.id) {
|
// Reorder within the group
|
||||||
// Same group - reorder within the group
|
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
|
||||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
// Remove task from old position
|
||||||
// Remove task from old position
|
groupCopy.taskIds.splice(sourceIndex, 1);
|
||||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
// Insert at new position
|
||||||
// Insert at new position
|
groupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now assign sequential sort orders to ALL tasks across ALL groups
|
// Now assign sequential sort orders to ALL tasks across ALL groups
|
||||||
let currentSortOrder = 0;
|
let currentSortOrder = 0;
|
||||||
updatedGroups.forEach(group => {
|
updatedGroups.forEach(grp => {
|
||||||
group.taskIds.forEach(id => {
|
grp.taskIds.forEach(id => {
|
||||||
const update: any = {
|
taskUpdates.push({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
sort_order: currentSortOrder
|
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++;
|
currentSortOrder++;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
group_by: currentGrouping || 'status',
|
group_by: currentGrouping || 'status',
|
||||||
task_updates: taskUpdates,
|
task_updates: taskUpdates,
|
||||||
from_group: sourceGroup.id,
|
from_group: group.id,
|
||||||
to_group: targetGroup.id,
|
to_group: group.id,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
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]
|
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
||||||
);
|
);
|
||||||
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
|
|
||||||
if (!over) {
|
if (!over) {
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = active.id;
|
const activeTask = allTasks.find(task => task.id === active.id);
|
||||||
const overId = over.id;
|
const overTask = allTasks.find(task => task.id === over.id);
|
||||||
|
|
||||||
// Set the overId for drop indicators
|
if (activeTask && overTask) {
|
||||||
setOverId(overId as string);
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
// Find the active task and the item being dragged over
|
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
// Only set overId if both tasks are in the same group
|
||||||
if (!activeTask) return;
|
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
|
||||||
|
setOverId(over.id as string);
|
||||||
// Check if we're dragging over a task or a group
|
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
// Calculate drop position based on task indices
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
// Find the groups
|
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
if (activeIndex < overIndex) {
|
||||||
let targetGroup = overGroup;
|
setDropPosition('after');
|
||||||
|
} else {
|
||||||
if (overTask) {
|
setDropPosition('before');
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
}
|
||||||
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeGroup || !targetGroup) return;
|
|
||||||
},
|
},
|
||||||
[allTasks, groups]
|
[allTasks, groups]
|
||||||
);
|
);
|
||||||
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
setDropPosition(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the groups
|
// Find the active task's group
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
if (!activeGroup) {
|
if (!activeGroup) {
|
||||||
logger.error('Could not find active group for task:', activeId);
|
logger.error('Could not find active group for task:', activeId);
|
||||||
return;
|
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 overTask = allTasks.find(task => task.id === overId);
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
if (!overTask) {
|
||||||
|
return;
|
||||||
// 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 overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
|
if (!overGroup || overGroup.id !== activeGroup.id) {
|
||||||
|
|
||||||
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');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
|
||||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||||
|
|
||||||
if (isCrossGroup) {
|
if (activeIndex !== overIndex) {
|
||||||
// Moving task between groups
|
// Reorder task within same group
|
||||||
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
|
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasksInGroup({
|
reorderTasksInGroup({
|
||||||
sourceTaskId: activeId as string,
|
sourceTaskId: activeId as string,
|
||||||
destinationTaskId: over.id as string,
|
destinationTaskId: overId as string,
|
||||||
sourceGroupId: activeGroup.id,
|
sourceGroupId: activeGroup.id,
|
||||||
destinationGroupId: targetGroup.id,
|
destinationGroupId: activeGroup.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Emit socket event for persistence
|
// Calculate the final index after reordering for socket emission
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
let finalIndex = overIndex;
|
||||||
} else {
|
if (activeIndex < overIndex) {
|
||||||
if (activeIndex !== insertIndex) {
|
// When dragging down, the task ends up just after the destination
|
||||||
// Reorder task within same group at drop position
|
finalIndex = overIndex;
|
||||||
dispatch(
|
} else {
|
||||||
reorderTasksInGroup({
|
// When dragging up, the task ends up at the destination position
|
||||||
sourceTaskId: activeId as string,
|
finalIndex = overIndex;
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: activeGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit socket event for persistence
|
|
||||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit socket event for persistence
|
||||||
|
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allTasks, groups, dispatch, emitTaskSortChange]
|
[allTasks, groups, dispatch, emitTaskSortChange]
|
||||||
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return {
|
return {
|
||||||
activeId,
|
activeId,
|
||||||
overId,
|
overId,
|
||||||
|
dropPosition,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({
|
|||||||
const group = state.groups.find(g => g.id === sourceGroupId);
|
const group = state.groups.find(g => g.id === sourceGroupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
const newTasks = Array.from(group.taskIds);
|
const newTasks = Array.from(group.taskIds);
|
||||||
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
|
const sourceIndex = newTasks.indexOf(sourceTaskId);
|
||||||
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
|
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;
|
group.taskIds = newTasks;
|
||||||
|
|
||||||
// Update order for affected tasks using the appropriate sort field
|
// Update order for affected tasks using the appropriate sort field
|
||||||
|
|||||||
Reference in New Issue
Block a user