@zeniai/client-epic-state 5.2.96 → 5.2.98

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.
@@ -22,6 +22,17 @@ export const fetchTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe(
22
22
  if (cacheOverride === true) {
23
23
  taskActions.push(removeTaskDetail(taskId));
24
24
  }
25
+ // The task is already loaded and the caller did not ask for a refresh, so
26
+ // serve it from the store. The `fetchState !== 'In-Progress'` check below
27
+ // only suppresses a duplicate request while one is in flight; it is not a
28
+ // cache check, because a loaded task sits at 'Completed' and so passes it.
29
+ // Without this, re-opening the same task refetches it every time.
30
+ if (cacheOverride === false && isLoadedTaskDetail(taskDetail)) {
31
+ // Rebuild the editable copy from the cached task entity rather than
32
+ // emitting nothing: leaving the page discards the local data, so a
33
+ // silent cache hit would render an empty form.
34
+ return of(initializeTaskToLocalStore(taskId));
35
+ }
25
36
  if (cacheOverride === true ||
26
37
  taskDetail == null ||
27
38
  taskDetail.fetchTaskStatus.fetchState !== 'In-Progress') {
@@ -1,5 +1,5 @@
1
1
  import recordGet from 'lodash/get';
2
- import { concat, from, of } from 'rxjs';
2
+ import { EMPTY, concat, from, of } from 'rxjs';
3
3
  import { catchError, filter, mergeMap } from 'rxjs/operators';
4
4
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
5
  import { fetchTaskHistory, updateTaskHistory, updateTaskHistoryFetchStatus, } from '../taskDetailReducer';
@@ -8,6 +8,14 @@ export const fetchTaskHistoryEpic = (actions$, state$, zeniAPI) => actions$.pipe
8
8
  const historyActions = [];
9
9
  const state = state$.value;
10
10
  const taskHistoryDetail = recordGet(state.taskDetailState.taskHistoryById, taskId, undefined);
11
+ // Already fetched and no refresh asked for: keep what we have. As in
12
+ // fetchTaskDetailEpic, the 'In-Progress' check below is an in-flight
13
+ // guard, not a cache check.
14
+ if (cacheOverride === false &&
15
+ taskHistoryDetail != null &&
16
+ taskHistoryDetail.fetchState === 'Completed') {
17
+ return EMPTY;
18
+ }
11
19
  if (cacheOverride === true ||
12
20
  taskHistoryDetail == null ||
13
21
  taskHistoryDetail.fetchState !== 'In-Progress') {
@@ -17,6 +17,12 @@ export const buildTaskDetailLocalData = (task) => ({
17
17
  recurringEndDate: task.recurringEndDate,
18
18
  },
19
19
  savedRecurringEndDate: task.recurringEndDate,
20
+ // Explicitly cleared, not omitted. `saveTaskUpdatesToLocalStore` SHALLOW
21
+ // MERGES this over the existing local data, so a key left out here keeps
22
+ // whatever was there before. The task entity carries no recurrence count —
23
+ // this is a form-local value — so a rebuild from the entity must reset it,
24
+ // or an abandoned recurrence edit survives leaving and re-opening the task.
25
+ savedRecurringTasksCount: undefined,
20
26
  recurringStartDate: task.recurringStartDate,
21
27
  timeSpent: task.timeSpent,
22
28
  isPrivate: task.isPrivate,
@@ -4,7 +4,6 @@ import { clearAll } from '../../../rootActions';
4
4
  import { initialCommonHistory } from '../../spendManagement/commonHistoryView/commonHistory';
5
5
  import { toHistoricEvent, } from '../../spendManagement/commonHistoryView/commonHistoryPayload';
6
6
  import { initialTaskDetail, } from './taskDetail';
7
- import { isLoadedTaskDetail } from './taskDetailLocalData';
8
7
  export const initialState = {
9
8
  newTaskState: initialTaskDetail,
10
9
  editTaskStateById: {},
@@ -21,12 +20,17 @@ const taskDetailView = createSlice({
21
20
  reducers: {
22
21
  fetchTaskDetailPage: {
23
22
  reducer(draft, action) {
24
- const { taskId } = action.payload;
23
+ const { cacheOverride, taskId } = action.payload;
25
24
  if (taskId != null) {
26
25
  const detail = recordGet(draft.editTaskStateById, taskId, undefined);
27
- const isRefresh = action.payload.cacheOverride === true && isLoadedTaskDetail(detail);
28
- if (!isRefresh &&
29
- (action.payload.cacheOverride === true ||
26
+ // An already-loaded task is served from the store on BOTH paths:
27
+ // cacheOverride=true refreshes it in place, cacheOverride=false
28
+ // re-opens it. Resetting the record to `initialTaskDetail` would put
29
+ // `fetchTaskStatus` back to 'Not-Started' and throw the cached task
30
+ // away, so the detail epic's cache check could never hit and every
31
+ // open refetched behind a skeleton.
32
+ if (detail?.fetchTaskStatus.fetchState !== 'Completed' &&
33
+ (cacheOverride === true ||
30
34
  detail == null ||
31
35
  detail.fetchTaskStatus.fetchState !== 'In-Progress')) {
32
36
  draft.editTaskStateById[taskId] = {
@@ -119,7 +123,29 @@ const taskDetailView = createSlice({
119
123
  discardTaskUpdatesInLocalStore(draft, action) {
120
124
  if (action.payload.taskId != null) {
121
125
  const key = action.payload.taskId;
122
- draft.editTaskStateById[key] = initialTaskDetail;
126
+ const existing = draft.editTaskStateById[key];
127
+ // Reset the MUTATION statuses. Nothing else.
128
+ //
129
+ // Replacing the whole record reset `fetchTaskStatus` to 'Not-Started',
130
+ // which threw the cached task away, so re-opening always refetched.
131
+ // But blanking `taskDetailLocalData` while keeping 'Completed' is just
132
+ // as wrong the other way: the page skeletons only on 'Not-Started' /
133
+ // 'In-Progress', so the detail page renders one more frame on its way
134
+ // out — now as an EMPTY form, which reads as "a new task" for an
135
+ // instant before the board appears. The skeleton used to cover that.
136
+ //
137
+ // The edits do not need clearing here: every open dispatches
138
+ // `initializeTaskToLocalStore`, and its epic overwrites the local data
139
+ // wholesale from the task entity. That is what actually discards them.
140
+ draft.editTaskStateById[key] =
141
+ existing == null
142
+ ? initialTaskDetail
143
+ : {
144
+ ...initialTaskDetail,
145
+ taskId: existing.taskId,
146
+ fetchTaskStatus: existing.fetchTaskStatus,
147
+ taskDetailLocalData: existing.taskDetailLocalData,
148
+ };
123
149
  }
124
150
  else {
125
151
  draft.newTaskState = initialTaskDetail;
@@ -293,11 +319,21 @@ const taskDetailView = createSlice({
293
319
  },
294
320
  fetchTaskHistory: {
295
321
  reducer(draft, action) {
296
- const { taskId, cacheOverride } = action.payload;
322
+ // `cacheOverride` is deliberately unused: a refresh of loaded history
323
+ // is silent either way now, and the epic decides whether to refetch.
324
+ const { taskId } = action.payload;
297
325
  const detail = recordGet(draft.taskHistoryById, taskId, undefined);
298
- if (cacheOverride === true ||
299
- detail == null ||
300
- detail.fetchState !== 'In-Progress') {
326
+ // Same as `fetchTaskDetailPage`: re-opening a task must not wipe the
327
+ // history it already has, or the history epic's cache check can never
328
+ // hit and every open refetches it.
329
+ //
330
+ // Only a record that does not exist yet is seeded. Keying this on
331
+ // `fetchState === 'Completed'` left a gap: a silent refresh moves the
332
+ // record to 'In-Progress' with its events intact, and a second refresh
333
+ // landing on top of that no longer saw 'Completed', so it rebuilt from
334
+ // `initialCommonHistory` and emptied `historicEvents` — the very flash
335
+ // this is here to prevent.
336
+ if (detail == null) {
301
337
  draft.taskHistoryById[taskId] = {
302
338
  ...initialCommonHistory,
303
339
  };
@@ -321,8 +357,12 @@ const taskDetailView = createSlice({
321
357
  },
322
358
  updateTaskHistoryFetchStatus(draft, action) {
323
359
  const { taskId, fetchState, error } = action.payload;
360
+ // Keep the events that are already there. Spreading `initialCommonHistory`
361
+ // emptied the list on every status change, which the loading skeleton used
362
+ // to hide; now that a re-open serves from cache and shows no skeleton, a
363
+ // silent refresh would flash the history list empty and refill it.
324
364
  draft.taskHistoryById[taskId] = {
325
- ...initialCommonHistory,
365
+ ...(draft.taskHistoryById[taskId] ?? initialCommonHistory),
326
366
  fetchState,
327
367
  error,
328
368
  };
@@ -25,7 +25,24 @@ const taskGroupView = createSlice({
25
25
  initialState,
26
26
  reducers: {
27
27
  fetchAllTaskGroups(draft) {
28
- draft.fetchState = 'In-Progress';
28
+ // Groups already in the store: refetch in the background and stay
29
+ // 'Completed'. This mirrors `fetchTaskList`, which has always done it.
30
+ //
31
+ // The board's fetch state is `reduceAnyFetchState([taskListState,
32
+ // taskGroupViewState, ...])`, so flipping this one slice to
33
+ // 'In-Progress' skeletons the WHOLE board — every tab — even with the
34
+ // task list fully cached. The Pusher snapshot-on-connect catch-up
35
+ // refetches groups on every re-subscribe, which is why returning from a
36
+ // task made the board look like it was reloading when no task request
37
+ // was in flight at all.
38
+ //
39
+ // Keyed on "has a fetch ever completed", NOT on `taskGroupsIds.length`:
40
+ // a tenant that uses no groups has a legitimately empty list, and
41
+ // counting ids makes that indistinguishable from a cold load — so every
42
+ // catch-up would skeleton the board for exactly the orgs this is meant
43
+ // to help.
44
+ draft.fetchState =
45
+ draft.fetchState === 'Completed' ? 'Completed' : 'In-Progress';
29
46
  draft.error = undefined;
30
47
  },
31
48
  updateTaskGroupsOnSuccess(draft, action) {
@@ -28,6 +28,17 @@ const fetchTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe((0, ope
28
28
  if (cacheOverride === true) {
29
29
  taskActions.push((0, taskDetailReducer_1.removeTaskDetail)(taskId));
30
30
  }
31
+ // The task is already loaded and the caller did not ask for a refresh, so
32
+ // serve it from the store. The `fetchState !== 'In-Progress'` check below
33
+ // only suppresses a duplicate request while one is in flight; it is not a
34
+ // cache check, because a loaded task sits at 'Completed' and so passes it.
35
+ // Without this, re-opening the same task refetches it every time.
36
+ if (cacheOverride === false && (0, taskDetailLocalData_1.isLoadedTaskDetail)(taskDetail)) {
37
+ // Rebuild the editable copy from the cached task entity rather than
38
+ // emitting nothing: leaving the page discards the local data, so a
39
+ // silent cache hit would render an empty form.
40
+ return (0, rxjs_1.of)((0, taskDetailReducer_1.initializeTaskToLocalStore)(taskId));
41
+ }
31
42
  if (cacheOverride === true ||
32
43
  taskDetail == null ||
33
44
  taskDetail.fetchTaskStatus.fetchState !== 'In-Progress') {
@@ -14,6 +14,14 @@ const fetchTaskHistoryEpic = (actions$, state$, zeniAPI) => actions$.pipe((0, op
14
14
  const historyActions = [];
15
15
  const state = state$.value;
16
16
  const taskHistoryDetail = (0, get_1.default)(state.taskDetailState.taskHistoryById, taskId, undefined);
17
+ // Already fetched and no refresh asked for: keep what we have. As in
18
+ // fetchTaskDetailEpic, the 'In-Progress' check below is an in-flight
19
+ // guard, not a cache check.
20
+ if (cacheOverride === false &&
21
+ taskHistoryDetail != null &&
22
+ taskHistoryDetail.fetchState === 'Completed') {
23
+ return rxjs_1.EMPTY;
24
+ }
17
25
  if (cacheOverride === true ||
18
26
  taskHistoryDetail == null ||
19
27
  taskHistoryDetail.fetchState !== 'In-Progress') {
@@ -23,6 +23,12 @@ const buildTaskDetailLocalData = (task) => ({
23
23
  recurringEndDate: task.recurringEndDate,
24
24
  },
25
25
  savedRecurringEndDate: task.recurringEndDate,
26
+ // Explicitly cleared, not omitted. `saveTaskUpdatesToLocalStore` SHALLOW
27
+ // MERGES this over the existing local data, so a key left out here keeps
28
+ // whatever was there before. The task entity carries no recurrence count —
29
+ // this is a form-local value — so a rebuild from the entity must reset it,
30
+ // or an abandoned recurrence edit survives leaving and re-opening the task.
31
+ savedRecurringTasksCount: undefined,
26
32
  recurringStartDate: task.recurringStartDate,
27
33
  timeSpent: task.timeSpent,
28
34
  isPrivate: task.isPrivate,
@@ -11,7 +11,6 @@ const rootActions_1 = require("../../../rootActions");
11
11
  const commonHistory_1 = require("../../spendManagement/commonHistoryView/commonHistory");
12
12
  const commonHistoryPayload_1 = require("../../spendManagement/commonHistoryView/commonHistoryPayload");
13
13
  const taskDetail_1 = require("./taskDetail");
14
- const taskDetailLocalData_1 = require("./taskDetailLocalData");
15
14
  exports.initialState = {
16
15
  newTaskState: taskDetail_1.initialTaskDetail,
17
16
  editTaskStateById: {},
@@ -28,12 +27,17 @@ const taskDetailView = (0, toolkit_1.createSlice)({
28
27
  reducers: {
29
28
  fetchTaskDetailPage: {
30
29
  reducer(draft, action) {
31
- const { taskId } = action.payload;
30
+ const { cacheOverride, taskId } = action.payload;
32
31
  if (taskId != null) {
33
32
  const detail = (0, get_1.default)(draft.editTaskStateById, taskId, undefined);
34
- const isRefresh = action.payload.cacheOverride === true && (0, taskDetailLocalData_1.isLoadedTaskDetail)(detail);
35
- if (!isRefresh &&
36
- (action.payload.cacheOverride === true ||
33
+ // An already-loaded task is served from the store on BOTH paths:
34
+ // cacheOverride=true refreshes it in place, cacheOverride=false
35
+ // re-opens it. Resetting the record to `initialTaskDetail` would put
36
+ // `fetchTaskStatus` back to 'Not-Started' and throw the cached task
37
+ // away, so the detail epic's cache check could never hit and every
38
+ // open refetched behind a skeleton.
39
+ if (detail?.fetchTaskStatus.fetchState !== 'Completed' &&
40
+ (cacheOverride === true ||
37
41
  detail == null ||
38
42
  detail.fetchTaskStatus.fetchState !== 'In-Progress')) {
39
43
  draft.editTaskStateById[taskId] = {
@@ -126,7 +130,29 @@ const taskDetailView = (0, toolkit_1.createSlice)({
126
130
  discardTaskUpdatesInLocalStore(draft, action) {
127
131
  if (action.payload.taskId != null) {
128
132
  const key = action.payload.taskId;
129
- draft.editTaskStateById[key] = taskDetail_1.initialTaskDetail;
133
+ const existing = draft.editTaskStateById[key];
134
+ // Reset the MUTATION statuses. Nothing else.
135
+ //
136
+ // Replacing the whole record reset `fetchTaskStatus` to 'Not-Started',
137
+ // which threw the cached task away, so re-opening always refetched.
138
+ // But blanking `taskDetailLocalData` while keeping 'Completed' is just
139
+ // as wrong the other way: the page skeletons only on 'Not-Started' /
140
+ // 'In-Progress', so the detail page renders one more frame on its way
141
+ // out — now as an EMPTY form, which reads as "a new task" for an
142
+ // instant before the board appears. The skeleton used to cover that.
143
+ //
144
+ // The edits do not need clearing here: every open dispatches
145
+ // `initializeTaskToLocalStore`, and its epic overwrites the local data
146
+ // wholesale from the task entity. That is what actually discards them.
147
+ draft.editTaskStateById[key] =
148
+ existing == null
149
+ ? taskDetail_1.initialTaskDetail
150
+ : {
151
+ ...taskDetail_1.initialTaskDetail,
152
+ taskId: existing.taskId,
153
+ fetchTaskStatus: existing.fetchTaskStatus,
154
+ taskDetailLocalData: existing.taskDetailLocalData,
155
+ };
130
156
  }
131
157
  else {
132
158
  draft.newTaskState = taskDetail_1.initialTaskDetail;
@@ -300,11 +326,21 @@ const taskDetailView = (0, toolkit_1.createSlice)({
300
326
  },
301
327
  fetchTaskHistory: {
302
328
  reducer(draft, action) {
303
- const { taskId, cacheOverride } = action.payload;
329
+ // `cacheOverride` is deliberately unused: a refresh of loaded history
330
+ // is silent either way now, and the epic decides whether to refetch.
331
+ const { taskId } = action.payload;
304
332
  const detail = (0, get_1.default)(draft.taskHistoryById, taskId, undefined);
305
- if (cacheOverride === true ||
306
- detail == null ||
307
- detail.fetchState !== 'In-Progress') {
333
+ // Same as `fetchTaskDetailPage`: re-opening a task must not wipe the
334
+ // history it already has, or the history epic's cache check can never
335
+ // hit and every open refetches it.
336
+ //
337
+ // Only a record that does not exist yet is seeded. Keying this on
338
+ // `fetchState === 'Completed'` left a gap: a silent refresh moves the
339
+ // record to 'In-Progress' with its events intact, and a second refresh
340
+ // landing on top of that no longer saw 'Completed', so it rebuilt from
341
+ // `initialCommonHistory` and emptied `historicEvents` — the very flash
342
+ // this is here to prevent.
343
+ if (detail == null) {
308
344
  draft.taskHistoryById[taskId] = {
309
345
  ...commonHistory_1.initialCommonHistory,
310
346
  };
@@ -328,8 +364,12 @@ const taskDetailView = (0, toolkit_1.createSlice)({
328
364
  },
329
365
  updateTaskHistoryFetchStatus(draft, action) {
330
366
  const { taskId, fetchState, error } = action.payload;
367
+ // Keep the events that are already there. Spreading `initialCommonHistory`
368
+ // emptied the list on every status change, which the loading skeleton used
369
+ // to hide; now that a re-open serves from cache and shows no skeleton, a
370
+ // silent refresh would flash the history list empty and refill it.
331
371
  draft.taskHistoryById[taskId] = {
332
- ...commonHistory_1.initialCommonHistory,
372
+ ...(draft.taskHistoryById[taskId] ?? commonHistory_1.initialCommonHistory),
333
373
  fetchState,
334
374
  error,
335
375
  };
@@ -29,7 +29,24 @@ const taskGroupView = (0, toolkit_1.createSlice)({
29
29
  initialState: exports.initialState,
30
30
  reducers: {
31
31
  fetchAllTaskGroups(draft) {
32
- draft.fetchState = 'In-Progress';
32
+ // Groups already in the store: refetch in the background and stay
33
+ // 'Completed'. This mirrors `fetchTaskList`, which has always done it.
34
+ //
35
+ // The board's fetch state is `reduceAnyFetchState([taskListState,
36
+ // taskGroupViewState, ...])`, so flipping this one slice to
37
+ // 'In-Progress' skeletons the WHOLE board — every tab — even with the
38
+ // task list fully cached. The Pusher snapshot-on-connect catch-up
39
+ // refetches groups on every re-subscribe, which is why returning from a
40
+ // task made the board look like it was reloading when no task request
41
+ // was in flight at all.
42
+ //
43
+ // Keyed on "has a fetch ever completed", NOT on `taskGroupsIds.length`:
44
+ // a tenant that uses no groups has a legitimately empty list, and
45
+ // counting ids makes that indistinguishable from a cold load — so every
46
+ // catch-up would skeleton the board for exactly the orgs this is meant
47
+ // to help.
48
+ draft.fetchState =
49
+ draft.fetchState === 'Completed' ? 'Completed' : 'In-Progress';
33
50
  draft.error = undefined;
34
51
  },
35
52
  updateTaskGroupsOnSuccess(draft, action) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.2.96",
3
+ "version": "5.2.98",
4
4
  "description": "Shared module between Web & Mobile containing required abstractions for state management, async network communication. ",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/esm/index.js",