Enhance EnhancedKanbanBoardNativeDnD to support phase reordering

- Integrated phase reordering functionality within the EnhancedKanbanBoardNativeDnD component.
- Added logic to fetch phases by project ID and update phase order through API calls.
- Updated drag-and-drop handling to accommodate reordering of phases alongside existing status-based reordering.
This commit is contained in:
shancds
2025-07-17 10:56:06 +05:30
parent 6adf40f5a6
commit 7226932247
2 changed files with 608 additions and 2 deletions

View File

@@ -21,6 +21,9 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice';
const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => {
const dispatch = useDispatch();
@@ -34,6 +37,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
loadingGroups,
error,
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer);
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null);
@@ -56,6 +60,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
if (!statusCategories.length) {
dispatch(fetchStatusesCategories() as any);
}
if ( groupBy === 'phase' && !phaseList.length) {
dispatch(fetchPhasesByProjectId(projectId) as any);
}
}, [dispatch, projectId]);
// Reset drag state if taskGroups changes (e.g., real-time update)
useEffect(() => {
@@ -90,9 +97,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
reorderedGroups.splice(toIdx, 0, moved);
dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }));
dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any);
// API call for group order
try {
if (groupBy === 'status') {
const columnOrder = reorderedGroups.map(group => group.id);
const requestBody = { status_order: columnOrder };
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
@@ -104,6 +111,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
}
} else if (groupBy === 'phase') {
const newPhaseList = [...phaseList];
const [movedItem] = newPhaseList.splice(fromIdx, 1);
newPhaseList.splice(toIdx, 0, movedItem);
dispatch(updatePhaseListOrder(newPhaseList));
const requestBody = {
from_index: fromIdx,
to_index: toIdx,
phases: newPhaseList,
project_id: projectId,
};
const response = await phasesApiService.updatePhaseOrder(projectId, requestBody);
if (!response.done) {
alertService.error('Failed to update phase order', 'Please try again');
}
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
@@ -119,7 +142,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
};
// Utility to recalculate all task orders for all groups
function getAllTaskUpdates(allGroups, groupBy) {
function getAllTaskUpdates(allGroups: ITaskListGroup[], groupBy: string) {
const taskUpdates = [];
let currentSortOrder = 0;
for (const group of allGroups) {

View File

@@ -0,0 +1,583 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd';
import BoardSectionCardContainer from './board-section/board-section-container';
import {
fetchBoardTaskGroups,
reorderTaskGroups,
moveTaskBetweenGroups,
IGroupBy,
updateTaskProgress,
} from '@features/board/board-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
closestCenter,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
getFirstCollision,
pointerWithin,
rectIntersection,
UniqueIdentifier,
} from '@dnd-kit/core';
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import alertService from '@/services/alerts/alertService';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import logger from '@/utils/errorLogger';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { debounce } from 'lodash';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
interface DroppableContainer {
id: UniqueIdentifier;
data: {
current?: {
type?: string;
};
};
}
const ProjectViewBoard = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const { socket } = useSocket();
const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
// Add local loading state to immediately show skeleton
const [isLoading, setIsLoading] = useState(true);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const [activeItem, setActiveItem] = useState<any>(null);
// Store the original source group ID when drag starts
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
useEffect(() => {
setIsLoading(loadingGroups || loadingStatusCategories);
}, [loadingGroups, loadingStatusCategories]);
// Load data efficiently with async/await and Promise.all
useEffect(() => {
const loadData = async () => {
if (projectId && groupBy && projectView === 'kanban') {
const promises = [];
if (!loadingGroups) {
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
}
};
loadData();
}, [dispatch, projectId, groupBy, projectView, search, archived]);
// Create sensors with memoization to prevent unnecessary re-renders
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
delay: 100,
tolerance: 5,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
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: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(updateTaskProgress(data));
};
// Debounced move task function to prevent rapid updates
const debouncedMoveTask = useCallback(
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
dispatch(
moveTaskBetweenGroups({
taskId,
sourceGroupId,
targetGroupId,
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 &&
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 handlePriorityChange = (taskId: string, priorityId: string) => {
if (!taskId || !priorityId || !socket) return;
const payload = {
task_id: taskId,
priority_id: priorityId,
team_id: currentSession?.team_id,
};
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
dispatch(updateBoardTaskPriority(data));
});
};
const handleDragEnd = async (event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || !projectId) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
setClonedItems(null);
return;
}
const isActiveTask = active.data.current?.type === 'task';
const isActiveSection = active.data.current?.type === 'section';
// Handle task dragging between columns
if (isActiveTask) {
const task = active.data.current?.task;
// 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 (over.data.current?.type === 'task') {
// If dropping on a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (over.data.current?.type === 'section') {
// If dropping directly on a section
targetGroupId = over.id;
} else {
// Fallback to the over ID if type is not specified
targetGroupId = over.id;
}
// Find source and target groups
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (!sourceGroup || !targetGroup || !task) {
logger.error('Could not find source or target group, or task is undefined');
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
if (targetGroupId !== sourceGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
targetGroupId: sourceGroupId, // Move it back to the original source group
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
})
);
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
}
// Find indices
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
// Handle case where task is not found in source group (might have been moved already in UI)
if (fromIndex === -1) {
logger.info('Task not found in source group. Using task sort_order from task object.');
// Use the sort_order from the task object itself
const fromSortOrder = task.sort_order;
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId,
to_group: targetGroupId,
group_by: groupBy || 'status',
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
// Handle priority change if groupBy is priority
if (groupBy === IGroupBy.PRIORITY) {
handlePriorityChange(task.id, targetGroupId);
}
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId, // Use the direct IDs instead of group objects
to_group: targetGroupId, // Use the direct IDs instead of group objects
group_by: groupBy || 'status', // Use the current groupBy value
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
// Handle column reordering
else if (isActiveSection) {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
const sectionId = active.id;
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
const toIndex = taskGroups.findIndex(group => group.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
// Create a new array with the reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Dispatch action to reorder columns with the new array
dispatch(reorderTaskGroups(reorderedGroups));
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
// Use the correct API endpoint based on the Angular code
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
}
}
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
};
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) {
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
}
return () => {
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket]);
// Track analytics event on component mount
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
}, []);
// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedMoveTask.cancel();
};
}, [debouncedMoveTask]);
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<BoardSectionCardContainer
datasource={taskGroups}
group={groupBy as 'status' | 'priority' | 'phases'}
/>
<DragOverlay>
{activeItem?.type === 'task' && (
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
)}
</DragOverlay>
</DndContext>
</Skeleton>
</Flex>
);
};
export default ProjectViewBoard;