diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts new file mode 100644 index 00000000..e3aa4f41 --- /dev/null +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -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, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts new file mode 100644 index 00000000..cab0a361 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts @@ -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; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts new file mode 100644 index 00000000..7c85ead6 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -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, + ]); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx new file mode 100644 index 00000000..e5800fe4 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx @@ -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 = ({ + 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 */} + + + + {dropdownItems.length > 0 && !isRenaming && ( + +
+ ); +}; + +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 410644fb..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 @@ -4,7 +4,7 @@ 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,29 +17,50 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - 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, + })); - // Memoize the loading state calculation - ignoring task list filter loading - const isLoadingState = useMemo(() => - loadingGroups || loadingPhases || loadingStatusCategories, - [loadingGroups, loadingPhases, loadingStatusCategories] + 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 && !isLoadingState, - [taskGroups, isLoadingState] + taskGroups && taskGroups.length === 0 && !isLoading, + [taskGroups, isLoading] ); // Handle view type changes @@ -50,34 +71,32 @@ const ProjectViewTaskList = () => { newParams.set('pinned_tab', 'tasks-list'); setSearchParams(newParams); } - }, [projectView, setSearchParams]); + }, [projectView, setSearchParams, searchParams]); - // Update loading state - useEffect(() => { - setIsLoading(isLoadingState); - }, [isLoadingState]); - - // Fetch initial data only once + // Batch initial data fetching - core data only useEffect(() => { const fetchInitialData = async () => { if (!projectId || !groupBy || initialLoadComplete) return; 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(fetchPhasesByProjectId(projectId)), - dispatch(fetchStatusesCategories()) + dispatch(fetchStatusesCategories()), ]); setInitialLoadComplete(true); } catch (error) { console.error('Error fetching initial data:', error); + setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading } }; fetchInitialData(); }, [projectId, groupBy, dispatch, initialLoadComplete]); - // Fetch task groups + // Fetch task groups with dependency on initial load completion useEffect(() => { const fetchTasks = async () => { if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; @@ -92,15 +111,22 @@ const ProjectViewTaskList = () => { 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 */} {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 (