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,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user