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.
This commit is contained in:
chamiakJ
2025-06-23 07:29:50 +05:30
parent 05729285af
commit 687fff9c74
5 changed files with 596 additions and 398 deletions

View File

@@ -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<TaskGroupProps> = ({
// 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<TaskGroupProps> = React.memo(({
group,
projectId,
currentGrouping,
@@ -53,57 +70,63 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
.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 (
<div
ref={setNodeRef}
className={`task-group ${isOver ? 'drag-over' : ''}`}
style={{
backgroundColor: isOver ? '#f0f8ff' : undefined,
}}
style={containerStyle}
>
{/* Group Header Row */}
<div className="task-group-header">
<div className="task-group-header-row">
<div
className="task-group-header-content"
style={{ backgroundColor: getGroupColor() }}
style={{ backgroundColor: groupColor }}
>
<Button
type="text"
@@ -123,7 +146,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{!isCollapsed && totalTasks > 0 && (
<div
className="task-group-column-headers"
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
style={{ borderLeft: `4px solid ${groupColor}` }}
>
<div className="task-group-column-headers-row">
<div className="task-table-fixed-columns">
@@ -170,7 +193,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{!isCollapsed && (
<div
className="task-group-body"
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
style={{ borderLeft: `4px solid ${groupColor}` }}
>
{groupTasks.length === 0 ? (
<div className="task-group-empty">
@@ -428,6 +451,17 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
`}</style>
</div>
);
};
}, (prevProps, nextProps) => {
// Simplified comparison for better performance
return (
prevProps.group.id === nextProps.group.id &&
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
prevProps.group.collapsed === nextProps.group.collapsed &&
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
prevProps.currentGrouping === nextProps.currentGrouping
);
});
TaskGroup.displayName = 'TaskGroup';
export default TaskGroup;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
DndContext,
@@ -21,6 +21,7 @@ import {
taskManagementSelectors,
reorderTasks,
moveTaskToGroup,
optimisticTaskMove,
setLoading,
fetchTasks
} from '@/features/task-management/task-management.slice';
@@ -54,6 +55,27 @@ interface DragState {
activeGroupId: string | null;
}
// Throttle utility for performance optimization
const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
let timeoutId: NodeJS.Timeout | null = null;
let lastExecTime = 0;
return ((...args: any[]) => {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func(...args);
lastExecTime = currentTime;
} else {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
lastExecTime = Date.now();
}, delay - (currentTime - lastExecTime));
}
}) as T;
};
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch<AppDispatch>();
const [dragState, setDragState] = useState<DragState>({
@@ -61,6 +83,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
activeGroupId: null,
});
// Refs for performance optimization
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Enable real-time socket updates for task changes
useTaskSocketHandlers();
@@ -72,11 +97,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const loading = useSelector((state: RootState) => state.taskManagement.loading);
const error = useSelector((state: RootState) => state.taskManagement.error);
// Drag and Drop sensors
// Drag and Drop sensors - optimized for better performance
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
distance: 3, // Reduced from 8 for more responsive dragging
},
}),
useSensor(KeyboardSensor, {
@@ -92,23 +117,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
}
}, [dispatch, projectId, currentGrouping]);
// Memoized calculations
const allTaskIds = useMemo(() => {
return tasks.map(task => task.id);
}, [tasks]);
const totalTasksCount = useMemo(() => {
return tasks.length;
}, [tasks]);
// Memoized calculations - optimized
const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]);
const totalTasksCount = useMemo(() => tasks.length, [tasks]);
const hasSelection = selectedTaskIds.length > 0;
// Handlers
const handleGroupingChange = (newGroupBy: typeof currentGrouping) => {
// Memoized handlers for better performance
const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => {
dispatch(setCurrentGrouping(newGroupBy));
};
}, [dispatch]);
const handleDragStart = (event: DragStartEvent) => {
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const taskId = active.id as string;
@@ -131,28 +150,76 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
activeTask,
activeGroupId,
});
};
}, [tasks, currentGrouping]);
const handleDragOver = (event: DragOverEvent) => {
// Handle drag over logic if needed for visual feedback
};
const handleDragEnd = (event: DragEndEvent) => {
// Throttled drag over handler for better performance
const handleDragOver = useCallback(throttle((event: DragOverEvent) => {
const { active, over } = event;
if (!over || !dragState.activeTask) return;
const activeTaskId = active.id as string;
const overContainer = over.id as string;
// Clear any existing timeout
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
}
// Optimistic update with throttling
dragOverTimeoutRef.current = setTimeout(() => {
// Only update if we're hovering over a different container
const targetTask = tasks.find(t => t.id === overContainer);
let targetGroupId = overContainer;
if (targetTask) {
if (currentGrouping === 'status') {
targetGroupId = `status-${targetTask.status}`;
} else if (currentGrouping === 'priority') {
targetGroupId = `priority-${targetTask.priority}`;
} else if (currentGrouping === 'phase') {
targetGroupId = `phase-${targetTask.phase}`;
}
}
if (targetGroupId !== dragState.activeGroupId) {
// Perform optimistic update for visual feedback
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (targetGroup) {
dispatch(optimisticTaskMove({
taskId: activeTaskId,
newGroupId: targetGroupId,
newIndex: targetGroup.taskIds.length,
}));
}
}
}, 50); // 50ms throttle for drag over events
}, 50), [dragState, tasks, taskGroups, currentGrouping, dispatch]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
// Clear any pending drag over timeouts
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
dragOverTimeoutRef.current = null;
}
// Reset drag state immediately for better UX
const currentDragState = dragState;
setDragState({
activeTask: null,
activeGroupId: null,
});
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
return;
}
const activeTaskId = active.id as string;
const overContainer = over.id as string;
// Parse the group ID to get group type and value
// Parse the group ID to get group type and value - optimized
const parseGroupId = (groupId: string) => {
const [groupType, ...groupValueParts] = groupId.split('-');
return {
@@ -184,11 +251,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
}
}
const sourceGroupInfo = parseGroupId(dragState.activeGroupId);
const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
const targetGroupInfo = parseGroupId(targetGroupId);
// If moving between different groups, update the task's group property
if (dragState.activeGroupId !== targetGroupId) {
if (currentDragState.activeGroupId !== targetGroupId) {
dispatch(moveTaskToGroup({
taskId: activeTaskId,
groupType: targetGroupInfo.groupType,
@@ -197,37 +264,65 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
}
// Handle reordering within the same group or between groups
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (sourceGroup && targetGroup) {
if (sourceGroup && targetGroup && targetIndex !== -1) {
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
// Calculate new order values
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
const newOrder = allTasksInTargetGroup.map((task, index) => {
if (index < finalTargetIndex) return task.order;
if (index === finalTargetIndex) return dragState.activeTask!.order;
return task.order + 1;
});
// Only reorder if actually moving to a different position
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
// Calculate new order values - simplified
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
const newOrder = allTasksInTargetGroup.map((task, index) => {
if (index < finalTargetIndex) return task.order;
if (index === finalTargetIndex) return currentDragState.activeTask!.order;
return task.order + 1;
});
// Dispatch reorder action
dispatch(reorderTasks({
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
newOrder: [dragState.activeTask!.order, ...newOrder]
}));
// Dispatch reorder action
dispatch(reorderTasks({
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
newOrder: [currentDragState.activeTask!.order, ...newOrder]
}));
}
}
};
}, [dragState, tasks, taskGroups, currentGrouping, dispatch]);
const handleSelectTask = (taskId: string, selected: boolean) => {
const handleSelectTask = useCallback((taskId: string, selected: boolean) => {
dispatch(toggleTaskSelection(taskId));
};
}, [dispatch]);
const handleToggleSubtasks = (taskId: string) => {
const handleToggleSubtasks = useCallback((taskId: string) => {
// Implementation for toggling subtasks
console.log('Toggle subtasks for task:', taskId);
};
}, []);
// Memoized DragOverlay content for better performance
const dragOverlayContent = useMemo(() => {
if (!dragState.activeTask || !dragState.activeGroupId) return null;
return (
<TaskRow
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId}
currentGrouping={currentGrouping}
isSelected={false}
isDragOverlay
/>
);
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
// Cleanup effect
useEffect(() => {
return () => {
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
}
};
}, []);
if (error) {
return (
@@ -242,51 +337,51 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
return (
<div className={`task-list-board ${className}`}>
{/* Task Filters */}
<Card
size="small"
className="mb-4"
styles={{ body: { padding: '12px 16px' } }}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<React.Suspense fallback={<div>Loading filters...</div>}>
<TaskListFilters position="list" />
</React.Suspense>
</Card>
{/* Task Filters */}
<Card
size="small"
className="mb-4"
styles={{ body: { padding: '12px 16px' } }}
>
<React.Suspense fallback={<div>Loading filters...</div>}>
<TaskListFilters position="list" />
</React.Suspense>
</Card>
{/* Bulk Action Bar */}
{hasSelection && (
<BulkActionBar
selectedTaskIds={selectedTaskIds}
totalSelected={selectedTaskIds.length}
currentGrouping={currentGrouping as any}
projectId={projectId}
onClearSelection={() => dispatch(clearSelection())}
/>
)}
{/* Bulk Action Bar */}
{hasSelection && (
<BulkActionBar
selectedTaskIds={selectedTaskIds}
totalSelected={selectedTaskIds.length}
currentGrouping={currentGrouping as any}
projectId={projectId}
onClearSelection={() => dispatch(clearSelection())}
/>
)}
{/* Task Groups Container */}
<div className="task-groups-container">
{loading ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty
description="No tasks found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Task Groups Container */}
<div className="task-groups-container">
{loading ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty
description="No tasks found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
) : (
<div className="task-groups">
{taskGroups.map((group) => (
<TaskGroup
@@ -300,22 +395,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
/>
))}
</div>
)}
</div>
<DragOverlay>
{dragState.activeTask ? (
<TaskRow
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId!}
currentGrouping={currentGrouping}
isSelected={false}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
<DragOverlay
adjustScale={false}
dropAnimation={null}
style={{
cursor: 'grabbing',
}}
>
{dragOverlayContent}
</DragOverlay>
</DndContext>
<style>{`
.task-groups-container {
@@ -324,13 +416,52 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
overflow-x: visible;
padding: 8px 8px 8px 0;
border-radius: 8px;
transition: background-color 0.3s ease;
position: relative;
/* GPU acceleration for smooth scrolling */
transform: translateZ(0);
will-change: scroll-position;
}
.task-groups {
min-width: fit-content;
position: relative;
/* GPU acceleration for drag operations */
transform: translateZ(0);
}
/* Optimized drag overlay styles */
[data-dnd-overlay] {
/* GPU acceleration for smooth dragging */
transform: translateZ(0);
will-change: transform;
pointer-events: none;
}
/* Fix drag overlay positioning */
[data-rbd-drag-handle-dragging-id] {
transform: none !important;
}
/* DndKit drag overlay specific styles */
.dndkit-drag-overlay {
z-index: 9999;
pointer-events: none;
transform: translateZ(0);
will-change: transform;
}
/* Ensure drag overlay follows cursor properly */
[data-dnd-context] {
position: relative;
}
/* Fix for scrollable containers affecting drag overlay */
.task-groups-container [data-dnd-overlay] {
position: fixed !important;
top: 0 !important;
left: 0 !important;
transform: translateZ(0);
z-index: 9999;
}
/* Dark mode support */
@@ -370,6 +501,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
--task-drag-over-bg: #1a2332;
--task-drag-over-border: #40a9ff;
}
/* Performance optimizations */
.task-group {
contain: layout style paint;
}
.task-row {
contain: layout style;
}
/* Reduce layout thrashing */
.task-table-cell {
contain: layout;
}
`}</style>
</div>
);

View File

@@ -24,6 +24,20 @@ interface TaskRowProps {
onToggleSubtasks?: (taskId: string) => void;
}
// Priority and status colors - moved outside component to avoid recreation
const PRIORITY_COLORS = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
} as const;
const STATUS_COLORS = {
todo: '#f0f0f0',
doing: '#1890ff',
done: '#52c41a',
} as const;
const TaskRow: React.FC<TaskRowProps> = React.memo(({
task,
projectId,
@@ -53,12 +67,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
});
// Get theme from Redux store
const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light');
// Memoize derived values for performance
const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]);
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Memoize style calculations
// Memoize style calculations - simplified
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
@@ -74,7 +85,67 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
onToggleSubtasks?.(task.id);
}, [onToggleSubtasks, task.id]);
// Format due date - memoized for performance
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
const avatarGroupMembers = useMemo(() => {
return task.assignee_names || [];
}, [task.assignee_names]);
// Simplified class name calculations
const containerClasses = useMemo(() => {
const baseClasses = 'border-b transition-all duration-300';
const themeClasses = isDarkMode
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
: 'border-gray-200 bg-white hover:bg-gray-50';
const selectedClasses = isSelected
? (isDarkMode ? 'bg-blue-900/20 border-l-4 border-l-blue-500' : 'bg-blue-50 border-l-4 border-l-blue-500')
: '';
const overlayClasses = isDragOverlay
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
: '';
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
}, [isDarkMode, isSelected, isDragOverlay]);
const fixedColumnsClasses = useMemo(() =>
`flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`,
[isDarkMode]
);
const taskNameClasses = useMemo(() => {
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300';
const themeClasses = isDarkMode ? 'text-gray-100' : 'text-gray-900';
const completedClasses = task.progress === 100
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
: '';
return `${baseClasses} ${themeClasses} ${completedClasses}`;
}, [isDarkMode, task.progress]);
// Get colors - using constants for better performance
const getPriorityColor = useCallback((priority: string) =>
PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []);
const getStatusColor = useCallback((status: string) =>
STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []);
// Create adapter for LabelsSelector - memoized
const taskAdapter = useMemo(() => ({
id: task.id,
name: task.title,
parent_task_id: null,
all_labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
} as any), [task.id, task.title, task.labels]);
// Memoize due date calculation
const dueDate = useMemo(() => {
if (!task.dueDate) return null;
const date = new Date(task.dueDate);
@@ -93,278 +164,187 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
}
}, [task.dueDate]);
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
const avatarGroupMembers = useMemo(() => {
return task.assignees?.map(assigneeId => ({
id: assigneeId,
team_member_id: assigneeId,
name: assigneeId // TODO: Map to actual user names
})) || [];
}, [task.assignees]);
// Memoize class names for better performance
const containerClassName = useMemo(() => `
border-b transition-all duration-300
${isDarkMode
? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}`
: `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}`
}
${isSelected ? 'border-l-4 border-l-blue-500' : ''}
${isDragOverlay
? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}`
: ''
}
`, [isDarkMode, isSelected, isDragOverlay]);
const fixedColumnsClassName = useMemo(() => `
flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}
`, [isDarkMode]);
const taskNameClassName = useMemo(() => `
text-sm font-medium flex-1
overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300
${isDarkMode ? 'text-gray-100' : 'text-gray-900'}
${task.progress === 100
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
: ''
}
`, [isDarkMode, task.progress]);
// Get priority color
const getPriorityColor = (priority: string) => {
const colors = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
};
return colors[priority as keyof typeof colors] || '#d9d9d9';
};
// Get status color
const getStatusColor = (status: string) => {
const colors = {
todo: '#f0f0f0',
doing: '#1890ff',
done: '#52c41a',
};
return colors[status as keyof typeof colors] || '#d9d9d9';
};
// Create adapter for LabelsSelector to work with new Task type
const taskAdapter = useMemo(() => {
// Convert new Task type to IProjectTask for compatibility
return {
id: task.id,
name: task.title,
parent_task_id: null, // TODO: Add parent task support
all_labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
} as any; // Type assertion for compatibility
}, [task.id, task.title, task.labels]);
return (
<>
<div
ref={setNodeRef}
style={style}
className={containerClassName}
>
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
{/* Fixed Columns */}
<div className={fixedColumnsClassName}>
{/* Drag Handle */}
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Button
variant="text"
size="small"
icon={<HolderOutlined />}
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
isDarkMode={isDarkMode}
{...attributes}
{...listeners}
>
</Button>
</div>
{/* Selection Checkbox */}
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Checkbox
checked={isSelected}
onChange={handleSelectChange}
isDarkMode={isDarkMode}
/>
</div>
{/* Task Key */}
<div className={`w-20 flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Tag
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
color={isDarkMode ? "#d1d5db" : "#666"}
className="truncate whitespace-nowrap max-w-full"
>
{task.task_key}
</Tag>
</div>
{/* Task Name */}
<div className="w-[475px] flex items-center px-2">
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
<div className="flex items-center gap-2 h-5 overflow-hidden">
<span className={taskNameClassName}>
{task.title}
</span>
</div>
</div>
</div>
<div
ref={setNodeRef}
style={style}
className={containerClasses}
>
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
{/* Fixed Columns */}
<div className={fixedColumnsClasses}>
{/* Drag Handle */}
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Button
variant="text"
size="small"
icon={<HolderOutlined />}
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
isDarkMode={isDarkMode}
{...attributes}
{...listeners}
/>
</div>
{/* Scrollable Columns */}
<div className="flex flex-1 min-w-0">
{/* Progress */}
<div className={`w-[90px] flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
{task.progress !== undefined && task.progress >= 0 && (
<Progress
type="circle"
percent={task.progress}
size={24}
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
strokeWidth={2}
showInfo={true}
isDarkMode={isDarkMode}
/>
)}
</div>
{/* Selection Checkbox */}
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Checkbox
checked={isSelected}
onChange={handleSelectChange}
isDarkMode={isDarkMode}
/>
</div>
{/* Members */}
<div className={`w-[150px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2">
{avatarGroupMembers.length > 0 && (
<AvatarGroup
members={avatarGroupMembers}
size={24}
maxCount={3}
isDarkMode={isDarkMode}
/>
)}
<button
className={`
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
onClick={() => {
// TODO: Implement assignee selector functionality
console.log('Add assignee clicked for task:', task.id);
}}
>
<span className="text-xs">+</span>
</button>
</div>
</div>
{/* Task Key */}
<div className={`w-20 flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Tag
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
color={isDarkMode ? "#d1d5db" : "#666"}
className="truncate whitespace-nowrap max-w-full"
>
{task.task_key}
</Tag>
</div>
{/* Labels */}
<div className={`w-[200px] max-w-[200px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
{task.labels?.map((label, index) => (
label.end && label.names && label.name ? (
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={label.names}
namesString={label.name}
isDarkMode={isDarkMode}
/>
) : (
<CustomColordLabel
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
)
))}
<LabelsSelector
task={taskAdapter}
isDarkMode={isDarkMode}
/>
</div>
</div>
{/* Status */}
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Tag
backgroundColor={getStatusColor(task.status)}
color="white"
className="text-xs font-medium uppercase"
>
{task.status}
</Tag>
</div>
{/* Priority */}
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{task.priority}
{/* Task Name */}
<div className="w-[475px] flex items-center px-2">
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
<div className="flex items-center gap-2 h-5 overflow-hidden">
<span className={taskNameClasses}>
{task.title}
</span>
</div>
</div>
{/* Time Tracking */}
<div className={`w-[120px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2 h-full overflow-hidden">
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
<div className="flex items-center gap-1">
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{typeof task.timeTracking.logged === 'number'
? `${task.timeTracking.logged}h`
: task.timeTracking.logged
}
</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Scrollable Columns */}
<div className="flex flex-1 min-w-0">
{/* Progress */}
<div className={`w-[90px] flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
{task.progress !== undefined && task.progress >= 0 && (
<Progress
type="circle"
percent={task.progress}
size={24}
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
strokeWidth={2}
showInfo={true}
isDarkMode={isDarkMode}
/>
)}
</div>
{/* Members */}
<div className={`w-[150px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2">
{avatarGroupMembers.length > 0 && (
<AvatarGroup
members={avatarGroupMembers}
size={24}
maxCount={3}
isDarkMode={isDarkMode}
/>
)}
<button
className={`
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
onClick={() => {
// TODO: Implement assignee selector functionality
console.log('Add assignee clicked for task:', task.id);
}}
>
<span className="text-xs">+</span>
</button>
</div>
</div>
{/* Labels */}
<div className={`w-[200px] max-w-[200px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
{task.labels?.map((label, index) => (
label.end && label.names && label.name ? (
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={label.names}
namesString={label.name}
isDarkMode={isDarkMode}
/>
) : (
<CustomColordLabel
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
)
))}
<LabelsSelector
task={taskAdapter}
isDarkMode={isDarkMode}
/>
</div>
</div>
{/* Status */}
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Tag
backgroundColor={getStatusColor(task.status)}
color="white"
className="text-xs font-medium uppercase"
>
{task.status}
</Tag>
</div>
{/* Priority */}
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{task.priority}
</span>
</div>
</div>
{/* Time Tracking */}
<div className={`w-[120px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2 h-full overflow-hidden">
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
<div className="flex items-center gap-1">
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{typeof task.timeTracking.logged === 'number'
? `${task.timeTracking.logged}h`
: task.timeTracking.logged
}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</>
</div>
);
}, (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

View File

@@ -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<Task> = {
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<boolean>) => {
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<RootState>(
(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;
export const selectTasksError = (state: RootState) => state.taskManagement.error;

View File

@@ -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: {