Merge branch 'imp/task-list-performance-fixes' of https://github.com/Worklenz/worklenz into release/v2.0.4
This commit is contained in:
@@ -76,6 +76,10 @@ interface BoardState {
|
||||
priorities: string[];
|
||||
members: string[];
|
||||
editableSectionId: string | null;
|
||||
|
||||
allTasks: IProjectTask[];
|
||||
grouping: string;
|
||||
totalTasks: number;
|
||||
}
|
||||
|
||||
const initialState: BoardState = {
|
||||
@@ -98,6 +102,9 @@ const initialState: BoardState = {
|
||||
priorities: [],
|
||||
members: [],
|
||||
editableSectionId: null,
|
||||
allTasks: [],
|
||||
grouping: '',
|
||||
totalTasks: 0,
|
||||
};
|
||||
|
||||
const deleteTaskFromGroup = (
|
||||
@@ -186,7 +193,7 @@ export const fetchBoardTaskGroups = createAsyncThunk(
|
||||
priorities: boardReducer.priorities.join(' '),
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Task Groups', error);
|
||||
@@ -803,7 +810,10 @@ const boardSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
state.taskGroups = action.payload;
|
||||
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||
state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||
})
|
||||
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { Task, TaskManagementState } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { tasksApiService, ITaskListConfigV2, ITaskListV3Response } from '@/api/tasks/tasks.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
// Entity adapter for normalized state
|
||||
const tasksAdapter = createEntityAdapter<Task>({
|
||||
selectId: (task) => task.id,
|
||||
sortComparer: (a, b) => a.order - b.order,
|
||||
});
|
||||
|
||||
@@ -13,8 +14,198 @@ const initialState: TaskManagementState = {
|
||||
ids: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
groups: [],
|
||||
grouping: null,
|
||||
selectedPriorities: [],
|
||||
};
|
||||
|
||||
// Async thunk to fetch tasks from API
|
||||
export const fetchTasks = createAsyncThunk(
|
||||
'taskManagement/fetchTasks',
|
||||
async (projectId: string, { rejectWithValue, getState }) => {
|
||||
try {
|
||||
const state = getState() as RootState;
|
||||
const currentGrouping = state.grouping.currentGrouping;
|
||||
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
field: '',
|
||||
order: '',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: '',
|
||||
projects: '',
|
||||
isSubtasksInclude: false,
|
||||
labels: '',
|
||||
priorities: '',
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
|
||||
// Helper function to safely convert time values
|
||||
const convertTimeValue = (value: any): number => {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Handle time objects like {hours: 2, minutes: 30}
|
||||
if ('hours' in value || 'minutes' in value) {
|
||||
const hours = Number(value.hours || 0);
|
||||
const minutes = Number(value.minutes || 0);
|
||||
return hours + (minutes / 60);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Create a mapping from status IDs to group names
|
||||
const statusIdToNameMap: Record<string, string> = {};
|
||||
const priorityIdToNameMap: Record<string, string> = {};
|
||||
|
||||
response.body.forEach((group: any) => {
|
||||
statusIdToNameMap[group.id] = group.name.toLowerCase();
|
||||
});
|
||||
|
||||
// For priority mapping, we need to get priority names from the tasks themselves
|
||||
// Since the API doesn't provide priority names in the group structure
|
||||
response.body.forEach((group: any) => {
|
||||
group.tasks.forEach((task: any) => {
|
||||
// Map priority value to name (this is an assumption based on common patterns)
|
||||
if (task.priority_value !== undefined) {
|
||||
switch (task.priority_value) {
|
||||
case 0: priorityIdToNameMap[task.priority] = 'low'; break;
|
||||
case 1: priorityIdToNameMap[task.priority] = 'medium'; break;
|
||||
case 2: priorityIdToNameMap[task.priority] = 'high'; break;
|
||||
case 3: priorityIdToNameMap[task.priority] = 'critical'; break;
|
||||
default: priorityIdToNameMap[task.priority] = 'medium';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transform the API response to our Task type
|
||||
const tasks: Task[] = response.body.flatMap((group: any) =>
|
||||
group.tasks.map((task: any) => ({
|
||||
id: task.id,
|
||||
task_key: task.task_key || '',
|
||||
title: task.name || '',
|
||||
description: task.description || '',
|
||||
status: statusIdToNameMap[task.status] || 'todo',
|
||||
priority: priorityIdToNameMap[task.priority] || 'medium',
|
||||
phase: task.phase_name || 'Development',
|
||||
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||
assignee_names: task.assignee_names || task.names || [],
|
||||
labels: task.labels?.map((l: any) => ({
|
||||
id: l.id || l.label_id,
|
||||
name: l.name,
|
||||
color: l.color_code || '#1890ff',
|
||||
end: l.end,
|
||||
names: l.names
|
||||
})) || [],
|
||||
dueDate: task.end_date,
|
||||
timeTracking: {
|
||||
estimated: convertTimeValue(task.total_time),
|
||||
logged: convertTimeValue(task.time_spent),
|
||||
},
|
||||
customFields: {},
|
||||
createdAt: task.created_at || new Date().toISOString(),
|
||||
updatedAt: task.updated_at || new Date().toISOString(),
|
||||
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||
}))
|
||||
);
|
||||
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Tasks', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch tasks');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// New V3 fetch that minimizes frontend processing
|
||||
export const fetchTasksV3 = createAsyncThunk(
|
||||
'taskManagement/fetchTasksV3',
|
||||
async (projectId: string, { rejectWithValue, getState }) => {
|
||||
try {
|
||||
const state = getState() as RootState;
|
||||
const currentGrouping = state.grouping.currentGrouping;
|
||||
|
||||
// Get selected labels from taskReducer
|
||||
const selectedLabels = state.taskReducer.labels
|
||||
? state.taskReducer.labels.filter(l => l.selected).map(l => l.id).join(',')
|
||||
: '';
|
||||
|
||||
// Get selected assignees from taskReducer
|
||||
const selectedAssignees = state.taskReducer.taskAssignees
|
||||
? state.taskReducer.taskAssignees.filter(m => m.selected).map(m => m.id).join(',')
|
||||
: '';
|
||||
|
||||
// Get selected priorities from taskManagement slice
|
||||
const selectedPriorities = state.taskManagement.selectedPriorities
|
||||
? state.taskManagement.selectedPriorities.join(',')
|
||||
: '';
|
||||
|
||||
console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities);
|
||||
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
field: '',
|
||||
order: '',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: selectedAssignees,
|
||||
projects: '',
|
||||
isSubtasksInclude: false,
|
||||
labels: selectedLabels,
|
||||
priorities: selectedPriorities,
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
|
||||
// Minimal processing - tasks are already processed by backend
|
||||
return {
|
||||
tasks: response.body.allTasks,
|
||||
groups: response.body.groups,
|
||||
grouping: response.body.grouping,
|
||||
totalTasks: response.body.totalTasks
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Fetch Tasks V3', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch tasks');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Refresh task progress separately to avoid slowing down initial load
|
||||
export const refreshTaskProgress = createAsyncThunk(
|
||||
'taskManagement/refreshTaskProgress',
|
||||
async (projectId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await tasksApiService.refreshTaskProgress(projectId);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Refresh Task Progress', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to refresh task progress');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const taskManagementSlice = createSlice({
|
||||
name: 'taskManagement',
|
||||
initialState: tasksAdapter.getInitialState(initialState),
|
||||
@@ -61,13 +252,19 @@ const taskManagementSlice = createSlice({
|
||||
tasksAdapter.removeMany(state, action.payload);
|
||||
},
|
||||
|
||||
// Drag and drop operations
|
||||
// Optimized drag and drop operations
|
||||
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
||||
const { taskIds, newOrder } = action.payload;
|
||||
|
||||
// Batch update for better performance
|
||||
const updates = taskIds.map((id, index) => ({
|
||||
id,
|
||||
changes: { order: newOrder[index] },
|
||||
changes: {
|
||||
order: newOrder[index],
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
|
||||
tasksAdapter.updateMany(state, updates);
|
||||
},
|
||||
|
||||
@@ -89,6 +286,34 @@ const taskManagementSlice = createSlice({
|
||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||
},
|
||||
|
||||
// Optimistic update for drag operations - reduces perceived lag
|
||||
optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => {
|
||||
const { taskId, newGroupId, newIndex } = action.payload;
|
||||
const task = state.entities[taskId];
|
||||
|
||||
if (task) {
|
||||
// Parse group ID to determine new values
|
||||
const [groupType, ...groupValueParts] = newGroupId.split('-');
|
||||
const groupValue = groupValueParts.join('-');
|
||||
|
||||
const changes: Partial<Task> = {
|
||||
order: newIndex,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update group-specific field
|
||||
if (groupType === 'status') {
|
||||
changes.status = groupValue as Task['status'];
|
||||
} else if (groupType === 'priority') {
|
||||
changes.priority = groupValue as Task['priority'];
|
||||
} else if (groupType === 'phase') {
|
||||
changes.phase = groupValue;
|
||||
}
|
||||
|
||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||
}
|
||||
},
|
||||
|
||||
// Loading states
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
@@ -98,6 +323,54 @@ const taskManagementSlice = createSlice({
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
|
||||
// Filter actions
|
||||
setSelectedPriorities: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedPriorities = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchTasks.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
tasksAdapter.setAll(state, action.payload);
|
||||
})
|
||||
.addCase(fetchTasks.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || 'Failed to fetch tasks';
|
||||
})
|
||||
.addCase(fetchTasksV3.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchTasksV3.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
// Tasks are already processed by backend, minimal setup needed
|
||||
tasksAdapter.setAll(state, action.payload.tasks);
|
||||
state.groups = action.payload.groups;
|
||||
state.grouping = action.payload.grouping;
|
||||
})
|
||||
.addCase(fetchTasksV3.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || 'Failed to fetch tasks';
|
||||
})
|
||||
.addCase(refreshTaskProgress.pending, (state) => {
|
||||
// Don't set loading to true for refresh to avoid UI blocking
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(refreshTaskProgress.fulfilled, (state) => {
|
||||
state.error = null;
|
||||
// Progress refresh completed successfully
|
||||
})
|
||||
.addCase(refreshTaskProgress.rejected, (state, action) => {
|
||||
state.error = action.payload as string || 'Failed to refresh task progress';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,16 +383,20 @@ export const {
|
||||
bulkDeleteTasks,
|
||||
reorderTasks,
|
||||
moveTaskToGroup,
|
||||
optimisticTaskMove,
|
||||
setLoading,
|
||||
setError,
|
||||
setSelectedPriorities,
|
||||
} = taskManagementSlice.actions;
|
||||
|
||||
export default taskManagementSlice.reducer;
|
||||
|
||||
// Selectors
|
||||
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
||||
(state) => state.taskManagement
|
||||
);
|
||||
|
||||
// Additional selectors
|
||||
// Enhanced selectors for better performance
|
||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
||||
|
||||
@@ -132,4 +409,6 @@ export const selectTasksByPhase = (state: RootState, phase: string) =>
|
||||
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
||||
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
||||
|
||||
export default taskManagementSlice.reducer;
|
||||
// V3 API selectors - no processing needed, data is pre-processed by backend
|
||||
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
||||
export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping;
|
||||
@@ -80,6 +80,9 @@ interface ITaskState {
|
||||
convertToSubtaskDrawerOpen: boolean;
|
||||
customColumns: ITaskListColumn[];
|
||||
customColumnValues: Record<string, Record<string, any>>;
|
||||
allTasks: IProjectTask[];
|
||||
grouping: string;
|
||||
totalTasks: number;
|
||||
}
|
||||
|
||||
const initialState: ITaskState = {
|
||||
@@ -105,6 +108,9 @@ const initialState: ITaskState = {
|
||||
convertToSubtaskDrawerOpen: false,
|
||||
customColumns: [],
|
||||
customColumnValues: {},
|
||||
allTasks: [],
|
||||
grouping: '',
|
||||
totalTasks: 0,
|
||||
};
|
||||
|
||||
export const COLUMN_KEYS = {
|
||||
@@ -165,7 +171,7 @@ export const fetchTaskGroups = createAsyncThunk(
|
||||
priorities: taskReducer.priorities.join(' '),
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Task Groups', error);
|
||||
@@ -234,9 +240,9 @@ export const fetchSubTasks = createAsyncThunk(
|
||||
parent_task: taskId,
|
||||
};
|
||||
try {
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
// Only expand if we actually fetched subtasks
|
||||
if (response.body.length > 0) {
|
||||
if (response.body && response.body.groups && response.body.groups.length > 0) {
|
||||
dispatch(toggleTaskRowExpansion(taskId));
|
||||
}
|
||||
return response.body;
|
||||
@@ -1026,7 +1032,10 @@ const taskSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
state.taskGroups = action.payload;
|
||||
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||
state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||
})
|
||||
.addCase(fetchTaskGroups.rejected, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
@@ -1035,14 +1044,16 @@ const taskSlice = createSlice({
|
||||
.addCase(fetchSubTasks.pending, state => {
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction<IProjectTask[]>) => {
|
||||
if (action.payload.length > 0) {
|
||||
const taskId = action.payload[0].parent_task_id;
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
if (action.payload && action.payload.groups && action.payload.groups.length > 0) {
|
||||
// Assuming subtasks are in the first group for this context
|
||||
const subtasks = action.payload.groups[0].tasks;
|
||||
const taskId = subtasks.length > 0 ? subtasks[0].parent_task_id : null;
|
||||
if (taskId) {
|
||||
for (const group of state.taskGroups) {
|
||||
const task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.sub_tasks = action.payload;
|
||||
task.sub_tasks = subtasks;
|
||||
task.show_sub_tasks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user