@zeniai/client-epic-state 5.1.45 → 5.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
- package/lib/entity/snackbar/snackbarTypes.js +3 -0
- package/lib/entity/task/taskPayload.d.ts +3 -1
- package/lib/entity/task/taskPayload.js +24 -2
- package/lib/entity/task/taskReducer.d.ts +8 -1
- package/lib/entity/task/taskReducer.js +29 -4
- package/lib/entity/task/taskState.d.ts +2 -0
- package/lib/entity/tenant/epic/saveExternalConnectionEpic.d.ts +2 -1
- package/lib/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
- package/lib/epic.d.ts +6 -3
- package/lib/epic.js +6 -3
- package/lib/esm/entity/snackbar/snackbarTypes.js +3 -0
- package/lib/esm/entity/task/taskPayload.js +24 -2
- package/lib/esm/entity/task/taskReducer.js +28 -3
- package/lib/esm/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
- package/lib/esm/epic.js +6 -3
- package/lib/esm/index.js +8 -8
- package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +31 -1
- package/lib/esm/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
- package/lib/esm/view/aiCfoView/epics/submitFeedbackEpic.js +39 -0
- package/lib/esm/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
- package/lib/esm/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
- package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +96 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +53 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +70 -9
- package/lib/esm/view/taskManager/taskDetailView/taskDetailReducer.js +56 -2
- package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +35 -9
- package/lib/esm/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
- package/lib/esm/view/taskManager/taskListView/taskList.js +1 -0
- package/lib/esm/view/taskManager/taskListView/taskListReducer.js +310 -18
- package/lib/esm/view/taskManager/taskListView/taskListSelector.js +1 -0
- package/lib/esm/view/taskManager/taskListView/taskListViewHelpers.js +58 -0
- package/lib/esm/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
- package/lib/esm/view/transactionDetail/epics/updateTransactionDetailEpic.js +75 -3
- package/lib/esm/view/transactionDetail/journalEntryLinesViewModel.js +58 -0
- package/lib/esm/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
- package/lib/esm/view/transactionDetail/transactionDetailReducer.js +66 -1
- package/lib/index.d.ts +12 -11
- package/lib/index.js +37 -28
- package/lib/view/aiCfoView/aiCfoViewPayload.d.ts +10 -0
- package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +7 -2
- package/lib/view/aiCfoView/aiCfoViewReducer.js +32 -2
- package/lib/view/aiCfoView/aiCfoViewState.d.ts +5 -1
- package/lib/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
- package/lib/view/aiCfoView/epics/submitFeedbackEpic.d.ts +13 -0
- package/lib/view/aiCfoView/epics/submitFeedbackEpic.js +43 -0
- package/lib/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
- package/lib/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
- package/lib/view/companyView/types/cockpitTypes.d.ts +3 -0
- package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
- package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
- package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.d.ts +9 -0
- package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +100 -0
- package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.d.ts +8 -0
- package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +57 -0
- package/lib/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
- package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.d.ts +2 -2
- package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +69 -8
- package/lib/view/taskManager/taskDetailView/taskDetail.d.ts +15 -0
- package/lib/view/taskManager/taskDetailView/taskDetailReducer.d.ts +14 -2
- package/lib/view/taskManager/taskDetailView/taskDetailReducer.js +57 -3
- package/lib/view/taskManager/taskDetailView/taskDetailSelector.d.ts +3 -1
- package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +33 -7
- package/lib/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
- package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
- package/lib/view/taskManager/taskListView/taskList.js +1 -0
- package/lib/view/taskManager/taskListView/taskListPayload.d.ts +4 -0
- package/lib/view/taskManager/taskListView/taskListReducer.d.ts +6 -2
- package/lib/view/taskManager/taskListView/taskListReducer.js +311 -19
- package/lib/view/taskManager/taskListView/taskListSelector.js +1 -0
- package/lib/view/taskManager/taskListView/taskListViewHelpers.d.ts +11 -0
- package/lib/view/taskManager/taskListView/taskListViewHelpers.js +60 -1
- package/lib/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
- package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.d.ts +5 -1
- package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.js +77 -4
- package/lib/view/transactionDetail/journalEntryLinesViewModel.d.ts +27 -0
- package/lib/view/transactionDetail/journalEntryLinesViewModel.js +61 -1
- package/lib/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
- package/lib/view/transactionDetail/transactionDetailReducer.d.ts +11 -2
- package/lib/view/transactionDetail/transactionDetailReducer.js +67 -2
- package/lib/view/transactionDetail/transactionDetailState.d.ts +8 -1
- package/lib/view/transactionDetail/transactionDetailTypes.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import isEqual from 'lodash/isEqual';
|
|
2
|
-
import {
|
|
2
|
+
import { reduceAnyFetchState } from '../../../commonStateTypes/reduceFetchState';
|
|
3
3
|
import { getClassesByIds } from '../../../entity/class/classSelector';
|
|
4
4
|
import { getFilesByFileIds } from '../../../entity/file/fileSelector';
|
|
5
|
-
import { getTaskById } from '../../../entity/task/taskSelector';
|
|
5
|
+
import { getTaskById, getTasksByIds } from '../../../entity/task/taskSelector';
|
|
6
6
|
import { getUserAndUserRole, } from '../../companyView/types/userAndRole';
|
|
7
7
|
import { getFileDeleteStatusById, getFileUpdateNameStatusById, } from '../../fileView/fileViewSelector';
|
|
8
8
|
import { getUserList, } from '../../userListView/userListViewSelector';
|
|
@@ -24,40 +24,58 @@ export const getTaskDetail = (state, taskId) => {
|
|
|
24
24
|
let createdByUser = undefined;
|
|
25
25
|
let isArchived = undefined;
|
|
26
26
|
let isDeleted = undefined;
|
|
27
|
+
let depth = undefined;
|
|
27
28
|
let recurringSourceTaskId = undefined;
|
|
28
29
|
let snoozedUntil = undefined;
|
|
29
30
|
let taskGroupId = undefined;
|
|
31
|
+
let subtasks = [];
|
|
30
32
|
if (taskId != null && sourceTaskDetail != null) {
|
|
31
33
|
const taskEntity = getTaskById(taskState, taskId);
|
|
34
|
+
subtasks =
|
|
35
|
+
taskEntity != null
|
|
36
|
+
? getTasksByIds(taskState, taskEntity.subTasksIds)
|
|
37
|
+
: [];
|
|
32
38
|
const fileIdsInEntity = taskEntity?.fileIds ?? [];
|
|
33
39
|
if (taskEntity != null) {
|
|
34
40
|
createdByUser = getUserAndUserRole(userState, userRoleState, addressState, taskEntity.createdBy);
|
|
41
|
+
depth = taskEntity.depth;
|
|
35
42
|
isArchived = taskEntity.isArchived;
|
|
36
43
|
isDeleted = taskEntity.isDeleted;
|
|
37
44
|
recurringSourceTaskId = taskEntity.recurringSourceTaskId;
|
|
38
45
|
snoozedUntil = taskEntity.snoozedUntil;
|
|
39
46
|
taskGroupId = taskEntity.taskGroupIds[0];
|
|
40
47
|
}
|
|
48
|
+
// Treat "no entry" as Completed so it doesn't block the combined
|
|
49
|
+
// fetch state. The Not-Started default would pin the detail page
|
|
50
|
+
// below Completed forever before the first fetchSubTasks dispatches.
|
|
51
|
+
const completedSubTaskStatus = {
|
|
52
|
+
fetchState: 'Completed',
|
|
53
|
+
error: undefined,
|
|
54
|
+
};
|
|
55
|
+
const subTaskStatus = taskDetailState.subTaskListFetchStatusByParentId[taskId] ??
|
|
56
|
+
completedSubTaskStatus;
|
|
41
57
|
if (fileIdsInEntity.length > 0) {
|
|
42
|
-
fetchStatus =
|
|
58
|
+
fetchStatus = reduceAnyFetchState([
|
|
43
59
|
fileViewState.fetchFilesStatus,
|
|
44
60
|
sourceTaskDetail.fetchTaskStatus,
|
|
45
61
|
userList,
|
|
46
62
|
classListState,
|
|
63
|
+
subTaskStatus,
|
|
47
64
|
]);
|
|
48
65
|
}
|
|
49
66
|
else {
|
|
50
|
-
fetchStatus =
|
|
67
|
+
fetchStatus = reduceAnyFetchState([
|
|
51
68
|
sourceTaskDetail.fetchTaskStatus,
|
|
52
69
|
userList,
|
|
53
70
|
classListState,
|
|
71
|
+
subTaskStatus,
|
|
54
72
|
]);
|
|
55
73
|
}
|
|
56
74
|
showTaskDetailFormFooter = showFormFooter(sourceTaskDetail.taskDetailLocalData, taskEntity);
|
|
57
75
|
taskHistory = taskDetailState.taskHistoryById[taskId]?.historicEvents ?? [];
|
|
58
76
|
}
|
|
59
77
|
else if (taskId == null) {
|
|
60
|
-
fetchStatus =
|
|
78
|
+
fetchStatus = reduceAnyFetchState([userList, classListState]);
|
|
61
79
|
}
|
|
62
80
|
const { classIds } = classListState;
|
|
63
81
|
const allAccountingClasses = getClassesByIds(classState, {
|
|
@@ -84,12 +102,14 @@ export const getTaskDetail = (state, taskId) => {
|
|
|
84
102
|
error: fetchStatus.error,
|
|
85
103
|
version: 0,
|
|
86
104
|
createdByUser,
|
|
105
|
+
depth,
|
|
87
106
|
isArchived,
|
|
88
107
|
isDeleted,
|
|
89
108
|
recurringSourceTaskId,
|
|
90
109
|
showTaskDetailFormFooter,
|
|
91
110
|
snoozedUntil,
|
|
92
111
|
taskGroupId,
|
|
112
|
+
subtasks,
|
|
93
113
|
};
|
|
94
114
|
};
|
|
95
115
|
export const allTaskStatus = [
|
|
@@ -137,13 +157,16 @@ export const allTaskPriority = [
|
|
|
137
157
|
},
|
|
138
158
|
];
|
|
139
159
|
const showFormFooter = (taskDetailLocalData, taskDetailInStore) => {
|
|
140
|
-
if (
|
|
160
|
+
if (taskDetailInStore == null) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (taskDetailLocalData.name !== taskDetailInStore.name ||
|
|
141
164
|
taskDetailLocalData.description !== taskDetailInStore.description ||
|
|
142
165
|
taskDetailLocalData.priority !== taskDetailInStore.priority.code ||
|
|
143
166
|
taskDetailLocalData.status !== taskDetailInStore.status.code ||
|
|
144
167
|
!isEqual(taskDetailInStore.tagIds, taskDetailLocalData.tagIds) ||
|
|
145
|
-
!isEqual(taskDetailLocalData.assignee, taskDetailInStore
|
|
146
|
-
!isEqual(taskDetailLocalData.groupAssignees, taskDetailInStore
|
|
168
|
+
!isEqual(taskDetailLocalData.assignee, taskDetailInStore.assignees ?? []) ||
|
|
169
|
+
!isEqual(taskDetailLocalData.groupAssignees, taskDetailInStore.groupAssignees ?? []) ||
|
|
147
170
|
!isEqual(taskDetailLocalData.fileIds, taskDetailInStore.fileIds ?? []) ||
|
|
148
171
|
!isEqual(taskDetailLocalData.dueDate, taskDetailInStore.dueDate) ||
|
|
149
172
|
!isEqual(taskDetailLocalData.type, taskDetailInStore.type) ||
|
|
@@ -151,7 +174,10 @@ const showFormFooter = (taskDetailLocalData, taskDetailInStore) => {
|
|
|
151
174
|
!isEqual(taskDetailLocalData.recurringFrequency, taskDetailInStore.recurringFrequency) ||
|
|
152
175
|
!isEqual(taskDetailLocalData.recurringDaysOfWeek, taskDetailInStore.recurringDaysOfWeek) ||
|
|
153
176
|
!isEqual(taskDetailLocalData.recurringStartDate, taskDetailInStore.recurringStartDate) ||
|
|
154
|
-
!isEqual(taskDetailLocalData.timeSpent, taskDetailInStore.timeSpent)
|
|
177
|
+
!isEqual(taskDetailLocalData.timeSpent, taskDetailInStore.timeSpent) ||
|
|
178
|
+
!isEqual(taskDetailLocalData.taskGroupId, taskDetailInStore.taskGroupIds.length > 0
|
|
179
|
+
? taskDetailInStore.taskGroupIds[0]
|
|
180
|
+
: undefined)) {
|
|
155
181
|
return true;
|
|
156
182
|
}
|
|
157
183
|
return false;
|
|
@@ -9,15 +9,17 @@ export const fetchTaskListEpic = (actions$, _state$, zeniAPI) => actions$.pipe(f
|
|
|
9
9
|
.getJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks?query=${encodeURIComponent(`{"task_type": "all"}`)}`)
|
|
10
10
|
.pipe(mergeMap((response) => {
|
|
11
11
|
if (isSuccessResponse(response) && response.data != null) {
|
|
12
|
-
const { tasks, deleted, archived, snoozed } = response.data;
|
|
12
|
+
const { tasks, deleted, archived, snoozed, completed } = response.data;
|
|
13
13
|
const allTasks = [
|
|
14
14
|
...tasks,
|
|
15
|
+
...(completed ?? []),
|
|
15
16
|
...(deleted ?? []),
|
|
16
17
|
...(archived ?? []),
|
|
17
18
|
...(snoozed ?? []),
|
|
18
19
|
];
|
|
19
20
|
return of(updateTasks(allTasks), updateTaskList({
|
|
20
21
|
data: tasks,
|
|
22
|
+
completed: completed ?? [],
|
|
21
23
|
deleted: deleted ?? [],
|
|
22
24
|
archived: archived ?? [],
|
|
23
25
|
snoozed: snoozed ?? [],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
import { updateTasks } from '../../../entity/task/taskReducer';
|
|
2
3
|
import { toPriorityCodeType, toTaskStatusCodeType, } from '../../../entity/task/taskState';
|
|
3
4
|
import { date, dateNow } from '../../../zeniDayJS';
|
|
4
5
|
import { toDueDateGroupKeyType, } from './taskList';
|
|
@@ -42,6 +43,7 @@ export const initialState = {
|
|
|
42
43
|
currentTab: 'live',
|
|
43
44
|
byTab: {
|
|
44
45
|
live: { ...initialTabData },
|
|
46
|
+
completed: { ...initialTabData },
|
|
45
47
|
archived: { ...initialTabData },
|
|
46
48
|
deleted: { ...initialTabData },
|
|
47
49
|
snoozed: { ...initialTabData },
|
|
@@ -63,14 +65,12 @@ export const initialState = {
|
|
|
63
65
|
},
|
|
64
66
|
filters: {
|
|
65
67
|
categoryCombinationOperator: 'AND',
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
],
|
|
68
|
+
// Default to no filters. The Completed tab now owns the "hide resolved
|
|
69
|
+
// tasks from the live list" responsibility via the byTab split in
|
|
70
|
+
// updateTaskList / updateTaskFromListView / the updateTasks
|
|
71
|
+
// extraReducer. Keeping the historical `status != resolved` filter
|
|
72
|
+
// here would empty out the Completed tab.
|
|
73
|
+
categories: [],
|
|
74
74
|
},
|
|
75
75
|
hasValidState() {
|
|
76
76
|
return this.fetchState == 'Completed';
|
|
@@ -102,21 +102,37 @@ const taskList = createSlice({
|
|
|
102
102
|
draft.error = undefined;
|
|
103
103
|
},
|
|
104
104
|
updateTaskList(draft, action) {
|
|
105
|
-
const { data, archived, deleted, snoozed } = action.payload;
|
|
105
|
+
const { data, archived, completed, deleted, snoozed } = action.payload;
|
|
106
106
|
draft.fetchState = 'Completed';
|
|
107
107
|
draft.error = undefined;
|
|
108
|
+
// Build the nested-subtask set from the UNION of every bucket in
|
|
109
|
+
// this payload — not per-bucket. A resolved subtask can arrive in
|
|
110
|
+
// `completed` while its parent (which claims it via `subtasks`)
|
|
111
|
+
// is still in `data`; a per-bucket scan would miss the cross-tab
|
|
112
|
+
// claim and surface the subtask as a phantom top-level row in
|
|
113
|
+
// the Completed tab.
|
|
114
|
+
const nestedSubtaskIds = collectNestedSubtaskIds([
|
|
115
|
+
data,
|
|
116
|
+
completed,
|
|
117
|
+
archived,
|
|
118
|
+
deleted,
|
|
119
|
+
snoozed,
|
|
120
|
+
]);
|
|
108
121
|
// Group live tasks
|
|
109
|
-
const liveGrouped = getGroupedTaskIds(data);
|
|
122
|
+
const liveGrouped = getGroupedTaskIds(data, nestedSubtaskIds);
|
|
110
123
|
draft.byTab.live = toTabData(liveGrouped);
|
|
111
124
|
// Group other tabs if provided
|
|
125
|
+
if (completed != null) {
|
|
126
|
+
draft.byTab.completed = toTabData(getGroupedTaskIds(completed, nestedSubtaskIds));
|
|
127
|
+
}
|
|
112
128
|
if (archived != null) {
|
|
113
|
-
draft.byTab.archived = toTabData(getGroupedTaskIds(archived));
|
|
129
|
+
draft.byTab.archived = toTabData(getGroupedTaskIds(archived, nestedSubtaskIds));
|
|
114
130
|
}
|
|
115
131
|
if (deleted != null) {
|
|
116
|
-
draft.byTab.deleted = toTabData(getGroupedTaskIds(deleted));
|
|
132
|
+
draft.byTab.deleted = toTabData(getGroupedTaskIds(deleted, nestedSubtaskIds));
|
|
117
133
|
}
|
|
118
134
|
if (snoozed != null) {
|
|
119
|
-
draft.byTab.snoozed = toTabData(getGroupedTaskIds(snoozed));
|
|
135
|
+
draft.byTab.snoozed = toTabData(getGroupedTaskIds(snoozed, nestedSubtaskIds));
|
|
120
136
|
}
|
|
121
137
|
// Copy active tab data to top-level fields
|
|
122
138
|
const activeTabData = draft.byTab[draft.currentTab];
|
|
@@ -354,9 +370,26 @@ const taskList = createSlice({
|
|
|
354
370
|
draft.taskIdsByAssignees[assigneeKey] = draft.taskIdsByAssignees[assigneeKey].filter((taskId) => taskId !== currentTaskState.id);
|
|
355
371
|
draft.taskIdsByTags[tagsKey] = draft.taskIdsByTags[tagsKey].filter((taskId) => taskId !== currentTaskState.id);
|
|
356
372
|
draft.taskIdsByDueDate[dueDateKey] = draft.taskIdsByDueDate[dueDateKey].filter((taskId) => taskId !== currentTaskState.id);
|
|
357
|
-
|
|
373
|
+
// Remove from EVERY previous group bucket the task lived in, not
|
|
374
|
+
// just the destination bucket. Otherwise a detail-page save that
|
|
375
|
+
// moves a task from group A to group B leaves the row listed
|
|
376
|
+
// under both A and B until refetch.
|
|
377
|
+
const previousGroupIds = currentTaskState.taskGroupIds ?? [];
|
|
378
|
+
const allGroupIdsToClean = new Set([
|
|
379
|
+
...previousGroupIds,
|
|
380
|
+
taskGroupId,
|
|
381
|
+
]);
|
|
382
|
+
allGroupIdsToClean.forEach((gid) => {
|
|
383
|
+
if (draft.taskIdsByGroupsIds[gid] != null) {
|
|
384
|
+
draft.taskIdsByGroupsIds[gid] = draft.taskIdsByGroupsIds[gid].filter((id) => id !== currentTaskState.id);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
358
387
|
}
|
|
359
388
|
doUpdateTaskList(draft, task, taskGroupId);
|
|
389
|
+
// Mirror top-level grouping into the active tab's byTab snapshot,
|
|
390
|
+
// otherwise the next updateTaskListTab call copies stale byTab data
|
|
391
|
+
// back over this optimistic update.
|
|
392
|
+
syncTopLevelToActiveTab(draft);
|
|
360
393
|
},
|
|
361
394
|
updateTaskListOnCreateTaskFromTemplateSuccess(draft, action) {
|
|
362
395
|
const { tasks } = action.payload;
|
|
@@ -380,6 +413,7 @@ const taskList = createSlice({
|
|
|
380
413
|
const taskId = task.id;
|
|
381
414
|
draft.updateTasksFetchState.fetchState = 'In-Progress';
|
|
382
415
|
draft.updateTasksFetchState.error = undefined;
|
|
416
|
+
let didMigrateTab = false;
|
|
383
417
|
if (updates.priority != null) {
|
|
384
418
|
const prevPriorityCode = toPriorityCodeType(task.priority.code);
|
|
385
419
|
draft.taskIdsByPriority[prevPriorityCode] = draft.taskIdsByPriority[prevPriorityCode].filter((id) => id != taskId);
|
|
@@ -391,6 +425,32 @@ const taskList = createSlice({
|
|
|
391
425
|
draft.taskIdsByStatus[prevStatusCode] = draft.taskIdsByStatus[prevStatusCode].filter((id) => id != taskId);
|
|
392
426
|
const newStatusCode = toTaskStatusCodeType(updates.status.code);
|
|
393
427
|
draft.taskIdsByStatus[newStatusCode].push(taskId);
|
|
428
|
+
// Optimistically re-bucket between the live and completed tabs
|
|
429
|
+
// when status transitions in/out of "resolved", so the task
|
|
430
|
+
// moves to the Completed tab without waiting for the next
|
|
431
|
+
// fetch. Mirrors backend split in task_handler.py: only
|
|
432
|
+
// `resolved` counts as completed; `done` stays Active.
|
|
433
|
+
//
|
|
434
|
+
// "Top-level" here means: currently rendered as a standalone
|
|
435
|
+
// row in the live or completed tab (i.e. present in
|
|
436
|
+
// `byTab.<tab>.taskIds`). We use presence in byTab — NOT a
|
|
437
|
+
// `parent_task_id == null` check — so orphan subtasks (parent
|
|
438
|
+
// hidden from the customer's payload, so we render them flat)
|
|
439
|
+
// ALSO migrate between tabs on status flip. This matches the
|
|
440
|
+
// criterion `getGroupedTaskIds` uses when it builds byTab from
|
|
441
|
+
// the initial fetch — a task is nested iff a visible parent's
|
|
442
|
+
// `subtasks` array claims it, otherwise it's top-level here.
|
|
443
|
+
const isTopLevelTask = draft.byTab.live.taskIds.includes(taskId) ||
|
|
444
|
+
draft.byTab.completed.taskIds.includes(taskId);
|
|
445
|
+
const wasCompleted = prevStatusCode === 'resolved';
|
|
446
|
+
const isCompleted = newStatusCode === 'resolved';
|
|
447
|
+
if (isTopLevelTask && wasCompleted !== isCompleted) {
|
|
448
|
+
const from = isCompleted ? 'live' : 'completed';
|
|
449
|
+
const to = isCompleted ? 'completed' : 'live';
|
|
450
|
+
const updatedTask = { ...task, ...updates };
|
|
451
|
+
migrateTaskBetweenTabs(draft, taskId, updatedTask, from, to);
|
|
452
|
+
didMigrateTab = true;
|
|
453
|
+
}
|
|
394
454
|
}
|
|
395
455
|
if (updates.assignees != null) {
|
|
396
456
|
const prevGroupId = getAssigneesGroupKey(task.assignees);
|
|
@@ -430,7 +490,18 @@ const taskList = createSlice({
|
|
|
430
490
|
};
|
|
431
491
|
}
|
|
432
492
|
}
|
|
433
|
-
|
|
493
|
+
if (didMigrateTab) {
|
|
494
|
+
// The migration moved the task out of the source byTab and into
|
|
495
|
+
// the destination byTab. The active tab's grouping arrays are
|
|
496
|
+
// now authoritative — mirror them back to the top-level so the
|
|
497
|
+
// UI sees the task gone from Live (or freshly arrived in
|
|
498
|
+
// Completed). Inverse of syncTopLevelToActiveTab, used only on
|
|
499
|
+
// this path.
|
|
500
|
+
copyTabDataToTopLevel(draft, draft.byTab[draft.currentTab]);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
syncTopLevelToActiveTab(draft);
|
|
504
|
+
}
|
|
434
505
|
},
|
|
435
506
|
updateTasksListOnUpdateTaskSuccess(draft) {
|
|
436
507
|
draft.updateTasksFetchState.fetchState = 'Completed';
|
|
@@ -443,12 +514,83 @@ const taskList = createSlice({
|
|
|
443
514
|
removeTaskFromList(draft, action) {
|
|
444
515
|
removeTaskIds(draft, [action.payload]);
|
|
445
516
|
},
|
|
517
|
+
/**
|
|
518
|
+
* Remove a task id from a single group bucket without touching tab
|
|
519
|
+
* membership or other groupings. Used when the user clears a task's
|
|
520
|
+
* group on the Detail page so the row stops appearing under the old
|
|
521
|
+
* group while saveTaskDetailEpic skips the standard list re-insert.
|
|
522
|
+
*/
|
|
523
|
+
removeTaskFromGroupBucket(draft, action) {
|
|
524
|
+
const { taskId, taskGroupId } = action.payload;
|
|
525
|
+
if (draft.taskIdsByGroupsIds[taskGroupId] != null) {
|
|
526
|
+
draft.taskIdsByGroupsIds[taskGroupId] = draft.taskIdsByGroupsIds[taskGroupId].filter((id) => id !== taskId);
|
|
527
|
+
}
|
|
528
|
+
// Mirror to the active tab's grouping so the UI re-renders.
|
|
529
|
+
const activeTab = draft.byTab[draft.currentTab];
|
|
530
|
+
if (activeTab.taskIdsByGroupsIds[taskGroupId] != null) {
|
|
531
|
+
activeTab.taskIdsByGroupsIds[taskGroupId] =
|
|
532
|
+
activeTab.taskIdsByGroupsIds[taskGroupId].filter((id) => id !== taskId);
|
|
533
|
+
}
|
|
534
|
+
},
|
|
446
535
|
clearTaskList(draft) {
|
|
447
536
|
Object.assign(draft, initialState);
|
|
448
537
|
},
|
|
449
538
|
},
|
|
539
|
+
extraReducers: (builder) => {
|
|
540
|
+
// When the entity store receives updated tasks (e.g. from task detail
|
|
541
|
+
// save, snooze/archive/unarchive, etc.), re-bucket top-level tasks
|
|
542
|
+
// between the live and completed tabs based on the new status. Mirrors
|
|
543
|
+
// the backend split in task_handler.py: only `resolved` counts as
|
|
544
|
+
// completed.
|
|
545
|
+
//
|
|
546
|
+
// "Top-level" here means: currently rendered as a standalone row in
|
|
547
|
+
// the live or completed tab (i.e. present in
|
|
548
|
+
// `byTab.<tab>.taskIds`). Tasks that are nested under a parent's
|
|
549
|
+
// expanded subtask row live outside byTab.taskIds and are silently
|
|
550
|
+
// skipped by the "in-bucket" check below. This uses the same
|
|
551
|
+
// criterion as `getGroupedTaskIds` — a task is nested iff a visible
|
|
552
|
+
// parent's `subtasks` array claims it, otherwise it's top-level —
|
|
553
|
+
// so orphan subtasks (parent hidden from the customer's payload)
|
|
554
|
+
// migrate correctly on status flip via pusher / detail-save.
|
|
555
|
+
builder.addCase(updateTasks, (draft, action) => {
|
|
556
|
+
action.payload.forEach((taskPayload) => {
|
|
557
|
+
const taskId = taskPayload.task_id;
|
|
558
|
+
// `TaskPayload.status` is required per the type — every producer
|
|
559
|
+
// in this repo sends it, and `mapTaskPayloadToTask` dereferences
|
|
560
|
+
// it unguarded. The optional chain + null guard are defensive
|
|
561
|
+
// only, in case a downstream consumer (web-app-ui / cockpit-ui)
|
|
562
|
+
// ever dispatches `updateTasks` with a partial payload; in that
|
|
563
|
+
// case we treat missing status as "no status change" and skip
|
|
564
|
+
// re-bucketing so we don't migrate a Completed task back into
|
|
565
|
+
// the Live tab.
|
|
566
|
+
const statusCode = taskPayload.status?.code;
|
|
567
|
+
if (statusCode == null) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const isCompleted = statusCode === 'resolved';
|
|
571
|
+
const targetTab = isCompleted ? 'completed' : 'live';
|
|
572
|
+
const otherTab = isCompleted ? 'live' : 'completed';
|
|
573
|
+
// Skip if the task isn't currently in either of these buckets
|
|
574
|
+
// (archived/snoozed/deleted are managed by their own actions).
|
|
575
|
+
const inLive = draft.byTab.live.taskIds.includes(taskId);
|
|
576
|
+
const inCompleted = draft.byTab.completed.taskIds.includes(taskId);
|
|
577
|
+
if (!inLive && !inCompleted) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (draft.byTab[otherTab].taskIds.includes(taskId)) {
|
|
581
|
+
migrateTaskPayloadBetweenTabs(draft, taskId, taskPayload, otherTab, targetTab);
|
|
582
|
+
}
|
|
583
|
+
else if (!draft.byTab[targetTab].taskIds.includes(taskId)) {
|
|
584
|
+
draft.byTab[targetTab].taskIds.push(taskId);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
// Re-sync the top-level taskIds / grouping arrays for whichever
|
|
588
|
+
// tab the user is currently looking at.
|
|
589
|
+
copyTabDataToTopLevel(draft, draft.byTab[draft.currentTab]);
|
|
590
|
+
});
|
|
591
|
+
},
|
|
450
592
|
});
|
|
451
|
-
export const { fetchTaskListPage, fetchTaskList, updateTaskList, updateTaskListTab, updateTaskListFetchStatus, updateTaskListSearchText, updateTaskListUIState, updateTaskFilters, updateTaskListOnNewGroupCreationSuccess, bulkUpdateTaskList, updateTasksListOnBulkUpdateTasksSuccess, updateTasksListOnBulkUpdateTasksFailure, initiateTaskListLocalData, updateTaskListLocalData, updateTaskListOnTaskGroupDeletion, dragNDropTasks, updateTaskListOnNewTaskCreationSuccess, updateTaskListOnTaskGroupNameUpdate, updateTaskFromListView, updateTasksListOnUpdateTaskSuccess, updateTasksListOnUpdateTaskFailure, updateTaskListOnCreateTaskFromTemplateSuccess, removeTaskFromList, clearTaskList, } = taskList.actions;
|
|
593
|
+
export const { fetchTaskListPage, fetchTaskList, updateTaskList, updateTaskListTab, updateTaskListFetchStatus, updateTaskListSearchText, updateTaskListUIState, updateTaskFilters, updateTaskListOnNewGroupCreationSuccess, bulkUpdateTaskList, updateTasksListOnBulkUpdateTasksSuccess, updateTasksListOnBulkUpdateTasksFailure, initiateTaskListLocalData, updateTaskListLocalData, updateTaskListOnTaskGroupDeletion, dragNDropTasks, updateTaskListOnNewTaskCreationSuccess, updateTaskListOnTaskGroupNameUpdate, updateTaskFromListView, updateTasksListOnUpdateTaskSuccess, updateTasksListOnUpdateTaskFailure, updateTaskListOnCreateTaskFromTemplateSuccess, removeTaskFromList, removeTaskFromGroupBucket, clearTaskList, } = taskList.actions;
|
|
452
594
|
export default taskList.reducer;
|
|
453
595
|
/**
|
|
454
596
|
* helpers
|
|
@@ -555,6 +697,115 @@ const copyTabDataToTopLevel = (draft, tabData) => {
|
|
|
555
697
|
draft.taskIdsByDueDate = tabData.taskIdsByDueDate;
|
|
556
698
|
draft.taskIdsByTags = tabData.taskIdsByTags;
|
|
557
699
|
};
|
|
700
|
+
/**
|
|
701
|
+
* Migrate a parent task between two `byTab` buckets (live ↔ completed)
|
|
702
|
+
* when its status flips into / out of `resolved`. The destination tab
|
|
703
|
+
* picks up the task in EVERY grouping dimension (status, priority,
|
|
704
|
+
* group id, assignees, dueDate, tags) using the UPDATED task as the
|
|
705
|
+
* source-of-truth for the new bucket keys. Otherwise the Completed tab
|
|
706
|
+
* shows the task in the right tab-level list but the grouped views
|
|
707
|
+
* (Group by Status, Group by Priority, etc.) miss it until the next
|
|
708
|
+
* full fetch.
|
|
709
|
+
*/
|
|
710
|
+
const migrateTaskBetweenTabs = (draft, taskId, updatedTask, from, to) => {
|
|
711
|
+
const fromTab = draft.byTab[from];
|
|
712
|
+
const toTab = draft.byTab[to];
|
|
713
|
+
// Source tab: drop the task from every grouping dimension.
|
|
714
|
+
removeIdsFromTabData(fromTab, [taskId]);
|
|
715
|
+
// Destination tab: add the task to taskIds (deduped) plus the
|
|
716
|
+
// appropriate bucket inside each grouping dimension based on the
|
|
717
|
+
// updated task.
|
|
718
|
+
if (!toTab.taskIds.includes(taskId)) {
|
|
719
|
+
toTab.taskIds.push(taskId);
|
|
720
|
+
}
|
|
721
|
+
const priorityKey = toPriorityCodeType(updatedTask.priority.code);
|
|
722
|
+
if (!toTab.taskIdsByPriority[priorityKey].includes(taskId)) {
|
|
723
|
+
toTab.taskIdsByPriority[priorityKey].push(taskId);
|
|
724
|
+
}
|
|
725
|
+
const statusKey = toTaskStatusCodeType(updatedTask.status.code);
|
|
726
|
+
if (!toTab.taskIdsByStatus[statusKey].includes(taskId)) {
|
|
727
|
+
toTab.taskIdsByStatus[statusKey].push(taskId);
|
|
728
|
+
}
|
|
729
|
+
const dueDateKey = updatedTask.dueDate != null
|
|
730
|
+
? getDueDateGroupKey(updatedTask.dueDate.toString())
|
|
731
|
+
: 'upcoming';
|
|
732
|
+
if (!toTab.taskIdsByDueDate[dueDateKey].includes(taskId)) {
|
|
733
|
+
toTab.taskIdsByDueDate[dueDateKey].push(taskId);
|
|
734
|
+
}
|
|
735
|
+
const assigneeKey = getAssigneesGroupKey(updatedTask.assignees);
|
|
736
|
+
if (toTab.taskIdsByAssignees[assigneeKey] == null) {
|
|
737
|
+
toTab.taskIdsByAssignees[assigneeKey] = [taskId];
|
|
738
|
+
}
|
|
739
|
+
else if (!toTab.taskIdsByAssignees[assigneeKey].includes(taskId)) {
|
|
740
|
+
toTab.taskIdsByAssignees[assigneeKey].push(taskId);
|
|
741
|
+
}
|
|
742
|
+
const tagIds = [...updatedTask.tagIds].sort();
|
|
743
|
+
const tagsKey = tagIds.join(',');
|
|
744
|
+
if (toTab.taskIdsByTags[tagsKey] == null) {
|
|
745
|
+
toTab.taskIdsByTags[tagsKey] = [taskId];
|
|
746
|
+
}
|
|
747
|
+
else if (!toTab.taskIdsByTags[tagsKey].includes(taskId)) {
|
|
748
|
+
toTab.taskIdsByTags[tagsKey].push(taskId);
|
|
749
|
+
}
|
|
750
|
+
(updatedTask.taskGroupIds ?? []).forEach((groupId) => {
|
|
751
|
+
if (toTab.taskIdsByGroupsIds[groupId] == null) {
|
|
752
|
+
toTab.taskIdsByGroupsIds[groupId] = [taskId];
|
|
753
|
+
}
|
|
754
|
+
else if (!toTab.taskIdsByGroupsIds[groupId].includes(taskId)) {
|
|
755
|
+
toTab.taskIdsByGroupsIds[groupId].push(taskId);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
/**
|
|
760
|
+
* `updateTasks` extraReducer variant — same migration semantics but
|
|
761
|
+
* keyed off the raw `TaskPayload` instead of the rich `Task` entity.
|
|
762
|
+
*/
|
|
763
|
+
const migrateTaskPayloadBetweenTabs = (draft, taskId, payload, from, to) => {
|
|
764
|
+
const fromTab = draft.byTab[from];
|
|
765
|
+
const toTab = draft.byTab[to];
|
|
766
|
+
removeIdsFromTabData(fromTab, [taskId]);
|
|
767
|
+
if (!toTab.taskIds.includes(taskId)) {
|
|
768
|
+
toTab.taskIds.push(taskId);
|
|
769
|
+
}
|
|
770
|
+
if (payload.priority?.code != null) {
|
|
771
|
+
const priorityKey = toPriorityCodeType(payload.priority.code);
|
|
772
|
+
if (!toTab.taskIdsByPriority[priorityKey].includes(taskId)) {
|
|
773
|
+
toTab.taskIdsByPriority[priorityKey].push(taskId);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (payload.status?.code != null) {
|
|
777
|
+
const statusKey = toTaskStatusCodeType(payload.status.code);
|
|
778
|
+
if (!toTab.taskIdsByStatus[statusKey].includes(taskId)) {
|
|
779
|
+
toTab.taskIdsByStatus[statusKey].push(taskId);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const dueDateKey = getDueDateGroupKey(payload.due_date);
|
|
783
|
+
if (!toTab.taskIdsByDueDate[dueDateKey].includes(taskId)) {
|
|
784
|
+
toTab.taskIdsByDueDate[dueDateKey].push(taskId);
|
|
785
|
+
}
|
|
786
|
+
const assigneeKey = getAssigneesGroupKey(payload.assignees ?? []);
|
|
787
|
+
if (toTab.taskIdsByAssignees[assigneeKey] == null) {
|
|
788
|
+
toTab.taskIdsByAssignees[assigneeKey] = [taskId];
|
|
789
|
+
}
|
|
790
|
+
else if (!toTab.taskIdsByAssignees[assigneeKey].includes(taskId)) {
|
|
791
|
+
toTab.taskIdsByAssignees[assigneeKey].push(taskId);
|
|
792
|
+
}
|
|
793
|
+
const tagsKey = getTagsGroupKey(payload.tags ?? []);
|
|
794
|
+
if (toTab.taskIdsByTags[tagsKey] == null) {
|
|
795
|
+
toTab.taskIdsByTags[tagsKey] = [taskId];
|
|
796
|
+
}
|
|
797
|
+
else if (!toTab.taskIdsByTags[tagsKey].includes(taskId)) {
|
|
798
|
+
toTab.taskIdsByTags[tagsKey].push(taskId);
|
|
799
|
+
}
|
|
800
|
+
(payload.task_group_ids ?? []).forEach((groupId) => {
|
|
801
|
+
if (toTab.taskIdsByGroupsIds[groupId] == null) {
|
|
802
|
+
toTab.taskIdsByGroupsIds[groupId] = [taskId];
|
|
803
|
+
}
|
|
804
|
+
else if (!toTab.taskIdsByGroupsIds[groupId].includes(taskId)) {
|
|
805
|
+
toTab.taskIdsByGroupsIds[groupId].push(taskId);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
};
|
|
558
809
|
const removeIdsFromTabData = (tabData, taskIds) => {
|
|
559
810
|
const removedSet = new Set(taskIds);
|
|
560
811
|
const isRemoved = (id) => removedSet.has(id);
|
|
@@ -582,7 +833,39 @@ const removeTaskIds = (draft, taskIds) => {
|
|
|
582
833
|
removeIdsFromTabData(draft, taskIds);
|
|
583
834
|
removeIdsFromTabData(draft.byTab[draft.currentTab], taskIds);
|
|
584
835
|
};
|
|
585
|
-
|
|
836
|
+
// Collect the union of subtask ids claimed by any parent across every
|
|
837
|
+
// bucket in the payload. See the callsite in `updateTaskList` for why
|
|
838
|
+
// this must be cross-bucket rather than per-bucket.
|
|
839
|
+
const collectNestedSubtaskIds = (buckets) => {
|
|
840
|
+
const nestedSubtaskIds = new Set();
|
|
841
|
+
buckets.forEach((bucket) => {
|
|
842
|
+
if (bucket == null) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
bucket.forEach((task) => {
|
|
846
|
+
(task.subtasks ?? []).forEach((subtaskId) => nestedSubtaskIds.add(subtaskId));
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
return nestedSubtaskIds;
|
|
850
|
+
};
|
|
851
|
+
// A task is a "nested subtask" (hidden from the flat top-level list +
|
|
852
|
+
// dropped from the tab count) iff a parent in the same payload claims
|
|
853
|
+
// it via its `subtasks: [...]` array. Match this criterion exactly to
|
|
854
|
+
// WC's `allSubtaskIds` union in TaskManagementPage.tsx — otherwise
|
|
855
|
+
// the tab count and the visible top-level row count disagree
|
|
856
|
+
// whenever the BE emits `parent_task_id` on a subtask but omits it
|
|
857
|
+
// from the parent's `subtasks` array (a real inconsistency this UI
|
|
858
|
+
// sees today).
|
|
859
|
+
//
|
|
860
|
+
// Using `parent_task_id` here would sometimes drop a task from the
|
|
861
|
+
// count that WC still renders as a flat top-level row — that's the
|
|
862
|
+
// bug we're fixing.
|
|
863
|
+
//
|
|
864
|
+
// `nestedSubtaskIds` is passed in from the caller and covers subtasks
|
|
865
|
+
// claimed by parents in ANY bucket (live/completed/archived/deleted/
|
|
866
|
+
// snoozed), so a subtask resolved into `completed` while its parent
|
|
867
|
+
// stays in `data` is still correctly recognized as nested.
|
|
868
|
+
const getGroupedTaskIds = (tasks, nestedSubtaskIds) => {
|
|
586
869
|
const taskIds = [];
|
|
587
870
|
const taskIdsByGroupIds = {};
|
|
588
871
|
const taskIdsByAssignees = {};
|
|
@@ -597,12 +880,21 @@ const getGroupedTaskIds = (tasks) => {
|
|
|
597
880
|
};
|
|
598
881
|
const taskIdsByTags = {};
|
|
599
882
|
tasks.forEach((task) => {
|
|
600
|
-
|
|
883
|
+
const isNestedSubtask = nestedSubtaskIds.has(task.task_id);
|
|
884
|
+
// Subtasks stay in every groupBy array so the UI can render them
|
|
885
|
+
// nested under their parent (WC's TaskManagementPage builds
|
|
886
|
+
// `subtasksByParentId` from `group.tasks` and then filters them
|
|
887
|
+
// out of top-level rows via `filterOutSubtasks`). Only the flat
|
|
888
|
+
// top-level `taskIds` array — which drives the Active/Completed/…
|
|
889
|
+
// tab counts — must exclude nested subtasks.
|
|
601
890
|
const priorityKey = toPriorityCodeType(task.priority.code);
|
|
602
891
|
const statusKey = toTaskStatusCodeType(task.status.code);
|
|
603
892
|
const assigneeKey = getAssigneesGroupKey(task.assignees);
|
|
604
893
|
const dueDateKey = getDueDateGroupKey(task.due_date);
|
|
605
894
|
const tagsKey = getTagsGroupKey(task.tags);
|
|
895
|
+
if (!isNestedSubtask) {
|
|
896
|
+
taskIds.push(task.task_id);
|
|
897
|
+
}
|
|
606
898
|
taskIdsGroupedByPriority[priorityKey] = [
|
|
607
899
|
...taskIdsGroupedByPriority[priorityKey],
|
|
608
900
|
task.task_id,
|
|
@@ -334,6 +334,7 @@ export const getAllTasks = createSelector((state) => state.taskListState, (state
|
|
|
334
334
|
const taskGroupTemplates = getTaskGroupTemplateByIds(taskGroupTemplateState, taskGroupTemplateViewState.taskGroupTemplateIds);
|
|
335
335
|
const tabCounts = {
|
|
336
336
|
live: taskListState.byTab.live.taskIds.length,
|
|
337
|
+
completed: taskListState.byTab.completed.taskIds.length,
|
|
337
338
|
archived: taskListState.byTab.archived.taskIds.length,
|
|
338
339
|
deleted: taskListState.byTab.deleted.taskIds.length,
|
|
339
340
|
snoozed: taskListState.byTab.snoozed.taskIds.length,
|
|
@@ -48,3 +48,61 @@ export const getTaskUpdates = (groupByKey, groupId) => {
|
|
|
48
48
|
taskGroupIds: [groupId],
|
|
49
49
|
};
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Sort a subtask list by one of the sortable columns. Pure — no state
|
|
53
|
+
* dependency. `statusOrder` is derived from `allTaskStatus` so any new
|
|
54
|
+
* status added upstream slots in by its enum position instead of
|
|
55
|
+
* falling into a catch-all bucket.
|
|
56
|
+
*/
|
|
57
|
+
export const sortSubtasks = (subtasks, sortKey, sortOrder, userList, tagList) => {
|
|
58
|
+
if (sortKey == null) {
|
|
59
|
+
return subtasks;
|
|
60
|
+
}
|
|
61
|
+
const statusOrder = allTaskStatus.reduce((acc, status, index) => {
|
|
62
|
+
acc[status.code] = index;
|
|
63
|
+
return acc;
|
|
64
|
+
}, {});
|
|
65
|
+
const unknownStatusRank = allTaskStatus.length;
|
|
66
|
+
const cmpStr = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
|
|
67
|
+
const keyFn = (task) => {
|
|
68
|
+
if (sortKey === 'name') {
|
|
69
|
+
return task.name ?? '';
|
|
70
|
+
}
|
|
71
|
+
if (sortKey === 'status') {
|
|
72
|
+
return statusOrder[task.status.code] ?? unknownStatusRank;
|
|
73
|
+
}
|
|
74
|
+
if (sortKey === 'assignee') {
|
|
75
|
+
const firstId = task.assignees?.[0];
|
|
76
|
+
if (firstId == null) {
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
const user = userList.find((candidate) => candidate.userId === firstId);
|
|
80
|
+
return user
|
|
81
|
+
? `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim()
|
|
82
|
+
: '';
|
|
83
|
+
}
|
|
84
|
+
if (sortKey === 'tag') {
|
|
85
|
+
const firstTagId = task.tagIds?.[0];
|
|
86
|
+
if (firstTagId == null) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
const tag = tagList.find((candidate) => candidate.tagId === firstTagId);
|
|
90
|
+
return tag?.name ?? '';
|
|
91
|
+
}
|
|
92
|
+
return '';
|
|
93
|
+
};
|
|
94
|
+
const copy = [...subtasks];
|
|
95
|
+
copy.sort((a, b) => {
|
|
96
|
+
const av = keyFn(a);
|
|
97
|
+
const bv = keyFn(b);
|
|
98
|
+
let cmp;
|
|
99
|
+
if (typeof av === 'number' && typeof bv === 'number') {
|
|
100
|
+
cmp = av - bv;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
cmp = cmpStr(String(av), String(bv));
|
|
104
|
+
}
|
|
105
|
+
return sortOrder === 'asc' ? cmp : -cmp;
|
|
106
|
+
});
|
|
107
|
+
return copy;
|
|
108
|
+
};
|