feat(task-management): implement context menu for task actions
- Added a context menu to the TaskRow component, allowing users to perform actions such as assigning tasks, archiving, deleting, and moving tasks between statuses, priorities, and phases. - Introduced TaskContextMenu component to handle context menu logic and interactions. - Enhanced task row styling for improved hover effects and visibility in both light and dark modes. - Updated task management slice to include new actions for handling task assignments and conversions.
This commit is contained in:
@@ -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<TaskContextMenuProps> = ({
|
||||||
|
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<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) 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: (
|
||||||
|
<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.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.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 hover:bg-gray-100 w-full text-left"
|
||||||
|
disabled={updatingAssignToMe}
|
||||||
|
>
|
||||||
|
{updatingAssignToMe ? (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4 text-gray-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<UserAddOutlined className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
<span>{t('contextMenu.assignToMe')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'moveTo',
|
||||||
|
label: (
|
||||||
|
<div className="relative">
|
||||||
|
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RetweetOutlined className="text-gray-500" />
|
||||||
|
<span>{t('contextMenu.moveTo')}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-gray-500"
|
||||||
|
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 border border-gray-200 rounded-md shadow-lg z-20 hidden group-hover:block">
|
||||||
|
{getMoveToOptions().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 hover:bg-gray-100 w-full text-left"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 hover:bg-gray-100 w-full text-left"
|
||||||
|
>
|
||||||
|
<InboxOutlined className="text-gray-500" />
|
||||||
|
<span>{task.archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hover:bg-gray-100 w-full text-left"
|
||||||
|
>
|
||||||
|
<DoubleRightOutlined className="text-gray-500" />
|
||||||
|
<span>{t('contextMenu.convertToSubTask')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hover:bg-gray-100 w-full text-left"
|
||||||
|
>
|
||||||
|
<DoubleRightOutlined className="text-gray-500" />
|
||||||
|
<span>{t('contextMenu.convertToTask')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: 'delete',
|
||||||
|
label: (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-100 w-full text-left"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-red-500" />
|
||||||
|
<span>{t('contextMenu.delete')}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
updatingAssignToMe,
|
||||||
|
handleAssignToMe,
|
||||||
|
handleArchive,
|
||||||
|
handleDelete,
|
||||||
|
handleConvertToTask,
|
||||||
|
getMoveToOptions,
|
||||||
|
dispatch,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute bg-white border border-gray-200 rounded-md shadow-lg z-50 py-1"
|
||||||
|
style={{
|
||||||
|
top: position.y,
|
||||||
|
left: position.x,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
@@ -1236,6 +1236,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
--task-selected-border: #1890ff;
|
--task-selected-border: #1890ff;
|
||||||
--task-drag-over-bg: #f0f8ff;
|
--task-drag-over-bg: #f0f8ff;
|
||||||
--task-drag-over-border: #40a9ff;
|
--task-drag-over-border: #40a9ff;
|
||||||
|
--task-border-hover-top: #c0c0c0; /* Slightly darker for visibility */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .task-groups-container-fixed,
|
.dark .task-groups-container-fixed,
|
||||||
@@ -1257,6 +1258,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
--task-selected-border: #1890ff;
|
--task-selected-border: #1890ff;
|
||||||
--task-drag-over-bg: #1a2332;
|
--task-drag-over-bg: #1a2332;
|
||||||
--task-drag-over-border: #40a9ff;
|
--task-drag-over-border: #40a9ff;
|
||||||
|
--task-border-hover-top-dark: #505050; /* Slightly darker for visibility in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar */
|
/* Dark mode scrollbar */
|
||||||
|
|||||||
@@ -94,8 +94,9 @@
|
|||||||
|
|
||||||
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
||||||
.task-row-optimized:hover {
|
.task-row-optimized:hover {
|
||||||
/* Remove transform that was causing GPU conflicts */
|
background-color: var(--task-hover-bg, #fafafa);
|
||||||
/* Remove complex containment rules */
|
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 */
|
/* OPTIMIZED HOVER BUTTONS: Use opacity only, no visibility changes */
|
||||||
@@ -284,16 +285,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode optimizations */
|
/* Dark mode optimizations */
|
||||||
|
:root {
|
||||||
|
/* ... existing variables ... */
|
||||||
|
--task-border-hover-top: #c0c0c0; /* Slightly darker for visibility */
|
||||||
|
}
|
||||||
|
|
||||||
.dark .task-row-optimized {
|
.dark .task-row-optimized {
|
||||||
contain: layout style;
|
/* ... existing variables ... */
|
||||||
background: var(--task-bg-primary, #1f1f1f);
|
}
|
||||||
color: var(--task-text-primary, #fff);
|
|
||||||
border-color: var(--task-border-primary, #303030);
|
.dark {
|
||||||
|
/* ... existing variables ... */
|
||||||
|
--task-border-hover-top-dark: #505050; /* Slightly darker for visibility in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .task-row-optimized:hover {
|
.dark .task-row-optimized:hover {
|
||||||
contain: layout style;
|
background-color: var(--task-hover-bg, #2a2a2a);
|
||||||
/* Remove complex containment rules */
|
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 */
|
/* Animation performance */
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
fetchTask,
|
fetchTask,
|
||||||
} from '@/features/task-drawer/task-drawer.slice';
|
} from '@/features/task-drawer/task-drawer.slice';
|
||||||
import useDragCursor from '@/hooks/useDragCursor';
|
import useDragCursor from '@/hooks/useDragCursor';
|
||||||
|
import TaskContextMenu from './task-context-menu/task-context-menu';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -429,7 +430,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
const addSubtaskInputRef = useRef<InputRef>(null);
|
const addSubtaskInputRef = useRef<InputRef>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(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
|
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -572,6 +575,11 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
onToggleSubtasks?.(task.id);
|
onToggleSubtasks?.(task.id);
|
||||||
}, [task.id, onToggleSubtasks]);
|
}, [task.id, onToggleSubtasks]);
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
|
setShowContextMenu(true);
|
||||||
|
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle successful subtask creation
|
// Handle successful subtask creation
|
||||||
const handleSubtaskCreated = useCallback(
|
const handleSubtaskCreated = useCallback(
|
||||||
(newTask: any) => {
|
(newTask: any) => {
|
||||||
@@ -1007,6 +1015,54 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Indicators section */}
|
||||||
|
{!editTaskName && (
|
||||||
|
<div className="task-indicators flex items-center gap-2">
|
||||||
|
{/* Comments indicator */}
|
||||||
|
{(task as any).comments_count > 0 && (
|
||||||
|
<Tooltip title={t('taskManagement.comments', 'Comments')}>
|
||||||
|
<MessageOutlined
|
||||||
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Attachments indicator */}
|
||||||
|
{(task as any).attachments_count > 0 && (
|
||||||
|
<Tooltip title={t('taskManagement.attachments', 'Attachments')}>
|
||||||
|
<PaperClipOutlined
|
||||||
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Dependencies indicator */}
|
||||||
|
{(task as any).has_dependencies && (
|
||||||
|
<Tooltip title={t('taskManagement.dependencies', 'Dependencies')}>
|
||||||
|
<MinusCircleOutlined
|
||||||
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Subscribers indicator */}
|
||||||
|
{(task as any).has_subscribers && (
|
||||||
|
<Tooltip title={t('taskManagement.subscribers', 'Subscribers')}>
|
||||||
|
<EyeOutlined
|
||||||
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Recurring indicator */}
|
||||||
|
{(task as any).schedule_id && (
|
||||||
|
<Tooltip title={t('taskManagement.recurringTask', 'Recurring Task')}>
|
||||||
|
<RetweetOutlined
|
||||||
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1056,6 +1112,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section with open button - CSS hover only */}
|
{/* Right section with open button - CSS hover only */}
|
||||||
@@ -1478,6 +1536,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
data-dnd-dragging={isDragging ? 'true' : 'false'}
|
data-dnd-dragging={isDragging ? 'true' : 'false'}
|
||||||
data-task-id={task.id}
|
data-task-id={task.id}
|
||||||
data-group-id={groupId}
|
data-group-id={groupId}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<div className="task-row-container flex h-10 max-h-10 relative">
|
<div className="task-row-container flex h-10 max-h-10 relative">
|
||||||
{/* All Columns - No Fixed Positioning */}
|
{/* All Columns - No Fixed Positioning */}
|
||||||
@@ -1506,6 +1565,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showContextMenu && (
|
||||||
|
<TaskContextMenu
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
position={contextMenuPosition}
|
||||||
|
onClose={() => setShowContextMenu(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ export const {
|
|||||||
setTimeLogEditing,
|
setTimeLogEditing,
|
||||||
setTaskRecurringSchedule,
|
setTaskRecurringSchedule,
|
||||||
resetTaskDrawer,
|
resetTaskDrawer,
|
||||||
|
setConvertToSubtaskDrawerOpen,
|
||||||
} = taskDrawerSlice.actions;
|
} = taskDrawerSlice.actions;
|
||||||
export default taskDrawerSlice.reducer;
|
export default taskDrawerSlice.reducer;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import {
|
|||||||
} from '@/api/tasks/tasks.api.service';
|
} from '@/api/tasks/tasks.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
|
export enum IGroupBy {
|
||||||
|
STATUS = 'status',
|
||||||
|
PRIORITY = 'priority',
|
||||||
|
PHASE = 'phase',
|
||||||
|
MEMBERS = 'members',
|
||||||
|
}
|
||||||
|
|
||||||
// Entity adapter for normalized state
|
// Entity adapter for normalized state
|
||||||
const tasksAdapter = createEntityAdapter<Task>({
|
const tasksAdapter = createEntityAdapter<Task>({
|
||||||
sortComparer: (a, b) => a.order - b.order,
|
sortComparer: (a, b) => a.order - b.order,
|
||||||
@@ -616,7 +623,7 @@ const taskManagementSlice = createSlice({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Reset action
|
// Reset action
|
||||||
resetTaskManagement: (state) => {
|
resetTaskManagement: state => {
|
||||||
return tasksAdapter.getInitialState(initialState);
|
return tasksAdapter.getInitialState(initialState);
|
||||||
},
|
},
|
||||||
toggleTaskExpansion: (state, action: PayloadAction<string>) => {
|
toggleTaskExpansion: (state, action: PayloadAction<string>) => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
|
|||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchTaskAssignees,
|
fetchTaskAssignees,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
moveTaskBetweenGroups,
|
moveTaskBetweenGroups,
|
||||||
selectCurrentGroupingV3,
|
selectCurrentGroupingV3,
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
|
addSubtaskToParent,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
updateEnhancedKanbanSubtask,
|
updateEnhancedKanbanSubtask,
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
MinusCircleOutlined,
|
MinusCircleOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
|
DoubleRightOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// Re-export all components with React
|
// Re-export all components with React
|
||||||
|
|||||||
Reference in New Issue
Block a user