diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 8431073f..48219464 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -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(null); const [draggedTaskId, setDraggedTaskId] = useState(null); const [draggedTaskGroupId, setDraggedTaskGroupId] = useState(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) { diff --git a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx new file mode 100644 index 00000000..4bea53db --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx @@ -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(null); + + // Store the original source group ID when drag starts + const originalSourceGroupIdRef = useRef(null); + const lastOverId = useRef(null); + const recentlyMovedToNewContainer = useRef(false); + const [clonedItems, setClonedItems] = useState(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 ( + + + + + + + {activeItem?.type === 'task' && ( + + )} + + + + + ); +}; + +export default ProjectViewBoard; \ No newline at end of file