Merge branch 'release/v2.0.4' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.1.2

This commit is contained in:
shancds
2025-07-04 14:23:44 +05:30
24 changed files with 1388 additions and 524 deletions

View File

@@ -61,9 +61,16 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
// Check if dropdown would go below viewport
const spaceBelow = viewportHeight - rect.bottom;
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
setDropdownPosition({
top: rect.bottom + window.scrollY + 2,
left: rect.left + window.scrollX,
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
});
}
}, []);
@@ -81,23 +88,11 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
}
};
const handleScroll = () => {
const handleScroll = (event: Event) => {
if (isOpen) {
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
// Hide dropdown if button is not visible
setIsOpen(false);
}
// Only close dropdown if scrolling happens outside the dropdown
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
};

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Label } from '@/types/task-management.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
interface CustomColordLabelProps {
label: Label;
label: Label | ITaskLabel;
isDarkMode?: boolean;
}
@@ -11,11 +12,37 @@ const CustomColordLabel: React.FC<CustomColordLabelProps> = ({ label, isDarkMode
const truncatedName =
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
// Ensure we have a valid color, fallback to a default if not
const backgroundColor = label.color || '#6b7280'; // Default to gray-500 if no color
// Function to determine if we should use white or black text based on background color
const getTextColor = (bgColor: string): string => {
// Remove # if present
const color = bgColor.replace('#', '');
// Convert to RGB
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return white for dark backgrounds, black for light backgrounds
return luminance > 0.5 ? '#000000' : '#ffffff';
};
const textColor = getTextColor(backgroundColor);
return (
<Tooltip title={label.name}>
<span
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium text-white shrink-0 max-w-[120px]"
style={{ backgroundColor: label.color }}
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium shrink-0 max-w-[120px]"
style={{
backgroundColor,
color: textColor,
border: `1px solid ${backgroundColor}`,
}}
>
<span className="truncate">{truncatedName}</span>
</span>

View File

@@ -1,25 +1,31 @@
import React from 'react';
import { Tooltip } from 'antd';
import { NumbersColorMap } from '@/shared/constants';
interface CustomNumberLabelProps {
labelList: string[];
namesString: string;
isDarkMode?: boolean;
color?: string; // Add color prop for label color
}
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
labelList,
namesString,
isDarkMode = false,
color,
}) => {
// Use provided color, or fall back to NumbersColorMap based on first digit
const backgroundColor = color || (() => {
const firstDigit = namesString.match(/\d/)?.[0] || '0';
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
})();
return (
<Tooltip title={labelList.join(', ')}>
<span
className={`
inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
${isDarkMode ? 'bg-gray-600 text-gray-100' : 'bg-gray-200 text-gray-700'}
cursor-help
`}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
style={{ backgroundColor }}
>
{namesString}
</span>

View File

@@ -39,10 +39,24 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 2,
left: rect.left + window.scrollX,
});
const dropdownHeight = 300; // Approximate height of dropdown (max-height + padding)
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Position dropdown above button if there's not enough space below
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove > dropdownHeight;
if (shouldPositionAbove) {
setDropdownPosition({
top: rect.top + window.scrollY - dropdownHeight - 2,
left: rect.left + window.scrollX,
});
} else {
setDropdownPosition({
top: rect.bottom + window.scrollY + 2,
left: rect.left + window.scrollX,
});
}
}
}, []);
@@ -59,23 +73,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
}
};
const handleScroll = () => {
const handleScroll = (event: Event) => {
if (isOpen) {
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
// Hide dropdown if button is not visible
setIsOpen(false);
}
// Only close dropdown if scrolling happens outside the dropdown
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
};

View File

@@ -0,0 +1,139 @@
import React from 'react';
interface SubtaskLoadingSkeletonProps {
visibleColumns: Array<{
id: string;
width: string;
isSticky?: boolean;
}>;
}
const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visibleColumns }) => {
const renderColumn = (columnId: string, width: string) => {
const baseStyle = { width };
switch (columnId) {
case 'dragHandle':
return <div style={baseStyle} />;
case 'checkbox':
return <div style={baseStyle} />;
case 'taskKey':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'title':
return (
<div style={baseStyle} className="flex items-center">
{/* Subtask indentation */}
<div className="w-8" />
<div className="w-8" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'status':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'assignees':
return (
<div style={baseStyle} className="flex items-center gap-1">
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
</div>
);
case 'priority':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'dueDate':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'progress':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-2 w-16 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
</div>
);
case 'labels':
return (
<div style={baseStyle} className="flex items-center gap-1">
<div className="h-5 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'phase':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'timeTracking':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'estimation':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'startDate':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'completedDate':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'createdDate':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'lastUpdated':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'reporter':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
default:
return <div style={baseStyle} />;
}
};
return (
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
{visibleColumns.map((column) => (
<div key={column.id}>
{renderColumn(column.id, column.width)}
</div>
))}
</div>
</div>
);
};
export default SubtaskLoadingSkeleton;

View File

@@ -47,7 +47,7 @@ import {
selectRange,
clearSelection,
} from '@/features/task-management/selection.slice';
import TaskRow from './TaskRow';
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
import TaskGroupHeader from './TaskGroupHeader';
import { Task, TaskGroup } from '@/types/task-management.types';
import { RootState } from '@/app/store';
@@ -64,13 +64,13 @@ const BASE_COLUMNS = [
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
{ id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' },
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'title', label: 'Title', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
{ id: 'labels', label: 'Labels', width: '150px', key: COLUMN_KEYS.LABELS },
{ id: 'labels', label: 'Labels', width: 'auto', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
@@ -91,17 +91,13 @@ type ColumnStyle = {
flexShrink?: number;
};
interface TaskListV2Props {
projectId: string;
}
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
const TaskListV2: React.FC = () => {
const dispatch = useAppDispatch();
const { projectId: urlProjectId } = useParams();
// Drag and drop state
const [activeId, setActiveId] = useState<string | null>(null);
// Configure sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -119,7 +115,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
},
})
);
// Using Redux state for collapsedGroups instead of local state
const collapsedGroups = useAppSelector(selectCollapsedGroups);
@@ -159,176 +155,190 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
}, [dispatch, urlProjectId]);
// Handlers
const handleTaskSelect = useCallback((taskId: string, event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
dispatch(toggleTaskSelection(taskId));
} else if (event.shiftKey && lastSelectedTaskId) {
const taskIds = allTasks.map(t => t.id); // Use allTasks here
const startIdx = taskIds.indexOf(lastSelectedTaskId);
const endIdx = taskIds.indexOf(taskId);
const rangeIds = taskIds.slice(
Math.min(startIdx, endIdx),
Math.max(startIdx, endIdx) + 1
);
dispatch(selectRange(rangeIds));
} else {
dispatch(clearSelection());
dispatch(selectTask(taskId));
}
}, [dispatch, lastSelectedTaskId, allTasks]);
const handleTaskSelect = useCallback(
(taskId: string, event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
dispatch(toggleTaskSelection(taskId));
} else if (event.shiftKey && lastSelectedTaskId) {
const taskIds = allTasks.map(t => t.id); // Use allTasks here
const startIdx = taskIds.indexOf(lastSelectedTaskId);
const endIdx = taskIds.indexOf(taskId);
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
dispatch(selectRange(rangeIds));
} else {
dispatch(clearSelection());
dispatch(selectTask(taskId));
}
},
[dispatch, lastSelectedTaskId, allTasks]
);
const handleGroupCollapse = useCallback((groupId: string) => {
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
}, [dispatch]);
const handleGroupCollapse = useCallback(
(groupId: string) => {
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
},
[dispatch]
);
// Drag and drop handlers
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
const activeId = active.id;
const overId = over.id;
if (!over) return;
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
const activeId = active.id;
const overId = over.id;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
}
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (!activeGroup || !targetGroup) return;
// If dragging to a different group, we need to handle cross-group movement
if (activeGroup.id !== targetGroup.id) {
console.log('Cross-group drag detected:', {
activeTask: activeTask.id,
fromGroup: activeGroup.id,
toGroup: targetGroup.id,
});
}
}, [allTasks, groups]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) {
return;
}
const activeId = active.id;
const overId = over.id;
// Find the active task
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) {
console.error('Active task not found:', activeId);
return;
}
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
console.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
let targetGroup = overGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
}
if (!targetGroup) {
console.error('Could not find target group');
return;
}
if (!activeGroup || !targetGroup) return;
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
console.log('Drag operation:', {
activeId,
overId,
activeTask: activeTask.name || activeTask.title,
activeGroup: activeGroup.id,
targetGroup: targetGroup.id,
activeIndex,
insertIndex,
isCrossGroup,
});
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// Move task to the target group
dispatch(moveTaskBetweenGroups({
taskId: activeId as string,
sourceGroupId: activeGroup.id,
targetGroupId: targetGroup.id,
}));
// Reorder task within target group at drop position
dispatch(reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
}));
} else {
// Reordering within the same group
console.log('Reordering task within same group:', {
task: activeTask.name || activeTask.title,
group: activeGroup.title,
from: activeIndex,
to: insertIndex,
});
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
}));
// If dragging to a different group, we need to handle cross-group movement
if (activeGroup.id !== targetGroup.id) {
console.log('Cross-group drag detected:', {
activeTask: activeTask.id,
fromGroup: activeGroup.id,
toGroup: targetGroup.id,
});
}
}
},
[allTasks, groups]
);
}, [allTasks, groups]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) {
return;
}
const activeId = active.id;
const overId = over.id;
// Find the active task
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) {
console.error('Active task not found:', activeId);
return;
}
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
console.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
let targetGroup = overGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
}
if (!targetGroup) {
console.error('Could not find target group');
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
console.log('Drag operation:', {
activeId,
overId,
activeTask: activeTask.name || activeTask.title,
activeGroup: activeGroup.id,
targetGroup: targetGroup.id,
activeIndex,
insertIndex,
isCrossGroup,
});
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// Move task to the target group
dispatch(
moveTaskBetweenGroups({
taskId: activeId as string,
sourceGroupId: activeGroup.id,
targetGroupId: targetGroup.id,
})
);
// Reorder task within target group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
})
);
} else {
// Reordering within the same group
console.log('Reordering task within same group:', {
task: activeTask.name || activeTask.title,
group: activeGroup.title,
from: activeIndex,
to: insertIndex,
});
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
}
}
},
[allTasks, groups]
);
// Bulk action handlers
const handleClearSelection = useCallback(() => {
@@ -395,14 +405,14 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
let currentTaskIndex = 0;
return groups.map(group => {
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
// Order tasks according to group.taskIds array to maintain proper order
const visibleTasksInGroup = isCurrentGroupCollapsed
? []
const visibleTasksInGroup = isCurrentGroupCollapsed
? []
: group.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
...task,
originalIndex: allTasks.indexOf(task),
@@ -428,71 +438,87 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
}, [virtuosoGroups]);
// Memoize column headers to prevent unnecessary re-renders
const columnHeaders = useMemo(() => (
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
{visibleColumns.map((column) => {
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0, // Prevent columns from shrinking
};
const columnHeaders = useMemo(
() => (
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
{visibleColumns.map(column => {
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0, // Prevent columns from shrinking
// Add specific styling for labels column with auto width
...(column.id === 'labels' && column.width === 'auto'
? {
minWidth: '200px', // Ensure minimum width for labels
flexGrow: 1, // Allow it to grow
}
: {}),
};
return (
<div
key={column.id}
className="text-xs font-medium text-gray-500 dark:text-gray-400"
style={columnStyle}
>
{column.id === 'dragHandle' ? (
<HolderOutlined className="text-gray-400" />
) : column.id === 'checkbox' ? (
<span></span> // Empty for checkbox column header
) : (
column.label
)}
</div>
);
})}
</div>
), [visibleColumns]);
return (
<div
key={column.id}
className="text-xs font-medium text-gray-500 dark:text-gray-400"
style={columnStyle}
>
{column.id === 'dragHandle' ? (
<HolderOutlined className="text-gray-400" />
) : column.id === 'checkbox' ? (
<span></span> // Empty for checkbox column header
) : (
column.label
)}
</div>
);
})}
</div>
),
[visibleColumns]
);
// Render functions
const renderGroup = useCallback((groupIndex: number) => {
const group = virtuosoGroups[groupIndex];
const isGroupEmpty = group.count === 0;
return (
<div className={groupIndex > 0 ? 'mt-2' : ''}>
<TaskGroupHeader
group={{
id: group.id,
name: group.title,
count: group.count,
color: group.color,
}}
isCollapsed={collapsedGroups.has(group.id)}
onToggle={() => handleGroupCollapse(group.id)}
/>
{/* Empty group drop zone */}
{isGroupEmpty && !collapsedGroups.has(group.id) && (
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
<div className="text-sm">Drop tasks here</div>
</div>
)}
</div>
);
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
const renderGroup = useCallback(
(groupIndex: number) => {
const group = virtuosoGroups[groupIndex];
const isGroupEmpty = group.count === 0;
const renderTask = useCallback((taskIndex: number) => {
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
if (!task) return null; // Should not happen if logic is correct
return (
<TaskRow
taskId={task.id}
projectId={projectId}
visibleColumns={visibleColumns}
/>
);
}, [virtuosoItems, visibleColumns]);
return (
<div className={groupIndex > 0 ? 'mt-2' : ''}>
<TaskGroupHeader
group={{
id: group.id,
name: group.title,
count: group.count,
color: group.color,
}}
isCollapsed={collapsedGroups.has(group.id)}
onToggle={() => handleGroupCollapse(group.id)}
/>
{/* Empty group drop zone */}
{isGroupEmpty && !collapsedGroups.has(group.id) && (
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
<div className="text-sm">Drop tasks here</div>
</div>
)}
</div>
);
},
[virtuosoGroups, collapsedGroups, handleGroupCollapse]
);
const renderTask = useCallback(
(taskIndex: number) => {
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
if (!task || !urlProjectId) return null; // Should not happen if logic is correct
return (
<TaskRowWithSubtasks
taskId={task.id}
projectId={urlProjectId}
visibleColumns={visibleColumns}
/>
);
},
[virtuosoItems, visibleColumns]
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
@@ -522,7 +548,10 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
{/* Task List - Scrollable content */}
<div className="flex-1">
<SortableContext
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
items={virtuosoItems
.filter(task => !task.parent_task_id)
.map(task => task.id)
.filter((id): id is string => id !== undefined)}
strategy={verticalListSortingStrategy}
>
<GroupedVirtuoso
@@ -531,12 +560,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
groupContent={renderGroup}
itemContent={renderTask}
components={{
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
<div
ref={ref}
style={style || {}}
className="virtuoso-list-container"
>
List: React.forwardRef<
HTMLDivElement,
{ style?: React.CSSProperties; children?: React.ReactNode }
>(({ style, children }, ref) => (
<div ref={ref} style={style || {}} className="virtuoso-list-container">
{children}
</div>
)),
@@ -556,9 +584,9 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
<HolderOutlined className="text-blue-500" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title ||
'Task'}
{allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title ||
'Task'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allTasks.find(task => task.id === activeId)?.task_key}
@@ -571,11 +599,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
</DragOverlay>
{/* Bulk Action Bar */}
{selectedTaskIds.length > 0 && (
{selectedTaskIds.length > 0 && urlProjectId && (
<OptimizedBulkActionBar
selectedTaskIds={selectedTaskIds}
totalSelected={selectedTaskIds.length}
projectId={projectId}
projectId={urlProjectId}
onClearSelection={handleClearSelection}
onBulkStatusChange={handleBulkStatusChange}
onBulkPriorityChange={handleBulkPriorityChange}
@@ -595,4 +623,4 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
);
};
export default TaskListV2;
export default TaskListV2;

View File

@@ -1,15 +1,13 @@
import React, { memo, useMemo, useCallback } from 'react';
import React, { memo, useMemo, useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
import { Checkbox } from 'antd';
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons';
import { Checkbox, DatePicker } from 'antd';
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import Avatar from '@/components/Avatar';
import AssigneeSelector from '@/components/AssigneeSelector';
import { format } from 'date-fns';
import { Bars3Icon } from '@heroicons/react/24/outline';
import { ClockIcon } from '@heroicons/react/24/outline';
import AvatarGroup from '../AvatarGroup';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
@@ -17,8 +15,16 @@ import TaskStatusDropdown from '@/components/task-management/task-status-dropdow
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectTaskById } from '@/features/task-management/task-management.slice';
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';
interface TaskRowProps {
taskId: string;
@@ -28,8 +34,45 @@ interface TaskRowProps {
width: string;
isSticky?: boolean;
}>;
isSubtask?: boolean;
}
interface TaskLabelsCellProps {
labels: Task['labels'];
isDarkMode: boolean;
}
const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isDarkMode }) => {
if (!labels) {
return null;
}
return (
<>
{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}
/>
);
})}
</>
);
});
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
@@ -42,30 +85,34 @@ const getTaskDisplayName = (task: Task): string => {
// Memoized date formatter to avoid repeated date parsing
const formatDate = (dateString: string): string => {
try {
return format(new Date(dateString), 'MMM d');
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return '';
}
};
// Memoized date formatter to avoid repeated date parsing
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns }) => {
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false }) => {
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);
if (!task) {
return null; // Don't render if task is not found in store
}
// Drag and drop functionality
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag and drop for subtasks
});
// Memoize style object to prevent unnecessary re-renders
@@ -101,35 +148,43 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
// Memoize formatted dates
const formattedDueDate = useMemo(() =>
task.dueDate ? formatDate(task.dueDate) : null,
[task.dueDate]
);
const formattedStartDate = useMemo(() =>
task.startDate ? formatDate(task.startDate) : null,
[task.startDate]
);
const formattedCompletedDate = useMemo(() =>
task.completedAt ? formatDate(task.completedAt) : null,
[task.completedAt]
);
const formattedCreatedDate = useMemo(() =>
task.created_at ? formatDate(task.created_at) : null,
[task.created_at]
);
const formattedUpdatedDate = useMemo(() =>
task.updatedAt ? formatDate(task.updatedAt) : null,
[task.updatedAt]
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.created_at ? formatDate(task.created_at) : null,
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, 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]
);
// Debugging: Log assignee_names whenever the task prop changes
React.useEffect(() => {
console.log(`Task ${task.id} assignees:`, task.assignee_names);
}, [task.id, task.assignee_names]);
// 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.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color,
})) || [],
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.labels]);
// Handle checkbox change
const handleCheckboxChange = useCallback((e: any) => {
@@ -137,30 +192,61 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
dispatch(toggleTaskSelection(taskId));
}, [dispatch, taskId]);
// Memoize status style
const statusStyle = useMemo(() => ({
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
color: task.statusColor || 'rgb(31, 41, 55)',
}), [task.statusColor]);
// Memoize priority style
const priorityStyle = useMemo(() => ({
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
color: task.priorityColor || 'rgb(31, 41, 55)',
}), [task.priorityColor]);
// Memoize labels display
const labelsDisplay = useMemo(() => {
if (!task.labels || task.labels.length === 0) return null;
// Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const visibleLabels = task.labels.slice(0, 2);
const remainingCount = task.labels.length - 2;
// 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 }));
}
return {
visibleLabels,
remainingCount: remainingCount > 0 ? remainingCount : null,
};
}, [task.labels]);
// 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 };
@@ -169,12 +255,11 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'dragHandle':
return (
<div
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
className={`flex items-center justify-center ${isSubtask ? '' : 'cursor-grab active:cursor-grabbing'}`}
style={baseStyle}
{...attributes}
{...listeners}
{...(isSubtask ? {} : { ...attributes, ...listeners })}
>
<HolderOutlined className="text-gray-400 hover:text-gray-600" />
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
</div>
);
@@ -200,10 +285,63 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'title':
return (
<div className="flex items-center" style={baseStyle}>
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
{taskDisplayName}
</span>
<div className="flex items-center justify-between group" style={baseStyle}>
<div className="flex items-center flex-1">
{/* Indentation for subtasks - increased padding */}
{isSubtask && <div className="w-8" />}
{/* 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-2 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors ${
task.sub_tasks_count && task.sub_tasks_count > 0
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{task.sub_tasks_count && task.sub_tasks_count > 0 ? (
task.show_sub_tasks ? (
<DownOutlined className="text-gray-600 dark:text-gray-400" />
) : (
<RightOutlined className="text-gray-600 dark:text-gray-400" />
)
) : (
<RightOutlined className="text-gray-600 dark:text-gray-400" />
)}
</button>
)}
{/* Additional indentation for subtasks after the expand button space */}
{isSubtask && <div className="w-4" />}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
{taskDisplayName}
</span>
{/* Subtask count indicator */}
{!isSubtask && task.sub_tasks_count && task.sub_tasks_count > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
{task.sub_tasks_count}
</span>
<DoubleRightOutlined className="text-xs text-blue-600 dark:text-blue-400" />
</div>
)}
</div>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border-none bg-transparent cursor-pointer"
onClick={(e) => {
e.stopPropagation();
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
}}
>
{t('openButton')}
</button>
</div>
);
@@ -248,11 +386,58 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'dueDate':
return (
<div style={baseStyle}>
{formattedDueDate && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedDueDate}
</span>
<div style={baseStyle} className="relative group">
{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"
onClick={(e) => {
e.stopPropagation();
datePickerHandlers.setDueDate();
}}
>
{formattedDates.due ? (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedDates.due}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">
{t('setDueDate')}
</span>
)}
</div>
)}
</div>
);
@@ -283,48 +468,27 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'labels':
return (
<div className="flex items-center gap-1" style={baseStyle}>
{labelsDisplay?.visibleLabels.map((label, index) => (
<span
key={`${label.id}-${index}`}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
color: label.color || 'rgb(31, 41, 55)',
}}
>
{label.name}
</span>
))}
{labelsDisplay?.remainingCount && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{labelsDisplay.remainingCount}
</span>
)}
<div className="flex items-center gap-1 flex-wrap min-w-0" style={{ ...baseStyle, minWidth: '200px' }}>
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
</div>
);
case 'phase':
return (
<div style={baseStyle}>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{task.phase}
</span>
<TaskPhaseDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
);
case 'timeTracking':
return (
<div className="flex items-center gap-1" style={baseStyle}>
<ClockIcon className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500 dark:text-gray-400">
{task.timeTracking?.logged || 0}h
</span>
{task.timeTracking?.estimated && (
<span className="text-sm text-gray-400 dark:text-gray-500">
/{task.timeTracking.estimated}h
</span>
)}
<div style={baseStyle}>
<TaskTimeTracking taskId={task.id || ''} isDarkMode={isDarkMode} />
</div>
);
@@ -341,11 +505,58 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'startDate':
return (
<div style={baseStyle}>
{formattedStartDate && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedStartDate}
</span>
<div style={baseStyle} className="relative group">
{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"
onClick={(e) => {
e.stopPropagation();
datePickerHandlers.setStartDate();
}}
>
{formattedDates.start ? (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedDates.start}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">
{t('setStartDate')}
</span>
)}
</div>
)}
</div>
);
@@ -353,9 +564,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'completedDate':
return (
<div style={baseStyle}>
{formattedCompletedDate && (
{formattedDates.completed && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedCompletedDate}
{formattedDates.completed}
</span>
)}
</div>
@@ -364,9 +575,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'createdDate':
return (
<div style={baseStyle}>
{formattedCreatedDate && (
{formattedDates.created && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedCreatedDate}
{formattedDates.created}
</span>
)}
</div>
@@ -375,9 +586,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'lastUpdated':
return (
<div style={baseStyle}>
{formattedUpdatedDate && (
{formattedDates.updated && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formattedUpdatedDate}
{formattedDates.updated}
</span>
)}
</div>
@@ -396,30 +607,31 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
return null;
}
}, [
// Essential props and state
attributes,
listeners,
task.task_key,
task.status,
task.priority,
task.phase,
task.reporter,
task.assignee_names,
task.timeTracking,
task.progress,
task.sub_tasks,
taskDisplayName,
statusStyle,
priorityStyle,
formattedDueDate,
formattedStartDate,
formattedCompletedDate,
formattedCreatedDate,
formattedUpdatedDate,
labelsDisplay,
isDarkMode,
convertedTask,
isSelected,
handleCheckboxChange,
activeDatePicker,
isDarkMode,
projectId,
// Task data
task,
taskDisplayName,
convertedTask,
// Memoized values
dateValues,
formattedDates,
labelsAdapter,
// Handlers
handleDateChange,
datePickerHandlers,
// Translation
t,
]);
return (

View File

@@ -0,0 +1,208 @@
import React, { memo, useState, useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectTaskById, createSubtask, selectSubtaskLoading } from '@/features/task-management/task-management.slice';
import TaskRow from './TaskRow';
import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton';
import { Task } from '@/types/task-management.types';
import { Input, Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
interface TaskRowWithSubtasksProps {
taskId: string;
projectId: string;
visibleColumns: Array<{
id: string;
width: string;
isSticky?: boolean;
}>;
}
interface AddSubtaskRowProps {
parentTaskId: string;
projectId: string;
visibleColumns: Array<{
id: string;
width: string;
isSticky?: boolean;
}>;
onSubtaskAdded: () => void;
}
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
parentTaskId,
projectId,
visibleColumns,
onSubtaskAdded
}) => {
const [isAdding, setIsAdding] = useState(false);
const [subtaskName, setSubtaskName] = useState('');
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const handleAddSubtask = useCallback(() => {
if (!subtaskName.trim()) return;
// Create optimistic subtask immediately for better UX
dispatch(createSubtask({
parentTaskId,
name: subtaskName.trim(),
projectId
}));
// Emit socket event for server-side creation
if (connected && socket) {
socket.emit(
SocketEvents.QUICK_TASK.toString(),
JSON.stringify({
name: subtaskName.trim(),
project_id: projectId,
parent_task_id: parentTaskId,
})
);
}
setSubtaskName('');
setIsAdding(false);
onSubtaskAdded();
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, onSubtaskAdded]);
const handleCancel = useCallback(() => {
setSubtaskName('');
setIsAdding(false);
}, []);
const renderColumn = useCallback((columnId: string, width: string) => {
const baseStyle = { width };
switch (columnId) {
case 'dragHandle':
return <div style={baseStyle} />;
case 'checkbox':
return <div style={baseStyle} />;
case 'taskKey':
return <div style={baseStyle} />;
case 'title':
return (
<div className="flex items-center h-full" style={baseStyle}>
<div className="flex items-center w-full h-full">
{/* Match subtask indentation pattern - same as TaskRow for subtasks */}
<div className="w-8" />
{!isAdding ? (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
>
<PlusOutlined className="text-xs" />
{t('addSubTaskText')}
</button>
) : (
<Input
value={subtaskName}
onChange={(e) => setSubtaskName(e.target.value)}
onPressEnter={handleAddSubtask}
onBlur={handleCancel}
placeholder="Type subtask name and press Enter to save"
className="w-full h-full border-none shadow-none bg-transparent"
style={{
height: '100%',
minHeight: '42px',
padding: '0',
fontSize: '14px'
}}
autoFocus
/>
)}
</div>
</div>
);
default:
return <div style={baseStyle} />;
}
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
return (
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
{visibleColumns.map((column) =>
renderColumn(column.id, column.width)
)}
</div>
);
});
AddSubtaskRow.displayName = 'AddSubtaskRow';
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
taskId,
projectId,
visibleColumns
}) => {
const task = useAppSelector(state => selectTaskById(state, taskId));
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
const dispatch = useAppDispatch();
const handleSubtaskAdded = useCallback(() => {
// Refresh subtasks after adding a new one
// The socket event will handle the real-time update
}, []);
if (!task) {
return null;
}
return (
<>
{/* Main task row */}
<TaskRow
taskId={taskId}
projectId={projectId}
visibleColumns={visibleColumns}
/>
{/* Subtasks and add subtask row when expanded */}
{task.show_sub_tasks && (
<>
{/* Show loading skeleton while fetching subtasks */}
{isLoadingSubtasks && (
<>
<SubtaskLoadingSkeleton visibleColumns={visibleColumns} />
</>
)}
{/* Render existing subtasks when not loading */}
{!isLoadingSubtasks && task.sub_tasks?.map((subtask: Task) => (
<div key={subtask.id} className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
<TaskRow
taskId={subtask.id}
projectId={projectId}
visibleColumns={visibleColumns}
isSubtask={true}
/>
</div>
))}
{/* Add subtask row - only show when not loading */}
{!isLoadingSubtasks && (
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
<AddSubtaskRow
parentTaskId={taskId}
projectId={projectId}
visibleColumns={visibleColumns}
onSubtaskAdded={handleSubtaskAdded}
/>
</div>
)}
</>
)}
</>
);
});
TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks';
export default TaskRowWithSubtasks;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
interface TaskTimeTrackingProps {
taskId: string;
isDarkMode: boolean;
}
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
taskId,
null // The hook will get the timer start time from Redux
);
return (
<TaskTimer
taskId={taskId}
started={started}
handleStartTimer={handleStartTimer}
handleStopTimer={handleStopTimer}
timeString={timeString}
/>
);
});
TaskTimeTracking.displayName = 'TaskTimeTracking';
export default TaskTimeTracking;

View File

@@ -24,6 +24,7 @@ import {
EyeOutlined,
RetweetOutlined,
DownOutlined, // Added DownOutlined for expand/collapse
CloseOutlined, // Added CloseOutlined for clear button
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { Task } from '@/types/task-management.types';
@@ -752,7 +753,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
className={`flex items-center px-2 ${borderClasses}`}
style={{ width: col.width }}
>
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
<TaskKey taskKey={task.task_key || ''} isDarkMode={isDarkMode} />
</div>
);
@@ -892,7 +893,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
className={`flex items-center px-2 ${borderClasses}`}
style={{ width: col.width }}
>
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
<TaskKey taskKey={task.task_key || ''} isDarkMode={isDarkMode} />
</div>
);
@@ -1316,13 +1317,35 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
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 className="w-full relative group">
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.start}
onChange={date => handleDateChange(date, 'startDate')}
placeholder="Start Date"
allowClear={false} // We'll handle clear manually
suffixIcon={null}
/>
{/* Custom clear button - only show when there's a value */}
{dateValues.start && (
<button
onClick={e => {
e.preventDefault();
e.stopPropagation();
handleDateChange(null, 'startDate');
}}
className={`absolute right-1 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 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="Clear start date"
>
<CloseOutlined style={{ fontSize: '10px' }} />
</button>
)}
</div>
</div>
);
@@ -1333,13 +1356,35 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
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 className="w-full relative group">
<DatePicker
{...taskManagementAntdConfig.datePickerDefaults}
className="w-full bg-transparent border-none shadow-none"
value={dateValues.due}
onChange={date => handleDateChange(date, 'dueDate')}
placeholder="Due Date"
allowClear={false} // We'll handle clear manually
suffixIcon={null}
/>
{/* Custom clear button - only show when there's a value */}
{dateValues.due && (
<button
onClick={e => {
e.preventDefault();
e.stopPropagation();
handleDateChange(null, 'dueDate');
}}
className={`absolute right-1 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 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="Clear due date"
>
<CloseOutlined style={{ fontSize: '10px' }} />
</button>
)}
</div>
</div>
);