@zeniai/client-epic-state 5.2.77 → 5.2.79

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 (88) hide show
  1. package/lib/esm/view/invoicing/couponView/couponViewReducer.js +14 -25
  2. package/lib/esm/view/invoicing/couponView/couponViewSelector.js +21 -22
  3. package/lib/esm/view/invoicing/couponView/couponViewState.js +2 -0
  4. package/lib/esm/view/invoicing/couponView/fetchInvoicingCouponListEpic.js +26 -8
  5. package/lib/esm/view/invoicing/couponView/fetchInvoicingCouponListPageEpic.js +16 -2
  6. package/lib/esm/view/invoicing/createInvoice/createInvoiceSelector.js +23 -15
  7. package/lib/esm/view/invoicing/createInvoice/epics/fetchCreateInvoiceFormPageEpic.js +5 -2
  8. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewSelector.js +2 -2
  9. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/fetchInvoicingSubscriptionDetailPageEpic.js +9 -6
  10. package/lib/esm/view/invoicing/invoicingCreditNoteListView/epics/fetchInvoicingCreditNoteListEpic.js +25 -8
  11. package/lib/esm/view/invoicing/invoicingCreditNoteListView/epics/fetchInvoicingCreditNoteListPageEpic.js +15 -2
  12. package/lib/esm/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewReducer.js +12 -23
  13. package/lib/esm/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewSelector.js +6 -9
  14. package/lib/esm/view/invoicing/invoicingCustomerListView/epics/fetchInvoicingCustomerListEpic.js +26 -8
  15. package/lib/esm/view/invoicing/invoicingCustomerListView/epics/fetchInvoicingCustomerListPageEpic.js +16 -2
  16. package/lib/esm/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewReducer.js +14 -25
  17. package/lib/esm/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewSelector.js +16 -13
  18. package/lib/esm/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewState.js +2 -0
  19. package/lib/esm/view/invoicing/invoicingDunningCaseListView/epics/fetchInvoicingDunningCaseListEpic.js +25 -8
  20. package/lib/esm/view/invoicing/invoicingDunningCaseListView/epics/fetchInvoicingDunningCaseListPageEpic.js +15 -2
  21. package/lib/esm/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewReducer.js +12 -23
  22. package/lib/esm/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewSelector.js +6 -9
  23. package/lib/esm/view/invoicing/invoicingListTabCache.js +98 -0
  24. package/lib/esm/view/invoicing/invoicingOverview/epics/fetchInvoicingOverviewEpic.js +4 -7
  25. package/lib/esm/view/invoicing/invoicingOverview/invoicingOverviewSelector.js +8 -5
  26. package/lib/esm/view/invoicing/invoicingSubscriptionListView/epics/fetchInvoicingSubscriptionListEpic.js +28 -9
  27. package/lib/esm/view/invoicing/invoicingSubscriptionListView/epics/fetchInvoicingSubscriptionListPageEpic.js +15 -2
  28. package/lib/esm/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewReducer.js +13 -25
  29. package/lib/esm/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewSelector.js +6 -9
  30. package/lib/esm/view/taskManager/taskDetailView/epics/fetchTaskDetailEpic.js +82 -41
  31. package/lib/esm/view/taskManager/taskDetailView/epics/fetchTaskDetailPageEpic.js +9 -1
  32. package/lib/esm/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +2 -23
  33. package/lib/esm/view/taskManager/taskDetailView/taskDetailLocalData.js +36 -0
  34. package/lib/esm/view/taskManager/taskDetailView/taskDetailReducer.js +11 -3
  35. package/lib/view/invoicing/couponView/couponViewReducer.d.ts +8 -1
  36. package/lib/view/invoicing/couponView/couponViewReducer.js +14 -25
  37. package/lib/view/invoicing/couponView/couponViewSelector.js +20 -21
  38. package/lib/view/invoicing/couponView/couponViewState.d.ts +7 -4
  39. package/lib/view/invoicing/couponView/couponViewState.js +3 -1
  40. package/lib/view/invoicing/couponView/fetchInvoicingCouponListEpic.d.ts +1 -1
  41. package/lib/view/invoicing/couponView/fetchInvoicingCouponListEpic.js +25 -7
  42. package/lib/view/invoicing/couponView/fetchInvoicingCouponListPageEpic.js +16 -2
  43. package/lib/view/invoicing/createInvoice/createInvoiceSelector.js +22 -14
  44. package/lib/view/invoicing/createInvoice/epics/fetchCreateInvoiceFormPageEpic.js +5 -2
  45. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewSelector.js +1 -1
  46. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/fetchInvoicingSubscriptionDetailPageEpic.js +9 -6
  47. package/lib/view/invoicing/invoicingCreditNoteListView/epics/fetchInvoicingCreditNoteListEpic.d.ts +1 -1
  48. package/lib/view/invoicing/invoicingCreditNoteListView/epics/fetchInvoicingCreditNoteListEpic.js +24 -7
  49. package/lib/view/invoicing/invoicingCreditNoteListView/epics/fetchInvoicingCreditNoteListPageEpic.js +15 -2
  50. package/lib/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewReducer.d.ts +8 -1
  51. package/lib/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewReducer.js +12 -23
  52. package/lib/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewSelector.js +6 -9
  53. package/lib/view/invoicing/invoicingCreditNoteListView/invoicingCreditNoteListViewState.d.ts +5 -4
  54. package/lib/view/invoicing/invoicingCustomerListView/epics/fetchInvoicingCustomerListEpic.d.ts +1 -1
  55. package/lib/view/invoicing/invoicingCustomerListView/epics/fetchInvoicingCustomerListEpic.js +25 -7
  56. package/lib/view/invoicing/invoicingCustomerListView/epics/fetchInvoicingCustomerListPageEpic.js +16 -2
  57. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewReducer.d.ts +8 -1
  58. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewReducer.js +14 -25
  59. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewSelector.d.ts +5 -0
  60. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewSelector.js +17 -13
  61. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewState.d.ts +7 -4
  62. package/lib/view/invoicing/invoicingCustomerListView/invoicingCustomerListViewState.js +3 -1
  63. package/lib/view/invoicing/invoicingDunningCaseListView/epics/fetchInvoicingDunningCaseListEpic.d.ts +1 -1
  64. package/lib/view/invoicing/invoicingDunningCaseListView/epics/fetchInvoicingDunningCaseListEpic.js +24 -7
  65. package/lib/view/invoicing/invoicingDunningCaseListView/epics/fetchInvoicingDunningCaseListPageEpic.js +15 -2
  66. package/lib/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewReducer.d.ts +8 -1
  67. package/lib/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewReducer.js +12 -23
  68. package/lib/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewSelector.js +6 -9
  69. package/lib/view/invoicing/invoicingDunningCaseListView/invoicingDunningCaseListViewState.d.ts +5 -4
  70. package/lib/view/invoicing/invoicingListTabCache.d.ts +71 -0
  71. package/lib/view/invoicing/invoicingListTabCache.js +111 -0
  72. package/lib/view/invoicing/invoicingOverview/epics/fetchInvoicingOverviewEpic.js +4 -7
  73. package/lib/view/invoicing/invoicingOverview/invoicingOverviewSelector.js +8 -5
  74. package/lib/view/invoicing/invoicingSubscriptionListView/epics/fetchInvoicingSubscriptionListEpic.d.ts +1 -1
  75. package/lib/view/invoicing/invoicingSubscriptionListView/epics/fetchInvoicingSubscriptionListEpic.js +27 -8
  76. package/lib/view/invoicing/invoicingSubscriptionListView/epics/fetchInvoicingSubscriptionListPageEpic.js +15 -2
  77. package/lib/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewReducer.d.ts +8 -1
  78. package/lib/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewReducer.js +13 -25
  79. package/lib/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewSelector.js +6 -9
  80. package/lib/view/invoicing/invoicingSubscriptionListView/invoicingSubscriptionListViewState.d.ts +5 -5
  81. package/lib/view/taskManager/taskDetailView/epics/fetchTaskDetailEpic.d.ts +2 -2
  82. package/lib/view/taskManager/taskDetailView/epics/fetchTaskDetailEpic.js +80 -39
  83. package/lib/view/taskManager/taskDetailView/epics/fetchTaskDetailPageEpic.js +9 -1
  84. package/lib/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +2 -23
  85. package/lib/view/taskManager/taskDetailView/taskDetailLocalData.d.ts +5 -0
  86. package/lib/view/taskManager/taskDetailView/taskDetailLocalData.js +45 -0
  87. package/lib/view/taskManager/taskDetailView/taskDetailReducer.js +11 -3
  88. package/package.json +1 -1
@@ -1,13 +1,18 @@
1
1
  import { of } from 'rxjs';
2
- import { catchError, filter, switchMap } from 'rxjs/operators';
2
+ import { catchError, filter, groupBy, map, mergeMap, switchMap, } from 'rxjs/operators';
3
3
  import { updateAddresses } from '../../../../entity/address/addressReducer';
4
4
  import { extractInvoicingCustomerAddresses } from '../../../../entity/invoicing/invoicingCustomer/invoicingCustomerPayload';
5
5
  import { updateInvoicingCustomers } from '../../../../entity/invoicing/invoicingCustomer/invoicingCustomerReducer';
6
6
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
7
7
  import { INVOICING_LIST_PAGE_SIZE, applyListSortParams, withQuery, } from '../../invoicingApiHelper';
8
+ import { invoicingListFetchTab, invoicingListStatusParam, } from '../../invoicingListTabCache';
8
9
  import { fetchInvoicingCustomerList, updateInvoicingCustomerList, updateInvoicingCustomerListFailure, } from '../invoicingCustomerListViewReducer';
9
- const buildQuery = (filters, cursor, sort) => {
10
+ import { INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS, } from '../invoicingCustomerListViewState';
11
+ const buildQuery = (filters, cursor, sort, status) => {
10
12
  const params = new URLSearchParams();
13
+ if (status != null) {
14
+ params.set('status', status);
15
+ }
11
16
  if (filters.search != null && filters.search.length > 0) {
12
17
  params.set('search', filters.search);
13
18
  }
@@ -18,9 +23,15 @@ const buildQuery = (filters, cursor, sort) => {
18
23
  applyListSortParams(params, sort);
19
24
  return params;
20
25
  };
21
- export const fetchInvoicingCustomerListEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchInvoicingCustomerList.match), switchMap((action) => {
26
+ export const fetchInvoicingCustomerListEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchInvoicingCustomerList.match), map((action) => ({
27
+ action,
28
+ tabId: invoicingListFetchTab(state$.value.invoicingCustomerListViewState.activeTab, action.payload.tabId, INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS),
29
+ })),
30
+ // Per tab: a newer fetch of the same tab replaces its in-flight one, while
31
+ // another tab's request keeps running and lands in its own cache.
32
+ groupBy(({ tabId }) => tabId), mergeMap((tabFetches$) => tabFetches$.pipe(switchMap(({ action, tabId }) => {
22
33
  const { filters, cursor, keepExistingListItems, sort } = action.payload;
23
- const apiUrl = withQuery(`${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/customers`, buildQuery(filters, cursor, sort));
34
+ const apiUrl = withQuery(`${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/customers`, buildQuery(filters, cursor, sort, invoicingListStatusParam(tabId)));
24
35
  return zeniAPI.getJSON(apiUrl).pipe(switchMap((response) => {
25
36
  if (isSuccessResponse(response)) {
26
37
  const items = response.data?.items ?? [];
@@ -31,10 +42,17 @@ export const fetchInvoicingCustomerListEpic = (actions$, _state$, zeniAPI) => ac
31
42
  ids: items.map((item) => item.id),
32
43
  nextCursor: response.data?.next_cursor ?? null,
33
44
  keepExistingListItems,
45
+ tabId,
34
46
  }),
35
47
  ];
36
48
  }
37
- return of(updateInvoicingCustomerListFailure(response.status));
38
- }), catchError((error) => of(updateInvoicingCustomerListFailure(createZeniAPIStatus('Unexpected Error', 'Fetch customer list REST API call errored out ' +
39
- JSON.stringify(error))))));
40
- }));
49
+ return of(updateInvoicingCustomerListFailure({
50
+ status: response.status,
51
+ tabId,
52
+ }));
53
+ }), catchError((error) => of(updateInvoicingCustomerListFailure({
54
+ status: createZeniAPIStatus('Unexpected Error', 'Fetch customer list REST API call errored out ' +
55
+ JSON.stringify(error)),
56
+ tabId,
57
+ }))));
58
+ }))));
@@ -1,6 +1,8 @@
1
1
  import { concat, from } from 'rxjs';
2
2
  import { filter, mergeMap } from 'rxjs/operators';
3
+ import { getInvoicingListTab, invoicingListSourceTab, planInvoicingListTabFetch, } from '../../invoicingListTabCache';
3
4
  import { fetchInvoicingCustomerCounts, fetchInvoicingCustomerList, fetchInvoicingCustomerListPage, } from '../invoicingCustomerListViewReducer';
5
+ import { INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS } from '../invoicingCustomerListViewState';
4
6
  /**
5
7
  * Fetch unless the endpoint is already loaded or in flight — or always when the
6
8
  * caller forces a refresh via `cacheOverride` (e.g. on a filter/sort change).
@@ -17,8 +19,20 @@ export const fetchInvoicingCustomerListPageEpic = (actions$, state$) => actions$
17
19
  const { cacheOverride, filters, sort } = action.payload;
18
20
  const state = state$.value.invoicingCustomerListViewState;
19
21
  const pageActions = [];
20
- if (shouldFetch({ fetchState: state.fetchState, error: state.error }, cacheOverride)) {
21
- pageActions.push(fetchInvoicingCustomerList({ filters, sort }));
22
+ const tabId = invoicingListSourceTab(state.activeTab, INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS);
23
+ const tabFetch = planInvoicingListTabFetch(getInvoicingListTab(state.byTab, tabId), cacheOverride);
24
+ if (tabFetch.kind === 'firstPage') {
25
+ pageActions.push(fetchInvoicingCustomerList({ filters, sort, tabId }));
26
+ }
27
+ else if (tabFetch.kind === 'nextPage') {
28
+ // Less than a page cached and the server has more: top it up.
29
+ pageActions.push(fetchInvoicingCustomerList({
30
+ cursor: tabFetch.cursor,
31
+ filters,
32
+ keepExistingListItems: true,
33
+ sort,
34
+ tabId,
35
+ }));
22
36
  }
23
37
  if (shouldFetch(state.countsFetchState, cacheOverride)) {
24
38
  pageActions.push(fetchInvoicingCustomerCounts());
@@ -1,17 +1,13 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  import { clearAll } from '../../../rootActions';
3
+ import { addToInvoicingListTabs, completeInvoicingListTabFetch, failInvoicingListTabFetch, invoicingListFetchTab, isSameInvoicingListQuery, startInvoicingListTabFetch, } from '../invoicingListTabCache';
4
+ import { INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS, } from './invoicingCustomerListViewState';
3
5
  export const initialState = {
4
- fetchState: 'Not-Started',
5
- error: undefined,
6
6
  activeTab: 'all',
7
- ids: [],
8
- nextCursor: null,
7
+ byTab: {},
9
8
  filters: {},
10
9
  counts: { all: 0 },
11
10
  countsFetchState: { fetchState: 'Not-Started', error: undefined },
12
- hasValidState() {
13
- return this.fetchState === 'Completed';
14
- },
15
11
  };
16
12
  const invoicingCustomerListView = createSlice({
17
13
  name: 'invoicingCustomerListView',
@@ -19,14 +15,9 @@ const invoicingCustomerListView = createSlice({
19
15
  reducers: {
20
16
  fetchInvoicingCustomerList: {
21
17
  reducer(draft, action) {
22
- if (!action.payload.keepExistingListItems) {
23
- draft.ids = [];
24
- draft.nextCursor = null;
25
- }
18
+ startInvoicingListTabFetch(draft.byTab, invoicingListFetchTab(draft.activeTab, action.payload.tabId, INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS), action.payload.keepExistingListItems);
26
19
  draft.filters = action.payload.filters;
27
20
  draft.sort = action.payload.sort;
28
- draft.fetchState = 'In-Progress';
29
- draft.error = undefined;
30
21
  },
31
22
  prepare(payload) {
32
23
  return {
@@ -35,12 +26,17 @@ const invoicingCustomerListView = createSlice({
35
26
  cursor: payload.cursor,
36
27
  keepExistingListItems: payload.keepExistingListItems ?? false,
37
28
  sort: payload.sort,
29
+ tabId: payload.tabId,
38
30
  },
39
31
  };
40
32
  },
41
33
  },
42
34
  fetchInvoicingCustomerListPage: {
43
35
  reducer(draft, action) {
36
+ // Another search or sort makes every cached tab stale.
37
+ if (!isSameInvoicingListQuery(draft, action.payload)) {
38
+ draft.byTab = {};
39
+ }
44
40
  draft.filters = action.payload.filters;
45
41
  draft.sort = action.payload.sort;
46
42
  },
@@ -55,16 +51,10 @@ const invoicingCustomerListView = createSlice({
55
51
  },
56
52
  },
57
53
  updateInvoicingCustomerList(draft, action) {
58
- draft.ids = action.payload.keepExistingListItems
59
- ? [...new Set([...draft.ids, ...action.payload.ids])]
60
- : action.payload.ids;
61
- draft.nextCursor = action.payload.nextCursor;
62
- draft.fetchState = 'Completed';
63
- draft.error = undefined;
54
+ completeInvoicingListTabFetch(draft.byTab, action.payload.tabId, action.payload);
64
55
  },
65
56
  updateInvoicingCustomerListFailure(draft, action) {
66
- draft.fetchState = 'Error';
67
- draft.error = action.payload;
57
+ failInvoicingListTabFetch(draft.byTab, action.payload.tabId, action.payload.status);
68
58
  },
69
59
  fetchInvoicingCustomerCounts(draft) {
70
60
  draft.countsFetchState.fetchState = 'In-Progress';
@@ -82,8 +72,8 @@ const invoicingCustomerListView = createSlice({
82
72
  updateInvoicingCustomerFilters(draft, action) {
83
73
  draft.filters = action.payload;
84
74
  },
85
- // Select a status tab. The list API ignores `status` (returns all statuses),
86
- // so the selector filters rows to this tab client-side — no refetch needed.
75
+ // Select a status tab. The view dispatches the page fetch after, which loads
76
+ // the tab from the server only when its cache is empty or failed.
87
77
  setInvoicingCustomerActiveTab(draft, action) {
88
78
  draft.activeTab = action.payload;
89
79
  },
@@ -94,10 +84,9 @@ const invoicingCustomerListView = createSlice({
94
84
  // dispatch time, so bump only `counts.all` when `status` is absent.
95
85
  addInvoicingCustomerToList(draft, action) {
96
86
  const { id, status } = action.payload;
97
- if (draft.ids.includes(id)) {
87
+ if (!addToInvoicingListTabs(draft.byTab, id, status)) {
98
88
  return;
99
89
  }
100
- draft.ids = [id, ...draft.ids];
101
90
  draft.counts.all += 1;
102
91
  if (status != null) {
103
92
  draft.counts[status] = (draft.counts[status] ?? 0) + 1;
@@ -1,7 +1,8 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
2
  import { getInvoicingCustomersByIds } from '../../../entity/invoicing/invoicingCustomer/invoicingCustomerSelector';
3
+ import { INVOICING_LIST_ALL_TAB, getInvoicingListTab, invoicingListSourceTab, } from '../invoicingListTabCache';
3
4
  import { invoicingTabIdsFromConfig } from '../invoicingTabHelper';
4
- import { ALL_INVOICING_CUSTOMER_TAB_IDS, INVOICING_CUSTOMER_COLUMNS, } from './invoicingCustomerListViewState';
5
+ import { ALL_INVOICING_CUSTOMER_TAB_IDS, INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS, INVOICING_CUSTOMER_COLUMNS, } from './invoicingCustomerListViewState';
5
6
  /**
6
7
  * Memoized so the denormalized `customers` array (and the wrapping view object)
7
8
  * keep referential identity until the customer entity slice or the list-view
@@ -10,22 +11,19 @@ import { ALL_INVOICING_CUSTOMER_TAB_IDS, INVOICING_CUSTOMER_COLUMNS, } from './i
10
11
  */
11
12
  export const getInvoicingCustomerListView = createSelector((state) => state.invoicingCustomerState, (state) => state.invoicingCustomerListViewState, (state) => state.invoicingConfigViewState.customerStatusOptions, (customerState, listViewState, statusOptions) => {
12
13
  const { activeTab } = listViewState;
13
- const allCustomers = getInvoicingCustomersByIds(customerState, listViewState.ids);
14
- // The list API ignores `status`, so it returns every status; filter to the
15
- // active tab client-side ('all' shows everything, 'at_risk' keys off the
16
- // `isAtRisk` flag, the rest match the customer `status`).
17
- const customers = activeTab === 'all'
18
- ? allCustomers
19
- : activeTab === 'at_risk'
20
- ? allCustomers.filter((customer) => customer.isAtRisk === true)
21
- : allCustomers.filter((customer) => customer.status === activeTab);
14
+ const tab = getInvoicingListTab(listViewState.byTab, invoicingListSourceTab(activeTab, INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS));
15
+ const tabRows = getInvoicingCustomersByIds(customerState, tab.ids);
16
+ // `at_risk` is not a stored status, so it is the `all` rows filtered here.
17
+ const customers = activeTab === 'at_risk'
18
+ ? tabRows.filter((customer) => customer.isAtRisk === true)
19
+ : tabRows;
22
20
  return {
23
21
  activeTab,
24
22
  columns: INVOICING_CUSTOMER_COLUMNS,
25
23
  customers,
26
- fetchState: listViewState.fetchState,
27
- error: listViewState.error,
28
- nextCursor: listViewState.nextCursor,
24
+ fetchState: tab.fetchState,
25
+ error: tab.error,
26
+ nextCursor: tab.nextCursor,
29
27
  filters: listViewState.filters,
30
28
  count: listViewState.counts,
31
29
  countsFetchState: listViewState.countsFetchState,
@@ -38,3 +36,8 @@ export const getInvoicingCustomerListView = createSelector((state) => state.invo
38
36
  sort: listViewState.sort,
39
37
  };
40
38
  });
39
+ /**
40
+ * Every loaded customer, unfiltered: the pickers read this rather than the list
41
+ * view so they never narrow to whichever tab the customers list was left on.
42
+ */
43
+ export const getInvoicingAllCustomers = (state) => getInvoicingCustomersByIds(state.invoicingCustomerState, getInvoicingListTab(state.invoicingCustomerListViewState.byTab, INVOICING_LIST_ALL_TAB).ids);
@@ -23,3 +23,5 @@ export const ALL_INVOICING_CUSTOMER_TAB_IDS = [
23
23
  'at_risk',
24
24
  ];
25
25
  export const toInvoicingCustomerTabId = (v) => stringToUnion(v, ALL_INVOICING_CUSTOMER_TAB_IDS);
26
+ /** Tabs the list API cannot filter on: they read the `all` rows, filtered here. */
27
+ export const INVOICING_CUSTOMER_CLIENT_FILTERED_TAB_IDS = ['at_risk'];
@@ -1,11 +1,15 @@
1
1
  import { of } from 'rxjs';
2
- import { catchError, filter, switchMap } from 'rxjs/operators';
2
+ import { catchError, filter, groupBy, map, mergeMap, switchMap, } from 'rxjs/operators';
3
3
  import { updateInvoicingDunningCases } from '../../../../entity/invoicing/dunningCase/dunningCaseReducer';
4
4
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
5
  import { INVOICING_LIST_PAGE_SIZE, applyListSortParams, withQuery, } from '../../invoicingApiHelper';
6
+ import { invoicingListFetchTab, invoicingListStatusParam, } from '../../invoicingListTabCache';
6
7
  import { fetchInvoicingDunningCaseList, updateInvoicingDunningCaseList, updateInvoicingDunningCaseListFailure, } from '../invoicingDunningCaseListViewReducer';
7
- const buildQuery = (filters, cursor, sort) => {
8
+ const buildQuery = (filters, cursor, sort, status) => {
8
9
  const params = new URLSearchParams();
10
+ if (status != null) {
11
+ params.set('status', status);
12
+ }
9
13
  if (filters.search != null && filters.search.length > 0) {
10
14
  params.set('search', filters.search);
11
15
  }
@@ -16,9 +20,15 @@ const buildQuery = (filters, cursor, sort) => {
16
20
  applyListSortParams(params, sort);
17
21
  return params;
18
22
  };
19
- export const fetchInvoicingDunningCaseListEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchInvoicingDunningCaseList.match), switchMap((action) => {
23
+ export const fetchInvoicingDunningCaseListEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchInvoicingDunningCaseList.match), map((action) => ({
24
+ action,
25
+ tabId: invoicingListFetchTab(state$.value.invoicingDunningCaseListViewState.activeTab, action.payload.tabId),
26
+ })),
27
+ // Per tab: a newer fetch of the same tab replaces its in-flight one, while
28
+ // another tab's request keeps running and lands in its own cache.
29
+ groupBy(({ tabId }) => tabId), mergeMap((tabFetches$) => tabFetches$.pipe(switchMap(({ action, tabId }) => {
20
30
  const { filters, cursor, keepExistingListItems, sort } = action.payload;
21
- const apiUrl = withQuery(`${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/dunning`, buildQuery(filters, cursor, sort));
31
+ const apiUrl = withQuery(`${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/dunning`, buildQuery(filters, cursor, sort, invoicingListStatusParam(tabId)));
22
32
  return zeniAPI.getJSON(apiUrl).pipe(switchMap((response) => {
23
33
  if (isSuccessResponse(response)) {
24
34
  const items = response.data?.items ?? [];
@@ -28,10 +38,17 @@ export const fetchInvoicingDunningCaseListEpic = (actions$, _state$, zeniAPI) =>
28
38
  ids: items.map((item) => item.id),
29
39
  nextCursor: response.data?.next_cursor ?? null,
30
40
  keepExistingListItems,
41
+ tabId,
31
42
  }),
32
43
  ];
33
44
  }
34
- return of(updateInvoicingDunningCaseListFailure(response.status));
35
- }), catchError((error) => of(updateInvoicingDunningCaseListFailure(createZeniAPIStatus('Unexpected Error', 'Fetch dunning case list REST API call errored out ' +
36
- JSON.stringify(error))))));
37
- }));
45
+ return of(updateInvoicingDunningCaseListFailure({
46
+ status: response.status,
47
+ tabId,
48
+ }));
49
+ }), catchError((error) => of(updateInvoicingDunningCaseListFailure({
50
+ status: createZeniAPIStatus('Unexpected Error', 'Fetch dunning case list REST API call errored out ' +
51
+ JSON.stringify(error)),
52
+ tabId,
53
+ }))));
54
+ }))));
@@ -1,5 +1,6 @@
1
1
  import { concat, from } from 'rxjs';
2
2
  import { filter, mergeMap } from 'rxjs/operators';
3
+ import { getInvoicingListTab, invoicingListSourceTab, planInvoicingListTabFetch, } from '../../invoicingListTabCache';
3
4
  import { fetchInvoicingDunningCaseCounts, fetchInvoicingDunningCaseList, fetchInvoicingDunningCaseListPage, } from '../invoicingDunningCaseListViewReducer';
4
5
  /**
5
6
  * Fetch unless the endpoint is already loaded or in flight — or always when the
@@ -17,8 +18,20 @@ export const fetchInvoicingDunningCaseListPageEpic = (actions$, state$) => actio
17
18
  const { cacheOverride, filters, sort } = action.payload;
18
19
  const state = state$.value.invoicingDunningCaseListViewState;
19
20
  const pageActions = [];
20
- if (shouldFetch({ fetchState: state.fetchState, error: state.error }, cacheOverride)) {
21
- pageActions.push(fetchInvoicingDunningCaseList({ filters, sort }));
21
+ const tabId = invoicingListSourceTab(state.activeTab);
22
+ const tabFetch = planInvoicingListTabFetch(getInvoicingListTab(state.byTab, tabId), cacheOverride);
23
+ if (tabFetch.kind === 'firstPage') {
24
+ pageActions.push(fetchInvoicingDunningCaseList({ filters, sort, tabId }));
25
+ }
26
+ else if (tabFetch.kind === 'nextPage') {
27
+ // Less than a page cached and the server has more: top it up.
28
+ pageActions.push(fetchInvoicingDunningCaseList({
29
+ cursor: tabFetch.cursor,
30
+ filters,
31
+ keepExistingListItems: true,
32
+ sort,
33
+ tabId,
34
+ }));
22
35
  }
23
36
  if (shouldFetch(state.countsFetchState, cacheOverride)) {
24
37
  pageActions.push(fetchInvoicingDunningCaseCounts());
@@ -1,17 +1,12 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  import { clearAll } from '../../../rootActions';
3
+ import { completeInvoicingListTabFetch, failInvoicingListTabFetch, invoicingListFetchTab, isSameInvoicingListQuery, startInvoicingListTabFetch, } from '../invoicingListTabCache';
3
4
  export const initialState = {
4
- fetchState: 'Not-Started',
5
- error: undefined,
6
5
  activeTab: 'all',
7
- ids: [],
8
- nextCursor: null,
6
+ byTab: {},
9
7
  filters: {},
10
8
  counts: { all: 0 },
11
9
  countsFetchState: { fetchState: 'Not-Started', error: undefined },
12
- hasValidState() {
13
- return this.fetchState === 'Completed';
14
- },
15
10
  };
16
11
  const invoicingDunningCaseListView = createSlice({
17
12
  name: 'invoicingDunningCaseListView',
@@ -19,14 +14,9 @@ const invoicingDunningCaseListView = createSlice({
19
14
  reducers: {
20
15
  fetchInvoicingDunningCaseList: {
21
16
  reducer(draft, action) {
22
- if (!action.payload.keepExistingListItems) {
23
- draft.ids = [];
24
- draft.nextCursor = null;
25
- }
17
+ startInvoicingListTabFetch(draft.byTab, invoicingListFetchTab(draft.activeTab, action.payload.tabId), action.payload.keepExistingListItems);
26
18
  draft.filters = action.payload.filters;
27
19
  draft.sort = action.payload.sort;
28
- draft.fetchState = 'In-Progress';
29
- draft.error = undefined;
30
20
  },
31
21
  prepare(payload) {
32
22
  return {
@@ -35,12 +25,17 @@ const invoicingDunningCaseListView = createSlice({
35
25
  cursor: payload.cursor,
36
26
  keepExistingListItems: payload.keepExistingListItems ?? false,
37
27
  sort: payload.sort,
28
+ tabId: payload.tabId,
38
29
  },
39
30
  };
40
31
  },
41
32
  },
42
33
  fetchInvoicingDunningCaseListPage: {
43
34
  reducer(draft, action) {
35
+ // Another search or sort makes every cached tab stale.
36
+ if (!isSameInvoicingListQuery(draft, action.payload)) {
37
+ draft.byTab = {};
38
+ }
44
39
  draft.filters = action.payload.filters;
45
40
  draft.sort = action.payload.sort;
46
41
  },
@@ -55,16 +50,10 @@ const invoicingDunningCaseListView = createSlice({
55
50
  },
56
51
  },
57
52
  updateInvoicingDunningCaseList(draft, action) {
58
- draft.ids = action.payload.keepExistingListItems
59
- ? [...new Set([...draft.ids, ...action.payload.ids])]
60
- : action.payload.ids;
61
- draft.nextCursor = action.payload.nextCursor;
62
- draft.fetchState = 'Completed';
63
- draft.error = undefined;
53
+ completeInvoicingListTabFetch(draft.byTab, action.payload.tabId, action.payload);
64
54
  },
65
55
  updateInvoicingDunningCaseListFailure(draft, action) {
66
- draft.fetchState = 'Error';
67
- draft.error = action.payload;
56
+ failInvoicingListTabFetch(draft.byTab, action.payload.tabId, action.payload.status);
68
57
  },
69
58
  fetchInvoicingDunningCaseCounts(draft) {
70
59
  draft.countsFetchState.fetchState = 'In-Progress';
@@ -82,8 +71,8 @@ const invoicingDunningCaseListView = createSlice({
82
71
  updateInvoicingDunningCaseFilters(draft, action) {
83
72
  draft.filters = action.payload;
84
73
  },
85
- // Select a status tab. The list API ignores `status` (returns all statuses),
86
- // so the selector filters rows to this tab client-side — no refetch needed.
74
+ // Select a status tab. The view dispatches the page fetch after, which loads
75
+ // the tab from the server only when its cache is empty or failed.
87
76
  setInvoicingDunningCaseActiveTab(draft, action) {
88
77
  draft.activeTab = action.payload;
89
78
  },
@@ -1,5 +1,6 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
2
  import { getInvoicingDunningCasesByIds } from '../../../entity/invoicing/dunningCase/dunningCaseSelector';
3
+ import { getInvoicingListTab, invoicingListSourceTab, } from '../invoicingListTabCache';
3
4
  import { invoicingTabIdsFromConfig } from '../invoicingTabHelper';
4
5
  import { ALL_INVOICING_DUNNING_CASE_TAB_IDS, INVOICING_DUNNING_CASE_COLUMNS, } from './invoicingDunningCaseListViewState';
5
6
  /**
@@ -9,12 +10,8 @@ import { ALL_INVOICING_DUNNING_CASE_TAB_IDS, INVOICING_DUNNING_CASE_COLUMNS, } f
9
10
  */
10
11
  export const getInvoicingDunningCaseListView = createSelector((state) => state.invoicingDunningCaseState, (state) => state.invoicingDunningCaseListViewState, (state) => state.invoicingConfigViewState.dunningCaseStatusOptions, (dunningCaseState, listViewState, statusOptions) => {
11
12
  const { activeTab } = listViewState;
12
- const allDunningCases = getInvoicingDunningCasesByIds(dunningCaseState, listViewState.ids);
13
- // The list API ignores `status`, so it returns every status; filter to the
14
- // active tab client-side ('all' shows everything).
15
- const dunningCases = activeTab === 'all'
16
- ? allDunningCases
17
- : allDunningCases.filter((dunningCase) => dunningCase.status === activeTab);
13
+ const tab = getInvoicingListTab(listViewState.byTab, invoicingListSourceTab(activeTab));
14
+ const dunningCases = getInvoicingDunningCasesByIds(dunningCaseState, tab.ids);
18
15
  return {
19
16
  activeTab,
20
17
  columns: INVOICING_DUNNING_CASE_COLUMNS,
@@ -22,9 +19,9 @@ export const getInvoicingDunningCaseListView = createSelector((state) => state.i
22
19
  count: listViewState.counts,
23
20
  countsFetchState: listViewState.countsFetchState,
24
21
  filters: listViewState.filters,
25
- nextCursor: listViewState.nextCursor,
26
- fetchState: listViewState.fetchState,
27
- error: listViewState.error,
22
+ nextCursor: tab.nextCursor,
23
+ fetchState: tab.fetchState,
24
+ error: tab.error,
28
25
  statusOptions,
29
26
  tabIds: invoicingTabIdsFromConfig({
30
27
  allTabIds: ALL_INVOICING_DUNNING_CASE_TAB_IDS,
@@ -0,0 +1,98 @@
1
+ import { INVOICING_LIST_PAGE_SIZE, } from './invoicingApiHelper';
2
+ export const INVOICING_LIST_ALL_TAB = 'all';
3
+ const NOT_STARTED_TAB = Object.freeze({
4
+ ids: [],
5
+ nextCursor: null,
6
+ fetchState: 'Not-Started',
7
+ error: undefined,
8
+ });
9
+ /** A tab's cache, or a not-started one when it was never fetched. */
10
+ export const getInvoicingListTab = (byTab, tabId) => byTab[tabId] ?? NOT_STARTED_TAB;
11
+ /**
12
+ * The cache a tab reads and fetches. A tab the list API cannot filter on (a
13
+ * derived grouping such as the customers' `at_risk`, not a stored status) is
14
+ * the `all` rows, filtered by the selector.
15
+ */
16
+ export const invoicingListSourceTab = (tabId, clientFilteredTabIds = []) => clientFilteredTabIds.includes(tabId) ? INVOICING_LIST_ALL_TAB : tabId;
17
+ /**
18
+ * The cache a list fetch fills: the tab the caller named, else the active tab's
19
+ * source. The reducer and the epic both resolve it, so they agree on the tab.
20
+ */
21
+ export const invoicingListFetchTab = (activeTab, requestedTab, clientFilteredTabIds = []) => requestedTab ?? invoicingListSourceTab(activeTab, clientFilteredTabIds);
22
+ /** The `status` query param for a source tab; `all` sends none. */
23
+ export const invoicingListStatusParam = (sourceTab) => sourceTab === INVOICING_LIST_ALL_TAB ? undefined : sourceTab;
24
+ /**
25
+ * Whether two list queries match, so their per-tab caches stay valid. A filter
26
+ * left unset and one cleared to `''` are the same query.
27
+ */
28
+ export const isSameInvoicingListQuery = (a, b) => {
29
+ const aFilters = a.filters;
30
+ const bFilters = b.filters;
31
+ const keys = new Set([...Object.keys(aFilters), ...Object.keys(bFilters)]);
32
+ return ([...keys].every((key) => (aFilters[key] ?? '') === (bFilters[key] ?? '')) &&
33
+ a.sort?.field === b.sort?.field &&
34
+ a.sort?.dir === b.sort?.dir);
35
+ };
36
+ /**
37
+ * Fetch a tab that is empty or failed; top up one that holds less than a page
38
+ * while the server says there is more; leave a full or complete one cached.
39
+ * Runs on page/tab visits only, so a server returning short pages cannot loop.
40
+ */
41
+ export function planInvoicingListTabFetch(tab, cacheOverride) {
42
+ if (cacheOverride) {
43
+ return { kind: 'firstPage' };
44
+ }
45
+ if (tab.fetchState === 'In-Progress') {
46
+ return { kind: 'none' };
47
+ }
48
+ if (tab.fetchState !== 'Completed') {
49
+ return { kind: 'firstPage' };
50
+ }
51
+ if (tab.ids.length < INVOICING_LIST_PAGE_SIZE && tab.nextCursor != null) {
52
+ return { cursor: tab.nextCursor, kind: 'nextPage' };
53
+ }
54
+ return { kind: 'none' };
55
+ }
56
+ const tabFor = (byTab, tabId) => {
57
+ byTab[tabId] ?? (byTab[tabId] = { ...NOT_STARTED_TAB, ids: [] });
58
+ return byTab[tabId];
59
+ };
60
+ export function startInvoicingListTabFetch(byTab, tabId, keepExistingListItems) {
61
+ const tab = tabFor(byTab, tabId);
62
+ if (!keepExistingListItems) {
63
+ tab.ids = [];
64
+ tab.nextCursor = null;
65
+ }
66
+ tab.fetchState = 'In-Progress';
67
+ tab.error = undefined;
68
+ }
69
+ export function completeInvoicingListTabFetch(byTab, tabId, page) {
70
+ const tab = tabFor(byTab, tabId);
71
+ tab.ids = page.keepExistingListItems
72
+ ? [...new Set([...tab.ids, ...page.ids])]
73
+ : page.ids;
74
+ tab.nextCursor = page.nextCursor;
75
+ tab.fetchState = 'Completed';
76
+ tab.error = undefined;
77
+ }
78
+ export function failInvoicingListTabFetch(byTab, tabId, error) {
79
+ const tab = tabFor(byTab, tabId);
80
+ tab.fetchState = 'Error';
81
+ tab.error = error;
82
+ }
83
+ /**
84
+ * A created record leads `all` and, when its status is known, that status's
85
+ * tab if already loaded. Returns false when `all` already had it.
86
+ */
87
+ export function addToInvoicingListTabs(byTab, id, status) {
88
+ const all = tabFor(byTab, INVOICING_LIST_ALL_TAB);
89
+ if (all.ids.includes(id)) {
90
+ return false;
91
+ }
92
+ all.ids = [id, ...all.ids];
93
+ const statusTab = status != null ? byTab[status] : undefined;
94
+ if (statusTab != null && !statusTab.ids.includes(id)) {
95
+ statusTab.ids = [id, ...statusTab.ids];
96
+ }
97
+ return true;
98
+ }
@@ -1,7 +1,7 @@
1
1
  import { concat, from } from 'rxjs';
2
2
  import { filter, mergeMap } from 'rxjs/operators';
3
+ import { INVOICING_LIST_ALL_TAB, getInvoicingListTab, } from '../../invoicingListTabCache';
3
4
  import { fetchInvoicingSubscriptionList } from '../../invoicingSubscriptionListView/invoicingSubscriptionListViewReducer';
4
- import { getInvoicingSubscriptionListView } from '../../invoicingSubscriptionListView/invoicingSubscriptionListViewSelector';
5
5
  import { fetchInvoicingSettings } from '../../settingsView/settingsViewReducer';
6
6
  import { getInvoicingSettingsFetchState } from '../../settingsView/settingsViewSelector';
7
7
  import { fetchInvoicingOverview, fetchInvoicingOverviewChurn, fetchInvoicingOverviewDashboardSummary, fetchInvoicingOverviewKPIs, fetchInvoicingOverviewMRR, fetchInvoicingOverviewPlanRevenue, fetchInvoicingOverviewRevenue, } from '../invoicingOverviewReducer';
@@ -44,12 +44,9 @@ export const fetchInvoicingOverviewEpic = (actions$, state$) => actions$.pipe(fi
44
44
  if (shouldFetch(getInvoicingSettingsFetchState(rootState), cacheOverride)) {
45
45
  overviewActions.push(fetchInvoicingSettings());
46
46
  }
47
- const subscriptionListView = getInvoicingSubscriptionListView(rootState);
48
- if (shouldFetch({
49
- fetchState: subscriptionListView.fetchState,
50
- error: subscriptionListView.error,
51
- }, cacheOverride)) {
52
- overviewActions.push(fetchInvoicingSubscriptionList({}));
47
+ const subscriptionsTab = getInvoicingListTab(rootState.invoicingSubscriptionListViewState.byTab, INVOICING_LIST_ALL_TAB);
48
+ if (shouldFetch(subscriptionsTab, cacheOverride)) {
49
+ overviewActions.push(fetchInvoicingSubscriptionList({ tabId: INVOICING_LIST_ALL_TAB }));
53
50
  }
54
51
  return concat(from(overviewActions));
55
52
  }));
@@ -1,5 +1,6 @@
1
1
  import { reduceAllFetchState } from '../../../commonStateTypes/reduceFetchState';
2
- import { getInvoicingSubscriptionListView } from '../invoicingSubscriptionListView/invoicingSubscriptionListViewSelector';
2
+ import { getInvoicingSubscriptionsByIds } from '../../../entity/invoicing/invoicingSubscription/invoicingSubscriptionSelector';
3
+ import { INVOICING_LIST_ALL_TAB, getInvoicingListTab, } from '../invoicingListTabCache';
3
4
  import { getInvoicingSettings, getInvoicingSettingsFetchState, } from '../settingsView/settingsViewSelector';
4
5
  const PERIOD_OPTION_MONTHS = 12;
5
6
  function daysInMonth(year, month) {
@@ -25,10 +26,12 @@ export function buildPeriodOptions() {
25
26
  export const getInvoicingOverview = (state) => {
26
27
  const overview = state.invoicingOverviewState;
27
28
  const settingsFetchState = getInvoicingSettingsFetchState(state);
28
- const subscriptionListView = getInvoicingSubscriptionListView(state);
29
+ // The overview waits on the unfiltered `all` subscriptions, not the tab the
30
+ // subscriptions list was left on.
31
+ const subscriptionsTab = getInvoicingListTab(state.invoicingSubscriptionListViewState.byTab, INVOICING_LIST_ALL_TAB);
29
32
  const subscriptionListFetchState = {
30
- fetchState: subscriptionListView.fetchState,
31
- error: subscriptionListView.error,
33
+ fetchState: subscriptionsTab.fetchState,
34
+ error: subscriptionsTab.error,
32
35
  };
33
36
  const reducedFetchState = reduceAllFetchState([
34
37
  overview.kpisFetchState,
@@ -56,7 +59,7 @@ export const getInvoicingOverview = (state) => {
56
59
  fetchState: settingsFetchState,
57
60
  },
58
61
  subscriptionList: {
59
- data: subscriptionListView.subscriptions,
62
+ data: getInvoicingSubscriptionsByIds(state.invoicingSubscriptionState, subscriptionsTab.ids),
60
63
  fetchState: subscriptionListFetchState,
61
64
  },
62
65
  ...reducedFetchState,