Files
worklenz/worklenz-frontend/src/components/task-management/task-list-board.tsx
chamiakJ f405777463 feat(task-management): enhance task filtering and UI components for improved usability
- Updated AssigneeSelector and LabelsSelector components to include text color adjustments for better visibility in dark mode.
- Introduced ImprovedTaskFilters component for a more efficient task filtering experience, integrating Redux state management for selected priorities and labels.
- Refactored task management slice to support new filtering capabilities, including selected priorities and improved task fetching logic.
- Enhanced TaskGroup and TaskRow components to accommodate new filtering features and improve overall layout consistency.
2025-06-24 21:40:01 +05:30

700 lines
22 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { Card, Spin, Empty } from 'antd';
import { RootState } from '@/app/store';
import {
taskManagementSelectors,
reorderTasks,
moveTaskToGroup,
optimisticTaskMove,
setLoading,
fetchTasks,
fetchTasksV3,
selectTaskGroupsV3,
selectCurrentGroupingV3,
} from '@/features/task-management/task-management.slice';
import {
selectTaskGroups,
selectCurrentGrouping,
setCurrentGrouping,
} from '@/features/task-management/grouping.slice';
import {
selectSelectedTaskIds,
toggleTaskSelection,
clearSelection,
} from '@/features/task-management/selection.slice';
import { Task } from '@/types/task-management.types';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import TaskRow from './task-row';
// import BulkActionBar from './bulk-action-bar';
import VirtualizedTaskList from './virtualized-task-list';
import { AppDispatch } from '@/app/store';
// Import the improved TaskListFilters component
const ImprovedTaskFilters = React.lazy(
() => import('./improved-task-filters')
);
interface TaskListBoardProps {
projectId: string;
className?: string;
}
interface DragState {
activeTask: Task | null;
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>({
activeTask: null,
activeGroupId: null,
});
// Refs for performance optimization
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Enable real-time socket updates for task changes
useTaskSocketHandlers();
// Redux selectors using V3 API (pre-processed data, minimal loops)
const tasks = useSelector(taskManagementSelectors.selectAll);
const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend
const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend
const selectedTaskIds = useSelector(selectSelectedTaskIds);
const loading = useSelector((state: RootState) => state.taskManagement.loading);
const error = useSelector((state: RootState) => state.taskManagement.error);
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Drag and Drop sensors - optimized for better performance
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 3, // Reduced from 8 for more responsive dragging
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Fetch task groups when component mounts or dependencies change
useEffect(() => {
if (projectId) {
// Fetch real tasks from V3 API (minimal processing needed)
dispatch(fetchTasksV3(projectId));
}
}, [dispatch, projectId, currentGrouping]);
// Memoized calculations - optimized
const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]);
const totalTasksCount = useMemo(() => tasks.length, [tasks]);
const hasSelection = selectedTaskIds.length > 0;
// Memoized handlers for better performance
const handleGroupingChange = useCallback(
(newGroupBy: 'status' | 'priority' | 'phase') => {
dispatch(setCurrentGrouping(newGroupBy));
},
[dispatch]
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event;
const taskId = active.id as string;
// Find the task and its group
const activeTask = tasks.find(t => t.id === taskId) || null;
let activeGroupId: string | null = null;
if (activeTask) {
// Determine group ID based on current grouping
if (currentGrouping === 'status') {
activeGroupId = `status-${activeTask.status}`;
} else if (currentGrouping === 'priority') {
activeGroupId = `priority-${activeTask.priority}`;
} else if (currentGrouping === 'phase') {
activeGroupId = `phase-${activeTask.phase}`;
}
}
setDragState({
activeTask,
activeGroupId,
});
},
[tasks, currentGrouping]
);
// 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 || !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 - optimized
const parseGroupId = (groupId: string) => {
const [groupType, ...groupValueParts] = groupId.split('-');
return {
groupType: groupType as 'status' | 'priority' | 'phase',
groupValue: groupValueParts.join('-'),
};
};
// Determine target group
let targetGroupId = overContainer;
let targetIndex = -1;
// Check if dropping on a task or a group
const targetTask = tasks.find(t => t.id === overContainer);
if (targetTask) {
// Dropping on a task, determine its group
if (currentGrouping === 'status') {
targetGroupId = `status-${targetTask.status}`;
} else if (currentGrouping === 'priority') {
targetGroupId = `priority-${targetTask.priority}`;
} else if (currentGrouping === 'phase') {
targetGroupId = `phase-${targetTask.phase}`;
}
// Find the index of the target task within its group
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (targetGroup) {
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
}
}
const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
const targetGroupInfo = parseGroupId(targetGroupId);
// If moving between different groups, update the task's group property
if (currentDragState.activeGroupId !== targetGroupId) {
dispatch(
moveTaskToGroup({
taskId: activeTaskId,
groupType: targetGroupInfo.groupType,
groupValue: targetGroupInfo.groupValue,
})
);
}
// Handle reordering within the same group or between groups
const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (sourceGroup && targetGroup && targetIndex !== -1) {
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
// 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: [currentDragState.activeTask!.order, ...newOrder],
})
);
}
}
},
[dragState, tasks, taskGroups, currentGrouping, dispatch]
);
const handleSelectTask = useCallback(
(taskId: string, selected: boolean) => {
dispatch(toggleTaskSelection(taskId));
},
[dispatch]
);
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 as 'status' | 'priority' | 'phase') || 'status'}
isSelected={false}
isDragOverlay
/>
);
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
// Cleanup effect
useEffect(() => {
return () => {
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
}
};
}, []);
if (error) {
return (
<Card className={className}>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
return (
<div className={`task-list-board ${className}`} ref={containerRef}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Task Filters */}
<div className="mb-4">
<React.Suspense fallback={<div>Loading filters...</div>}>
<ImprovedTaskFilters position="list" />
</React.Suspense>
</div>
{/* Virtualized 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="virtualized-task-groups">
{taskGroups.map((group, index) => {
// Calculate dynamic height for each group
const groupTasks = group.taskIds.length;
const baseHeight = 120; // Header + column headers + add task row
const taskRowsHeight = groupTasks * 40; // 40px per task row
const minGroupHeight = 300; // Minimum height for better visual appearance
const maxGroupHeight = 600; // Increased maximum height per group
const calculatedHeight = baseHeight + taskRowsHeight;
const groupHeight = Math.max(
minGroupHeight,
Math.min(calculatedHeight, maxGroupHeight)
);
return (
<VirtualizedTaskList
key={group.id}
group={group}
projectId={projectId}
currentGrouping={
(currentGrouping as 'status' | 'priority' | 'phase') || 'status'
}
selectedTaskIds={selectedTaskIds}
onSelectTask={handleSelectTask}
onToggleSubtasks={handleToggleSubtasks}
height={groupHeight}
width={1200}
/>
);
})}
</div>
)}
</div>
<DragOverlay
adjustScale={false}
dropAnimation={null}
style={{
cursor: 'grabbing',
}}
>
{dragOverlayContent}
</DragOverlay>
</DndContext>
<style>{`
.task-groups-container {
max-height: calc(100vh - 300px);
overflow-y: auto;
overflow-x: visible;
padding: 8px 8px 8px 0;
border-radius: 8px;
position: relative;
/* GPU acceleration for smooth scrolling */
transform: translateZ(0);
will-change: scroll-position;
}
.virtualized-task-groups {
min-width: fit-content;
position: relative;
/* GPU acceleration for drag operations */
transform: translateZ(0);
}
.virtualized-task-group {
border: 1px solid var(--task-border-primary, #e8e8e8);
border-radius: 8px;
margin-bottom: 16px;
background: var(--task-bg-primary, white);
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
overflow: hidden;
transition: all 0.3s ease;
position: relative;
}
.virtualized-task-group:last-child {
margin-bottom: 0;
}
/* Task group header styles */
.task-group-header {
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;
}
.task-group-header-row {
display: inline-flex;
height: auto;
max-height: none;
overflow: hidden;
}
.task-group-header-content {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px 6px 0 0;
background-color: #f0f0f0;
color: white;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.task-group-header-text {
color: white !important;
font-size: 13px !important;
font-weight: 600 !important;
margin: 0 !important;
}
/* Column headers styles */
.task-group-column-headers {
background: var(--task-bg-secondary, #f5f5f5);
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
transition: background-color 0.3s ease;
}
.task-group-column-headers-row {
display: flex;
height: 40px;
max-height: 40px;
overflow: visible;
position: relative;
min-width: 1200px;
}
.task-table-header-cell {
background: var(--task-bg-secondary, #f5f5f5);
font-weight: 600;
color: var(--task-text-secondary, #595959);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
height: 32px;
max-height: 32px;
overflow: hidden;
transition: all 0.3s ease;
}
.column-header-text {
font-size: 11px;
font-weight: 600;
color: var(--task-text-secondary, #595959);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: color 0.3s ease;
}
/* Add task row styles */
.task-group-add-task {
background: var(--task-bg-primary, white);
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
transition: all 0.3s ease;
padding: 0 12px;
width: 100%;
min-height: 40px;
display: flex;
align-items: center;
}
.task-group-add-task:hover {
background: var(--task-hover-bg, #fafafa);
}
.task-table-fixed-columns {
display: flex;
background: var(--task-bg-secondary, #f5f5f5);
position: sticky;
left: 0;
z-index: 11;
border-right: 2px solid var(--task-border-primary, #e8e8e8);
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.task-table-scrollable-columns {
display: flex;
flex: 1;
min-width: 0;
}
.task-table-cell {
display: flex;
align-items: center;
padding: 0 12px;
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
font-size: 12px;
white-space: nowrap;
height: 40px;
max-height: 40px;
min-height: 40px;
overflow: hidden;
color: var(--task-text-primary, #262626);
transition: all 0.3s ease;
}
.task-table-cell:last-child {
border-right: none;
}
/* 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 */
:root {
--task-bg-primary: #ffffff;
--task-bg-secondary: #f5f5f5;
--task-bg-tertiary: #f8f9fa;
--task-border-primary: #e8e8e8;
--task-border-secondary: #f0f0f0;
--task-border-tertiary: #d9d9d9;
--task-text-primary: #262626;
--task-text-secondary: #595959;
--task-text-tertiary: #8c8c8c;
--task-shadow: rgba(0, 0, 0, 0.1);
--task-hover-bg: #fafafa;
--task-selected-bg: #e6f7ff;
--task-selected-border: #1890ff;
--task-drag-over-bg: #f0f8ff;
--task-drag-over-border: #40a9ff;
}
.dark .task-groups-container,
[data-theme="dark"] .task-groups-container {
--task-bg-primary: #1f1f1f;
--task-bg-secondary: #141414;
--task-bg-tertiary: #262626;
--task-border-primary: #303030;
--task-border-secondary: #404040;
--task-border-tertiary: #505050;
--task-text-primary: #ffffff;
--task-text-secondary: #d9d9d9;
--task-text-tertiary: #8c8c8c;
--task-shadow: rgba(0, 0, 0, 0.3);
--task-hover-bg: #2a2a2a;
--task-selected-bg: #1a2332;
--task-selected-border: #1890ff;
--task-drag-over-bg: #1a2332;
--task-drag-over-border: #40a9ff;
}
/* Performance optimizations */
.virtualized-task-group {
contain: layout style paint;
}
.task-row {
contain: layout style;
}
/* Reduce layout thrashing */
.task-table-cell {
contain: layout;
}
/* React Window specific optimizations */
.react-window-list {
outline: none;
}
.react-window-list-item {
contain: layout style;
}
`}</style>
</div>
);
};
export default TaskListBoard;