@capillarytech/creatives-library 9.0.57 → 9.0.59-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.
- package/package.json +1 -1
- package/services/api.js +15 -0
- package/services/tests/api.test.js +55 -0
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +49 -16
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +17 -5
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +13 -1
- package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +268 -0
- package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +155 -0
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +17 -1
- package/v2Containers/MobilePush/commonMethods.js +6 -7
- package/v2Containers/MobilePushNew/index.js +20 -29
- package/v2Containers/MobilePushNew/tests/utils.test.js +10 -1
- package/v2Containers/MobilePushNew/utils.js +10 -6
package/package.json
CHANGED
package/services/api.js
CHANGED
|
@@ -722,6 +722,21 @@ export const sendTransactionalComm = (payload) => {
|
|
|
722
722
|
return request(url, getAPICallObject(HTTP_METHODS.POST, payload, false, false, false, true));
|
|
723
723
|
};
|
|
724
724
|
|
|
725
|
+
// CommunicationFlow's own fetch-by-id path (see mapCommDefinitionToStepData in
|
|
726
|
+
// CommunicationFlow/utils) — lets a consumer that only stored a commDefinitionId (no local
|
|
727
|
+
// content copy) hand CommunicationFlow just that id on edit; it fetches metadata + content itself.
|
|
728
|
+
export const getCommDefinition = (commDefinitionId, include) => {
|
|
729
|
+
const query = include ? `?include=${encodeURIComponent(include)}` : '';
|
|
730
|
+
const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}${query}`;
|
|
731
|
+
return request(url, getAPICallObject(HTTP_METHODS.GET, false, false, false, true));
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// CommDefinition GET returns metadata only; fetch the version separately for message content.
|
|
735
|
+
export const getCommDefinitionVersion = (commDefinitionId, version) => {
|
|
736
|
+
const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}/version/${version}`;
|
|
737
|
+
return request(url, getAPICallObject(HTTP_METHODS.GET, false, false, false, true));
|
|
738
|
+
};
|
|
739
|
+
|
|
725
740
|
export const getCentralCommsMetaIds = (metaIds, metaType = TRANSACTION) => {
|
|
726
741
|
const url = `${API_ENDPOINT}/common/central-comms/meta-id/${metaType}?metaIds=${metaIds}`;
|
|
727
742
|
return request(url, getAPICallObject(HTTP_METHODS.GET, null, false, false, false, true));
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
getCmsAccounts,
|
|
32
32
|
getMembersLookup,
|
|
33
33
|
createTestCustomer,
|
|
34
|
+
getCommDefinition,
|
|
35
|
+
getCommDefinitionVersion,
|
|
34
36
|
} from '../api';
|
|
35
37
|
import { mockData } from './mockData';
|
|
36
38
|
import getSchema from '../getSchema';
|
|
@@ -1243,3 +1245,56 @@ describe('bulkClaimAndApprove', () => {
|
|
|
1243
1245
|
expect(result).toEqual({ error: 'Network error' });
|
|
1244
1246
|
});
|
|
1245
1247
|
});
|
|
1248
|
+
|
|
1249
|
+
describe('getCommDefinition', () => {
|
|
1250
|
+
beforeEach(() => {
|
|
1251
|
+
global.fetch = jest.fn();
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
afterEach(() => {
|
|
1255
|
+
jest.restoreAllMocks();
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('builds the URL without an include query param when include is not supplied', async () => {
|
|
1259
|
+
global.fetch.mockReturnValue(Promise.resolve({
|
|
1260
|
+
status: 200,
|
|
1261
|
+
json: () => Promise.resolve({ status: 200, response: { data: { id: 'cd_1' } } }),
|
|
1262
|
+
}));
|
|
1263
|
+
await getCommDefinition('cd_1');
|
|
1264
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
1265
|
+
const lastCall = global.fetch.mock.calls[global.fetch.mock.calls.length - 1];
|
|
1266
|
+
expect(lastCall[0]).toContain('/comm-definitions/cd_1?');
|
|
1267
|
+
expect(lastCall[0]).not.toContain('include=');
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it('appends an include query param when supplied', async () => {
|
|
1271
|
+
global.fetch.mockReturnValue(Promise.resolve({
|
|
1272
|
+
status: 200,
|
|
1273
|
+
json: () => Promise.resolve({ status: 200, response: { data: { id: 'cd_1' } } }),
|
|
1274
|
+
}));
|
|
1275
|
+
await getCommDefinition('cd_1', 'versions');
|
|
1276
|
+
const lastCall = global.fetch.mock.calls[global.fetch.mock.calls.length - 1];
|
|
1277
|
+
expect(lastCall[0]).toContain('/comm-definitions/cd_1?include=versions');
|
|
1278
|
+
});
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
describe('getCommDefinitionVersion', () => {
|
|
1282
|
+
beforeEach(() => {
|
|
1283
|
+
global.fetch = jest.fn();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
afterEach(() => {
|
|
1287
|
+
jest.restoreAllMocks();
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('builds the URL with the commDefinitionId and version segments', async () => {
|
|
1291
|
+
global.fetch.mockReturnValue(Promise.resolve({
|
|
1292
|
+
status: 200,
|
|
1293
|
+
json: () => Promise.resolve({ status: 200, response: { data: {} } }),
|
|
1294
|
+
}));
|
|
1295
|
+
await getCommDefinitionVersion('cd_1', 2);
|
|
1296
|
+
const lastCall = global.fetch.mock.calls[global.fetch.mock.calls.length - 1];
|
|
1297
|
+
expect(lastCall[0]).toContain('/comm-definitions/cd_1/version/2');
|
|
1298
|
+
expect(lastCall[1].method).toBe('GET');
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
@@ -16,11 +16,11 @@ import { createStructuredSelector } from 'reselect';
|
|
|
16
16
|
import CapRow from '@capillarytech/cap-ui-library/CapRow';
|
|
17
17
|
import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
|
|
18
18
|
import CapButton from '@capillarytech/cap-ui-library/CapButton';
|
|
19
|
-
import
|
|
19
|
+
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 } from '../../services/api';
|
|
23
|
+
import { createCommDefinition, editCommDefinition, getCommDefinition, getCommDefinitionVersion } from '../../services/api';
|
|
24
24
|
import DynamicControlsStep from './steps/DynamicControlsStep';
|
|
25
25
|
import MessageTypeStep from './steps/MessageTypeStep';
|
|
26
26
|
import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
|
|
@@ -41,8 +41,10 @@ import {
|
|
|
41
41
|
CCS_CHANNEL_NAME_MAP,
|
|
42
42
|
CCS_CONTENT_TRANSFORMS,
|
|
43
43
|
REFERENCE_ID_EXISTS,
|
|
44
|
+
SINGLE_TEMPLATE,
|
|
44
45
|
} from './constants';
|
|
45
46
|
import { getEnabledSteps } from './utils/getEnabledSteps';
|
|
47
|
+
import { fetchAndMapCommDefinition } from './utils/mapCommDefinitionToStepData';
|
|
46
48
|
import messages from './messages';
|
|
47
49
|
import './CommunicationFlow.scss';
|
|
48
50
|
|
|
@@ -76,12 +78,26 @@ const CommunicationFlow = ({
|
|
|
76
78
|
}) => {
|
|
77
79
|
const { formatMessage } = intl || {};
|
|
78
80
|
const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
|
|
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.
|
|
87
|
+
const [shouldFetchCommDefinition] = useState(
|
|
88
|
+
() => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
|
|
89
|
+
);
|
|
90
|
+
const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetchCommDefinition);
|
|
91
|
+
|
|
79
92
|
// Initialize step data from initialData or defaults
|
|
80
93
|
const [stepData, setStepData] = useState(() => {
|
|
81
94
|
const defaultMessageType = messageTypeData.defaultOption?.value || MESSAGE_TYPES_OPTIONS?.[1]?.value || null;
|
|
82
95
|
return {
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
99
|
+
messageType: initialData?.messageType || (shouldFetchCommDefinition ? 'transactional' : defaultMessageType),
|
|
100
|
+
communicationStrategy: initialData?.communicationStrategy || (shouldFetchCommDefinition ? SINGLE_TEMPLATE : null),
|
|
85
101
|
channel: initialData?.channel || config.channel || null,
|
|
86
102
|
channels: initialData?.channels || [],
|
|
87
103
|
selectedOfferDetails: initialData?.selectedOfferDetails || [],
|
|
@@ -91,7 +107,29 @@ const CommunicationFlow = ({
|
|
|
91
107
|
};
|
|
92
108
|
});
|
|
93
109
|
const [validationErrors, setValidationErrors] = useState({});
|
|
94
|
-
|
|
110
|
+
|
|
111
|
+
// 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
|
+
}, []);
|
|
95
133
|
|
|
96
134
|
// Memoize enabled steps
|
|
97
135
|
const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
|
|
@@ -151,7 +189,6 @@ const CommunicationFlow = ({
|
|
|
151
189
|
}, []);
|
|
152
190
|
|
|
153
191
|
const handleSave = useCallback(async () => {
|
|
154
|
-
setSaveError(null);
|
|
155
192
|
const aggregatedData = getAggregatedData();
|
|
156
193
|
const shouldUseCCS = config?.useCCS !== false;
|
|
157
194
|
let ccsCommDefinition = null;
|
|
@@ -222,11 +259,11 @@ const CommunicationFlow = ({
|
|
|
222
259
|
})
|
|
223
260
|
: await createCommDefinition(payload);
|
|
224
261
|
if (res?.isError) {
|
|
225
|
-
|
|
262
|
+
CapNotification.error({ message: formatMessage(messages.genericSaveError) });
|
|
226
263
|
return;
|
|
227
264
|
}
|
|
228
265
|
if (isDuplicateReferenceIdError(res)) {
|
|
229
|
-
|
|
266
|
+
CapNotification.error({ message: formatMessage(messages.duplicateReferenceIdError) });
|
|
230
267
|
return;
|
|
231
268
|
}
|
|
232
269
|
const data = res?.response?.data;
|
|
@@ -239,7 +276,7 @@ const CommunicationFlow = ({
|
|
|
239
276
|
status: data.status,
|
|
240
277
|
};
|
|
241
278
|
} else {
|
|
242
|
-
|
|
279
|
+
CapNotification.error({ message: formatMessage(messages.genericSaveError) });
|
|
243
280
|
return;
|
|
244
281
|
}
|
|
245
282
|
} else if (data?.id) {
|
|
@@ -250,12 +287,12 @@ const CommunicationFlow = ({
|
|
|
250
287
|
status: data.status,
|
|
251
288
|
};
|
|
252
289
|
} else {
|
|
253
|
-
|
|
290
|
+
CapNotification.error({ message: formatMessage(messages.genericSaveError) });
|
|
254
291
|
return;
|
|
255
292
|
}
|
|
256
293
|
} catch (error) {
|
|
257
294
|
console.error('[CommunicationFlow] CCS createCommDefinition error:', error);
|
|
258
|
-
|
|
295
|
+
CapNotification.error({ message: formatMessage(messages.genericSaveError) });
|
|
259
296
|
return;
|
|
260
297
|
}
|
|
261
298
|
} else if (!name) {
|
|
@@ -349,6 +386,7 @@ const CommunicationFlow = ({
|
|
|
349
386
|
deliverySettingsData={config.features?.deliverySettingsData}
|
|
350
387
|
config={config}
|
|
351
388
|
capData={cap || capData}
|
|
389
|
+
isContentLoading={isFetchingCommDefinition}
|
|
352
390
|
/>
|
|
353
391
|
{stepData.contentItems?.length > 0 && <CapDivider />}
|
|
354
392
|
</CapRow>
|
|
@@ -377,11 +415,6 @@ const CommunicationFlow = ({
|
|
|
377
415
|
{renderSteps()}
|
|
378
416
|
{onSave && (
|
|
379
417
|
<CapRow useLegacy className="communication-flow-container__footer">
|
|
380
|
-
{saveError && (
|
|
381
|
-
<CapLabel type="label2" className="communication-flow-container__save-error">
|
|
382
|
-
{saveError}
|
|
383
|
-
</CapLabel>
|
|
384
|
-
)}
|
|
385
418
|
<CapButton type="primary" onClick={handleSave} disabled={isSaveDisabled}>
|
|
386
419
|
{formatMessage(messages.save)}
|
|
387
420
|
</CapButton>
|
|
@@ -5,6 +5,13 @@ jest.mock('../../../services/api', () => ({
|
|
|
5
5
|
editCommDefinition: jest.fn(),
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
+
jest.mock('@capillarytech/cap-ui-library/CapNotification', () => ({
|
|
9
|
+
error: jest.fn(),
|
|
10
|
+
success: jest.fn(),
|
|
11
|
+
warning: jest.fn(),
|
|
12
|
+
info: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
8
15
|
jest.mock('../../CreativesContainer', () => function MockCreativesContainer({
|
|
9
16
|
getCreativesData,
|
|
10
17
|
handleCloseCreatives,
|
|
@@ -38,6 +45,7 @@ import { IntlProvider } from 'react-intl';
|
|
|
38
45
|
import history from '../../../utils/history';
|
|
39
46
|
import { initialReducer } from '../../../initialReducer';
|
|
40
47
|
import CommunicationFlow from '../CommunicationFlow';
|
|
48
|
+
import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
|
|
41
49
|
import { createCommDefinition, editCommDefinition } from '../../../services/api';
|
|
42
50
|
import { getEnabledSteps } from '../utils/getEnabledSteps';
|
|
43
51
|
import {
|
|
@@ -705,7 +713,7 @@ describe('handleSave — CCS flow', () => {
|
|
|
705
713
|
|
|
706
714
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
707
715
|
|
|
708
|
-
await waitFor(() => expect(
|
|
716
|
+
await waitFor(() => expect(CapNotification.error).toHaveBeenCalledWith({ message: 'Something went wrong while saving your content. Please try again.' }));
|
|
709
717
|
expect(onSave).not.toHaveBeenCalled();
|
|
710
718
|
});
|
|
711
719
|
|
|
@@ -721,7 +729,7 @@ describe('handleSave — CCS flow', () => {
|
|
|
721
729
|
|
|
722
730
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
723
731
|
|
|
724
|
-
await waitFor(() => expect(
|
|
732
|
+
await waitFor(() => expect(CapNotification.error).toHaveBeenCalledWith({ message: 'Something went wrong while saving your content. Please try again.' }));
|
|
725
733
|
expect(onSave).not.toHaveBeenCalled();
|
|
726
734
|
});
|
|
727
735
|
|
|
@@ -737,7 +745,7 @@ describe('handleSave — CCS flow', () => {
|
|
|
737
745
|
|
|
738
746
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
739
747
|
|
|
740
|
-
await waitFor(() => expect(
|
|
748
|
+
await waitFor(() => expect(CapNotification.error).toHaveBeenCalledWith({ message: 'Something went wrong while saving your content. Please try again.' }));
|
|
741
749
|
expect(onSave).not.toHaveBeenCalled();
|
|
742
750
|
});
|
|
743
751
|
|
|
@@ -758,7 +766,9 @@ describe('handleSave — CCS flow', () => {
|
|
|
758
766
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
759
767
|
|
|
760
768
|
await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
|
|
761
|
-
|
|
769
|
+
await waitFor(() => expect(CapNotification.error).toHaveBeenCalledWith({
|
|
770
|
+
message: 'This reference ID is already in use in your organization. Please use a different reference ID.',
|
|
771
|
+
}));
|
|
762
772
|
expect(onSave).not.toHaveBeenCalled();
|
|
763
773
|
});
|
|
764
774
|
|
|
@@ -904,7 +914,9 @@ describe('handleSave — CCS flow', () => {
|
|
|
904
914
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
905
915
|
|
|
906
916
|
await waitFor(() => expect(editCommDefinition).toHaveBeenCalledTimes(1));
|
|
907
|
-
|
|
917
|
+
await waitFor(() => expect(CapNotification.error).toHaveBeenCalledWith({
|
|
918
|
+
message: 'This reference ID is already in use in your organization. Please use a different reference ID.',
|
|
919
|
+
}));
|
|
908
920
|
expect(onSave).not.toHaveBeenCalled();
|
|
909
921
|
});
|
|
910
922
|
});
|
|
@@ -16,6 +16,7 @@ import CapIcon from '@capillarytech/cap-ui-library/CapIcon';
|
|
|
16
16
|
import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
|
|
17
17
|
import CapHeader from '@capillarytech/cap-ui-library/CapHeader';
|
|
18
18
|
import CapCustomCard from '@capillarytech/cap-ui-library/CapCustomCard';
|
|
19
|
+
import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
|
|
19
20
|
import CreativesContainer from '../../../CreativesContainer';
|
|
20
21
|
import TestAndPreviewSlidebox from '../../../../v2Components/TestAndPreviewSlidebox';
|
|
21
22
|
import { DeliverySettingsSection } from '../DeliverySettingsStep';
|
|
@@ -54,6 +55,11 @@ const ChannelSelectionStep = ({
|
|
|
54
55
|
intl,
|
|
55
56
|
capData, // From Redux - contains user/org info needed by CouponsWrapper
|
|
56
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,
|
|
57
63
|
}) => {
|
|
58
64
|
const contentItems = value?.contentItems || [];
|
|
59
65
|
const [showCreativesContainer, setShowCreativesContainer] = useState(false);
|
|
@@ -433,7 +439,11 @@ const ChannelSelectionStep = ({
|
|
|
433
439
|
</CapHeading>
|
|
434
440
|
)}
|
|
435
441
|
|
|
436
|
-
{contentItems?.length === 0 ? (
|
|
442
|
+
{contentItems?.length === 0 && isContentLoading ? (
|
|
443
|
+
<CapRow type="flex" justify="center" align="middle" className="content-template-section content-loading">
|
|
444
|
+
<CapSpin spinning />
|
|
445
|
+
</CapRow>
|
|
446
|
+
) : contentItems?.length === 0 ? (
|
|
437
447
|
<CapRow className={`content-template-section ${contentItems?.length === 0 ? 'no-content-items' : ''}`}>
|
|
438
448
|
<CapDropdown
|
|
439
449
|
overlay={renderChannelDropdownOverlay() || <CapMenu />}
|
|
@@ -582,6 +592,7 @@ ChannelSelectionStep.propTypes = {
|
|
|
582
592
|
intl: PropTypes.object.isRequired,
|
|
583
593
|
capData: PropTypes.object, // Cap data from Redux (user/org info)
|
|
584
594
|
config: PropTypes.object,
|
|
595
|
+
isContentLoading: PropTypes.bool,
|
|
585
596
|
};
|
|
586
597
|
|
|
587
598
|
ChannelSelectionStep.defaultProps = {
|
|
@@ -597,6 +608,7 @@ ChannelSelectionStep.defaultProps = {
|
|
|
597
608
|
incentivesData: null,
|
|
598
609
|
capData: {},
|
|
599
610
|
config: {},
|
|
611
|
+
isContentLoading: false,
|
|
600
612
|
};
|
|
601
613
|
|
|
602
614
|
export default injectIntl(ChannelSelectionStep);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mapCommDefinitionToStepData,
|
|
3
|
+
fetchAndMapCommDefinition,
|
|
4
|
+
getLatestVersionFromAuditInfos,
|
|
5
|
+
} from '../mapCommDefinitionToStepData';
|
|
6
|
+
|
|
7
|
+
describe('getLatestVersionFromAuditInfos', () => {
|
|
8
|
+
it('returns currentVersion (or 0) when there are no SUBMITTED audit entries', () => {
|
|
9
|
+
expect(getLatestVersionFromAuditInfos({ auditInfos: [], currentVersion: 3 })).toBe(3);
|
|
10
|
+
expect(getLatestVersionFromAuditInfos({})).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns one less than the SUBMITTED count when there are submitted entries', () => {
|
|
14
|
+
const commDefinition = {
|
|
15
|
+
auditInfos: [
|
|
16
|
+
{ action: 'CREATED' },
|
|
17
|
+
{ action: 'SUBMITTED' },
|
|
18
|
+
{ action: 'APPROVED' },
|
|
19
|
+
{ action: 'SUBMITTED' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
expect(getLatestVersionFromAuditInfos(commDefinition)).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('mapCommDefinitionToStepData', () => {
|
|
27
|
+
it('returns null when there is no variant at all', () => {
|
|
28
|
+
expect(mapCommDefinitionToStepData({})).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns null when the variant has no recognized channel content', () => {
|
|
32
|
+
const commDefinition = {
|
|
33
|
+
id: 'cd_1',
|
|
34
|
+
singleChannelStrategy: { variant: { channel: 'SMS' } },
|
|
35
|
+
};
|
|
36
|
+
expect(mapCommDefinitionToStepData(commDefinition)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('maps an SMS CommDefinition (inverse of the SMS forward transform) into stepData', () => {
|
|
40
|
+
const commDefinition = {
|
|
41
|
+
id: 'cd_1',
|
|
42
|
+
referenceId: 'ref_1',
|
|
43
|
+
status: 'DRAFT',
|
|
44
|
+
singleChannelStrategy: {
|
|
45
|
+
variant: {
|
|
46
|
+
channel: 'SMS',
|
|
47
|
+
smsMessageContent: { message: 'Hello {{first_name}}', channel: 'SMS' },
|
|
48
|
+
smsDeliverySettings: { channelSettings: { senderId: 'ABC' } },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
54
|
+
|
|
55
|
+
expect(result).toMatchObject({
|
|
56
|
+
messageType: 'transactional',
|
|
57
|
+
communicationStrategy: 'SINGLE_TEMPLATE',
|
|
58
|
+
channel: 'SMS',
|
|
59
|
+
contentItems: [
|
|
60
|
+
{
|
|
61
|
+
contentId: 'cd_1',
|
|
62
|
+
channel: 'SMS',
|
|
63
|
+
templateData: { messageBody: 'Hello {{first_name}}', channel: 'SMS' },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
deliverySetting: { channelSetting: { SMS: { senderId: 'ABC' } } },
|
|
67
|
+
ccsCommDefinition: {
|
|
68
|
+
id: 'cd_1',
|
|
69
|
+
referenceId: 'ref_1',
|
|
70
|
+
status: 'DRAFT',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('maps a mobile push (PUSH) CommDefinition back to the MOBILEPUSH UI channel via CCS_CHANNEL_NAME_MAP', () => {
|
|
76
|
+
const commDefinition = {
|
|
77
|
+
id: 'cd_2',
|
|
78
|
+
singleChannelStrategy: {
|
|
79
|
+
variant: {
|
|
80
|
+
channel: 'PUSH',
|
|
81
|
+
mpushMessageContent: {
|
|
82
|
+
channel: 'PUSH',
|
|
83
|
+
androidContent: { title: 'Hi', message: 'There' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
90
|
+
|
|
91
|
+
expect(result.channel).toBe('MOBILEPUSH');
|
|
92
|
+
expect(result.contentItems[0].channel).toBe('MOBILEPUSH');
|
|
93
|
+
// channel is stamped last so it always reads MOBILEPUSH, not the raw CCS "PUSH".
|
|
94
|
+
expect(result.contentItems[0].templateData.channel).toBe('MOBILEPUSH');
|
|
95
|
+
expect(result.contentItems[0].templateData.androidContent).toEqual({ title: 'Hi', message: 'There' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('maps an EMAIL CommDefinition using the dedicated reverse transform', () => {
|
|
99
|
+
const commDefinition = {
|
|
100
|
+
id: 'cd_3',
|
|
101
|
+
singleChannelStrategy: {
|
|
102
|
+
variant: {
|
|
103
|
+
channel: 'EMAIL',
|
|
104
|
+
emailMessageContent: { messageSubject: 'Subject', messageBody: '<p>Body</p>' },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
110
|
+
|
|
111
|
+
expect(result.contentItems[0].templateData).toEqual({
|
|
112
|
+
emailSubject: 'Subject',
|
|
113
|
+
emailBody: '<p>Body</p>',
|
|
114
|
+
channel: 'EMAIL',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reads the variant from commDefinition.version when top-level singleChannelStrategy is absent', () => {
|
|
119
|
+
const commDefinition = {
|
|
120
|
+
id: 'cd_4',
|
|
121
|
+
version: {
|
|
122
|
+
version: 2,
|
|
123
|
+
status: 'DRAFT',
|
|
124
|
+
singleChannelStrategy: {
|
|
125
|
+
variant: {
|
|
126
|
+
channel: 'SMS',
|
|
127
|
+
smsMessageContent: { message: 'From version' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
134
|
+
|
|
135
|
+
expect(result.contentItems[0].templateData.messageBody).toBe('From version');
|
|
136
|
+
expect(result.ccsCommDefinition.version).toBe(2);
|
|
137
|
+
expect(result.ccsCommDefinition.status).toBe('DRAFT');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('falls back to getLatestVersionFromAuditInfos when version.version is absent', () => {
|
|
141
|
+
const commDefinition = {
|
|
142
|
+
id: 'cd_5',
|
|
143
|
+
auditInfos: [{ action: 'SUBMITTED' }, { action: 'SUBMITTED' }],
|
|
144
|
+
singleChannelStrategy: {
|
|
145
|
+
variant: { channel: 'SMS', smsMessageContent: { message: 'Hi' } },
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
150
|
+
|
|
151
|
+
expect(result.ccsCommDefinition.version).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('maps additionalSettings into dynamicControls', () => {
|
|
155
|
+
const commDefinition = {
|
|
156
|
+
id: 'cd_6',
|
|
157
|
+
version: {
|
|
158
|
+
settings: {
|
|
159
|
+
additionalSettings: {
|
|
160
|
+
useTinyUrl: true,
|
|
161
|
+
encryptUrl: true,
|
|
162
|
+
linkTrackingEnabled: true,
|
|
163
|
+
userSubscriptionDisabled: true,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
singleChannelStrategy: {
|
|
168
|
+
variant: { channel: 'SMS', smsMessageContent: { message: 'Hi' } },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
173
|
+
|
|
174
|
+
expect(result.dynamicControls).toEqual({
|
|
175
|
+
useTinyUrl: true,
|
|
176
|
+
sendToControlCustomers: true,
|
|
177
|
+
overrideDailyLimit: true,
|
|
178
|
+
sendToBrandPocs: true,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('passes through content for a channel with no dedicated reverse transform (e.g. RCS)', () => {
|
|
183
|
+
const commDefinition = {
|
|
184
|
+
id: 'cd_7',
|
|
185
|
+
singleChannelStrategy: {
|
|
186
|
+
variant: {
|
|
187
|
+
channel: 'RCS',
|
|
188
|
+
rcsMessageContent: {
|
|
189
|
+
channel: 'RCS',
|
|
190
|
+
accountId: 123,
|
|
191
|
+
rcsRichCardContent: { cardContent: [{ title: 'Card' }] },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
198
|
+
|
|
199
|
+
expect(result.contentItems[0].templateData).toEqual({
|
|
200
|
+
accountId: 123,
|
|
201
|
+
rcsRichCardContent: { cardContent: [{ title: 'Card' }] },
|
|
202
|
+
channel: 'RCS',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('fetchAndMapCommDefinition', () => {
|
|
208
|
+
it('returns null when getCommDefinition resolves with no data', async () => {
|
|
209
|
+
const getCommDefinitionMock = jest.fn().mockResolvedValue({ response: {} });
|
|
210
|
+
const getCommDefinitionVersionMock = jest.fn();
|
|
211
|
+
|
|
212
|
+
const result = await fetchAndMapCommDefinition('cd_1', {
|
|
213
|
+
getCommDefinition: getCommDefinitionMock,
|
|
214
|
+
getCommDefinitionVersion: getCommDefinitionVersionMock,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result).toBeNull();
|
|
218
|
+
expect(getCommDefinitionMock).toHaveBeenCalledWith('cd_1', 'versions');
|
|
219
|
+
expect(getCommDefinitionVersionMock).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('fetches metadata + latest version, merges them, and maps the result', async () => {
|
|
223
|
+
const getCommDefinitionMock = jest.fn().mockResolvedValue({
|
|
224
|
+
response: {
|
|
225
|
+
data: {
|
|
226
|
+
id: 'cd_1',
|
|
227
|
+
referenceId: 'ref_1',
|
|
228
|
+
status: 'DRAFT',
|
|
229
|
+
auditInfos: [],
|
|
230
|
+
currentVersion: 0,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
const getCommDefinitionVersionMock = jest.fn().mockResolvedValue({
|
|
235
|
+
response: {
|
|
236
|
+
data: {
|
|
237
|
+
singleChannelStrategy: {
|
|
238
|
+
variant: { channel: 'SMS', smsMessageContent: { message: 'Hello' } },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await fetchAndMapCommDefinition('cd_1', {
|
|
245
|
+
getCommDefinition: getCommDefinitionMock,
|
|
246
|
+
getCommDefinitionVersion: getCommDefinitionVersionMock,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(getCommDefinitionVersionMock).toHaveBeenCalledWith('cd_1', 0);
|
|
250
|
+
expect(result.contentItems[0].templateData.messageBody).toBe('Hello');
|
|
251
|
+
expect(result.ccsCommDefinition.id).toBe('cd_1');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('swallows a version-fetch failure and still returns metadata-only mapping (null, since there is no content)', async () => {
|
|
255
|
+
const getCommDefinitionMock = jest.fn().mockResolvedValue({
|
|
256
|
+
response: { data: { id: 'cd_1', auditInfos: [] } },
|
|
257
|
+
});
|
|
258
|
+
const getCommDefinitionVersionMock = jest.fn().mockRejectedValue(new Error('network error'));
|
|
259
|
+
|
|
260
|
+
const result = await fetchAndMapCommDefinition('cd_1', {
|
|
261
|
+
getCommDefinition: getCommDefinitionMock,
|
|
262
|
+
getCommDefinitionVersion: getCommDefinitionVersionMock,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// No content ever arrived (version fetch failed), so there's nothing mappable.
|
|
266
|
+
expect(result).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-maps a CCS CommDefinition back into CommunicationFlow's own `stepData` shape.
|
|
3
|
+
*
|
|
4
|
+
* This is the general-purpose counterpart of cap-campaigns-v2's own
|
|
5
|
+
* mapCommDefinitionToCommunicationFlowData (app/containers/CapNotifySettings/utils.js) — that
|
|
6
|
+
* version is CapNotify/Alert-specific and lives in the consumer app. Other consumers of
|
|
7
|
+
* CommunicationFlow ("Pluggable Modules") only store a commDefinitionId, with no local content
|
|
8
|
+
* copy, so CommunicationFlow needs to be able to fetch + reverse-map a CommDefinition itself
|
|
9
|
+
* (see fetchAndMapCommDefinition below, wired into CommunicationFlow.js via
|
|
10
|
+
* config.context.existingCommDefinitionId).
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
CCS_CHANNEL_CONTENT_KEY_MAP,
|
|
14
|
+
CCS_CHANNEL_DELIVERY_KEY_MAP,
|
|
15
|
+
CCS_CHANNEL_NAME_MAP,
|
|
16
|
+
} from '../constants';
|
|
17
|
+
|
|
18
|
+
// Reverse of CCS_CHANNEL_NAME_MAP (UI channel -> CCS channel); only PUSH -> MOBILEPUSH differs today.
|
|
19
|
+
const CCS_TO_UI_CHANNEL_MAP = Object.entries(CCS_CHANNEL_NAME_MAP).reduce(
|
|
20
|
+
(acc, [uiChannel, ccsChannel]) => ({ ...acc, [ccsChannel]: uiChannel }),
|
|
21
|
+
{},
|
|
22
|
+
);
|
|
23
|
+
const reverseUiChannel = (ccsChannel) => CCS_TO_UI_CHANNEL_MAP[ccsChannel] || ccsChannel;
|
|
24
|
+
|
|
25
|
+
// Inverse of CCS_CONTENT_TRANSFORMS (./constants.js) — only channels with a dedicated forward
|
|
26
|
+
// transform there need one here; every other channel's CCS content already matches the legacy
|
|
27
|
+
// editor's templateData shape and is passed through unchanged (see the default branch below).
|
|
28
|
+
const CCS_CONTENT_REVERSE_TRANSFORMS = {
|
|
29
|
+
EMAIL: (payload = {}) => ({
|
|
30
|
+
emailSubject: payload.messageSubject,
|
|
31
|
+
emailBody: payload.messageBody,
|
|
32
|
+
}),
|
|
33
|
+
SMS: (payload = {}) => ({
|
|
34
|
+
messageBody: payload.message || '',
|
|
35
|
+
}),
|
|
36
|
+
WEBPUSH: (payload = {}) => ({
|
|
37
|
+
messageContent: {
|
|
38
|
+
content: {
|
|
39
|
+
messageSubject: payload.messageSubject,
|
|
40
|
+
accountId: payload.accountId,
|
|
41
|
+
content: payload.content,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getCommDefinitionVariant = (commDefinition = {}) => (
|
|
48
|
+
commDefinition.singleChannelStrategy?.variant
|
|
49
|
+
|| commDefinition.version?.singleChannelStrategy?.variant
|
|
50
|
+
|| null
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Counts SUBMITTED audit entries to derive the latest version number when the CommDefinition
|
|
54
|
+
// itself doesn't carry an explicit current version (mirrors cap-campaigns-v2's own helper).
|
|
55
|
+
export const getLatestVersionFromAuditInfos = (commDefinition = {}) => {
|
|
56
|
+
const submittedCount = (commDefinition.auditInfos || []).filter(
|
|
57
|
+
(entry) => entry?.action === 'SUBMITTED',
|
|
58
|
+
).length;
|
|
59
|
+
if (submittedCount === 0) return commDefinition.currentVersion ?? 0;
|
|
60
|
+
return submittedCount - 1;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Reverse of CommunicationFlow's own additionalSettings mapping (see CommunicationFlow.js's
|
|
64
|
+
// handleSave) — used to pre-populate the Advanced Controls step when editing existing content.
|
|
65
|
+
const mapAdditionalSettingsToDynamicControls = (additionalSettings = {}) => ({
|
|
66
|
+
useTinyUrl: additionalSettings.useTinyUrl ?? false,
|
|
67
|
+
sendToControlCustomers: additionalSettings.encryptUrl ?? false,
|
|
68
|
+
overrideDailyLimit: additionalSettings.linkTrackingEnabled ?? false,
|
|
69
|
+
sendToBrandPocs: additionalSettings.userSubscriptionDisabled ?? false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Maps a CCS CommDefinition (metadata + version content merged onto it — see
|
|
74
|
+
* fetchAndMapCommDefinition) into the subset of `stepData` CommunicationFlow needs to re-open
|
|
75
|
+
* its "Add content" editor with existing content, instead of starting empty. Returns null when
|
|
76
|
+
* there's no mappable channel/content (e.g. the version fetch failed or came back empty).
|
|
77
|
+
*/
|
|
78
|
+
export const mapCommDefinitionToStepData = (commDefinition = {}) => {
|
|
79
|
+
const variant = getCommDefinitionVariant(commDefinition);
|
|
80
|
+
const ccsChannel = variant?.channel;
|
|
81
|
+
if (!variant || !ccsChannel) return null;
|
|
82
|
+
|
|
83
|
+
const contentKey = CCS_CHANNEL_CONTENT_KEY_MAP[ccsChannel];
|
|
84
|
+
const rawContent = contentKey ? variant[contentKey] : null;
|
|
85
|
+
if (!rawContent) return null;
|
|
86
|
+
|
|
87
|
+
const uiChannel = reverseUiChannel(ccsChannel);
|
|
88
|
+
const reverseTransform = CCS_CONTENT_REVERSE_TRANSFORMS[ccsChannel];
|
|
89
|
+
// Spread channel after content: CCS content may carry its own raw `channel` ("PUSH"), which
|
|
90
|
+
// would otherwise overwrite `uiChannel` ("MOBILEPUSH") and break template rendering.
|
|
91
|
+
const templateData = reverseTransform
|
|
92
|
+
? { ...reverseTransform(rawContent), channel: uiChannel }
|
|
93
|
+
: { ...rawContent, channel: uiChannel };
|
|
94
|
+
|
|
95
|
+
const deliveryKey = CCS_CHANNEL_DELIVERY_KEY_MAP[ccsChannel];
|
|
96
|
+
const channelSettings = deliveryKey ? variant[deliveryKey]?.channelSettings : undefined;
|
|
97
|
+
|
|
98
|
+
const version = commDefinition.version || {};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
// CCS create/edit only supports the SINGLE strategy today (see CommunicationFlow.js's
|
|
102
|
+
// handleSave, which skips CHANNEL_PRIORITY/AB_TEST) — a fetched CommDefinition is always one.
|
|
103
|
+
messageType: 'transactional',
|
|
104
|
+
communicationStrategy: 'SINGLE_TEMPLATE',
|
|
105
|
+
channel: uiChannel,
|
|
106
|
+
channels: [],
|
|
107
|
+
// contentId must be truthy — ChannelSelectionStep matches contentItems by it to resolve
|
|
108
|
+
// editingContentId; a falsy id collapses the reopen back to an empty template picker.
|
|
109
|
+
contentItems: [
|
|
110
|
+
{ contentId: commDefinition.id, channel: uiChannel, templateData },
|
|
111
|
+
],
|
|
112
|
+
deliverySetting: channelSettings
|
|
113
|
+
? { channelSetting: { [uiChannel]: channelSettings } }
|
|
114
|
+
: undefined,
|
|
115
|
+
dynamicControls: mapAdditionalSettingsToDynamicControls(
|
|
116
|
+
version.settings?.additionalSettings || commDefinition.settings?.additionalSettings,
|
|
117
|
+
),
|
|
118
|
+
ccsCommDefinition: {
|
|
119
|
+
id: commDefinition.id,
|
|
120
|
+
referenceId: commDefinition.referenceId,
|
|
121
|
+
version: version.version ?? getLatestVersionFromAuditInfos(commDefinition),
|
|
122
|
+
status: version.status ?? commDefinition.status,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetches a CommDefinition's metadata + latest version content (same two calls, same merge, as
|
|
129
|
+
* cap-campaigns-v2's getCapNotifyAlertById saga) and reverse-maps the result via
|
|
130
|
+
* mapCommDefinitionToStepData. Returns null if the CommDefinition can't be found; content-fetch
|
|
131
|
+
* failures are swallowed (matching the saga) so the caller still gets back whatever metadata it
|
|
132
|
+
* could — the "Add content" editor just won't have pre-filled content in that case.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} commDefinitionId
|
|
135
|
+
* @param {{ getCommDefinition: Function, getCommDefinitionVersion: Function }} api - the two
|
|
136
|
+
* service functions (app/services/api.js), injected so this stays testable without mocking fetch.
|
|
137
|
+
*/
|
|
138
|
+
export const fetchAndMapCommDefinition = async (commDefinitionId, { getCommDefinition, getCommDefinitionVersion }) => {
|
|
139
|
+
const response = await getCommDefinition(commDefinitionId, 'versions');
|
|
140
|
+
const commDefinition = response?.response?.data;
|
|
141
|
+
if (!commDefinition) return null;
|
|
142
|
+
|
|
143
|
+
const latestVersion = getLatestVersionFromAuditInfos(commDefinition);
|
|
144
|
+
try {
|
|
145
|
+
const versionResponse = await getCommDefinitionVersion(commDefinitionId, latestVersion);
|
|
146
|
+
const versionData = versionResponse?.response?.data;
|
|
147
|
+
if (versionData) {
|
|
148
|
+
commDefinition.version = { ...commDefinition.version, ...versionData };
|
|
149
|
+
}
|
|
150
|
+
} catch (versionError) {
|
|
151
|
+
// swallow — content just won't be available; caller still gets the CommDefinition's metadata.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return mapCommDefinitionToStepData(commDefinition);
|
|
155
|
+
};
|
|
@@ -79,7 +79,7 @@ describe('MobilePush/Edit — onContentValidityChange reporting', () => {
|
|
|
79
79
|
);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('treats content as
|
|
82
|
+
it('treats content as present when the iOS tab (tabCount 2) is cleared but Android has content (single-device template allowed)', () => {
|
|
83
83
|
const onContentValidityChange = jest.fn();
|
|
84
84
|
const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
85
85
|
wrapper.setState({
|
|
@@ -90,6 +90,22 @@ describe('MobilePush/Edit — onContentValidityChange reporting', () => {
|
|
|
90
90
|
tabCount: 2,
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
94
|
+
expect.objectContaining({ isContentEmpty: false }),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('treats content as empty when both Android and iOS tabs (tabCount 2) are cleared', () => {
|
|
99
|
+
const onContentValidityChange = jest.fn();
|
|
100
|
+
const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
101
|
+
wrapper.setState({
|
|
102
|
+
formData: {
|
|
103
|
+
0: { 'message-title': '', 'message-editor': '' },
|
|
104
|
+
1: { 'message-title2': '', 'message-editor2': '' },
|
|
105
|
+
},
|
|
106
|
+
tabCount: 2,
|
|
107
|
+
});
|
|
108
|
+
|
|
93
109
|
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
94
110
|
expect.objectContaining({ isContentEmpty: true }),
|
|
95
111
|
);
|
|
@@ -309,24 +309,23 @@ function getMobilePushTabContent(tabIndex, tabData) {
|
|
|
309
309
|
* Live "is content empty" check for the legacy Mobile Push Edit/Create forms — the
|
|
310
310
|
* Mobile Push analogue of Sms's `getSmsEmbeddedFooterValidity` (see
|
|
311
311
|
* app/v2Containers/Sms/smsFormDataHelpers.js). Checks every active tab (Android, and
|
|
312
|
-
* iOS when tabCount > 1);
|
|
313
|
-
* overall content is
|
|
312
|
+
* iOS when tabCount > 1); Mobile Push allows saving with just one active platform
|
|
313
|
+
* filled in, so the overall content is empty only when EVERY active tab's
|
|
314
|
+
* title+message are both empty.
|
|
314
315
|
* @param {object} formData FormBuilder state (same shape as this.state.formData)
|
|
315
316
|
* @param {number} [tabCount] Total number of active tabs (1 = Android only, 2 = Android + iOS)
|
|
316
317
|
* @returns {{ isContentEmpty: boolean }}
|
|
317
318
|
*/
|
|
318
319
|
function getMobilePushEmbeddedContentValidity(formData, tabCount) {
|
|
319
320
|
const count = tabCount != null && tabCount > 1 ? tabCount : 1;
|
|
320
|
-
let isContentEmpty = false;
|
|
321
321
|
for (let i = 0; i < count; i++) {
|
|
322
322
|
const content = getMobilePushTabContent(i, formData?.[i]);
|
|
323
323
|
const trimmed = content != null && content !== '' ? String(content).trim() : '';
|
|
324
|
-
if (
|
|
325
|
-
isContentEmpty
|
|
326
|
-
break;
|
|
324
|
+
if (trimmed) {
|
|
325
|
+
return { isContentEmpty: false };
|
|
327
326
|
}
|
|
328
327
|
}
|
|
329
|
-
return { isContentEmpty };
|
|
328
|
+
return { isContentEmpty: true };
|
|
330
329
|
}
|
|
331
330
|
|
|
332
331
|
export {
|
|
@@ -1758,9 +1758,14 @@ export const MobilePushNew = ({
|
|
|
1758
1758
|
onPersonalizationTokensChange,
|
|
1759
1759
|
]);
|
|
1760
1760
|
|
|
1761
|
-
// Validation logic for template creation/update
|
|
1761
|
+
// Validation logic for template creation/update.
|
|
1762
1762
|
const isAndroidFieldsMissing = isPlatformFieldsMissing(isAndroidSupported, androidContent);
|
|
1763
1763
|
const isIosFieldsMissing = isPlatformFieldsMissing(isIosSupported, iosContent);
|
|
1764
|
+
const isMobilePushContentMissing = (
|
|
1765
|
+
(isAndroidSupported || isIosSupported)
|
|
1766
|
+
&& (!isAndroidSupported || isAndroidFieldsMissing)
|
|
1767
|
+
&& (!isIosSupported || isIosFieldsMissing)
|
|
1768
|
+
);
|
|
1764
1769
|
|
|
1765
1770
|
// Ref to dedupe reporting of content emptiness to the parent, so we only
|
|
1766
1771
|
// call onContentValidityChange when the computed value actually changes.
|
|
@@ -1770,19 +1775,10 @@ export const MobilePushNew = ({
|
|
|
1770
1775
|
// supported platforms) changes, mirroring the Save-button gating logic.
|
|
1771
1776
|
useEffect(() => {
|
|
1772
1777
|
if (typeof onContentValidityChange !== 'function') return;
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
}, [
|
|
1778
|
-
androidContent,
|
|
1779
|
-
iosContent,
|
|
1780
|
-
isAndroidSupported,
|
|
1781
|
-
isIosSupported,
|
|
1782
|
-
isAndroidFieldsMissing,
|
|
1783
|
-
isIosFieldsMissing,
|
|
1784
|
-
onContentValidityChange,
|
|
1785
|
-
]);
|
|
1778
|
+
if (lastReportedIsContentEmptyRef.current === isMobilePushContentMissing) return;
|
|
1779
|
+
lastReportedIsContentEmptyRef.current = isMobilePushContentMissing;
|
|
1780
|
+
onContentValidityChange({ isContentEmpty: isMobilePushContentMissing });
|
|
1781
|
+
}, [isMobilePushContentMissing, onContentValidityChange]);
|
|
1786
1782
|
|
|
1787
1783
|
// Add changeSourceRef for debounced sync
|
|
1788
1784
|
const changeSourceRef = useRef(null);
|
|
@@ -2201,28 +2197,24 @@ export const MobilePushNew = ({
|
|
|
2201
2197
|
const errorInTitle = activeTab === ANDROID ? androidTitleError : iosTitleError;
|
|
2202
2198
|
const errorInMessage = activeTab === ANDROID ? androidMessageError : iosMessageError;
|
|
2203
2199
|
|
|
2204
|
-
// Save button disabled logic: only check enabled platforms
|
|
2200
|
+
// Save button disabled logic: only check enabled platforms, and allow
|
|
2201
|
+
// saving with just one supported platform filled in (see isMobilePushContentMissing).
|
|
2205
2202
|
const isSaveDisabled = (
|
|
2206
|
-
|
|
2207
|
-
|| (isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim()))
|
|
2203
|
+
isMobilePushContentMissing
|
|
2208
2204
|
|| templateNameError
|
|
2209
2205
|
|| Object.values(carouselLinkErrors).some((error) => error !== null && error !== "")
|
|
2210
2206
|
|| !isCarouselDataValid()
|
|
2211
2207
|
|| errorInTitle || errorInMessage
|
|
2212
2208
|
);
|
|
2213
2209
|
|
|
2214
|
-
// Validation in handleSave: only
|
|
2210
|
+
// Validation in handleSave: only enforce content on enabled platforms, and
|
|
2211
|
+
// allow saving with just one supported platform filled in.
|
|
2215
2212
|
const handleSave = useCallback(() => {
|
|
2216
|
-
if (
|
|
2213
|
+
if (isMobilePushContentMissing) {
|
|
2217
2214
|
CapNotification.error({
|
|
2218
|
-
message: formatMessage(
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2223
|
-
if (isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim())) {
|
|
2224
|
-
CapNotification.error({
|
|
2225
|
-
message: formatMessage(messages.iosValidationError),
|
|
2215
|
+
message: formatMessage(
|
|
2216
|
+
isAndroidSupported ? messages.androidValidationError : messages.iosValidationError,
|
|
2217
|
+
),
|
|
2226
2218
|
});
|
|
2227
2219
|
if (onValidationFail) onValidationFail();
|
|
2228
2220
|
return;
|
|
@@ -2583,8 +2575,7 @@ export const MobilePushNew = ({
|
|
|
2583
2575
|
createTimeoutRef,
|
|
2584
2576
|
isAndroidSupported,
|
|
2585
2577
|
isIosSupported,
|
|
2586
|
-
|
|
2587
|
-
isIosFieldsMissing,
|
|
2578
|
+
isMobilePushContentMissing,
|
|
2588
2579
|
isCarouselDataValid,
|
|
2589
2580
|
isFullMode,
|
|
2590
2581
|
templateId,
|
|
@@ -459,12 +459,21 @@ describe("utils.js", () => {
|
|
|
459
459
|
})).toBe(true);
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
-
it("returns
|
|
462
|
+
it("returns false when both platforms are supported and only one has valid fields (single-device template allowed)", () => {
|
|
463
463
|
expect(computeIsContentEmpty({
|
|
464
464
|
isAndroidSupported: true,
|
|
465
465
|
isIosSupported: true,
|
|
466
466
|
androidContent: { title: "t", message: "m" },
|
|
467
467
|
iosContent: { title: "", message: "" },
|
|
468
|
+
})).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("returns true when both platforms are supported and both are missing fields", () => {
|
|
472
|
+
expect(computeIsContentEmpty({
|
|
473
|
+
isAndroidSupported: true,
|
|
474
|
+
isIosSupported: true,
|
|
475
|
+
androidContent: { title: "", message: "" },
|
|
476
|
+
iosContent: { title: "", message: "" },
|
|
468
477
|
})).toBe(true);
|
|
469
478
|
});
|
|
470
479
|
|
|
@@ -97,8 +97,10 @@ export const isPlatformFieldsMissing = (isSupported, content) => (
|
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
99
|
* Compute whether the Mobile Push content is empty/invalid for reporting to
|
|
100
|
-
* a parent component (e.g. via onContentValidityChange).
|
|
101
|
-
*
|
|
100
|
+
* a parent component (e.g. via onContentValidityChange). Mobile Push allows
|
|
101
|
+
* saving a template with just one supported platform filled in, so content
|
|
102
|
+
* is only considered empty when EVERY supported platform is missing its
|
|
103
|
+
* required fields.
|
|
102
104
|
* @param {Object} params
|
|
103
105
|
* @param {boolean} params.isAndroidSupported
|
|
104
106
|
* @param {boolean} params.isIosSupported
|
|
@@ -111,7 +113,9 @@ export const computeIsContentEmpty = ({
|
|
|
111
113
|
isIosSupported,
|
|
112
114
|
androidContent,
|
|
113
115
|
iosContent,
|
|
114
|
-
}) =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
116
|
+
}) => {
|
|
117
|
+
if (!isAndroidSupported && !isIosSupported) return false;
|
|
118
|
+
const isAndroidMissing = isPlatformFieldsMissing(isAndroidSupported, androidContent);
|
|
119
|
+
const isIosMissing = isPlatformFieldsMissing(isIosSupported, iosContent);
|
|
120
|
+
return (!isAndroidSupported || isAndroidMissing) && (!isIosSupported || isIosMissing);
|
|
121
|
+
};
|