@capillarytech/creatives-library 9.0.59 → 9.0.61-alpha.0

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 (48) hide show
  1. package/constants/unified.js +2 -3
  2. package/package.json +1 -1
  3. package/utils/common.js +0 -8
  4. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
  5. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  6. package/v2Components/FormBuilder/Functional/constants.js +8 -46
  7. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  8. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  9. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  10. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  11. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  12. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  13. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  14. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  15. package/v2Components/FormBuilder/_formBuilder.scss +0 -11
  16. package/v2Components/FormBuilder/index.js +13 -20
  17. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
  18. package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -25
  19. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -28
  20. package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
  21. package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
  22. package/v2Containers/TagList/index.js +3 -3
  23. package/v2Containers/TagList/tests/TagList.test.js +16 -0
  24. package/v2Containers/Viber/constants.js +6 -0
  25. package/v2Containers/Viber/index.js +22 -16
  26. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  27. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  33. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  34. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  35. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  36. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  37. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  38. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  39. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  40. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  41. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  42. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  43. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  44. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  45. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  46. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  47. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  48. 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,11 +79,12 @@ const CommunicationFlow = ({
79
79
  const { formatMessage } = intl || {};
80
80
  const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
81
81
 
82
- // Other consumers may provide only commDefinitionId and rely on CommunicationFlow to fetch content; capture this decision once at mount.
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.
83
85
  const [shouldFetchCommDefinition] = useState(
84
86
  () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
85
87
  );
86
- const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetchCommDefinition);
87
88
 
88
89
  // Initialize step data from initialData or defaults
89
90
  const [stepData, setStepData] = useState(() => {
@@ -103,27 +104,13 @@ const CommunicationFlow = ({
103
104
  const [validationErrors, setValidationErrors] = useState({});
104
105
 
105
106
  // Fetch-by-id: populate stepData from the CommDefinition itself when the consumer only
106
- // supplied a commDefinitionId (see shouldFetchCommDefinition above). Runs once on mount.
107
- useEffect(() => {
108
- if (!shouldFetchCommDefinition) return;
109
- let cancelled = false;
110
- (async () => {
111
- try {
112
- const mapped = await fetchAndMapCommDefinition(config.context.existingCommDefinitionId, {
113
- getCommDefinition,
114
- getCommDefinitionVersion,
115
- });
116
- if (!cancelled && mapped) {
117
- setStepData((prevStepData) => ({ ...prevStepData, ...mapped }));
118
- }
119
- } catch (error) {
120
- console.error('[CommunicationFlow] fetchAndMapCommDefinition error:', error);
121
- } finally {
122
- if (!cancelled) setIsFetchingCommDefinition(false);
123
- }
124
- })();
125
- return () => { cancelled = true; };
126
- }, []);
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
+ });
127
114
 
128
115
  // Memoize enabled steps
129
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,32 +92,14 @@ const CommunicationFlowCard = ({
93
92
  }
94
93
  }, [initialData]);
95
94
 
96
- // Other consumers may provide only commDefinitionId; fetch it here so the summary card shows configured content immediately instead of "Add creatives".
97
- const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(
98
- () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
99
- );
100
-
101
- useEffect(() => {
102
- const existingCommDefinitionId = config?.context?.existingCommDefinitionId;
103
- if (!existingCommDefinitionId || initialData?.contentItems?.length) return undefined;
104
- let cancelled = false;
105
- (async () => {
106
- try {
107
- const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
108
- getCommDefinition,
109
- getCommDefinitionVersion,
110
- });
111
- if (!cancelled && mapped) {
112
- setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped }));
113
- }
114
- } catch (error) {
115
- console.error('[CommunicationFlowCard] fetchAndMapCommDefinition error:', error);
116
- } finally {
117
- if (!cancelled) setIsFetchingCommDefinition(false);
118
- }
119
- })();
120
- return () => { cancelled = true; };
121
- }, []);
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
+ });
122
103
 
123
104
  const handleSave = useCallback((data) => {
124
105
  setSavedData(data);
@@ -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
+ }
@@ -110,9 +110,9 @@ export class TagList extends React.Component { // eslint-disable-line react/pref
110
110
  } = prevProps;
111
111
 
112
112
  if (
113
- tags !== prevTags
114
- || injectedTags !== prevInjectedTags
115
- || selectedOfferDetails !== prevSelectedOfferDetails
113
+ !_.isEqual(tags, prevTags)
114
+ || !_.isEqual(injectedTags, prevInjectedTags)
115
+ || !_.isEqual(selectedOfferDetails, prevSelectedOfferDetails)
116
116
  || !_.isEqual(eventContextTags, prevEventContextTags)
117
117
  || !_.isEqual(waitEventContextTags, prevWaitEventContextTags)
118
118
  ) {
@@ -134,6 +134,22 @@ describe("TagList test : UNIT", () => {
134
134
  expect(() => unmount()).not.toThrow();
135
135
  });
136
136
 
137
+ it('does not regenerate tags when selectedOfferDetails gets a new [] reference with same content', () => {
138
+ const spy = jest.spyOn(TagList.prototype, 'populateTags');
139
+ const { rerender, Component, store } = initializeTagList({
140
+ selectedOfferDetails: [],
141
+ tags: TagListData.tags,
142
+ });
143
+ spy.mockClear();
144
+ rerender(
145
+ <Provider store={store}>
146
+ <Component {...buildProps({ selectedOfferDetails: [], tags: TagListData.tags })} />
147
+ </Provider>
148
+ );
149
+ expect(spy).not.toHaveBeenCalled();
150
+ spy.mockRestore();
151
+ });
152
+
137
153
  it('regenerates tags when props.tags change (componentDidUpdate)', () => {
138
154
  const { rerender, Component, store } = initializeTagList({ tags: TagListData.tags });
139
155
  const extra = [
@@ -13,6 +13,12 @@ export const VIBER_IMG_SIZE = 10000000;
13
13
  export const VIBER_VIDEO_SIZE = 209715200;
14
14
  export const charLimit = 1000;
15
15
 
16
+ /** Stable fallbacks for TagList props — inline `= []` / `|| {}` allocate new refs every render. */
17
+ export const EMPTY_OFFER_DETAILS = [];
18
+ export const EMPTY_INJECTED_TAGS = {};
19
+ export const EMPTY_TAGS = [];
20
+ export const EMPTY_TEMPLATE_DATA = {};
21
+
16
22
  export const UPLOAD_VIBER_ASSET_REQUEST = 'app/v2Containers/Viber/UPLOAD_ASSET_REQUEST';
17
23
  export const UPLOAD_VIBER_ASSET_SUCCESS = 'app/v2Containers/Viber/UPLOAD_ASSET_SUCCESS';
18
24
  export const UPLOAD_VIBER_ASSET_FAILURE = 'app/v2Containers/Viber/UPLOAD_ASSET_FAILURE';
@@ -63,6 +63,10 @@ import {
63
63
  VIBER_CAROUSEL_IMG_SIZE,
64
64
  STATIC_URL,
65
65
  DYNAMIC_URL,
66
+ EMPTY_OFFER_DETAILS,
67
+ EMPTY_INJECTED_TAGS,
68
+ EMPTY_TAGS,
69
+ EMPTY_TEMPLATE_DATA,
66
70
  } from './constants';
67
71
  import withCreatives from '../../hoc/withCreatives';
68
72
  import {
@@ -129,12 +133,12 @@ export const Viber = (props) => {
129
133
  handleClose,
130
134
  onCreateComplete,
131
135
  params,
132
- templateData = {},
136
+ templateData = EMPTY_TEMPLATE_DATA,
133
137
  actions,
134
138
  viber = {},
135
139
  getFormSubscriptionData,
136
140
  viberData = {},
137
- selectedOfferDetails = [],
141
+ selectedOfferDetails = EMPTY_OFFER_DETAILS,
138
142
  eventContextTags,
139
143
  waitEventContextTags,
140
144
  // TestAndPreviewSlidebox props
@@ -258,7 +262,7 @@ export const Viber = (props) => {
258
262
  setTemplateMediaType(VIBER_MEDIA_TYPES.TEXT);
259
263
  }
260
264
  }
261
- }, [viber.templateDetails || templateData]);
265
+ }, [params?.id, viber.templateDetails, templateData]);
262
266
 
263
267
  // Reports live message-content validity to a parent (e.g. CreativesContainer's
264
268
  // slidebox) so it can disable its own Done/Preview-and-test buttons whenever the
@@ -332,7 +336,8 @@ export const Viber = (props) => {
332
336
  globalActionsProps.fetchSchemaForEntity(query);
333
337
  };
334
338
 
335
- const tags = metaEntities?.tags?.standard || [];
339
+ const tags = metaEntities?.tags?.standard ?? EMPTY_TAGS;
340
+ const resolvedInjectedTags = injectedTags ?? EMPTY_INJECTED_TAGS;
336
341
  // tags Code end here
337
342
 
338
343
  // validation on Text area and tags validation
@@ -393,7 +398,7 @@ export const Viber = (props) => {
393
398
  onContextChange={handleOnTagsContextChange}
394
399
  location={location}
395
400
  tags={tags}
396
- injectedTags={injectedTags || {}}
401
+ injectedTags={resolvedInjectedTags}
397
402
  id="viber_tags"
398
403
  userLocale={localStorage.getItem("jlocale") || "en"}
399
404
  selectedOfferDetails={selectedOfferDetails}
@@ -882,7 +887,7 @@ export const Viber = (props) => {
882
887
  onContextChange={handleOnTagsContextChange}
883
888
  location={location}
884
889
  tags={tags}
885
- injectedTags={injectedTags || {}}
890
+ injectedTags={resolvedInjectedTags}
886
891
  id={`viber_carousel_card_tags_${cardIndex}`}
887
892
  userLocale={localStorage.getItem("jlocale") || "en"}
888
893
  selectedOfferDetails={selectedOfferDetails}
@@ -1260,7 +1265,7 @@ export const Viber = (props) => {
1260
1265
  onContextChange={handleOnTagsContextChange}
1261
1266
  location={location}
1262
1267
  tags={tags}
1263
- injectedTags={injectedTags || {}}
1268
+ injectedTags={resolvedInjectedTags}
1264
1269
  userLocale={localStorage.getItem("jlocale") || "en"}
1265
1270
  selectedOfferDetails={selectedOfferDetails}
1266
1271
  eventContextTags={eventContextTags}
@@ -1578,15 +1583,16 @@ export const Viber = (props) => {
1578
1583
  </CapButton>
1579
1584
  </ViberFooter>
1580
1585
  </CapSpin>
1581
- {/* Test and Preview Slidebox */}
1582
- <TestAndPreviewSlidebox
1583
- show={showTestAndPreviewSlidebox || propsShowTestAndPreviewSlidebox}
1584
- onClose={handleCloseTestAndPreview}
1585
- channel={VIBER}
1586
- formData={getTemplateContent()} // Pass templateContent as formData (contains all payload fields)
1587
- content={getTemplateContent()} // Also pass as content for preview (contains viberPreviewContent)
1588
- formatMessage={formatMessage}
1589
- />
1586
+ {(showTestAndPreviewSlidebox || propsShowTestAndPreviewSlidebox) && (
1587
+ <TestAndPreviewSlidebox
1588
+ show={showTestAndPreviewSlidebox || propsShowTestAndPreviewSlidebox}
1589
+ onClose={handleCloseTestAndPreview}
1590
+ channel={VIBER}
1591
+ formData={getTemplateContent()}
1592
+ content={getTemplateContent()}
1593
+ formatMessage={formatMessage}
1594
+ />
1595
+ )}
1590
1596
  </>
1591
1597
  );
1592
1598
  };
@@ -1,10 +0,0 @@
1
- /**
2
- * MOBILEPUSH submit payload — identity, exactly like Classic: the container
3
- * receives the full legacy formData object and builds the API payload itself
4
- * (getTransformedData). Note that getChannelData has NO MOBILEPUSH branch —
5
- * Classic short-circuits before reaching it (Classic.js:1402-1405) — so this
6
- * adapter must never call it.
7
- */
8
- const buildSubmitPayload = (legacyFormData) => legacyFormData;
9
-
10
- export default buildSubmitPayload;