@capillarytech/creatives-library 9.0.59 → 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 (44) 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/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  23. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  24. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  25. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  26. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  27. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  33. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  34. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  35. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  36. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  37. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  38. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  39. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  40. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  41. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  42. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  43. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  44. 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
+ }
@@ -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;
@@ -1,75 +0,0 @@
1
- /**
2
- * MOBILEPUSH channel config — declarative capabilities + field ids for the MPUSH
3
- * adapter. MOBILEPUSH is a two-tab channel (Android=tab 0, iOS=tab 1); iOS field
4
- * ids carry the legacy `2` suffix (message-editor2). The containers mutate the
5
- * schema at runtime (CTA row splices), so the engine's reconcile path is active.
6
- */
7
- import { MOBILE_PUSH } from '../../../../../v2Containers/CreativesContainer/constants';
8
- import { TEMPLATE_NAME_FIELD } from '../../constants';
9
-
10
- // The exact legacy error VALUE written for brace mismatches — containers pattern
11
- // on it (string ≠ boolean true matters: Classic's cross-tab toast reacts only to
12
- // boolean true). Mirrors errorMessageForTags.TAG_BRACKET_COUNT_MISMATCH_ERROR.
13
- export const ERROR_VALUE = {
14
- BRACKET: 'tagBracketCountMismatchError',
15
- };
16
-
17
- // Legacy tab semantics: 1-based currentTab, 1=Android, 2=iOS (Classic.js:483-486).
18
- export const TABS = {
19
- ANDROID: 0,
20
- IOS: 1,
21
- /** iOS field ids get suffix '2'; Android none (Classic.js:967). */
22
- suffixFor: (tabIndex) => (tabIndex > 0 ? `${Number(tabIndex) + 1}` : ''),
23
- };
24
-
25
- export const fieldIds = {
26
- name: TEMPLATE_NAME_FIELD, // root field
27
- editor: 'message-editor', // per-tab, suffixed on iOS
28
- title: 'message-title', // per-tab, suffixed on iOS
29
- image: 'image', // per-tab (imageSchema only), same id on both panes
30
- bracketError: 'bracket-error',
31
- };
32
-
33
- // The 13 fixed CTA/deeplink rules of Classic's validateForm MPUSH block
34
- // (Classic.js:1037-1135), in source order. kind: 'empty' => `=== ""` fails
35
- // (absent field passes); kind: 'url' => isValidExternalLink semantics.
36
- // Named fieldId (not `key:`) so gitleaks' generic-api-key rule stops matching.
37
- export const CTA_RULES = [
38
- { fieldId: 'cta-deeplink-select', kind: 'empty' },
39
- { fieldId: 'cta-deeplink2-select', kind: 'empty' },
40
- { fieldId: 'cta-deeplink-text', kind: 'url' },
41
- { fieldId: 'cta-deeplink2-text', kind: 'url' },
42
- { fieldId: 'secondary-cta-0-link', kind: 'empty' },
43
- { fieldId: 'secondary-cta-0-label', kind: 'empty' },
44
- { fieldId: 'cta-deeplink-secondary-cta-0-text', kind: 'url' },
45
- { fieldId: 'cta-deeplink-secondary-cta-0-select', kind: 'empty' },
46
- { fieldId: 'secondary-cta-1-label', kind: 'empty' },
47
- { fieldId: 'cta-deeplink-secondary-cta-1-text', kind: 'url' },
48
- { fieldId: 'cta-deeplink-secondary-cta-1-select', kind: 'empty' },
49
- { fieldId: 'cta-deeplink-secondary-cta-1-text2', kind: 'url' },
50
- { fieldId: 'cta-deeplink-secondary-cta-1-select2', kind: 'empty' },
51
- ];
52
-
53
- export default {
54
- channel: MOBILE_PUSH,
55
- fieldIds,
56
- // Classic passes the live 1-based currentTab as showLiquidErrorInFooter's second
57
- // argument for MPUSH (Classic.js:1352, 1487); SMS is the only null channel.
58
- footerTabArg: 'currentTab',
59
- features: {
60
- tabs: true,
61
- versions: false,
62
- languages: false,
63
- unicode: false,
64
- liquid: true, // LIQUID_SUPPORTED_CHANNELS member; pipeline runs only outside isFullMode
65
- askAira: true, // textarea only (Classic.js:2927)
66
- preview: true,
67
- imageUpload: true,
68
- cta: true,
69
- bridgeFieldEvents: true, // checkbox/radio/select commits use the performFormDataUpdate signature (true, formData, field)
70
- inlineErrorWithLiquid: true, // MPUSH keeps the inline textarea error despite liquid (Classic.js:2908-2915)
71
- liveValidation: false, // Classic re-validates typing only under startValidation (Classic.js:334-345)
72
- validateOnExternalChange: true, // background validity on parent/schema/tags changes gates the save flows (Classic.js:516-578, 1361)
73
- emitFormDataOnSchemaSync: true, // push formData up on init + schema re-inits; copy-content handlers read it (Classic.js:1906-1911)
74
- },
75
- };
@@ -1,74 +0,0 @@
1
- /**
2
- * MOBILEPUSH editor-error descriptor — the library-mode footer echo of the ACTIVE
3
- * tab's editor error (Classic's render-phase footer push, Classic.js:2885-2895).
4
- * Mirrored rules: full mode returns null (MPUSH errors show inline); the whole echo
5
- * is gated on checkValidation (Classic computes no errorData pre-save, 334-345);
6
- * personalization restriction has its own message (2877-2879); otherwise the schema
7
- * field's errorMessage (already localized by the containers) is echoed as-is.
8
- */
9
-
10
- import { hasPersonalizationTags } from '../../../../../utils/commonUtils';
11
- import formMessages from '../../../messages';
12
- import { EDITOR_ERROR_KIND, SECTION_TYPE, CONTAINER_TYPE } from '../../constants';
13
- import { ERROR_VALUE, TABS } from './config';
14
-
15
- /** Find a leaf field by id inside one pane of the tabs container. */
16
- const findPaneField = (schema, tabIndex, fieldId) => {
17
- const container = (schema?.containers || []).find(
18
- (candidate) => candidate?.type === CONTAINER_TYPE.TABS
19
- && (candidate.isActive === undefined || candidate.isActive),
20
- );
21
- const pane = container?.panes?.[tabIndex];
22
- let found = null;
23
- const visitSection = (section) => {
24
- if (!section || found) return;
25
- if (section.type === SECTION_TYPE.PARENT) {
26
- (section.childSections || []).forEach(visitSection);
27
- } else if (section.type === SECTION_TYPE.MULTICOLS) {
28
- (section.inputFields || []).concat(section.actionFields || []).forEach((row) => {
29
- (row?.cols || []).forEach((col) => { if (col?.id === fieldId) found = col; });
30
- });
31
- } else if (section.type === SECTION_TYPE.COL_LABEL) {
32
- (section.inputFields || []).concat(section.actionFields || []).forEach((col) => {
33
- if (col?.id === fieldId) found = col;
34
- });
35
- }
36
- };
37
- (pane?.sections || []).forEach(visitSection);
38
- return found;
39
- };
40
-
41
- /**
42
- * @param {object} legacyFormData the legacy dialect ({0: {...}, 1: {...}, ...})
43
- * @param {object} ctx { isFullMode, currentTab (1-based), errorData, schema,
44
- * checkValidation, restrictPersonalization }
45
- * @returns null | { text, kind } | { message: intlDescriptor, kind }
46
- */
47
- export default function getEditorErrorDescriptor(legacyFormData = {}, ctx = {}) {
48
- if (ctx.isFullMode) return null;
49
- // Classic parity: no footer echo before a save attempt (see header).
50
- if (!ctx.checkValidation) return null;
51
-
52
- const tabIndex = Math.max(((ctx.currentTab || 1) - 1), 0);
53
- const suffix = TABS.suffixFor(tabIndex);
54
- const editorId = `message-editor${suffix}`;
55
- const editorError = ctx.errorData?.[tabIndex]?.[editorId];
56
- if (!editorError) return null;
57
-
58
- const tab = legacyFormData?.[tabIndex] || {};
59
- const content = tab[editorId];
60
-
61
- if (ctx.restrictPersonalization && content && hasPersonalizationTags(String(content))) {
62
- return {
63
- message: formMessages.personalizationTagsErrorMessage,
64
- kind: EDITOR_ERROR_KIND.MISSING,
65
- };
66
- }
67
-
68
- const field = findPaneField(ctx.schema, tabIndex, editorId);
69
- if (!field?.errorMessage) return null;
70
- return {
71
- text: field.errorMessage,
72
- kind: editorError === ERROR_VALUE.BRACKET ? EDITOR_ERROR_KIND.BRACE : EDITOR_ERROR_KIND.MISSING,
73
- };
74
- }