Merge pull request #275 from shancds/test/kanban-order-v1.2.3

Enhance EnhancedKanbanBoardNativeDnD to support phase reordering
This commit is contained in:
Chamika J
2025-07-17 16:30:39 +05:30
committed by GitHub
11 changed files with 757 additions and 76 deletions

View File

@@ -37,5 +37,17 @@
"noDueDate": "Pa datë përfundimi",
"save": "Ruaj",
"clear": "Pastro",
"nextWeek": "Javën e ardhshme"
"nextWeek": "Javën e ardhshme",
"noSubtasks": "Pa nëndetyra",
"showSubtasks": "Shfaq nëndetyrat",
"hideSubtasks": "Fshih nëndetyrat",
"errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave",
"noTasksFound": "Nuk u gjetën detyra",
"loadingFilters": "Duke ngarkuar filtra...",
"failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave",
"failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave",
"pleaseTryAgain": "Ju lutemi provoni përsëri",
"taskNotCompleted": "Detyra nuk është përfunduar",
"completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni"
}

View File

@@ -37,5 +37,17 @@
"noDueDate": "Kein Fälligkeitsdatum",
"save": "Speichern",
"clear": "Löschen",
"nextWeek": "Nächste Woche"
"nextWeek": "Nächste Woche",
"noSubtasks": "Keine Unteraufgaben",
"showSubtasks": "Unteraufgaben anzeigen",
"hideSubtasks": "Unteraufgaben ausblenden",
"errorLoadingTasks": "Fehler beim Laden der Aufgaben",
"noTasksFound": "Keine Aufgaben gefunden",
"loadingFilters": "Filter werden geladen...",
"failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge",
"failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge",
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
"taskNotCompleted": "Aufgabe ist nicht abgeschlossen",
"completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren"
}

View File

@@ -40,5 +40,14 @@
"nextWeek": "Next week",
"noSubtasks": "No subtasks",
"showSubtasks": "Show subtasks",
"hideSubtasks": "Hide subtasks"
"hideSubtasks": "Hide subtasks",
"errorLoadingTasks": "Error loading tasks",
"noTasksFound": "No tasks found",
"loadingFilters": "Loading filters...",
"failedToUpdateColumnOrder": "Failed to update column order",
"failedToUpdatePhaseOrder": "Failed to update phase order",
"pleaseTryAgain": "Please try again",
"taskNotCompleted": "Task is not completed",
"completeTaskDependencies": "Please complete the task dependencies before proceeding"
}

View File

@@ -37,5 +37,17 @@
"noDueDate": "Sin fecha de vencimiento",
"save": "Guardar",
"clear": "Limpiar",
"nextWeek": "Próxima semana"
"nextWeek": "Próxima semana",
"noSubtasks": "Sin subtareas",
"showSubtasks": "Mostrar subtareas",
"hideSubtasks": "Ocultar subtareas",
"errorLoadingTasks": "Error al cargar tareas",
"noTasksFound": "No se encontraron tareas",
"loadingFilters": "Cargando filtros...",
"failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas",
"failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases",
"pleaseTryAgain": "Por favor, inténtalo de nuevo",
"taskNotCompleted": "La tarea no está completada",
"completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar"
}

View File

@@ -37,5 +37,17 @@
"noDueDate": "Sem data de vencimento",
"save": "Salvar",
"clear": "Limpar",
"nextWeek": "Próxima semana"
"nextWeek": "Próxima semana",
"noSubtasks": "Sem subtarefas",
"showSubtasks": "Mostrar subtarefas",
"hideSubtasks": "Ocultar subtarefas",
"errorLoadingTasks": "Erro ao carregar tarefas",
"noTasksFound": "Nenhuma tarefa encontrada",
"loadingFilters": "Carregando filtros...",
"failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas",
"failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases",
"pleaseTryAgain": "Por favor, tente novamente",
"taskNotCompleted": "Tarefa não está concluída",
"completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir"
}

View File

@@ -23,5 +23,24 @@
"deleteStatusTitle": "删除状态",
"deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。",
"deletePhaseTitle": "删除阶段",
"deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。"
"deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。",
"untitledSection": "未命名部分",
"unmapped": "未映射",
"clickToChangeDate": "点击更改日期",
"noDueDate": "无截止日期",
"save": "保存",
"clear": "清除",
"nextWeek": "下周",
"noSubtasks": "无子任务",
"showSubtasks": "显示子任务",
"hideSubtasks": "隐藏子任务",
"errorLoadingTasks": "加载任务时出错",
"noTasksFound": "未找到任务",
"loadingFilters": "正在加载过滤器...",
"failedToUpdateColumnOrder": "更新列顺序失败",
"failedToUpdatePhaseOrder": "更新阶段顺序失败",
"pleaseTryAgain": "请重试",
"taskNotCompleted": "任务未完成",
"completeTaskDependencies": "请先完成任务依赖项,然后再继续"
}

View File

@@ -8,7 +8,7 @@ import ImprovedTaskFilters from '../../task-management/improved-task-filters';
import Card from 'antd/es/card';
import Spin from 'antd/es/spin';
import Empty from 'antd/es/empty';
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees, updateEnhancedKanbanTaskPriority } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import KanbanGroup from './KanbanGroup';
@@ -21,8 +21,14 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice';
import { useTranslation } from 'react-i18next';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => {
const { t } = useTranslation('kanban-board');
const dispatch = useDispatch();
const authService = useAuthService();
const { socket } = useSocket();
@@ -34,6 +40,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
loadingGroups,
error,
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer);
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null);
@@ -56,6 +63,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
if (!statusCategories.length) {
dispatch(fetchStatusesCategories() as any);
}
if (groupBy === 'phase' && !phaseList.length) {
dispatch(fetchPhasesByProjectId(projectId) as any);
}
}, [dispatch, projectId]);
// Reset drag state if taskGroups changes (e.g., real-time update)
useEffect(() => {
@@ -72,6 +82,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
setDraggedGroupId(groupId);
setDragType('group');
e.dataTransfer.effectAllowed = 'move';
try {
e.dataTransfer.setData('text/plain', groupId);
} catch {}
};
const handleGroupDragOver = (e: React.DragEvent) => {
if (dragType !== 'group') return;
@@ -90,19 +103,35 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
reorderedGroups.splice(toIdx, 0, moved);
dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }));
dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any);
// API call for group order
try {
const columnOrder = reorderedGroups.map(group => group.id);
const requestBody = { status_order: columnOrder };
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
if (groupBy === 'status') {
const columnOrder = reorderedGroups.map(group => group.id);
const requestBody = { status_order: columnOrder };
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain'));
}
} else if (groupBy === 'phase') {
const newPhaseList = [...phaseList];
const [movedItem] = newPhaseList.splice(fromIdx, 1);
newPhaseList.splice(toIdx, 0, movedItem);
dispatch(updatePhaseListOrder(newPhaseList));
const requestBody = {
from_index: fromIdx,
to_index: toIdx,
phases: newPhaseList,
project_id: projectId,
};
const response = await phasesApiService.updatePhaseOrder(projectId, requestBody);
if (!response.done) {
alertService.error(t('failedToUpdatePhaseOrder'), t('pleaseTryAgain'));
}
}
} catch (error) {
// Revert the change if API call fails
@@ -110,7 +139,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
revertedGroups.splice(fromIdx, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain'));
logger.error('Failed to update column order', error);
}
@@ -119,18 +148,30 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
};
// Utility to recalculate all task orders for all groups
function getAllTaskUpdates(allGroups, groupBy) {
const taskUpdates = [];
function getAllTaskUpdates(allGroups: ITaskListGroup[], groupBy: string) {
const taskUpdates: Array<{
task_id: string | undefined;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
}> = [];
let currentSortOrder = 0;
for (const group of allGroups) {
for (const task of group.tasks) {
const update = {
const update: {
task_id: string | undefined;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
} = {
task_id: task.id,
sort_order: currentSortOrder,
};
if (groupBy === 'status') update.status_id = group.id;
else if (groupBy === 'priority') update.priority_id = group.id;
else if (groupBy === 'phase') update.phase_id = group.id;
else if (groupBy === 'phase' && group.name !== 'Unmapped') update.phase_id = group.id;
taskUpdates.push(update);
currentSortOrder++;
}
@@ -144,6 +185,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
setDraggedTaskGroupId(groupId);
setDragType('task');
e.dataTransfer.effectAllowed = 'move';
try {
e.dataTransfer.setData('text/plain', taskId);
} catch {}
};
const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => {
if (dragType !== 'task') return;
@@ -177,8 +221,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
t('taskNotCompleted'),
t('completeTaskDependencies')
);
return;
}
@@ -203,7 +247,6 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
if (insertIdx > updatedTasks.length) insertIdx = updatedTasks.length;
updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
@@ -272,7 +315,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
team_id: teamId,
from_index: taskIdx,
to_index: insertIdx,
to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length - 1 : targetGroup.tasks.length),
to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? (newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length || 0) - 1 : targetGroup.tasks.length),
task: {
id: movedTask.id,
project_id: movedTask.project_id || projectId,
@@ -293,6 +336,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
})
);
}
if (groupBy === 'priority' && movedTask.id) {
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: movedTask.id,
priority_id: targetGroupId,
team_id: teamId,
})
);
socket?.once(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
(data: ITaskListPriorityChangeResponse) => {
dispatch(updateEnhancedKanbanTaskPriority(data));
}
);
}
}
setDraggedTaskId(null);
@@ -313,7 +372,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
if (error) {
return (
<Card>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={`${t('errorLoadingTasks')}: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
@@ -321,21 +380,21 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
return (
<>
<div className="mb-4">
<React.Suspense fallback={<div>Loading filters...</div>}>
<React.Suspense fallback={<div>{t('loadingFilters')}</div>}>
<ImprovedTaskFilters position="board" />
</React.Suspense>
</div>
<div className="enhanced-kanban-board">
{loadingGroups ? (
<div className="flex flex-row gap-2 h-[600px]">
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '60%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '100%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '80%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '40%' }} />
</div>
<div className="flex flex-row gap-2 h-[600px]">
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '60%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '100%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '80%' }} />
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '40%' }} />
</div>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={t('noTasksFound')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<div className="kanban-groups-container">

View File

@@ -575,7 +575,6 @@ const enhancedKanbanSlice = createSlice({
action: PayloadAction<ITaskListPriorityChangeResponse>
) => {
const { id, priority_id, color_code, color_code_dark } = action.payload;
// Find the task in any group
const taskInfo = findTaskInAllGroups(state.taskGroups, id);
if (!taskInfo || !priority_id) return;
@@ -603,7 +602,6 @@ const enhancedKanbanSlice = createSlice({
// Update cache
state.taskCache[id] = task;
},
// Enhanced Kanban assignee update (for use in task drawer dropdown)
updateEnhancedKanbanTaskAssignees: (
state,

View File

@@ -285,23 +285,7 @@ export const useTaskSocketHandlers = () => {
);
}
console.log('🔄 Status change group movement debug:', {
taskId: response.id,
newStatusValue,
currentGroupId: currentGroup?.id,
currentGroupValue: currentGroup?.groupValue,
currentGroupTitle: currentGroup?.title,
targetGroupId: targetGroup?.id,
targetGroupValue: targetGroup?.groupValue,
targetGroupTitle: targetGroup?.title,
allGroups: groups.map(g => ({ id: g.id, title: g.title, groupValue: g.groupValue }))
});
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
console.log('✅ Moving task between groups:', {
from: currentGroup.title,
to: targetGroup.title
});
// Use the action to move task between groups
dispatch(
moveTaskBetweenGroups({
@@ -448,12 +432,6 @@ export const useTaskSocketHandlers = () => {
}
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
console.log('🔄 Moving task between priority groups:', {
taskId: response.id,
from: currentGroup.title,
to: targetGroup.title,
newPriorityValue
});
dispatch(
moveTaskBetweenGroups({
taskId: response.id,
@@ -603,12 +581,6 @@ export const useTaskSocketHandlers = () => {
}
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
console.log('🔄 Moving task between phase groups:', {
taskId,
from: currentGroup.title,
to: targetGroup.title,
newPhaseValue
});
dispatch(
moveTaskBetweenGroups({
taskId: taskId,
@@ -925,10 +897,6 @@ export const useTaskSocketHandlers = () => {
// Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data)
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
if (!data || !data.assigneeIds) return;
// This event only provides assignee IDs, so we update what we can
// The full assignee data will come from QUICK_ASSIGNEES_UPDATE
// console.log('🔄 Task assignees change (limited data):', data);
}, []);
// Handler for timer start events
@@ -994,9 +962,6 @@ export const useTaskSocketHandlers = () => {
try {
if (!Array.isArray(data) || data.length === 0) return;
// DEBUG: Log the data received from the backend
console.log('[TASK_SORT_ORDER_CHANGE] Received data:', data);
// Get canonical lists from Redux
const state = store.getState();
const priorityList = state.priorityReducer?.priorities || [];

View File

@@ -0,0 +1,583 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd';
import BoardSectionCardContainer from './board-section/board-section-container';
import {
fetchBoardTaskGroups,
reorderTaskGroups,
moveTaskBetweenGroups,
IGroupBy,
updateTaskProgress,
} from '@features/board/board-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
closestCenter,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
getFirstCollision,
pointerWithin,
rectIntersection,
UniqueIdentifier,
} from '@dnd-kit/core';
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import alertService from '@/services/alerts/alertService';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import logger from '@/utils/errorLogger';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { debounce } from 'lodash';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
interface DroppableContainer {
id: UniqueIdentifier;
data: {
current?: {
type?: string;
};
};
}
const ProjectViewBoard = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const { socket } = useSocket();
const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
// Add local loading state to immediately show skeleton
const [isLoading, setIsLoading] = useState(true);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const [activeItem, setActiveItem] = useState<any>(null);
// Store the original source group ID when drag starts
const originalSourceGroupIdRef = useRef<string | null>(null);
const lastOverId = useRef<UniqueIdentifier | null>(null);
const recentlyMovedToNewContainer = useRef(false);
const [clonedItems, setClonedItems] = useState<any>(null);
const isDraggingRef = useRef(false);
// Update loading state based on all loading conditions
useEffect(() => {
setIsLoading(loadingGroups || loadingStatusCategories);
}, [loadingGroups, loadingStatusCategories]);
// Load data efficiently with async/await and Promise.all
useEffect(() => {
const loadData = async () => {
if (projectId && groupBy && projectView === 'kanban') {
const promises = [];
if (!loadingGroups) {
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
}
};
loadData();
}, [dispatch, projectId, groupBy, projectView, search, archived]);
// Create sensors with memoization to prevent unnecessary re-renders
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
delay: 100,
tolerance: 5,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const collisionDetectionStrategy = useCallback(
(args: {
active: { id: UniqueIdentifier; data: { current?: { type?: string } } };
droppableContainers: DroppableContainer[];
}) => {
if (activeItem?.type === 'section') {
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container: DroppableContainer) => container.data.current?.type === 'section'
),
});
}
// Start by finding any intersecting droppable
const pointerIntersections = pointerWithin(args);
const intersections =
pointerIntersections.length > 0
? pointerIntersections
: rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
if (overId !== null) {
const overContainer = args.droppableContainers.find(
(container: DroppableContainer) => container.id === overId
);
if (overContainer?.data.current?.type === 'section') {
const containerItems = taskGroups.find(
(group) => group.id === overId
)?.tasks || [];
if (containerItems.length > 0) {
overId = closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container: DroppableContainer) =>
container.id !== overId &&
container.data.current?.type === 'task'
),
})[0]?.id;
}
}
lastOverId.current = overId;
return [{ id: overId }];
}
if (recentlyMovedToNewContainer.current) {
lastOverId.current = activeItem?.id;
}
return lastOverId.current ? [{ id: lastOverId.current }] : [];
},
[activeItem, taskGroups]
);
const handleTaskProgress = (data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(updateTaskProgress(data));
};
// Debounced move task function to prevent rapid updates
const debouncedMoveTask = useCallback(
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
dispatch(
moveTaskBetweenGroups({
taskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
}, 100),
[dispatch]
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
isDraggingRef.current = true;
setActiveItem(active.data.current);
setCurrentTaskIndex(active.data.current?.sortable.index);
if (active.data.current?.type === 'task') {
originalSourceGroupIdRef.current = active.data.current.sectionId;
}
setClonedItems(taskGroups);
};
const findGroupForId = (id: string) => {
// If id is a sectionId
if (taskGroups.some(group => group.id === id)) return id;
// If id is a taskId, find the group containing it
const group = taskGroups.find(g => g.tasks.some(t => t.id === id));
return group?.id;
};
const handleDragOver = (event: DragOverEvent) => {
try {
if (!isDraggingRef.current) return;
const { active, over } = event;
if (!over) return;
// Get the ids
const activeId = active.id;
const overId = over.id;
// Find the group (section) for each
const activeGroupId = findGroupForId(activeId as string);
const overGroupId = findGroupForId(overId as string);
// Only move if both groups exist and are different, and the active is a task
if (
activeGroupId &&
overGroupId &&
active.data.current?.type === 'task'
) {
// Find the target index in the over group
const targetGroup = taskGroups.find(g => g.id === overGroupId);
let targetIndex = 0;
if (targetGroup) {
// If over is a task, insert before it; if over is a section, append to end
if (over.data.current?.type === 'task') {
targetIndex = targetGroup.tasks.findIndex(t => t.id === overId);
if (targetIndex === -1) targetIndex = targetGroup.tasks.length;
} else {
targetIndex = targetGroup.tasks.length;
}
}
// Use debounced move task to prevent rapid updates
debouncedMoveTask(
activeId as string,
activeGroupId,
overGroupId,
targetIndex
);
}
} catch (error) {
console.error('handleDragOver error:', error);
}
};
const handlePriorityChange = (taskId: string, priorityId: string) => {
if (!taskId || !priorityId || !socket) return;
const payload = {
task_id: taskId,
priority_id: priorityId,
team_id: currentSession?.team_id,
};
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
dispatch(updateBoardTaskPriority(data));
});
};
const handleDragEnd = async (event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || !projectId) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
setClonedItems(null);
return;
}
const isActiveTask = active.data.current?.type === 'task';
const isActiveSection = active.data.current?.type === 'section';
// Handle task dragging between columns
if (isActiveTask) {
const task = active.data.current?.task;
// Use the original source group ID from ref instead of the potentially modified one
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
// Fix: Ensure we correctly identify the target group ID
let targetGroupId;
if (over.data.current?.type === 'task') {
// If dropping on a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (over.data.current?.type === 'section') {
// If dropping directly on a section
targetGroupId = over.id;
} else {
// Fallback to the over ID if type is not specified
targetGroupId = over.id;
}
// Find source and target groups
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (!sourceGroup || !targetGroup || !task) {
logger.error('Could not find source or target group, or task is undefined');
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
if (targetGroupId !== sourceGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
targetGroupId: sourceGroupId, // Move it back to the original source group
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
})
);
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
}
// Find indices
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
// Handle case where task is not found in source group (might have been moved already in UI)
if (fromIndex === -1) {
logger.info('Task not found in source group. Using task sort_order from task object.');
// Use the sort_order from the task object itself
const fromSortOrder = task.sort_order;
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId,
to_group: targetGroupId,
group_by: groupBy || 'status',
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
// Handle priority change if groupBy is priority
if (groupBy === IGroupBy.PRIORITY) {
handlePriorityChange(task.id, targetGroupId);
}
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId, // Use the direct IDs instead of group objects
to_group: targetGroupId, // Use the direct IDs instead of group objects
group_by: groupBy || 'status', // Use the current groupBy value
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
// Handle column reordering
else if (isActiveSection) {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
const sectionId = active.id;
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
const toIndex = taskGroups.findIndex(group => group.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
// Create a new array with the reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Dispatch action to reorder columns with the new array
dispatch(reorderTaskGroups(reorderedGroups));
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
// Use the correct API endpoint based on the Angular code
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
}
}
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
};
const handleDragCancel = () => {
isDraggingRef.current = false;
if (clonedItems) {
dispatch(reorderTaskGroups(clonedItems));
}
setActiveItem(null);
setClonedItems(null);
originalSourceGroupIdRef.current = null;
};
// Reset the recently moved flag after animation frame
useEffect(() => {
requestAnimationFrame(() => {
recentlyMovedToNewContainer.current = false;
});
}, [taskGroups]);
useEffect(() => {
if (socket) {
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
}
return () => {
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket]);
// Track analytics event on component mount
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
}, []);
// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedMoveTask.cancel();
};
}, [debouncedMoveTask]);
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<BoardSectionCardContainer
datasource={taskGroups}
group={groupBy as 'status' | 'priority' | 'phases'}
/>
<DragOverlay>
{activeItem?.type === 'task' && (
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
)}
</DragOverlay>
</DndContext>
</Skeleton>
</Flex>
);
};
export default ProjectViewBoard;

View File

@@ -86,7 +86,7 @@ export class EnhancedPerformanceMonitor {
this.collectInitialMetrics();
this.startPeriodicCollection();
console.log('🚀 Enhanced performance monitoring started');
// console.log('🚀 Enhanced performance monitoring started');
}
// Stop monitoring and cleanup
@@ -97,7 +97,7 @@ export class EnhancedPerformanceMonitor {
this.cleanupObservers();
this.clearIntervals();
console.log('🛑 Enhanced performance monitoring stopped');
// console.log('🛑 Enhanced performance monitoring stopped');
}
// Setup performance observers
@@ -357,7 +357,7 @@ export class EnhancedPerformanceMonitor {
const recent = this.metrics.slice(-10); // Last 10 metrics
const report = this.analyzeMetrics(recent);
console.log('📊 Performance Report:', report);
// console.log('📊 Performance Report:', report);
// Check for performance issues
this.checkPerformanceIssues(report);