feat(tasks): implement V3 API for task management and enhance UI components
- Introduced `getTasksV3` and `refreshTaskProgress` methods in `TasksControllerV2` to optimize task retrieval and progress refreshing. - Updated API routes to include new endpoints for V3 task management. - Enhanced frontend components to utilize the new V3 API, improving performance by reducing frontend processing. - Added `VirtualizedTaskList` and `VirtualizedTaskGroup` components for efficient rendering of task lists. - Updated task management slice to support new V3 data structure and improved state management. - Refactored styles for better dark mode support and overall UI consistency.
This commit is contained in:
@@ -31,7 +31,6 @@ const GROUP_COLORS = {
|
||||
done: '#52c41a',
|
||||
},
|
||||
priority: {
|
||||
critical: '#ff4d4f',
|
||||
high: '#fa8c16',
|
||||
medium: '#faad14',
|
||||
low: '#52c41a',
|
||||
@@ -63,6 +62,9 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
// Get all tasks from the store
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get theme from Redux store
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
@@ -112,8 +114,10 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
|
||||
// Memoized style object
|
||||
const containerStyle = useMemo(() => ({
|
||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
||||
}), [isOver]);
|
||||
backgroundColor: isOver
|
||||
? (isDarkMode ? '#1a2332' : '#f0f8ff')
|
||||
: undefined,
|
||||
}), [isOver, isDarkMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
moveTaskToGroup,
|
||||
optimisticTaskMove,
|
||||
setLoading,
|
||||
fetchTasks
|
||||
fetchTasks,
|
||||
fetchTasksV3,
|
||||
selectTaskGroupsV3,
|
||||
selectCurrentGroupingV3
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectTaskGroups,
|
||||
@@ -37,9 +40,9 @@ import {
|
||||
} from '@/features/task-management/selection.slice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import TaskGroup from './task-group';
|
||||
import TaskRow from './task-row';
|
||||
import BulkActionBar from './bulk-action-bar';
|
||||
import VirtualizedTaskList from './virtualized-task-list';
|
||||
import { AppDispatch } from '@/app/store';
|
||||
|
||||
// Import the TaskListFilters component
|
||||
@@ -85,17 +88,21 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
|
||||
// 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 new task management slices
|
||||
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||
const taskGroups = useSelector(selectTaskGroups);
|
||||
const currentGrouping = useSelector(selectCurrentGrouping);
|
||||
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(
|
||||
@@ -112,8 +119,8 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
// Fetch task groups when component mounts or dependencies change
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
// Fetch real tasks from API
|
||||
dispatch(fetchTasks(projectId));
|
||||
// Fetch real tasks from V3 API (minimal processing needed)
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
}, [dispatch, projectId, currentGrouping]);
|
||||
|
||||
@@ -123,7 +130,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const hasSelection = selectedTaskIds.length > 0;
|
||||
|
||||
// Memoized handlers for better performance
|
||||
const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => {
|
||||
const handleGroupingChange = useCallback((newGroupBy: 'status' | 'priority' | 'phase') => {
|
||||
dispatch(setCurrentGrouping(newGroupBy));
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -308,7 +315,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
task={dragState.activeTask}
|
||||
projectId={projectId}
|
||||
groupId={dragState.activeGroupId}
|
||||
currentGrouping={currentGrouping}
|
||||
currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'}
|
||||
isSelected={false}
|
||||
isDragOverlay
|
||||
/>
|
||||
@@ -336,7 +343,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`task-list-board ${className}`}>
|
||||
<div className={`task-list-board ${className}`} ref={containerRef}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
@@ -366,7 +373,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Groups Container */}
|
||||
{/* Virtualized Task Groups Container */}
|
||||
<div className="task-groups-container">
|
||||
{loading ? (
|
||||
<Card>
|
||||
@@ -382,18 +389,31 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="task-groups">
|
||||
{taskGroups.map((group) => (
|
||||
<TaskGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
currentGrouping={currentGrouping}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onSelectTask={handleSelectTask}
|
||||
onToggleSubtasks={handleToggleSubtasks}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
@@ -422,13 +442,150 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.task-groups {
|
||||
.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 */
|
||||
@@ -503,7 +660,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.task-group {
|
||||
.virtualized-task-group {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
@@ -515,6 +672,15 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
.task-table-cell {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
/* React Window specific optimizations */
|
||||
.react-window-list {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.react-window-list-item {
|
||||
contain: layout style;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Input, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import {
|
||||
HolderOutlined,
|
||||
MessageOutlined,
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
@@ -49,6 +53,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
onSelect,
|
||||
onToggleSubtasks,
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
// Edit task name state
|
||||
const [editTaskName, setEditTaskName] = useState(false);
|
||||
const [taskName, setTaskName] = useState(task.title || '');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -69,6 +81,40 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
// Get theme from Redux store
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
|
||||
// Click outside detection for edit mode
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
handleTaskNameSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (editTaskName) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editTaskName]);
|
||||
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = inputRef.current?.input?.value;
|
||||
if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
name: newTaskName,
|
||||
parent_task: null, // Assuming top-level tasks for now
|
||||
})
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [connected, socket, task.id, task.title]);
|
||||
|
||||
// Memoize style calculations - simplified
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -97,12 +143,11 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
? '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')
|
||||
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
|
||||
: '';
|
||||
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]);
|
||||
|
||||
@@ -112,8 +157,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
);
|
||||
|
||||
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 baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer';
|
||||
const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600';
|
||||
const completedClasses = task.progress === 100
|
||||
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
||||
: '';
|
||||
@@ -207,12 +252,36 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</div>
|
||||
|
||||
{/* Task Name */}
|
||||
<div className="w-[475px] flex items-center px-2">
|
||||
<div className={`w-[475px] flex items-center px-2 ${editTaskName ? (isDarkMode ? 'bg-blue-900/10 border border-blue-500' : 'bg-blue-50/20 border border-blue-500') : ''}`}>
|
||||
<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 ref={wrapperRef} className="flex-1 min-w-0">
|
||||
{!editTaskName ? (
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
onClick={() => setEditTaskName(true)}
|
||||
className={taskNameClasses}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
variant="borderless"
|
||||
value={taskName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTaskName(e.target.value)}
|
||||
onPressEnter={handleTaskNameSave}
|
||||
className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '2px 4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import TaskRow from './task-row';
|
||||
|
||||
interface VirtualizedTaskGroupProps {
|
||||
group: any;
|
||||
projectId: string;
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
selectedTaskIds: string[];
|
||||
onSelectTask: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks: (taskId: string) => void;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const VirtualizedTaskGroup: React.FC<VirtualizedTaskGroupProps> = React.memo(({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
height,
|
||||
width
|
||||
}) => {
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
.map((taskId: string) => allTasks.find((task: Task) => task.id === taskId))
|
||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||
}, [group.taskIds, allTasks]);
|
||||
|
||||
const TASK_ROW_HEIGHT = 40;
|
||||
const GROUP_HEADER_HEIGHT = 40;
|
||||
const COLUMN_HEADER_HEIGHT = 40;
|
||||
const ADD_TASK_ROW_HEIGHT = 40;
|
||||
|
||||
// Calculate total height for the group
|
||||
const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT;
|
||||
|
||||
// Row renderer for virtualization
|
||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
// Header row
|
||||
if (index === 0) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div className="task-group-header-content">
|
||||
<span className="task-group-header-text">
|
||||
{group.title} ({groupTasks.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Column headers row
|
||||
if (index === 1) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-column-headers">
|
||||
<div className="task-group-column-headers-row">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||
<span className="column-header-text">Key</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||
<span className="column-header-text">Task</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||
<span className="column-header-text">Progress</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<span className="column-header-text">Members</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||
<span className="column-header-text">Labels</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Status</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Priority</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||
<span className="column-header-text">Time Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Task rows
|
||||
const taskIndex = index - 2;
|
||||
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
||||
const task = groupTasks[taskIndex];
|
||||
return (
|
||||
<div style={style}>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={taskIndex}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add task row (last row)
|
||||
if (taskIndex === groupTasks.length) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-add-task">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div style={{ width: '380px', padding: '8px 12px' }}>
|
||||
<span className="text-gray-500">+ Add task</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-group">
|
||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||
<List
|
||||
height={Math.min(height, totalHeight)}
|
||||
width={width}
|
||||
itemCount={groupTasks.length + 3} // +3 for header, column headers, and add task row
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
overscanCount={5} // Render 5 extra items for smooth scrolling
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default VirtualizedTaskGroup;
|
||||
@@ -0,0 +1,429 @@
|
||||
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import TaskRow from './task-row';
|
||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||
|
||||
interface VirtualizedTaskListProps {
|
||||
group: any;
|
||||
projectId: string;
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
selectedTaskIds: string[];
|
||||
onSelectTask: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks: (taskId: string) => void;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
height,
|
||||
width
|
||||
}) => {
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
.map((taskId: string) => allTasks.find((task: Task) => task.id === taskId))
|
||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||
}, [group.taskIds, allTasks]);
|
||||
|
||||
const TASK_ROW_HEIGHT = 40;
|
||||
const HEADER_HEIGHT = 40;
|
||||
const COLUMN_HEADER_HEIGHT = 40;
|
||||
|
||||
// Calculate the actual height needed for the virtualized list
|
||||
const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT);
|
||||
const listHeight = Math.min(height - 40, actualContentHeight);
|
||||
|
||||
// Calculate item count - only include actual content
|
||||
const getItemCount = () => {
|
||||
return groupTasks.length + 2; // +2 for header and column headers only
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('VirtualizedTaskList:', {
|
||||
groupId: group.id,
|
||||
groupTasks: groupTasks.length,
|
||||
height,
|
||||
listHeight,
|
||||
itemCount: getItemCount(),
|
||||
isVirtualized: groupTasks.length > 10, // Show if virtualization should be active
|
||||
minHeight: 300,
|
||||
maxHeight: 600
|
||||
});
|
||||
}, [group.id, groupTasks.length, height, listHeight]);
|
||||
|
||||
// Row renderer for virtualization
|
||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
// Header row
|
||||
if (index === 0) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div
|
||||
className="task-group-header-content"
|
||||
style={{
|
||||
backgroundColor: group.color || '#f0f0f0',
|
||||
borderLeft: `4px solid ${group.color || '#f0f0f0'}`
|
||||
}}
|
||||
>
|
||||
<span className="task-group-header-text">
|
||||
{group.title} ({groupTasks.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Column headers row
|
||||
if (index === 1) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
className="task-group-column-headers"
|
||||
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }}
|
||||
>
|
||||
<div className="task-group-column-headers-row">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||
<span className="column-header-text">Key</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||
<span className="column-header-text">Task</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||
<span className="column-header-text">Progress</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<span className="column-header-text">Members</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||
<span className="column-header-text">Labels</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Status</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Priority</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||
<span className="column-header-text">Time Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Task rows
|
||||
const taskIndex = index - 2;
|
||||
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
||||
const task = groupTasks[taskIndex];
|
||||
return (
|
||||
<div
|
||||
className="task-row-container"
|
||||
style={{
|
||||
...style,
|
||||
'--group-color': group.color || '#f0f0f0'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={taskIndex}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-list" style={{ height: height }}>
|
||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||
<List
|
||||
height={listHeight}
|
||||
width={width}
|
||||
itemCount={getItemCount()}
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
overscanCount={15} // Render 15 extra items for smooth scrolling
|
||||
className="react-window-list"
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
</SortableContext>
|
||||
|
||||
{/* Add Task Row - Always show at the bottom */}
|
||||
<div
|
||||
className="task-group-add-task"
|
||||
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }}
|
||||
>
|
||||
<AddTaskListRow groupId={group.id} />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.virtualized-task-list {
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.virtualized-task-list:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.react-window-list {
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.react-window-list-item {
|
||||
contain: layout style;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Task row container styles */
|
||||
.task-row-container {
|
||||
position: relative;
|
||||
background: var(--task-bg-primary, white);
|
||||
}
|
||||
|
||||
.task-row-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: var(--group-color, #f0f0f0);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Ensure no gaps between list items */
|
||||
.react-window-list > div {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Task group header styles */
|
||||
.task-group-header {
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.task-group-header-row {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-group-header-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
top: 40px;
|
||||
z-index: 19;
|
||||
}
|
||||
|
||||
.task-group-column-headers-row {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
min-width: 1200px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.virtualized-task-list {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* 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 .virtualized-task-list,
|
||||
[data-theme="dark"] .virtualized-task-list {
|
||||
--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;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default VirtualizedTaskList;
|
||||
Reference in New Issue
Block a user