@capillarytech/creatives-library 9.0.59-alpha.2 → 9.0.60

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 (50) hide show
  1. package/constants/unified.js +2 -3
  2. package/package.json +1 -1
  3. package/services/api.js +1 -3
  4. package/utils/common.js +0 -8
  5. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
  6. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  7. package/v2Components/FormBuilder/Functional/constants.js +8 -46
  8. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  9. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  10. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  11. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  12. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  13. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  14. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  15. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  16. package/v2Components/FormBuilder/_formBuilder.scss +0 -11
  17. package/v2Components/FormBuilder/index.js +13 -20
  18. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
  19. package/v2Containers/CommunicationFlow/CommunicationFlow.js +13 -32
  20. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -36
  21. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -0
  22. package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
  23. package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
  24. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +1 -5
  25. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +28 -0
  26. package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +29 -0
  27. package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +6 -13
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  33. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  34. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  35. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  36. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  37. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  38. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  39. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  40. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  41. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  42. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  43. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  44. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  45. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  46. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  47. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  48. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  49. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  50. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -6,11 +6,10 @@
6
6
  * monolith, frozen) and the new functional implementation (./Functional)
7
7
  * based on the per-channel, per-org feature flag.
8
8
  *
9
- * Phase 1 migrated SMS behind `ENABLE_NEW_FORMBUILDER_SMS`; Phase 2 migrates
10
- * MOBILEPUSH behind `ENABLE_NEW_FORMBUILDER_MPUSH`. The new path is taken ONLY
11
- * when (a) the org has that channel's flag AND (b) this FormBuilder instance is
12
- * rendering that channel. Every other channel — and every org without the
13
- * flag — keeps using Classic with byte-identical behavior. The flags default
9
+ * Phase 1 migrates SMS only, behind `ENABLE_NEW_FORMBUILDER_SMS`. The new path
10
+ * is taken ONLY when (a) the org has that flag AND (b) this FormBuilder instance
11
+ * is rendering the SMS channel. Every other channel — and every org without the
12
+ * flag — keeps using Classic with byte-identical behavior. The flag defaults
14
13
  * OFF, so until rollout this gate always renders Classic.
15
14
  *
16
15
  * This component is a pure pass-through: no defaultProps, no prop mapping, no
@@ -22,30 +21,24 @@
22
21
  import React, { useState } from 'react';
23
22
  import ClassicFormBuilder from './Classic';
24
23
  import FunctionalFormBuilder from './Functional';
25
- import {
26
- hasNewFormBuilderEnabledForSms,
27
- hasNewFormBuilderEnabledForMpush,
28
- } from '../../utils/common';
29
- import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
24
+ import { hasNewFormBuilderEnabledForSms } from '../../utils/common';
25
+ import { SMS } from '../../v2Containers/CreativesContainer/constants';
30
26
 
31
27
  const FormBuilder = (props) => {
32
- // One-time org-flag read (API-provisioned via Auth.hasFeatureAccess); the try/catch
33
- // guards early bootstrap where window.capAuth is not ready — any failure routes to Classic.
34
- const [newBuilderFlags] = useState(() => {
28
+ // The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
29
+ const [isSmsNewBuilderOrg] = useState(() => {
35
30
  try {
36
- return {
37
- [SMS]: Boolean(hasNewFormBuilderEnabledForSms()),
38
- [MOBILE_PUSH]: Boolean(hasNewFormBuilderEnabledForMpush()),
39
- };
31
+ console.log('### ENABLE_NEW_FORMBUILDER_SMS enabled for org:', hasNewFormBuilderEnabledForSms());
32
+ return Boolean(hasNewFormBuilderEnabledForSms());
40
33
  } catch (e) {
41
- return {};
34
+ return false;
42
35
  }
43
36
  });
44
37
 
45
- // Derived every render: the schema (and therefore the channel) arrives asynchronously.
46
38
  const channel = (props?.channel || props.schema?.channel)?.toUpperCase();
47
39
 
48
- return newBuilderFlags[channel] ? (
40
+ const useNewFormBuilder = isSmsNewBuilderOrg && channel === SMS;
41
+ return useNewFormBuilder ? (
49
42
  <FunctionalFormBuilder {...props} />
50
43
  ) : (
51
44
  <ClassicFormBuilder {...props} />
@@ -5,10 +5,10 @@
5
5
  * implementations are mocked with trivial stubs so this stays a fast unit test
6
6
  * of the gate, independent of the 4,400-line monolith.
7
7
  *
8
- * Contract pinned here (per-channel flag map — SMS Phase 1, MOBILEPUSH Phase 2):
9
- * - flags OFF -> Classic, for every channel (the default until rollout)
10
- * - a channel's flag ON + that channel (via `channel` prop OR `schema.channel`) -> Functional
11
- * - a channel's flag ON + any OTHER channel -> Classic (flags are independent)
8
+ * Contract pinned here:
9
+ * - flag OFF -> Classic, for every channel (the default until rollout)
10
+ * - flag ON + SMS channel (via `channel` prop OR `schema.channel`) -> Functional
11
+ * - flag ON + non-SMS channel -> Classic (other channels untouched)
12
12
  * - flag ON + channel unknown (async schema not yet arrived) -> Classic
13
13
  * - flag read throws (bootstrap/test race) -> Classic, no crash
14
14
  */
@@ -25,26 +25,20 @@ jest.mock('../Functional', () => (props) => (
25
25
  <div data-testid="impl-functional">functional:{(props.schema && props.schema.channel) || props.channel || ''}</div>
26
26
  ));
27
27
 
28
- // Mock only the flag readers the gate uses; keep the rest of common.js intact.
28
+ // Mock only the one flag reader the gate uses; keep the rest of common.js intact.
29
29
  jest.mock('../../../utils/common', () => ({
30
30
  ...jest.requireActual('../../../utils/common'),
31
31
  hasNewFormBuilderEnabledForSms: jest.fn(),
32
- hasNewFormBuilderEnabledForMpush: jest.fn(),
33
32
  }));
34
33
 
35
34
  // eslint-disable-next-line import/first
36
35
  import FormBuilder from '../index';
37
36
  // eslint-disable-next-line import/first
38
- import { hasNewFormBuilderEnabledForSms, hasNewFormBuilderEnabledForMpush } from '../../../utils/common';
37
+ import { hasNewFormBuilderEnabledForSms } from '../../../utils/common';
39
38
 
40
39
  const renderGate = (props = {}) => render(<FormBuilder {...props} />);
41
40
 
42
41
  describe('FormBuilder entry gate — routing', () => {
43
- beforeEach(() => {
44
- hasNewFormBuilderEnabledForSms.mockReturnValue(false);
45
- hasNewFormBuilderEnabledForMpush.mockReturnValue(false);
46
- });
47
-
48
42
  describe('flag OFF (default)', () => {
49
43
  beforeEach(() => hasNewFormBuilderEnabledForSms.mockReturnValue(false));
50
44
 
@@ -99,73 +93,8 @@ describe('FormBuilder entry gate — routing', () => {
99
93
  });
100
94
  });
101
95
 
102
- describe('MPUSH flag (ENABLE_NEW_FORMBUILDER_MPUSH — Phase 2)', () => {
103
- it('flag OFF: MOBILEPUSH renders Classic', () => {
104
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
105
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
106
- });
107
-
108
- it('flag ON: routes MOBILEPUSH to Functional (schema.channel)', () => {
109
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
110
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
111
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
112
- expect(screen.queryByTestId('impl-classic')).toBeNull();
113
- });
114
-
115
- it('flag ON: is case-insensitive and honors the channel prop', () => {
116
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
117
- renderGate({ channel: 'mobilepush', schema: {} });
118
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
119
- });
120
-
121
- it('flags are independent: MPUSH flag ON does not move SMS (and vice versa)', () => {
122
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
123
- renderGate({ schema: { channel: 'SMS' } });
124
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
125
- });
126
-
127
- it('SMS flag ON does not move MOBILEPUSH', () => {
128
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
129
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
130
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
131
- });
132
-
133
- it('both flags ON route each channel to Functional', () => {
134
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
135
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
136
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
137
- expect(screen.getByTestId('impl-functional')).toHaveTextContent('functional:MOBILEPUSH');
138
- });
139
-
140
- it('unmigrated channels stay on Classic even with both flags ON', () => {
141
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
142
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
143
- renderGate({ schema: { channel: 'EMAIL' } });
144
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
145
- });
146
- });
147
-
148
- describe('the gate is purely org-flag controlled (no localStorage override)', () => {
149
- afterEach(() => {
150
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
151
- });
152
-
153
- it('a localStorage key does NOT enable the new path when the org flag is OFF', () => {
154
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
155
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
156
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
157
- });
158
-
159
- it('a localStorage key does NOT disable the new path when the org flag is ON', () => {
160
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
161
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'false');
162
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
163
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
164
- });
165
- });
166
-
167
96
  describe('resilience', () => {
168
- it('falls back to Classic if a flag read throws (window.capAuth not initialized)', () => {
97
+ it('falls back to Classic if the flag read throws (window.capAuth not initialized)', () => {
169
98
  hasNewFormBuilderEnabledForSms.mockImplementation(() => {
170
99
  throw new Error('capAuth not initialized');
171
100
  });
@@ -173,14 +102,5 @@ describe('FormBuilder entry gate — routing', () => {
173
102
  expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
174
103
  expect(screen.queryByTestId('impl-functional')).toBeNull();
175
104
  });
176
-
177
- it('one flag reader throwing routes EVERY channel to Classic (whole map falls back)', () => {
178
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
179
- hasNewFormBuilderEnabledForMpush.mockImplementation(() => {
180
- throw new Error('capAuth not initialized');
181
- });
182
- renderGate({ schema: { channel: 'SMS' } });
183
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
184
- });
185
105
  });
186
106
  });
@@ -20,7 +20,7 @@ import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
20
20
  import injectReducer from '../../utils/injectReducer';
21
21
  import { makeSelectAuthenticated } from '../Cap/selectors';
22
22
  import capReducer from '../Cap/reducer';
23
- import { createCommDefinition, editCommDefinition, getCommDefinition, getCommDefinitionVersion } from '../../services/api';
23
+ import { createCommDefinition, editCommDefinition } from '../../services/api';
24
24
  import DynamicControlsStep from './steps/DynamicControlsStep';
25
25
  import MessageTypeStep from './steps/MessageTypeStep';
26
26
  import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
@@ -44,7 +44,7 @@ import {
44
44
  SINGLE_TEMPLATE,
45
45
  } from './constants';
46
46
  import { getEnabledSteps } from './utils/getEnabledSteps';
47
- import { fetchAndMapCommDefinition } from './utils/mapCommDefinitionToStepData';
47
+ import useFetchCommDefinition from './hooks/useFetchCommDefinition';
48
48
  import messages from './messages';
49
49
  import './CommunicationFlow.scss';
50
50
 
@@ -79,23 +79,18 @@ const CommunicationFlow = ({
79
79
  const { formatMessage } = intl || {};
80
80
  const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
81
81
 
82
- // A consumer other than CapNotify (which always pre-fetches and passes full initialData
83
- // itself) may hand CommunicationFlow only a commDefinitionId — no local content copy at all
84
- // — and rely on CommunicationFlow to fetch + populate its own content (see the fetch effect
85
- // below, and mapCommDefinitionToStepData). Captured once at mount: initialData/config aren't
86
- // expected to change identity across the component's lifetime for this decision.
82
+ // Other consumers may provide only commDefinitionId and rely on CommunicationFlow to fetch
83
+ // content; captured once at mount so it also seeds messageType/communicationStrategy below,
84
+ // before useFetchCommDefinition's effect (mount-only) has a chance to run.
87
85
  const [shouldFetchCommDefinition] = useState(
88
86
  () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
89
87
  );
90
- const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetchCommDefinition);
91
88
 
92
89
  // Initialize step data from initialData or defaults
93
90
  const [stepData, setStepData] = useState(() => {
94
91
  const defaultMessageType = messageTypeData.defaultOption?.value || MESSAGE_TYPES_OPTIONS?.[1]?.value || null;
95
92
  return {
96
- // Pre-set to CCS's only supported strategy so the Channel Selection step (which only
97
- // renders once communicationStrategy is set) is already mounted — showing its own
98
- // loading state — while the fetch below is in flight, instead of staying blank/unmounted.
93
+ // Pre-set CCS's only supported strategy so Channel Selection mounts and shows its loading state while the fetch is in flight.
99
94
  messageType: initialData?.messageType || (shouldFetchCommDefinition ? 'transactional' : defaultMessageType),
100
95
  communicationStrategy: initialData?.communicationStrategy || (shouldFetchCommDefinition ? SINGLE_TEMPLATE : null),
101
96
  channel: initialData?.channel || config.channel || null,
@@ -109,27 +104,13 @@ const CommunicationFlow = ({
109
104
  const [validationErrors, setValidationErrors] = useState({});
110
105
 
111
106
  // Fetch-by-id: populate stepData from the CommDefinition itself when the consumer only
112
- // supplied a commDefinitionId (see shouldFetchCommDefinition above). Runs once on mount.
113
- useEffect(() => {
114
- if (!shouldFetchCommDefinition) return;
115
- let cancelled = false;
116
- (async () => {
117
- try {
118
- const mapped = await fetchAndMapCommDefinition(config.context.existingCommDefinitionId, {
119
- getCommDefinition,
120
- getCommDefinitionVersion,
121
- });
122
- if (!cancelled && mapped) {
123
- setStepData((prevStepData) => ({ ...prevStepData, ...mapped }));
124
- }
125
- } catch (error) {
126
- console.error('[CommunicationFlow] fetchAndMapCommDefinition error:', error);
127
- } finally {
128
- if (!cancelled) setIsFetchingCommDefinition(false);
129
- }
130
- })();
131
- return () => { cancelled = true; };
132
- }, []);
107
+ // supplied a commDefinitionId (see shouldFetchCommDefinition above). Shared with
108
+ // CommunicationFlowCard.js via useFetchCommDefinition.
109
+ const isFetchingCommDefinition = useFetchCommDefinition({
110
+ existingCommDefinitionId: config?.context?.existingCommDefinitionId,
111
+ hasLocalContent: !!initialData?.contentItems?.length,
112
+ onFetched: (mapped) => setStepData((prevStepData) => ({ ...prevStepData, ...mapped })),
113
+ });
133
114
 
134
115
  // Memoize enabled steps
135
116
  const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
@@ -27,8 +27,7 @@ import {
27
27
  VIBER, SMS, EMAIL, WHATSAPP, RCS, ZALO,
28
28
  } from '../CreativesContainer/constants';
29
29
  import getContentBody from './utils/getContentBody';
30
- import { getCommDefinition, getCommDefinitionVersion } from '../../services/api';
31
- import { fetchAndMapCommDefinition } from './utils/mapCommDefinitionToStepData';
30
+ import useFetchCommDefinition from './hooks/useFetchCommDefinition';
32
31
  import messages from './messages';
33
32
  import addCreativesIllustration from '../Assets/images/addCreativesIllustration.svg';
34
33
  import './CommunicationFlow.scss';
@@ -93,39 +92,14 @@ const CommunicationFlowCard = ({
93
92
  }
94
93
  }, [initialData]);
95
94
 
96
- // A consumer other than CapNotify (which always pre-fetches and passes full initialData
97
- // itself) may hand this card only a commDefinitionId — no local content copy at all — and
98
- // rely on CommunicationFlowCard to fetch + show the configured summary itself, instead of
99
- // the empty "Add creatives" state. Mirrors CommunicationFlow.js's own fetch-by-id path
100
- // (config.context.existingCommDefinitionId); doing it here too means the SUMMARY CARD
101
- // reflects fetched content immediately, without the user having to open the slidebox and
102
- // click Save first — CommunicationFlow's own fetch would only ever reach this card's
103
- // savedData via that save round-trip, since nothing here listens for its internal onChange.
104
- const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(
105
- () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
106
- );
107
-
108
- useEffect(() => {
109
- const existingCommDefinitionId = config?.context?.existingCommDefinitionId;
110
- if (!existingCommDefinitionId || initialData?.contentItems?.length) return undefined;
111
- let cancelled = false;
112
- (async () => {
113
- try {
114
- const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
115
- getCommDefinition,
116
- getCommDefinitionVersion,
117
- });
118
- if (!cancelled && mapped) {
119
- setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped }));
120
- }
121
- } catch (error) {
122
- console.error('[CommunicationFlowCard] fetchAndMapCommDefinition error:', error);
123
- } finally {
124
- if (!cancelled) setIsFetchingCommDefinition(false);
125
- }
126
- })();
127
- return () => { cancelled = true; };
128
- }, []);
95
+ // Other consumers may provide only commDefinitionId; fetch it here so the summary card shows
96
+ // configured content immediately instead of "Add creatives". Shared with CommunicationFlow.js
97
+ // via useFetchCommDefinition.
98
+ const isFetchingCommDefinition = useFetchCommDefinition({
99
+ existingCommDefinitionId: config?.context?.existingCommDefinitionId,
100
+ hasLocalContent: !!initialData?.contentItems?.length,
101
+ onFetched: (mapped) => setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped })),
102
+ });
129
103
 
130
104
  const handleSave = useCallback((data) => {
131
105
  setSavedData(data);
@@ -241,7 +215,7 @@ const CommunicationFlowCard = ({
241
215
  className="communication-flow-card-loading"
242
216
  bodyStyle={{ padding: 0, height: 152 }}
243
217
  >
244
- <CapRow type="flex" justify="center" align="middle" style={{ height: '100%' }}>
218
+ <CapRow type="flex" justify="center" align="middle" className="loading-card-body-row">
245
219
  <CapSpin spinning />
246
220
  </CapRow>
247
221
  </CapCard>
@@ -3,6 +3,12 @@ import React from 'react';
3
3
  jest.mock('../../../services/api', () => ({
4
4
  createCommDefinition: jest.fn(),
5
5
  editCommDefinition: jest.fn(),
6
+ getCommDefinition: jest.fn(),
7
+ getCommDefinitionVersion: jest.fn(),
8
+ }));
9
+
10
+ jest.mock('../utils/mapCommDefinitionToStepData', () => ({
11
+ fetchAndMapCommDefinition: jest.fn(),
6
12
  }));
7
13
 
8
14
  jest.mock('@capillarytech/cap-ui-library/CapNotification', () => ({
@@ -47,6 +53,7 @@ import { initialReducer } from '../../../initialReducer';
47
53
  import CommunicationFlow from '../CommunicationFlow';
48
54
  import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
49
55
  import { createCommDefinition, editCommDefinition } from '../../../services/api';
56
+ import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
50
57
  import { getEnabledSteps } from '../utils/getEnabledSteps';
51
58
  import {
52
59
  CHANNELS,
@@ -921,6 +928,82 @@ describe('handleSave — CCS flow', () => {
921
928
  });
922
929
  });
923
930
 
931
+ describe('CommunicationFlow — fetch-by-id (config.context.existingCommDefinitionId, no local content)', () => {
932
+ const ccsConfig = { ...baseConfig, context: { name: 'Order Placed Notification' }, features: {} };
933
+ const configWithId = {
934
+ ...ccsConfig,
935
+ context: { ...ccsConfig.context, existingCommDefinitionId: 'cd_fetch_1' },
936
+ };
937
+
938
+ beforeEach(() => {
939
+ fetchAndMapCommDefinition.mockReset();
940
+ });
941
+
942
+ afterEach(() => {
943
+ jest.clearAllMocks();
944
+ });
945
+
946
+ it('fetches and merges the mapped data into stepData when only a commDefinitionId is supplied', async () => {
947
+ fetchAndMapCommDefinition.mockResolvedValue({
948
+ channel: 'SMS',
949
+ contentItems: [{ contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } }],
950
+ ccsCommDefinition: { id: 'cd_fetch_1', referenceId: 'ref_1', version: 1, status: 'DRAFT' },
951
+ });
952
+ const onChange = jest.fn();
953
+ renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
954
+
955
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledWith(
956
+ 'cd_fetch_1',
957
+ expect.objectContaining({
958
+ getCommDefinition: expect.any(Function),
959
+ getCommDefinitionVersion: expect.any(Function),
960
+ }),
961
+ ));
962
+
963
+ await waitFor(() => {
964
+ const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
965
+ expect(payload.contentItems).toEqual([
966
+ { contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } },
967
+ ]);
968
+ });
969
+ });
970
+
971
+ it('does not crash and leaves stepData unchanged when the fetch resolves with nothing mappable', async () => {
972
+ fetchAndMapCommDefinition.mockResolvedValue(null);
973
+ const onChange = jest.fn();
974
+ renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
975
+
976
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
977
+ const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
978
+ expect(payload.contentItems).toEqual([]);
979
+ });
980
+
981
+ it('does not crash when the fetch rejects', async () => {
982
+ fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
983
+ renderWithFlow({ features: {}, config: configWithId, initialData: null });
984
+
985
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
986
+ });
987
+
988
+ it('does not fetch when initialData already has contentItems (e.g. CapNotify, which always pre-fetches itself)', async () => {
989
+ renderWithFlow({
990
+ features: {},
991
+ config: configWithId,
992
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
993
+ });
994
+
995
+ await new Promise((resolve) => setTimeout(resolve, 0));
996
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
997
+ });
998
+
999
+ it('does not fetch when config.context.existingCommDefinitionId is absent', async () => {
1000
+ renderWithFlow({ features: {}, config: ccsConfig, initialData: null });
1001
+
1002
+ await new Promise((resolve) => setTimeout(resolve, 0));
1003
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
1004
+ });
1005
+ });
1006
+
924
1007
  describe('renderSteps — null returns and edge cases', () => {
925
1008
  it('returns null for CHANNEL_SELECTION when communicationStrategy is null', () => {
926
1009
  renderWithFlow({
@@ -0,0 +1,118 @@
1
+ import { renderHook } from '@testing-library/react-hooks';
2
+ import { waitFor } from '@testing-library/react';
3
+
4
+ jest.mock('../../../../services/api', () => ({
5
+ getCommDefinition: jest.fn(),
6
+ getCommDefinitionVersion: jest.fn(),
7
+ }));
8
+
9
+ jest.mock('../../utils/mapCommDefinitionToStepData', () => ({
10
+ fetchAndMapCommDefinition: jest.fn(),
11
+ }));
12
+
13
+ import useFetchCommDefinition from '../useFetchCommDefinition';
14
+ import { getCommDefinition, getCommDefinitionVersion } from '../../../../services/api';
15
+ import { fetchAndMapCommDefinition } from '../../utils/mapCommDefinitionToStepData';
16
+
17
+ describe('useFetchCommDefinition', () => {
18
+ beforeEach(() => {
19
+ fetchAndMapCommDefinition.mockReset();
20
+ });
21
+
22
+ it('starts in a fetching state and calls onFetched with the mapped result once the fetch resolves', async () => {
23
+ fetchAndMapCommDefinition.mockResolvedValue({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
24
+ const onFetched = jest.fn();
25
+
26
+ const { result } = renderHook(() => useFetchCommDefinition({
27
+ existingCommDefinitionId: 'cd_1',
28
+ hasLocalContent: false,
29
+ onFetched,
30
+ }));
31
+
32
+ expect(result.current).toBe(true);
33
+
34
+ await waitFor(() => expect(result.current).toBe(false));
35
+ expect(fetchAndMapCommDefinition).toHaveBeenCalledWith('cd_1', {
36
+ getCommDefinition,
37
+ getCommDefinitionVersion,
38
+ });
39
+ expect(onFetched).toHaveBeenCalledWith({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
40
+ });
41
+
42
+ it('does not call onFetched when the fetch resolves with nothing mappable, but still stops fetching', async () => {
43
+ fetchAndMapCommDefinition.mockResolvedValue(null);
44
+ const onFetched = jest.fn();
45
+
46
+ const { result } = renderHook(() => useFetchCommDefinition({
47
+ existingCommDefinitionId: 'cd_1',
48
+ hasLocalContent: false,
49
+ onFetched,
50
+ }));
51
+
52
+ await waitFor(() => expect(result.current).toBe(false));
53
+ expect(onFetched).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('does not crash and stops fetching when the fetch rejects', async () => {
57
+ fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
58
+ const onFetched = jest.fn();
59
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
60
+
61
+ const { result } = renderHook(() => useFetchCommDefinition({
62
+ existingCommDefinitionId: 'cd_1',
63
+ hasLocalContent: false,
64
+ onFetched,
65
+ }));
66
+
67
+ await waitFor(() => expect(result.current).toBe(false));
68
+ expect(onFetched).not.toHaveBeenCalled();
69
+ expect(consoleErrorSpy).toHaveBeenCalled();
70
+ consoleErrorSpy.mockRestore();
71
+ });
72
+
73
+ it('never fetches, and reports isFetchingCommDefinition as false throughout, when hasLocalContent is true', async () => {
74
+ const onFetched = jest.fn();
75
+
76
+ const { result } = renderHook(() => useFetchCommDefinition({
77
+ existingCommDefinitionId: 'cd_1',
78
+ hasLocalContent: true,
79
+ onFetched,
80
+ }));
81
+
82
+ expect(result.current).toBe(false);
83
+ await new Promise((resolve) => setTimeout(resolve, 0));
84
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('never fetches when existingCommDefinitionId is absent', async () => {
88
+ const onFetched = jest.fn();
89
+
90
+ const { result } = renderHook(() => useFetchCommDefinition({
91
+ existingCommDefinitionId: undefined,
92
+ hasLocalContent: false,
93
+ onFetched,
94
+ }));
95
+
96
+ expect(result.current).toBe(false);
97
+ await new Promise((resolve) => setTimeout(resolve, 0));
98
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('does not call onFetched after unmount, even if the fetch resolves late', async () => {
102
+ let resolveFetch;
103
+ fetchAndMapCommDefinition.mockReturnValue(new Promise((resolve) => { resolveFetch = resolve; }));
104
+ const onFetched = jest.fn();
105
+
106
+ const { unmount } = renderHook(() => useFetchCommDefinition({
107
+ existingCommDefinitionId: 'cd_1',
108
+ hasLocalContent: false,
109
+ onFetched,
110
+ }));
111
+
112
+ unmount();
113
+ resolveFetch({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
114
+ await new Promise((resolve) => setTimeout(resolve, 0));
115
+
116
+ expect(onFetched).not.toHaveBeenCalled();
117
+ });
118
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared by CommunicationFlow.js and CommunicationFlowCard.js — both need to fetch + reverse-map
3
+ * a CommDefinition by id when a consumer supplies only config.context.existingCommDefinitionId
4
+ * and no local content copy (see mapCommDefinitionToStepData.js). Previously duplicated in both
5
+ * files; this hook is the single place that owns the fetch, its loading state, and error handling.
6
+ */
7
+ import { useState, useEffect } from 'react';
8
+ import { getCommDefinition, getCommDefinitionVersion } from '../../../services/api';
9
+ import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
10
+
11
+ /**
12
+ * @param {string|undefined} existingCommDefinitionId - config.context.existingCommDefinitionId
13
+ * @param {boolean} hasLocalContent - true when the consumer already supplied content
14
+ * (e.g. CapNotify, which always pre-fetches and passes full initialData itself) — skips the fetch.
15
+ * @param {(mapped: object) => void} onFetched - called once, only when the fetch resolves with
16
+ * mappable data; the caller merges `mapped` into its own state (stepData/savedData).
17
+ * @returns {boolean} isFetchingCommDefinition - true from mount until the fetch settles; always
18
+ * false when existingCommDefinitionId is absent or hasLocalContent is true.
19
+ */
20
+ export default function useFetchCommDefinition({ existingCommDefinitionId, hasLocalContent, onFetched }) {
21
+ // Captured once at mount — existingCommDefinitionId/hasLocalContent aren't expected to change
22
+ // identity across the component's lifetime for this decision.
23
+ const [shouldFetch] = useState(() => !!existingCommDefinitionId && !hasLocalContent);
24
+ const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetch);
25
+
26
+ useEffect(() => {
27
+ if (!shouldFetch) return undefined;
28
+ let cancelled = false;
29
+ (async () => {
30
+ try {
31
+ const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
32
+ getCommDefinition,
33
+ getCommDefinitionVersion,
34
+ });
35
+ if (!cancelled && mapped) {
36
+ onFetched(mapped);
37
+ }
38
+ } catch (error) {
39
+ console.error('[useFetchCommDefinition] fetchAndMapCommDefinition error:', error);
40
+ } finally {
41
+ if (!cancelled) setIsFetchingCommDefinition(false);
42
+ }
43
+ })();
44
+ return () => { cancelled = true; };
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, []);
47
+
48
+ return isFetchingCommDefinition;
49
+ }
@@ -55,11 +55,7 @@ const ChannelSelectionStep = ({
55
55
  intl,
56
56
  capData, // From Redux - contains user/org info needed by CouponsWrapper
57
57
  config,
58
- // True while CommunicationFlow is fetching an existing CommDefinition's content by id (see
59
- // config.context.existingCommDefinitionId in CommunicationFlow.js) — shown in place of the
60
- // empty "Add creative" state so a consumer that only stored a commDefinitionId doesn't flash
61
- // an empty content step before the fetched content arrives.
62
- isContentLoading = false,
58
+ isContentLoading = false, // True while fetching existing CommDefinition content by id.
63
59
  }) => {
64
60
  const contentItems = value?.contentItems || [];
65
61
  const [showCreativesContainer, setShowCreativesContainer] = useState(false);
@@ -138,6 +138,34 @@ describe('ChannelSelectionStep', () => {
138
138
  expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
139
139
  });
140
140
 
141
+ it('shows a loading spinner instead of the "Add creative" empty state while isContentLoading is true', () => {
142
+ renderStep(
143
+ <ChannelSelectionStep
144
+ value={{ contentItems: [] }}
145
+ onChange={jest.fn()}
146
+ channels={CHANNELS}
147
+ isContentLoading
148
+ />,
149
+ );
150
+
151
+ expect(screen.queryByRole('button', { name: /add creative/i })).not.toBeInTheDocument();
152
+ expect(document.querySelector('.content-loading')).toBeInTheDocument();
153
+ });
154
+
155
+ it('shows the empty "Add creative" state (not the loading spinner) once isContentLoading is false, even with no content', () => {
156
+ renderStep(
157
+ <ChannelSelectionStep
158
+ value={{ contentItems: [] }}
159
+ onChange={jest.fn()}
160
+ channels={CHANNELS}
161
+ isContentLoading={false}
162
+ />,
163
+ );
164
+
165
+ expect(screen.getByRole('button', { name: /add creative/i })).toBeInTheDocument();
166
+ expect(document.querySelector('.content-loading')).not.toBeInTheDocument();
167
+ });
168
+
141
169
  it('shows validation error when error prop is set', () => {
142
170
  renderStep(
143
171
  <ChannelSelectionStep