diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx new file mode 100644 index 00000000..f9600aa8 --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -0,0 +1,329 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '@/app/store'; +import '../EnhancedKanbanBoard.css'; +import '../EnhancedKanbanGroup.css'; +import '../EnhancedKanbanTaskCard.css'; +import ImprovedTaskFilters from '../../task-management/improved-task-filters'; +import Card from 'antd/es/card'; +import Spin from 'antd/es/spin'; +import Empty from 'antd/es/empty'; +import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import KanbanGroup from './KanbanGroup'; +import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import alertService from '@/services/alerts/alertService'; +import logger from '@/utils/errorLogger'; +import Skeleton from 'antd/es/skeleton/Skeleton'; + +const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { + const dispatch = useDispatch(); + const authService = useAuthService(); + const { socket } = useSocket(); + const project = useAppSelector((state: RootState) => state.projectReducer.project); + const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy); + const teamId = authService.getCurrentSession()?.team_id; + const { + taskGroups, + loadingGroups, + error, + } = useSelector((state: RootState) => state.enhancedKanbanReducer); + const [draggedGroupId, setDraggedGroupId] = useState(null); + const [draggedTaskId, setDraggedTaskId] = useState(null); + const [draggedTaskGroupId, setDraggedTaskGroupId] = useState(null); + const [hoveredGroupId, setHoveredGroupId] = useState(null); + const [hoveredTaskIdx, setHoveredTaskIdx] = useState(null); + const [dragType, setDragType] = useState<'group' | 'task' | null>(null); + const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer); + useEffect(() => { + if (projectId) { + dispatch(fetchEnhancedKanbanGroups(projectId) as any); + // Load filter data for enhanced kanban + dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any); + dispatch(fetchEnhancedKanbanLabels(projectId) as any); + } + + if (!statusCategories.length) { + dispatch(fetchStatusesCategories() as any); + } + }, [dispatch, projectId]); + // Reset drag state if taskGroups changes (e.g., real-time update) + useEffect(() => { + setDraggedGroupId(null); + setDraggedTaskId(null); + setDraggedTaskGroupId(null); + setHoveredGroupId(null); + setHoveredTaskIdx(null); + setDragType(null); + }, [taskGroups]); + + // Group drag handlers + const handleGroupDragStart = (e: React.DragEvent, groupId: string) => { + setDraggedGroupId(groupId); + setDragType('group'); + e.dataTransfer.effectAllowed = 'move'; + }; + const handleGroupDragOver = (e: React.DragEvent) => { + if (dragType !== 'group') return; + e.preventDefault(); + }; + const handleGroupDrop = async (e: React.DragEvent, targetGroupId: string) => { + if (dragType !== 'group') return; + e.preventDefault(); + if (!draggedGroupId || draggedGroupId === targetGroupId) return; + // Calculate new order and dispatch + const fromIdx = taskGroups.findIndex(g => g.id === draggedGroupId); + const toIdx = taskGroups.findIndex(g => g.id === targetGroupId); + if (fromIdx === -1 || toIdx === -1) return; + const reorderedGroups = [...taskGroups]; + const [moved] = reorderedGroups.splice(fromIdx, 1); + 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 { + const columnOrder = reorderedGroups.map(group => group.id); + const requestBody = { status_order: columnOrder }; + const response = await statusApiService.updateStatusOrder(requestBody, projectId); + if (!response.done) { + // Revert the change if API call fails + const revertedGroups = [...reorderedGroups]; + const [movedBackGroup] = revertedGroups.splice(toIdx, 1); + revertedGroups.splice(fromIdx, 0, movedBackGroup); + dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: 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(toIdx, 1); + revertedGroups.splice(fromIdx, 0, movedBackGroup); + dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); + alertService.error('Failed to update column order', 'Please try again'); + logger.error('Failed to update column order', error); + } + + setDraggedGroupId(null); + setDragType(null); + }; + + // Task drag handlers + const handleTaskDragStart = (e: React.DragEvent, taskId: string, groupId: string) => { + setDraggedTaskId(taskId); + setDraggedTaskGroupId(groupId); + setDragType('task'); + e.dataTransfer.effectAllowed = 'move'; + }; + const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number) => { + if (dragType !== 'task') return; + e.preventDefault(); + if (draggedTaskId) { + setHoveredGroupId(groupId); + setHoveredTaskIdx(taskIdx); + } + }; + const handleTaskDrop = (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number) => { + if (dragType !== 'task') return; + e.preventDefault(); + if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return; + + // Calculate new order and dispatch + const sourceGroup = taskGroups.find(g => g.id === draggedTaskGroupId); + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (!sourceGroup || !targetGroup) return; + + const taskIdx = sourceGroup.tasks.findIndex(t => t.id === draggedTaskId); + if (taskIdx === -1) return; + + const movedTask = sourceGroup.tasks[taskIdx]; + let insertIdx = hoveredTaskIdx; + + // Handle same group reordering + if (sourceGroup.id === targetGroup.id) { + // Create a single updated array for the same group + const updatedTasks = [...sourceGroup.tasks]; + updatedTasks.splice(taskIdx, 1); // Remove from original position + + // Adjust insert index if moving forward in the same array + if (taskIdx < insertIdx) { + insertIdx--; + } + + if (insertIdx < 0) insertIdx = 0; + if (insertIdx > updatedTasks.length) insertIdx = updatedTasks.length; + + updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position + + dispatch(reorderTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: taskIdx, + toIndex: insertIdx, + task: movedTask, + updatedSourceTasks: updatedTasks, + updatedTargetTasks: updatedTasks, + })); + dispatch(reorderEnhancedKanbanTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: taskIdx, + toIndex: insertIdx, + task: movedTask, + updatedSourceTasks: updatedTasks, + updatedTargetTasks: updatedTasks, + }) as any); + } else { + // Handle cross-group reordering + const updatedSourceTasks = [...sourceGroup.tasks]; + updatedSourceTasks.splice(taskIdx, 1); + + const updatedTargetTasks = [...targetGroup.tasks]; + if (insertIdx < 0) insertIdx = 0; + if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length; + updatedTargetTasks.splice(insertIdx, 0, movedTask); + + dispatch(reorderTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: taskIdx, + toIndex: insertIdx, + task: movedTask, + updatedSourceTasks, + updatedTargetTasks, + })); + dispatch(reorderEnhancedKanbanTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: taskIdx, + toIndex: insertIdx, + task: movedTask, + updatedSourceTasks, + updatedTargetTasks, + }) as any); + } + + // Socket emit for task order + if (socket && projectId && teamId && movedTask) { + let toSortOrder = -1; + let toLastIndex = false; + if (insertIdx === targetGroup.tasks.length) { + toSortOrder = -1; + toLastIndex = true; + } else if (targetGroup.tasks[insertIdx]) { + toSortOrder = typeof targetGroup.tasks[insertIdx].sort_order === 'number' + ? targetGroup.tasks[insertIdx].sort_order + : -1; + toLastIndex = false; + } else if (targetGroup.tasks.length > 0) { + const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order; + toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : -1; + toLastIndex = false; + } + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + project_id: projectId, + from_index: movedTask.sort_order, + to_index: toSortOrder, + to_last_index: toLastIndex, + from_group: sourceGroup.id, + to_group: targetGroup.id, + group_by: groupBy || 'status', + task: movedTask, + team_id: teamId, + }); + } + + setDraggedTaskId(null); + setDraggedTaskGroupId(null); + setHoveredGroupId(null); + setHoveredTaskIdx(null); + setDragType(null); + }; + + useEffect(() => { + if (!socket) return; + + // Handler for new task received via socket + const handleNewTaskReceived = (data: any) => { + if (!data) return; + if (data.parent_task_id) { + // Subtask: update subtasks in the correct group + dispatch({ + type: 'enhancedKanbanReducer/updateEnhancedKanbanSubtask', + payload: { sectionId: '', subtask: data, mode: 'add' } + }); + } else { + // Regular task: add to the correct group + let sectionId = ''; + if (groupBy === 'status') sectionId = data.status; + else if (groupBy === 'priority') sectionId = data.priority; + else if (groupBy === 'phase') sectionId = data.phase_id; + dispatch({ + type: 'enhancedKanbanReducer/addTaskToGroup', + payload: { sectionId, task: data } + }); + } + }; + + socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); + return () => { + socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); + }; + }, [socket, groupBy, dispatch]); + + if (error) { + return ( + + + + ); + } + + return ( + <> +
+ Loading filters...
}> + + + +
+ {loadingGroups ? ( + +
+ +
+
+ ) : taskGroups.length === 0 ? ( + + + + ) : ( +
+ {taskGroups.map(group => ( + + ))} + +
+ )} +
+ + ); +}; + +export default EnhancedKanbanBoardNativeDnD; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx new file mode 100644 index 00000000..e7dd5591 --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -0,0 +1,169 @@ +import React, { memo, useMemo, useState } from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import TaskCard from './TaskCard'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import EnhancedKanbanCreateTaskCard from '../EnhancedKanbanCreateTaskCard'; +import { PlusOutlined } from '@ant-design/icons'; +import Button from 'antd/es/button'; +import { useTranslation } from 'react-i18next'; +import { useAuthService } from '@/hooks/useAuth'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; + +interface KanbanGroupProps { + group: ITaskListGroup; + onGroupDragStart: (e: React.DragEvent, groupId: string) => void; + onGroupDragOver: (e: React.DragEvent) => void; + onGroupDrop: (e: React.DragEvent, groupId: string) => void; + onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void; + onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + hoveredTaskIdx: number | null; + hoveredGroupId: string | null; +} + +const KanbanGroup: React.FC = memo(({ + group, + onGroupDragStart, + onGroupDragOver, + onGroupDrop, + onTaskDragStart, + onTaskDragOver, + onTaskDrop, + hoveredTaskIdx, + hoveredGroupId +}) => { + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { t } = useTranslation('kanban-board'); + const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + const isProjectManager = useIsProjectManager(); + const [showNewCardTop, setShowNewCardTop] = useState(false); + const [showNewCardBottom, setShowNewCardBottom] = useState(false); + const headerBackgroundColor = useMemo(() => { + if (themeMode === 'dark') { + return group.color_code_dark || group.color_code || '#1e1e1e'; + } + return group.color_code || '#f5f5f5'; + }, [themeMode, group.color_code, group.color_code_dark]); + + return ( +
+
onGroupDragStart(e, group.id)} + onDragOver={onGroupDragOver} + onDrop={e => onGroupDrop(e, group.id)} + > +

{group.name}

+ {group.tasks.length} +
+
+ {/* If group is empty, render a drop zone */} + {group.tasks.length === 0 && ( +
{ e.preventDefault(); onTaskDragOver(e, group.id, 0); }} + onDrop={e => { e.preventDefault(); onTaskDrop(e, group.id, 0); }} + > + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} + {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( + + )} + {showNewCardTop && } +
+ )} + + {/* Drop indicator at the top of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( +
+
+
+ )} + + {group.tasks.map((task, idx) => ( + + ))} + {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( + + )} + {showNewCardBottom && } + + {/* Drop indicator at the end of the group */} + {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( +
+
+
+ )} +
+
+ ); +}); + +KanbanGroup.displayName = 'KanbanGroup'; + +export default KanbanGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx new file mode 100644 index 00000000..237cd80b --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -0,0 +1,120 @@ +import React, { memo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; +import AvatarGroup from '@/components/AvatarGroup'; +import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector'; +import { format } from 'date-fns'; + +interface TaskCardProps { + task: IProjectTask; + onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void; + onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void; + groupId: string; + isDropIndicator: boolean; + idx: number; +} + +const TaskCard: React.FC = memo(({ + task, + onTaskDragStart, + onTaskDragOver, + onTaskDrop, + groupId, + isDropIndicator, + idx +}) => { + const themeMode = useSelector((state: RootState) => state.themeReducer.mode); + const background = themeMode === 'dark' ? '#23272f' : '#fff'; + const color = themeMode === 'dark' ? '#fff' : '#23272f'; + const dispatch = useAppDispatch(); + const { t } = useTranslation('kanban-board'); + + const handleCardClick = useCallback((e: React.MouseEvent, id: string) => { + // Prevent the event from propagating to parent elements + e.stopPropagation(); + dispatch(setSelectedTaskId(id)); + dispatch(setShowTaskDrawer(true)); + }, [dispatch]); + + return ( + <> + {isDropIndicator && ( +
onTaskDragStart(e, task.id!, groupId)} + onDragOver={e => onTaskDragOver(e, groupId, idx)} + onDrop={e => onTaskDrop(e, groupId, idx)} + /> + )} +
onTaskDragStart(e, task.id!, groupId)} + onDragOver={e => onTaskDragOver(e, groupId, idx)} + onDrop={e => onTaskDrop(e, groupId, idx)} + style={{ background, color }} + onClick={e => handleCardClick(e, task.id!)} + > +
+
+ {task.labels?.map(label => ( +
+ {label.name} +
+ ))} +
+
+
+
{task.name}
+
+ +
+
+ {task.end_date ? format(new Date(task.end_date), 'MMM d, yyyy') : ''} +
+
+ + +
+
+
+
+ + ); +}); + +TaskCard.displayName = 'TaskCard'; + +export default TaskCard; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/index.ts b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/index.ts new file mode 100644 index 00000000..1ddc40f1 --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/index.ts @@ -0,0 +1,3 @@ +export { default } from './EnhancedKanbanBoardNativeDnD'; +export { default as TaskCard } from './TaskCard'; +export { default as KanbanGroup } from './KanbanGroup'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.css b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.css index e0a6dbf1..a8d563dc 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.css +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.css @@ -13,6 +13,12 @@ position: relative; } +html.light .enhanced-kanban-task-card { + border: 1.5px solid #e1e4e8 !important; /* Asana-like light border */ + box-shadow: 0 1px 4px 0 rgba(60, 64, 67, 0.08), 0 0.5px 1.5px 0 rgba(60, 64, 67, 0.03); + background: #fff !important; +} + .enhanced-kanban-task-card:hover { box-shadow: 0 2px 6px var(--ant-color-shadow); transform: translateY(-1px); diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx index 4706c6a3..39ec6c4c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import EnhancedKanbanBoard from '@/components/enhanced-kanban/EnhancedKanbanBoard'; +import EnhancedKanbanBoardNativeDnD from '@/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD'; const ProjectViewEnhancedBoard: React.FC = () => { const { project } = useAppSelector(state => state.projectReducer); @@ -11,7 +12,8 @@ const ProjectViewEnhancedBoard: React.FC = () => { return (
- + {/* */} +
); };