feat(task-context-menu): implement context menu for task actions
- Added TaskContextMenu component to provide a context menu for task-related actions such as assigning, archiving, deleting, and moving tasks. - Integrated context menu into TitleColumn component, allowing users to access task actions via right-click. - Enhanced user experience by providing immediate feedback for actions like assigning tasks and archiving. - Improved code organization by separating context menu logic into its own component.
This commit is contained in:
@@ -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<TaskContextMenuProps> = ({
|
||||||
|
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<HTMLDivElement>(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: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: status.color_code }}
|
||||||
|
></span>
|
||||||
|
<span>{status.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => handleStatusMoveTo(status.id!),
|
||||||
|
}));
|
||||||
|
} else if (currentGrouping === IGroupBy.PRIORITY) {
|
||||||
|
options = priorityList.filter(priority => priority.id).map(priority => ({
|
||||||
|
key: priority.id!,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: priority.color_code }}
|
||||||
|
></span>
|
||||||
|
<span>{priority.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||||
|
}));
|
||||||
|
} else if (currentGrouping === IGroupBy.PHASE) {
|
||||||
|
options = phaseList.filter(phase => phase.id).map(phase => ({
|
||||||
|
key: phase.id!,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: phase.color_code }}
|
||||||
|
></span>
|
||||||
|
<span>{phase.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
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: (
|
||||||
|
<button
|
||||||
|
onClick={handleAssignToMe}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
disabled={updatingAssignToMe}
|
||||||
|
>
|
||||||
|
{updatingAssignToMe ? (
|
||||||
|
<LoadingOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<UserAddOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span>{t('contextMenu.assignToMe')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add Move To submenu if there are options
|
||||||
|
const moveToOptions = getMoveToOptions();
|
||||||
|
if (moveToOptions.length > 0) {
|
||||||
|
items.push({
|
||||||
|
key: 'moveTo',
|
||||||
|
label: (
|
||||||
|
<div className="relative group">
|
||||||
|
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RetweetOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{t('contextMenu.moveTo')}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul className="absolute left-full top-0 mt-0 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 hidden group-hover:block">
|
||||||
|
{moveToOptions.map(option => (
|
||||||
|
<li key={option.key}>
|
||||||
|
<button
|
||||||
|
onClick={option.onClick}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Archive/Unarchive for parent tasks only
|
||||||
|
if (!task?.parent_task_id) {
|
||||||
|
items.push({
|
||||||
|
key: 'archive',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleArchive}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
<InboxOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: (
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(setConvertToSubtaskDrawerOpen(true))}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{t('contextMenu.convertToSubTask')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Convert to Task for subtasks
|
||||||
|
if (task?.parent_task_id) {
|
||||||
|
items.push({
|
||||||
|
key: 'convertToTask',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleConvertToTask}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||||
|
>
|
||||||
|
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||||
|
<span>{t('contextMenu.convertToTask')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Delete
|
||||||
|
items.push({
|
||||||
|
key: 'delete',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20 w-full text-left"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-red-500 dark:text-red-400" />
|
||||||
|
<span>{t('contextMenu.delete')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
updatingAssignToMe,
|
||||||
|
archived,
|
||||||
|
handleAssignToMe,
|
||||||
|
handleArchive,
|
||||||
|
handleDelete,
|
||||||
|
handleConvertToTask,
|
||||||
|
getMoveToOptions,
|
||||||
|
dispatch,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-48"
|
||||||
|
style={{
|
||||||
|
top: position.y,
|
||||||
|
left: position.x,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul className="list-none p-0 m-0">
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<li key={item.key} className="relative group">
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskContextMenu;
|
||||||
@@ -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 { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
import { Input, Tooltip } from 'antd';
|
import { Input, Tooltip } from 'antd';
|
||||||
import type { InputRef } from 'antd';
|
import type { InputRef } from 'antd';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
|
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 { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getTaskDisplayName } from './TaskRowColumns';
|
import { getTaskDisplayName } from './TaskRowColumns';
|
||||||
|
import TaskContextMenu from './TaskContextMenu';
|
||||||
|
|
||||||
interface TitleColumnProps {
|
interface TitleColumnProps {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -42,6 +44,10 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
|||||||
const inputRef = useRef<InputRef>(null);
|
const inputRef = useRef<InputRef>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Context menu state
|
||||||
|
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||||
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Handle task expansion toggle
|
// Handle task expansion toggle
|
||||||
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -71,6 +77,24 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
|||||||
onEditTaskName(false);
|
onEditTaskName(false);
|
||||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
|
}, [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
|
// Handle click outside for task name editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -169,6 +193,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onEditTaskName(true);
|
onEditTaskName(true);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
title={taskDisplayName}
|
title={taskDisplayName}
|
||||||
>
|
>
|
||||||
{taskDisplayName}
|
{taskDisplayName}
|
||||||
@@ -251,6 +276,17 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
{contextMenuVisible && createPortal(
|
||||||
|
<TaskContextMenu
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
position={contextMenuPosition}
|
||||||
|
onClose={handleContextMenuClose}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{
|
|||||||
columnKey: string;
|
columnKey: string;
|
||||||
updateValue: (taskId: string, columnKey: string, value: string) => void;
|
updateValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}> = ({ selectionsList, value, task, columnKey, updateValue }) => {
|
}> = ({ 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 (
|
return (
|
||||||
<CustomColumnSelectionCell
|
<CustomColumnSelectionCell
|
||||||
@@ -1256,19 +1245,6 @@ const renderCustomColumnContent = (
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
selection: () => {
|
selection: () => {
|
||||||
// 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 (
|
return (
|
||||||
<SelectionFieldCell
|
<SelectionFieldCell
|
||||||
selectionsList={columnObj?.selectionsList || []}
|
selectionsList={columnObj?.selectionsList || []}
|
||||||
@@ -1650,35 +1626,12 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
|
|
||||||
const activeTask = displayTasks.find(task => task.id === active.id);
|
const activeTask = displayTasks.find(task => task.id === active.id);
|
||||||
if (!activeTask) {
|
if (!activeTask) {
|
||||||
console.error('Active task not found:', {
|
|
||||||
activeId: active.id,
|
|
||||||
displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })),
|
|
||||||
});
|
|
||||||
return;
|
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)
|
// Use the tableId directly as the group ID (it should be the group ID)
|
||||||
const currentGroupId = tableId;
|
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
|
// Check if this is a reorder within the same group
|
||||||
const overTask = displayTasks.find(task => task.id === over.id);
|
const overTask = displayTasks.find(task => task.id === over.id);
|
||||||
if (overTask) {
|
if (overTask) {
|
||||||
@@ -1686,36 +1639,17 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
|
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
|
||||||
const newIndex = displayTasks.findIndex(task => task.id === over.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) {
|
if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
|
||||||
// Get the actual sort_order values from the tasks
|
// Get the actual sort_order values from the tasks
|
||||||
const fromSortOrder = activeTask.sort_order || oldIndex;
|
const fromSortOrder = activeTask.sort_order || oldIndex;
|
||||||
const overTaskAtNewIndex = displayTasks[newIndex];
|
const overTaskAtNewIndex = displayTasks[newIndex];
|
||||||
const toSortOrder = overTaskAtNewIndex?.sort_order || 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
|
// Create updated task list with reordered tasks
|
||||||
const updatedTasks = [...displayTasks];
|
const updatedTasks = [...displayTasks];
|
||||||
const [movedTask] = updatedTasks.splice(oldIndex, 1);
|
const [movedTask] = updatedTasks.splice(oldIndex, 1);
|
||||||
updatedTasks.splice(newIndex, 0, movedTask);
|
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
|
// Update local state immediately for better UX
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasks({
|
reorderTasks({
|
||||||
@@ -1758,34 +1692,10 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
|
|
||||||
// Validate required fields before sending
|
// Validate required fields before sending
|
||||||
if (!body.task.id) {
|
if (!body.task.id) {
|
||||||
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
|
|
||||||
return;
|
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);
|
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user