From 687fff9c744f3809b85b8d3e2bc25f6403a528bc Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Mon, 23 Jun 2025 07:29:50 +0530 Subject: [PATCH] feat(task-management): optimize task components for performance and usability - Refactored TaskGroup and TaskRow components to improve rendering efficiency by utilizing memoization and callbacks. - Moved color mappings for group statuses and priorities outside of components to prevent unnecessary re-creations. - Enhanced drag-and-drop functionality with optimistic updates and throttling for smoother user experience. - Updated task management slice to support new properties and batch updates for better performance. - Simplified selectors and improved error handling in the task management slice. --- .../components/task-management/task-group.tsx | 92 +++- .../task-management/task-list-board.tsx | 351 ++++++++---- .../components/task-management/task-row.tsx | 498 +++++++++--------- .../task-management/task-management.slice.ts | 50 +- .../src/types/task-management.types.ts | 3 + 5 files changed, 596 insertions(+), 398 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index d534beb5..9919c313 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector } from 'react-redux'; @@ -23,7 +23,24 @@ interface TaskGroupProps { onToggleSubtasks?: (taskId: string) => void; } -const TaskGroup: React.FC = ({ +// Group color mapping - moved outside component for better performance +const GROUP_COLORS = { + status: { + todo: '#faad14', + doing: '#1890ff', + done: '#52c41a', + }, + priority: { + critical: '#ff4d4f', + high: '#fa8c16', + medium: '#faad14', + low: '#52c41a', + }, + phase: '#722ed1', + default: '#d9d9d9', +} as const; + +const TaskGroup: React.FC = React.memo(({ group, projectId, currentGrouping, @@ -53,57 +70,63 @@ const TaskGroup: React.FC = ({ .filter((task): task is Task => task !== undefined); }, [group.taskIds, allTasks]); - // Calculate group statistics - const completedTasks = useMemo(() => { - return groupTasks.filter(task => task.progress === 100).length; + // Calculate group statistics - memoized + const { completedTasks, totalTasks, completionRate } = useMemo(() => { + const completed = groupTasks.filter(task => task.progress === 100).length; + const total = groupTasks.length; + const rate = total > 0 ? Math.round((completed / total) * 100) : 0; + + return { + completedTasks: completed, + totalTasks: total, + completionRate: rate, + }; }, [groupTasks]); - - const totalTasks = groupTasks.length; - const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; - // Get group color based on grouping type - const getGroupColor = () => { + // Get group color based on grouping type - memoized + const groupColor = useMemo(() => { if (group.color) return group.color; // Fallback colors based on group value switch (currentGrouping) { case 'status': - return group.groupValue === 'todo' ? '#faad14' : - group.groupValue === 'doing' ? '#1890ff' : '#52c41a'; + return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default; case 'priority': - return group.groupValue === 'critical' ? '#ff4d4f' : - group.groupValue === 'high' ? '#fa8c16' : - group.groupValue === 'medium' ? '#faad14' : '#52c41a'; + return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default; case 'phase': - return '#722ed1'; + return GROUP_COLORS.phase; default: - return '#d9d9d9'; + return GROUP_COLORS.default; } - }; + }, [group.color, group.groupValue, currentGrouping]); - const handleToggleCollapse = () => { + // Memoized event handlers + const handleToggleCollapse = useCallback(() => { setIsCollapsed(!isCollapsed); onToggleCollapse?.(group.id); - }; + }, [isCollapsed, onToggleCollapse, group.id]); - const handleAddTask = () => { + const handleAddTask = useCallback(() => { onAddTask?.(group.id); - }; + }, [onAddTask, group.id]); + + // Memoized style object + const containerStyle = useMemo(() => ({ + backgroundColor: isOver ? '#f0f8ff' : undefined, + }), [isOver]); return (
{/* Group Header Row */}
-
- - {/* Selection Checkbox */} -
- -
- - {/* Task Key */} -
- - {task.task_key} - -
- - {/* Task Name */} -
-
-
- - {task.title} - -
-
-
+
+
+ {/* Fixed Columns */} +
+ {/* Drag Handle */} +
+
- {/* Scrollable Columns */} -
- {/* Progress */} -
- {task.progress !== undefined && task.progress >= 0 && ( - - )} -
+ {/* Selection Checkbox */} +
+ +
- {/* Members */} -
-
- {avatarGroupMembers.length > 0 && ( - - )} - -
-
+ {/* Task Key */} +
+ + {task.task_key} + +
- {/* Labels */} -
-
- {task.labels?.map((label, index) => ( - label.end && label.names && label.name ? ( - - ) : ( - - ) - ))} - -
-
- - {/* Status */} -
- - {task.status} - -
- - {/* Priority */} -
-
-
- - {task.priority} + {/* Task Name */} +
+
+
+ + {task.title}
- - {/* Time Tracking */} -
-
- {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( -
- - - {typeof task.timeTracking.logged === 'number' - ? `${task.timeTracking.logged}h` - : task.timeTracking.logged - } - -
- )} -
-
+
+
+ + {/* Scrollable Columns */} +
+ {/* Progress */} +
+ {task.progress !== undefined && task.progress >= 0 && ( + + )} +
+ + {/* Members */} +
+
+ {avatarGroupMembers.length > 0 && ( + + )} + +
+
+ + {/* Labels */} +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} + +
+
+ + {/* Status */} +
+ + {task.status} + +
+ + {/* Priority */} +
+
+
+ + {task.priority} + +
+
+ + {/* Time Tracking */} +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } + +
+ )} +
- +
); }, (prevProps, nextProps) => { - // Custom comparison function for React.memo - // Only re-render if these specific props change - const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length && - prevProps.task.labels.every((label, index) => - label.id === nextProps.task.labels[index]?.id && - label.name === nextProps.task.labels[index]?.name && - label.color === nextProps.task.labels[index]?.color && - label.end === nextProps.task.labels[index]?.end && - JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names) - ); - + // Simplified comparison for better performance return ( prevProps.task.id === nextProps.task.id && - prevProps.task.assignees === nextProps.task.assignees && prevProps.task.title === nextProps.task.title && prevProps.task.progress === nextProps.task.progress && prevProps.task.status === nextProps.task.status && prevProps.task.priority === nextProps.task.priority && - labelsEqual && + prevProps.task.labels?.length === nextProps.task.labels?.length && + prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length && prevProps.isSelected === nextProps.isSelected && prevProps.isDragOverlay === nextProps.isDragOverlay && prevProps.groupId === nextProps.groupId diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 3c5f6298..861ff4a1 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -71,6 +71,7 @@ export const fetchTasks = createAsyncThunk( phase: task.phase_name || 'Development', progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], labels: task.labels?.map((l: any) => ({ id: l.id || l.label_id, name: l.name, @@ -147,13 +148,19 @@ const taskManagementSlice = createSlice({ tasksAdapter.removeMany(state, action.payload); }, - // Drag and drop operations + // Optimized drag and drop operations reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { const { taskIds, newOrder } = action.payload; + + // Batch update for better performance const updates = taskIds.map((id, index) => ({ id, - changes: { order: newOrder[index] }, + changes: { + order: newOrder[index], + updatedAt: new Date().toISOString(), + }, })); + tasksAdapter.updateMany(state, updates); }, @@ -175,6 +182,34 @@ const taskManagementSlice = createSlice({ tasksAdapter.updateOne(state, { id: taskId, changes }); }, + // Optimistic update for drag operations - reduces perceived lag + optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => { + const { taskId, newGroupId, newIndex } = action.payload; + const task = state.entities[taskId]; + + if (task) { + // Parse group ID to determine new values + const [groupType, ...groupValueParts] = newGroupId.split('-'); + const groupValue = groupValueParts.join('-'); + + const changes: Partial = { + order: newIndex, + updatedAt: new Date().toISOString(), + }; + + // Update group-specific field + if (groupType === 'status') { + changes.status = groupValue as Task['status']; + } else if (groupType === 'priority') { + changes.priority = groupValue as Task['priority']; + } else if (groupType === 'phase') { + changes.phase = groupValue; + } + + tasksAdapter.updateOne(state, { id: taskId, changes }); + } + }, + // Loading states setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -198,7 +233,7 @@ const taskManagementSlice = createSlice({ }) .addCase(fetchTasks.rejected, (state, action) => { state.loading = false; - state.error = action.payload as string; + state.error = action.payload as string || 'Failed to fetch tasks'; }); }, }); @@ -212,16 +247,19 @@ export const { bulkDeleteTasks, reorderTasks, moveTaskToGroup, + optimisticTaskMove, setLoading, setError, } = taskManagementSlice.actions; +export default taskManagementSlice.reducer; + // Selectors export const taskManagementSelectors = tasksAdapter.getSelectors( (state) => state.taskManagement ); -// Additional selectors +// Enhanced selectors for better performance export const selectTasksByStatus = (state: RootState, status: string) => taskManagementSelectors.selectAll(state).filter(task => task.status === status); @@ -232,6 +270,4 @@ export const selectTasksByPhase = (state: RootState, phase: string) => taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; -export const selectTasksError = (state: RootState) => state.taskManagement.error; - -export default taskManagementSlice.reducer; \ No newline at end of file +export const selectTasksError = (state: RootState) => state.taskManagement.error; \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index e8204805..f2fd5f66 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,3 +1,5 @@ +import { InlineMember } from './teamMembers/inlineMember.types'; + export interface Task { id: string; task_key: string; @@ -8,6 +10,7 @@ export interface Task { phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' progress: number; // 0-100 assignees: string[]; + assignee_names?: InlineMember[]; labels: Label[]; dueDate?: string; timeTracking: {