@capillarytech/creatives-library 9.0.52 → 9.0.53-alpha.1

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.
@@ -63,7 +63,6 @@ export const ENABLE_AI_SUGGESTIONS = 'ENABLE_AI_SUGGESTIONS';
63
63
  export const AI_CONTENT_BOT_DISABLED = 'AI_CONTENT_BOT_DISABLED';
64
64
  export const AI_DOCUMENTATION_BOT_ENABLED = 'AI_DOCUMENTATION_BOT_ENABLED';
65
65
  export const ENABLE_PRODUCT_SUPPORT_VIDEOS = 'ENABLE_PRODUCT_SUPPORT_VIDEOS';
66
- export const SUPPORT_ENGAGEMENT_MODULE = 'SUPPORT_ENGAGEMENT_MODULE';
67
66
  export const EMBEDDED = 'embedded';
68
67
  // --- Tag/Validation Constants ---
69
68
  export const CARD_RELATED_TAGS = [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.52",
4
+ "version": "9.0.53-alpha.1",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -701,6 +701,15 @@ export const createCentralCommsMetaId = (payload, metaType = TRANSACTION) => {
701
701
  return request(url, getAPICallObject('POST', payload, false, false, false, true));
702
702
  };
703
703
 
704
+ // CCS CommDefinition API (new Veyron /api/v1/commdefinition surface, proxied
705
+ // through cap-creatives-api's /comm-definitions). Used by CommunicationFlow's
706
+ // own Save action — separate from the legacy messageMeta functions above,
707
+ // which remain in use by CreativesContainer's own save flow (Cap/sagas.js).
708
+ export const createCommDefinition = (payload) => {
709
+ const url = `${API_ENDPOINT}/comm-definitions`;
710
+ return request(url, getAPICallObject('POST', payload, false, false, false, true));
711
+ };
712
+
704
713
  export const getCentralCommsMetaIds = (metaIds, metaType = TRANSACTION) => {
705
714
  const url = `${API_ENDPOINT}/common/central-comms/meta-id/${metaType}?metaIds=${metaIds}`;
706
715
  return request(url, getAPICallObject('GET', null, false, false, false, true));
package/utils/common.js CHANGED
@@ -99,10 +99,6 @@ export const hasSupportCKEditor = Auth.hasFeatureAccess.bind(
99
99
  null,
100
100
  SUPPORT_CK_EDITOR,
101
101
  );
102
- export const hasSupportEngagementModule = Auth.hasFeatureAccess.bind(
103
- null,
104
- SUPPORT_ENGAGEMENT_MODULE,
105
- );
106
102
 
107
103
  export const hasGiftVoucherFeature = Auth.hasFeatureAccess.bind(
108
104
  null,
@@ -17,10 +17,11 @@ import { createStructuredSelector } from 'reselect';
17
17
  import CapRow from '@capillarytech/cap-ui-library/CapRow';
18
18
  import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
19
19
  import CapButton from '@capillarytech/cap-ui-library/CapButton';
20
+ import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
20
21
  // import injectSaga from '../../utils/injectSaga'; // cap-coupons flows disabled
21
22
  // import injectReducer from '../../utils/injectReducer';
22
23
  import { makeSelectAuthenticated } from '../Cap/selectors';
23
- import { createCentralCommsMetaId, getCentralCommsMetaIds } from '../../services/api';
24
+ import { createCommDefinition } from '../../services/api';
24
25
  import DynamicControlsStep from './steps/DynamicControlsStep';
25
26
  import MessageTypeStep from './steps/MessageTypeStep';
26
27
  import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
@@ -35,9 +36,10 @@ import {
35
36
  CHANNELS,
36
37
  INCENTIVE_TYPES,
37
38
  DYNAMIC_CONTROLS_CONFIG,
38
- CHANNEL_CONTENT_KEY_MAP,
39
- CHANNEL_DELIVERY_KEY_MAP,
40
- CAMPAIGNS,
39
+ CCS_STRATEGY_TYPE_SINGLE,
40
+ CCS_CHANNEL_CONTENT_KEY_MAP,
41
+ CCS_CHANNEL_DELIVERY_KEY_MAP,
42
+ CCS_CHANNEL_NAME_MAP,
41
43
  } from './constants';
42
44
  import { getEnabledSteps } from './utils/getEnabledSteps';
43
45
  import messages from './messages';
@@ -64,6 +66,25 @@ const hasDeliverySettingForChannel = (channelSetting, channel) => {
64
66
  return !!settings && Object.values(settings).some((v) => v !== null && v !== '' && v !== undefined);
65
67
  };
66
68
 
69
+ // createCommDefinition resolves (rather than rejects) with the CCS error envelope
70
+ // for 4xx/5xx responses — see api.js's request()/checkStatus. A duplicate
71
+ // referenceId within the org comes back as this specific 409.
72
+ const isDuplicateReferenceIdError = (res) => res?.success === false && res?.status?.message === 'REFERENCE_ID_EXISTS';
73
+
74
+ /**
75
+ * CCS requires referenceId on create; the consumer-supplied Alert/comm name is
76
+ * mandatory but referenceId is optional in every consumer's own form. Generate
77
+ * a stable fallback from the name rather than failing the save.
78
+ */
79
+ const buildCcsReferenceId = (name) => {
80
+ const slug = (name || 'COMM')
81
+ .trim()
82
+ .toUpperCase()
83
+ .replace(/[^A-Z0-9]+/g, '_')
84
+ .replace(/^_+|_+$/g, '') || 'COMM';
85
+ return `${slug}_${Date.now()}`;
86
+ };
87
+
67
88
  const CommunicationFlow = ({
68
89
  config,
69
90
  initialData,
@@ -91,6 +112,7 @@ const CommunicationFlow = ({
91
112
  };
92
113
  });
93
114
  const [validationErrors, setValidationErrors] = useState({});
115
+ const [saveError, setSaveError] = useState(null);
94
116
 
95
117
  // Memoize enabled steps
96
118
  const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
@@ -150,74 +172,78 @@ const CommunicationFlow = ({
150
172
  }, []);
151
173
 
152
174
  const handleSave = useCallback(async () => {
175
+ setSaveError(null);
153
176
  const aggregatedData = getAggregatedData();
154
177
  const shouldUseCCS = config?.useCCS !== false;
178
+ let ccsCommDefinition = null;
155
179
 
156
180
  if (shouldUseCCS) {
157
- const ouId = config?.context?.ouId || -1;
158
- const module = config?.context?.module
159
- || (config?.consumer ? config.consumer.toUpperCase() : CAMPAIGNS);
181
+ const isMultiChannel = [CHANNEL_PRIORITY, AB_TEST].includes(aggregatedData.communicationStrategy);
182
+ const contentItem = (aggregatedData.contentItems || [])[0];
183
+ // Consumer-supplied name (e.g. CapNotify's Alert Name field) — CommunicationFlow
184
+ // has no name input of its own, so this is mandatory input from config.context.
185
+ const name = config?.context?.name;
160
186
 
161
- const channelContentKeyMap = CHANNEL_CONTENT_KEY_MAP;
162
- const channelDeliveryKeyMap = CHANNEL_DELIVERY_KEY_MAP;
187
+ // CCS create is SINGLE-strategy only this phase (D1); CHANNEL_PRIORITY/AB_TEST
188
+ // carry multiple content items with no CCS equivalent yet.
189
+ if (!isMultiChannel && contentItem && name) {
190
+ const rawChannel = (contentItem.channel || '').toUpperCase();
191
+ const channel = CCS_CHANNEL_NAME_MAP[rawChannel] || rawChannel;
192
+ const contentKey = CCS_CHANNEL_CONTENT_KEY_MAP[channel];
193
+ const deliveryKey = CCS_CHANNEL_DELIVERY_KEY_MAP[channel];
194
+ const { dynamicControls = {} } = aggregatedData;
195
+ const channelSettings = aggregatedData.deliverySetting?.channelSetting?.[channel] || {};
196
+ const referenceId = config?.context?.referenceId || buildCcsReferenceId(name);
163
197
 
164
- const contentItems = aggregatedData.contentItems || [];
165
- const { dynamicControls = {} } = aggregatedData;
198
+ const payload = {
199
+ referenceId,
200
+ name,
201
+ description: config?.context?.description || undefined,
202
+ strategyType: CCS_STRATEGY_TYPE_SINGLE,
203
+ settings: {
204
+ additionalSettings: {
205
+ useTinyUrl: dynamicControls.useTinyUrl ?? false,
206
+ encryptUrl: dynamicControls.sendToControlCustomers ?? false,
207
+ linkTrackingEnabled: dynamicControls.overrideDailyLimit ?? false,
208
+ userSubscriptionDisabled: dynamicControls.sendToBrandPocs ?? false,
209
+ },
210
+ executionParams: {},
211
+ },
212
+ singleChannelStrategy: {
213
+ variant: {
214
+ channel,
215
+ ...(contentKey && { [contentKey]: contentItem.templateData }),
216
+ ...(deliveryKey && { [deliveryKey]: { channelSettings: { channel, ...channelSettings } } }),
217
+ },
218
+ },
219
+ };
166
220
 
167
- const additionalSettings = {
168
- useTinyUrl: dynamicControls.useTinyUrl ?? false,
169
- encryptUrl: dynamicControls.sendToControlCustomers ?? false,
170
- linkTrackingEnabled: dynamicControls.overrideDailyLimit ?? false,
171
- userSubscriptionDisabled: dynamicControls.sendToBrandPocs ?? false,
172
- };
173
-
174
- if (contentItems.length > 0) {
175
221
  try {
176
- const responses = await Promise.all(
177
- contentItems.map((item) => {
178
- const channel = (item.channel || '').toUpperCase();
179
- const contentKey = channelContentKeyMap[channel];
180
- const deliveryKey = channelDeliveryKeyMap[channel];
181
- const payload = {
182
- centralCommsPayload: {
183
- ouId,
184
- channel,
185
- module,
186
- executionParams: {},
187
- clientName: 'EMF',
188
- ...(contentKey && {
189
- [contentKey]: { channel, ...item.templateData },
190
- }),
191
- ...(deliveryKey && {
192
- [deliveryKey]: {
193
- additionalSettings,
194
- channelSettings: {
195
- channel,
196
- ...(aggregatedData.deliverySetting?.channelSetting?.[channel] || {}),
197
- },
198
- },
199
- }),
200
- },
201
- };
202
- return createCentralCommsMetaId(payload);
203
- }),
204
- );
205
-
206
- const metaIds = responses
207
- .map((res) => res?.response?.data?.id)
208
- .filter(Boolean)
209
- .join(',');
210
-
211
- if (metaIds) {
212
- const getResponse = await getCentralCommsMetaIds(metaIds);
222
+ const res = await createCommDefinition(payload);
223
+ if (isDuplicateReferenceIdError(res)) {
224
+ // Duplicate referenceId in this org — block the save so the user can
225
+ // change it, rather than silently proceeding without a CCS comm.
226
+ setSaveError(formatMessage(messages.duplicateReferenceIdError));
227
+ return;
228
+ }
229
+ const data = res?.response?.data;
230
+ if (data?.id) {
231
+ ccsCommDefinition = {
232
+ id: data.id,
233
+ referenceId: data.referenceId,
234
+ version: data.version?.version || 1,
235
+ status: data.status,
236
+ };
213
237
  }
214
238
  } catch (error) {
215
- console.error('[CommunicationFlow] CCS createCentralCommsMetaId error:', error);
239
+ console.error('[CommunicationFlow] CCS createCommDefinition error:', error);
216
240
  }
241
+ } else if (!name) {
242
+ console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — config.context.name is required');
217
243
  }
218
244
  }
219
245
 
220
- onSave(aggregatedData);
246
+ onSave(ccsCommDefinition ? { ...aggregatedData, ccsCommDefinition } : aggregatedData);
221
247
  }, [getAggregatedData, config, onSave]);
222
248
 
223
249
  // Call onChange callback when stepData changes
@@ -317,6 +343,11 @@ const CommunicationFlow = ({
317
343
  {renderSteps()}
318
344
  {onSave && (
319
345
  <CapRow useLegacy className="communication-flow-container__footer">
346
+ {saveError && (
347
+ <CapLabel type="label2" className="communication-flow-container__save-error">
348
+ {saveError}
349
+ </CapLabel>
350
+ )}
320
351
  <CapButton type="primary" onClick={handleSave} disabled={isSaveDisabled}>
321
352
  {formatMessage(messages.save)}
322
353
  </CapButton>
@@ -19,6 +19,7 @@ import CapIcon from '@capillarytech/cap-ui-library/CapIcon';
19
19
  import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
20
20
  import CapHeader from '@capillarytech/cap-ui-library/CapHeader';
21
21
  import CapSlideBox from '@capillarytech/cap-ui-library/CapSlideBox';
22
+ import CapTooltip from '@capillarytech/cap-ui-library/CapTooltip';
22
23
  import CommunicationFlow from './index';
23
24
  import { CHANNELS, DEFAULT_COMMUNICATION_STRATEGY_OPTIONS, DYNAMIC_CONTROLS_CONFIG } from './constants';
24
25
  import {
@@ -74,6 +75,8 @@ const CommunicationFlowCard = ({
74
75
  onChange,
75
76
  cap,
76
77
  intl,
78
+ disabled,
79
+ disabledTooltip,
77
80
  }) => {
78
81
  const { formatMessage } = intl || {};
79
82
  const [showSlideBox, setShowSlideBox] = useState(false);
@@ -91,8 +94,9 @@ const CommunicationFlowCard = ({
91
94
  }, [onCancel]);
92
95
 
93
96
  const handleOpen = useCallback(() => {
97
+ if (disabled) return;
94
98
  setShowSlideBox(true);
95
- }, []);
99
+ }, [disabled]);
96
100
 
97
101
  const firstItem = savedData?.contentItems?.[0];
98
102
  const channelConfig = firstItem
@@ -182,9 +186,17 @@ const CommunicationFlowCard = ({
182
186
  <CapImage src={addCreativesIllustration} />
183
187
  </CapColumn>
184
188
  <CapColumn span={14} className="empty-card-action-col">
185
- <CapButton type="secondary" onClick={handleOpen}>
186
- {formatMessage(messages.addCreatives)}
187
- </CapButton>
189
+ {disabled && disabledTooltip ? (
190
+ <CapTooltip title={disabledTooltip}>
191
+ <CapButton type="secondary" disabled>
192
+ {formatMessage(messages.addCreatives)}
193
+ </CapButton>
194
+ </CapTooltip>
195
+ ) : (
196
+ <CapButton type="secondary" onClick={handleOpen} disabled={disabled}>
197
+ {formatMessage(messages.addCreatives)}
198
+ </CapButton>
199
+ )}
188
200
  </CapColumn>
189
201
  </CapRow>
190
202
  </CapCard>
@@ -227,6 +239,8 @@ CommunicationFlowCard.propTypes = {
227
239
  onChange: PropTypes.func,
228
240
  cap: PropTypes.object,
229
241
  intl: PropTypes.object.isRequired,
242
+ disabled: PropTypes.bool, // Disables the "Add creatives" trigger (e.g. until a consumer-required field is filled)
243
+ disabledTooltip: PropTypes.node, // Shown on hover when disabled is true
230
244
  };
231
245
 
232
246
  CommunicationFlowCard.defaultProps = {
@@ -235,6 +249,8 @@ CommunicationFlowCard.defaultProps = {
235
249
  onCancel: null,
236
250
  onChange: null,
237
251
  cap: null,
252
+ disabled: false,
253
+ disabledTooltip: null,
238
254
  };
239
255
 
240
256
  export default injectIntl(CommunicationFlowCard);
@@ -1,8 +1,7 @@
1
1
  import React from 'react';
2
2
 
3
3
  jest.mock('../../../services/api', () => ({
4
- createCentralCommsMetaId: jest.fn(),
5
- getCentralCommsMetaIds: jest.fn(),
4
+ createCommDefinition: jest.fn(),
6
5
  }));
7
6
 
8
7
  jest.mock('../../CreativesContainer', () => function MockCreativesContainer({
@@ -38,7 +37,7 @@ import { IntlProvider } from 'react-intl';
38
37
  import history from '../../../utils/history';
39
38
  import { initialReducer } from '../../../initialReducer';
40
39
  import CommunicationFlow from '../CommunicationFlow';
41
- import { createCentralCommsMetaId, getCentralCommsMetaIds } from '../../../services/api';
40
+ import { createCommDefinition } from '../../../services/api';
42
41
  import { getEnabledSteps } from '../utils/getEnabledSteps';
43
42
  import {
44
43
  CHANNELS,
@@ -359,74 +358,121 @@ describe('isSaveDisabled', () => {
359
358
  });
360
359
 
361
360
  describe('handleSave — CCS flow', () => {
361
+ const ccsConfig = { ...baseConfig, context: { name: 'Order Placed Notification' }, features: {} };
362
+
362
363
  beforeEach(() => {
363
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'meta-123' } } });
364
- getCentralCommsMetaIds.mockResolvedValue({ response: { data: {} } });
364
+ createCommDefinition.mockResolvedValue({
365
+ response: { data: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', status: 'DRAFT', version: { version: 1 } } },
366
+ });
365
367
  });
366
368
 
367
369
  afterEach(() => {
368
370
  jest.clearAllMocks();
369
371
  });
370
372
 
371
- it('calls createCentralCommsMetaId for each content item when useCCS is not false', async () => {
373
+ it('calls createCommDefinition with a SINGLE-strategy payload when useCCS is not false', async () => {
372
374
  const onSave = jest.fn();
373
375
  renderWithFlow({
374
376
  features: {},
377
+ config: ccsConfig,
375
378
  initialData: {
376
- contentItems: [{ channel: 'sms', templateData: { smsBody: 'Hello' } }],
379
+ contentItems: [{ channel: 'sms', templateData: { message: 'Hello' } }],
377
380
  },
378
381
  onSave,
379
382
  });
380
383
 
381
384
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
382
385
 
383
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalledTimes(1));
384
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
386
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
387
+ expect(createCommDefinition).toHaveBeenCalledWith(
385
388
  expect.objectContaining({
386
- centralCommsPayload: expect.objectContaining({ channel: 'SMS', module: 'CAMPAIGNS' }),
389
+ name: 'Order Placed Notification',
390
+ strategyType: 'SINGLE',
391
+ singleChannelStrategy: expect.objectContaining({
392
+ variant: expect.objectContaining({
393
+ channel: 'SMS',
394
+ smsMessageContent: { message: 'Hello' },
395
+ }),
396
+ }),
387
397
  }),
388
398
  );
389
399
  expect(onSave).toHaveBeenCalledTimes(1);
390
400
  });
391
401
 
392
- it('calls getCentralCommsMetaIds when metaIds are returned from createCentralCommsMetaId', async () => {
402
+ it('normalizes the internal MOBILEPUSH channel to CCS\'s MPUSH enum and key names', async () => {
403
+ const onSave = jest.fn();
393
404
  renderWithFlow({
394
405
  features: {},
395
- initialData: { contentItems: [{ channel: 'EMAIL', templateData: {} }] },
406
+ config: ccsConfig,
407
+ initialData: {
408
+ contentItems: [{ channel: 'MOBILEPUSH', templateData: { messageSubject: 'Hi' } }],
409
+ },
410
+ onSave,
396
411
  });
397
412
 
398
413
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
399
414
 
400
- await waitFor(() => expect(getCentralCommsMetaIds).toHaveBeenCalledWith('meta-123'));
415
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
416
+ expect(createCommDefinition).toHaveBeenCalledWith(
417
+ expect.objectContaining({
418
+ singleChannelStrategy: expect.objectContaining({
419
+ variant: expect.objectContaining({
420
+ channel: 'MPUSH',
421
+ mpushMessageContent: { messageSubject: 'Hi' },
422
+ }),
423
+ }),
424
+ }),
425
+ );
401
426
  });
402
427
 
403
- it('skips getCentralCommsMetaIds when response contains no id', async () => {
404
- createCentralCommsMetaId.mockResolvedValue({ response: { data: {} } });
428
+ it('merges ccsCommDefinition into the data passed to onSave when create succeeds', async () => {
429
+ const onSave = jest.fn();
405
430
  renderWithFlow({
406
431
  features: {},
432
+ config: ccsConfig,
407
433
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
434
+ onSave,
408
435
  });
409
436
 
410
437
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
411
438
 
412
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
413
- expect(getCentralCommsMetaIds).not.toHaveBeenCalled();
439
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
440
+ expect(onSave).toHaveBeenCalledWith(
441
+ expect.objectContaining({
442
+ ccsCommDefinition: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', version: 1, status: 'DRAFT' },
443
+ }),
444
+ );
414
445
  });
415
446
 
416
- it('uses ouId and module from config.context when provided', async () => {
447
+ it('generates a referenceId from name when config.context.referenceId is absent', async () => {
448
+ // moduleMocks.js spies on Date.now globally, but this config's resetMocks:true
449
+ // clears that return value before every test — set it explicitly here.
450
+ jest.spyOn(Date, 'now').mockReturnValue(1612267539410);
417
451
  renderWithFlow({
418
452
  features: {},
419
- config: { ...baseConfig, context: { ouId: 42, module: 'LOYALTY' }, features: {} },
453
+ config: ccsConfig,
420
454
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
421
455
  });
422
456
 
423
457
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
424
458
 
425
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
426
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
427
- expect.objectContaining({
428
- centralCommsPayload: expect.objectContaining({ ouId: 42, module: 'LOYALTY' }),
429
- }),
459
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
460
+ const payload = createCommDefinition.mock.calls[0][0];
461
+ expect(payload.referenceId).toBe('ORDER_PLACED_NOTIFICATION_1612267539410');
462
+ });
463
+
464
+ it('uses config.context.referenceId and description when provided', async () => {
465
+ renderWithFlow({
466
+ features: {},
467
+ config: { ...ccsConfig, context: { ...ccsConfig.context, referenceId: 'ORDER_PLACED', description: 'desc' } },
468
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
469
+ });
470
+
471
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
472
+
473
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
474
+ expect(createCommDefinition).toHaveBeenCalledWith(
475
+ expect.objectContaining({ referenceId: 'ORDER_PLACED', description: 'desc' }),
430
476
  );
431
477
  });
432
478
 
@@ -434,7 +480,7 @@ describe('handleSave — CCS flow', () => {
434
480
  const onSave = jest.fn();
435
481
  renderWithFlow({
436
482
  features: {},
437
- config: { ...baseConfig, useCCS: false, features: {} },
483
+ config: { ...ccsConfig, useCCS: false },
438
484
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
439
485
  onSave,
440
486
  });
@@ -442,14 +488,15 @@ describe('handleSave — CCS flow', () => {
442
488
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
443
489
 
444
490
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
445
- expect(createCentralCommsMetaId).not.toHaveBeenCalled();
491
+ expect(createCommDefinition).not.toHaveBeenCalled();
492
+ expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
446
493
  });
447
494
 
448
- it('still calls onSave when createCentralCommsMetaId rejects', async () => {
449
- createCentralCommsMetaId.mockRejectedValue(new Error('Network error'));
495
+ it('skips createCommDefinition when config.context.name is absent (mandatory)', async () => {
450
496
  const onSave = jest.fn();
451
497
  renderWithFlow({
452
498
  features: {},
499
+ config: { ...baseConfig, features: {} },
453
500
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
454
501
  onSave,
455
502
  });
@@ -457,12 +504,70 @@ describe('handleSave — CCS flow', () => {
457
504
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
458
505
 
459
506
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
507
+ expect(createCommDefinition).not.toHaveBeenCalled();
508
+ });
509
+
510
+ it('skips createCommDefinition for multi-channel strategies (CHANNEL_PRIORITY/AB_TEST)', async () => {
511
+ const onSave = jest.fn();
512
+ renderWithFlow({
513
+ features: {},
514
+ config: ccsConfig,
515
+ initialData: {
516
+ communicationStrategy: CHANNEL_PRIORITY,
517
+ channels: ['SMS', 'EMAIL'],
518
+ contentItems: [{ channel: 'SMS', templateData: {} }, { channel: 'EMAIL', templateData: {} }],
519
+ },
520
+ onSave,
521
+ });
522
+
523
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
524
+
525
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
526
+ expect(createCommDefinition).not.toHaveBeenCalled();
460
527
  });
461
528
 
462
- it('skips createCentralCommsMetaId when contentItems is empty', async () => {
529
+ it('still calls onSave (without ccsCommDefinition) when createCommDefinition rejects', async () => {
530
+ createCommDefinition.mockRejectedValue(new Error('Network error'));
463
531
  const onSave = jest.fn();
464
532
  renderWithFlow({
465
533
  features: {},
534
+ config: ccsConfig,
535
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
536
+ onSave,
537
+ });
538
+
539
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
540
+
541
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
542
+ expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
543
+ });
544
+
545
+ it('blocks save and shows an error when createCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
546
+ createCommDefinition.mockResolvedValue({
547
+ success: false,
548
+ status: { isError: true, code: 409, message: 'REFERENCE_ID_EXISTS' },
549
+ message: 'REFERENCE_ID_EXISTS',
550
+ });
551
+ const onSave = jest.fn();
552
+ renderWithFlow({
553
+ features: {},
554
+ config: ccsConfig,
555
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
556
+ onSave,
557
+ });
558
+
559
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
560
+
561
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
562
+ expect(await screen.findByText(/already in use in your organization/i)).toBeInTheDocument();
563
+ expect(onSave).not.toHaveBeenCalled();
564
+ });
565
+
566
+ it('skips createCommDefinition when contentItems is empty', async () => {
567
+ const onSave = jest.fn();
568
+ renderWithFlow({
569
+ features: {},
570
+ config: ccsConfig,
466
571
  initialData: { contentItems: [] },
467
572
  onSave,
468
573
  });
@@ -470,12 +575,13 @@ describe('handleSave — CCS flow', () => {
470
575
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
471
576
 
472
577
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
473
- expect(createCentralCommsMetaId).not.toHaveBeenCalled();
578
+ expect(createCommDefinition).not.toHaveBeenCalled();
474
579
  });
475
580
 
476
- it('includes additionalSettings derived from dynamicControls in the payload', async () => {
581
+ it('places additionalSettings derived from dynamicControls under settings.additionalSettings (not delivery settings)', async () => {
477
582
  renderWithFlow({
478
583
  features: {},
584
+ config: ccsConfig,
479
585
  initialData: {
480
586
  contentItems: [{ channel: 'SMS', templateData: {} }],
481
587
  dynamicControls: {
@@ -489,14 +595,36 @@ describe('handleSave — CCS flow', () => {
489
595
 
490
596
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
491
597
 
492
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
493
- const payload = createCentralCommsMetaId.mock.calls[0][0];
494
- expect(payload.centralCommsPayload.smsDeliverySettings.additionalSettings).toEqual({
598
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
599
+ const payload = createCommDefinition.mock.calls[0][0];
600
+ expect(payload.settings.additionalSettings).toEqual({
495
601
  useTinyUrl: true,
496
602
  encryptUrl: true,
497
603
  linkTrackingEnabled: true,
498
604
  userSubscriptionDisabled: true,
499
605
  });
606
+ expect(payload.singleChannelStrategy.variant.smsDeliverySettings.additionalSettings).toBeUndefined();
607
+ });
608
+
609
+ it('includes channelSettings from deliverySetting.channelSetting in the payload', async () => {
610
+ renderWithFlow({
611
+ features: {},
612
+ config: ccsConfig,
613
+ initialData: {
614
+ contentItems: [{ channel: 'SMS', templateData: {} }],
615
+ deliverySetting: { channelSetting: { SMS: { domainId: 1001, gsmSenderId: 'BRAND1' } } },
616
+ },
617
+ });
618
+
619
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
620
+
621
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
622
+ const payload = createCommDefinition.mock.calls[0][0];
623
+ expect(payload.singleChannelStrategy.variant.smsDeliverySettings.channelSettings).toEqual({
624
+ channel: 'SMS',
625
+ domainId: 1001,
626
+ gsmSenderId: 'BRAND1',
627
+ });
500
628
  });
501
629
  });
502
630
 
@@ -552,39 +680,34 @@ describe('optional chaining safety', () => {
552
680
  jest.clearAllMocks();
553
681
  });
554
682
 
555
- it('defaults ouId to -1 when config.context is absent', async () => {
556
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
683
+ it('does not crash and skips CCS when config.context is entirely absent', async () => {
684
+ const onSave = jest.fn();
557
685
  renderWithFlow({
558
686
  features: {},
687
+ config: { ...baseConfig, features: {} },
559
688
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
689
+ onSave,
560
690
  });
561
691
 
562
692
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
563
693
 
564
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
565
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
566
- expect.objectContaining({
567
- centralCommsPayload: expect.objectContaining({ ouId: -1 }),
568
- }),
569
- );
694
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
695
+ expect(createCommDefinition).not.toHaveBeenCalled();
570
696
  });
571
697
 
572
- it('defaults module to consumer.toUpperCase() when config.context.module absent', async () => {
573
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
698
+ it('does not crash when config.context is present but empty', async () => {
699
+ const onSave = jest.fn();
574
700
  renderWithFlow({
575
701
  features: {},
576
- config: { ...baseConfig, consumer: 'loyalty', features: {} },
702
+ config: { ...baseConfig, context: {}, features: {} },
577
703
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
704
+ onSave,
578
705
  });
579
706
 
580
707
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
581
708
 
582
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
583
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
584
- expect.objectContaining({
585
- centralCommsPayload: expect.objectContaining({ module: 'LOYALTY' }),
586
- }),
587
- );
709
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
710
+ expect(createCommDefinition).not.toHaveBeenCalled();
588
711
  });
589
712
 
590
713
  it('initializes channel from config.channel when initialData.channel is absent', () => {
@@ -108,6 +108,22 @@ describe('CommunicationFlowCard', () => {
108
108
  fireEvent.click(screen.getByText('Add creatives'));
109
109
  expect(screen.getByTestId('comm-flow-mock')).toHaveAttribute('data-mode', 'create');
110
110
  });
111
+
112
+ it('disables the Add creatives button when disabled prop is true', () => {
113
+ renderCard({ disabled: true, disabledTooltip: 'Enter an Alert name to add content' });
114
+ expect(screen.getByText('Add creatives').closest('button')).toBeDisabled();
115
+ });
116
+
117
+ it('does not open SlideBox when Add creatives is clicked while disabled', () => {
118
+ renderCard({ disabled: true, disabledTooltip: 'Enter an Alert name to add content' });
119
+ fireEvent.click(screen.getByText('Add creatives'));
120
+ expect(screen.queryByTestId('slide-box')).not.toBeInTheDocument();
121
+ });
122
+
123
+ it('Add creatives button is enabled by default (disabled prop absent)', () => {
124
+ renderCard();
125
+ expect(screen.getByText('Add creatives').closest('button')).not.toBeDisabled();
126
+ });
111
127
  });
112
128
 
113
129
  describe('configured state (initialData provided)', () => {
@@ -38,6 +38,53 @@ export const CHANNEL_PRIORITY = 'CHANNEL_PRIORITY';
38
38
  export const AB_TEST = 'AB_TEST';
39
39
  export const SINGLE_TEMPLATE = 'SINGLE_TEMPLATE';
40
40
 
41
+ // CCS CommDefinition strategyType — distinct from SINGLE_TEMPLATE above, which
42
+ // is this UI's own communicationStrategy value. Only SINGLE is supported this
43
+ // phase; CCS create is skipped entirely for CHANNEL_PRIORITY/AB_TEST.
44
+ export const CCS_STRATEGY_TYPE_SINGLE = 'SINGLE';
45
+
46
+ // Mirrors CCS's SingleChannelStrategy field names exactly (Cap Notify API
47
+ // Design doc sample payloads, verified against real create responses). Kept
48
+ // separate from CHANNEL_CONTENT_KEY_MAP/CHANNEL_DELIVERY_KEY_MAP below, which
49
+ // are the legacy messageMeta payload's keys.
50
+ export const CCS_CHANNEL_CONTENT_KEY_MAP = {
51
+ SMS: 'smsMessageContent',
52
+ EMAIL: 'emailMessageContent',
53
+ WHATSAPP: 'whatsappMessageContent',
54
+ MPUSH: 'mpushMessageContent',
55
+ INAPP: 'inAppMessageContent',
56
+ ANDROID: 'androidMessageContent',
57
+ IOS: 'iosMessageContent',
58
+ ZALO: 'zaloMessageContent',
59
+ VIBER: 'viberMessageContent',
60
+ LINE: 'lineMessageContent',
61
+ WEBPUSH: 'webPushMessageContent',
62
+ RCS: 'rcsMessageContent',
63
+ };
64
+
65
+ export const CCS_CHANNEL_DELIVERY_KEY_MAP = {
66
+ SMS: 'smsDeliverySettings',
67
+ EMAIL: 'emailDeliverySettings',
68
+ WHATSAPP: 'whatsappDeliverySettings',
69
+ MPUSH: 'mpushDeliverySettings',
70
+ INAPP: 'inAppDeliverySettings',
71
+ ANDROID: 'androidDeliverySettings',
72
+ IOS: 'iosDeliverySettings',
73
+ ZALO: 'zaloDeliverySettings',
74
+ VIBER: 'viberDeliverySettings',
75
+ LINE: 'lineDeliverySettings',
76
+ WEBPUSH: 'webPushDeliverySettings',
77
+ RCS: 'rcsDeliverySettings',
78
+ };
79
+
80
+ // The UI's own internal channel identifier for mobile push is 'MOBILEPUSH'
81
+ // (see CreativesContainer/constants.js MOBILE_PUSH), but CCS's channel enum
82
+ // only recognises 'MPUSH' (globals.js CCS_CHANNELS) — normalize before
83
+ // building the CCS payload. Every other channel identifier matches CCS as-is.
84
+ export const CCS_CHANNEL_NAME_MAP = {
85
+ MOBILEPUSH: 'MPUSH',
86
+ };
87
+
41
88
  // Channel config - single source of truth for CreativesContainer and TemplatesV2
42
89
  // paneKey: TemplatesV2 defaultPanes object key (for channelsToHide)
43
90
  // channelProp: CreativesContainer channel prop (must match pane.key for tab to be active)
@@ -9,7 +9,6 @@
9
9
  import React from 'react';
10
10
  import PropTypes from 'prop-types';
11
11
  import CommunicationFlow from './CommunicationFlow';
12
- import { hasSupportEngagementModule } from '../../utils/common';
13
12
 
14
13
  const CommunicationFlowContainer = ({
15
14
  config,
@@ -18,22 +17,16 @@ const CommunicationFlowContainer = ({
18
17
  onCancel,
19
18
  onChange,
20
19
  ...otherProps
21
- }) => {
22
- if (!hasSupportEngagementModule()) {
23
- return null;
24
- }
25
-
26
- return (
27
- <CommunicationFlow
28
- config={config}
29
- initialData={initialData}
30
- onSave={onSave}
31
- onCancel={onCancel}
32
- onChange={onChange}
33
- {...otherProps}
34
- />
35
- );
36
- };
20
+ }) => (
21
+ <CommunicationFlow
22
+ config={config}
23
+ initialData={initialData}
24
+ onSave={onSave}
25
+ onCancel={onCancel}
26
+ onChange={onChange}
27
+ {...otherProps}
28
+ />
29
+ );
37
30
 
38
31
  CommunicationFlowContainer.propTypes = {
39
32
  config: PropTypes.shape({
@@ -87,11 +80,13 @@ CommunicationFlowContainer.propTypes = {
87
80
  controls: PropTypes.array,
88
81
  }),
89
82
  }),
90
- context: PropTypes.object, // ouId, campaignId, programId, etc.
91
- useCCS: PropTypes.bool, // If false, skips CCS bulk-claim-approve on save. Defaults to true.
83
+ context: PropTypes.object, // ouId, campaignId, programId; name (required for CCS create),
84
+ // referenceId (optional, auto-generated from name if absent), description (optional).
85
+ useCCS: PropTypes.bool, // If false, skips the CCS createCommDefinition call on save. Defaults to true.
92
86
  }).isRequired,
93
87
  initialData: PropTypes.object, // for edit/preview mode
94
- onSave: PropTypes.func.isRequired, // (data) => void - called when user saves
88
+ onSave: PropTypes.func.isRequired, // (data) => void - called when user saves; data.ccsCommDefinition
89
+ // ({id, referenceId, version, status}) is present when the CCS create succeeded
95
90
  onCancel: PropTypes.func.isRequired, // () => void - called when user cancels
96
91
  onChange: PropTypes.func, // (data) => void - optional, called on data changes
97
92
  };
@@ -363,4 +363,8 @@ export default {
363
363
  id: `${prefix}.senderNotConfiguredError`,
364
364
  defaultMessage: 'Selected domain gateway id is not correct. Please change the domain id or contact the gateway team to register them with Capillary.',
365
365
  },
366
+ duplicateReferenceIdError: {
367
+ id: `${prefix}.duplicateReferenceIdError`,
368
+ defaultMessage: 'This reference ID is already in use in your organization. Please use a different reference ID.',
369
+ },
366
370
  };