@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.
Files changed (85) hide show
  1. package/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
  2. package/lib/entity/snackbar/snackbarTypes.js +3 -0
  3. package/lib/entity/task/taskPayload.d.ts +3 -1
  4. package/lib/entity/task/taskPayload.js +24 -2
  5. package/lib/entity/task/taskReducer.d.ts +8 -1
  6. package/lib/entity/task/taskReducer.js +29 -4
  7. package/lib/entity/task/taskState.d.ts +2 -0
  8. package/lib/entity/tenant/epic/saveExternalConnectionEpic.d.ts +2 -1
  9. package/lib/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
  10. package/lib/epic.d.ts +6 -3
  11. package/lib/epic.js +6 -3
  12. package/lib/esm/entity/snackbar/snackbarTypes.js +3 -0
  13. package/lib/esm/entity/task/taskPayload.js +24 -2
  14. package/lib/esm/entity/task/taskReducer.js +28 -3
  15. package/lib/esm/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
  16. package/lib/esm/epic.js +6 -3
  17. package/lib/esm/index.js +8 -8
  18. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +31 -1
  19. package/lib/esm/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
  20. package/lib/esm/view/aiCfoView/epics/submitFeedbackEpic.js +39 -0
  21. package/lib/esm/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
  22. package/lib/esm/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
  23. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
  24. package/lib/esm/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +96 -0
  25. package/lib/esm/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +53 -0
  26. package/lib/esm/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
  27. package/lib/esm/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +70 -9
  28. package/lib/esm/view/taskManager/taskDetailView/taskDetailReducer.js +56 -2
  29. package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +35 -9
  30. package/lib/esm/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
  31. package/lib/esm/view/taskManager/taskListView/taskList.js +1 -0
  32. package/lib/esm/view/taskManager/taskListView/taskListReducer.js +310 -18
  33. package/lib/esm/view/taskManager/taskListView/taskListSelector.js +1 -0
  34. package/lib/esm/view/taskManager/taskListView/taskListViewHelpers.js +58 -0
  35. package/lib/esm/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
  36. package/lib/esm/view/transactionDetail/epics/updateTransactionDetailEpic.js +75 -3
  37. package/lib/esm/view/transactionDetail/journalEntryLinesViewModel.js +58 -0
  38. package/lib/esm/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
  39. package/lib/esm/view/transactionDetail/transactionDetailReducer.js +66 -1
  40. package/lib/index.d.ts +12 -11
  41. package/lib/index.js +37 -28
  42. package/lib/view/aiCfoView/aiCfoViewPayload.d.ts +10 -0
  43. package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +7 -2
  44. package/lib/view/aiCfoView/aiCfoViewReducer.js +32 -2
  45. package/lib/view/aiCfoView/aiCfoViewState.d.ts +5 -1
  46. package/lib/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
  47. package/lib/view/aiCfoView/epics/submitFeedbackEpic.d.ts +13 -0
  48. package/lib/view/aiCfoView/epics/submitFeedbackEpic.js +43 -0
  49. package/lib/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
  50. package/lib/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
  51. package/lib/view/companyView/types/cockpitTypes.d.ts +3 -0
  52. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
  53. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
  54. package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.d.ts +9 -0
  55. package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +100 -0
  56. package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.d.ts +8 -0
  57. package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +57 -0
  58. package/lib/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
  59. package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.d.ts +2 -2
  60. package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +69 -8
  61. package/lib/view/taskManager/taskDetailView/taskDetail.d.ts +15 -0
  62. package/lib/view/taskManager/taskDetailView/taskDetailReducer.d.ts +14 -2
  63. package/lib/view/taskManager/taskDetailView/taskDetailReducer.js +57 -3
  64. package/lib/view/taskManager/taskDetailView/taskDetailSelector.d.ts +3 -1
  65. package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +33 -7
  66. package/lib/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
  67. package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
  68. package/lib/view/taskManager/taskListView/taskList.js +1 -0
  69. package/lib/view/taskManager/taskListView/taskListPayload.d.ts +4 -0
  70. package/lib/view/taskManager/taskListView/taskListReducer.d.ts +6 -2
  71. package/lib/view/taskManager/taskListView/taskListReducer.js +311 -19
  72. package/lib/view/taskManager/taskListView/taskListSelector.js +1 -0
  73. package/lib/view/taskManager/taskListView/taskListViewHelpers.d.ts +11 -0
  74. package/lib/view/taskManager/taskListView/taskListViewHelpers.js +60 -1
  75. package/lib/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
  76. package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.d.ts +5 -1
  77. package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.js +77 -4
  78. package/lib/view/transactionDetail/journalEntryLinesViewModel.d.ts +27 -0
  79. package/lib/view/transactionDetail/journalEntryLinesViewModel.js +61 -1
  80. package/lib/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
  81. package/lib/view/transactionDetail/transactionDetailReducer.d.ts +11 -2
  82. package/lib/view/transactionDetail/transactionDetailReducer.js +67 -2
  83. package/lib/view/transactionDetail/transactionDetailState.d.ts +8 -1
  84. package/lib/view/transactionDetail/transactionDetailTypes.d.ts +2 -0
  85. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import isEqual from 'lodash/isEqual';
2
- import { reduceFetchState } from '../../../commonStateTypes/reduceFetchState';
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 = reduceFetchState([
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 = reduceFetchState([
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 = reduceFetchState([userList, classListState]);
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 (taskDetailLocalData.name !== taskDetailInStore?.name ||
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?.assignees ?? []) ||
146
- !isEqual(taskDetailLocalData.groupAssignees, taskDetailInStore?.groupAssignees ?? []) ||
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,6 +1,7 @@
1
1
  import { stringToUnion, stringToUnionStrict, } from '../../../commonStateTypes/stringToUnion';
2
2
  export const ALL_TASK_LIST_TABS = [
3
3
  'live',
4
+ 'completed',
4
5
  'archived',
5
6
  'deleted',
6
7
  '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
- categories: [
67
- {
68
- field: 'status',
69
- matchingOperator: 'not_equal',
70
- values: ['resolved'],
71
- valuesCombinationOperator: 'ANY',
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
- draft.taskIdsByGroupsIds[taskGroupId] = draft.taskIdsByGroupsIds[taskGroupId].filter((taskId) => taskId !== currentTaskState.id);
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
- syncTopLevelToActiveTab(draft);
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
- const getGroupedTaskIds = (tasks) => {
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
- taskIds.push(task.task_id);
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
+ };