Merge pull request #235 from Worklenz/fix/task-drag-and-drop-improvement
feat(task-management): enhance task management UI with subtask functi…
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
"selectText": "Zgjidh",
|
||||
"labelsSelectorInputTip": "Shtyp Enter për të krijuar!",
|
||||
|
||||
"addTaskText": "+ Shto Detyrë",
|
||||
"addTaskText": "Shto Detyrë",
|
||||
"addSubTaskText": "+ Shto Nën-Detyrë",
|
||||
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"selectText": "Auswählen",
|
||||
"labelsSelectorInputTip": "Enter drücken zum Erstellen!",
|
||||
|
||||
"addTaskText": "+ Aufgabe hinzufügen",
|
||||
"addTaskText": "Aufgabe hinzufügen",
|
||||
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
||||
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"subTasks": {
|
||||
"title": "Sub Tasks",
|
||||
"add-sub-task": "+ Add Sub Task",
|
||||
"add-sub-task": "Add Sub Task",
|
||||
"refresh-sub-tasks": "Refresh Sub Tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"subTasks": {
|
||||
"title": "Sub Tasks",
|
||||
"addSubTask": "+ Add Sub Task",
|
||||
"addSubTask": "Add Sub Task",
|
||||
"addSubTaskInputPlaceholder": "Type your task and hit enter",
|
||||
"refreshSubTasks": "Refresh Sub Tasks",
|
||||
"edit": "Edit",
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"selectText": "Select",
|
||||
"labelsSelectorInputTip": "Hit enter to create!",
|
||||
|
||||
"addTaskText": "+ Add Task",
|
||||
"addSubTaskText": "+ Add Sub Task",
|
||||
"addTaskText": "Add Task",
|
||||
"addSubTaskText": "Add Sub Task",
|
||||
"addTaskInputPlaceholder": "Type your task and hit enter",
|
||||
|
||||
"openButton": "Open",
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"selectText": "Seleccionar",
|
||||
"labelsSelectorInputTip": "¡Presiona enter para crear!",
|
||||
|
||||
"addTaskText": "+ Agregar tarea",
|
||||
"addSubTaskText": "+ Agregar subtarea",
|
||||
"addTaskText": "Agregar tarea",
|
||||
"addSubTaskText": "Agregar subtarea",
|
||||
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
||||
|
||||
"openButton": "Abrir",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"selectText": "Selecionar",
|
||||
"labelsSelectorInputTip": "Pressione enter para criar!",
|
||||
|
||||
"addTaskText": "+ Adicionar Tarefa",
|
||||
"addTaskText": "Adicionar Tarefa",
|
||||
"addSubTaskText": "+ Adicionar Subtarefa",
|
||||
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,7 +64,7 @@ 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 },
|
||||
@@ -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,76 +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
|
||||
// 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
|
||||
} : {}),
|
||||
};
|
||||
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>;
|
||||
@@ -527,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
|
||||
@@ -536,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>
|
||||
)),
|
||||
@@ -561,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}
|
||||
@@ -576,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}
|
||||
@@ -600,4 +623,4 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListV2;
|
||||
export default TaskListV2;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { CheckCircleOutlined, HolderOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
@@ -15,8 +15,9 @@ 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';
|
||||
@@ -33,6 +34,7 @@ interface TaskRowProps {
|
||||
width: string;
|
||||
isSticky?: boolean;
|
||||
}>;
|
||||
isSubtask?: boolean;
|
||||
}
|
||||
|
||||
interface TaskLabelsCellProps {
|
||||
@@ -89,7 +91,7 @@ const formatDate = (dateString: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
@@ -103,13 +105,14 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
||||
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
|
||||
@@ -189,6 +192,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
||||
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') => {
|
||||
@@ -239,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>
|
||||
);
|
||||
|
||||
@@ -270,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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -55,6 +55,7 @@ const initialState: TaskManagementState = {
|
||||
grouping: undefined,
|
||||
selectedPriorities: [],
|
||||
search: '',
|
||||
loadingSubtasks: {},
|
||||
};
|
||||
|
||||
// Async thunk to fetch tasks from API
|
||||
@@ -703,6 +704,68 @@ const taskManagementSlice = createSlice({
|
||||
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||
}
|
||||
},
|
||||
createSubtask: (
|
||||
state,
|
||||
action: PayloadAction<{ parentTaskId: string; name: string; projectId: string }>
|
||||
) => {
|
||||
const { parentTaskId, name, projectId } = action.payload;
|
||||
const parent = state.entities[parentTaskId];
|
||||
if (parent) {
|
||||
// Create a temporary subtask - the real one will come from the socket
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const tempSubtask: Task = {
|
||||
id: tempId,
|
||||
task_key: '',
|
||||
title: name,
|
||||
name: name,
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
phase: 'Development',
|
||||
progress: 0,
|
||||
assignees: [],
|
||||
assignee_names: [],
|
||||
labels: [],
|
||||
dueDate: undefined,
|
||||
due_date: undefined,
|
||||
startDate: undefined,
|
||||
timeTracking: {
|
||||
estimated: 0,
|
||||
logged: 0,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
order: 0,
|
||||
parent_task_id: parentTaskId,
|
||||
is_sub_task: true,
|
||||
sub_tasks_count: 0,
|
||||
show_sub_tasks: false,
|
||||
isTemporary: true, // Mark as temporary
|
||||
};
|
||||
|
||||
// Add temporary subtask for immediate UI feedback
|
||||
if (!parent.sub_tasks) {
|
||||
parent.sub_tasks = [];
|
||||
}
|
||||
parent.sub_tasks.push(tempSubtask);
|
||||
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||
state.entities[tempId] = tempSubtask;
|
||||
state.ids.push(tempId);
|
||||
}
|
||||
},
|
||||
removeTemporarySubtask: (
|
||||
state,
|
||||
action: PayloadAction<{ parentTaskId: string; tempId: string }>
|
||||
) => {
|
||||
const { parentTaskId, tempId } = action.payload;
|
||||
const parent = state.entities[parentTaskId];
|
||||
if (parent && parent.sub_tasks) {
|
||||
parent.sub_tasks = parent.sub_tasks.filter(subtask => subtask.id !== tempId);
|
||||
parent.sub_tasks_count = Math.max((parent.sub_tasks_count || 0) - 1, 0);
|
||||
delete state.entities[tempId];
|
||||
state.ids = state.ids.filter(id => id !== tempId);
|
||||
}
|
||||
},
|
||||
updateTaskAssignees: (state, action: PayloadAction<{
|
||||
taskId: string;
|
||||
assigneeIds: string[];
|
||||
@@ -719,6 +782,7 @@ const taskManagementSlice = createSlice({
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
@@ -742,20 +806,66 @@ const taskManagementSlice = createSlice({
|
||||
state.groups = [];
|
||||
})
|
||||
.addCase(fetchSubTasks.pending, (state, action) => {
|
||||
// Don't set global loading state for subtasks
|
||||
// Set loading state for specific task
|
||||
const { taskId } = action.meta.arg;
|
||||
state.loadingSubtasks[taskId] = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subtasks } = action.payload;
|
||||
const parentTask = state.entities[parentTaskId];
|
||||
if (parentTask) {
|
||||
parentTask.sub_tasks = subtasks;
|
||||
parentTask.sub_tasks_count = subtasks.length;
|
||||
parentTask.show_sub_tasks = true;
|
||||
// Clear loading state
|
||||
state.loadingSubtasks[parentTaskId] = false;
|
||||
if (parentTask && subtasks) {
|
||||
// Convert subtasks to the proper format
|
||||
const convertedSubtasks = subtasks.map(subtask => ({
|
||||
id: subtask.id || '',
|
||||
task_key: subtask.task_key || '',
|
||||
title: subtask.name || subtask.title || '',
|
||||
name: subtask.name || subtask.title || '',
|
||||
description: subtask.description || '',
|
||||
status: subtask.status || 'todo',
|
||||
priority: subtask.priority || 'low',
|
||||
phase: subtask.phase_name || subtask.phase || 'Development',
|
||||
progress: subtask.complete_ratio || subtask.progress || 0,
|
||||
assignees: subtask.assignees || [],
|
||||
assignee_names: subtask.assignee_names || subtask.names || [],
|
||||
labels: subtask.labels || [],
|
||||
dueDate: subtask.end_date || subtask.dueDate,
|
||||
due_date: subtask.end_date || subtask.due_date,
|
||||
startDate: subtask.start_date || subtask.startDate,
|
||||
timeTracking: subtask.timeTracking || {
|
||||
estimated: 0,
|
||||
logged: 0,
|
||||
},
|
||||
createdAt: subtask.created_at || subtask.createdAt || new Date().toISOString(),
|
||||
created_at: subtask.created_at || subtask.createdAt || new Date().toISOString(),
|
||||
updatedAt: subtask.updated_at || subtask.updatedAt || new Date().toISOString(),
|
||||
updated_at: subtask.updated_at || subtask.updatedAt || new Date().toISOString(),
|
||||
order: subtask.sort_order || subtask.order || 0,
|
||||
parent_task_id: parentTaskId,
|
||||
is_sub_task: true,
|
||||
sub_tasks_count: 0,
|
||||
show_sub_tasks: false,
|
||||
}));
|
||||
|
||||
// Update parent task with subtasks
|
||||
parentTask.sub_tasks = convertedSubtasks;
|
||||
parentTask.sub_tasks_count = convertedSubtasks.length;
|
||||
|
||||
// Add subtasks to entities so they can be accessed by ID
|
||||
convertedSubtasks.forEach(subtask => {
|
||||
state.entities[subtask.id] = subtask;
|
||||
if (!state.ids.includes(subtask.id)) {
|
||||
state.ids.push(subtask.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.addCase(fetchSubTasks.rejected, (state, action) => {
|
||||
// Set error but don't clear task data
|
||||
// Clear loading state and set error
|
||||
const { taskId } = action.meta.arg;
|
||||
state.loadingSubtasks[taskId] = false;
|
||||
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
||||
})
|
||||
.addCase(fetchTasks.pending, (state) => {
|
||||
@@ -801,6 +911,8 @@ export const {
|
||||
toggleTaskExpansion,
|
||||
addSubtaskToParent,
|
||||
updateTaskAssignees,
|
||||
createSubtask,
|
||||
removeTemporarySubtask,
|
||||
} = taskManagementSlice.actions;
|
||||
|
||||
// Export the selectors
|
||||
@@ -814,6 +926,7 @@ export const selectLoading = (state: RootState) => state.taskManagement.loading;
|
||||
export const selectError = (state: RootState) => state.taskManagement.error;
|
||||
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
||||
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
||||
export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false;
|
||||
|
||||
// Memoized selectors
|
||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||
|
||||
@@ -200,49 +200,57 @@ export const useTaskSocketHandlers = () => {
|
||||
// Update enhanced kanban slice
|
||||
dispatch(updateEnhancedKanbanTaskStatus(response));
|
||||
|
||||
// For the task management slice, move task between groups without resetting
|
||||
// For the task management slice, update the task entity and handle group movement
|
||||
const state = store.getState();
|
||||
const groups = state.taskManagement.groups;
|
||||
const currentTask = state.taskManagement.entities[response.id];
|
||||
const currentGrouping = state.taskManagement.grouping;
|
||||
|
||||
if (groups && groups.length > 0 && currentTask && response.status_id) {
|
||||
// Find current group containing the task
|
||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||
|
||||
// Find target group based on new status ID
|
||||
// The status_id from response is the UUID of the new status
|
||||
const targetGroup = groups.find(group => group.id === response.status_id);
|
||||
|
||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||
// Determine the new status value based on status category
|
||||
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
||||
if (response.statusCategory) {
|
||||
if (response.statusCategory.is_done) {
|
||||
newStatusValue = 'done';
|
||||
} else if (response.statusCategory.is_doing) {
|
||||
newStatusValue = 'doing';
|
||||
} else {
|
||||
newStatusValue = 'todo';
|
||||
}
|
||||
if (currentTask) {
|
||||
// Determine the new status value based on status category
|
||||
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
||||
if (response.statusCategory) {
|
||||
if (response.statusCategory.is_done) {
|
||||
newStatusValue = 'done';
|
||||
} else if (response.statusCategory.is_doing) {
|
||||
newStatusValue = 'doing';
|
||||
} else {
|
||||
newStatusValue = 'todo';
|
||||
}
|
||||
}
|
||||
|
||||
// Use the new action to move task between groups
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: response.id,
|
||||
fromGroupId: currentGroup.id,
|
||||
toGroupId: targetGroup.id,
|
||||
taskUpdate: {
|
||||
status: newStatusValue,
|
||||
progress: response.complete_ratio || currentTask.progress,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (!currentGroup || !targetGroup) {
|
||||
// Remove unnecessary refetch that causes data thrashing
|
||||
// if (projectId) {
|
||||
// dispatch(fetchTasksV3(projectId));
|
||||
// }
|
||||
// Update the task entity first
|
||||
dispatch(
|
||||
updateTask({
|
||||
...currentTask,
|
||||
status: newStatusValue,
|
||||
progress: response.complete_ratio || currentTask.progress,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// Handle group movement ONLY if grouping by status
|
||||
if (groups && groups.length > 0 && currentGrouping === 'status') {
|
||||
// Find current group containing the task
|
||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||
|
||||
// Find target group based on new status value (not UUID)
|
||||
const targetGroup = groups.find(group => group.groupValue === newStatusValue);
|
||||
|
||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||
// Use the action to move task between groups
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: response.id,
|
||||
sourceGroupId: currentGroup.id,
|
||||
targetGroupId: targetGroup.id,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.log('🔧 No group movement needed for status change');
|
||||
}
|
||||
} else {
|
||||
console.log('🔧 Not grouped by status, skipping group movement');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,9 +318,10 @@ export const useTaskSocketHandlers = () => {
|
||||
// Update enhanced kanban slice
|
||||
dispatch(updateEnhancedKanbanTaskPriority(response));
|
||||
|
||||
// For the task management slice, always update the task entity first
|
||||
// For the task management slice, update the task entity and handle group movement
|
||||
const state = store.getState();
|
||||
const currentTask = state.taskManagement.entities[response.id];
|
||||
const currentGrouping = state.taskManagement.grouping;
|
||||
|
||||
if (currentTask) {
|
||||
// Get priority list to map priority_id to priority name
|
||||
@@ -327,20 +336,17 @@ export const useTaskSocketHandlers = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the task entity
|
||||
// Update the task entity first
|
||||
dispatch(
|
||||
updateTask({
|
||||
id: response.id,
|
||||
changes: {
|
||||
priority: newPriorityValue,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
...currentTask,
|
||||
priority: newPriorityValue,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// Handle group movement ONLY if grouping by priority
|
||||
const groups = state.taskManagement.groups;
|
||||
const currentGrouping = state.taskManagement.grouping;
|
||||
|
||||
if (groups && groups.length > 0 && currentGrouping === 'priority') {
|
||||
// Find current group containing the task
|
||||
@@ -348,18 +354,15 @@ export const useTaskSocketHandlers = () => {
|
||||
|
||||
// Find target group based on new priority value
|
||||
const targetGroup = groups.find(
|
||||
group => group.groupValue.toLowerCase() === newPriorityValue.toLowerCase()
|
||||
group => group.groupValue?.toLowerCase() === newPriorityValue.toLowerCase()
|
||||
);
|
||||
|
||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: response.id,
|
||||
fromGroupId: currentGroup.id,
|
||||
toGroupId: targetGroup.id,
|
||||
taskUpdate: {
|
||||
priority: newPriorityValue,
|
||||
},
|
||||
sourceGroupId: currentGroup.id,
|
||||
targetGroupId: targetGroup.id,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
@@ -619,7 +622,7 @@ export const useTaskSocketHandlers = () => {
|
||||
parent_task_id: data.parent_task_id,
|
||||
is_sub_task: true,
|
||||
};
|
||||
dispatch(addSubtaskToParent({ subtask, parentTaskId: data.parent_task_id }));
|
||||
dispatch(addSubtaskToParent({ parentId: data.parent_task_id, subtask }));
|
||||
|
||||
// Also update enhanced kanban slice for subtask creation
|
||||
dispatch(
|
||||
|
||||
@@ -310,8 +310,6 @@
|
||||
}
|
||||
|
||||
.dark .ant-btn {
|
||||
background-color: #262626;
|
||||
border-color: #404040;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface TaskManagementState {
|
||||
grouping: string | undefined;
|
||||
selectedPriorities: string[];
|
||||
search: string;
|
||||
loadingSubtasks: Record<string, boolean>; // Track loading state for individual tasks
|
||||
}
|
||||
|
||||
export interface TaskGroupsState {
|
||||
|
||||
Reference in New Issue
Block a user