@zeniai/client-epic-state 5.1.41-beta1ND → 5.1.41-betaDI3

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 (57) hide show
  1. package/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
  2. package/lib/entity/snackbar/snackbarTypes.js +1 -1
  3. package/lib/entity/task/taskPayload.d.ts +2 -1
  4. package/lib/entity/task/taskPayload.js +8 -1
  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 +1 -0
  8. package/lib/entity/tenant/epic/saveExternalConnectionEpic.js +7 -15
  9. package/lib/epic.d.ts +5 -3
  10. package/lib/epic.js +5 -3
  11. package/lib/esm/entity/snackbar/snackbarTypes.js +1 -1
  12. package/lib/esm/entity/task/taskPayload.js +8 -1
  13. package/lib/esm/entity/task/taskReducer.js +28 -3
  14. package/lib/esm/entity/tenant/epic/saveExternalConnectionEpic.js +7 -15
  15. package/lib/esm/epic.js +5 -3
  16. package/lib/esm/index.js +3 -3
  17. package/lib/esm/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
  18. package/lib/esm/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
  19. package/lib/esm/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +96 -0
  20. package/lib/esm/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +53 -0
  21. package/lib/esm/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
  22. package/lib/esm/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +70 -9
  23. package/lib/esm/view/taskManager/taskDetailView/taskDetailReducer.js +56 -2
  24. package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +32 -9
  25. package/lib/esm/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
  26. package/lib/esm/view/taskManager/taskListView/taskList.js +1 -0
  27. package/lib/esm/view/taskManager/taskListView/taskListReducer.js +240 -12
  28. package/lib/esm/view/taskManager/taskListView/taskListSelector.js +1 -0
  29. package/lib/esm/view/taskManager/taskListView/taskListViewHelpers.js +58 -0
  30. package/lib/index.d.ts +6 -6
  31. package/lib/index.js +13 -9
  32. package/lib/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
  33. package/lib/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
  34. package/lib/view/companyView/types/cockpitTypes.d.ts +3 -0
  35. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
  36. package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.d.ts +9 -0
  37. package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +100 -0
  38. package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.d.ts +8 -0
  39. package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +57 -0
  40. package/lib/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
  41. package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.d.ts +2 -2
  42. package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +69 -8
  43. package/lib/view/taskManager/taskDetailView/taskDetail.d.ts +15 -0
  44. package/lib/view/taskManager/taskDetailView/taskDetailReducer.d.ts +14 -2
  45. package/lib/view/taskManager/taskDetailView/taskDetailReducer.js +57 -3
  46. package/lib/view/taskManager/taskDetailView/taskDetailSelector.d.ts +2 -1
  47. package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +30 -7
  48. package/lib/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
  49. package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
  50. package/lib/view/taskManager/taskListView/taskList.js +1 -0
  51. package/lib/view/taskManager/taskListView/taskListPayload.d.ts +4 -0
  52. package/lib/view/taskManager/taskListView/taskListReducer.d.ts +6 -2
  53. package/lib/view/taskManager/taskListView/taskListReducer.js +241 -13
  54. package/lib/view/taskManager/taskListView/taskListSelector.js +1 -0
  55. package/lib/view/taskManager/taskListView/taskListViewHelpers.d.ts +11 -0
  56. package/lib/view/taskManager/taskListView/taskListViewHelpers.js +60 -1
  57. package/package.json +1 -1
@@ -6,6 +6,11 @@ import { initialTaskDetail, } from './taskDetail';
6
6
  export const initialState = {
7
7
  newTaskState: initialTaskDetail,
8
8
  editTaskStateById: {},
9
+ subTaskCreateStatus: {
10
+ fetchState: 'Not-Started',
11
+ error: undefined,
12
+ },
13
+ subTaskListFetchStatusByParentId: {},
9
14
  taskHistoryById: {},
10
15
  };
11
16
  const taskDetailView = createSlice({
@@ -50,7 +55,7 @@ const taskDetailView = createSlice({
50
55
  updateEditTaskFetchStatus(draft, action) {
51
56
  const { taskId, fetchState, error } = action.payload;
52
57
  draft.editTaskStateById[taskId] = {
53
- ...draft.editTaskStateById[taskId],
58
+ ...(draft.editTaskStateById[taskId] ?? initialTaskDetail),
54
59
  fetchTaskStatus: {
55
60
  fetchState,
56
61
  error: fetchState === 'Error' ? error : undefined,
@@ -318,10 +323,59 @@ const taskDetailView = createSlice({
318
323
  error,
319
324
  };
320
325
  },
326
+ createSubTask: {
327
+ reducer(draft) {
328
+ draft.subTaskCreateStatus = {
329
+ fetchState: 'In-Progress',
330
+ error: undefined,
331
+ };
332
+ },
333
+ prepare(payload) {
334
+ return { payload };
335
+ },
336
+ },
337
+ createSubTaskSuccessOrFailure(draft, action) {
338
+ const { fetchState, error } = action.payload;
339
+ draft.subTaskCreateStatus = {
340
+ fetchState,
341
+ error,
342
+ };
343
+ },
344
+ resetSubTaskCreateStatus(draft) {
345
+ draft.subTaskCreateStatus = {
346
+ ...initialState.subTaskCreateStatus,
347
+ };
348
+ },
349
+ fetchSubTasks: {
350
+ reducer(draft, action) {
351
+ const { parentTaskId } = action.payload;
352
+ draft.subTaskListFetchStatusByParentId[parentTaskId] = {
353
+ fetchState: 'In-Progress',
354
+ error: undefined,
355
+ };
356
+ },
357
+ prepare(parentTaskId) {
358
+ return { payload: { parentTaskId } };
359
+ },
360
+ },
361
+ updateSubTasks(draft, action) {
362
+ const { parentTaskId } = action.payload;
363
+ draft.subTaskListFetchStatusByParentId[parentTaskId] = {
364
+ fetchState: 'Completed',
365
+ error: undefined,
366
+ };
367
+ },
368
+ updateSubTasksFetchStatus(draft, action) {
369
+ const { parentTaskId, fetchState, error } = action.payload;
370
+ draft.subTaskListFetchStatusByParentId[parentTaskId] = {
371
+ fetchState,
372
+ error,
373
+ };
374
+ },
321
375
  clearTaskDetail(draft) {
322
376
  Object.assign(draft, initialState);
323
377
  },
324
378
  },
325
379
  });
326
- export const { fetchTaskDetailPage, fetchTaskDetail, initializeTaskToLocalStore, updateEditTaskFetchStatus, saveTaskUpdatesToLocalStore, discardTaskUpdatesInLocalStore, saveTaskDetail, archiveTask, archiveTaskSuccessOrFailure, saveTaskSuccessOrFailure, deleteTask, removeTaskDetail, deleteTaskSuccessOrFailure, snoozeTask, snoozeTaskSuccessOrFailure, unsnoozeTask, fetchTaskHistory, updateTaskHistory, updateTaskHistoryFetchStatus, clearTaskDetail, updateCreatedTagToLocalStore, updateDeletedTagToLocalStore, } = taskDetailView.actions;
380
+ export const { fetchTaskDetailPage, fetchTaskDetail, initializeTaskToLocalStore, updateEditTaskFetchStatus, saveTaskUpdatesToLocalStore, discardTaskUpdatesInLocalStore, saveTaskDetail, archiveTask, archiveTaskSuccessOrFailure, saveTaskSuccessOrFailure, createSubTask, createSubTaskSuccessOrFailure, resetSubTaskCreateStatus, fetchSubTasks, updateSubTasks, updateSubTasksFetchStatus, deleteTask, removeTaskDetail, deleteTaskSuccessOrFailure, snoozeTask, snoozeTaskSuccessOrFailure, unsnoozeTask, fetchTaskHistory, updateTaskHistory, updateTaskHistoryFetchStatus, clearTaskDetail, updateCreatedTagToLocalStore, updateDeletedTagToLocalStore, } = taskDetailView.actions;
327
381
  export default taskDetailView.reducer;
@@ -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';
@@ -27,8 +27,13 @@ export const getTaskDetail = (state, taskId) => {
27
27
  let recurringSourceTaskId = undefined;
28
28
  let snoozedUntil = undefined;
29
29
  let taskGroupId = undefined;
30
+ let subtasks = [];
30
31
  if (taskId != null && sourceTaskDetail != null) {
31
32
  const taskEntity = getTaskById(taskState, taskId);
33
+ subtasks =
34
+ taskEntity != null
35
+ ? getTasksByIds(taskState, taskEntity.subTasksIds)
36
+ : [];
32
37
  const fileIdsInEntity = taskEntity?.fileIds ?? [];
33
38
  if (taskEntity != null) {
34
39
  createdByUser = getUserAndUserRole(userState, userRoleState, addressState, taskEntity.createdBy);
@@ -38,26 +43,37 @@ export const getTaskDetail = (state, taskId) => {
38
43
  snoozedUntil = taskEntity.snoozedUntil;
39
44
  taskGroupId = taskEntity.taskGroupIds[0];
40
45
  }
46
+ // Treat "no entry" as Completed so it doesn't block the combined
47
+ // fetch state. The Not-Started default would pin the detail page
48
+ // below Completed forever before the first fetchSubTasks dispatches.
49
+ const completedSubTaskStatus = {
50
+ fetchState: 'Completed',
51
+ error: undefined,
52
+ };
53
+ const subTaskStatus = taskDetailState.subTaskListFetchStatusByParentId[taskId] ??
54
+ completedSubTaskStatus;
41
55
  if (fileIdsInEntity.length > 0) {
42
- fetchStatus = reduceFetchState([
56
+ fetchStatus = reduceAnyFetchState([
43
57
  fileViewState.fetchFilesStatus,
44
58
  sourceTaskDetail.fetchTaskStatus,
45
59
  userList,
46
60
  classListState,
61
+ subTaskStatus,
47
62
  ]);
48
63
  }
49
64
  else {
50
- fetchStatus = reduceFetchState([
65
+ fetchStatus = reduceAnyFetchState([
51
66
  sourceTaskDetail.fetchTaskStatus,
52
67
  userList,
53
68
  classListState,
69
+ subTaskStatus,
54
70
  ]);
55
71
  }
56
72
  showTaskDetailFormFooter = showFormFooter(sourceTaskDetail.taskDetailLocalData, taskEntity);
57
73
  taskHistory = taskDetailState.taskHistoryById[taskId]?.historicEvents ?? [];
58
74
  }
59
75
  else if (taskId == null) {
60
- fetchStatus = reduceFetchState([userList, classListState]);
76
+ fetchStatus = reduceAnyFetchState([userList, classListState]);
61
77
  }
62
78
  const { classIds } = classListState;
63
79
  const allAccountingClasses = getClassesByIds(classState, {
@@ -90,6 +106,7 @@ export const getTaskDetail = (state, taskId) => {
90
106
  showTaskDetailFormFooter,
91
107
  snoozedUntil,
92
108
  taskGroupId,
109
+ subtasks,
93
110
  };
94
111
  };
95
112
  export const allTaskStatus = [
@@ -137,13 +154,16 @@ export const allTaskPriority = [
137
154
  },
138
155
  ];
139
156
  const showFormFooter = (taskDetailLocalData, taskDetailInStore) => {
140
- if (taskDetailLocalData.name !== taskDetailInStore?.name ||
157
+ if (taskDetailInStore == null) {
158
+ return false;
159
+ }
160
+ if (taskDetailLocalData.name !== taskDetailInStore.name ||
141
161
  taskDetailLocalData.description !== taskDetailInStore.description ||
142
162
  taskDetailLocalData.priority !== taskDetailInStore.priority.code ||
143
163
  taskDetailLocalData.status !== taskDetailInStore.status.code ||
144
164
  !isEqual(taskDetailInStore.tagIds, taskDetailLocalData.tagIds) ||
145
- !isEqual(taskDetailLocalData.assignee, taskDetailInStore?.assignees ?? []) ||
146
- !isEqual(taskDetailLocalData.groupAssignees, taskDetailInStore?.groupAssignees ?? []) ||
165
+ !isEqual(taskDetailLocalData.assignee, taskDetailInStore.assignees ?? []) ||
166
+ !isEqual(taskDetailLocalData.groupAssignees, taskDetailInStore.groupAssignees ?? []) ||
147
167
  !isEqual(taskDetailLocalData.fileIds, taskDetailInStore.fileIds ?? []) ||
148
168
  !isEqual(taskDetailLocalData.dueDate, taskDetailInStore.dueDate) ||
149
169
  !isEqual(taskDetailLocalData.type, taskDetailInStore.type) ||
@@ -151,7 +171,10 @@ const showFormFooter = (taskDetailLocalData, taskDetailInStore) => {
151
171
  !isEqual(taskDetailLocalData.recurringFrequency, taskDetailInStore.recurringFrequency) ||
152
172
  !isEqual(taskDetailLocalData.recurringDaysOfWeek, taskDetailInStore.recurringDaysOfWeek) ||
153
173
  !isEqual(taskDetailLocalData.recurringStartDate, taskDetailInStore.recurringStartDate) ||
154
- !isEqual(taskDetailLocalData.timeSpent, taskDetailInStore.timeSpent)) {
174
+ !isEqual(taskDetailLocalData.timeSpent, taskDetailInStore.timeSpent) ||
175
+ !isEqual(taskDetailLocalData.taskGroupId, taskDetailInStore.taskGroupIds.length > 0
176
+ ? taskDetailInStore.taskGroupIds[0]
177
+ : undefined)) {
155
178
  return true;
156
179
  }
157
180
  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,13 +102,16 @@ 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
108
  // Group live tasks
109
109
  const liveGrouped = getGroupedTaskIds(data);
110
110
  draft.byTab.live = toTabData(liveGrouped);
111
111
  // Group other tabs if provided
112
+ if (completed != null) {
113
+ draft.byTab.completed = toTabData(getGroupedTaskIds(completed));
114
+ }
112
115
  if (archived != null) {
113
116
  draft.byTab.archived = toTabData(getGroupedTaskIds(archived));
114
117
  }
@@ -354,9 +357,26 @@ const taskList = createSlice({
354
357
  draft.taskIdsByAssignees[assigneeKey] = draft.taskIdsByAssignees[assigneeKey].filter((taskId) => taskId !== currentTaskState.id);
355
358
  draft.taskIdsByTags[tagsKey] = draft.taskIdsByTags[tagsKey].filter((taskId) => taskId !== currentTaskState.id);
356
359
  draft.taskIdsByDueDate[dueDateKey] = draft.taskIdsByDueDate[dueDateKey].filter((taskId) => taskId !== currentTaskState.id);
357
- draft.taskIdsByGroupsIds[taskGroupId] = draft.taskIdsByGroupsIds[taskGroupId].filter((taskId) => taskId !== currentTaskState.id);
360
+ // Remove from EVERY previous group bucket the task lived in, not
361
+ // just the destination bucket. Otherwise a detail-page save that
362
+ // moves a task from group A to group B leaves the row listed
363
+ // under both A and B until refetch.
364
+ const previousGroupIds = currentTaskState.taskGroupIds ?? [];
365
+ const allGroupIdsToClean = new Set([
366
+ ...previousGroupIds,
367
+ taskGroupId,
368
+ ]);
369
+ allGroupIdsToClean.forEach((gid) => {
370
+ if (draft.taskIdsByGroupsIds[gid] != null) {
371
+ draft.taskIdsByGroupsIds[gid] = draft.taskIdsByGroupsIds[gid].filter((id) => id !== currentTaskState.id);
372
+ }
373
+ });
358
374
  }
359
375
  doUpdateTaskList(draft, task, taskGroupId);
376
+ // Mirror top-level grouping into the active tab's byTab snapshot,
377
+ // otherwise the next updateTaskListTab call copies stale byTab data
378
+ // back over this optimistic update.
379
+ syncTopLevelToActiveTab(draft);
360
380
  },
361
381
  updateTaskListOnCreateTaskFromTemplateSuccess(draft, action) {
362
382
  const { tasks } = action.payload;
@@ -380,6 +400,7 @@ const taskList = createSlice({
380
400
  const taskId = task.id;
381
401
  draft.updateTasksFetchState.fetchState = 'In-Progress';
382
402
  draft.updateTasksFetchState.error = undefined;
403
+ let didMigrateTab = false;
383
404
  if (updates.priority != null) {
384
405
  const prevPriorityCode = toPriorityCodeType(task.priority.code);
385
406
  draft.taskIdsByPriority[prevPriorityCode] = draft.taskIdsByPriority[prevPriorityCode].filter((id) => id != taskId);
@@ -391,6 +412,25 @@ const taskList = createSlice({
391
412
  draft.taskIdsByStatus[prevStatusCode] = draft.taskIdsByStatus[prevStatusCode].filter((id) => id != taskId);
392
413
  const newStatusCode = toTaskStatusCodeType(updates.status.code);
393
414
  draft.taskIdsByStatus[newStatusCode].push(taskId);
415
+ // Optimistically re-bucket between the live and completed tabs
416
+ // when status transitions in/out of "resolved", so the task
417
+ // moves to the Completed tab without waiting for the next
418
+ // fetch. Mirrors backend split in task_handler.py: only
419
+ // `resolved` counts as completed; `done` stays Active.
420
+ //
421
+ // Subtasks travel with their parent — they appear nested under
422
+ // the parent row, not as standalone entries in byTab.taskIds.
423
+ // So we only rebucket parent tasks (tasks with no parentTaskId).
424
+ const isTopLevelTask = task.parentTaskId == null;
425
+ const wasCompleted = prevStatusCode === 'resolved';
426
+ const isCompleted = newStatusCode === 'resolved';
427
+ if (isTopLevelTask && wasCompleted !== isCompleted) {
428
+ const from = isCompleted ? 'live' : 'completed';
429
+ const to = isCompleted ? 'completed' : 'live';
430
+ const updatedTask = { ...task, ...updates };
431
+ migrateTaskBetweenTabs(draft, taskId, updatedTask, from, to);
432
+ didMigrateTab = true;
433
+ }
394
434
  }
395
435
  if (updates.assignees != null) {
396
436
  const prevGroupId = getAssigneesGroupKey(task.assignees);
@@ -430,7 +470,18 @@ const taskList = createSlice({
430
470
  };
431
471
  }
432
472
  }
433
- syncTopLevelToActiveTab(draft);
473
+ if (didMigrateTab) {
474
+ // The migration moved the task out of the source byTab and into
475
+ // the destination byTab. The active tab's grouping arrays are
476
+ // now authoritative — mirror them back to the top-level so the
477
+ // UI sees the task gone from Live (or freshly arrived in
478
+ // Completed). Inverse of syncTopLevelToActiveTab, used only on
479
+ // this path.
480
+ copyTabDataToTopLevel(draft, draft.byTab[draft.currentTab]);
481
+ }
482
+ else {
483
+ syncTopLevelToActiveTab(draft);
484
+ }
434
485
  },
435
486
  updateTasksListOnUpdateTaskSuccess(draft) {
436
487
  draft.updateTasksFetchState.fetchState = 'Completed';
@@ -443,12 +494,80 @@ const taskList = createSlice({
443
494
  removeTaskFromList(draft, action) {
444
495
  removeTaskIds(draft, [action.payload]);
445
496
  },
497
+ /**
498
+ * Remove a task id from a single group bucket without touching tab
499
+ * membership or other groupings. Used when the user clears a task's
500
+ * group on the Detail page so the row stops appearing under the old
501
+ * group while saveTaskDetailEpic skips the standard list re-insert.
502
+ */
503
+ removeTaskFromGroupBucket(draft, action) {
504
+ const { taskId, taskGroupId } = action.payload;
505
+ if (draft.taskIdsByGroupsIds[taskGroupId] != null) {
506
+ draft.taskIdsByGroupsIds[taskGroupId] = draft.taskIdsByGroupsIds[taskGroupId].filter((id) => id !== taskId);
507
+ }
508
+ // Mirror to the active tab's grouping so the UI re-renders.
509
+ const activeTab = draft.byTab[draft.currentTab];
510
+ if (activeTab.taskIdsByGroupsIds[taskGroupId] != null) {
511
+ activeTab.taskIdsByGroupsIds[taskGroupId] =
512
+ activeTab.taskIdsByGroupsIds[taskGroupId].filter((id) => id !== taskId);
513
+ }
514
+ },
446
515
  clearTaskList(draft) {
447
516
  Object.assign(draft, initialState);
448
517
  },
449
518
  },
519
+ extraReducers: (builder) => {
520
+ // When the entity store receives updated tasks (e.g. from task detail
521
+ // save, snooze/archive/unarchive, etc.), re-bucket parent tasks between
522
+ // the live and completed tabs based on the new status. Mirrors the
523
+ // backend split in task_handler.py: only `resolved` parents count as
524
+ // completed. Subtasks are nested under their parent row, so they
525
+ // never move between tabs on their own.
526
+ builder.addCase(updateTasks, (draft, action) => {
527
+ action.payload.forEach((taskPayload) => {
528
+ // Only re-bucket parents; subtasks (parent_task_id is a non-empty
529
+ // string) are nested under their parent row and don't move tabs.
530
+ if (taskPayload.parent_task_id != null &&
531
+ taskPayload.parent_task_id !== '') {
532
+ return;
533
+ }
534
+ const taskId = taskPayload.task_id;
535
+ // `TaskPayload.status` is required per the type — every producer
536
+ // in this repo sends it, and `mapTaskPayloadToTask` dereferences
537
+ // it unguarded. The optional chain + null guard are defensive
538
+ // only, in case a downstream consumer (web-app-ui / cockpit-ui)
539
+ // ever dispatches `updateTasks` with a partial payload; in that
540
+ // case we treat missing status as "no status change" and skip
541
+ // re-bucketing so we don't migrate a Completed task back into
542
+ // the Live tab.
543
+ const statusCode = taskPayload.status?.code;
544
+ if (statusCode == null) {
545
+ return;
546
+ }
547
+ const isCompleted = statusCode === 'resolved';
548
+ const targetTab = isCompleted ? 'completed' : 'live';
549
+ const otherTab = isCompleted ? 'live' : 'completed';
550
+ // Skip if the task isn't currently in either of these buckets
551
+ // (archived/snoozed/deleted are managed by their own actions).
552
+ const inLive = draft.byTab.live.taskIds.includes(taskId);
553
+ const inCompleted = draft.byTab.completed.taskIds.includes(taskId);
554
+ if (!inLive && !inCompleted) {
555
+ return;
556
+ }
557
+ if (draft.byTab[otherTab].taskIds.includes(taskId)) {
558
+ migrateTaskPayloadBetweenTabs(draft, taskId, taskPayload, otherTab, targetTab);
559
+ }
560
+ else if (!draft.byTab[targetTab].taskIds.includes(taskId)) {
561
+ draft.byTab[targetTab].taskIds.push(taskId);
562
+ }
563
+ });
564
+ // Re-sync the top-level taskIds / grouping arrays for whichever
565
+ // tab the user is currently looking at.
566
+ copyTabDataToTopLevel(draft, draft.byTab[draft.currentTab]);
567
+ });
568
+ },
450
569
  });
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;
570
+ 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
571
  export default taskList.reducer;
453
572
  /**
454
573
  * helpers
@@ -555,6 +674,115 @@ const copyTabDataToTopLevel = (draft, tabData) => {
555
674
  draft.taskIdsByDueDate = tabData.taskIdsByDueDate;
556
675
  draft.taskIdsByTags = tabData.taskIdsByTags;
557
676
  };
677
+ /**
678
+ * Migrate a parent task between two `byTab` buckets (live ↔ completed)
679
+ * when its status flips into / out of `resolved`. The destination tab
680
+ * picks up the task in EVERY grouping dimension (status, priority,
681
+ * group id, assignees, dueDate, tags) using the UPDATED task as the
682
+ * source-of-truth for the new bucket keys. Otherwise the Completed tab
683
+ * shows the task in the right tab-level list but the grouped views
684
+ * (Group by Status, Group by Priority, etc.) miss it until the next
685
+ * full fetch.
686
+ */
687
+ const migrateTaskBetweenTabs = (draft, taskId, updatedTask, from, to) => {
688
+ const fromTab = draft.byTab[from];
689
+ const toTab = draft.byTab[to];
690
+ // Source tab: drop the task from every grouping dimension.
691
+ removeIdsFromTabData(fromTab, [taskId]);
692
+ // Destination tab: add the task to taskIds (deduped) plus the
693
+ // appropriate bucket inside each grouping dimension based on the
694
+ // updated task.
695
+ if (!toTab.taskIds.includes(taskId)) {
696
+ toTab.taskIds.push(taskId);
697
+ }
698
+ const priorityKey = toPriorityCodeType(updatedTask.priority.code);
699
+ if (!toTab.taskIdsByPriority[priorityKey].includes(taskId)) {
700
+ toTab.taskIdsByPriority[priorityKey].push(taskId);
701
+ }
702
+ const statusKey = toTaskStatusCodeType(updatedTask.status.code);
703
+ if (!toTab.taskIdsByStatus[statusKey].includes(taskId)) {
704
+ toTab.taskIdsByStatus[statusKey].push(taskId);
705
+ }
706
+ const dueDateKey = updatedTask.dueDate != null
707
+ ? getDueDateGroupKey(updatedTask.dueDate.toString())
708
+ : 'upcoming';
709
+ if (!toTab.taskIdsByDueDate[dueDateKey].includes(taskId)) {
710
+ toTab.taskIdsByDueDate[dueDateKey].push(taskId);
711
+ }
712
+ const assigneeKey = getAssigneesGroupKey(updatedTask.assignees);
713
+ if (toTab.taskIdsByAssignees[assigneeKey] == null) {
714
+ toTab.taskIdsByAssignees[assigneeKey] = [taskId];
715
+ }
716
+ else if (!toTab.taskIdsByAssignees[assigneeKey].includes(taskId)) {
717
+ toTab.taskIdsByAssignees[assigneeKey].push(taskId);
718
+ }
719
+ const tagIds = [...updatedTask.tagIds].sort();
720
+ const tagsKey = tagIds.join(',');
721
+ if (toTab.taskIdsByTags[tagsKey] == null) {
722
+ toTab.taskIdsByTags[tagsKey] = [taskId];
723
+ }
724
+ else if (!toTab.taskIdsByTags[tagsKey].includes(taskId)) {
725
+ toTab.taskIdsByTags[tagsKey].push(taskId);
726
+ }
727
+ (updatedTask.taskGroupIds ?? []).forEach((groupId) => {
728
+ if (toTab.taskIdsByGroupsIds[groupId] == null) {
729
+ toTab.taskIdsByGroupsIds[groupId] = [taskId];
730
+ }
731
+ else if (!toTab.taskIdsByGroupsIds[groupId].includes(taskId)) {
732
+ toTab.taskIdsByGroupsIds[groupId].push(taskId);
733
+ }
734
+ });
735
+ };
736
+ /**
737
+ * `updateTasks` extraReducer variant — same migration semantics but
738
+ * keyed off the raw `TaskPayload` instead of the rich `Task` entity.
739
+ */
740
+ const migrateTaskPayloadBetweenTabs = (draft, taskId, payload, from, to) => {
741
+ const fromTab = draft.byTab[from];
742
+ const toTab = draft.byTab[to];
743
+ removeIdsFromTabData(fromTab, [taskId]);
744
+ if (!toTab.taskIds.includes(taskId)) {
745
+ toTab.taskIds.push(taskId);
746
+ }
747
+ if (payload.priority?.code != null) {
748
+ const priorityKey = toPriorityCodeType(payload.priority.code);
749
+ if (!toTab.taskIdsByPriority[priorityKey].includes(taskId)) {
750
+ toTab.taskIdsByPriority[priorityKey].push(taskId);
751
+ }
752
+ }
753
+ if (payload.status?.code != null) {
754
+ const statusKey = toTaskStatusCodeType(payload.status.code);
755
+ if (!toTab.taskIdsByStatus[statusKey].includes(taskId)) {
756
+ toTab.taskIdsByStatus[statusKey].push(taskId);
757
+ }
758
+ }
759
+ const dueDateKey = getDueDateGroupKey(payload.due_date);
760
+ if (!toTab.taskIdsByDueDate[dueDateKey].includes(taskId)) {
761
+ toTab.taskIdsByDueDate[dueDateKey].push(taskId);
762
+ }
763
+ const assigneeKey = getAssigneesGroupKey(payload.assignees ?? []);
764
+ if (toTab.taskIdsByAssignees[assigneeKey] == null) {
765
+ toTab.taskIdsByAssignees[assigneeKey] = [taskId];
766
+ }
767
+ else if (!toTab.taskIdsByAssignees[assigneeKey].includes(taskId)) {
768
+ toTab.taskIdsByAssignees[assigneeKey].push(taskId);
769
+ }
770
+ const tagsKey = getTagsGroupKey(payload.tags ?? []);
771
+ if (toTab.taskIdsByTags[tagsKey] == null) {
772
+ toTab.taskIdsByTags[tagsKey] = [taskId];
773
+ }
774
+ else if (!toTab.taskIdsByTags[tagsKey].includes(taskId)) {
775
+ toTab.taskIdsByTags[tagsKey].push(taskId);
776
+ }
777
+ (payload.task_group_ids ?? []).forEach((groupId) => {
778
+ if (toTab.taskIdsByGroupsIds[groupId] == null) {
779
+ toTab.taskIdsByGroupsIds[groupId] = [taskId];
780
+ }
781
+ else if (!toTab.taskIdsByGroupsIds[groupId].includes(taskId)) {
782
+ toTab.taskIdsByGroupsIds[groupId].push(taskId);
783
+ }
784
+ });
785
+ };
558
786
  const removeIdsFromTabData = (tabData, taskIds) => {
559
787
  const removedSet = new Set(taskIds);
560
788
  const isRemoved = (id) => removedSet.has(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
+ };