@capillarytech/creatives-library 9.0.14-beta.0 → 9.0.14

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 (83) hide show
  1. package/constants/unified.js +0 -3
  2. package/package.json +1 -1
  3. package/services/api.js +10 -0
  4. package/services/tests/api.test.js +83 -0
  5. package/utils/common.js +0 -8
  6. package/v2Components/CommonTestAndPreview/UnifiedPreview/WhatsAppPreviewContent.js +5 -3
  7. package/v2Components/CommonTestAndPreview/index.js +7 -0
  8. package/v2Components/FormBuilder/_formBuilder.scss +0 -5
  9. package/v2Components/FormBuilder/index.js +4479 -41
  10. package/v2Components/NavigationBar/index.js +27 -0
  11. package/v2Components/NavigationBar/messages.js +4 -0
  12. package/v2Components/NavigationBar/tests/index.test.js +19 -0
  13. package/v2Components/NewCallTask/index.js +6 -1
  14. package/v2Components/TemplatePreview/index.js +4 -2
  15. package/v2Containers/Cap/index.js +3 -1
  16. package/v2Containers/CommunicationFlow/CommunicationFlow.js +130 -20
  17. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +154 -0
  18. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +240 -0
  19. package/v2Containers/CommunicationFlow/DemoPage.js +47 -0
  20. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +369 -2
  21. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +619 -0
  22. package/v2Containers/CommunicationFlow/Tests/DemoPage.test.js +77 -0
  23. package/v2Containers/CommunicationFlow/Tests/getContentBody.test.js +933 -0
  24. package/v2Containers/CommunicationFlow/constants.js +45 -10
  25. package/v2Containers/CommunicationFlow/index.js +5 -2
  26. package/v2Containers/CommunicationFlow/messages.js +20 -0
  27. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +94 -31
  28. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +14 -11
  29. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +1144 -32
  30. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/extractContentForPreview.js +183 -0
  31. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/CommunicationStrategyStep.js +3 -0
  32. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +39 -0
  33. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +6 -2
  34. package/v2Containers/CommunicationFlow/utils/getContentBody.js +369 -0
  35. package/v2Containers/CommunicationFlow/utils/getContentBody.scss +19 -0
  36. package/v2Containers/CommunicationFlow/utils/getEnabledSteps.js +1 -1
  37. package/v2Containers/CreativesContainer/constants.js +6 -0
  38. package/v2Containers/CreativesContainer/index.js +68 -1
  39. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxContent.test.js.snap +2 -2
  40. package/v2Containers/Templates/index.js +2 -2
  41. package/v2Containers/TemplatesV2/index.js +9 -1
  42. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +41 -34
  43. package/v2Components/FormBuilder/Classic.js +0 -4487
  44. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +0 -371
  45. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -17
  46. package/v2Components/FormBuilder/Functional/channels/sms/buildSubmitPayload.js +0 -9
  47. package/v2Components/FormBuilder/Functional/channels/sms/config.js +0 -30
  48. package/v2Components/FormBuilder/Functional/channels/sms/getEditorErrorDescriptor.js +0 -46
  49. package/v2Components/FormBuilder/Functional/channels/sms/getLiquidContent.js +0 -13
  50. package/v2Components/FormBuilder/Functional/channels/sms/index.js +0 -22
  51. package/v2Components/FormBuilder/Functional/channels/sms/tests/getEditorErrorDescriptor.test.js +0 -52
  52. package/v2Components/FormBuilder/Functional/channels/sms/tests/getLiquidContent.test.js +0 -25
  53. package/v2Components/FormBuilder/Functional/channels/sms/tests/validate.test.js +0 -87
  54. package/v2Components/FormBuilder/Functional/channels/sms/validate.js +0 -89
  55. package/v2Components/FormBuilder/Functional/constants.js +0 -42
  56. package/v2Components/FormBuilder/Functional/core/schema/fieldRegistry.js +0 -38
  57. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +0 -85
  58. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +0 -81
  59. package/v2Components/FormBuilder/Functional/core/store/selectors.js +0 -30
  60. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +0 -91
  61. package/v2Components/FormBuilder/Functional/index.js +0 -26
  62. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +0 -59
  63. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +0 -31
  64. package/v2Components/FormBuilder/Functional/layout/Section.js +0 -116
  65. package/v2Components/FormBuilder/Functional/renderers/smsRenderers.js +0 -258
  66. package/v2Components/FormBuilder/Functional/tests/channelRegistry.test.js +0 -21
  67. package/v2Components/FormBuilder/Functional/tests/fieldRegistry.test.js +0 -65
  68. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +0 -97
  69. package/v2Components/FormBuilder/Functional/tests/fixtures/smsParityCases.js +0 -192
  70. package/v2Components/FormBuilder/Functional/tests/formReducer.test.js +0 -129
  71. package/v2Components/FormBuilder/Functional/tests/initializeFormState.test.js +0 -132
  72. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -40
  73. package/v2Components/FormBuilder/Functional/tests/section.test.js +0 -99
  74. package/v2Components/FormBuilder/Functional/tests/selectors.test.js +0 -67
  75. package/v2Components/FormBuilder/Functional/tests/sms.crossFlowParity.test.js +0 -155
  76. package/v2Components/FormBuilder/Functional/tests/sms.liquid.test.js +0 -172
  77. package/v2Components/FormBuilder/Functional/tests/sms.rollout.test.js +0 -122
  78. package/v2Components/FormBuilder/Functional/tests/sms.shell.parity.test.js +0 -329
  79. package/v2Components/FormBuilder/Functional/tests/smsRenderers.test.js +0 -160
  80. package/v2Components/FormBuilder/Functional/tests/toLegacyFormData.test.js +0 -95
  81. package/v2Components/FormBuilder/tests/__snapshots__/sms.characterization.test.js.snap +0 -114
  82. package/v2Components/FormBuilder/tests/entryGate.test.js +0 -106
  83. package/v2Components/FormBuilder/tests/sms.characterization.test.js +0 -336
@@ -0,0 +1,240 @@
1
+ /**
2
+ * CommunicationFlowCard
3
+ *
4
+ * Card-based entry point for CommunicationFlow.
5
+ * - Empty state: shows an "Add" button that opens CommunicationFlow in a CapSlideBox (create mode)
6
+ * - Configured state: shows a two-column CapCard with pen icon, channel preview (left), dynamic controls (right)
7
+ */
8
+
9
+ import React, { useState, useCallback } from 'react';
10
+ import PropTypes from 'prop-types';
11
+ import { injectIntl } from 'react-intl';
12
+ import { CAP_G09 } from '@capillarytech/cap-ui-library/styled/variables';
13
+ import CapButton from '@capillarytech/cap-ui-library/CapButton';
14
+ import CapCard from '@capillarytech/cap-ui-library/CapCard';
15
+ import CapColumn from '@capillarytech/cap-ui-library/CapColumn';
16
+ import CapImage from '@capillarytech/cap-ui-library/CapImage';
17
+ import CapRow from '@capillarytech/cap-ui-library/CapRow';
18
+ import CapIcon from '@capillarytech/cap-ui-library/CapIcon';
19
+ import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
20
+ import CapHeader from '@capillarytech/cap-ui-library/CapHeader';
21
+ import CapSlideBox from '@capillarytech/cap-ui-library/CapSlideBox';
22
+ import CommunicationFlow from './index';
23
+ import { CHANNELS, DEFAULT_COMMUNICATION_STRATEGY_OPTIONS, DYNAMIC_CONTROLS_CONFIG } from './constants';
24
+ import {
25
+ VIBER, SMS, EMAIL, WHATSAPP, RCS, ZALO,
26
+ } from '../CreativesContainer/constants';
27
+ import getContentBody from './utils/getContentBody';
28
+ import messages from './messages';
29
+ import addCreativesIllustration from '../Assets/images/addCreativesIllustration.svg';
30
+ import './CommunicationFlow.scss';
31
+
32
+ const resolveValue = (val) => {
33
+ if (val == null || val === '') return null;
34
+ if (Array.isArray(val)) return resolveValue(val[0]);
35
+ if (typeof val === 'object') {
36
+ const inner = val.value ?? val.label;
37
+ return inner != null ? resolveValue(inner) : null;
38
+ }
39
+ return String(val) || null;
40
+ };
41
+
42
+ const SENDER_ID_RESOLVERS = {
43
+ [SMS]: (setting) => resolveValue(setting?.gsmSenderId),
44
+ [EMAIL]: (setting) => resolveValue(setting?.senderEmail),
45
+ [VIBER]: (setting) => resolveValue(setting?.sender) || resolveValue(setting?.gsmSenderId),
46
+ [WHATSAPP]: (setting) => resolveValue(setting?.senderMobNum),
47
+ [RCS]: (setting) => resolveValue(setting?.senderMobNum) || resolveValue(setting?.rcsSender),
48
+ [ZALO]: (setting) => resolveValue(setting?.zaloSenderId),
49
+ };
50
+
51
+ const getSenderIdValue = (item, savedData) => {
52
+ const channel = item?.channel?.toUpperCase();
53
+ const channelSetting = savedData?.deliverySetting?.channelSetting?.[channel] || {};
54
+ return SENDER_ID_RESOLVERS[channel]?.(channelSetting) ?? null;
55
+ };
56
+
57
+ const getStrategyLabel = (config, strategyValue) => {
58
+ const options = config?.features?.communicationStrategyData?.options || DEFAULT_COMMUNICATION_STRATEGY_OPTIONS;
59
+ const match = options.find((option) => option.value === strategyValue);
60
+ return match?.label || strategyValue || '';
61
+ };
62
+
63
+ const getMessageTypeLabel = (config, messageTypeValue) => {
64
+ const options = config?.features?.messageTypeData?.options || [];
65
+ const match = options.find((option) => option.value === messageTypeValue);
66
+ return match?.label || messageTypeValue || null;
67
+ };
68
+
69
+ const CommunicationFlowCard = ({
70
+ config,
71
+ initialData,
72
+ onSave,
73
+ onCancel,
74
+ onChange,
75
+ cap,
76
+ intl,
77
+ }) => {
78
+ const { formatMessage } = intl || {};
79
+ const [showSlideBox, setShowSlideBox] = useState(false);
80
+ const [savedData, setSavedData] = useState(initialData || null);
81
+
82
+ const handleSave = useCallback((data) => {
83
+ setSavedData(data);
84
+ setShowSlideBox(false);
85
+ onSave?.(data);
86
+ }, [onSave]);
87
+
88
+ const handleClose = useCallback(() => {
89
+ setShowSlideBox(false);
90
+ onCancel?.();
91
+ }, [onCancel]);
92
+
93
+ const handleOpen = useCallback(() => {
94
+ setShowSlideBox(true);
95
+ }, []);
96
+
97
+ const firstItem = savedData?.contentItems?.[0];
98
+ const channelConfig = firstItem
99
+ ? CHANNELS.find((c) => c.value === firstItem.channel || c.channelProp === firstItem.channel?.toLowerCase())
100
+ : null;
101
+
102
+ const renderSummaryCard = () => {
103
+ if (!firstItem) return null;
104
+
105
+ const strategyLabel = getStrategyLabel(config, savedData?.communicationStrategy);
106
+ const messageTypeLabel = getMessageTypeLabel(config, savedData?.messageType);
107
+ const senderIdValue = getSenderIdValue(firstItem, savedData);
108
+ const controls = config?.features?.dynamicControlsData?.controls || DYNAMIC_CONTROLS_CONFIG;
109
+ const dynamicControlKeys = Object.keys(savedData?.dynamicControls || {});
110
+ const channel = firstItem.channel?.toUpperCase();
111
+ const senderLabel = [WHATSAPP, RCS].includes(channel)
112
+ ? formatMessage(messages.senderNumberLabel)
113
+ : formatMessage(messages.senderIdLabel);
114
+
115
+ return (
116
+ <CapCard
117
+ className="communication-flow-summary-card"
118
+ title={(
119
+ <CapRow type="flex" align="middle" className="summary-card-title-row">
120
+ <CapLabel type="label8">Message:</CapLabel>
121
+ <CapLabel type="label2">{strategyLabel}</CapLabel>
122
+ {messageTypeLabel && (
123
+ <CapLabel type="label1">({messageTypeLabel})</CapLabel>
124
+ )}
125
+ </CapRow>
126
+ )}
127
+ extra={(
128
+ <CapIcon
129
+ type="edit"
130
+ onClick={handleOpen}
131
+ style={{ cursor: 'pointer' }}
132
+ />
133
+ )}
134
+ headStyle={{ background: CAP_G09, width: '100%' }}
135
+ bodyStyle={{ padding: 0 }}
136
+ >
137
+ <CapRow gutter={0} className="card-body-row">
138
+ <CapColumn span={12} className="card-body-left-col">
139
+ <CapRow type="flex" align="middle" className="channel-header-row">
140
+ <CapIcon type={channelConfig?.iconType || 'sms'} size="s" />
141
+ <CapLabel type="label8">{channelConfig?.label || firstItem.channel}</CapLabel>
142
+ {senderIdValue && (
143
+ <CapLabel type="label1">({senderLabel}: {senderIdValue})</CapLabel>
144
+ )}
145
+ </CapRow>
146
+ {getContentBody(firstItem)}
147
+ </CapColumn>
148
+ <CapColumn span={12} className="card-body-right-col">
149
+ <CapLabel type="label8">{formatMessage(messages.dynamicControlsTitle)}</CapLabel>
150
+ {dynamicControlKeys.map((key) => {
151
+ const controlConfig = controls.find((c) => c.key === key);
152
+ if (!controlConfig) return null;
153
+ return (
154
+ <CapRow key={key} type="flex" justify="space-between" className="control-row">
155
+ <CapLabel type="label2">{controlConfig.label}</CapLabel>
156
+ <CapLabel type="label2">
157
+ {savedData.dynamicControls[key]
158
+ ? formatMessage(messages.yes)
159
+ : formatMessage(messages.no)}
160
+ </CapLabel>
161
+ </CapRow>
162
+ );
163
+ })}
164
+ </CapColumn>
165
+ </CapRow>
166
+ </CapCard>
167
+ );
168
+ };
169
+
170
+ const flowMode = savedData ? 'edit' : 'create';
171
+ const flowConfig = { ...config, mode: flowMode };
172
+
173
+ return (
174
+ <>
175
+ {!firstItem ? (
176
+ <CapCard
177
+ className="communication-flow-card-empty"
178
+ bodyStyle={{ padding: 0, height: 152 }}
179
+ >
180
+ <CapRow type="flex" align="middle" gutter={0} className="empty-card-body-row">
181
+ <CapColumn span={10} className="empty-card-illustration-col">
182
+ <CapImage src={addCreativesIllustration} />
183
+ </CapColumn>
184
+ <CapColumn span={14} className="empty-card-action-col">
185
+ <CapButton type="secondary" onClick={handleOpen}>
186
+ {formatMessage(messages.addCreatives)}
187
+ </CapButton>
188
+ </CapColumn>
189
+ </CapRow>
190
+ </CapCard>
191
+ ) : (
192
+ renderSummaryCard()
193
+ )}
194
+
195
+ <CapRow className="slide-box-wrapper">
196
+ <CapSlideBox
197
+ show={showSlideBox}
198
+ handleClose={handleClose}
199
+ size="size-xl"
200
+ header={(
201
+ <CapHeader
202
+ title={formatMessage(messages.addMessage)}
203
+ handleClose={handleClose}
204
+ />
205
+ )}
206
+ content={(
207
+ <CommunicationFlow
208
+ config={flowConfig}
209
+ initialData={savedData}
210
+ onSave={handleSave}
211
+ onCancel={handleClose}
212
+ onChange={onChange}
213
+ cap={cap}
214
+ />
215
+ )}
216
+ />
217
+ </CapRow>
218
+ </>
219
+ );
220
+ };
221
+
222
+ CommunicationFlowCard.propTypes = {
223
+ config: PropTypes.object.isRequired,
224
+ initialData: PropTypes.object,
225
+ onSave: PropTypes.func,
226
+ onCancel: PropTypes.func,
227
+ onChange: PropTypes.func,
228
+ cap: PropTypes.object,
229
+ intl: PropTypes.object.isRequired,
230
+ };
231
+
232
+ CommunicationFlowCard.defaultProps = {
233
+ initialData: null,
234
+ onSave: null,
235
+ onCancel: null,
236
+ onChange: null,
237
+ cap: null,
238
+ };
239
+
240
+ export default injectIntl(CommunicationFlowCard);
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { CommunicationFlowCard } from './index';
3
+
4
+ const CommunicationFlowDemoPage = () => {
5
+ const config = {
6
+ consumer: 'PROGRAM',
7
+ mode: 'create',
8
+ useCCS: true,
9
+ features: {
10
+ messageTypeData: {
11
+ required: false,
12
+ },
13
+ communicationStrategyData: {
14
+ required: true,
15
+ disabled: false,
16
+ },
17
+ contentTemplateData: {
18
+ required: true,
19
+ channelsToHide: [],
20
+ channelsToDisable: [],
21
+ },
22
+ incentivesData: {
23
+ required: false,
24
+ },
25
+ deliverySettingsData: {
26
+ required: false,
27
+ deliverySettings: [{}],
28
+ },
29
+ showDynamicControls: true,
30
+ },
31
+ context: {
32
+ orgUnitId: '1231',
33
+ campaignId: '456789',
34
+ },
35
+ };
36
+
37
+ const handleSave = (data) => {
38
+ // eslint-disable-next-line no-console
39
+ console.log('Save called with data:', data);
40
+ };
41
+
42
+ return (
43
+ <CommunicationFlowCard config={config} onSave={handleSave} />
44
+ );
45
+ };
46
+
47
+ export default CommunicationFlowDemoPage;
@@ -1,5 +1,10 @@
1
1
  import React from 'react';
2
2
 
3
+ jest.mock('../../../services/api', () => ({
4
+ createCentralCommsMetaId: jest.fn(),
5
+ getCentralCommsMetaIds: jest.fn(),
6
+ }));
7
+
3
8
  jest.mock('../../CreativesContainer', () => function MockCreativesContainer({
4
9
  getCreativesData,
5
10
  handleCloseCreatives,
@@ -33,6 +38,7 @@ import { IntlProvider } from 'react-intl';
33
38
  import history from '../../../utils/history';
34
39
  import { initialReducer } from '../../../initialReducer';
35
40
  import CommunicationFlow from '../CommunicationFlow';
41
+ import { createCentralCommsMetaId, getCentralCommsMetaIds } from '../../../services/api';
36
42
  import { getEnabledSteps } from '../utils/getEnabledSteps';
37
43
  import {
38
44
  CHANNELS,
@@ -87,7 +93,8 @@ const FEATURES = {
87
93
  strategyContentDynamic: {
88
94
  communicationStrategyData: { required: true, options: STRATEGY_OPTIONS, disabled: false },
89
95
  contentTemplateData: { required: true, channels: CHANNELS },
90
- dynamicControlsData: { required: true, controls: DYNAMIC_CONTROLS },
96
+ showDynamicControls: true,
97
+ dynamicControlsData: { controls: DYNAMIC_CONTROLS },
91
98
  },
92
99
  };
93
100
 
@@ -212,7 +219,7 @@ describe('CommunicationFlow', () => {
212
219
 
213
220
  await selectCommunicationStrategy('Single template');
214
221
 
215
- await userEvent.click(screen.getByRole('button', { name: /content template/i }));
222
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
216
223
  await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
217
224
  await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
218
225
 
@@ -257,4 +264,364 @@ describe('CommunicationFlow', () => {
257
264
  });
258
265
  expect(screen.getByRole('radio', { name: /transactional/i })).toBeChecked();
259
266
  });
267
+
268
+ it('initializes messageType from messageTypeData.defaultOption.value when initialData absent', () => {
269
+ renderWithFlow({
270
+ features: {
271
+ messageTypeData: {
272
+ required: true,
273
+ options: MESSAGE_OPTIONS,
274
+ defaultOption: { value: 'transactional', label: 'Transactional' },
275
+ },
276
+ },
277
+ });
278
+ expect(screen.getByRole('radio', { name: /transactional/i })).toBeChecked();
279
+ });
280
+ });
281
+
282
+ describe('isSaveDisabled', () => {
283
+ it('is false with empty features — Save button is enabled', () => {
284
+ renderWithFlow({ features: {} });
285
+ expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
286
+ });
287
+
288
+ it('is true when communicationStrategy required and not yet selected', () => {
289
+ renderWithFlow({ features: FEATURES.strategyContent });
290
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled();
291
+ });
292
+
293
+ it('is true when channel selection required, single-channel, and contentItems empty', () => {
294
+ renderWithFlow({
295
+ features: FEATURES.strategyContent,
296
+ initialData: { communicationStrategy: SINGLE_TEMPLATE, contentItems: [] },
297
+ });
298
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled();
299
+ });
300
+
301
+ it('is false for multi-channel strategy even without content items', () => {
302
+ renderWithFlow({
303
+ features: FEATURES.strategyContent,
304
+ initialData: { communicationStrategy: CHANNEL_PRIORITY, contentItems: [] },
305
+ });
306
+ expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
307
+ });
308
+
309
+ it('is true when delivery settings enabled and a channel is missing sender details', () => {
310
+ renderWithFlow({
311
+ features: {
312
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
313
+ contentTemplateData: { required: true, channels: CHANNELS },
314
+ deliverySettingsData: {},
315
+ },
316
+ initialData: {
317
+ communicationStrategy: SINGLE_TEMPLATE,
318
+ contentItems: [{ channel: 'SMS', templateData: {} }],
319
+ deliverySetting: {},
320
+ },
321
+ });
322
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled();
323
+ });
324
+
325
+ it('is false when delivery settings enabled and all channels have sender details', () => {
326
+ renderWithFlow({
327
+ features: {
328
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
329
+ contentTemplateData: { required: true, channels: CHANNELS },
330
+ deliverySettingsData: {},
331
+ },
332
+ initialData: {
333
+ communicationStrategy: SINGLE_TEMPLATE,
334
+ contentItems: [{ channel: 'SMS', templateData: {} }],
335
+ deliverySetting: { channelSetting: { SMS: { gsmSenderId: 'CAPS_SENDER' } } },
336
+ },
337
+ });
338
+ expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
339
+ });
340
+
341
+ it('skips delivery check for CHANNELS_WITHOUT_DELIVERY (e.g. MOBILEPUSH)', () => {
342
+ renderWithFlow({
343
+ features: {
344
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
345
+ contentTemplateData: { required: true, channels: CHANNELS },
346
+ deliverySettingsData: {},
347
+ },
348
+ initialData: {
349
+ communicationStrategy: SINGLE_TEMPLATE,
350
+ contentItems: [{ channel: 'MOBILEPUSH', templateData: {} }],
351
+ deliverySetting: {},
352
+ },
353
+ });
354
+ expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
355
+ });
356
+ });
357
+
358
+ describe('handleSave — CCS flow', () => {
359
+ beforeEach(() => {
360
+ createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'meta-123' } } });
361
+ getCentralCommsMetaIds.mockResolvedValue({ response: { data: {} } });
362
+ });
363
+
364
+ afterEach(() => {
365
+ jest.clearAllMocks();
366
+ });
367
+
368
+ it('calls createCentralCommsMetaId for each content item when useCCS is not false', async () => {
369
+ const onSave = jest.fn();
370
+ renderWithFlow({
371
+ features: {},
372
+ initialData: {
373
+ contentItems: [{ channel: 'sms', templateData: { smsBody: 'Hello' } }],
374
+ },
375
+ onSave,
376
+ });
377
+
378
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
379
+
380
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalledTimes(1));
381
+ expect(createCentralCommsMetaId).toHaveBeenCalledWith(
382
+ expect.objectContaining({
383
+ centralCommsPayload: expect.objectContaining({ channel: 'SMS', module: 'CAMPAIGNS' }),
384
+ }),
385
+ );
386
+ expect(onSave).toHaveBeenCalledTimes(1);
387
+ });
388
+
389
+ it('calls getCentralCommsMetaIds when metaIds are returned from createCentralCommsMetaId', async () => {
390
+ renderWithFlow({
391
+ features: {},
392
+ initialData: { contentItems: [{ channel: 'EMAIL', templateData: {} }] },
393
+ });
394
+
395
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
396
+
397
+ await waitFor(() => expect(getCentralCommsMetaIds).toHaveBeenCalledWith('meta-123'));
398
+ });
399
+
400
+ it('skips getCentralCommsMetaIds when response contains no id', async () => {
401
+ createCentralCommsMetaId.mockResolvedValue({ response: { data: {} } });
402
+ renderWithFlow({
403
+ features: {},
404
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
405
+ });
406
+
407
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
408
+
409
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
410
+ expect(getCentralCommsMetaIds).not.toHaveBeenCalled();
411
+ });
412
+
413
+ it('uses ouId and module from config.context when provided', async () => {
414
+ renderWithFlow({
415
+ features: {},
416
+ config: { ...baseConfig, context: { ouId: 42, module: 'LOYALTY' }, features: {} },
417
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
418
+ });
419
+
420
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
421
+
422
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
423
+ expect(createCentralCommsMetaId).toHaveBeenCalledWith(
424
+ expect.objectContaining({
425
+ centralCommsPayload: expect.objectContaining({ ouId: 42, module: 'LOYALTY' }),
426
+ }),
427
+ );
428
+ });
429
+
430
+ it('skips CCS entirely when useCCS is false', async () => {
431
+ const onSave = jest.fn();
432
+ renderWithFlow({
433
+ features: {},
434
+ config: { ...baseConfig, useCCS: false, features: {} },
435
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
436
+ onSave,
437
+ });
438
+
439
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
440
+
441
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
442
+ expect(createCentralCommsMetaId).not.toHaveBeenCalled();
443
+ });
444
+
445
+ it('still calls onSave when createCentralCommsMetaId rejects', async () => {
446
+ createCentralCommsMetaId.mockRejectedValue(new Error('Network error'));
447
+ const onSave = jest.fn();
448
+ renderWithFlow({
449
+ features: {},
450
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
451
+ onSave,
452
+ });
453
+
454
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
455
+
456
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
457
+ });
458
+
459
+ it('skips createCentralCommsMetaId when contentItems is empty', async () => {
460
+ const onSave = jest.fn();
461
+ renderWithFlow({
462
+ features: {},
463
+ initialData: { contentItems: [] },
464
+ onSave,
465
+ });
466
+
467
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
468
+
469
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
470
+ expect(createCentralCommsMetaId).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it('includes additionalSettings derived from dynamicControls in the payload', async () => {
474
+ renderWithFlow({
475
+ features: {},
476
+ initialData: {
477
+ contentItems: [{ channel: 'SMS', templateData: {} }],
478
+ dynamicControls: {
479
+ useTinyUrl: true,
480
+ sendToControlCustomers: true,
481
+ overrideDailyLimit: true,
482
+ sendToBrandPocs: true,
483
+ },
484
+ },
485
+ });
486
+
487
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
488
+
489
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
490
+ const payload = createCentralCommsMetaId.mock.calls[0][0];
491
+ expect(payload.centralCommsPayload.smsDeliverySettings.additionalSettings).toEqual({
492
+ useTinyUrl: true,
493
+ encryptUrl: true,
494
+ linkTrackingEnabled: true,
495
+ userSubscriptionDisabled: true,
496
+ });
497
+ });
498
+ });
499
+
500
+ describe('renderSteps — null returns and edge cases', () => {
501
+ it('returns null for CHANNEL_SELECTION when communicationStrategy is null', () => {
502
+ renderWithFlow({
503
+ features: {
504
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
505
+ contentTemplateData: { required: true, channels: CHANNELS },
506
+ },
507
+ });
508
+ expect(screen.queryByText(/content template/i)).not.toBeInTheDocument();
509
+ });
510
+
511
+ it('returns null for DYNAMIC_CONTROLS when communicationStrategy is null', () => {
512
+ renderWithFlow({ features: FEATURES.strategyContentDynamic });
513
+ expect(screen.queryByText(/other controls/i)).not.toBeInTheDocument();
514
+ });
515
+
516
+ it('does not render the Save footer when onSave is not provided', () => {
517
+ renderFlow(
518
+ <CommunicationFlow
519
+ config={baseConfig}
520
+ onCancel={jest.fn()}
521
+ capData={{}}
522
+ />,
523
+ );
524
+ expect(screen.queryByRole('button', { name: /^save$/i })).not.toBeInTheDocument();
525
+ });
526
+
527
+ it('passes cap prop to ChannelSelectionStep over capData when both provided', async () => {
528
+ const capOverride = { orgId: 999 };
529
+ renderWithFlow({
530
+ features: FEATURES.strategyContent,
531
+ initialData: { communicationStrategy: SINGLE_TEMPLATE },
532
+ cap: capOverride,
533
+ });
534
+ // Renders without crash and channel step is visible after strategy selection
535
+ expect(screen.queryByText(/communication strategy/i)).toBeInTheDocument();
536
+ });
537
+
538
+ it('renders DYNAMIC_CONTROLS step after communicationStrategy is selected', async () => {
539
+ renderWithFlow({ features: FEATURES.strategyContentDynamic });
540
+ await selectCommunicationStrategy('Single template');
541
+ await waitFor(() => {
542
+ expect(screen.getByText(/other controls/i)).toBeInTheDocument();
543
+ });
544
+ });
545
+ });
546
+
547
+ describe('optional chaining safety', () => {
548
+ afterEach(() => {
549
+ jest.clearAllMocks();
550
+ });
551
+
552
+ it('defaults ouId to -1 when config.context is absent', async () => {
553
+ createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
554
+ renderWithFlow({
555
+ features: {},
556
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
557
+ });
558
+
559
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
560
+
561
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
562
+ expect(createCentralCommsMetaId).toHaveBeenCalledWith(
563
+ expect.objectContaining({
564
+ centralCommsPayload: expect.objectContaining({ ouId: -1 }),
565
+ }),
566
+ );
567
+ });
568
+
569
+ it('defaults module to consumer.toUpperCase() when config.context.module absent', async () => {
570
+ createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
571
+ renderWithFlow({
572
+ features: {},
573
+ config: { ...baseConfig, consumer: 'loyalty', features: {} },
574
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
575
+ });
576
+
577
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
578
+
579
+ await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
580
+ expect(createCentralCommsMetaId).toHaveBeenCalledWith(
581
+ expect.objectContaining({
582
+ centralCommsPayload: expect.objectContaining({ module: 'LOYALTY' }),
583
+ }),
584
+ );
585
+ });
586
+
587
+ it('initializes channel from config.channel when initialData.channel is absent', () => {
588
+ renderWithFlow({
589
+ features: {},
590
+ config: { ...baseConfig, channel: 'EMAIL', features: {} },
591
+ initialData: {},
592
+ });
593
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
594
+ });
595
+
596
+ it('renders without crash when config.features is absent', () => {
597
+ renderWithFlow({ config: { ...baseConfig, features: undefined } });
598
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
599
+ });
600
+
601
+ it('handles onChange being null without crashing on step data change', async () => {
602
+ renderWithFlow({
603
+ features: FEATURES.messageType,
604
+ onChange: null,
605
+ });
606
+ await expect(
607
+ userEvent.click(screen.getByRole('radio', { name: /transactional/i })),
608
+ ).resolves.not.toThrow();
609
+ });
610
+
611
+ it('handles deliverySetting?.channelSetting absent without crashing in isSaveDisabled', () => {
612
+ renderWithFlow({
613
+ features: {
614
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
615
+ contentTemplateData: { required: true, channels: CHANNELS },
616
+ deliverySettingsData: {},
617
+ },
618
+ initialData: {
619
+ communicationStrategy: SINGLE_TEMPLATE,
620
+ contentItems: [{ channel: 'SMS', templateData: {} }],
621
+ deliverySetting: null,
622
+ },
623
+ });
624
+ // Missing deliverySetting → channelSetting defaults to {} → SMS missing → disabled
625
+ expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled();
626
+ });
260
627
  });