refactor(task-list): enhance task rendering and editing functionality in TaskRow and TaskListV2Table

- Updated TaskListV2Table to pass isFirstInGroup prop to renderTask for improved task grouping logic.
- Enhanced TaskRow to support inline editing of task names with a new input field and associated state management.
- Implemented click outside detection to save task name changes when editing is complete.
- Improved layout and styling for better user experience during task editing and display.
This commit is contained in:
chamikaJ
2025-07-09 16:32:28 +05:30
parent 6f63041148
commit deb0f3f602
3 changed files with 227 additions and 126 deletions

View File

@@ -457,7 +457,7 @@ const TaskListV2Section: React.FC = () => {
);
const renderTask = useCallback(
(taskIndex: number) => {
(taskIndex: number, isFirstInGroup: boolean = false) => {
const item = virtuosoItems[taskIndex];
if (!item || !urlProjectId) return null;
@@ -480,6 +480,7 @@ const TaskListV2Section: React.FC = () => {
taskId={item.id}
projectId={urlProjectId}
visibleColumns={visibleColumns}
isFirstInGroup={isFirstInGroup}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
@@ -647,9 +648,12 @@ const TaskListV2Section: React.FC = () => {
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
taskIndex;
// Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{renderTask(globalTaskIndex)}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
</div>
);
})}

View File

@@ -1,8 +1,9 @@
import React, { memo, useMemo, useCallback, useState } from 'react';
import React, { memo, useMemo, useCallback, useState, useRef, useEffect } 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 } from 'antd';
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';
@@ -40,6 +41,7 @@ interface TaskRowProps {
isCustom?: boolean;
}>;
isSubtask?: boolean;
isFirstInGroup?: boolean;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
@@ -97,7 +99,7 @@ const formatDate = (dateString: string): string => {
}
};
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false, updateTaskCustomColumnValue }) => {
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));
@@ -107,6 +109,12 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
// 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
}
@@ -153,6 +161,45 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
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: (() => {
@@ -289,129 +336,169 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
</div>
);
case 'title':
case 'title':
return (
<div className="flex items-center justify-between group pl-1 border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
<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">
<Tooltip title={taskDisplayName}>
<span
className="text-sm text-gray-700 dark:text-gray-300 truncate cursor-pointer block"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{taskDisplayName}
</span>
</Tooltip>
</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
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>
</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" />}
<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>
{/* 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>
);
@@ -755,6 +842,10 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
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
@@ -775,6 +866,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
// Handlers
handleDateChange,
datePickerHandlers,
handleTaskNameSave,
// Translation
t,
@@ -787,8 +879,10 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
style={{ ...style, height: '40px' }}
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
} ${
isDragging ? 'shadow-lg border border-blue-300' : ''
}`}
>

View File

@@ -20,6 +20,7 @@ interface TaskRowWithSubtasksProps {
width: string;
isSticky?: boolean;
}>;
isFirstInGroup?: boolean;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
@@ -153,6 +154,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
taskId,
projectId,
visibleColumns,
isFirstInGroup = false,
updateTaskCustomColumnValue
}) => {
const task = useAppSelector(state => selectTaskById(state, taskId));
@@ -175,6 +177,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
taskId={taskId}
projectId={projectId}
visibleColumns={visibleColumns}
isFirstInGroup={isFirstInGroup}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>