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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user