@zeniai/client-epic-state 5.2.49 → 5.2.50-beta0ND

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 (87) hide show
  1. package/lib/entity/notificationRegistry/notificationRegistryReducer.d.ts +2 -1
  2. package/lib/entity/notificationRegistry/notificationRegistryReducer.js +20 -9
  3. package/lib/entity/notificationRegistry/notificationRegistrySelector.d.ts +2 -1
  4. package/lib/entity/notificationRegistry/notificationRegistrySelector.js +7 -1
  5. package/lib/entity/notificationRegistry/notificationRegistryState.d.ts +5 -0
  6. package/lib/entity/notificationRegistry/notificationRegistryState.js +7 -1
  7. package/lib/entity/sectionProjectView/sectionProjectViewReducer.js +3 -1
  8. package/lib/epic.js +4 -4
  9. package/lib/esm/entity/notificationRegistry/notificationRegistryReducer.js +20 -9
  10. package/lib/esm/entity/notificationRegistry/notificationRegistrySelector.js +5 -0
  11. package/lib/esm/entity/notificationRegistry/notificationRegistryState.js +5 -0
  12. package/lib/esm/entity/sectionProjectView/sectionProjectViewReducer.js +3 -1
  13. package/lib/esm/epic.js +4 -4
  14. package/lib/esm/index.js +4 -4
  15. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +4 -2
  16. package/lib/esm/view/aiCfoView/epics/fetchRoutineRunsEpic.js +3 -1
  17. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewReducer.js +19 -11
  18. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +4 -1
  19. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/saveInvoicingCatalogItemEpic.js +3 -2
  20. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/seedInvoicingCatalogItemFormDraftEpic.js +26 -7
  21. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewReducer.js +19 -11
  22. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +3 -1
  23. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/saveInvoicingCouponEpic.js +1 -1
  24. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/seedInvoicingCouponFormDraftEpic.js +25 -6
  25. package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewReducer.js +19 -11
  26. package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +4 -2
  27. package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/saveInvoicingCustomerEpic.js +1 -1
  28. package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/seedInvoicingCustomerFormDraftEpic.js +25 -6
  29. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewReducer.js +21 -13
  30. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewSelector.js +3 -2
  31. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/fetchInvoicingSubscriptionFormPageEpic.js +4 -1
  32. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/saveInvoicingSubscriptionEpic.js +1 -1
  33. package/lib/esm/view/invoicing/editInvoicingSubscriptionDetailView/seedInvoicingSubscriptionFormDraftEpic.js +29 -8
  34. package/lib/esm/view/invoicing/invoicingFormDraft.js +65 -0
  35. package/lib/esm/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +20 -7
  36. package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewReducer.js +43 -17
  37. package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewSelector.js +24 -4
  38. package/lib/esm/view/settingsView/epic/fetchNotificationSettingsEpic.js +12 -2
  39. package/lib/index.d.ts +4 -4
  40. package/lib/index.js +21 -14
  41. package/lib/view/aiCfoView/aiCfoViewReducer.js +4 -2
  42. package/lib/view/aiCfoView/aiCfoViewSelector.d.ts +1 -1
  43. package/lib/view/aiCfoView/epics/fetchRoutineRunsEpic.js +3 -1
  44. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewReducer.d.ts +3 -1
  45. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewReducer.js +19 -11
  46. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.d.ts +10 -0
  47. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +4 -1
  48. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewState.d.ts +2 -1
  49. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/saveInvoicingCatalogItemEpic.js +3 -2
  50. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/seedInvoicingCatalogItemFormDraftEpic.d.ts +10 -6
  51. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/seedInvoicingCatalogItemFormDraftEpic.js +25 -6
  52. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewReducer.d.ts +3 -1
  53. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewReducer.js +19 -11
  54. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.d.ts +13 -0
  55. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +3 -1
  56. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewState.d.ts +2 -1
  57. package/lib/view/invoicing/editInvoicingCouponDetailView/saveInvoicingCouponEpic.js +1 -1
  58. package/lib/view/invoicing/editInvoicingCouponDetailView/seedInvoicingCouponFormDraftEpic.d.ts +10 -6
  59. package/lib/view/invoicing/editInvoicingCouponDetailView/seedInvoicingCouponFormDraftEpic.js +24 -5
  60. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewReducer.d.ts +3 -1
  61. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewReducer.js +19 -11
  62. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.d.ts +9 -0
  63. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +4 -2
  64. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewState.d.ts +2 -1
  65. package/lib/view/invoicing/editInvoicingCustomerDetailView/saveInvoicingCustomerEpic.js +1 -1
  66. package/lib/view/invoicing/editInvoicingCustomerDetailView/seedInvoicingCustomerFormDraftEpic.d.ts +10 -6
  67. package/lib/view/invoicing/editInvoicingCustomerDetailView/seedInvoicingCustomerFormDraftEpic.js +24 -5
  68. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewReducer.d.ts +3 -1
  69. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewReducer.js +21 -13
  70. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewSelector.d.ts +9 -0
  71. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewSelector.js +3 -2
  72. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/editInvoicingSubscriptionDetailViewState.d.ts +2 -1
  73. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/fetchInvoicingSubscriptionFormPageEpic.js +4 -1
  74. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/saveInvoicingSubscriptionEpic.js +1 -1
  75. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/seedInvoicingSubscriptionFormDraftEpic.d.ts +10 -6
  76. package/lib/view/invoicing/editInvoicingSubscriptionDetailView/seedInvoicingSubscriptionFormDraftEpic.js +28 -7
  77. package/lib/view/invoicing/invoicingFormDraft.d.ts +54 -0
  78. package/lib/view/invoicing/invoicingFormDraft.js +72 -0
  79. package/lib/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +19 -6
  80. package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.d.ts +8 -5
  81. package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.js +43 -17
  82. package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.d.ts +4 -0
  83. package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.js +30 -5
  84. package/lib/view/notificationPreferencesView/notificationPreferencesViewState.d.ts +4 -3
  85. package/lib/view/settingsView/epic/fetchNotificationSettingsEpic.js +12 -2
  86. package/lib/view/settingsView/settingsViewPayload.d.ts +2 -0
  87. package/package.json +1 -1
@@ -1,16 +1,35 @@
1
1
  import { EMPTY, of } from 'rxjs';
2
2
  import { filter, mergeMap, withLatestFrom } from 'rxjs/operators';
3
3
  import { getInvoicingCouponById } from '../../../entity/invoicing/coupon/couponSelector';
4
- import { seedInvoicingCouponFormDraft, updateInvoicingCouponDetail, } from './editInvoicingCouponDetailViewReducer';
4
+ import { fetchInvoicingCouponDetailPage, seedInvoicingCouponFormDraft, updateInvoicingCouponDetail, } from './editInvoicingCouponDetailViewReducer';
5
5
  import { entityToInvoicingCouponFormLocalData } from './editInvoicingCouponDetailViewState';
6
6
  /**
7
- * Seeds the edit draft from the record the moment the detail fetch lands, so
8
- * the form has its values before it mounts. The reducer ignores the seed when a
9
- * draft already exists, which is what stops a background refetch from
10
- * overwriting edits in progress.
7
+ * Seeds the edit draft from the record, so the form has its values before it
8
+ * mounts. The reducer ignores a seed once the draft holds the record, which is
9
+ * what stops a background refetch from overwriting edits in progress.
10
+ *
11
+ * Opening the page counts as a trigger, not just the detail response: the page
12
+ * fetch is cache-guarded, so re-opening a form for a record already loaded this
13
+ * session issues no request at all — and a draft cleared in between (by a save
14
+ * or a cancel) would have nothing left to seed it.
11
15
  */
12
- export const seedInvoicingCouponFormDraftEpic = (actions$, state$) => actions$.pipe(filter(updateInvoicingCouponDetail.match), withLatestFrom(state$), mergeMap(([action, state]) => {
16
+ export const seedInvoicingCouponFormDraftEpic = (actions$, state$) => actions$.pipe(filter((action) => updateInvoicingCouponDetail.match(action) ||
17
+ fetchInvoicingCouponDetailPage.match(action)), withLatestFrom(state$), mergeMap(([action, state]) => {
13
18
  const { id } = action.payload;
19
+ if (id == null) {
20
+ return EMPTY;
21
+ }
22
+ // The entity alone does not say the whole record is loaded — the list
23
+ // endpoints merge their own list-shaped rows into the same store. Only a
24
+ // completed detail load does, which is also exactly when the page's
25
+ // cache-guarded fetch issues no request and this is the sole seed.
26
+ if (fetchInvoicingCouponDetailPage.match(action) &&
27
+ state.editInvoicingCouponDetailViewState.recordStateById[id]
28
+ ?.fetchState !== 'Completed') {
29
+ return EMPTY;
30
+ }
31
+ // Nothing to seed from on a cold open; the detail response triggers this
32
+ // again once it lands.
14
33
  const coupon = getInvoicingCouponById(state.invoicingCouponState, id);
15
34
  if (coupon == null) {
16
35
  return EMPTY;
@@ -1,5 +1,6 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  import { clearAll } from '../../../rootActions';
3
+ import { applyInvoicingFormDraftEdit, applyInvoicingFormDraftFailure, applyInvoicingFormDraftSeed, } from '../invoicingFormDraft';
3
4
  import { initialInvoicingCustomerFormLocalData } from './invoicingCustomerFormConfig';
4
5
  export const initialState = {
5
6
  emailPreviewFetchState: { fetchState: 'Not-Started', error: undefined },
@@ -123,20 +124,17 @@ const editInvoicingCustomerDetailView = createSlice({
123
124
  fetchState: 'Error',
124
125
  error: action.payload.status,
125
126
  };
127
+ applyInvoicingFormDraftFailure(draft.editDraftById, action.payload.id, initialInvoicingCustomerFormLocalData);
126
128
  },
127
- /**
128
- * Seed the draft from the loaded record, once. Skipped when a draft already
129
- * exists so a late refetch cannot overwrite in-progress edits.
130
- */
129
+ /** Fill the draft from the loaded record — see `applyInvoicingFormDraftSeed`. */
131
130
  seedInvoicingCustomerFormDraft(draft, action) {
132
- if (draft.editDraftById[action.payload.id] == null) {
133
- draft.editDraftById[action.payload.id] = action.payload.localData;
134
- }
131
+ const { id, localData } = action.payload;
132
+ applyInvoicingFormDraftSeed(draft.editDraftById, id, localData);
135
133
  },
136
134
  updateInvoicingCustomerFormDraft(draft, action) {
137
135
  const { id, localData } = action.payload;
138
136
  if (id != null) {
139
- draft.editDraftById[id] = localData;
137
+ applyInvoicingFormDraftEdit(draft.editDraftById, id, localData);
140
138
  }
141
139
  else {
142
140
  draft.newCustomerDraft = localData;
@@ -161,12 +159,22 @@ const editInvoicingCustomerDetailView = createSlice({
161
159
  draft.fetchState = 'Error';
162
160
  draft.error = action.payload;
163
161
  },
164
- resetEditInvoicingCustomerDetailView(draft) {
162
+ /**
163
+ * Discard a form's draft — the customer's own when `id` is given,
164
+ * the create draft otherwise. Scoped to the one form on purpose: another
165
+ * customer's half-finished edit is not this form's to throw away.
166
+ * Clearing every draft at once is what `clearAll` is for.
167
+ */
168
+ resetEditInvoicingCustomerDetailView(draft, action) {
165
169
  draft.fetchState = 'Not-Started';
166
170
  draft.error = undefined;
167
171
  draft.savedCustomerId = undefined;
168
- draft.editDraftById = {};
169
- draft.newCustomerDraft = { ...initialInvoicingCustomerFormLocalData };
172
+ const id = action.payload?.id;
173
+ if (id == null) {
174
+ draft.newCustomerDraft = { ...initialInvoicingCustomerFormLocalData };
175
+ return;
176
+ }
177
+ delete draft.editDraftById[id];
170
178
  },
171
179
  // Trigger only — handled by initializeInvoicingCustomerAddressEpic.
172
180
  initializeInvoicingCustomerAddress: {
@@ -5,6 +5,7 @@ import { invoicingCustomerPaymentMethods } from '../../../entity/invoicing/invoi
5
5
  import { getFormattedAddress } from '../../addressView/addressViewSelector';
6
6
  import { getInvoicingPaymentLinkSendState, getInvoicingPaymentMethodSaveState, getInvoicingPlaidLinkToken, getInvoicingPlaidLinkTokenFetchState, getInvoicingSentPaymentLinkEmail, getInvoicingSentPaymentLinkMagicUrl, getInvoicingSetupIntent, getInvoicingSetupIntentFetchState, } from '../customerPaymentMethod/customerPaymentMethodSelector';
7
7
  import { getInvoicingConfigView, invoicingFieldHelpForGroup, } from '../invoicingConfigView/invoicingConfigViewSelector';
8
+ import { getInvoicingFormDraftLocalData, isInvoicingFormDraftPending, } from '../invoicingFormDraft';
8
9
  import { isStripeConnectedFromSettings } from '../settingsView/invoicingSettingsHelpers';
9
10
  import { getInvoicingSettings } from '../settingsView/settingsViewSelector';
10
11
  import { INVOICING_CUSTOMER_BILLING_ADDRESS_TYPE, initialInvoicingCustomerFormLocalData, } from './invoicingCustomerFormConfig';
@@ -59,8 +60,7 @@ export const getInvoicingCustomerFormView = (state, invoicingCustomerID) => {
59
60
  // `seedInvoicingCustomerFormDraftEpic` when the detail load lands. Until then
60
61
  // the page is showing its skeleton.
61
62
  const localData = invoicingCustomerID != null
62
- ? (editDraftById[invoicingCustomerID] ??
63
- initialInvoicingCustomerFormLocalData)
63
+ ? getInvoicingFormDraftLocalData(editDraftById, invoicingCustomerID, initialInvoicingCustomerFormLocalData)
64
64
  : newCustomerDraft;
65
65
  // Billing address is captured via the shared Address screen and seeded there
66
66
  // (from the address entity) on detail load, so the formatted label comes from
@@ -78,6 +78,7 @@ export const getInvoicingCustomerFormView = (state, invoicingCustomerID) => {
78
78
  ? { error: undefined, fetchState: 'Completed' }
79
79
  : (state.editInvoicingCustomerDetailViewState.recordStateById[invoicingCustomerID] ?? { error: undefined, fetchState: 'Not-Started' });
80
80
  const { emailPreview, emailPreviewCustomerId, emailPreviewFetchState } = state.editInvoicingCustomerDetailViewState;
81
+ const isFormLoading = isInvoicingFormDraftPending(editDraftById, invoicingCustomerID);
81
82
  return {
82
83
  // A preview cached for another customer is not this screen's.
83
84
  emailPreview: emailPreviewCustomerId === invoicingCustomerID ? emailPreview : undefined,
@@ -92,6 +93,7 @@ export const getInvoicingCustomerFormView = (state, invoicingCustomerID) => {
92
93
  promotionalCreditSubmitState: state.editInvoicingCustomerDetailViewState.promotionalCreditSubmitState,
93
94
  record: getInvoicingCustomerRecord(state, invoicingCustomerID),
94
95
  fieldHelp: invoicingFieldHelpForGroup(state, 'customer'),
96
+ isFormLoading,
95
97
  customerTypeOptions,
96
98
  localData,
97
99
  mode,
@@ -16,7 +16,7 @@ export const saveInvoicingCustomerEpic = (actions$, state$, zeniAPI) => actions$
16
16
  const rootState = state$.value;
17
17
  const viewState = rootState.editInvoicingCustomerDetailViewState;
18
18
  const localData = (id != null
19
- ? viewState.editDraftById[id]
19
+ ? viewState.editDraftById[id]?.localData
20
20
  : viewState.newCustomerDraft) ?? viewState.newCustomerDraft;
21
21
  // Billing address captured via the shared Address screen is sent as an
22
22
  // object in the payload; the backend creates it and returns its id
@@ -1,16 +1,35 @@
1
1
  import { EMPTY, of } from 'rxjs';
2
2
  import { filter, mergeMap, withLatestFrom } from 'rxjs/operators';
3
3
  import { getInvoicingCustomerById } from '../../../entity/invoicing/invoicingCustomer/invoicingCustomerSelector';
4
- import { seedInvoicingCustomerFormDraft, updateInvoicingCustomerDetail, } from './editInvoicingCustomerDetailViewReducer';
4
+ import { fetchInvoicingCustomerDetailPage, seedInvoicingCustomerFormDraft, updateInvoicingCustomerDetail, } from './editInvoicingCustomerDetailViewReducer';
5
5
  import { entityToInvoicingCustomerFormLocalData } from './invoicingCustomerFormConfig';
6
6
  /**
7
- * Seeds the edit draft from the record the moment the detail fetch lands, so
8
- * the form has its values before it mounts. The reducer ignores the seed when a
9
- * draft already exists, which is what stops a background refetch from
10
- * overwriting edits in progress.
7
+ * Seeds the edit draft from the record, so the form has its values before it
8
+ * mounts. The reducer ignores a seed once the draft holds the record, which is
9
+ * what stops a background refetch from overwriting edits in progress.
10
+ *
11
+ * Opening the page counts as a trigger, not just the detail response: the page
12
+ * fetch is cache-guarded, so re-opening for a record already loaded this
13
+ * session issues no request at all — and a draft cleared in between (by a save
14
+ * or a cancel) would have nothing left to seed it.
11
15
  */
12
- export const seedInvoicingCustomerFormDraftEpic = (actions$, state$) => actions$.pipe(filter(updateInvoicingCustomerDetail.match), withLatestFrom(state$), mergeMap(([action, state]) => {
16
+ export const seedInvoicingCustomerFormDraftEpic = (actions$, state$) => actions$.pipe(filter((action) => updateInvoicingCustomerDetail.match(action) ||
17
+ fetchInvoicingCustomerDetailPage.match(action)), withLatestFrom(state$), mergeMap(([action, state]) => {
13
18
  const { id } = action.payload;
19
+ if (id == null) {
20
+ return EMPTY;
21
+ }
22
+ // The entity alone does not say the whole record is loaded — the list
23
+ // endpoints merge their own list-shaped rows into the same store. Only a
24
+ // completed detail load does, which is also exactly when the page's
25
+ // cache-guarded fetch issues no request and this is the sole seed.
26
+ if (fetchInvoicingCustomerDetailPage.match(action) &&
27
+ state.editInvoicingCustomerDetailViewState.recordStateById[id]
28
+ ?.fetchState !== 'Completed') {
29
+ return EMPTY;
30
+ }
31
+ // Nothing to seed from on a cold open; the detail response triggers this
32
+ // again once it lands.
14
33
  const customer = getInvoicingCustomerById(state.invoicingCustomerState, id);
15
34
  if (customer == null) {
16
35
  return EMPTY;
@@ -1,5 +1,6 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  import { clearAll } from '../../../rootActions';
3
+ import { applyInvoicingFormDraftEdit, applyInvoicingFormDraftFailure, applyInvoicingFormDraftSeed, } from '../invoicingFormDraft';
3
4
  import { initialInvoicingSubscriptionFormLocalData } from './invoicingSubscriptionFormConfig';
4
5
  export const initialState = {
5
6
  fetchState: 'Not-Started',
@@ -65,15 +66,12 @@ const editInvoicingSubscriptionDetailView = createSlice({
65
66
  fetchState: 'Error',
66
67
  error: action.payload.error,
67
68
  };
69
+ applyInvoicingFormDraftFailure(draft.editDraftById, action.payload.id, initialInvoicingSubscriptionFormLocalData);
68
70
  },
69
- /**
70
- * Seed the draft from the loaded record, once. Skipped when a draft already
71
- * exists so a late refetch cannot overwrite in-progress edits.
72
- */
71
+ /** Fill the draft from the loaded record — see `applyInvoicingFormDraftSeed`. */
73
72
  seedInvoicingSubscriptionFormDraft(draft, action) {
74
- if (draft.editDraftById[action.payload.id] == null) {
75
- draft.editDraftById[action.payload.id] = action.payload.localData;
76
- }
73
+ const { id, localData } = action.payload;
74
+ applyInvoicingFormDraftSeed(draft.editDraftById, id, localData);
77
75
  },
78
76
  fetchInvoicingSubscriptionFormPage: {
79
77
  reducer() {
@@ -87,7 +85,7 @@ const editInvoicingSubscriptionDetailView = createSlice({
87
85
  updateInvoicingSubscriptionFormDraft(draft, action) {
88
86
  const { id, localData } = action.payload;
89
87
  if (id != null) {
90
- draft.editDraftById[id] = localData;
88
+ applyInvoicingFormDraftEdit(draft.editDraftById, id, localData);
91
89
  }
92
90
  else {
93
91
  draft.newSubscriptionDraft = localData;
@@ -112,14 +110,24 @@ const editInvoicingSubscriptionDetailView = createSlice({
112
110
  draft.fetchState = 'Error';
113
111
  draft.error = action.payload;
114
112
  },
115
- resetEditInvoicingSubscriptionDetailView(draft) {
113
+ /**
114
+ * Discard a form's draft — the subscription's own when `id` is given,
115
+ * the create draft otherwise. Scoped to the one form on purpose: another
116
+ * subscription's half-finished edit is not this form's to throw away.
117
+ * Clearing every draft at once is what `clearAll` is for.
118
+ */
119
+ resetEditInvoicingSubscriptionDetailView(draft, action) {
116
120
  draft.fetchState = 'Not-Started';
117
121
  draft.error = undefined;
118
122
  draft.savedSubscriptionId = undefined;
119
- draft.editDraftById = {};
120
- draft.newSubscriptionDraft = {
121
- ...initialInvoicingSubscriptionFormLocalData,
122
- };
123
+ const id = action.payload?.id;
124
+ if (id == null) {
125
+ draft.newSubscriptionDraft = {
126
+ ...initialInvoicingSubscriptionFormLocalData,
127
+ };
128
+ return;
129
+ }
130
+ delete draft.editDraftById[id];
123
131
  },
124
132
  // Trigger only — handled by initializeInvoicingSubscriptionAddressEpic.
125
133
  initializeInvoicingSubscriptionAddress: {
@@ -5,6 +5,7 @@ import { getInvoicingCouponListItems } from '../couponView/couponViewSelector';
5
5
  import { getInvoicingCatalogListView, getInvoicingCatalogPrices, } from '../invoicingCatalogListView/invoicingCatalogListViewSelector';
6
6
  import { getInvoicingConfigView, invoicingFieldHelpForGroup, } from '../invoicingConfigView/invoicingConfigViewSelector';
7
7
  import { getInvoicingCustomerListView } from '../invoicingCustomerListView/invoicingCustomerListViewSelector';
8
+ import { getInvoicingFormDraftLocalData, isInvoicingFormDraftPending, } from '../invoicingFormDraft';
8
9
  import { initialInvoicingSubscriptionFormLocalData } from './invoicingSubscriptionFormConfig';
9
10
  import { INVOICING_SUBSCRIPTION_SHIPPING_ADDRESS_TYPE } from './invoicingSubscriptionFormConfig';
10
11
  export const getEditInvoicingSubscriptionDetailViewState = (state, invoicingSubscriptionID) => {
@@ -33,8 +34,7 @@ export const getInvoicingSubscriptionFormView = (state, invoicingSubscriptionID)
33
34
  // `seedInvoicingSubscriptionFormDraftEpic` when the detail load lands. Until
34
35
  // then the page is showing its skeleton.
35
36
  const localData = invoicingSubscriptionID != null
36
- ? (editDraftById[invoicingSubscriptionID] ??
37
- initialInvoicingSubscriptionFormLocalData)
37
+ ? getInvoicingFormDraftLocalData(editDraftById, invoicingSubscriptionID, initialInvoicingSubscriptionFormLocalData)
38
38
  : newSubscriptionDraft;
39
39
  // Shipping address is captured via the shared Address screen and seeded there
40
40
  // (looked up in the shared address store by `shippingAddressId`) on detail
@@ -65,6 +65,7 @@ export const getInvoicingSubscriptionFormView = (state, invoicingSubscriptionID)
65
65
  subscriptionLifecycleActionOptions: configState.subscriptionLifecycleActionOptions,
66
66
  subscriptionMetadataEditAllowedFromStatuses: configState.subscriptionMetadataEditAllowedFromStatuses,
67
67
  recordState,
68
+ isFormLoading: isInvoicingFormDraftPending(editDraftById, invoicingSubscriptionID),
68
69
  fieldHelp: invoicingFieldHelpForGroup(state, 'subscription'),
69
70
  coupons: getInvoicingCouponListItems(state),
70
71
  customers: getInvoicingCustomerListView(state).customers,
@@ -16,8 +16,11 @@ export const fetchInvoicingSubscriptionFormPageEpic = (actions$) => actions$.pip
16
16
  const { subscriptionId } = action.payload;
17
17
  const pageActions = [
18
18
  fetchInvoicingCustomerList({ filters: {}, keepExistingListItems: false }),
19
+ // Plans only: the form's plan picker is the one consumer, and an
20
+ // unfiltered list would replace the store's plans with every catalog
21
+ // item.
19
22
  fetchInvoicingCatalogPlanList({
20
- filters: {},
23
+ filters: { type: 'plan' },
21
24
  keepExistingListItems: false,
22
25
  }),
23
26
  fetchInvoicingCouponList({ filters: {}, keepExistingListItems: false }),
@@ -17,7 +17,7 @@ export const saveInvoicingSubscriptionEpic = (actions$, state$, zeniAPI) => acti
17
17
  const rootState = state$.value;
18
18
  const viewState = rootState.editInvoicingSubscriptionDetailViewState;
19
19
  const localData = (id != null
20
- ? viewState.editDraftById[id]
20
+ ? viewState.editDraftById[id]?.localData
21
21
  : viewState.newSubscriptionDraft) ?? viewState.newSubscriptionDraft;
22
22
  // Shipping address captured via the shared Address screen is sent as an
23
23
  // object in the payload (same pattern as the customer save flow).
@@ -1,17 +1,38 @@
1
1
  import { EMPTY, of } from 'rxjs';
2
2
  import { filter, mergeMap, withLatestFrom } from 'rxjs/operators';
3
- import { seedInvoicingSubscriptionFormDraft, updateInvoicingSubscriptionDetail, } from './editInvoicingSubscriptionDetailViewReducer';
3
+ import { fetchInvoicingSubscriptionFormPage, seedInvoicingSubscriptionFormDraft, updateInvoicingSubscriptionDetail, } from './editInvoicingSubscriptionDetailViewReducer';
4
4
  import { getInvoicingSubscriptionById } from './invoicingSubscriptionDetailHelper';
5
5
  import { entityToInvoicingSubscriptionFormLocalData } from './invoicingSubscriptionFormConfig';
6
6
  /**
7
- * Seeds the edit draft from the record the moment the detail fetch lands, so
8
- * the form has its values before it mounts. The reducer ignores the seed when a
9
- * draft already exists, which is what stops a background refetch from
10
- * overwriting edits in progress.
7
+ * Seeds the edit draft from the record, so the form has its values before it
8
+ * mounts. The reducer ignores a seed once the draft holds the record, which is
9
+ * what stops a background refetch from overwriting edits in progress.
10
+ *
11
+ * Opening the page counts as a trigger, not just the detail response: the page
12
+ * fetch is cache-guarded, so re-opening a form for a record already loaded this
13
+ * session issues no request at all — and a draft cleared in between (by a save
14
+ * or a cancel) would have nothing left to seed it.
11
15
  */
12
- export const seedInvoicingSubscriptionFormDraftEpic = (actions$, state$) => actions$.pipe(filter(updateInvoicingSubscriptionDetail.match), withLatestFrom(state$), mergeMap(([action, state]) => {
13
- const { id } = action.payload;
14
- const subscription = getInvoicingSubscriptionById(state, id);
16
+ export const seedInvoicingSubscriptionFormDraftEpic = (actions$, state$) => actions$.pipe(filter((action) => updateInvoicingSubscriptionDetail.match(action) ||
17
+ fetchInvoicingSubscriptionFormPage.match(action)), withLatestFrom(state$), mergeMap(([action, state]) => {
18
+ const id = updateInvoicingSubscriptionDetail.match(action)
19
+ ? action.payload.id
20
+ : action.payload.subscriptionId;
21
+ if (id == null) {
22
+ return EMPTY;
23
+ }
24
+ // The entity alone does not say the whole record is loaded — the list
25
+ // endpoints merge their own list-shaped rows into the same store. Only a
26
+ // completed detail load does, which is also exactly when the page's
27
+ // cache-guarded fetch issues no request and this is the sole seed.
28
+ if (fetchInvoicingSubscriptionFormPage.match(action) &&
29
+ state.editInvoicingSubscriptionDetailViewState.recordStateById[id]
30
+ ?.fetchState !== 'Completed') {
31
+ return EMPTY;
32
+ }
33
+ // Nothing to seed from on a cold open; the detail response triggers this
34
+ // again once it lands.
35
+ const subscription = id != null ? getInvoicingSubscriptionById(state, id) : undefined;
15
36
  if (subscription == null) {
16
37
  return EMPTY;
17
38
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The form's own write. Keeps the status: an edit on top of a seeded draft is
3
+ * still the record's draft, and an edit made before the record arrived must not
4
+ * pass for one.
5
+ */
6
+ export function applyInvoicingFormDraftEdit(draftById, id, localData) {
7
+ draftById[id] = {
8
+ fetchState: draftById[id]?.fetchState ?? 'Not-Started',
9
+ localData,
10
+ };
11
+ }
12
+ /**
13
+ * Fill the draft from the loaded record, once. A second seed is ignored so a
14
+ * late refetch cannot overwrite edits in progress — but a draft the form wrote
15
+ * before the record landed is replaced, since it was never the record's.
16
+ */
17
+ export function applyInvoicingFormDraftSeed(draftById, id, localData) {
18
+ if (draftById[id]?.fetchState === 'Completed') {
19
+ return;
20
+ }
21
+ draftById[id] = { fetchState: 'Completed', localData };
22
+ }
23
+ /**
24
+ * Record that the fetch failed, so the form renders instead of waiting behind a
25
+ * skeleton that will never resolve.
26
+ *
27
+ * Mirrors the seed: a draft holding the record is left alone, since a
28
+ * background refetch failing is no reason to discard it, and anything else is
29
+ * marked failed. A draft the form wrote before the record landed keeps its
30
+ * values — there is no reason to throw away what the user typed — but stops
31
+ * counting as still loading, and a later successful fetch can still seed it.
32
+ */
33
+ export function applyInvoicingFormDraftFailure(draftById, id, blankLocalData) {
34
+ const existing = draftById[id];
35
+ if (existing == null) {
36
+ draftById[id] = { fetchState: 'Error', localData: blankLocalData };
37
+ return;
38
+ }
39
+ if (existing.fetchState === 'Completed') {
40
+ return;
41
+ }
42
+ existing.fetchState = 'Error';
43
+ }
44
+ /** The draft's values, or the blank form when nothing is drafted yet. */
45
+ export function getInvoicingFormDraftLocalData(draftById, id, blankLocalData) {
46
+ if (id == null) {
47
+ return blankLocalData;
48
+ }
49
+ return draftById[id]?.localData ?? blankLocalData;
50
+ }
51
+ /**
52
+ * Whether the form should still be showing its skeleton — the record has
53
+ * neither reached the draft nor failed trying.
54
+ *
55
+ * Settles once and stays settled, which is what keeps a later refetch from
56
+ * swapping a form the user is typing into back for a skeleton. Create mode
57
+ * (no id) has no record to wait for.
58
+ */
59
+ export function isInvoicingFormDraftPending(draftById, id) {
60
+ if (id == null) {
61
+ return false;
62
+ }
63
+ const fetchState = draftById[id]?.fetchState;
64
+ return fetchState !== 'Completed' && fetchState !== 'Error';
65
+ }
@@ -1,11 +1,12 @@
1
1
  import { EMPTY, of } from 'rxjs';
2
2
  import { catchError, filter, finalize, mergeMap, switchMap, takeUntil, withLatestFrom, } from 'rxjs/operators';
3
+ import { NOTIFICATION_DOMAINS } from '../../../entity/notificationRegistry/notificationRegistryState';
3
4
  import { openSnackbar } from '../../../entity/snackbar/snackbarReducer';
4
5
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../responsePayload';
5
6
  import { notificationPreferencesUrl } from '../notificationPreferencesEndpoint';
6
7
  import { mapPreferencesToPayload, } from '../notificationPreferencesViewPayload';
7
8
  import { clearAllNotificationPreferencesView, clearNotificationPreferencesLocalOverrides, saveNotificationPreferences, saveNotificationPreferencesFailure, saveNotificationPreferencesSuccess, } from '../notificationPreferencesViewReducer';
8
- import { getNotificationLocalOverrides } from '../notificationPreferencesViewSelector';
9
+ import { getNotificationLocalOverridesForDomain } from '../notificationPreferencesViewSelector';
9
10
  export const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter((action) => saveNotificationPreferences.match(action)), withLatestFrom(state$),
10
11
  // `switchMap` unsubscribes from any in-flight PUT observable when a newer
11
12
  // Save fires. The prior HTTP request has already left the
@@ -13,9 +14,19 @@ export const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => ac
13
14
  // ignored, so stale success/failure actions cannot clobber the newer
14
15
  // batch's state. Server-side ordering is arrival-order LWW.
15
16
  switchMap(([, state]) => {
16
- const overrides = getNotificationLocalOverrides(state);
17
- const preferencesPatch = mapPreferencesToPayload(overrides);
18
- if (Object.keys(preferencesPatch).length === 0) {
17
+ // A clean domain is omitted entirely: an empty patch is still a write against it.
18
+ const overridesByDomain = {};
19
+ const body = {};
20
+ NOTIFICATION_DOMAINS.forEach((domain) => {
21
+ const overrides = getNotificationLocalOverridesForDomain(state, domain);
22
+ const preferencesPatch = mapPreferencesToPayload(overrides);
23
+ if (Object.keys(preferencesPatch).length === 0) {
24
+ return;
25
+ }
26
+ overridesByDomain[domain] = overrides;
27
+ body[`${domain}_preferences`] = preferencesPatch;
28
+ });
29
+ if (Object.keys(body).length === 0) {
19
30
  // No diff → do not dispatch a synthetic success (that would wipe
20
31
  // stored preferences via the reducer). Simply no-op.
21
32
  return EMPTY;
@@ -28,7 +39,6 @@ switchMap(([, state]) => {
28
39
  // in-flight save's response (Success/Failure never dispatched, so
29
40
  // the counter never drained and future saves got their merge dropped).
30
41
  const cancelEpochAtDispatch = state.notificationPreferencesViewState.cancelEpoch;
31
- const body = { task_checklist_preferences: preferencesPatch };
32
42
  // Suppress the error snackbar when the response lands AFTER the user
33
43
  // pressed Cancel — the Failure reducer already swallows the state
34
44
  // update, so the toast would be a stale error for a save the user
@@ -57,8 +67,11 @@ switchMap(([, state]) => {
57
67
  if (isSuccessResponse(response)) {
58
68
  return of(saveNotificationPreferencesSuccess({
59
69
  cancelEpochAtDispatch,
60
- savedOverrides: overrides,
61
- serverEcho: response.data?.task_checklist_preferences,
70
+ savedOverridesByDomain: overridesByDomain,
71
+ serverEchoByDomain: {
72
+ task_checklist: response.data?.task_checklist_preferences,
73
+ commenting: response.data?.commenting_preferences,
74
+ },
62
75
  }));
63
76
  }
64
77
  const actions = [
@@ -1,14 +1,25 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
+ import { DEFAULT_NOTIFICATION_DOMAIN, } from '../../entity/notificationRegistry/notificationRegistryState';
2
3
  import { clearAll } from '../../rootActions';
3
4
  import { mapPayloadToPreferences, } from './notificationPreferencesViewPayload';
4
5
  import { emptyNotificationPreferences, } from './notificationPreferencesViewState';
5
6
  export const initialState = {
6
7
  cancelEpoch: 0,
7
- localOverrides: emptyNotificationPreferences(),
8
- preferences: emptyNotificationPreferences(),
8
+ localOverridesByDomain: {},
9
+ preferencesByDomain: {},
9
10
  saveEpoch: 0,
10
11
  savePreferencesState: { fetchState: 'Not-Started' },
11
12
  };
13
+ // Lazy: a gate-off tenant never receives that domain's key at all.
14
+ const overridesFor = (byDomain, domain) => {
15
+ const existing = byDomain[domain];
16
+ if (existing != null) {
17
+ return existing;
18
+ }
19
+ const created = emptyNotificationPreferences();
20
+ byDomain[domain] = created;
21
+ return created;
22
+ };
12
23
  const mergePreferences = (base, patch) => {
13
24
  const eventEnabledByChannel = {
14
25
  ...base.eventEnabledByChannel,
@@ -146,7 +157,8 @@ const notificationPreferencesView = createSlice({
146
157
  // Success/Failure never dispatches, subsequent legitimate saves
147
158
  // still work because their captured epoch matches the current one.
148
159
  draft.cancelEpoch += 1;
149
- draft.localOverrides = emptyNotificationPreferences();
160
+ // One shared Save/Cancel footer, so Cancel discards every domain.
161
+ draft.localOverridesByDomain = {};
150
162
  // Also drop `savePreferencesState` back to Not-Started so any
151
163
  // "Saving…" affordance bound to `getNotificationPreferencesSaveState`
152
164
  // clears on Cancel. Without this, a Cancel-during-save whose stale
@@ -163,9 +175,6 @@ const notificationPreferencesView = createSlice({
163
175
  // them; the save-success path is the only clearer.
164
176
  // A `null`/`undefined` payload isn't a snapshot at all (e.g. the
165
177
  // envelope key was omitted); no-op instead of wiping preferences.
166
- if (action.payload.preferences == null) {
167
- return;
168
- }
169
178
  // Stale-GET guard (Cursor Bugbot 3632510237): if the fetch epic
170
179
  // captured a `saveEpoch` lower than the current one, a save has
171
180
  // succeeded since this GET dispatched — the response is carrying
@@ -175,7 +184,13 @@ const notificationPreferencesView = createSlice({
175
184
  if (action.payload.saveEpochAtDispatch < draft.saveEpoch) {
176
185
  return;
177
186
  }
178
- draft.preferences = mapPayloadToPreferences(action.payload.preferences);
187
+ Object.entries(action.payload.preferencesByDomain).forEach(([domain, payload]) => {
188
+ if (payload == null) {
189
+ return;
190
+ }
191
+ draft.preferencesByDomain[domain] =
192
+ mapPayloadToPreferences(payload);
193
+ });
179
194
  },
180
195
  saveNotificationPreferences(draft) {
181
196
  draft.savePreferencesState = { fetchState: 'In-Progress' };
@@ -234,33 +249,44 @@ const notificationPreferencesView = createSlice({
234
249
  // skip the `preferences` replace. Cursor Bugbot 3632510237.
235
250
  draft.saveEpoch += 1;
236
251
  draft.savePreferencesState = { fetchState: 'Completed' };
237
- const patch = action.payload.serverEcho == null
238
- ? action.payload.savedOverrides
239
- : mapPayloadToPreferences(action.payload.serverEcho);
240
- draft.preferences = mergePreferences(draft.preferences, patch);
241
- draft.localOverrides = subtractSavedOverrides(draft.localOverrides, action.payload.savedOverrides);
252
+ Object.entries(action.payload.savedOverridesByDomain).forEach(([rawDomain, savedOverrides]) => {
253
+ if (savedOverrides == null) {
254
+ return;
255
+ }
256
+ const domain = rawDomain;
257
+ const serverEcho = action.payload.serverEchoByDomain[domain];
258
+ const patch = serverEcho == null
259
+ ? savedOverrides
260
+ : mapPayloadToPreferences(serverEcho);
261
+ draft.preferencesByDomain[domain] = mergePreferences(draft.preferencesByDomain[domain] ?? emptyNotificationPreferences(), patch);
262
+ draft.localOverridesByDomain[domain] = subtractSavedOverrides(draft.localOverridesByDomain[domain] ??
263
+ emptyNotificationPreferences(), savedOverrides);
264
+ });
242
265
  },
243
266
  setGroupChannelMaster(draft, action) {
244
267
  // Mirror of `toggleEventChannel` but scoped to the group-channel master
245
268
  // held under `groupChannelEnabledByGroupId[groupId][channel]`.
246
269
  const { channel, enabled, groupId } = action.payload;
270
+ const overrides = overridesFor(draft.localOverridesByDomain, action.payload.domain ?? DEFAULT_NOTIFICATION_DOMAIN);
247
271
  const groupMap = {
248
- ...(draft.localOverrides.groupChannelEnabledByGroupId[groupId] ?? {}),
272
+ ...(overrides.groupChannelEnabledByGroupId[groupId] ?? {}),
249
273
  };
250
274
  groupMap[channel] = enabled;
251
- draft.localOverrides.groupChannelEnabledByGroupId[groupId] = groupMap;
275
+ overrides.groupChannelEnabledByGroupId[groupId] = groupMap;
252
276
  },
253
277
  setGroupFrequency(draft, action) {
254
- draft.localOverrides.groupFrequencyByGroupId[action.payload.groupId] =
278
+ const overrides = overridesFor(draft.localOverridesByDomain, action.payload.domain ?? DEFAULT_NOTIFICATION_DOMAIN);
279
+ overrides.groupFrequencyByGroupId[action.payload.groupId] =
255
280
  action.payload.frequency;
256
281
  },
257
282
  toggleEventChannel(draft, action) {
258
283
  const { channel, enabled, eventId } = action.payload;
284
+ const overrides = overridesFor(draft.localOverridesByDomain, action.payload.domain ?? DEFAULT_NOTIFICATION_DOMAIN);
259
285
  const eventMap = {
260
- ...(draft.localOverrides.eventEnabledByChannel[eventId] ?? {}),
286
+ ...(overrides.eventEnabledByChannel[eventId] ?? {}),
261
287
  };
262
288
  eventMap[channel] = enabled;
263
- draft.localOverrides.eventEnabledByChannel[eventId] = eventMap;
289
+ overrides.eventEnabledByChannel[eventId] = eventMap;
264
290
  },
265
291
  },
266
292
  extraReducers: (builder) => {