diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx new file mode 100644 index 00000000..0982dafa --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx @@ -0,0 +1,491 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import logger from '@/utils/errorLogger'; +import { Task } from '@/types/task-management.types'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; +import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types'; +import { + deleteTask, + fetchTasksV3, + IGroupBy, + toggleTaskExpansion, + updateTaskAssignees, +} from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import { setConvertToSubtaskDrawerOpen } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { + evt_project_task_list_context_menu_archive, + evt_project_task_list_context_menu_assign_me, + evt_project_task_list_context_menu_delete, +} from '@/shared/worklenz-analytics-events'; +import { + DeleteOutlined, + DoubleRightOutlined, + InboxOutlined, + RetweetOutlined, + UserAddOutlined, + LoadingOutlined, +} from '@ant-design/icons'; + +interface TaskContextMenuProps { + task: Task; + projectId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +const TaskContextMenu: React.FC = ({ + task, + projectId, + position, + onClose, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-list-table'); + const { socket, connected } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); + + const { groups: taskGroups } = useAppSelector(state => state.taskManagement); + const statusList = useAppSelector(state => state.taskStatusReducer.status); + const priorityList = useAppSelector(state => state.priorityReducer.priorities); + const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const currentGrouping = useAppSelector(state => state.grouping.currentGrouping); + const archived = useAppSelector(state => state.taskReducer.archived); + + const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false); + + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handleAssignToMe = useCallback(async () => { + if (!projectId || !task.id || !currentSession?.team_member_id) return; + + try { + setUpdatingAssignToMe(true); + + // Immediate UI update - add current user to assignees + const currentUser = { + id: currentSession.team_member_id, + name: currentSession.name || '', + email: currentSession.email || '', + avatar_url: currentSession.avatar_url || '', + team_member_id: currentSession.team_member_id, + }; + + const updatedAssignees = task.assignees || []; + const updatedAssigneeNames = task.assignee_names || []; + + // Check if current user is already assigned + const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id); + + if (!isAlreadyAssigned) { + // Add current user to assignees for immediate UI feedback + const newAssignees = [...updatedAssignees, currentSession.team_member_id]; + const newAssigneeNames = [...updatedAssigneeNames, currentUser]; + + // Update Redux store immediately for instant UI feedback + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: newAssignees, + assigneeNames: newAssigneeNames, + }) + ); + } + + const body: IBulkAssignRequest = { + tasks: [task.id], + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_assign_me); + // Socket event will handle syncing with other users + } + } catch (error) { + logger.error('Error assigning to me:', error); + // Revert the optimistic update on error + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: task.assignees || [], + assigneeNames: task.assignee_names || [], + }) + ); + } finally { + setUpdatingAssignToMe(false); + onClose(); + } + }, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]); + + const handleArchive = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.archiveTasks( + { + tasks: [task.id], + project_id: projectId, + }, + false + ); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_archive); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error archiving task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleDelete = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_delete); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error deleting task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleStatusMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving status:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePriorityMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + priority_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving priority:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePhaseMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { + task_id: task.id, + phase_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }); + } catch (error) { + logger.error('Error moving phase:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const getMoveToOptions = useCallback(() => { + let options: { key: string; label: React.ReactNode; onClick: () => void }[] = []; + + if (currentGrouping === IGroupBy.STATUS) { + options = statusList.filter(status => status.id).map(status => ({ + key: status.id!, + label: ( +
+ + {status.name} +
+ ), + onClick: () => handleStatusMoveTo(status.id!), + })); + } else if (currentGrouping === IGroupBy.PRIORITY) { + options = priorityList.filter(priority => priority.id).map(priority => ({ + key: priority.id!, + label: ( +
+ + {priority.name} +
+ ), + onClick: () => handlePriorityMoveTo(priority.id!), + })); + } else if (currentGrouping === IGroupBy.PHASE) { + options = phaseList.filter(phase => phase.id).map(phase => ({ + key: phase.id!, + label: ( +
+ + {phase.name} +
+ ), + onClick: () => handlePhaseMoveTo(phase.id!), + })); + } + return options; + }, [ + currentGrouping, + statusList, + priorityList, + phaseList, + handleStatusMoveTo, + handlePriorityMoveTo, + handlePhaseMoveTo, + ]); + + const handleConvertToTask = useCallback(async () => { + if (!task?.id || !projectId) return; + + try { + const res = await tasksApiService.convertToTask(task.id as string, projectId as string); + if (res.done) { + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error converting to task', error); + } finally { + onClose(); + } + }, [task?.id, projectId, dispatch, onClose]); + + const menuItems = useMemo(() => { + const items = [ + { + key: 'assignToMe', + label: ( + + ), + }, + ]; + + // Add Move To submenu if there are options + const moveToOptions = getMoveToOptions(); + if (moveToOptions.length > 0) { + items.push({ + key: 'moveTo', + label: ( +
+ +
    + {moveToOptions.map(option => ( +
  • + +
  • + ))} +
+
+ ), + }); + } + + // Add Archive/Unarchive for parent tasks only + if (!task?.parent_task_id) { + items.push({ + key: 'archive', + label: ( + + ), + }); + } + + // Add Convert to Sub Task for parent tasks with no subtasks + if (task?.sub_tasks_count === 0 && !task?.parent_task_id) { + items.push({ + key: 'convertToSubTask', + label: ( + + ), + }); + } + + // Add Convert to Task for subtasks + if (task?.parent_task_id) { + items.push({ + key: 'convertToTask', + label: ( + + ), + }); + } + + // Add Delete + items.push({ + key: 'delete', + label: ( + + ), + }); + + return items; + }, [ + task, + projectId, + updatingAssignToMe, + archived, + handleAssignToMe, + handleArchive, + handleDelete, + handleConvertToTask, + getMoveToOptions, + dispatch, + t, + ]); + + return ( +
+
    + {menuItems.map(item => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +}; + +export default TaskContextMenu; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx index a005a1c4..1fc5ae38 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; import { Input, Tooltip } from 'antd'; import type { InputRef } from 'antd'; +import { createPortal } from 'react-dom'; import { Task } from '@/types/task-management.types'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice'; @@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useTranslation } from 'react-i18next'; import { getTaskDisplayName } from './TaskRowColumns'; +import TaskContextMenu from './TaskContextMenu'; interface TitleColumnProps { width: string; @@ -41,6 +43,10 @@ export const TitleColumn: React.FC = memo(({ const { t } = useTranslation('task-list-table'); const inputRef = useRef(null); const wrapperRef = useRef(null); + + // Context menu state + const [contextMenuVisible, setContextMenuVisible] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); // Handle task expansion toggle const handleToggleExpansion = useCallback((e: React.MouseEvent) => { @@ -71,6 +77,24 @@ export const TitleColumn: React.FC = memo(({ onEditTaskName(false); }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]); + // Handle context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Use clientX and clientY directly for fixed positioning + setContextMenuPosition({ + x: e.clientX, + y: e.clientY + }); + setContextMenuVisible(true); + }, []); + + // Handle context menu close + const handleContextMenuClose = useCallback(() => { + setContextMenuVisible(false); + }, []); + // Handle click outside for task name editing useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -169,6 +193,7 @@ export const TitleColumn: React.FC = memo(({ e.preventDefault(); onEditTaskName(true); }} + onContextMenu={handleContextMenu} title={taskDisplayName} > {taskDisplayName} @@ -251,6 +276,17 @@ export const TitleColumn: React.FC = memo(({ )} + + {/* Context Menu */} + {contextMenuVisible && createPortal( + , + document.body + )} ); }); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index 5f250577..255bbf78 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{ columnKey: string; updateValue: (taskId: string, columnKey: string, value: string) => void; }> = ({ selectionsList, value, task, columnKey, updateValue }) => { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, selectionsList, loggedInfo]); return ( { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList: columnObj?.selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, loggedInfo]); - return ( = ({ taskList, tableId, active const activeTask = displayTasks.find(task => task.id === active.id); if (!activeTask) { - console.error('Active task not found:', { - activeId: active.id, - displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })), - }); return; } - console.log('Found activeTask:', { - id: activeTask.id, - name: activeTask.name, - status_id: activeTask.status_id, - status: activeTask.status, - priority: activeTask.priority, - project_id: project?.id, - team_id: project?.team_id, - fullProject: project, - }); - // Use the tableId directly as the group ID (it should be the group ID) const currentGroupId = tableId; - console.log('Drag operation:', { - activeId: active.id, - overId: over.id, - tableId, - currentGroupId, - displayTasksLength: displayTasks.length, - }); - // Check if this is a reorder within the same group const overTask = displayTasks.find(task => task.id === over.id); if (overTask) { @@ -1686,36 +1639,17 @@ const TaskListTable: React.FC = ({ taskList, tableId, active const oldIndex = displayTasks.findIndex(task => task.id === active.id); const newIndex = displayTasks.findIndex(task => task.id === over.id); - console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name }); - if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) { // Get the actual sort_order values from the tasks const fromSortOrder = activeTask.sort_order || oldIndex; const overTaskAtNewIndex = displayTasks[newIndex]; const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex; - console.log('Sort order details:', { - oldIndex, - newIndex, - fromSortOrder, - toSortOrder, - activeTaskSortOrder: activeTask.sort_order, - overTaskSortOrder: overTaskAtNewIndex?.sort_order, - }); - // Create updated task list with reordered tasks const updatedTasks = [...displayTasks]; const [movedTask] = updatedTasks.splice(oldIndex, 1); updatedTasks.splice(newIndex, 0, movedTask); - console.log('Dispatching reorderTasks with:', { - activeGroupId: currentGroupId, - overGroupId: currentGroupId, - fromIndex: oldIndex, - toIndex: newIndex, - taskName: activeTask.name, - }); - // Update local state immediately for better UX dispatch( reorderTasks({ @@ -1758,34 +1692,10 @@ const TaskListTable: React.FC = ({ taskList, tableId, active // Validate required fields before sending if (!body.task.id) { - console.error('Cannot send socket event: task.id is missing', { activeTask, active }); return; } - console.log('Validated values:', { - from_index: body.from_index, - to_index: body.to_index, - status: body.task.status, - priority: body.task.priority, - team_id: body.team_id, - originalStatus: activeTask.status_id || activeTask.status, - originalPriority: activeTask.priority, - originalTeamId: project.team_id, - sessionTeamId: currentSession?.team_id, - finalTeamId: body.team_id, - }); - - console.log('Sending socket event:', body); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); - } else { - console.error('Cannot send socket event: missing required data', { - hasSocket: !!socket, - hasProjectId: !!project?.id, - hasActiveId: !!active.id, - hasActiveTaskId: !!activeTask.id, - activeTask, - active, - }); } } }