= ({
+ 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: ,
+ onClick: () => setIsRenaming(true),
+ },
+ {
+ key: 'change-category',
+ label: t('changeCategoryText'),
+ icon: ,
+ 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 (
+
+
+ {/* Group Header */}
+
+ }
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ {isRenaming ? (
+ setGroupName(e.target.value)}
+ onBlur={handleRename}
+ onPressEnter={handleRename}
+ onClick={e => e.stopPropagation()}
+ autoFocus
+ />
+ ) : (
+
+ {taskGroup.name} ({taskCount})
+
+ )}
+
+
+ {dropdownItems.length > 0 && !isRenaming && (
+
+ } className="borderless-icon-btn" />
+
+ )}
+
+
+ {/* Task List */}
+
+
+
+
+
+ );
+};
+
+export default React.memo(TaskGroup);
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
index fcd4931a..29914771 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom';
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 { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
@@ -17,64 +17,116 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
- // Add local loading state to immediately show skeleton
- const [isLoading, setIsLoading] = useState(true);
+ const [initialLoadComplete, setInitialLoadComplete] = useState(false);
- const { projectId } = useAppSelector(state => state.projectReducer);
- const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
- state => state.taskReducer
- );
- const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
- state => state.taskStatusReducer
- );
- const { loadingPhases } = useAppSelector(state => state.phaseReducer);
- const { loadingColumns } = useAppSelector(state => state.taskReducer);
+ // Combine related selectors to reduce subscriptions
+ const {
+ projectId,
+ taskGroups,
+ loadingGroups,
+ groupBy,
+ archived,
+ fields,
+ 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,
+ }));
+ const {
+ statusCategories,
+ loading: 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
+ const isEmptyState = useMemo(() =>
+ taskGroups && taskGroups.length === 0 && !isLoading,
+ [taskGroups, isLoading]
+ );
+
+ // Handle view type changes
useEffect(() => {
- // Set default view to list if projectView is not list or board
if (projectView !== 'list' && projectView !== 'board') {
- searchParams.set('tab', 'tasks-list');
- searchParams.set('pinned_tab', 'tasks-list');
- setSearchParams(searchParams);
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set('tab', 'tasks-list');
+ newParams.set('pinned_tab', 'tasks-list');
+ setSearchParams(newParams);
}
- }, [projectView, searchParams, setSearchParams]);
+ }, [projectView, setSearchParams, searchParams]);
+ // Batch initial data fetching - core data only
useEffect(() => {
- // Set loading state based on all loading conditions
- setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
- }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
+ const fetchInitialData = async () => {
+ if (!projectId || !groupBy || initialLoadComplete) return;
- useEffect(() => {
- const loadData = async () => {
- if (projectId && groupBy) {
- const promises = [];
-
- if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
- if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
- if (!loadingGroups && projectView === 'list') {
- promises.push(dispatch(fetchTaskGroups(projectId)));
- }
- if (!statusCategories.length) {
- promises.push(dispatch(fetchStatusesCategories()));
- }
-
- // Wait for all data to load
- await Promise.all(promises);
+ try {
+ // 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(fetchPhasesByProjectId(projectId)),
+ dispatch(fetchStatusesCategories()),
+ ]);
+ setInitialLoadComplete(true);
+ } catch (error) {
+ console.error('Error fetching initial data:', error);
+ setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
}
};
-
- loadData();
- }, [dispatch, projectId, groupBy, fields, search, archived]);
+
+ fetchInitialData();
+ }, [projectId, groupBy, dispatch, initialLoadComplete]);
+
+ // Fetch task groups with dependency on initial load completion
+ useEffect(() => {
+ const fetchTasks = async () => {
+ if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
+
+ try {
+ await dispatch(fetchTaskGroups(projectId));
+ } catch (error) {
+ console.error('Error fetching task groups:', error);
+ }
+ };
+
+ fetchTasks();
+ }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
+
+ // Memoize the task groups to prevent unnecessary re-renders
+ const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
return (
+ {/* Filters load independently and don't block the main content */}
- {(taskGroups && taskGroups.length === 0 && !isLoading) ? (
+ {isEmptyState ? (
) : (
-
+
)}
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx
new file mode 100644
index 00000000..71257305
--- /dev/null
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx
@@ -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 (
+
+
+ {taskGroupsWithColors.map(taskGroup => (
+
+ ))}
+
+ {createPortal(, document.body, 'bulk-action-container')}
+
+ {createPortal(
+ {}} />,
+ document.body,
+ 'task-template-drawer'
+ )}
+
+
+ );
+};
+
+export default React.memo(TaskGroupWrapperOptimized);
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
index c32153b8..fcf866f1 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import {
fetchLabelsByProject,
fetchTaskAssignees,
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC = ({ position }) => {
const { projectView } = useTabSearchParam();
const priorities = useAppSelector(state => state.priorityReducer.priorities);
-
const projectId = useAppSelector(state => state.projectReducer.projectId);
const archived = useAppSelector(state => state.taskReducer.archived);
const handleShowArchivedChange = () => dispatch(toggleArchived());
+ // Load filter data asynchronously and non-blocking
+ // This runs independently of the main task list loading
useEffect(() => {
- const fetchInitialData = async () => {
- if (!priorities.length) await dispatch(fetchPriorities());
- if (projectId) {
- await dispatch(fetchLabelsByProject(projectId));
- await dispatch(fetchTaskAssignees(projectId));
+ const loadFilterData = async () => {
+ try {
+ // Load priorities first (usually cached/fast)
+ if (!priorities.length) {
+ 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]);
return (
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
index f619f20a..6a0e9374 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { projectId } = useAppSelector(state => state.projectReducer);
- const sensors = useSensors(
- useSensor(PointerSensor, {
+ // Move useSensors to top level and memoize its configuration
+ const sensorConfig = useMemo(
+ () => ({
activationConstraint: { distance: 8 },
- })
+ }),
+ []
);
+ const pointerSensor = useSensor(PointerSensor, sensorConfig);
+ const sensors = useSensors(pointerSensor);
+
useEffect(() => {
setGroups(taskGroups);
}, [taskGroups]);
+ // Memoize resetTaskRowStyles to prevent unnecessary re-renders
const resetTaskRowStyles = useCallback(() => {
document.querySelectorAll('.task-row').forEach(row => {
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}, []);
- // Socket handler for assignee updates
- useEffect(() => {
- if (!socket) return;
-
- const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
+ // Memoize socket event handlers
+ const handleAssigneesUpdate = useCallback(
+ (data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
- const updatedAssignees = data.assignees.map(assignee => ({
+ const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
- }));
+ })) || [];
- // Find the group that contains the task or its subtasks
- const groupId = groups.find(group =>
- group.tasks.some(
+ const groupId = groups?.find(group =>
+ group.tasks?.some(
task =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
})
);
- dispatch(setTaskAssignee(data));
+ dispatch(
+ setTaskAssignee({
+ ...data,
+ manual_progress: false,
+ } as IProjectTask)
+ );
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
- };
+ },
+ [groups, dispatch, currentSession?.team_id, loadingAssignees]
+ );
- socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
- return () => {
- socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
- };
- }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
-
- // Socket handler for label updates
- useEffect(() => {
- if (!socket) return;
-
- const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
+ // Memoize socket event handlers
+ 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]
+ );
- socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
- socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
+ // Memoize socket event handlers
+ const handleTaskStatusChange = useCallback(
+ (response: ITaskListStatusChangeResponse) => {
+ if (!response) return;
- return () => {
- socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
- socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
- };
- }, [socket, dispatch, projectId]);
-
- // Socket handler for status updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
dispatch(updateTaskStatus(response));
- // dispatch(setTaskStatus(response));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- const handleTaskProgress = (data: {
+ // Memoize socket event handlers
+ const handleTaskProgress = useCallback(
+ (data: {
id: string;
status: string;
complete_ratio: number;
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
total_tasks_count: number;
parent_task: string;
}) => {
+ if (!data) return;
+
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
completedCount: data.completed_count,
})
);
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
- socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
+ // Memoize socket event handlers
+ const handlePriorityChange = useCallback(
+ (response: ITaskListPriorityChangeResponse) => {
+ if (!response) return;
- return () => {
- socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
- socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
- };
- }, [socket, dispatch]);
-
- // Socket handler for priority updates
- useEffect(() => {
- if (!socket) return;
-
- const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
-
- return () => {
- socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for due date updates
- useEffect(() => {
- if (!socket) return;
-
- const handleEndDateChange = (task: {
+ // Memoize socket event handlers
+ const handleEndDateChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
- dispatch(updateTaskEndDate({ task }));
- dispatch(setTaskEndDate(task));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskEndDate({ task: taskWithProgress }));
+ dispatch(setTaskEndDate(taskWithProgress));
+ },
+ [dispatch]
+ );
- // Socket handler for task name updates
- useEffect(() => {
- if (!socket) return;
+ // Memoize socket event handlers
+ const handleTaskNameChange = useCallback(
+ (data: { id: string; parent_task: string; name: string }) => {
+ if (!data) return;
- const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
+ // Memoize socket event handlers
+ const handlePhaseChange = useCallback(
+ (data: ITaskPhaseChangeResponse) => {
+ if (!data) return;
- return () => {
- socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for phase updates
- useEffect(() => {
- if (!socket) return;
-
- const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
-
- return () => {
- socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for start date updates
- useEffect(() => {
- if (!socket) return;
-
- const handleStartDateChange = (task: {
+ // Memoize socket event handlers
+ const handleStartDateChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
- dispatch(updateTaskStartDate({ task }));
- dispatch(setStartDate(task));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskStartDate({ task: taskWithProgress }));
+ dispatch(setStartDate(taskWithProgress));
+ },
+ [dispatch]
+ );
- // Socket handler for task subscribers updates
- useEffect(() => {
- if (!socket) return;
+ // Memoize socket event handlers
+ const handleTaskSubscribersChange = useCallback(
+ (data: InlineMember[]) => {
+ if (!data) return;
- const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
-
- return () => {
- socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for task estimation updates
- useEffect(() => {
- if (!socket) return;
-
- const handleEstimationChange = (task: {
+ // Memoize socket event handlers
+ const handleEstimationChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
- dispatch(updateTaskEstimation({ task }));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskEstimation({ task: taskWithProgress }));
+ },
+ [dispatch]
+ );
- // Socket handler for task description updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskDescriptionChange = (data: {
+ // Memoize socket event handlers
+ const handleTaskDescriptionChange = useCallback(
+ (data: {
id: string;
parent_task: string;
description: string;
}) => {
+ if (!data) return;
+
dispatch(updateTaskDescription(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
-
- return () => {
- socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for new task creation
- useEffect(() => {
- if (!socket) return;
-
- const handleNewTaskReceived = (data: IProjectTask) => {
+ // Memoize socket event handlers
+ const handleNewTaskReceived = useCallback(
+ (data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
-
- return () => {
- socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
- };
- }, [socket, dispatch]);
-
- // Socket handler for task progress updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskProgressUpdated = (data: {
+ // Memoize socket event handlers
+ const handleTaskProgressUpdated = useCallback(
+ (data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
+ if (!data || !taskGroups) return;
+
if (data.progress_value !== undefined) {
- // Find the task in the task groups and update its progress
for (const group of taskGroups) {
- const task = group.tasks.find(task => task.id === data.task_id);
+ const task = group.tasks?.find(task => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
@@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
}
}
+ },
+ [dispatch, taskGroups]
+ );
+
+ // Set up socket event listeners
+ useEffect(() => {
+ if (!socket) return;
+
+ const eventHandlers = {
+ [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
+ [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
+ [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
+ [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
+ [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
+ [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
+ [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
+ [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
+ [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
+ [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
+ [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
+ [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
+ [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
+ [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
+ [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
};
- socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
+ // Register all event handlers
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.on(event, handler);
+ }
+ });
+ // Cleanup function
return () => {
- socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.off(event, handler);
+ }
+ });
};
- }, [socket, dispatch, taskGroups]);
+ }, [
+ socket,
+ handleAssigneesUpdate,
+ handleLabelsChange,
+ handleTaskStatusChange,
+ handleTaskProgress,
+ handlePriorityChange,
+ handleEndDateChange,
+ handleTaskNameChange,
+ handlePhaseChange,
+ handleStartDateChange,
+ handleTaskSubscribersChange,
+ handleEstimationChange,
+ handleTaskDescriptionChange,
+ handleNewTaskReceived,
+ handleTaskProgressUpdated,
+ ]);
+ // Memoize drag handlers
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);
- // Add smooth transition to the dragged item
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
if (draggedElement) {
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
}
}, []);
+ // Memoize drag handlers
const handleDragEnd = useCallback(
async ({ active, over }: DragEndEvent) => {
setActiveId(null);
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (fromIndex === -1) return;
- // Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
- // Check if task dependencies allow the move
if (activeGroupId !== overGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
if (!canContinue) {
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
- // Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
task.priority_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PHASE:
- // Check if ALPHA_CHANNEL is already added
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
- ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
- : targetGroup.color_code; // Use as is if not present
+ ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
+ : targetGroup.color_code;
task.phase_id = overGroupId;
- task.phase_color = baseColor; // Set the cleaned color
+ task.phase_color = baseColor;
break;
}
}
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
-
- // Calculate toIndex - for empty groups, always add at index 0
const toIndex = isTargetGroupEmpty
? 0
: overTaskId
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
: targetGroup.tasks.length;
- // Calculate toPos similar to Angular implementation
const toPos = isTargetGroupEmpty
? -1
: targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
- // Update Redux state
if (activeGroupId === overGroupId) {
- // Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
- // Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
- // Emit socket event
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy,
- task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
+ task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
});
- // Reset styles
setTimeout(resetTaskRowStyles, 0);
-
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
+ // Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
if (fromIndex === -1 || toIndex === -1) return;
- // Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
- // Update Redux state
if (activeGroupId === overGroupId) {
- // Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
- // Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
-
updatedTargetTasks.splice(toIndex, 0, task);
dispatch({
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
- // Final cleanup after React updates DOM
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
diff --git a/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx b/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx
index 714947fc..7da55d59 100644
--- a/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx
+++ b/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx
@@ -51,7 +51,7 @@ const ProjectTemplatesSettings = () => {
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
className="button-visibilty"
>
-
+ {/*
-
+ */}