- 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.
1123 lines
44 KiB
TypeScript
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; |