diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 650b4b6f..d92a4ca0 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -15,6 +15,8 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { updateTask } from '@/features/task-management/task-management.slice'; import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { updateTaskAssignees } from '@/features/task-management/task-management.slice'; +import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; interface AssigneeSelectorProps { task: IProjectTask; @@ -33,6 +35,12 @@ const AssigneeSelector: React.FC = ({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); const [optimisticAssignees, setOptimisticAssignees] = useState([]); // For optimistic updates const [pendingChanges, setPendingChanges] = useState>(new Set()); // Track pending member changes + + // Initialize optimistic assignees from task data on mount or when task changes + useEffect(() => { + const currentAssigneeIds = task?.assignees?.map(a => a.team_member_id) || []; + setOptimisticAssignees(currentAssigneeIds); + }, [task?.assignees]); const dropdownRef = useRef(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); @@ -123,11 +131,14 @@ const AssigneeSelector: React.FC = ({ if (!isOpen) { updateDropdownPosition(); - // Prepare team members data when opening - const assignees = task?.assignees?.map(assignee => assignee.team_member_id); - const membersData = (members?.data || []).map(member => ({ + // Prepare team members data when opening - use optimistic assignees for current state + const currentAssigneeIds = optimisticAssignees.length > 0 + ? optimisticAssignees + : task?.assignees?.map(assignee => assignee.team_member_id) || []; + + const membersData: (ITeamMembersViewModel & { selected?: boolean })[] = (members?.data || []).map(member => ({ ...member, - selected: assignees?.includes(member.id), + selected: currentAssigneeIds.includes(member.id), })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); @@ -148,16 +159,20 @@ const AssigneeSelector: React.FC = ({ // Add to pending changes for visual feedback setPendingChanges(prev => new Set(prev).add(memberId)); - // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback - const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; + // Get the current list of assignees, prioritizing optimistic updates for immediate feedback + const currentAssigneeIds = optimisticAssignees.length > 0 + ? optimisticAssignees + : task?.assignees?.map(a => a.team_member_id) || []; + let newAssigneeIds: string[]; if (checked) { - // Adding assignee - newAssigneeIds = [...currentAssignees, memberId]; + // Adding assignee: ensure no duplicates + const uniqueIds = new Set([...currentAssigneeIds, memberId]); + newAssigneeIds = Array.from(uniqueIds); } else { // Removing assignee - newAssigneeIds = currentAssignees.filter(id => id !== memberId); + newAssigneeIds = currentAssigneeIds.filter(id => id !== memberId); } // Update optimistic state for immediate UI feedback in dropdown @@ -183,13 +198,31 @@ const AssigneeSelector: React.FC = ({ // Emit socket event - the socket handler will update Redux with proper types socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => { - dispatch(updateEnhancedKanbanTaskAssignees(data)); + // Instead of updating enhancedKanbanSlice, update the main taskManagementSlice + // Filter members to get the actual InlineMember objects for the new assignees + const updatedAssigneeNames: InlineMember[] = (members?.data || []) + .filter((member): member is ITeamMemberViewModel & { id: string; name: string } => { + return typeof member.id === 'string' && typeof member.name === 'string' && newAssigneeIds.includes(member.id); + }) + .map(member => ({ + name: member.name || '', + id: member.id || '', + team_member_id: member.id || '', + avatar_url: member.avatar_url || '', + color_code: member.color_code || '', + })); + + dispatch(updateTaskAssignees({ + taskId: task.id || '', + assigneeIds: newAssigneeIds, + assigneeNames: updatedAssigneeNames, + })); }); // Remove from pending changes after a short delay (optimistic) setTimeout(() => { setPendingChanges(prev => { - const newSet = new Set(prev); + const newSet = new Set(Array.from(prev)); newSet.delete(memberId); return newSet; }); @@ -198,12 +231,8 @@ const AssigneeSelector: React.FC = ({ const checkMemberSelected = (memberId: string) => { if (!memberId) return false; - // Use optimistic assignees if available, otherwise fall back to task assignees - const assignees = - optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(assignee => assignee.team_member_id) || []; - return assignees.includes(memberId); + // Always use optimistic assignees for dropdown display + return optimisticAssignees.includes(memberId); }; const handleInviteProjectMemberDrawer = () => { diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 4048996d..6a0d5943 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,7 +1,12 @@ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { Checkbox } from 'antd'; import { getContrastColor } from '@/utils/colorUtils'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; +import { selectGroups } from '@/features/task-management/task-management.slice'; interface TaskGroupHeaderProps { group: { @@ -15,9 +20,52 @@ interface TaskGroupHeaderProps { } const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle }) => { + const dispatch = useAppDispatch(); + const selectedTaskIds = useAppSelector(selectSelectedTaskIds); + const groups = useAppSelector(selectGroups); + const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerTextColor = getContrastColor(headerBackgroundColor); + // Get tasks in this group + const currentGroup = useMemo(() => { + return groups.find(g => g.id === group.id); + }, [groups, group.id]); + + const tasksInGroup = useMemo(() => { + return currentGroup?.taskIds || []; + }, [currentGroup]); + + // Calculate selection state for this group + const { isAllSelected, isPartiallySelected } = useMemo(() => { + if (tasksInGroup.length === 0) { + return { isAllSelected: false, isPartiallySelected: false }; + } + + const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId)); + const allSelected = selectedTasksInGroup.length === tasksInGroup.length; + const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length; + + return { isAllSelected: allSelected, isPartiallySelected: partiallySelected }; + }, [tasksInGroup, selectedTaskIds]); + + // Handle select all checkbox change + const handleSelectAllChange = useCallback((e: any) => { + e.stopPropagation(); + + if (isAllSelected) { + // Deselect all tasks in this group + tasksInGroup.forEach(taskId => { + dispatch(deselectTask(taskId)); + }); + } else { + // Select all tasks in this group + tasksInGroup.forEach(taskId => { + dispatch(selectTask(taskId)); + }); + } + }, [dispatch, isAllSelected, tasksInGroup]); + // Make the group header droppable const { isOver, setNodeRef } = useDroppable({ id: group.id, @@ -42,21 +90,37 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }} onClick={onToggle} > - {/* Chevron button */} - + {/* Drag Handle Space */} +
+ {/* Chevron button */} + +
+ + {/* Select All Checkbox Space */} +
+ e.stopPropagation()} + style={{ + color: headerTextColor, + }} + /> +
{/* Group indicator and name */}
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 43b3ed3e..61bfb246 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -54,6 +54,7 @@ import { RootState } from '@/app/store'; import { TaskListField } from '@/types/task-list-field.types'; import { useParams } from 'react-router-dom'; import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; +import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; import { Bars3Icon } from '@heroicons/react/24/outline'; import { HolderOutlined } from '@ant-design/icons'; import { COLUMN_KEYS } from '@/features/tasks/tasks.slice'; @@ -61,6 +62,7 @@ import { COLUMN_KEYS } from '@/features/tasks/tasks.slice'; // Base column configuration 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: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS }, @@ -86,6 +88,7 @@ type ColumnStyle = { left?: number; backgroundColor?: string; zIndex?: number; + flexShrink?: number; }; interface TaskListV2Props { @@ -336,6 +339,66 @@ const TaskListV2: React.FC = ({ projectId }) => { }, [allTasks, groups]); + // Bulk action handlers + const handleClearSelection = useCallback(() => { + dispatch(clearSelection()); + }, [dispatch]); + + const handleBulkStatusChange = useCallback(async (statusId: string) => { + // TODO: Implement bulk status change + console.log('Bulk status change:', statusId); + }, []); + + const handleBulkPriorityChange = useCallback(async (priorityId: string) => { + // TODO: Implement bulk priority change + console.log('Bulk priority change:', priorityId); + }, []); + + const handleBulkPhaseChange = useCallback(async (phaseId: string) => { + // TODO: Implement bulk phase change + console.log('Bulk phase change:', phaseId); + }, []); + + const handleBulkAssignToMe = useCallback(async () => { + // TODO: Implement bulk assign to me + console.log('Bulk assign to me'); + }, []); + + const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => { + // TODO: Implement bulk assign members + console.log('Bulk assign members:', memberIds); + }, []); + + const handleBulkAddLabels = useCallback(async (labelIds: string[]) => { + // TODO: Implement bulk add labels + console.log('Bulk add labels:', labelIds); + }, []); + + const handleBulkArchive = useCallback(async () => { + // TODO: Implement bulk archive + console.log('Bulk archive'); + }, []); + + const handleBulkDelete = useCallback(async () => { + // TODO: Implement bulk delete + console.log('Bulk delete'); + }, []); + + const handleBulkDuplicate = useCallback(async () => { + // TODO: Implement bulk duplicate + console.log('Bulk duplicate'); + }, []); + + const handleBulkExport = useCallback(async () => { + // TODO: Implement bulk export + console.log('Bulk export'); + }, []); + + const handleBulkSetDueDate = useCallback(async (date: string) => { + // TODO: Implement bulk set due date + console.log('Bulk set due date:', date); + }, []); + // Memoized values for GroupedVirtuoso const virtuosoGroups = useMemo(() => { let currentTaskIndex = 0; @@ -375,10 +438,11 @@ const TaskListV2: React.FC = ({ projectId }) => { // Memoize column headers to prevent unnecessary re-renders const columnHeaders = useMemo(() => ( -
+
{visibleColumns.map((column) => { const columnStyle: ColumnStyle = { width: column.width, + flexShrink: 0, // Prevent columns from shrinking }; return ( @@ -389,6 +453,8 @@ const TaskListV2: React.FC = ({ projectId }) => { > {column.id === 'dragHandle' ? ( + ) : column.id === 'checkbox' ? ( + // Empty for checkbox column header ) : ( column.label )} @@ -430,7 +496,7 @@ const TaskListV2: React.FC = ({ projectId }) => { if (!task) return null; // Should not happen if logic is correct return ( ); @@ -453,37 +519,39 @@ const TaskListV2: React.FC = ({ projectId }) => {
- {/* Column Headers */} -
-
- {columnHeaders} -
+ {/* Table Container with synchronized horizontal scrolling */} +
+
+ {/* Column Headers - Fixed at top */} +
+ {columnHeaders} +
- {/* Task List */} -
- task.id).filter((id): id is string => id !== undefined)} - strategy={verticalListSortingStrategy} - > - (({ style, children }, ref) => ( -
- {children} -
- )), - }} - /> -
+ {/* Task List - Scrollable content */} +
+ task.id).filter((id): id is string => id !== undefined)} + strategy={verticalListSortingStrategy} + > + (({ style, children }, ref) => ( +
+ {children} +
+ )), + }} + /> +
+
@@ -509,6 +577,27 @@ const TaskListV2: React.FC = ({ projectId }) => {
) : null} + + {/* Bulk Action Bar */} + {selectedTaskIds.length > 0 && ( + + )}
); diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index ef84c6cc..31f4951d 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } 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 { Task } from '@/types/task-management.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import Avatar from '@/components/Avatar'; @@ -13,9 +14,12 @@ 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'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { selectTaskById } from '@/features/task-management/task-management.slice'; +import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice'; interface TaskRowProps { - task: Task; + taskId: string; visibleColumns: Array<{ id: string; width: string; @@ -43,7 +47,15 @@ const formatDate = (dateString: string): string => { // Memoized date formatter to avoid repeated date parsing -const TaskRow: React.FC = memo(({ task, visibleColumns }) => { +const TaskRow: React.FC = memo(({ taskId, visibleColumns }) => { + const dispatch = useAppDispatch(); + const task = useAppSelector(state => selectTaskById(state, taskId)); + const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); + + if (!task) { + return null; // Don't render if task is not found in store + } + // Drag and drop functionality const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, @@ -111,6 +123,17 @@ const TaskRow: React.FC = memo(({ task, visibleColumns }) => { [task.updatedAt] ); + // 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]); + + // Handle checkbox change + const handleCheckboxChange = useCallback((e: any) => { + e.stopPropagation(); // Prevent row click when clicking checkbox + dispatch(toggleTaskSelection(taskId)); + }, [dispatch, taskId]); + // Memoize status style const statusStyle = useMemo(() => ({ backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)', @@ -152,6 +175,17 @@ const TaskRow: React.FC = memo(({ task, visibleColumns }) => {
); + case 'checkbox': + return ( +
+ e.stopPropagation()} + /> +
+ ); + case 'taskKey': return (
@@ -383,13 +417,15 @@ const TaskRow: React.FC = memo(({ task, visibleColumns }) => { labelsDisplay, isDarkMode, convertedTask, + isSelected, + handleCheckboxChange, ]); return (
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 e865903d..52bf7f1a 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -15,6 +15,7 @@ import { } from '@/api/tasks/tasks.api.service'; import logger from '@/utils/errorLogger'; import { DEFAULT_TASK_NAME } from '@/shared/constants'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; // Helper function to safely convert time values const convertTimeValue = (value: any): number => { @@ -163,6 +164,19 @@ export const fetchTasks = createAsyncThunk( createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), order: typeof task.sort_order === 'number' ? task.sort_order : 0, + // Ensure all Task properties are mapped, even if undefined in API response + sub_tasks: task.sub_tasks || [], + sub_tasks_count: task.sub_tasks_count || 0, + show_sub_tasks: task.show_sub_tasks || false, + parent_task_id: task.parent_task_id || undefined, + weight: task.weight || 0, + color: task.color || undefined, + statusColor: task.statusColor || undefined, + priorityColor: task.priorityColor || undefined, + comments_count: task.comments_count || 0, + attachments_count: task.attachments_count || 0, + has_dependencies: task.has_dependencies || false, + schedule_id: task.schedule_id || null, })) ); @@ -226,11 +240,9 @@ export const fetchTasksV3 = createAsyncThunk( 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) => { + const tasks: Task[] = response.body.allTasks.map((task: any) => { const now = new Date().toISOString(); - - return { id: task.id, task_key: task.task_key || task.key || '', @@ -249,59 +261,33 @@ export const fetchTasksV3 = createAsyncThunk( end: l.end, names: l.names, })) || [], - due_date: task.end_date || '', + dueDate: task.end_date, timeTracking: { estimated: convertTimeValue(task.total_time), logged: convertTimeValue(task.time_spent), }, - created_at: task.created_at || now, - updated_at: task.updated_at || now, + customFields: {}, + createdAt: task.created_at || now, + updatedAt: task.updated_at || now, order: typeof task.sort_order === 'number' ? task.sort_order : 0, sub_tasks: task.sub_tasks || [], sub_tasks_count: task.sub_tasks_count || 0, show_sub_tasks: task.show_sub_tasks || false, - parent_task_id: task.parent_task_id || '', + parent_task_id: task.parent_task_id || undefined, weight: task.weight || 0, - color: task.color || '', - statusColor: task.status_color || '', - priorityColor: task.priority_color || '', + color: task.color || undefined, + statusColor: task.statusColor || undefined, + priorityColor: task.priorityColor || undefined, comments_count: task.comments_count || 0, attachments_count: task.attachments_count || 0, - has_dependencies: !!task.has_dependencies, + has_dependencies: task.has_dependencies || false, schedule_id: task.schedule_id || null, - } as Task; - }); - - // Map groups to match TaskGroup interface - const mappedGroups = response.body.groups.map((group: any) => ({ - id: group.id, - title: group.title, - taskIds: group.taskIds || [], - type: group.groupType as 'status' | 'priority' | 'phase' | 'members', - color: group.color, - })); - - // Log normalized data for debugging - console.log('Normalized data:', { - tasks, - groups: mappedGroups, - grouping: response.body.grouping, - totalTasks: response.body.totalTasks, - }); - - // Verify task IDs match group taskIds - const taskIds = new Set(tasks.map(t => t.id)); - const groupTaskIds = new Set(mappedGroups.flatMap(g => g.taskIds)); - console.log('Task ID verification:', { - taskIds: Array.from(taskIds), - groupTaskIds: Array.from(groupTaskIds), - allTaskIdsInGroups: Array.from(groupTaskIds).every(id => taskIds.has(id)), - allGroupTaskIdsInTasks: Array.from(taskIds).every(id => groupTaskIds.has(id)), + }; }); return { - tasks: tasks, - groups: mappedGroups, + allTasks: tasks, + groups: response.body.groups, grouping: response.body.grouping, totalTasks: response.body.totalTasks, }; @@ -310,7 +296,7 @@ export const fetchTasksV3 = createAsyncThunk( if (error instanceof Error) { return rejectWithValue(error.message); } - return rejectWithValue('Failed to fetch tasks'); + return rejectWithValue('Failed to fetch tasks V3'); } } ); @@ -471,8 +457,24 @@ const taskManagementSlice = createSlice({ } }, updateTask: (state, action: PayloadAction) => { - const task = action.payload; - state.entities[task.id] = task; + tasksAdapter.upsertOne(state as EntityState, action.payload); + // Additionally, update the task within its group if necessary (e.g., if status changed) + const updatedTask = action.payload; + const oldTask = state.entities[updatedTask.id]; + + if (oldTask && state.grouping?.id === IGroupBy.STATUS && oldTask.status !== updatedTask.status) { + // Remove from old status group + const oldGroup = state.groups.find(group => group.id === oldTask.status); + if (oldGroup) { + oldGroup.taskIds = oldGroup.taskIds.filter(id => id !== updatedTask.id); + } + + // Add to new status group + const newGroup = state.groups.find(group => group.id === updatedTask.status); + if (newGroup) { + newGroup.taskIds.push(updatedTask.id); + } + } }, deleteTask: (state, action: PayloadAction) => { const taskId = action.payload; @@ -556,13 +558,101 @@ const taskManagementSlice = createSlice({ }, reorderTasksInGroup: ( state, - action: PayloadAction<{ taskIds: string[]; groupId: string }> + action: PayloadAction<{ + sourceTaskId: string; + destinationTaskId: string; + sourceGroupId: string; + destinationGroupId: string; + }> ) => { - const { taskIds, groupId } = action.payload; - const group = state.groups.find(g => g.id === groupId); - if (group) { - group.taskIds = taskIds; + const { sourceTaskId, destinationTaskId, sourceGroupId, destinationGroupId } = action.payload; + + // Get a mutable copy of entities for updates + const newEntities = { ...state.entities }; + + const sourceTask = newEntities[sourceTaskId]; + const destinationTask = newEntities[destinationTaskId]; + + if (!sourceTask || !destinationTask) return; + + if (sourceGroupId === destinationGroupId) { + // Reordering within the same group + const group = state.groups.find(g => g.id === sourceGroupId); + if (group) { + const newTasks = Array.from(group.taskIds); + const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); + newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); + group.taskIds = newTasks; + + // Update order for affected tasks. Assuming simple reordering affects order. + // This might need more sophisticated logic based on how `order` is used. + newTasks.forEach((id, index) => { + if (newEntities[id]) { + newEntities[id] = { ...newEntities[id], order: index }; + } + }); + } + } else { + // Moving between different groups + const sourceGroup = state.groups.find(g => g.id === sourceGroupId); + const destinationGroup = state.groups.find(g => g.id === destinationGroupId); + + if (sourceGroup && destinationGroup) { + // Remove from source group + sourceGroup.taskIds = sourceGroup.taskIds.filter(id => id !== sourceTaskId); + + // Add to destination group at the correct position relative to destinationTask + const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId); + if (destinationIndex !== -1) { + destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId); + } else { + destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found + } + + // Update task's grouping field to reflect new group (e.g., status, priority, phase) + // This assumes the group ID directly corresponds to the task's field value + if (sourceTask) { + let updatedTask = { ...sourceTask }; + switch (state.grouping?.id) { + case IGroupBy.STATUS: + updatedTask.status = destinationGroup.id; + break; + case IGroupBy.PRIORITY: + updatedTask.priority = destinationGroup.id; + break; + case IGroupBy.PHASE: + updatedTask.phase = destinationGroup.id; + break; + case IGroupBy.MEMBERS: + // If moving to a member group, ensure task is assigned to that member + // This assumes the group ID is the member ID + if (!updatedTask.assignees) { + updatedTask.assignees = []; + } + if (!updatedTask.assignees.includes(destinationGroup.id)) { + updatedTask.assignees.push(destinationGroup.id); + } + // If moving from a member group, and the task is no longer in any member group, + // consider removing the assignment (more complex logic might be needed here) + break; + default: + break; + } + newEntities[sourceTaskId] = updatedTask; + } + + // Update order for affected tasks in both groups if necessary + sourceGroup.taskIds.forEach((id, index) => { + if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + }); + destinationGroup.taskIds.forEach((id, index) => { + if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + }); + } } + + // Update the state's entities after all modifications + state.entities = newEntities; }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -608,6 +698,22 @@ const taskManagementSlice = createSlice({ parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1; } }, + updateTaskAssignees: (state, action: PayloadAction<{ + taskId: string; + assigneeIds: string[]; + assigneeNames: InlineMember[]; + }>) => { + const { taskId, assigneeIds, assigneeNames } = action.payload; + const existingTask = state.entities[taskId]; + + if (existingTask) { + state.entities[taskId] = { + ...existingTask, + assignees: assigneeIds, + assignee_names: assigneeNames, + }; + } + }, }, extraReducers: builder => { builder @@ -617,46 +723,17 @@ const taskManagementSlice = createSlice({ }) .addCase(fetchTasksV3.fulfilled, (state, action) => { state.loading = false; - state.error = null; - - // Ensure we have tasks before updating state - if (action.payload.tasks && action.payload.tasks.length > 0) { - // Update tasks - const tasks = action.payload.tasks; - state.ids = tasks.map(task => task.id); - state.entities = tasks.reduce((acc, task) => { - acc[task.id] = task; - return acc; - }, {} as Record); - - // Update groups - state.groups = action.payload.groups; - state.grouping = action.payload.grouping; - - // Verify task IDs match group taskIds - const taskIds = new Set(Object.keys(state.entities)); - const groupTaskIds = new Set(state.groups.flatMap(g => g.taskIds)); - - // Ensure all tasks have IDs and all group taskIds exist - const validTaskIds = new Set(Object.keys(state.entities)); - state.groups = state.groups.map((group: TaskGroup) => ({ - ...group, - taskIds: group.taskIds.filter((id: string) => validTaskIds.has(id)), - })); - } else { - // Set empty state but don't show error - state.ids = []; - state.entities = {} as Record; - state.groups = []; - } + const { allTasks, groups, grouping } = action.payload; + tasksAdapter.setAll(state as EntityState, allTasks || []); // Ensure allTasks is an array + state.ids = (allTasks || []).map(task => task.id); // Also update ids + state.groups = groups; + state.grouping = grouping; }) .addCase(fetchTasksV3.rejected, (state, action) => { state.loading = false; - // Provide a more descriptive error message - 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.error = action.error?.message || (action.payload as string) || 'Failed to load tasks (V3)'; state.ids = []; - state.entities = {} as Record; + state.entities = {}; state.groups = []; }) .addCase(fetchSubTasks.pending, (state, action) => { @@ -675,6 +752,24 @@ const taskManagementSlice = createSlice({ .addCase(fetchSubTasks.rejected, (state, action) => { // Set error but don't clear task data state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.'; + }) + .addCase(fetchTasks.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.loading = false; + tasksAdapter.setAll(state as EntityState, action.payload || []); // Ensure payload is an array + state.ids = (action.payload || []).map(task => task.id); // Also update ids + state.groups = []; // Assuming no groups when using old fetchTasks + state.grouping = undefined; // Assuming no grouping when using old fetchTasks + }) + .addCase(fetchTasks.rejected, (state, action) => { + state.loading = false; + state.error = action.error?.message || (action.payload as string) || 'Failed to load tasks'; + state.ids = []; + state.entities = {}; + state.groups = []; }); }, }); @@ -700,6 +795,7 @@ export const { resetTaskManagement, toggleTaskExpansion, addSubtaskToParent, + updateTaskAssignees, } = taskManagementSlice.actions; // Export the selectors diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 708ebf20..0c82492f 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -11,6 +11,7 @@ export interface Task { priority: string; phase?: string; assignee?: string; + assignees?: string[]; // Array of assigned member IDs assignee_names?: InlineMember[]; // Array of assigned members names?: InlineMember[]; // Alternative names field due_date?: string;