diff --git a/worklenz-frontend/src/components/task-management/task-context-menu/task-context-menu.tsx b/worklenz-frontend/src/components/task-management/task-context-menu/task-context-menu.tsx new file mode 100644 index 00000000..b42fa431 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-context-menu/task-context-menu.tsx @@ -0,0 +1,449 @@ +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 { IProjectTask } from '@/types/project/projectTasksViewModel.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, + fetchTaskAssignees, + fetchTasksV3, + IGroupBy, + toggleTaskExpansion, +} 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 { + DeleteOutlined, + DoubleRightOutlined, + InboxOutlined, + RetweetOutlined, + UserAddOutlined, +} from '@/shared/antd-imports'; + +interface TaskContextMenuProps { + task: IProjectTask; + projectId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +const TaskContextMenu: React.FC = ({ + task, + projectId, + position, + onClose, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-management'); + const { socket, connected } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + + 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 [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) return; + + try { + setUpdatingAssignToMe(true); + const body: IBulkAssignRequest = { + tasks: [task.id], + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + // No need to manually update assignees here, socket event will handle it + // dispatch(fetchTasksV3(projectId)); // Re-fetch tasks to update UI + } + } catch (error) { + logger.error('Error assigning to me:', error); + } finally { + setUpdatingAssignToMe(false); + onClose(); + } + }, [projectId, task.id, onClose]); + + const handleArchive = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.archiveTasks( + { + tasks: [task.id], + project_id: projectId, + }, + task.archived || false + ); + + if (res.done) { + dispatch(deleteTask({ taskId: 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, task.archived, dispatch, socket, onClose]); + + const handleDelete = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId); + + if (res.done) { + dispatch(deleteTask({ taskId: 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]); + + 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.map(status => ({ + key: status.id, + label: ( +
+ + {status.name} +
+ ), + onClick: () => handleStatusMoveTo(status.id), + })); + } else if (currentGrouping === IGroupBy.PRIORITY) { + options = priorityList.map(priority => ({ + key: priority.id, + label: ( +
+ + {priority.name} +
+ ), + onClick: () => handlePriorityMoveTo(priority.id), + })); + } else if (currentGrouping === IGroupBy.PHASE) { + options = phaseList.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: ( + + ), + }, + { + key: 'moveTo', + label: ( +
+ +
    + {getMoveToOptions().map(option => ( +
  • + +
  • + ))} +
+
+ ), + }, + ]; + + if (!task?.parent_task_id) { + items.push({ + key: 'archive', + label: ( + + ), + }); + } + + if (task?.sub_tasks_count === 0 && !task?.parent_task_id) { + items.push({ + key: 'convertToSubTask', + label: ( + + ), + }); + } + + if (task?.parent_task_id) { + items.push({ + key: 'convertToTask', + label: ( + + ), + }); + } + + items.push({ + key: 'delete', + label: ( + + ), + }); + + return items; + }, [ + task, + projectId, + updatingAssignToMe, + handleAssignToMe, + handleArchive, + handleDelete, + handleConvertToTask, + getMoveToOptions, + dispatch, + t, + ]); + + return ( +
+
    + {menuItems.map(item => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +}; + +export default TaskContextMenu; diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index 66725261..b5433891 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -1236,6 +1236,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' --task-selected-border: #1890ff; --task-drag-over-bg: #f0f8ff; --task-drag-over-border: #40a9ff; + --task-border-hover-top: #c0c0c0; /* Slightly darker for visibility */ } .dark .task-groups-container-fixed, @@ -1257,6 +1258,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' --task-selected-border: #1890ff; --task-drag-over-bg: #1a2332; --task-drag-over-border: #40a9ff; + --task-border-hover-top-dark: #505050; /* Slightly darker for visibility in dark mode */ } /* Dark mode scrollbar */ diff --git a/worklenz-frontend/src/components/task-management/task-row-optimized.css b/worklenz-frontend/src/components/task-management/task-row-optimized.css index c2a97cc9..729398d1 100644 --- a/worklenz-frontend/src/components/task-management/task-row-optimized.css +++ b/worklenz-frontend/src/components/task-management/task-row-optimized.css @@ -94,8 +94,9 @@ /* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */ .task-row-optimized:hover { - /* Remove transform that was causing GPU conflicts */ - /* Remove complex containment rules */ + background-color: var(--task-hover-bg, #fafafa); + border-color: var(--task-border-primary, #e8e8e8); + border-top-color: var(--task-border-hover-top, #c0c0c0); /* Ensure top border is visible */ } /* OPTIMIZED HOVER BUTTONS: Use opacity only, no visibility changes */ @@ -284,16 +285,27 @@ } /* Dark mode optimizations */ +:root { + /* ... existing variables ... */ + --task-border-hover-top: #c0c0c0; /* Slightly darker for visibility */ +} + .dark .task-row-optimized { - contain: layout style; - background: var(--task-bg-primary, #1f1f1f); - color: var(--task-text-primary, #fff); - border-color: var(--task-border-primary, #303030); + /* ... existing variables ... */ +} + +.dark { + /* ... existing variables ... */ + --task-border-hover-top-dark: #505050; /* Slightly darker for visibility in dark mode */ } .dark .task-row-optimized:hover { - contain: layout style; - /* Remove complex containment rules */ + background-color: var(--task-hover-bg, #2a2a2a); + border-color: var(--task-border-primary, #303030); + border-top-color: var( + --task-border-hover-top-dark, + #505050 + ); /* Ensure top border is visible in dark mode */ } /* Animation performance */ diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 41b9ea11..ee91fc3f 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -55,6 +55,7 @@ import { fetchTask, } from '@/features/task-drawer/task-drawer.slice'; import useDragCursor from '@/hooks/useDragCursor'; +import TaskContextMenu from './task-context-menu/task-context-menu'; interface TaskRowProps { task: Task; @@ -429,7 +430,9 @@ const TaskRow: React.FC = React.memo( const addSubtaskInputRef = useRef(null); const wrapperRef = useRef(null); - // Subtask expansion state (managed by Redux) + // Context menu state + const [showContextMenu, setShowContextMenu] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); // PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading useEffect(() => { @@ -572,6 +575,11 @@ const TaskRow: React.FC = React.memo( onToggleSubtasks?.(task.id); }, [task.id, onToggleSubtasks]); + const handleContextMenu = useCallback((e: React.MouseEvent) => { + setShowContextMenu(true); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + }, []); + // Handle successful subtask creation const handleSubtaskCreated = useCallback( (newTask: any) => { @@ -1007,6 +1015,54 @@ const TaskRow: React.FC = React.memo( )} + {/* Indicators section */} + {!editTaskName && ( +
+ {/* Comments indicator */} + {(task as any).comments_count > 0 && ( + + + + )} + {/* Attachments indicator */} + {(task as any).attachments_count > 0 && ( + + + + )} + {/* Dependencies indicator */} + {(task as any).has_dependencies && ( + + + + )} + {/* Subscribers indicator */} + {(task as any).has_subscribers && ( + + + + )} + {/* Recurring indicator */} + {(task as any).schedule_id && ( + + + + )} +
+ )} + + )} + )} @@ -1056,6 +1112,8 @@ const TaskRow: React.FC = React.memo( )} )} + + )} {/* Right section with open button - CSS hover only */} @@ -1478,6 +1536,7 @@ const TaskRow: React.FC = React.memo( data-dnd-dragging={isDragging ? 'true' : 'false'} data-task-id={task.id} data-group-id={groupId} + onContextMenu={handleContextMenu} >
{/* All Columns - No Fixed Positioning */} @@ -1506,6 +1565,14 @@ const TaskRow: React.FC = React.memo(
+ {showContextMenu && ( + setShowContextMenu(false)} + /> + )} ); }, diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index f08d9328..8f880833 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -153,5 +153,6 @@ export const { setTimeLogEditing, setTaskRecurringSchedule, resetTaskDrawer, + setConvertToSubtaskDrawerOpen, } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; 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 07b32df4..20493847 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -13,6 +13,13 @@ import { } from '@/api/tasks/tasks.api.service'; import logger from '@/utils/errorLogger'; +export enum IGroupBy { + STATUS = 'status', + PRIORITY = 'priority', + PHASE = 'phase', + MEMBERS = 'members', +} + // Entity adapter for normalized state const tasksAdapter = createEntityAdapter({ sortComparer: (a, b) => a.order - b.order, @@ -616,7 +623,7 @@ const taskManagementSlice = createSlice({ }, // Reset action - resetTaskManagement: (state) => { + resetTaskManagement: state => { return tasksAdapter.getInitialState(initialState); }, toggleTaskExpansion: (state, action: PayloadAction) => { diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index d65610c5..52fe95ff 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -16,6 +16,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { Task } from '@/types/task-management.types'; import { fetchTaskAssignees, @@ -41,6 +42,7 @@ import { moveTaskBetweenGroups, selectCurrentGroupingV3, fetchTasksV3, + addSubtaskToParent, } from '@/features/task-management/task-management.slice'; import { updateEnhancedKanbanSubtask, diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index eed988b4..d75889bf 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -98,6 +98,8 @@ export { CheckCircleOutlined, MinusCircleOutlined, RetweetOutlined, + DoubleRightOutlined, + UserAddOutlined, } from '@ant-design/icons'; // Re-export all components with React