Files
worklenz/worklenz-frontend/src/components/task-management/task-row.tsx
chamikaJ c37ffd6991 feat(assignee-selector): implement optimistic updates for assignee management
- Added optimistic UI updates for assignee selection, improving user experience with immediate feedback.
- Introduced state management for pending changes to visually indicate ongoing updates.
- Enhanced member toggle functionality to reflect changes instantly in the UI while maintaining socket communication for backend updates.
- Improved checkbox behavior to prevent interaction during pending state, ensuring clarity in user actions.
2025-06-27 15:58:19 +05:30

1123 lines
44 KiB
TypeScript

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 DOMPurify from 'dompurify';
import {
Input,
Typography,
DatePicker,
dayjs,
taskManagementAntdConfig,
HolderOutlined,
MessageOutlined,
PaperClipOutlined,
ClockCircleOutlined,
UserOutlined,
type InputRef
} from './antd-imports';
import { DownOutlined, RightOutlined, ExpandAltOutlined, DoubleRightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Task } from '@/types/task-management.types';
import { RootState } from '@/app/store';
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import TaskStatusDropdown from './task-status-dropdown';
import TaskPriorityDropdown from './task-priority-dropdown';
import TaskPhaseDropdown from './task-phase-dropdown';
import {
formatDate as utilFormatDate,
formatDateTime as utilFormatDateTime,
createLabelsAdapter,
createAssigneeAdapter,
PRIORITY_COLORS as UTIL_PRIORITY_COLORS,
performanceMonitor,
taskPropsEqual
} from './task-row-utils';
import './task-row-optimized.css';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
interface TaskRowProps {
task: Task;
projectId: string;
groupId: string;
currentGrouping: 'status' | 'priority' | 'phase';
isSelected: boolean;
isDragOverlay?: boolean;
index?: number;
onSelect?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
}
// 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;
// Memoized sub-components for better performance
const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => (
<Button
variant="text"
size="small"
icon={<HolderOutlined />}
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
isDarkMode={isDarkMode}
{...attributes}
{...listeners}
/>
));
const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => (
<span
className={`px-2 py-1 text-xs font-medium rounded truncate whitespace-nowrap max-w-full ${
isDarkMode
? 'bg-gray-700 text-gray-300'
: 'bg-gray-100 text-gray-600'
}`}
>
{taskKey}
</span>
));
const TaskDescription = React.memo<{ description?: string; isDarkMode: boolean }>(({ description, isDarkMode }) => {
if (!description) return null;
const sanitizedDescription = DOMPurify.sanitize(description);
return (
<Typography.Paragraph
ellipsis={{
expandable: false,
rows: 1,
tooltip: description,
}}
className={`w-full mb-0 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
>
<span dangerouslySetInnerHTML={{ __html: sanitizedDescription }} />
</Typography.Paragraph>
);
});
const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ progress, isDarkMode }) => (
<Progress
type="circle"
percent={progress}
size={24}
strokeColor={progress === 100 ? '#52c41a' : '#1890ff'}
strokeWidth={2}
showInfo={true}
isDarkMode={isDarkMode}
/>
));
const TaskPriority = React.memo<{ priority: string; isDarkMode: boolean }>(({ priority, isDarkMode }) => {
const color = PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9';
return (
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{priority}
</span>
</div>
);
});
const TaskTimeTracking = React.memo<{ timeTracking?: { logged?: number | string }; isDarkMode: boolean }>(({ timeTracking, isDarkMode }) => {
if (!timeTracking?.logged || timeTracking.logged === 0) return null;
return (
<div className="flex items-center gap-1">
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{typeof timeTracking.logged === 'number'
? `${timeTracking.logged}h`
: timeTracking.logged
}
</span>
</div>
);
});
const TaskReporter = React.memo<{ reporter?: string; isDarkMode: boolean }>(({ reporter, isDarkMode }) => (
<div className="flex items-center gap-2">
<UserOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{reporter || '-'}
</span>
</div>
));
const TaskRow: React.FC<TaskRowProps> = React.memo(({
task,
projectId,
groupId,
currentGrouping,
isSelected,
isDragOverlay = false,
index,
onSelect,
onToggleSubtasks,
columns,
fixedColumns,
scrollableColumns,
}) => {
// PERFORMANCE OPTIMIZATION: Implement progressive loading
// Immediately load first few tasks to prevent blank content for visible items
const [isFullyLoaded, setIsFullyLoaded] = useState((index !== undefined && index < 10) || false);
const [isIntersecting, setIsIntersecting] = useState(false);
const rowRef = useRef<HTMLDivElement>(null);
const hasBeenFullyLoadedOnce = useRef((index !== undefined && index < 10) || false); // Track if we've ever been fully loaded
// PERFORMANCE OPTIMIZATION: Only connect to socket after component is visible
const { socket, connected } = useSocket();
// Redux dispatch
const dispatch = useAppDispatch();
// Edit task name state
const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.title || '');
const [showAddSubtask, setShowAddSubtask] = useState(false);
const [newSubtaskName, setNewSubtaskName] = useState('');
const inputRef = useRef<InputRef>(null);
const addSubtaskInputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
useEffect(() => {
// Skip intersection observer if already fully loaded
if (!rowRef.current || hasBeenFullyLoadedOnce.current) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && !isIntersecting && !hasBeenFullyLoadedOnce.current) {
setIsIntersecting(true);
// Delay full loading slightly to prioritize visible content
const timeoutId = setTimeout(() => {
setIsFullyLoaded(true);
hasBeenFullyLoadedOnce.current = true; // Mark as fully loaded once
}, 50);
return () => clearTimeout(timeoutId);
}
},
{
root: null,
rootMargin: '100px', // Start loading 100px before coming into view
threshold: 0.1,
}
);
observer.observe(rowRef.current);
return () => {
observer.disconnect();
};
}, [isIntersecting, hasBeenFullyLoadedOnce.current]);
// PERFORMANCE OPTIMIZATION: Skip expensive operations during initial render
// Once fully loaded, always render full to prevent blanking during real-time updates
const shouldRenderFull = isFullyLoaded || hasBeenFullyLoadedOnce.current || isDragOverlay || editTaskName;
// REAL-TIME UPDATES: Ensure content stays loaded during socket updates
useEffect(() => {
if (shouldRenderFull && !hasBeenFullyLoadedOnce.current) {
hasBeenFullyLoadedOnce.current = true;
}
}, [shouldRenderFull]);
// Optimized drag and drop setup with better performance
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id,
data: {
type: 'task',
taskId: task.id,
groupId,
},
disabled: isDragOverlay || !shouldRenderFull, // Disable drag until fully loaded
// Optimize animation performance
animateLayoutChanges: () => false, // Disable layout animations for better performance
});
// Get theme from Redux store - memoized selector
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Translation hook
const { t } = useTranslation('task-management');
// PERFORMANCE OPTIMIZATION: Only setup click outside detection when editing
useEffect(() => {
if (!editTaskName || !shouldRenderFull) return;
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
handleTaskNameSave();
}
};
document.addEventListener('mousedown', handleClickOutside, { passive: true });
inputRef.current?.focus();
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [editTaskName, shouldRenderFull]);
// Optimized task name save handler
const handleTaskNameSave = useCallback(() => {
const newTaskName = taskName?.trim();
if (newTaskName && connected && newTaskName !== task.title) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: newTaskName,
parent_task: null,
})
);
}
setEditTaskName(false);
}, [connected, socket, task.id, task.title, taskName]);
// Handle adding new subtask
const handleAddSubtask = useCallback(() => {
const subtaskName = newSubtaskName?.trim();
if (subtaskName && connected) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now
JSON.stringify({
name: subtaskName,
parent_task_id: task.id,
project_id: projectId,
})
);
setNewSubtaskName('');
setShowAddSubtask(false);
}
}, [newSubtaskName, connected, socket, task.id, projectId]);
// Handle canceling add subtask
const handleCancelAddSubtask = useCallback(() => {
setNewSubtaskName('');
setShowAddSubtask(false);
}, []);
// Optimized style calculations with better memoization
const dragStyle = useMemo(() => {
if (!isDragging && !transform) return {};
return {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
// Add GPU acceleration for better performance
willChange: isDragging ? 'transform' : 'auto',
};
}, [transform, transition, isDragging]);
// Memoized event handlers with better dependency tracking
const handleSelectChange = useCallback((checked: boolean) => {
onSelect?.(task.id, checked);
}, [onSelect, task.id]);
const handleToggleSubtasks = useCallback(() => {
onToggleSubtasks?.(task.id);
}, [onToggleSubtasks, task.id]);
// Handle expand/collapse or add subtask
const handleExpandClick = useCallback(() => {
// For now, just toggle add subtask row for all tasks
setShowAddSubtask(!showAddSubtask);
if (!showAddSubtask) {
// Focus the input after state update
setTimeout(() => {
addSubtaskInputRef.current?.focus();
}, 100);
}
}, [showAddSubtask]);
// Handle opening task drawer
const handleOpenTask = useCallback(() => {
if (!task.id) return;
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
// Fetch task data - this is necessary for detailed task drawer information
// that's not available in the list view (comments, attachments, etc.)
dispatch(fetchTask({ taskId: task.id, projectId }));
}, [task.id, projectId, dispatch]);
// Optimized date handling with better memoization
const dateValues = useMemo(() => ({
start: task.startDate ? dayjs(task.startDate) : undefined,
due: task.dueDate ? dayjs(task.dueDate) : undefined,
}), [task.startDate, task.dueDate]);
const handleDateChange = useCallback((date: dayjs.Dayjs | null, field: 'startDate' | 'dueDate') => {
if (!connected || !socket) return;
const eventType = field === 'startDate' ? SocketEvents.TASK_START_DATE_CHANGE : SocketEvents.TASK_END_DATE_CHANGE;
const dateField = field === 'startDate' ? 'start_date' : 'end_date';
socket.emit(
eventType.toString(),
JSON.stringify({
task_id: task.id,
[dateField]: date?.format('YYYY-MM-DD'),
parent_task: null,
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
}, [connected, socket, task.id]);
// Optimized class name calculations with better memoization
const styleClasses = useMemo(() => {
const base = 'border-b transition-all duration-200'; // Reduced duration for better performance
const theme = isDarkMode
? 'border-gray-600 hover:bg-gray-800'
: 'border-gray-300 hover:bg-gray-50';
const background = isDarkMode ? 'bg-[#18181b]' : 'bg-white';
const selected = isSelected
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
: '';
const overlay = isDragOverlay
? `rounded shadow-lg border-2 ${isDarkMode ? 'border-gray-600 shadow-2xl' : 'border-gray-300 shadow-2xl'}`
: '';
return {
container: `${base} ${theme} ${background} ${selected} ${overlay}`,
taskName: `text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-200 cursor-pointer ${
isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'
} ${task.progress === 100 ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` : ''}`,
};
}, [isDarkMode, isSelected, isDragOverlay, task.progress]);
// Memoized adapters for better performance
const adapters = useMemo(() => ({
labels: createLabelsAdapter(task),
assignee: createAssigneeAdapter(task),
}), [task]);
// PERFORMANCE OPTIMIZATION: Simplified column rendering for initial load
const renderColumnSimple = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
// If this is a scrollable column, use the normal logic
const isActuallyLast = isFixed
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
: (index === totalColumns - 1);
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
// Only render essential columns during initial load
switch (col.key) {
case 'drag':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className="w-4 h-4 opacity-30 bg-gray-300 rounded"></div>
</div>
);
case 'select':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<Checkbox
checked={isSelected}
onChange={handleSelectChange}
isDarkMode={isDarkMode}
/>
</div>
);
case 'key':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
</div>
);
case 'task':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<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">
<div className="flex-1 min-w-0">
<Typography.Text
ellipsis={{ tooltip: task.title }}
className={styleClasses.taskName}
>
{task.title}
</Typography.Text>
</div>
</div>
</div>
</div>
);
case 'status':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
{task.status || 'Todo'}
</div>
</div>
);
case 'progress':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
{task.progress || 0}%
</div>
</div>
);
case 'priority':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
{task.priority || 'Medium'}
</div>
</div>
);
case 'phase':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
{task.phase || 'No Phase'}
</div>
</div>
);
default:
// For non-essential columns, show placeholder during initial load
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<div className={`w-8 h-3 rounded ${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'} animate-pulse`}></div>
</div>
);
}
}, [isDarkMode, task, isSelected, handleSelectChange, styleClasses]);
// Optimized column rendering with better performance
const renderColumn = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
// Use simplified rendering for initial load
if (!shouldRenderFull) {
return renderColumnSimple(col, isFixed, index, totalColumns);
}
// Full rendering logic (existing code)
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
// If this is a scrollable column, use the normal logic
const isActuallyLast = isFixed
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
: (index === totalColumns - 1);
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
switch (col.key) {
case 'drag':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<DragHandle isDarkMode={isDarkMode} attributes={attributes} listeners={listeners} />
</div>
);
case 'select':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<Checkbox
checked={isSelected}
onChange={handleSelectChange}
isDarkMode={isDarkMode}
/>
</div>
);
case 'key':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
</div>
);
case 'task':
const cellStyle = editTaskName
? {
width: col.width,
borderTop: '1px solid #1890ff',
borderBottom: '1px solid #1890ff',
borderLeft: '1px solid #1890ff',
background: isDarkMode ? '#232b3a' : '#f0f7ff',
transition: 'border 0.2s'
}
: { width: col.width };
return (
<div
key={col.key}
className={`task-cell-container flex items-center px-2 ${borderClasses}${editTaskName ? ' task-name-edit-active' : ''}`}
style={cellStyle}
>
<div className="flex-1 min-w-0 flex items-center justify-between h-full overflow-hidden">
{/* Left section with expand icon and task content */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Expand/Collapse Icon - Smart visibility */}
<div className="expand-icon-container hover-only w-5 h-5 flex items-center justify-center">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleExpandClick();
}}
className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${
isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
style={{ backgroundColor: 'transparent' }}
title="Add subtask"
>
{showAddSubtask ? <DownOutlined /> : <RightOutlined />}
</button>
</div>
{/* Task name and input */}
<div ref={wrapperRef} className="flex-1 min-w-0">
{editTaskName ? (
<Input
ref={inputRef}
className="task-name-input"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
onBlur={handleTaskNameSave}
onPressEnter={handleTaskNameSave}
variant="borderless"
style={{
color: isDarkMode ? '#ffffff' : '#262626',
padding: 0
}}
autoFocus
/>
) : (
<Typography.Text
ellipsis={{ tooltip: task.title }}
onClick={() => setEditTaskName(true)}
className={styleClasses.taskName}
style={{ cursor: 'pointer' }}
>
{task.title}
</Typography.Text>
)}
</div>
{/* Indicators section */}
{!editTaskName && (
<div className="task-indicators flex items-center gap-1">
{/* Subtasks count */}
{(task as any).subtasks_count && (task as any).subtasks_count > 0 && (
<Tooltip title={`${(task as any).subtasks_count} ${(task as any).subtasks_count !== 1 ? t('subtasks') : t('subtask')}`}>
<div
className={`indicator-badge subtasks flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold cursor-pointer transition-colors duration-200 ${
isDarkMode
? 'bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700'
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
}`}
style={{ fontSize: '10px', border: '1px solid' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleSubtasks?.();
}}
>
<span>{(task as any).subtasks_count}</span>
<RightOutlined style={{ fontSize: '8px' }} />
</div>
</Tooltip>
)}
{/* Comments indicator */}
{(task as any).comments_count && (task as any).comments_count > 0 && (
<Tooltip title={`${(task as any).comments_count} ${(task as any).comments_count !== 1 ? t('comments') : t('comment')}`}>
<div
className={`indicator-badge comments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
isDarkMode
? 'bg-green-900 border-green-700 text-green-300'
: 'bg-green-50 border-green-200 text-green-700'
}`}
style={{ fontSize: '10px', border: '1px solid' }}
>
<MessageOutlined style={{ fontSize: '8px' }} />
<span>{(task as any).comments_count}</span>
</div>
</Tooltip>
)}
{/* Attachments indicator */}
{(task as any).attachments_count && (task as any).attachments_count > 0 && (
<Tooltip title={`${(task as any).attachments_count} ${(task as any).attachments_count !== 1 ? t('attachments') : t('attachment')}`}>
<div
className={`indicator-badge attachments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
isDarkMode
? 'bg-blue-900 border-blue-700 text-blue-300'
: 'bg-blue-50 border-blue-200 text-blue-700'
}`}
style={{ fontSize: '10px', border: '1px solid' }}
>
<PaperClipOutlined style={{ fontSize: '8px' }} />
<span>{(task as any).attachments_count}</span>
</div>
</Tooltip>
)}
</div>
)}
</div>
{/* Right section with open button - CSS hover only */}
{!editTaskName && (
<div className="task-open-button ml-2 opacity-0 transition-opacity duration-200" style={{ zIndex: 10 }}>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOpenTask();
}}
className={`flex items-center gap-1 px-2 py-1 rounded border transition-all duration-200 text-xs font-medium ${
isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-300 hover:bg-gray-600 hover:border-gray-500 hover:text-gray-100'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100 hover:border-gray-300 hover:text-gray-700'
}`}
style={{ fontSize: '11px', minWidth: 'fit-content' }}
>
<ExpandAltOutlined style={{ fontSize: '10px' }} />
<span>{t('openTask')}</span>
</button>
</div>
)}
</div>
</div>
);
case 'description':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<TaskDescription description={task.description} isDarkMode={isDarkMode} />
</div>
);
case 'progress':
return (
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
{task.progress !== undefined && task.progress >= 0 && (
<TaskProgress progress={task.progress} isDarkMode={isDarkMode} />
)}
</div>
);
case 'members':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width }}>
<div className="flex items-center gap-2 overflow-visible">
{task.assignee_names && task.assignee_names.length > 0 && (
<AvatarGroup
members={task.assignee_names}
size={24}
maxCount={3}
isDarkMode={isDarkMode}
/>
)}
<AssigneeSelector
task={adapters.assignee}
groupId={groupId}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'labels':
return (
<div key={col.key} className={`max-w-[200px] flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<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={adapters.labels}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'phase':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
<div className="w-full">
<TaskPhaseDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'status':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
<div className="w-full">
<TaskStatusDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'priority':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
<div className="w-full">
<TaskPriorityDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'timeTracking':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<TaskTimeTracking timeTracking={task.timeTracking} isDarkMode={isDarkMode} />
</div>
);
case 'estimation':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{task.timeTracking?.estimated ? `${task.timeTracking.estimated}h` : '-'}
</span>
</div>
);
case 'startDate':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.start}
onChange={(date) => handleDateChange(date, 'startDate')}
placeholder="Start Date"
/>
</div>
);
case 'dueDate':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.due}
onChange={(date) => handleDateChange(date, 'dueDate')}
placeholder="Due Date"
/>
</div>
);
case 'dueTime':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{task.dueDate ? dayjs(task.dueDate).format('HH:mm') : '-'}
</span>
</div>
);
case 'completedDate':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{task.completedAt ? utilFormatDate(task.completedAt) : '-'}
</span>
</div>
);
case 'createdDate':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{task.createdAt ? utilFormatDate(task.createdAt) : '-'}
</span>
</div>
);
case 'lastUpdated':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
{task.updatedAt ? utilFormatDateTime(task.updatedAt) : '-'}
</span>
</div>
);
case 'reporter':
return (
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
<TaskReporter reporter={task.reporter} isDarkMode={isDarkMode} />
</div>
);
default:
return null;
}
}, [
shouldRenderFull, renderColumnSimple, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
dateValues, styleClasses
]);
return (
<div
ref={(node) => {
setNodeRef(node);
rowRef.current = node;
}}
style={dragStyle}
className={`${styleClasses.container} task-row-optimized ${shouldRenderFull ? 'fully-loaded' : 'initial-load'} ${hasBeenFullyLoadedOnce.current ? 'stable-content' : ''}`}
data-task-id={task.id}
>
<div className="flex h-10 max-h-10 overflow-visible relative">
{/* Fixed Columns */}
{fixedColumns && fixedColumns.length > 0 && (
<div
className="flex overflow-visible"
style={{
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
}}
>
{fixedColumns.map((col, index) => renderColumn(col, true, index, fixedColumns.length))}
</div>
)}
{/* Scrollable Columns */}
{scrollableColumns && scrollableColumns.length > 0 && (
<div
className="overflow-visible"
style={{
display: 'flex',
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
}}
>
{scrollableColumns.map((col, index) => renderColumn(col, false, index, scrollableColumns.length))}
</div>
)}
</div>
{/* Add Subtask Row */}
{showAddSubtask && (
<div className={`add-subtask-row ${showAddSubtask ? 'visible' : ''} ${isDarkMode ? 'dark' : ''}`}>
<div className="flex h-10 max-h-10 overflow-visible relative">
{/* Fixed Columns for Add Subtask */}
{fixedColumns && fixedColumns.length > 0 && (
<div
className="flex overflow-visible"
style={{
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
}}
>
{fixedColumns.map((col, index) => {
// Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist
const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0);
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
if (col.key === 'task') {
return (
<div
key={col.key}
className={`flex items-center px-2 ${borderClasses}`}
style={{ width: col.width }}
>
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
<Input
ref={addSubtaskInputRef}
placeholder={t('enterSubtaskName')}
value={newSubtaskName}
onChange={(e) => setNewSubtaskName(e.target.value)}
onPressEnter={handleAddSubtask}
onBlur={handleCancelAddSubtask}
className={`add-subtask-input flex-1 ${
isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-200'
: 'bg-white border-gray-300 text-gray-900'
}`}
size="small"
autoFocus
/>
<div className="flex gap-1">
<Button
size="small"
onClick={handleAddSubtask}
disabled={!newSubtaskName.trim()}
className="h-6 px-2 text-xs bg-blue-500 text-white hover:bg-blue-600"
>
{t('add')}
</Button>
<Button
size="small"
onClick={handleCancelAddSubtask}
className="h-6 px-2 text-xs"
>
{t('cancel')}
</Button>
</div>
</div>
</div>
);
} else {
return (
<div
key={col.key}
className={`flex items-center px-2 ${borderClasses}`}
style={{ width: col.width }}
/>
);
}
})}
</div>
)}
{/* Scrollable Columns for Add Subtask */}
{scrollableColumns && scrollableColumns.length > 0 && (
<div
className="overflow-visible"
style={{
display: 'flex',
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
}}
>
{scrollableColumns.map((col, index) => {
const isLast = index === scrollableColumns.length - 1;
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
return (
<div
key={col.key}
className={`flex items-center px-2 ${borderClasses}`}
style={{ width: col.width }}
/>
);
})}
</div>
)}
</div>
</div>
)}
</div>
);
}, (prevProps, nextProps) => {
// PERFORMANCE OPTIMIZATION: Enhanced comparison function
// Skip comparison during initial renders to reduce CPU load
if (!prevProps.task.id || !nextProps.task.id) return false;
// Quick identity checks first
if (prevProps.task.id !== nextProps.task.id) return false;
if (prevProps.isSelected !== nextProps.isSelected) return false;
if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false;
if (prevProps.groupId !== nextProps.groupId) return false;
// REAL-TIME UPDATES: Always re-render if updatedAt changed (indicates real-time update)
if (prevProps.task.updatedAt !== nextProps.task.updatedAt) return false;
// Deep comparison for task properties that commonly change
const taskProps = ['title', 'progress', 'status', 'priority', 'description', 'startDate', 'dueDate'];
for (const prop of taskProps) {
if (prevProps.task[prop as keyof Task] !== nextProps.task[prop as keyof Task]) {
return false;
}
}
// REAL-TIME UPDATES: Compare assignees and labels content (not just length)
if (prevProps.task.assignees?.length !== nextProps.task.assignees?.length) return false;
if (prevProps.task.assignees?.length > 0) {
// Deep compare assignee IDs
const prevAssigneeIds = prevProps.task.assignees.sort();
const nextAssigneeIds = nextProps.task.assignees.sort();
for (let i = 0; i < prevAssigneeIds.length; i++) {
if (prevAssigneeIds[i] !== nextAssigneeIds[i]) return false;
}
}
if (prevProps.task.assignee_names?.length !== nextProps.task.assignee_names?.length) return false;
if (prevProps.task.assignee_names && nextProps.task.assignee_names && prevProps.task.assignee_names.length > 0) {
// Deep compare assignee names
for (let i = 0; i < prevProps.task.assignee_names.length; i++) {
if (prevProps.task.assignee_names[i] !== nextProps.task.assignee_names[i]) return false;
}
}
if (prevProps.task.labels?.length !== nextProps.task.labels?.length) return false;
if (prevProps.task.labels?.length > 0) {
// Deep compare label IDs and names
for (let i = 0; i < prevProps.task.labels.length; i++) {
const prevLabel = prevProps.task.labels[i];
const nextLabel = nextProps.task.labels[i];
if (prevLabel.id !== nextLabel.id || prevLabel.name !== nextLabel.name || prevLabel.color !== nextLabel.color) {
return false;
}
}
}
// Compare column configurations
if (prevProps.fixedColumns?.length !== nextProps.fixedColumns?.length) return false;
if (prevProps.scrollableColumns?.length !== nextProps.scrollableColumns?.length) return false;
// If we reach here, props are effectively equal
return true;
});
TaskRow.displayName = 'TaskRow';
export default TaskRow;