From b3d39b65b035ec6096dfaf2afbd1ff48ab1677f3 Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 23 Jun 2025 11:37:40 +0530 Subject: [PATCH] feat(enhanced-kanban): implement group reordering and improve drag-and-drop functionality - Added support for reordering kanban groups via drag-and-drop, enhancing user experience. - Updated EnhancedKanbanBoard and EnhancedKanbanGroup components to handle group dragging and state management. - Introduced visual feedback for dragging groups and tasks, improving usability. - Refined CSS styles for better layout and responsiveness during drag operations. --- .../enhanced-kanban/EnhancedKanbanBoard.tsx | 85 ++++++++++---- .../enhanced-kanban/EnhancedKanbanGroup.css | 105 +++++++++++++++++- .../enhanced-kanban/EnhancedKanbanGroup.tsx | 60 +++++++++- .../EnhancedKanbanTaskCard.tsx | 10 +- .../enhanced-kanban/enhanced-kanban.slice.ts | 76 ++++++++++--- 5 files changed, 284 insertions(+), 52 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx index 487656e2..35c72de7 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx @@ -26,6 +26,7 @@ import { RootState } from '@/app/store'; import { fetchEnhancedKanbanGroups, reorderEnhancedKanbanTasks, + reorderEnhancedKanbanGroups, setDragState } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import EnhancedKanbanGroup from './EnhancedKanbanGroup'; @@ -119,29 +120,43 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl const handleDragStart = (event: DragStartEvent) => { const { active } = event; const activeId = active.id as string; + const activeData = active.data.current; - // Find the active task and group - let foundTask = null; - let foundGroup = null; + // Check if dragging a group or a task + if (activeData?.type === 'group') { + // Dragging a group + const foundGroup = taskGroups.find(g => g.id === activeId); + setActiveGroup(foundGroup); + setActiveTask(null); - for (const group of taskGroups) { - const task = group.tasks.find(t => t.id === activeId); - if (task) { - foundTask = task; - foundGroup = group; - break; + dispatch(setDragState({ + activeTaskId: null, + activeGroupId: activeId, + isDragging: true, + })); + } else { + // Dragging a task + let foundTask = null; + let foundGroup = null; + + for (const group of taskGroups) { + const task = group.tasks.find(t => t.id === activeId); + if (task) { + foundTask = task; + foundGroup = group; + break; + } } + + setActiveTask(foundTask); + setActiveGroup(null); + + dispatch(setDragState({ + activeTaskId: activeId, + activeGroupId: foundGroup?.id || null, + isDragging: true, + })); } - - setActiveTask(foundTask); - setActiveGroup(foundGroup); - - // Update Redux drag state - dispatch(setDragState({ - activeTaskId: activeId, - activeGroupId: foundGroup?.id || null, - isDragging: true, - })); }; const handleDragOver = (event: DragOverEvent) => { @@ -164,6 +179,7 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; + const activeData = active.data.current; // Reset local state setActiveTask(null); @@ -183,7 +199,28 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl const activeId = active.id as string; const overId = over.id as string; - // Find source and target groups + // Handle group reordering + if (activeData?.type === 'group') { + const fromIndex = taskGroups.findIndex(g => g.id === activeId); + const toIndex = taskGroups.findIndex(g => g.id === overId); + + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + // Create new array with reordered groups + const reorderedGroups = [...taskGroups]; + const [movedGroup] = reorderedGroups.splice(fromIndex, 1); + reorderedGroups.splice(toIndex, 0, movedGroup); + + // Dispatch group reorder action + dispatch(reorderEnhancedKanbanGroups({ + fromIndex, + toIndex, + reorderedGroups, + }) as any); + } + return; + } + + // Handle task reordering (existing logic) let sourceGroup = null; let targetGroup = null; let sourceIndex = -1; @@ -309,6 +346,14 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl isDragOverlay={true} /> )} + {activeGroup && ( +
+
+

{activeGroup.name}

+ ({activeGroup.tasks.length}) +
+
+ )} )} diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.css b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.css index 21b6fa2b..46296ccc 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.css +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.css @@ -1,12 +1,15 @@ .enhanced-kanban-group { - min-width: 280px; - max-width: 320px; + width: 300px; + min-width: 300px; + max-width: 300px; background: var(--ant-color-bg-elevated); border-radius: 8px; padding: 12px; border: 1px solid var(--ant-color-border); box-shadow: 0 1px 2px var(--ant-color-shadow); transition: all 0.2s ease; + display: flex; + flex-direction: column; } .enhanced-kanban-group.drag-over { @@ -15,6 +18,16 @@ box-shadow: 0 0 0 2px var(--ant-color-primary-border); } +.enhanced-kanban-group.group-dragging { + opacity: 0.5; + z-index: 1000; + box-shadow: 0 8px 24px var(--ant-color-shadow); +} + +.enhanced-kanban-group.group-dragging .enhanced-kanban-group-tasks { + background: var(--ant-color-bg-elevated); +} + .enhanced-kanban-group-header { display: flex; justify-content: space-between; @@ -22,21 +35,44 @@ margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--ant-color-border); + cursor: grab; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + transition: all 0.2s ease; + border-radius: 6px; + padding: 8px 12px; + margin: -8px -8px 4px -8px; +} + +.enhanced-kanban-group-header:active { + cursor: grabbing; } .enhanced-kanban-group-header h3 { margin: 0; font-size: 16px; font-weight: 600; - color: var(--ant-color-text); + color: inherit; + text-shadow: 0 1px 2px var(--ant-color-shadow); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + display: inline-block; + vertical-align: middle; } .task-count { - background: var(--ant-color-fill-secondary); + background: var(--ant-color-bg-container); padding: 2px 8px; border-radius: 12px; font-size: 12px; - color: var(--ant-color-text-secondary); + color: var(--ant-color-text); + font-weight: 500; + border: 1px solid var(--ant-color-border); + opacity: 0.8; } .virtualization-indicator { @@ -62,7 +98,9 @@ min-height: 200px; max-height: 600px; transition: all 0.2s ease; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; + background: transparent; } /* Performance optimizations for large lists */ @@ -127,6 +165,42 @@ color: var(--ant-color-primary); } +/* Group drag overlay */ +.group-drag-overlay { + background: var(--ant-color-bg-elevated); + border: 1px solid var(--ant-color-border); + border-radius: 8px; + padding: 12px; + box-shadow: 0 8px 24px var(--ant-color-shadow); + min-width: 280px; + max-width: 320px; + opacity: 0.9; + pointer-events: none; + z-index: 1000; +} + +.group-drag-overlay .group-header-content { + display: flex; + align-items: center; + gap: 8px; +} + +.group-drag-overlay h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--ant-color-text); +} + +.group-drag-overlay .task-count { + background: var(--ant-color-bg-container); + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + color: var(--ant-color-text); + border: 1px solid var(--ant-color-border); +} + /* Responsive design for different screen sizes */ @media (max-width: 768px) { .enhanced-kanban-group { @@ -148,4 +222,23 @@ .enhanced-kanban-group-tasks { max-height: 300px; } +} + +.enhanced-kanban-task-card { + width: 100%; + box-sizing: border-box; +} + +.task-title { + font-weight: 500; + color: var(--ant-color-text); + margin-bottom: 4px; + line-height: 1.4; + word-break: break-word; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; + display: inline-block; + vertical-align: middle; } \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx index 1f62774b..9caed509 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx @@ -1,9 +1,11 @@ import React, { useMemo, useRef, useEffect, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard'; import VirtualizedTaskList from './VirtualizedTaskList'; +import { useAppSelector } from '@/hooks/useAppSelector'; import './EnhancedKanbanGroup.css'; interface EnhancedKanbanGroupProps { @@ -20,7 +22,25 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ activeTaskId, overId }) => { - const { setNodeRef, isOver } = useDroppable({ + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const { setNodeRef: setDroppableRef, isOver } = useDroppable({ + id: group.id, + data: { + type: 'group', + group, + }, + }); + + // Add sortable functionality for group header + const { + attributes, + listeners, + setNodeRef: setSortableRef, + transform, + transition, + isDragging: isGroupDragging, + } = useSortable({ id: group.id, data: { type: 'group', @@ -67,13 +87,41 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ // Performance optimization: Only render drop indicators when needed const shouldShowDropIndicators = isDraggingOver && !shouldVirtualize; + // Combine refs for the main container + const setRefs = (el: HTMLElement | null) => { + setDroppableRef(el); + setSortableRef(el); + }; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isGroupDragging ? 0.5 : 1, + }; + + // Get the appropriate background color based on theme + 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 (
-
-

{group.name}

+
+

{group.name}

({group.tasks.length}) {shouldVirtualize && ( diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx index 4326221d..a04ddb21 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; import './EnhancedKanbanTaskCard.css'; interface EnhancedKanbanTaskCardProps { @@ -11,12 +12,14 @@ interface EnhancedKanbanTaskCardProps { isDropTarget?: boolean; } -const EnhancedKanbanTaskCard: React.FC = React.memo(({ - task, +const EnhancedKanbanTaskCard: React.FC = React.memo(({ + task, isActive = false, isDragOverlay = false, isDropTarget = false }) => { + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { attributes, listeners, @@ -37,6 +40,7 @@ const EnhancedKanbanTaskCard: React.FC = React.memo transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, + backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa', }; return ( @@ -48,7 +52,7 @@ const EnhancedKanbanTaskCard: React.FC = React.memo {...listeners} >
-
{task.name}
+
{task.name}
{/* {task.task_key &&
{task.task_key}
} */} {task.assignees && task.assignees.length > 0 && (
diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index 47d72397..36326269 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -49,12 +49,12 @@ interface EnhancedKanbanState { groupBy: IGroupBy; isSubtasksInclude: boolean; fields: ITaskListSortableColumn[]; - + // Task data taskGroups: ITaskListGroup[]; loadingGroups: boolean; error: string | null; - + // Filters taskAssignees: ITaskListMemberFilter[]; loadingAssignees: boolean; @@ -63,12 +63,12 @@ interface EnhancedKanbanState { loadingLabels: boolean; priorities: string[]; members: string[]; - + // Performance optimizations virtualizedRendering: boolean; taskCache: Record; groupCache: Record; - + // Performance monitoring performanceMetrics: { totalTasks: number; @@ -78,7 +78,7 @@ interface EnhancedKanbanState { lastUpdateTime: number; virtualizationEnabled: boolean; }; - + // Drag and drop state dragState: { activeTaskId: string | null; @@ -86,7 +86,7 @@ interface EnhancedKanbanState { overId: string | null; isDragging: boolean; }; - + // UI state selectedTaskIds: string[]; expandedSubtasks: Record; @@ -137,7 +137,7 @@ const calculatePerformanceMetrics = (taskGroups: ITaskListGroup[]) => { const groupSizes = taskGroups.map(group => group.tasks.length); const largestGroupSize = Math.max(...groupSizes, 0); const averageGroupSize = groupSizes.length > 0 ? totalTasks / groupSizes.length : 0; - + return { totalTasks, largestGroupSize, @@ -234,6 +234,35 @@ export const reorderEnhancedKanbanTasks = createAsyncThunk( } ); +// Group reordering +export const reorderEnhancedKanbanGroups = createAsyncThunk( + 'enhancedKanban/reorderGroups', + async ( + { + fromIndex, + toIndex, + reorderedGroups, + }: { + fromIndex: number; + toIndex: number; + reorderedGroups: ITaskListGroup[]; + }, + { rejectWithValue } + ) => { + try { + // Optimistic update - return immediately for UI responsiveness + return { + fromIndex, + toIndex, + reorderedGroups, + }; + } catch (error) { + logger.error('Reorder Enhanced Kanban Groups', error); + return rejectWithValue('Failed to reorder groups'); + } + } +); + const enhancedKanbanSlice = createSlice({ name: 'enhancedKanbanReducer', initialState, @@ -327,7 +356,7 @@ const enhancedKanbanSlice = createSlice({ // Status updates updateTaskStatus: (state, action: PayloadAction) => { const { id: task_id, status_id } = action.payload; - + // Update in all groups state.taskGroups.forEach(group => { group.tasks.forEach(task => { @@ -342,7 +371,7 @@ const enhancedKanbanSlice = createSlice({ updateTaskPriority: (state, action: PayloadAction) => { const { id: task_id, priority_id } = action.payload; - + // Update in all groups state.taskGroups.forEach(group => { group.tasks.forEach(task => { @@ -358,12 +387,12 @@ const enhancedKanbanSlice = createSlice({ // Task deletion deleteTask: (state, action: PayloadAction) => { const taskId = action.payload; - + // Remove from all groups state.taskGroups.forEach(group => { group.tasks = group.tasks.filter(task => task.id !== taskId); }); - + // Remove from caches delete state.taskCache[taskId]; state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId); @@ -383,10 +412,10 @@ const enhancedKanbanSlice = createSlice({ .addCase(fetchEnhancedKanbanGroups.fulfilled, (state, action) => { state.loadingGroups = false; state.taskGroups = action.payload; - + // Update performance metrics state.performanceMetrics = calculatePerformanceMetrics(action.payload); - + // Update caches action.payload.forEach(group => { state.groupCache[group.id] = group; @@ -394,7 +423,7 @@ const enhancedKanbanSlice = createSlice({ state.taskCache[task.id!] = task; }); }); - + // Initialize column order if not set if (state.columnOrder.length === 0) { state.columnOrder = action.payload.map(group => group.id); @@ -406,20 +435,33 @@ const enhancedKanbanSlice = createSlice({ }) .addCase(reorderEnhancedKanbanTasks.fulfilled, (state, action) => { const { activeGroupId, overGroupId, updatedSourceTasks, updatedTargetTasks } = action.payload; - + // Update groups const sourceGroupIndex = state.taskGroups.findIndex(group => group.id === activeGroupId); const targetGroupIndex = state.taskGroups.findIndex(group => group.id === overGroupId); - + if (sourceGroupIndex !== -1) { state.taskGroups[sourceGroupIndex].tasks = updatedSourceTasks; state.groupCache[activeGroupId] = state.taskGroups[sourceGroupIndex]; } - + if (targetGroupIndex !== -1 && activeGroupId !== overGroupId) { state.taskGroups[targetGroupIndex].tasks = updatedTargetTasks; state.groupCache[overGroupId] = state.taskGroups[targetGroupIndex]; } + }) + .addCase(reorderEnhancedKanbanGroups.fulfilled, (state, action) => { + const { fromIndex, toIndex, reorderedGroups } = action.payload; + + // Update groups + state.taskGroups = reorderedGroups; + state.groupCache = reorderedGroups.reduce((cache, group) => { + cache[group.id] = group; + return cache; + }, {} as Record); + + // Update column order + state.columnOrder = reorderedGroups.map(group => group.id); }); }, });