refactor(task-list): restructure TaskRow and introduce new column components

- Refactored TaskRow to simplify state management and enhance readability by extracting logic into custom hooks.
- Introduced new components for rendering task columns, including DatePickerColumn, TitleColumn, and various column types for better modularity.
- Improved task name editing functionality with better state handling and click outside detection.
- Streamlined date handling and formatting within the task row for improved user experience.
This commit is contained in:
chamiakJ
2025-07-10 12:17:22 +05:30
parent 6448d24e20
commit 70cca5d4c0
8 changed files with 1595 additions and 833 deletions

View File

@@ -1,32 +1,13 @@
import React, { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react';
import React, { memo, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
import { Checkbox, DatePicker, Tooltip, Input } from 'antd';
import type { InputRef } from 'antd';
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import AssigneeSelector from '@/components/AssigneeSelector';
import { format } from 'date-fns';
import AvatarGroup from '../AvatarGroup';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
import TaskStatusDropdown from '@/components/task-management/task-status-dropdown';
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectTaskById, toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
import TaskTimeTracking from './TaskTimeTracking';
import { CustomNumberLabel, CustomColordLabel } from '@/components';
import LabelsSelector from '@/components/LabelsSelector';
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
import { CustomColumnCell } from './components/CustomColumnComponents';
import { selectTaskById } from '@/features/task-management/task-management.slice';
import { selectIsTaskSelected } from '@/features/task-management/selection.slice';
import { useTaskRowState } from './hooks/useTaskRowState';
import { useTaskRowActions } from './hooks/useTaskRowActions';
import { useTaskRowColumns } from './hooks/useTaskRowColumns';
interface TaskRowProps {
taskId: string;
@@ -45,79 +26,51 @@ interface TaskRowProps {
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
interface TaskLabelsCellProps {
labels: Task['labels'];
isDarkMode: boolean;
}
const TaskRow: React.FC<TaskRowProps> = memo(({
taskId,
projectId,
visibleColumns,
isSubtask = false,
isFirstInGroup = false,
updateTaskCustomColumnValue
}) => {
// Get task data and selection state from Redux
const task = useAppSelector(state => selectTaskById(state, taskId));
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDarkMode = themeMode === 'dark';
const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isDarkMode }) => {
if (!labels) {
// Early return if task is not found
if (!task) {
return null;
}
return (
<div className="flex items-center gap-0.5 flex-wrap">
{labels.map((label, index) => {
const extendedLabel = label as any;
return extendedLabel.end && extendedLabel.names && extendedLabel.name ? (
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={extendedLabel.names}
namesString={extendedLabel.name}
isDarkMode={isDarkMode}
color={label.color}
/>
) : (
<CustomColordLabel
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
);
})}
</div>
);
});
// Use extracted hooks for state management
const {
activeDatePicker,
setActiveDatePicker,
editTaskName,
setEditTaskName,
taskName,
setTaskName,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
} = useTaskRowState(task);
TaskLabelsCell.displayName = 'TaskLabelsCell';
// Utility function to get task display name with fallbacks
const getTaskDisplayName = (task: Task): string => {
// Check each field and only use if it has actual content after trimming
if (task.title && task.title.trim()) return task.title.trim();
if (task.name && task.name.trim()) return task.name.trim();
if (task.task_key && task.task_key.trim()) return task.task_key.trim();
return DEFAULT_TASK_NAME;
};
// Memoized date formatter to avoid repeated date parsing
const formatDate = (dateString: string): string => {
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return '';
}
};
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false, isFirstInGroup = false, updateTaskCustomColumnValue }) => {
const dispatch = useAppDispatch();
const task = useAppSelector(state => selectTaskById(state, taskId));
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
// State for tracking which date picker is open
const [activeDatePicker, setActiveDatePicker] = useState<string | null>(null);
// State for editing task name
const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.title || task.name || '');
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
if (!task) {
return null; // Don't render if task is not found in store
}
const {
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
} = useTaskRowActions({
task,
taskId,
taskName,
editTaskName,
setEditTaskName,
});
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -129,6 +82,33 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
disabled: isSubtask, // Disable drag and drop for subtasks
});
// Use extracted column renderer hook
const { renderColumn } = useTaskRowColumns({
task,
projectId,
isSubtask,
isSelected,
isDarkMode,
visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
});
// Memoize style object to prevent unnecessary re-renders
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
@@ -136,746 +116,6 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
opacity: isDragging ? 0.5 : 1,
}), [transform, transition, isDragging]);
// Get dark mode from Redux state
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDarkMode = themeMode === 'dark';
// Memoize task display name
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
// Memoize converted task for AssigneeSelector to prevent recreation
const convertedTask = useMemo(() => ({
id: task.id,
name: taskDisplayName,
task_key: task.task_key || taskDisplayName,
assignees:
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
team_member_id: assignee.team_member_id || `assignee-${index}`,
id: assignee.team_member_id || `assignee-${index}`,
project_member_id: assignee.team_member_id || `assignee-${index}`,
name: assignee.name || '',
})) || [],
parent_task_id: task.parent_task_id,
status_id: undefined,
project_id: undefined,
manual_progress: undefined,
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
// Handle task name save
const handleTaskNameSave = useCallback(() => {
const newTaskName = inputRef.current?.input?.value || taskName;
if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: newTaskName.trim(),
parent_task: task.parent_task_id,
})
);
}
setEditTaskName(false);
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name]);
// Handle click outside for task name editing
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, handleTaskNameSave]);
// Update local taskName state when task name changes
useEffect(() => {
setTaskName(task.title || task.name || '');
}, [task.title, task.name]);
// Memoize formatted dates
const formattedDates = useMemo(() => ({
due: (() => {
const dateValue = task.dueDate || task.due_date;
return dateValue ? formatDate(dateValue) : null;
})(),
start: task.startDate ? formatDate(task.startDate) : null,
completed: task.completedAt ? formatDate(task.completedAt) : null,
created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null,
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]);
// Memoize date values for DatePicker
const dateValues = useMemo(
() => ({
start: task.startDate ? dayjs(task.startDate) : undefined,
due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined,
}),
[task.startDate, task.dueDate, task.due_date]
);
// Create labels adapter for LabelsSelector
const labelsAdapter = useMemo(() => ({
id: task.id,
name: task.title || task.name,
parent_task_id: task.parent_task_id,
manual_progress: false,
all_labels: task.all_labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color_code,
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color,
})) || [],
}), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]);
// Handle checkbox change
const handleCheckboxChange = useCallback((e: any) => {
e.stopPropagation(); // Prevent row click when clicking checkbox
dispatch(toggleTaskSelection(taskId));
}, [dispatch, taskId]);
// Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Always try to fetch subtasks when expanding, regardless of count
if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) {
dispatch(fetchSubTasks({ taskId: task.id, projectId }));
}
// Toggle expansion state
dispatch(toggleTaskExpansion(task.id));
}, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]);
// Handle date change
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,
})
);
// Close the date picker after selection
setActiveDatePicker(null);
},
[connected, socket, task.id]
);
// Memoize date picker handlers
const datePickerHandlers = useMemo(() => ({
setDueDate: () => setActiveDatePicker('dueDate'),
setStartDate: () => setActiveDatePicker('startDate'),
clearDueDate: (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
handleDateChange(null, 'dueDate');
},
clearStartDate: (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
handleDateChange(null, 'startDate');
},
}), [handleDateChange]);
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
const baseStyle = { width };
switch (columnId) {
case 'dragHandle':
return (
<div
className="flex items-center justify-center"
style={baseStyle}
{...(isSubtask ? {} : { ...attributes, ...listeners })}
>
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
</div>
);
case 'checkbox':
return (
<div className="flex items-center justify-center dark:border-gray-700" style={baseStyle}>
<Checkbox
checked={isSelected}
onChange={handleCheckboxChange}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
case 'taskKey':
return (
<div className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
{task.task_key || 'N/A'}
</span>
</div>
);
case 'title':
return (
<div
className="flex items-center justify-between group pl-1 border-r border-gray-200 dark:border-gray-700"
style={baseStyle}
>
{editTaskName ? (
/* Full cell input when editing */
<div className="flex-1" style={{ height: '38px' }} ref={wrapperRef}>
<Input
ref={inputRef}
variant="borderless"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
autoFocus
onPressEnter={handleTaskNameSave}
onBlur={handleTaskNameSave}
className="text-sm"
style={{
width: '100%',
height: '38px',
margin: '0',
padding: '8px 12px',
border: '1px solid #1677ff',
backgroundColor: 'rgba(22, 119, 255, 0.02)',
borderRadius: '3px',
fontSize: '14px',
lineHeight: '22px',
boxSizing: 'border-box',
outline: 'none',
boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)',
}}
/>
</div>
) : (
/* Normal layout when not editing */
<>
<div className="flex items-center flex-1 min-w-0">
{/* Indentation for subtasks - tighter spacing */}
{isSubtask && <div className="w-4 flex-shrink-0" />}
{/* Expand/Collapse button - only show for parent tasks */}
{!isSubtask && (
<button
onClick={handleToggleExpansion}
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out flex-shrink-0 ${
task.sub_tasks_count != null && task.sub_tasks_count > 0
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
transformOrigin: 'center'
}}
>
<RightOutlined className="text-gray-600 dark:text-gray-400" />
</div>
</button>
)}
{/* Additional indentation for subtasks after the expand button space */}
{isSubtask && <div className="w-2 flex-shrink-0" />}
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Task name with dynamic width */}
<div className="flex-1 min-w-0" ref={wrapperRef}>
<span
className="text-sm text-gray-700 dark:text-gray-300 truncate cursor-text block"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setEditTaskName(true);
}}
title={taskDisplayName}
>
{taskDisplayName}
</span>
</div>
{/* Indicators container - flex-shrink-0 to prevent compression */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Subtask count indicator - only show if count > 0 */}
{!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
<Tooltip title={t(`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`, { count: task.sub_tasks_count })}>
<div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded text-xs">
<span className="text-blue-600 dark:text-blue-400 font-medium">
{task.sub_tasks_count}
</span>
<DoubleRightOutlined className="text-blue-600 dark:text-blue-400" style={{ fontSize: 10 }} />
</div>
</Tooltip>
)}
{/* Task indicators - compact layout */}
{task.comments_count != null && task.comments_count !== 0 && (
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
<CommentOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.has_subscribers && (
<Tooltip title={t('indicators.tooltips.subscribers')}>
<EyeOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.attachments_count != null && task.attachments_count !== 0 && (
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
<PaperClipOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.has_dependencies && (
<Tooltip title={t('indicators.tooltips.dependencies')}>
<MinusCircleOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.schedule_id && (
<Tooltip title={t('indicators.tooltips.recurring')}>
<RetweetOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
</div>
</div>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
}}
>
<ArrowsAltOutlined />
{t('openButton')}
</button>
</>
)}
</div>
);
case 'description':
return (
<div className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<div
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxHeight: '24px',
lineHeight: '24px',
}}
title={task.description || ''}
dangerouslySetInnerHTML={{ __html: task.description || '' }}
/>
</div>
);
case 'status':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<TaskStatusDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
);
case 'assignees':
return (
<div className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<AvatarGroup
members={task.assignee_names || []}
maxCount={3}
isDarkMode={isDarkMode}
size={24}
/>
<AssigneeSelector
task={convertedTask}
groupId={null}
isDarkMode={isDarkMode}
/>
</div>
);
case 'priority':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<TaskPriorityDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
);
case 'dueDate':
return (
<div className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{activeDatePicker === 'dueDate' ? (
<div className="w-full relative">
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.due}
onChange={date => handleDateChange(date, 'dueDate')}
placeholder={t('dueDatePlaceholder')}
allowClear={false}
suffixIcon={null}
open={true}
onOpenChange={(open) => {
if (!open) {
setActiveDatePicker(null);
}
}}
autoFocus
/>
{/* Custom clear button */}
{dateValues.due && (
<button
onClick={datePickerHandlers.clearDueDate}
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
title={t('clearDueDate')}
>
<CloseOutlined style={{ fontSize: '10px' }} />
</button>
)}
</div>
) : (
<div
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
onClick={(e) => {
e.stopPropagation();
datePickerHandlers.setDueDate();
}}
>
{formattedDates.due ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDates.due}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
{t('setDueDate')}
</span>
)}
</div>
)}
</div>
);
case 'progress':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{task.progress !== undefined &&
task.progress >= 0 &&
(task.progress === 100 ? (
<div className="flex items-center justify-center">
<CheckCircleOutlined
className="text-green-500"
style={{
fontSize: '20px',
color: '#52c41a',
}}
/>
</div>
) : (
<TaskProgress
progress={task.progress}
numberOfSubTasks={task.sub_tasks?.length || 0}
/>
))}
</div>
);
case 'labels':
const labelsColumn = visibleColumns.find(col => col.id === 'labels');
const labelsStyle = {
...baseStyle,
...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
};
return (
<div className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700" style={labelsStyle}>
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
</div>
);
case 'phase':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<TaskPhaseDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
);
case 'timeTracking':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<TaskTimeTracking taskId={task.id || ''} isDarkMode={isDarkMode} />
</div>
);
case 'estimation':
// Use timeTracking.estimated which is the converted value from backend's total_minutes
const estimationDisplay = (() => {
const estimatedHours = task.timeTracking?.estimated;
if (estimatedHours && estimatedHours > 0) {
// Convert decimal hours to hours and minutes for display
const hours = Math.floor(estimatedHours);
const minutes = Math.round((estimatedHours - hours) * 60);
if (hours > 0 && minutes > 0) {
return `${hours}h ${minutes}m`;
} else if (hours > 0) {
return `${hours}h`;
} else if (minutes > 0) {
return `${minutes}m`;
}
}
return null;
})();
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{estimationDisplay ? (
<span className="text-sm text-gray-500 dark:text-gray-400">
{estimationDisplay}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">
-
</span>
)}
</div>
);
case 'startDate':
return (
<div className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{activeDatePicker === 'startDate' ? (
<div className="w-full relative">
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.start}
onChange={date => handleDateChange(date, 'startDate')}
placeholder={t('startDatePlaceholder')}
allowClear={false}
suffixIcon={null}
open={true}
onOpenChange={(open) => {
if (!open) {
setActiveDatePicker(null);
}
}}
autoFocus
/>
{/* Custom clear button */}
{dateValues.start && (
<button
onClick={datePickerHandlers.clearStartDate}
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
title={t('clearStartDate')}
>
<CloseOutlined style={{ fontSize: '10px' }} />
</button>
)}
</div>
) : (
<div
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
onClick={(e) => {
e.stopPropagation();
datePickerHandlers.setStartDate();
}}
>
{formattedDates.start ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDates.start}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
{t('setStartDate')}
</span>
)}
</div>
)}
</div>
);
case 'completedDate':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{formattedDates.completed ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDates.completed}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}
</div>
);
case 'createdDate':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{formattedDates.created ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDates.created}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}
</div>
);
case 'lastUpdated':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{formattedDates.updated ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDates.updated}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}
</div>
);
case 'reporter':
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
{task.reporter ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{task.reporter}</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}
</div>
);
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<CustomColumnCell
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
</div>
);
}
return null;
}
}, [
// Essential props and state
attributes,
listeners,
isSelected,
handleCheckboxChange,
activeDatePicker,
isDarkMode,
projectId,
// Edit task name state - CRITICAL for re-rendering
editTaskName,
taskName,
// Task data - include specific fields that might update via socket
task,
task.labels, // Explicit dependency for labels updates
task.phase, // Explicit dependency for phase updates
task.comments_count, // Explicit dependency for comments count updates
task.has_subscribers, // Explicit dependency for subscribers updates
task.attachments_count, // Explicit dependency for attachments count updates
task.has_dependencies, // Explicit dependency for dependencies updates
task.schedule_id, // Explicit dependency for recurring task updates
taskDisplayName,
convertedTask,
// Memoized values
dateValues,
formattedDates,
labelsAdapter,
// Handlers
handleDateChange,
datePickerHandlers,
handleTaskNameSave,
// Translation
t,
// Custom columns
visibleColumns,
updateTaskCustomColumnValue,
]);
return (
<div
ref={setNodeRef}

View File

@@ -0,0 +1,136 @@
import React, { memo, useCallback, useState } from 'react';
import { DatePicker, Tooltip } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
import { Task } from '@/types/task-management.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
interface DatePickerColumnProps {
width: string;
task: Task;
field: 'dueDate' | 'startDate';
formattedDate: string | null;
dateValue: dayjs.Dayjs | undefined;
isDarkMode: boolean;
activeDatePicker: string | null;
onActiveDatePickerChange: (field: string | null) => void;
}
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
width,
task,
field,
formattedDate,
dateValue,
isDarkMode,
activeDatePicker,
onActiveDatePickerChange
}) => {
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
// Handle date change
const handleDateChange = useCallback(
(date: dayjs.Dayjs | null) => {
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,
})
);
// Close the date picker after selection
onActiveDatePickerChange(null);
},
[connected, socket, task.id, field, onActiveDatePickerChange]
);
// Handle clear date
const handleClearDate = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
handleDateChange(null);
}, [handleDateChange]);
// Handle open date picker
const handleOpenDatePicker = useCallback(() => {
onActiveDatePickerChange(field);
}, [field, onActiveDatePickerChange]);
const isActive = activeDatePicker === field;
const placeholder = field === 'dueDate' ? t('dueDatePlaceholder') : t('startDatePlaceholder');
const clearTitle = field === 'dueDate' ? t('clearDueDate') : t('clearStartDate');
const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate');
return (
<div className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{isActive ? (
<div className="w-full relative">
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValue}
onChange={handleDateChange}
placeholder={placeholder}
allowClear={false}
suffixIcon={null}
open={true}
onOpenChange={(open) => {
if (!open) {
onActiveDatePickerChange(null);
}
}}
autoFocus
/>
{/* Custom clear button */}
{dateValue && (
<button
onClick={handleClearDate}
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
title={clearTitle}
>
<CloseOutlined style={{ fontSize: '10px' }} />
</button>
)}
</div>
) : (
<div
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
onClick={(e) => {
e.stopPropagation();
handleOpenDatePicker();
}}
>
{formattedDate ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDate}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
{setTitle}
</span>
)}
</div>
)}
</div>
);
});
DatePickerColumn.displayName = 'DatePickerColumn';

View File

@@ -0,0 +1,404 @@
import React, { memo, useCallback, useState, useRef } from 'react';
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
import { Checkbox, DatePicker, Tooltip, Input } from 'antd';
import type { InputRef } from 'antd';
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import AssigneeSelector from '@/components/AssigneeSelector';
import { format } from 'date-fns';
import AvatarGroup from '../../AvatarGroup';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
import TaskStatusDropdown from '@/components/task-management/task-status-dropdown';
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useTranslation } from 'react-i18next';
import TaskTimeTracking from '../TaskTimeTracking';
import { CustomNumberLabel, CustomColordLabel } from '@/components';
import LabelsSelector from '@/components/LabelsSelector';
import { CustomColumnCell } from './CustomColumnComponents';
// Utility function to get task display name with fallbacks
export const getTaskDisplayName = (task: Task): string => {
if (task.title && task.title.trim()) return task.title.trim();
if (task.name && task.name.trim()) return task.name.trim();
if (task.task_key && task.task_key.trim()) return task.task_key.trim();
return DEFAULT_TASK_NAME;
};
// Memoized date formatter to avoid repeated date parsing
export const formatDate = (dateString: string): string => {
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return '';
}
};
interface TaskLabelsCellProps {
labels: Task['labels'];
isDarkMode: boolean;
}
export const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isDarkMode }) => {
if (!labels) {
return null;
}
return (
<div className="flex items-center gap-0.5 flex-wrap">
{labels.map((label, index) => {
const extendedLabel = label as any;
return extendedLabel.end && extendedLabel.names && extendedLabel.name ? (
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={extendedLabel.names}
namesString={extendedLabel.name}
isDarkMode={isDarkMode}
color={label.color}
/>
) : (
<CustomColordLabel
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
);
})}
</div>
);
});
TaskLabelsCell.displayName = 'TaskLabelsCell';
interface DragHandleColumnProps {
width: string;
isSubtask: boolean;
attributes: any;
listeners: any;
}
export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(({ width, isSubtask, attributes, listeners }) => (
<div
className="flex items-center justify-center"
style={{ width }}
{...(isSubtask ? {} : { ...attributes, ...listeners })}
>
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
</div>
));
DragHandleColumn.displayName = 'DragHandleColumn';
interface CheckboxColumnProps {
width: string;
isSelected: boolean;
onCheckboxChange: (e: any) => void;
}
export const CheckboxColumn: React.FC<CheckboxColumnProps> = memo(({ width, isSelected, onCheckboxChange }) => (
<div className="flex items-center justify-center dark:border-gray-700" style={{ width }}>
<Checkbox
checked={isSelected}
onChange={onCheckboxChange}
onClick={(e) => e.stopPropagation()}
/>
</div>
));
CheckboxColumn.displayName = 'CheckboxColumn';
interface TaskKeyColumnProps {
width: string;
taskKey: string;
}
export const TaskKeyColumn: React.FC<TaskKeyColumnProps> = memo(({ width, taskKey }) => (
<div className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
{taskKey || 'N/A'}
</span>
</div>
));
TaskKeyColumn.displayName = 'TaskKeyColumn';
interface DescriptionColumnProps {
width: string;
description: string;
}
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width, description }) => (
<div className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<div
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxHeight: '24px',
lineHeight: '24px',
}}
title={description || ''}
dangerouslySetInnerHTML={{ __html: description || '' }}
/>
</div>
));
DescriptionColumn.displayName = 'DescriptionColumn';
interface StatusColumnProps {
width: string;
task: Task;
projectId: string;
isDarkMode: boolean;
}
export const StatusColumn: React.FC<StatusColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<TaskStatusDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
));
StatusColumn.displayName = 'StatusColumn';
interface AssigneesColumnProps {
width: string;
task: Task;
convertedTask: any;
isDarkMode: boolean;
}
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(({ width, task, convertedTask, isDarkMode }) => (
<div className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<AvatarGroup
members={task.assignee_names || []}
maxCount={3}
isDarkMode={isDarkMode}
size={24}
/>
<AssigneeSelector
task={convertedTask}
groupId={null}
isDarkMode={isDarkMode}
/>
</div>
));
AssigneesColumn.displayName = 'AssigneesColumn';
interface PriorityColumnProps {
width: string;
task: Task;
projectId: string;
isDarkMode: boolean;
}
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<TaskPriorityDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
));
PriorityColumn.displayName = 'PriorityColumn';
interface ProgressColumnProps {
width: string;
task: Task;
}
export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{task.progress !== undefined &&
task.progress >= 0 &&
(task.progress === 100 ? (
<div className="flex items-center justify-center">
<CheckCircleOutlined
className="text-green-500"
style={{
fontSize: '20px',
color: '#52c41a',
}}
/>
</div>
) : (
<TaskProgress
progress={task.progress}
numberOfSubTasks={task.sub_tasks?.length || 0}
/>
))}
</div>
));
ProgressColumn.displayName = 'ProgressColumn';
interface LabelsColumnProps {
width: string;
task: Task;
labelsAdapter: any;
isDarkMode: boolean;
visibleColumns: any[];
}
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
const labelsColumn = visibleColumns.find(col => col.id === 'labels');
const labelsStyle = {
width,
...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
};
return (
<div className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700" style={labelsStyle}>
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
</div>
);
});
LabelsColumn.displayName = 'LabelsColumn';
interface PhaseColumnProps {
width: string;
task: Task;
projectId: string;
isDarkMode: boolean;
}
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<TaskPhaseDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
));
PhaseColumn.displayName = 'PhaseColumn';
interface TimeTrackingColumnProps {
width: string;
taskId: string;
isDarkMode: boolean;
}
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(({ width, taskId, isDarkMode }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<TaskTimeTracking taskId={taskId} isDarkMode={isDarkMode} />
</div>
));
TimeTrackingColumn.displayName = 'TimeTrackingColumn';
interface EstimationColumnProps {
width: string;
task: Task;
}
export const EstimationColumn: React.FC<EstimationColumnProps> = memo(({ width, task }) => {
const estimationDisplay = (() => {
const estimatedHours = task.timeTracking?.estimated;
if (estimatedHours && estimatedHours > 0) {
const hours = Math.floor(estimatedHours);
const minutes = Math.round((estimatedHours - hours) * 60);
if (hours > 0 && minutes > 0) {
return `${hours}h ${minutes}m`;
} else if (hours > 0) {
return `${hours}h`;
} else if (minutes > 0) {
return `${minutes}m`;
}
}
return null;
})();
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{estimationDisplay ? (
<span className="text-sm text-gray-500 dark:text-gray-400">
{estimationDisplay}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">
-
</span>
)}
</div>
);
});
EstimationColumn.displayName = 'EstimationColumn';
interface DateColumnProps {
width: string;
formattedDate: string | null;
placeholder?: string;
}
export const DateColumn: React.FC<DateColumnProps> = memo(({ width, formattedDate, placeholder = '-' }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{formattedDate ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formattedDate}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">{placeholder}</span>
)}
</div>
));
DateColumn.displayName = 'DateColumn';
interface ReporterColumnProps {
width: string;
reporter: string;
}
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
{reporter ? (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
)}
</div>
));
ReporterColumn.displayName = 'ReporterColumn';
interface CustomColumnProps {
width: string;
column: any;
task: Task;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
export const CustomColumn: React.FC<CustomColumnProps> = memo(({ width, column, task, updateTaskCustomColumnValue }) => {
if (!updateTaskCustomColumnValue) return null;
return (
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
<CustomColumnCell
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
</div>
);
});
CustomColumn.displayName = 'CustomColumn';

View File

@@ -0,0 +1,258 @@
import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import { Task } from '@/types/task-management.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
import { getTaskDisplayName } from './TaskRowColumns';
interface TitleColumnProps {
width: string;
task: Task;
projectId: string;
isSubtask: boolean;
taskDisplayName: string;
editTaskName: boolean;
taskName: string;
onEditTaskName: (editing: boolean) => void;
onTaskNameChange: (name: string) => void;
onTaskNameSave: () => void;
}
export const TitleColumn: React.FC<TitleColumnProps> = memo(({
width,
task,
projectId,
isSubtask,
taskDisplayName,
editTaskName,
taskName,
onEditTaskName,
onTaskNameChange,
onTaskNameSave
}) => {
const dispatch = useAppDispatch();
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Always try to fetch subtasks when expanding, regardless of count
if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) {
dispatch(fetchSubTasks({ taskId: task.id, projectId }));
}
// Toggle expansion state
dispatch(toggleTaskExpansion(task.id));
}, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]);
// Handle task name save
const handleTaskNameSave = useCallback(() => {
const newTaskName = inputRef.current?.input?.value || taskName;
if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: newTaskName.trim(),
parent_task: task.parent_task_id,
})
);
}
onEditTaskName(false);
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
// Handle click outside for task name editing
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, handleTaskNameSave]);
return (
<div
className="flex items-center justify-between group pl-1 border-r border-gray-200 dark:border-gray-700"
style={{ width }}
>
{editTaskName ? (
/* Full cell input when editing */
<div className="flex-1" style={{ height: '38px' }} ref={wrapperRef}>
<Input
ref={inputRef}
variant="borderless"
value={taskName}
onChange={(e) => onTaskNameChange(e.target.value)}
autoFocus
onPressEnter={handleTaskNameSave}
onBlur={handleTaskNameSave}
className="text-sm"
style={{
width: '100%',
height: '38px',
margin: '0',
padding: '8px 12px',
border: '1px solid #1677ff',
backgroundColor: 'rgba(22, 119, 255, 0.02)',
borderRadius: '3px',
fontSize: '14px',
lineHeight: '22px',
boxSizing: 'border-box',
outline: 'none',
boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)',
}}
/>
</div>
) : (
/* Normal layout when not editing */
<>
<div className="flex items-center flex-1 min-w-0">
{/* Indentation for subtasks - tighter spacing */}
{isSubtask && <div className="w-4 flex-shrink-0" />}
{/* Expand/Collapse button - only show for parent tasks */}
{!isSubtask && (
<button
onClick={handleToggleExpansion}
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out flex-shrink-0 ${
task.sub_tasks_count != null && task.sub_tasks_count > 0
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
transformOrigin: 'center'
}}
>
<RightOutlined className="text-gray-600 dark:text-gray-400" />
</div>
</button>
)}
{/* Additional indentation for subtasks after the expand button space */}
{isSubtask && <div className="w-2 flex-shrink-0" />}
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Task name with dynamic width */}
<div className="flex-1 min-w-0" ref={wrapperRef}>
<span
className="text-sm text-gray-700 dark:text-gray-300 truncate cursor-text block"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onEditTaskName(true);
}}
title={taskDisplayName}
>
{taskDisplayName}
</span>
</div>
{/* Indicators container - flex-shrink-0 to prevent compression */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Subtask count indicator - only show if count > 0 */}
{!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
<Tooltip title={t(`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`, { count: task.sub_tasks_count })}>
<div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded text-xs">
<span className="text-blue-600 dark:text-blue-400 font-medium">
{task.sub_tasks_count}
</span>
<DoubleRightOutlined className="text-blue-600 dark:text-blue-400" style={{ fontSize: 10 }} />
</div>
</Tooltip>
)}
{/* Task indicators - compact layout */}
{task.comments_count != null && task.comments_count !== 0 && (
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
<CommentOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.has_subscribers && (
<Tooltip title={t('indicators.tooltips.subscribers')}>
<EyeOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.attachments_count != null && task.attachments_count !== 0 && (
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
<PaperClipOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.has_dependencies && (
<Tooltip title={t('indicators.tooltips.dependencies')}>
<MinusCircleOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
{task.schedule_id && (
<Tooltip title={t('indicators.tooltips.recurring')}>
<RetweetOutlined
className="text-gray-500 dark:text-gray-400"
style={{ fontSize: 12 }}
/>
</Tooltip>
)}
</div>
</div>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
}}
>
<ArrowsAltOutlined />
{t('openButton')}
</button>
</>
)}
</div>
);
});
TitleColumn.displayName = 'TitleColumn';

View File

@@ -0,0 +1,63 @@
import { useCallback } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { toggleTaskSelection } from '@/features/task-management/selection.slice';
import { Task } from '@/types/task-management.types';
interface UseTaskRowActionsProps {
task: Task;
taskId: string;
taskName: string;
editTaskName: boolean;
setEditTaskName: (editing: boolean) => void;
}
export const useTaskRowActions = ({
task,
taskId,
taskName,
editTaskName,
setEditTaskName,
}: UseTaskRowActionsProps) => {
const dispatch = useAppDispatch();
const { socket, connected } = useSocket();
// Handle checkbox change
const handleCheckboxChange = useCallback((e: any) => {
e.stopPropagation(); // Prevent row click when clicking checkbox
dispatch(toggleTaskSelection(taskId));
}, [dispatch, taskId]);
// Handle task name save
const handleTaskNameSave = useCallback(() => {
if (taskName?.trim() !== '' && connected && taskName.trim() !== (task.title || task.name || '').trim()) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: taskName.trim(),
parent_task: task.parent_task_id,
})
);
}
setEditTaskName(false);
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, setEditTaskName]);
// Handle task name edit start
const handleTaskNameEdit = useCallback(() => {
setEditTaskName(true);
}, [setEditTaskName]);
// Handle task name change
const handleTaskNameChange = useCallback((name: string) => {
// This will be handled by the parent component's state setter
}, []);
return {
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
handleTaskNameChange,
};
};

View File

@@ -0,0 +1,320 @@
import React, { useCallback } from 'react';
import { Task } from '@/types/task-management.types';
import {
DragHandleColumn,
CheckboxColumn,
TaskKeyColumn,
DescriptionColumn,
StatusColumn,
AssigneesColumn,
PriorityColumn,
ProgressColumn,
LabelsColumn,
PhaseColumn,
TimeTrackingColumn,
EstimationColumn,
DateColumn,
ReporterColumn,
CustomColumn,
} from '../components/TaskRowColumns';
import { TitleColumn } from '../components/TitleColumn';
import { DatePickerColumn } from '../components/DatePickerColumn';
interface UseTaskRowColumnsProps {
task: Task;
projectId: string;
isSubtask: boolean;
isSelected: boolean;
isDarkMode: boolean;
visibleColumns: Array<{
id: string;
width: string;
isSticky?: boolean;
key?: string;
custom_column?: boolean;
custom_column_obj?: any;
isCustom?: boolean;
}>;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
// From useTaskRowState
taskDisplayName: string;
convertedTask: any;
formattedDates: any;
dateValues: any;
labelsAdapter: any;
activeDatePicker: string | null;
setActiveDatePicker: (field: string | null) => void;
editTaskName: boolean;
taskName: string;
setEditTaskName: (editing: boolean) => void;
setTaskName: (name: string) => void;
// From useTaskRowActions
handleCheckboxChange: (e: any) => void;
handleTaskNameSave: () => void;
handleTaskNameEdit: () => void;
// Drag and drop
attributes: any;
listeners: any;
}
export const useTaskRowColumns = ({
task,
projectId,
isSubtask,
isSelected,
isDarkMode,
visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
}: UseTaskRowColumnsProps) => {
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
switch (columnId) {
case 'dragHandle':
return (
<DragHandleColumn
width={width}
isSubtask={isSubtask}
attributes={attributes}
listeners={listeners}
/>
);
case 'checkbox':
return (
<CheckboxColumn
width={width}
isSelected={isSelected}
onCheckboxChange={handleCheckboxChange}
/>
);
case 'taskKey':
return (
<TaskKeyColumn
width={width}
taskKey={task.task_key || ''}
/>
);
case 'title':
return (
<TitleColumn
width={width}
task={task}
projectId={projectId}
isSubtask={isSubtask}
taskDisplayName={taskDisplayName}
editTaskName={editTaskName}
taskName={taskName}
onEditTaskName={setEditTaskName}
onTaskNameChange={setTaskName}
onTaskNameSave={handleTaskNameSave}
/>
);
case 'description':
return (
<DescriptionColumn
width={width}
description={task.description || ''}
/>
);
case 'status':
return (
<StatusColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'assignees':
return (
<AssigneesColumn
width={width}
task={task}
convertedTask={convertedTask}
isDarkMode={isDarkMode}
/>
);
case 'priority':
return (
<PriorityColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'dueDate':
return (
<DatePickerColumn
width={width}
task={task}
field="dueDate"
formattedDate={formattedDates.due}
dateValue={dateValues.due}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'startDate':
return (
<DatePickerColumn
width={width}
task={task}
field="startDate"
formattedDate={formattedDates.start}
dateValue={dateValues.start}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'progress':
return (
<ProgressColumn
width={width}
task={task}
/>
);
case 'labels':
return (
<LabelsColumn
width={width}
task={task}
labelsAdapter={labelsAdapter}
isDarkMode={isDarkMode}
visibleColumns={visibleColumns}
/>
);
case 'phase':
return (
<PhaseColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'timeTracking':
return (
<TimeTrackingColumn
width={width}
taskId={task.id || ''}
isDarkMode={isDarkMode}
/>
);
case 'estimation':
return (
<EstimationColumn
width={width}
task={task}
/>
);
case 'completedDate':
return (
<DateColumn
width={width}
formattedDate={formattedDates.completed}
/>
);
case 'createdDate':
return (
<DateColumn
width={width}
formattedDate={formattedDates.created}
/>
);
case 'lastUpdated':
return (
<DateColumn
width={width}
formattedDate={formattedDates.updated}
/>
);
case 'reporter':
return (
<ReporterColumn
width={width}
reporter={task.reporter || ''}
/>
);
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return (
<CustomColumn
width={width}
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
}
return null;
}
}, [
task,
projectId,
isSubtask,
isSelected,
isDarkMode,
visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
]);
return { renderColumn };
};

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useMemo } from 'react';
import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { dayjs } from '@/shared/antd-imports';
import { getTaskDisplayName, formatDate } from '../components/TaskRowColumns';
export const useTaskRowState = (task: Task) => {
// State for tracking which date picker is open
const [activeDatePicker, setActiveDatePicker] = useState<string | null>(null);
// State for editing task name
const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.title || task.name || '');
// Update local taskName state when task name changes
useEffect(() => {
setTaskName(task.title || task.name || '');
}, [task.title, task.name]);
// Memoize task display name
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
// Memoize converted task for AssigneeSelector to prevent recreation
const convertedTask = useMemo(() => ({
id: task.id,
name: taskDisplayName,
task_key: task.task_key || taskDisplayName,
assignees:
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
team_member_id: assignee.team_member_id || `assignee-${index}`,
id: assignee.team_member_id || `assignee-${index}`,
project_member_id: assignee.team_member_id || `assignee-${index}`,
name: assignee.name || '',
})) || [],
parent_task_id: task.parent_task_id,
status_id: undefined,
project_id: undefined,
manual_progress: undefined,
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
// Memoize formatted dates
const formattedDates = useMemo(() => ({
due: (() => {
const dateValue = task.dueDate || task.due_date;
return dateValue ? formatDate(dateValue) : null;
})(),
start: task.startDate ? formatDate(task.startDate) : null,
completed: task.completedAt ? formatDate(task.completedAt) : null,
created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null,
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]);
// Memoize date values for DatePicker
const dateValues = useMemo(
() => ({
start: task.startDate ? dayjs(task.startDate) : undefined,
due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined,
}),
[task.startDate, task.dueDate, task.due_date]
);
// Create labels adapter for LabelsSelector
const labelsAdapter = useMemo(() => ({
id: task.id,
name: task.title || task.name,
parent_task_id: task.parent_task_id,
manual_progress: false,
all_labels: task.all_labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color_code,
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color,
})) || [],
}), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]);
return {
// State
activeDatePicker,
setActiveDatePicker,
editTaskName,
setEditTaskName,
taskName,
setTaskName,
// Computed values
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
};
};

View File

@@ -0,0 +1,245 @@
import { Task } from '@/types/task-management.types';
import { dayjs } from '@/shared/antd-imports';
export interface TaskRowColumn {
id: string;
width: string;
isSticky?: boolean;
key?: string;
custom_column?: boolean;
custom_column_obj?: any;
isCustom?: boolean;
}
export interface TaskRowProps {
taskId: string;
projectId: string;
visibleColumns: TaskRowColumn[];
isSubtask?: boolean;
isFirstInGroup?: boolean;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
export interface TaskRowState {
activeDatePicker: string | null;
editTaskName: boolean;
taskName: string;
}
export interface TaskRowComputedValues {
taskDisplayName: string;
convertedTask: ConvertedTask;
formattedDates: FormattedDates;
dateValues: DateValues;
labelsAdapter: LabelsAdapter;
}
export interface ConvertedTask {
id: string;
name: string;
task_key: string;
assignees: ConvertedAssignee[];
parent_task_id?: string;
status_id?: string;
project_id?: string;
manual_progress?: boolean;
}
export interface ConvertedAssignee {
team_member_id: string;
id: string;
project_member_id: string;
name: string;
}
export interface FormattedDates {
due: string | null;
start: string | null;
completed: string | null;
created: string | null;
updated: string | null;
}
export interface DateValues {
start: dayjs.Dayjs | undefined;
due: dayjs.Dayjs | undefined;
}
export interface LabelsAdapter {
id: string;
name: string;
parent_task_id?: string;
manual_progress: boolean;
all_labels: LabelInfo[];
labels: LabelInfo[];
}
export interface LabelInfo {
id: string;
name: string;
color_code: string;
}
export interface TaskRowActions {
handleCheckboxChange: (e: any) => void;
handleTaskNameSave: () => void;
handleTaskNameEdit: () => void;
handleTaskNameChange: (name: string) => void;
}
export interface ColumnRendererProps {
task: Task;
projectId: string;
isSubtask: boolean;
isSelected: boolean;
isDarkMode: boolean;
visibleColumns: TaskRowColumn[];
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
taskDisplayName: string;
convertedTask: ConvertedTask;
formattedDates: FormattedDates;
dateValues: DateValues;
labelsAdapter: LabelsAdapter;
activeDatePicker: string | null;
setActiveDatePicker: (field: string | null) => void;
editTaskName: boolean;
taskName: string;
setEditTaskName: (editing: boolean) => void;
setTaskName: (name: string) => void;
handleCheckboxChange: (e: any) => void;
handleTaskNameSave: () => void;
handleTaskNameEdit: () => void;
attributes: any;
listeners: any;
}
export type ColumnId =
| 'dragHandle'
| 'checkbox'
| 'taskKey'
| 'title'
| 'description'
| 'status'
| 'assignees'
| 'priority'
| 'dueDate'
| 'startDate'
| 'progress'
| 'labels'
| 'phase'
| 'timeTracking'
| 'estimation'
| 'completedDate'
| 'createdDate'
| 'lastUpdated'
| 'reporter'
| string; // Allow custom column IDs
export interface BaseColumnProps {
width: string;
}
export interface DragHandleColumnProps extends BaseColumnProps {
isSubtask: boolean;
attributes: any;
listeners: any;
}
export interface CheckboxColumnProps extends BaseColumnProps {
isSelected: boolean;
onCheckboxChange: (e: any) => void;
}
export interface TaskKeyColumnProps extends BaseColumnProps {
taskKey: string;
}
export interface TitleColumnProps extends BaseColumnProps {
task: Task;
projectId: string;
isSubtask: boolean;
taskDisplayName: string;
editTaskName: boolean;
taskName: string;
onEditTaskName: (editing: boolean) => void;
onTaskNameChange: (name: string) => void;
onTaskNameSave: () => void;
}
export interface DescriptionColumnProps extends BaseColumnProps {
description: string;
}
export interface StatusColumnProps extends BaseColumnProps {
task: Task;
projectId: string;
isDarkMode: boolean;
}
export interface AssigneesColumnProps extends BaseColumnProps {
task: Task;
convertedTask: ConvertedTask;
isDarkMode: boolean;
}
export interface PriorityColumnProps extends BaseColumnProps {
task: Task;
projectId: string;
isDarkMode: boolean;
}
export interface DatePickerColumnProps extends BaseColumnProps {
task: Task;
field: 'dueDate' | 'startDate';
formattedDate: string | null;
dateValue: dayjs.Dayjs | undefined;
isDarkMode: boolean;
activeDatePicker: string | null;
onActiveDatePickerChange: (field: string | null) => void;
}
export interface ProgressColumnProps extends BaseColumnProps {
task: Task;
}
export interface LabelsColumnProps extends BaseColumnProps {
task: Task;
labelsAdapter: LabelsAdapter;
isDarkMode: boolean;
visibleColumns: TaskRowColumn[];
}
export interface PhaseColumnProps extends BaseColumnProps {
task: Task;
projectId: string;
isDarkMode: boolean;
}
export interface TimeTrackingColumnProps extends BaseColumnProps {
taskId: string;
isDarkMode: boolean;
}
export interface EstimationColumnProps extends BaseColumnProps {
task: Task;
}
export interface DateColumnProps extends BaseColumnProps {
formattedDate: string | null;
placeholder?: string;
}
export interface ReporterColumnProps extends BaseColumnProps {
reporter: string;
}
export interface CustomColumnProps extends BaseColumnProps {
column: TaskRowColumn;
task: Task;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
export interface TaskLabelsCellProps {
labels: Task['labels'];
isDarkMode: boolean;
}