diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 49350ffa..4048996d 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useDroppable } from '@dnd-kit/core'; import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { getContrastColor } from '@/utils/colorUtils'; @@ -17,11 +18,23 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerTextColor = getContrastColor(headerBackgroundColor); + // Make the group header droppable + const { isOver, setNodeRef } = useDroppable({ + id: group.id, + data: { + type: 'group', + group, + }, + }); + return (
= ({ projectId }) => { // Filter visible columns based on fields const visibleColumns = useMemo(() => { return BASE_COLUMNS.filter(column => { - // Always show drag handle, task key, and title + // Always show drag handle and title (sticky columns) if (column.isSticky) return true; - // Check if field is visible + // Check if field is visible for all other columns (including task key) const field = fields.find(f => f.key === column.key); return field?.visible ?? false; }); @@ -176,6 +180,42 @@ const TaskListV2: React.FC = ({ projectId }) => { setActiveId(event.active.id as string); }, []); + const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + + if (!over) return; + + const activeId = active.id; + const overId = over.id; + + // Find the active task and the item being dragged over + const activeTask = allTasks.find(task => task.id === activeId); + if (!activeTask) return; + + // 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 groups + const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); + let targetGroup = overGroup; + + if (overTask) { + targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); + } + + 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); @@ -184,47 +224,115 @@ const TaskListV2: React.FC = ({ projectId }) => { return; } + const activeId = active.id; + const overId = over.id; + // Find the active task - const activeTask = allTasks.find(task => task.id === active.id); + const activeTask = allTasks.find(task => task.id === activeId); if (!activeTask) { - console.error('Active task not found:', active.id); + console.error('Active task not found:', activeId); return; } - // Find which group the task is being moved to - const overTask = allTasks.find(task => task.id === over.id); - if (!overTask) { - console.error('Over task not found:', over.id); - return; - } - - // Find the groups for both tasks + // Find the groups const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); - const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); - - if (!activeGroup || !overGroup) { - console.error('Could not find groups for tasks'); + if (!activeGroup) { + console.error('Could not find active group for task:', activeId); return; } - // Calculate new positions - const activeIndex = allTasks.findIndex(task => task.id === active.id); - const overIndex = allTasks.findIndex(task => task.id === over.id); + // 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: active.id, - overId: over.id, - activeIndex, - overIndex, + activeId, + overId, + activeTask: activeTask.name || activeTask.title, activeGroup: activeGroup.id, - overGroup: overGroup.id, + targetGroup: targetGroup.id, + activeIndex, + insertIndex, + isCrossGroup, }); - // TODO: Implement the actual reordering logic - // This would typically involve: - // 1. Updating the task order in Redux - // 2. Sending the update to the backend - // 3. Optimistic UI updates + 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, + })); + + // If we need to insert at a specific position (not at the end) + if (insertIndex < targetGroup.taskIds.length) { + const newTaskIds = [...targetGroup.taskIds]; + // Remove the task if it was already added at the end + const taskIndex = newTaskIds.indexOf(activeId as string); + if (taskIndex > -1) { + newTaskIds.splice(taskIndex, 1); + } + // Insert at the correct position + newTaskIds.splice(insertIndex, 0, activeId as string); + + dispatch(reorderTasksInGroup({ + taskIds: newTaskIds, + groupId: 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) { + const newTaskIds = [...activeGroup.taskIds]; + // Remove task from old position + newTaskIds.splice(activeIndex, 1); + // Insert at new position + newTaskIds.splice(insertIndex, 0, activeId as string); + + dispatch(reorderTasksInGroup({ + taskIds: newTaskIds, + groupId: activeGroup.id, + })); + } + } }, [allTasks, groups]); @@ -233,7 +341,14 @@ const TaskListV2: React.FC = ({ projectId }) => { let currentTaskIndex = 0; return groups.map(group => { const isCurrentGroupCollapsed = collapsedGroups.has(group.id); - const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : allTasks.filter(task => group.taskIds.includes(task.id)); + + // Order tasks according to group.taskIds array to maintain proper order + 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), @@ -258,20 +373,55 @@ const TaskListV2: React.FC = ({ projectId }) => { return virtuosoGroups.flatMap(group => group.tasks); }, [virtuosoGroups]); + // Memoize column headers to prevent unnecessary re-renders + const columnHeaders = useMemo(() => ( +
+ {visibleColumns.map((column) => { + const columnStyle: ColumnStyle = { + width: column.width, + }; + + return ( +
+ {column.id === 'dragHandle' ? ( + + ) : ( + column.label + )} +
+ ); + })} +
+ ), [visibleColumns]); + // Render functions const renderGroup = useCallback((groupIndex: number) => { const group = virtuosoGroups[groupIndex]; + const isGroupEmpty = group.count === 0; + return ( - handleGroupCollapse(group.id)} - /> +
+ handleGroupCollapse(group.id)} + /> + {/* Empty group drop zone */} + {isGroupEmpty && !collapsedGroups.has(group.id) && ( +
+
Drop tasks here
+
+ )} +
); }, [virtuosoGroups, collapsedGroups, handleGroupCollapse]); @@ -289,19 +439,12 @@ const TaskListV2: React.FC = ({ projectId }) => { if (loading) return
Loading...
; if (error) return
Error: {error}
; - // Log data for debugging - console.log('Rendering with:', { - groups, - allTasks, - virtuosoGroups, - virtuosoGroupCounts, - virtuosoItems, - }); - return (
@@ -313,40 +456,13 @@ const TaskListV2: React.FC = ({ projectId }) => { {/* Column Headers */}
-
- {visibleColumns.map((column, index) => { - const columnStyle: ColumnStyle = { - width: column.width, - // Removed sticky functionality to prevent overlap with group headers - // ...(column.isSticky ? { - // position: 'sticky', - // left: index === 0 ? 0 : index === 1 ? 32 : 132, - // backgroundColor: 'inherit', - // zIndex: 2, - // } : {}), - }; - - return ( -
- {column.id === 'dragHandle' ? ( - - ) : ( - column.label - )} -
- ); - })} -
+ {columnHeaders}
{/* Task List */}
task.id)} + items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)} strategy={verticalListSortingStrategy} > = ({ projectId }) => {
{/* Drag Overlay */} - + {activeId ? ( -
-
- - {allTasks.find(task => task.id === activeId)?.name || 'Task'} - +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + 'Task'} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key} +
+
+
) : null} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index eb9e040c..ef84c6cc 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { HolderOutlined } from '@ant-design/icons'; +import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons'; import { Task } from '@/types/task-management.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import Avatar from '@/components/Avatar'; @@ -11,6 +11,8 @@ 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'; +import { useAppSelector } from '@/hooks/useAppSelector'; interface TaskRowProps { task: Task; @@ -30,16 +32,20 @@ const getTaskDisplayName = (task: Task): string => { return DEFAULT_TASK_NAME; }; -const TaskRow: React.FC = ({ task, visibleColumns }) => { +// Memoized date formatter to avoid repeated date parsing +const formatDate = (dateString: string): string => { + try { + return format(new Date(dateString), 'MMM d'); + } catch { + return ''; + } +}; + +// Memoized date formatter to avoid repeated date parsing + +const TaskRow: React.FC = memo(({ task, visibleColumns }) => { // Drag and drop functionality - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, data: { type: 'task', @@ -47,52 +53,97 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { }, }); - const style = { + // Memoize style object to prevent unnecessary re-renders + const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - }; + }), [transform, transition, isDragging]); - // Convert Task to IProjectTask format for AssigneeSelector compatibility - const convertTaskToProjectTask = (task: Task) => { + // Get dark mode from Redux state + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; + + // Memoize task display name + const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]); + + // Memoize converted task for AssigneeSelector to prevent recreation + const convertedTask = useMemo(() => ({ + id: task.id, + name: taskDisplayName, + task_key: task.task_key || taskDisplayName, + assignees: + task.assignee_names?.map((assignee: InlineMember, index: number) => ({ + team_member_id: assignee.team_member_id || `assignee-${index}`, + id: assignee.team_member_id || `assignee-${index}`, + project_member_id: assignee.team_member_id || `assignee-${index}`, + name: assignee.name || '', + })) || [], + parent_task_id: task.parent_task_id, + status_id: undefined, + project_id: undefined, + manual_progress: undefined, + }), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]); + + // 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] + ); + + // 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; + + const visibleLabels = task.labels.slice(0, 2); + const remainingCount = task.labels.length - 2; + return { - id: task.id, - name: getTaskDisplayName(task), - task_key: task.task_key || getTaskDisplayName(task), - assignees: - task.assignee_names?.map((assignee: InlineMember, index: number) => ({ - team_member_id: assignee.team_member_id || `assignee-${index}`, - id: assignee.team_member_id || `assignee-${index}`, - project_member_id: assignee.team_member_id || `assignee-${index}`, - name: assignee.name || '', - })) || [], - parent_task_id: task.parent_task_id, - // Add other required fields with defaults - status_id: undefined, - project_id: undefined, - manual_progress: undefined, // Required field + visibleLabels, + remainingCount: remainingCount > 0 ? remainingCount : null, }; - }; + }, [task.labels]); - const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => { - const baseStyle = { - width, - // Removed sticky functionality to prevent overlap with group headers - // ...(isSticky - // ? { - // position: 'sticky' as const, - // left: index === 0 ? 0 : index === 1 ? 32 : 132, - // backgroundColor: 'inherit', - // zIndex: 1, - // } - // : {}), - }; + const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => { + const baseStyle = { width }; switch (columnId) { case 'dragHandle': return ( -
= ({ task, visibleColumns }) => { return (
- {getTaskDisplayName(task)} + {taskDisplayName}
); @@ -124,10 +175,7 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => {
{task.status} @@ -137,20 +185,16 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'assignees': return (
- {/* Show existing assignee avatars */} - { - - } - {/* Add AssigneeSelector for adding/managing assignees */} +
); @@ -160,12 +204,7 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => {
{task.priority} @@ -175,9 +214,9 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'dueDate': return (
- {task.dueDate && ( + {formattedDueDate && ( - {format(new Date(task.dueDate), 'MMM d')} + {formattedDueDate} )}
@@ -186,21 +225,33 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'progress': return (
-
-
-
+ {task.progress !== undefined && + task.progress >= 0 && + (task.progress === 100 ? ( +
+ +
+ ) : ( + + ))}
); case 'labels': return (
- {task.labels?.slice(0, 2).map((label, index) => ( + {labelsDisplay?.visibleLabels.map((label, index) => ( = ({ task, visibleColumns }) => { {label.name} ))} - {(task.labels?.length || 0) > 2 && ( + {labelsDisplay?.remainingCount && ( - +{task.labels!.length - 2} + +{labelsDisplay.remainingCount} )}
@@ -256,9 +307,9 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'startDate': return (
- {task.startDate && ( + {formattedStartDate && ( - {format(new Date(task.startDate), 'MMM d')} + {formattedStartDate} )}
@@ -267,9 +318,9 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'completedDate': return (
- {task.completedAt && ( + {formattedCompletedDate && ( - {format(new Date(task.completedAt), 'MMM d')} + {formattedCompletedDate} )}
@@ -278,9 +329,9 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'createdDate': return (
- {task.created_at && ( + {formattedCreatedDate && ( - {format(new Date(task.created_at), 'MMM d')} + {formattedCreatedDate} )}
@@ -289,9 +340,9 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { case 'lastUpdated': return (
- {task.updatedAt && ( + {formattedUpdatedDate && ( - {format(new Date(task.updatedAt), 'MMM d')} + {formattedUpdatedDate} )}
@@ -309,10 +360,33 @@ const TaskRow: React.FC = ({ task, visibleColumns }) => { default: return null; } - }; + }, [ + 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, + ]); return ( -
= ({ task, visibleColumns }) => { )}
); -}; +}); + +TaskRow.displayName = 'TaskRow'; export default TaskRow; diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index e94d99a4..e865903d 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -223,6 +223,7 @@ export const fetchTasksV3 = createAsyncThunk( // Log raw response for debugging console.log('Raw API response:', response.body); console.log('Sample task from backend:', response.body.allTasks?.[0]); + console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key); // Ensure tasks are properly normalized const tasks = response.body.allTasks.map((task: any) => { @@ -232,6 +233,7 @@ export const fetchTasksV3 = createAsyncThunk( return { id: task.id, + task_key: task.task_key || task.key || '', title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME, description: task.description || '', status: task.status || 'todo', @@ -327,7 +329,7 @@ export const fetchSubTasks = createAsyncThunk( const config: ITaskListConfigV2 = { id: projectId, archived: false, - group: currentGrouping, + group: currentGrouping || '', field: '', order: '', search: '', @@ -644,7 +646,7 @@ const taskManagementSlice = createSlice({ } else { // Set empty state but don't show error state.ids = []; - state.entities = {}; + state.entities = {} as Record; state.groups = []; } }) @@ -654,7 +656,7 @@ const taskManagementSlice = createSlice({ state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.'; // Clear task data on error to prevent stale state state.ids = []; - state.entities = {}; + state.entities = {} as Record; state.groups = []; }) .addCase(fetchSubTasks.pending, (state, action) => {