feat(task-list): implement optimized task group handling and filter data loading
- Introduced `useFilterDataLoader` hook to manage asynchronous loading of filter data without blocking the main UI. - Created `TaskGroupWrapperOptimized` for improved rendering of task groups with drag-and-drop functionality. - Refactored `ProjectViewTaskList` to utilize the new optimized components and enhance loading state management. - Added `TaskGroup` component for better organization and interaction with task groups. - Updated `TaskListFilters` to leverage the new filter data loading mechanism, ensuring a smoother user experience.
This commit is contained in:
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
|
import {
|
||||||
|
fetchLabelsByProject,
|
||||||
|
fetchTaskAssignees,
|
||||||
|
} from '@/features/tasks/tasks.slice';
|
||||||
|
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage filter data loading independently of main task list loading
|
||||||
|
* This ensures filter data loading doesn't block the main UI skeleton
|
||||||
|
*/
|
||||||
|
export const useFilterDataLoader = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { priorities } = useAppSelector(state => ({
|
||||||
|
priorities: state.priorityReducer.priorities,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { projectId } = useAppSelector(state => ({
|
||||||
|
projectId: state.projectReducer.projectId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load filter data asynchronously
|
||||||
|
const loadFilterData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Load priorities if not already loaded (usually fast/cached)
|
||||||
|
if (!priorities.length) {
|
||||||
|
dispatch(fetchPriorities());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project-specific data in parallel without blocking
|
||||||
|
if (projectId) {
|
||||||
|
// These dispatch calls are fire-and-forget
|
||||||
|
// They will update the UI when ready, but won't block initial render
|
||||||
|
dispatch(fetchLabelsByProject(projectId));
|
||||||
|
dispatch(fetchTaskAssignees(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team members for member filters
|
||||||
|
dispatch(getTeamMembers({
|
||||||
|
index: 0,
|
||||||
|
size: 100,
|
||||||
|
field: null,
|
||||||
|
order: null,
|
||||||
|
search: null,
|
||||||
|
all: true
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter data:', error);
|
||||||
|
// Don't throw - filter loading errors shouldn't break the main UI
|
||||||
|
}
|
||||||
|
}, [dispatch, priorities.length, projectId]);
|
||||||
|
|
||||||
|
// Load filter data on mount and when dependencies change
|
||||||
|
useEffect(() => {
|
||||||
|
// Use setTimeout to ensure this runs after the main component render
|
||||||
|
// This prevents filter loading from blocking the initial render
|
||||||
|
const timeoutId = setTimeout(loadFilterData, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [loadFilterData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadFilterData,
|
||||||
|
};
|
||||||
|
};
|
||||||
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
KeyboardSensor,
|
||||||
|
TouchSensor,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
|
||||||
|
export const useTaskDragAndDrop = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { taskGroups, groupBy } = useAppSelector(state => ({
|
||||||
|
taskGroups: state.taskReducer.taskGroups,
|
||||||
|
groupBy: state.taskReducer.groupBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Memoize sensors configuration for better performance
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
// Add visual feedback for drag start
|
||||||
|
const { active } = event;
|
||||||
|
if (active) {
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
// Handle drag over logic if needed
|
||||||
|
// This can be used for visual feedback during drag
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
// Reset cursor
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || !taskGroups) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// Find the task being dragged
|
||||||
|
let draggedTask: IProjectTask | null = null;
|
||||||
|
let sourceGroupId: string | null = null;
|
||||||
|
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
|
||||||
|
if (task) {
|
||||||
|
draggedTask = task;
|
||||||
|
sourceGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draggedTask || !sourceGroupId) {
|
||||||
|
console.warn('Could not find dragged task');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target group
|
||||||
|
let targetGroupId: string | null = null;
|
||||||
|
|
||||||
|
// Check if dropped on a group container
|
||||||
|
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetGroupId = targetGroup.id;
|
||||||
|
} else {
|
||||||
|
// Check if dropped on another task
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
|
||||||
|
if (targetTask) {
|
||||||
|
targetGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetGroupId || targetGroupId === sourceGroupId) {
|
||||||
|
return; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task status based on group change
|
||||||
|
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
|
||||||
|
if (targetGroupData && groupBy === 'status') {
|
||||||
|
const updatePayload: any = {
|
||||||
|
task_id: draggedTask.id,
|
||||||
|
status_id: targetGroupData.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (draggedTask.parent_task_id) {
|
||||||
|
updatePayload.parent_task = draggedTask.parent_task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskStatus(updatePayload));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling drag end:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskGroups, groupBy, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the drag and drop configuration
|
||||||
|
const dragAndDropConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
sensors,
|
||||||
|
onDragStart: handleDragStart,
|
||||||
|
onDragOver: handleDragOver,
|
||||||
|
onDragEnd: handleDragEnd,
|
||||||
|
}),
|
||||||
|
[sensors, handleDragStart, handleDragOver, handleDragEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
return dragAndDropConfig;
|
||||||
|
};
|
||||||
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect } 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 alertService from '@/services/alerts/alertService';
|
||||||
|
|
||||||
|
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||||
|
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
|
||||||
|
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||||
|
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||||
|
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchTaskAssignees,
|
||||||
|
updateTaskAssignees,
|
||||||
|
fetchLabelsByProject,
|
||||||
|
updateTaskLabel,
|
||||||
|
updateTaskStatus,
|
||||||
|
updateTaskPriority,
|
||||||
|
updateTaskEndDate,
|
||||||
|
updateTaskEstimation,
|
||||||
|
updateTaskName,
|
||||||
|
updateTaskPhase,
|
||||||
|
updateTaskStartDate,
|
||||||
|
updateTaskDescription,
|
||||||
|
updateSubTasks,
|
||||||
|
updateTaskProgress,
|
||||||
|
} from '@/features/tasks/tasks.slice';
|
||||||
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
|
import {
|
||||||
|
setStartDate,
|
||||||
|
setTaskAssignee,
|
||||||
|
setTaskEndDate,
|
||||||
|
setTaskLabels,
|
||||||
|
setTaskPriority,
|
||||||
|
setTaskStatus,
|
||||||
|
setTaskSubscribers,
|
||||||
|
} from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
|
|
||||||
|
export const useTaskSocketHandlers = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
|
||||||
|
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||||
|
|
||||||
|
// Memoize socket event handlers
|
||||||
|
const handleAssigneesUpdate = useCallback(
|
||||||
|
(data: ITaskAssigneesUpdateResponse) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||||
|
...assignee,
|
||||||
|
selected: true,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const groupId = taskGroups?.find((group: ITaskListGroup) =>
|
||||||
|
group.tasks?.some(
|
||||||
|
(task: IProjectTask) =>
|
||||||
|
task.id === data.id ||
|
||||||
|
(task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
|
||||||
|
)
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
dispatch(
|
||||||
|
updateTaskAssignees({
|
||||||
|
groupId,
|
||||||
|
taskId: data.id,
|
||||||
|
assignees: updatedAssignees,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setTaskAssignee({
|
||||||
|
...data,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSession?.team_id && !loadingAssignees) {
|
||||||
|
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLabelsChange = useCallback(
|
||||||
|
async (labels: ILabelsChangeResponse) => {
|
||||||
|
if (!labels) return;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
dispatch(updateTaskLabel(labels)),
|
||||||
|
dispatch(setTaskLabels(labels)),
|
||||||
|
dispatch(fetchLabels()),
|
||||||
|
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskStatusChange = useCallback(
|
||||||
|
(response: ITaskListStatusChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.completed_deps === false) {
|
||||||
|
alertService.error(
|
||||||
|
'Task is not completed',
|
||||||
|
'Please complete the task dependencies before proceeding'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskStatus(response));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskProgress = useCallback(
|
||||||
|
(data: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
complete_ratio: number;
|
||||||
|
completed_count: number;
|
||||||
|
total_tasks_count: number;
|
||||||
|
parent_task: string;
|
||||||
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateTaskProgress({
|
||||||
|
taskId: data.parent_task || data.id,
|
||||||
|
progress: data.complete_ratio,
|
||||||
|
totalTasksCount: data.total_tasks_count,
|
||||||
|
completedCount: data.completed_count,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePriorityChange = useCallback(
|
||||||
|
(response: ITaskListPriorityChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
dispatch(updateTaskPriority(response));
|
||||||
|
dispatch(setTaskPriority(response));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEndDateChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
end_date: string;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||||
|
dispatch(setTaskEndDate(taskWithProgress));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskNameChange = useCallback(
|
||||||
|
(data: { id: string; parent_task: string; name: string }) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskName(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePhaseChange = useCallback(
|
||||||
|
(data: ITaskPhaseChangeResponse) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskPhase(data));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartDateChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
start_date: string;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||||
|
dispatch(setStartDate(taskWithProgress));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskSubscribersChange = useCallback(
|
||||||
|
(data: InlineMember[]) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(setTaskSubscribers(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEstimationChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
estimation: number;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskDescriptionChange = useCallback(
|
||||||
|
(data: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string;
|
||||||
|
description: string;
|
||||||
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskDescription(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewTaskReceived = useCallback(
|
||||||
|
(data: IProjectTask) => {
|
||||||
|
if (!data) return;
|
||||||
|
if (data.parent_task_id) {
|
||||||
|
dispatch(updateSubTasks(data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskProgressUpdated = useCallback(
|
||||||
|
(data: {
|
||||||
|
task_id: string;
|
||||||
|
progress_value?: number;
|
||||||
|
weight?: number;
|
||||||
|
}) => {
|
||||||
|
if (!data || !taskGroups) return;
|
||||||
|
|
||||||
|
if (data.progress_value !== undefined) {
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
|
||||||
|
if (task) {
|
||||||
|
dispatch(
|
||||||
|
updateTaskProgress({
|
||||||
|
taskId: data.task_id,
|
||||||
|
progress: data.progress_value,
|
||||||
|
totalTasksCount: task.total_tasks_count || 0,
|
||||||
|
completedCount: task.completed_count || 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, taskGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register socket event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const eventHandlers = [
|
||||||
|
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
|
||||||
|
{ event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
|
||||||
|
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
||||||
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
|
||||||
|
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
|
||||||
|
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
|
||||||
|
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
|
||||||
|
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
|
||||||
|
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
|
||||||
|
{ event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
|
||||||
|
{ event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
|
||||||
|
{ event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
|
||||||
|
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||||
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Register all event listeners
|
||||||
|
eventHandlers.forEach(({ event, handler }) => {
|
||||||
|
socket.on(event, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
eventHandlers.forEach(({ event, handler }) => {
|
||||||
|
socket.off(event, handler);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
socket,
|
||||||
|
handleAssigneesUpdate,
|
||||||
|
handleLabelsChange,
|
||||||
|
handleTaskStatusChange,
|
||||||
|
handleTaskProgress,
|
||||||
|
handlePriorityChange,
|
||||||
|
handleEndDateChange,
|
||||||
|
handleTaskNameChange,
|
||||||
|
handlePhaseChange,
|
||||||
|
handleStartDateChange,
|
||||||
|
handleTaskSubscribersChange,
|
||||||
|
handleEstimationChange,
|
||||||
|
handleTaskDescriptionChange,
|
||||||
|
handleNewTaskReceived,
|
||||||
|
handleTaskProgressUpdated,
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import Flex from 'antd/es/flex';
|
||||||
|
import Badge from 'antd/es/badge';
|
||||||
|
import Button from 'antd/es/button';
|
||||||
|
import Dropdown from 'antd/es/dropdown';
|
||||||
|
import Input from 'antd/es/input';
|
||||||
|
import Typography from 'antd/es/typography';
|
||||||
|
import { MenuProps } from 'antd/es/menu';
|
||||||
|
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { colors } from '@/styles/colors';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import Collapsible from '@/components/collapsible/collapsible';
|
||||||
|
import TaskListTable from '../../task-list-table/task-list-table';
|
||||||
|
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
|
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||||
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
|
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||||
|
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||||
|
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||||
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
|
interface TaskGroupProps {
|
||||||
|
taskGroup: ITaskListGroup;
|
||||||
|
groupBy: string;
|
||||||
|
color: string;
|
||||||
|
activeId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||||
|
taskGroup,
|
||||||
|
groupBy,
|
||||||
|
color,
|
||||||
|
activeId
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const isProjectManager = useIsProjectManager();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [groupName, setGroupName] = useState(taskGroup.name || '');
|
||||||
|
|
||||||
|
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||||
|
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// Memoize droppable configuration
|
||||||
|
const { setNodeRef } = useDroppable({
|
||||||
|
id: taskGroup.id,
|
||||||
|
data: {
|
||||||
|
type: 'group',
|
||||||
|
groupId: taskGroup.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize task count
|
||||||
|
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
|
||||||
|
|
||||||
|
// Memoize dropdown items
|
||||||
|
const dropdownItems: MenuProps['items'] = useMemo(() => {
|
||||||
|
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
label: t('renameText'),
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => setIsRenaming(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'change-category',
|
||||||
|
label: t('changeCategoryText'),
|
||||||
|
icon: <RetweetOutlined />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'todo',
|
||||||
|
label: t('todoText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('0'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'doing',
|
||||||
|
label: t('doingText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'done',
|
||||||
|
label: t('doneText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('2'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [groupBy, isProjectManager, t]);
|
||||||
|
|
||||||
|
const handleStatusCategoryChange = async (category: string) => {
|
||||||
|
if (!projectId || !taskGroup.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await statusApiService.updateStatus({
|
||||||
|
id: taskGroup.id,
|
||||||
|
category_id: category,
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(fetchStatuses());
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, {
|
||||||
|
column_id: taskGroup.id,
|
||||||
|
action: 'change_category',
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating status category:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!projectId || !taskGroup.id || !groupName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (groupBy === IGroupBy.STATUS) {
|
||||||
|
await statusApiService.updateStatus({
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
dispatch(fetchStatuses());
|
||||||
|
} else if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const phaseData: ITaskPhase = {
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
color_code: taskGroup.color_code,
|
||||||
|
};
|
||||||
|
await phasesApiService.updatePhase(phaseData);
|
||||||
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRenaming(false);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error renaming group:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = async (newColor: string) => {
|
||||||
|
if (!projectId || !taskGroup.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
|
||||||
|
? newColor.slice(0, -ALPHA_CHANNEL.length)
|
||||||
|
: newColor;
|
||||||
|
|
||||||
|
if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const phaseData: ITaskPhase = {
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: taskGroup.name || '',
|
||||||
|
project_id: projectId,
|
||||||
|
color_code: baseColor,
|
||||||
|
};
|
||||||
|
await phasesApiService.updatePhase(phaseData);
|
||||||
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskGroupColor({
|
||||||
|
groupId: taskGroup.id,
|
||||||
|
color: baseColor,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating group color:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef}>
|
||||||
|
<Flex vertical>
|
||||||
|
{/* Group Header */}
|
||||||
|
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||||
|
<Button
|
||||||
|
className="custom-collapse-button"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
border: 'none',
|
||||||
|
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||||
|
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||||
|
color: colors.darkGray,
|
||||||
|
minWidth: 200,
|
||||||
|
}}
|
||||||
|
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isRenaming ? (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={groupName}
|
||||||
|
onChange={e => setGroupName(e.target.value)}
|
||||||
|
onBlur={handleRename}
|
||||||
|
onPressEnter={handleRename}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{taskGroup.name} ({taskCount})
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{dropdownItems.length > 0 && !isRenaming && (
|
||||||
|
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||||
|
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<Collapsible isOpen={isExpanded}>
|
||||||
|
<TaskListTable
|
||||||
|
taskList={taskGroup.tasks || []}
|
||||||
|
tableId={taskGroup.id}
|
||||||
|
groupBy={groupBy}
|
||||||
|
color={color}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TaskGroup);
|
||||||
@@ -4,7 +4,7 @@ import Skeleton from 'antd/es/skeleton';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import TaskListFilters from './task-list-filters/task-list-filters';
|
import TaskListFilters from './task-list-filters/task-list-filters';
|
||||||
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
|
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||||
@@ -17,29 +17,50 @@ const ProjectViewTaskList = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
// Combine related selectors to reduce subscriptions
|
||||||
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
const {
|
||||||
state => state.taskReducer
|
projectId,
|
||||||
);
|
taskGroups,
|
||||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
loadingGroups,
|
||||||
state => state.taskStatusReducer
|
groupBy,
|
||||||
);
|
archived,
|
||||||
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
fields,
|
||||||
const { loadingColumns } = useAppSelector(state => state.taskReducer);
|
search,
|
||||||
|
} = useAppSelector(state => ({
|
||||||
|
projectId: state.projectReducer.projectId,
|
||||||
|
taskGroups: state.taskReducer.taskGroups,
|
||||||
|
loadingGroups: state.taskReducer.loadingGroups,
|
||||||
|
groupBy: state.taskReducer.groupBy,
|
||||||
|
archived: state.taskReducer.archived,
|
||||||
|
fields: state.taskReducer.fields,
|
||||||
|
search: state.taskReducer.search,
|
||||||
|
}));
|
||||||
|
|
||||||
// Memoize the loading state calculation - ignoring task list filter loading
|
const {
|
||||||
const isLoadingState = useMemo(() =>
|
statusCategories,
|
||||||
loadingGroups || loadingPhases || loadingStatusCategories,
|
loading: loadingStatusCategories,
|
||||||
[loadingGroups, loadingPhases, loadingStatusCategories]
|
} = useAppSelector(state => ({
|
||||||
|
statusCategories: state.taskStatusReducer.statusCategories,
|
||||||
|
loading: state.taskStatusReducer.loading,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadingPhases } = useAppSelector(state => ({
|
||||||
|
loadingPhases: state.phaseReducer.loadingPhases,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
||||||
|
// Labels loading should not block the main task list display
|
||||||
|
const isLoading = useMemo(() =>
|
||||||
|
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
|
||||||
|
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize the empty state check
|
// Memoize the empty state check
|
||||||
const isEmptyState = useMemo(() =>
|
const isEmptyState = useMemo(() =>
|
||||||
taskGroups && taskGroups.length === 0 && !isLoadingState,
|
taskGroups && taskGroups.length === 0 && !isLoading,
|
||||||
[taskGroups, isLoadingState]
|
[taskGroups, isLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle view type changes
|
// Handle view type changes
|
||||||
@@ -50,34 +71,32 @@ const ProjectViewTaskList = () => {
|
|||||||
newParams.set('pinned_tab', 'tasks-list');
|
newParams.set('pinned_tab', 'tasks-list');
|
||||||
setSearchParams(newParams);
|
setSearchParams(newParams);
|
||||||
}
|
}
|
||||||
}, [projectView, setSearchParams]);
|
}, [projectView, setSearchParams, searchParams]);
|
||||||
|
|
||||||
// Update loading state
|
// Batch initial data fetching - core data only
|
||||||
useEffect(() => {
|
|
||||||
setIsLoading(isLoadingState);
|
|
||||||
}, [isLoadingState]);
|
|
||||||
|
|
||||||
// Fetch initial data only once
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInitialData = async () => {
|
const fetchInitialData = async () => {
|
||||||
if (!projectId || !groupBy || initialLoadComplete) return;
|
if (!projectId || !groupBy || initialLoadComplete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
// Batch only essential API calls for initial load
|
||||||
|
// Filter data (labels, assignees, etc.) will load separately and not block the UI
|
||||||
|
await Promise.allSettled([
|
||||||
dispatch(fetchTaskListColumns(projectId)),
|
dispatch(fetchTaskListColumns(projectId)),
|
||||||
dispatch(fetchPhasesByProjectId(projectId)),
|
dispatch(fetchPhasesByProjectId(projectId)),
|
||||||
dispatch(fetchStatusesCategories())
|
dispatch(fetchStatusesCategories()),
|
||||||
]);
|
]);
|
||||||
setInitialLoadComplete(true);
|
setInitialLoadComplete(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching initial data:', error);
|
console.error('Error fetching initial data:', error);
|
||||||
|
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchInitialData();
|
fetchInitialData();
|
||||||
}, [projectId, groupBy, dispatch, initialLoadComplete]);
|
}, [projectId, groupBy, dispatch, initialLoadComplete]);
|
||||||
|
|
||||||
// Fetch task groups
|
// Fetch task groups with dependency on initial load completion
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTasks = async () => {
|
const fetchTasks = async () => {
|
||||||
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
|
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
|
||||||
@@ -92,15 +111,22 @@ const ProjectViewTaskList = () => {
|
|||||||
fetchTasks();
|
fetchTasks();
|
||||||
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
|
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
|
||||||
|
|
||||||
|
// Memoize the task groups to prevent unnecessary re-renders
|
||||||
|
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
{/* Filters load independently and don't block the main content */}
|
||||||
<TaskListFilters position="list" />
|
<TaskListFilters position="list" />
|
||||||
|
|
||||||
{isEmptyState ? (
|
{isEmptyState ? (
|
||||||
<Empty description="No tasks group found" />
|
<Empty description="No tasks group found" />
|
||||||
) : (
|
) : (
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
|
<TaskGroupWrapperOptimized
|
||||||
|
taskGroups={memoizedTaskGroups}
|
||||||
|
groupBy={groupBy}
|
||||||
|
/>
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import Flex from 'antd/es/flex';
|
||||||
|
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
pointerWithin,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
|
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||||
|
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
||||||
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
|
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
|
||||||
|
|
||||||
|
interface TaskGroupWrapperOptimizedProps {
|
||||||
|
taskGroups: ITaskListGroup[];
|
||||||
|
groupBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
|
||||||
|
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// Use extracted hooks
|
||||||
|
useTaskSocketHandlers();
|
||||||
|
const {
|
||||||
|
activeId,
|
||||||
|
sensors,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDragOver,
|
||||||
|
resetTaskRowStyles,
|
||||||
|
} = useTaskDragAndDrop({ taskGroups, groupBy });
|
||||||
|
|
||||||
|
// Memoize task groups with colors
|
||||||
|
const taskGroupsWithColors = useMemo(() =>
|
||||||
|
taskGroups?.map(taskGroup => ({
|
||||||
|
...taskGroup,
|
||||||
|
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||||
|
})) || [],
|
||||||
|
[taskGroups, themeMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add drag styles
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.task-row[data-is-dragging="true"] {
|
||||||
|
opacity: 0.5 !important;
|
||||||
|
transform: rotate(5deg) !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.task-row {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle animation cleanup after drag ends
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
if (activeId === null) {
|
||||||
|
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [activeId, resetTaskRowStyles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
>
|
||||||
|
<Flex gap={24} vertical>
|
||||||
|
{taskGroupsWithColors.map(taskGroup => (
|
||||||
|
<TaskListTableWrapper
|
||||||
|
key={taskGroup.id}
|
||||||
|
taskList={taskGroup.tasks}
|
||||||
|
tableId={taskGroup.id}
|
||||||
|
name={taskGroup.name}
|
||||||
|
groupBy={groupBy}
|
||||||
|
statusCategory={taskGroup.category_id}
|
||||||
|
color={taskGroup.displayColor}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
document.body,
|
||||||
|
'task-template-drawer'
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TaskGroupWrapperOptimized);
|
||||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||||
import {
|
import {
|
||||||
fetchLabelsByProject,
|
fetchLabelsByProject,
|
||||||
fetchTaskAssignees,
|
fetchTaskAssignees,
|
||||||
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
|||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
|
|
||||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
|
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
|
|
||||||
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
||||||
|
|
||||||
|
// Load filter data asynchronously and non-blocking
|
||||||
|
// This runs independently of the main task list loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInitialData = async () => {
|
const loadFilterData = async () => {
|
||||||
if (!priorities.length) await dispatch(fetchPriorities());
|
try {
|
||||||
if (projectId) {
|
// Load priorities first (usually cached/fast)
|
||||||
await dispatch(fetchLabelsByProject(projectId));
|
if (!priorities.length) {
|
||||||
await dispatch(fetchTaskAssignees(projectId));
|
dispatch(fetchPriorities());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project-specific filter data in parallel, but don't await
|
||||||
|
// This allows the main task list to load while filters are still loading
|
||||||
|
if (projectId) {
|
||||||
|
// Fire and forget - these will update the UI when ready
|
||||||
|
dispatch(fetchLabelsByProject(projectId));
|
||||||
|
dispatch(fetchTaskAssignees(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team members (usually needed for member filters)
|
||||||
|
dispatch(getTeamMembers({
|
||||||
|
index: 0,
|
||||||
|
size: 100,
|
||||||
|
field: null,
|
||||||
|
order: null,
|
||||||
|
search: null,
|
||||||
|
all: true
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter data:', error);
|
||||||
|
// Don't throw - filter loading errors shouldn't break the main UI
|
||||||
}
|
}
|
||||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchInitialData();
|
// Use setTimeout to ensure this runs after the main component render
|
||||||
|
// This prevents filter loading from blocking the initial render
|
||||||
|
const timeoutId = setTimeout(loadFilterData, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, [dispatch, priorities.length, projectId]);
|
}, [dispatch, priorities.length, projectId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user