feat(project-view-board): enhance drag-and-drop functionality and optimize task handling
- Added debounced task movement to prevent rapid updates during drag-and-drop operations. - Implemented a custom collision detection strategy for improved task placement logic. - Introduced new refs and state management for better handling of drag events and task cloning. - Refactored drag event handlers to streamline task movement between groups and sections. - Enhanced loading state management and cleanup for better user experience during task interactions.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||||
import { Flex, Skeleton } from 'antd';
|
import { Flex, Skeleton } from 'antd';
|
||||||
@@ -16,12 +16,20 @@ import {
|
|||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
DragOverEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
closestCorners,
|
closestCenter,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
MeasuringStrategy,
|
||||||
|
getFirstCollision,
|
||||||
|
pointerWithin,
|
||||||
|
rectIntersection,
|
||||||
|
UniqueIdentifier,
|
||||||
|
DragOverlayProps,
|
||||||
|
DragOverlay as DragOverlayType,
|
||||||
|
closestCorners,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
@@ -36,6 +44,16 @@ import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-reque
|
|||||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
|
interface DroppableContainer {
|
||||||
|
id: UniqueIdentifier;
|
||||||
|
data: {
|
||||||
|
current?: {
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ProjectViewBoard = () => {
|
const ProjectViewBoard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -47,7 +65,7 @@ const ProjectViewBoard = () => {
|
|||||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||||
// Add local loading state to immediately show skeleton
|
// Add local loading state to immediately show skeleton
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||||
@@ -57,6 +75,10 @@ const ProjectViewBoard = () => {
|
|||||||
|
|
||||||
// Store the original source group ID when drag starts
|
// Store the original source group ID when drag starts
|
||||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||||
|
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
||||||
|
const recentlyMovedToNewContainer = useRef(false);
|
||||||
|
const [clonedItems, setClonedItems] = useState<any>(null);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
// Update loading state based on all loading conditions
|
// Update loading state based on all loading conditions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,33 +90,33 @@ const ProjectViewBoard = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (projectId && groupBy && projectView === 'kanban') {
|
if (projectId && groupBy && projectView === 'kanban') {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
if (!loadingGroups) {
|
if (!loadingGroups) {
|
||||||
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statusCategories.length) {
|
if (!statusCategories.length) {
|
||||||
promises.push(dispatch(fetchStatusesCategories()));
|
promises.push(dispatch(fetchStatusesCategories()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all data to load
|
// Wait for all data to load
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||||
|
|
||||||
// Create sensors with memoization to prevent unnecessary re-renders
|
// Create sensors with memoization to prevent unnecessary re-renders
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
// Require the mouse to move by 10 pixels before activating
|
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 10,
|
distance: 10,
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 5,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, {
|
useSensor(TouchSensor, {
|
||||||
// Press delay of 250ms, with tolerance of 5px of movement
|
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
delay: 250,
|
delay: 250,
|
||||||
tolerance: 5,
|
tolerance: 5,
|
||||||
@@ -102,90 +124,164 @@ const ProjectViewBoard = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const collisionDetectionStrategy = useCallback(
|
||||||
|
(args: {
|
||||||
|
active: { id: UniqueIdentifier; data: { current?: { type?: string } } };
|
||||||
|
droppableContainers: DroppableContainer[];
|
||||||
|
}) => {
|
||||||
|
if (activeItem?.type === 'section') {
|
||||||
|
return closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter(
|
||||||
|
(container: DroppableContainer) => container.data.current?.type === 'section'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start by finding any intersecting droppable
|
||||||
|
const pointerIntersections = pointerWithin(args);
|
||||||
|
const intersections =
|
||||||
|
pointerIntersections.length > 0
|
||||||
|
? pointerIntersections
|
||||||
|
: rectIntersection(args);
|
||||||
|
let overId = getFirstCollision(intersections, 'id');
|
||||||
|
|
||||||
|
if (overId !== null) {
|
||||||
|
const overContainer = args.droppableContainers.find(
|
||||||
|
(container: DroppableContainer) => container.id === overId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overContainer?.data.current?.type === 'section') {
|
||||||
|
const containerItems = taskGroups.find(
|
||||||
|
(group) => group.id === overId
|
||||||
|
)?.tasks || [];
|
||||||
|
|
||||||
|
if (containerItems.length > 0) {
|
||||||
|
overId = closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter(
|
||||||
|
(container: DroppableContainer) =>
|
||||||
|
container.id !== overId &&
|
||||||
|
container.data.current?.type === 'task'
|
||||||
|
),
|
||||||
|
})[0]?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastOverId.current = overId;
|
||||||
|
return [{ id: overId }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyMovedToNewContainer.current) {
|
||||||
|
lastOverId.current = activeItem?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastOverId.current ? [{ id: lastOverId.current }] : [];
|
||||||
|
},
|
||||||
|
[activeItem, taskGroups]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskProgress = (data: {
|
const handleTaskProgress = (data: {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
complete_ratio: number;
|
complete_ratio: number;
|
||||||
completed_count: number;
|
completed_count: number;
|
||||||
total_tasks_count: number;
|
total_tasks_count: number;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskProgress(data));
|
dispatch(updateTaskProgress(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
// Debounced move task function to prevent rapid updates
|
||||||
const { active } = event;
|
const debouncedMoveTask = useCallback(
|
||||||
setActiveItem(active.data.current);
|
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
|
||||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
|
||||||
// Store the original source group ID when drag starts
|
|
||||||
if (active.data.current?.type === 'task') {
|
|
||||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over) return;
|
|
||||||
|
|
||||||
const activeId = active.id;
|
|
||||||
const overId = over.id;
|
|
||||||
|
|
||||||
if (activeId === overId) return;
|
|
||||||
|
|
||||||
const isActiveTask = active.data.current?.type === 'task';
|
|
||||||
const isOverTask = over.data.current?.type === 'task';
|
|
||||||
const isOverSection = over.data.current?.type === 'section';
|
|
||||||
|
|
||||||
// Handle task movement between sections
|
|
||||||
if (isActiveTask && (isOverTask || isOverSection)) {
|
|
||||||
// If we're over a task, we want to insert at that position
|
|
||||||
// If we're over a section, we want to append to the end
|
|
||||||
const activeTaskId = active.data.current?.task.id;
|
|
||||||
|
|
||||||
// Use the original source group ID from ref instead of the potentially modified one
|
|
||||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
|
||||||
|
|
||||||
// Fix: Ensure we correctly identify the target group ID
|
|
||||||
let targetGroupId;
|
|
||||||
if (isOverTask) {
|
|
||||||
// If over a task, get its section ID
|
|
||||||
targetGroupId = over.data.current?.sectionId;
|
|
||||||
} else if (isOverSection) {
|
|
||||||
// If over a section directly
|
|
||||||
targetGroupId = over.id;
|
|
||||||
} else {
|
|
||||||
// Fallback
|
|
||||||
targetGroupId = over.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the target index
|
|
||||||
let targetIndex = -1;
|
|
||||||
if (isOverTask) {
|
|
||||||
const overTaskId = over.data.current?.task.id;
|
|
||||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
|
||||||
if (targetGroup) {
|
|
||||||
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch the action to move the task
|
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
moveTaskBetweenGroups({
|
||||||
taskId: activeTaskId,
|
taskId,
|
||||||
sourceGroupId,
|
sourceGroupId,
|
||||||
targetGroupId,
|
targetGroupId,
|
||||||
targetIndex,
|
targetIndex,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}, 100),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
setActiveItem(active.data.current);
|
||||||
|
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||||
|
if (active.data.current?.type === 'task') {
|
||||||
|
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||||
|
}
|
||||||
|
setClonedItems(taskGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findGroupForId = (id: string) => {
|
||||||
|
// If id is a sectionId
|
||||||
|
if (taskGroups.some(group => group.id === id)) return id;
|
||||||
|
// If id is a taskId, find the group containing it
|
||||||
|
const group = taskGroups.find(g => g.tasks.some(t => t.id === id));
|
||||||
|
return group?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
try {
|
||||||
|
if (!isDraggingRef.current) return;
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
// Get the ids
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the group (section) for each
|
||||||
|
const activeGroupId = findGroupForId(activeId as string);
|
||||||
|
const overGroupId = findGroupForId(overId as string);
|
||||||
|
|
||||||
|
// Only move if both groups exist and are different, and the active is a task
|
||||||
|
if (
|
||||||
|
activeGroupId &&
|
||||||
|
overGroupId &&
|
||||||
|
activeGroupId !== overGroupId &&
|
||||||
|
active.data.current?.type === 'task'
|
||||||
|
) {
|
||||||
|
// Find the target index in the over group
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||||
|
let targetIndex = 0;
|
||||||
|
if (targetGroup) {
|
||||||
|
// If over is a task, insert before it; if over is a section, append to end
|
||||||
|
if (over.data.current?.type === 'task') {
|
||||||
|
targetIndex = targetGroup.tasks.findIndex(t => t.id === overId);
|
||||||
|
if (targetIndex === -1) targetIndex = targetGroup.tasks.length;
|
||||||
|
} else {
|
||||||
|
targetIndex = targetGroup.tasks.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use debounced move task to prevent rapid updates
|
||||||
|
debouncedMoveTask(
|
||||||
|
activeId as string,
|
||||||
|
activeGroupId,
|
||||||
|
overGroupId,
|
||||||
|
targetIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('handleDragOver error:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over || !projectId) {
|
if (!over || !projectId) {
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
originalSourceGroupIdRef.current = null;
|
||||||
|
setClonedItems(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +333,7 @@ const ProjectViewBoard = () => {
|
|||||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
originalSourceGroupIdRef.current = null;
|
originalSourceGroupIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -282,7 +378,7 @@ const ProjectViewBoard = () => {
|
|||||||
team_id: currentSession?.team_id
|
team_id: currentSession?.team_id
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.error('Emitting socket event with payload (task not found in source):', body);
|
// logger.error('Emitting socket event with payload (task not found in source):', body);
|
||||||
|
|
||||||
// Emit socket event
|
// Emit socket event
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -406,7 +502,24 @@ const ProjectViewBoard = () => {
|
|||||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleDragCancel = () => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
if (clonedItems) {
|
||||||
|
dispatch(reorderTaskGroups(clonedItems));
|
||||||
|
}
|
||||||
|
setActiveItem(null);
|
||||||
|
setClonedItems(null);
|
||||||
|
originalSourceGroupIdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset the recently moved flag after animation frame
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
recentlyMovedToNewContainer.current = false;
|
||||||
|
});
|
||||||
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||||
}
|
}
|
||||||
@@ -421,10 +534,16 @@ const ProjectViewBoard = () => {
|
|||||||
trackMixpanelEvent(evt_project_board_visit);
|
trackMixpanelEvent(evt_project_board_visit);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup debounced function on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedMoveTask.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedMoveTask]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<TaskListFilters position={'board'} />
|
<TaskListFilters position={'board'} />
|
||||||
|
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -432,6 +551,7 @@ const ProjectViewBoard = () => {
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<BoardSectionCardContainer
|
<BoardSectionCardContainer
|
||||||
datasource={taskGroups}
|
datasource={taskGroups}
|
||||||
|
|||||||
Reference in New Issue
Block a user