@capillarytech/creatives-library 8.0.317 → 8.0.319

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 (38) hide show
  1. package/constants/unified.js +1 -0
  2. package/package.json +1 -1
  3. package/services/api.js +6 -0
  4. package/services/tests/api.test.js +7 -0
  5. package/utils/common.js +6 -1
  6. package/v2Containers/CommunicationFlow/CommunicationFlow.js +291 -0
  7. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +25 -0
  8. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +255 -0
  9. package/v2Containers/CommunicationFlow/constants.js +200 -0
  10. package/v2Containers/CommunicationFlow/index.js +102 -0
  11. package/v2Containers/CommunicationFlow/messages.js +346 -0
  12. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +522 -0
  13. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +170 -0
  14. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +796 -0
  15. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/index.js +5 -0
  16. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/CommunicationStrategyStep.js +95 -0
  17. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/Tests/CommunicationStrategyStep.test.js +133 -0
  18. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/index.js +5 -0
  19. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +289 -0
  20. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.scss +70 -0
  21. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +319 -0
  22. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.scss +69 -0
  23. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +616 -0
  24. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +577 -0
  25. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +1111 -0
  26. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +696 -0
  27. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/index.js +7 -0
  28. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/DynamicControlsStep.js +102 -0
  29. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/DynamicControlsStep.scss +36 -0
  30. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/Tests/DynamicControlsStep.test.js +91 -0
  31. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/index.js +5 -0
  32. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/MessageTypeStep.js +86 -0
  33. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/Tests/MessageTypeStep.test.js +100 -0
  34. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/index.js +5 -0
  35. package/v2Containers/CommunicationFlow/utils/getEnabledSteps.js +30 -0
  36. package/v2Containers/CreativesContainer/constants.js +3 -0
  37. package/v2Containers/CreativesContainer/index.js +1 -1
  38. package/v2Containers/Rcs/index.js +4 -2
@@ -60,6 +60,7 @@ export const AI_CONTENT_BOT_DISABLED = 'AI_CONTENT_BOT_DISABLED';
60
60
  export const AI_DOCUMENTATION_BOT_ENABLED = 'AI_DOCUMENTATION_BOT_ENABLED';
61
61
  export const ENABLE_NEW_LEFT_NAVIGATION = 'ENABLE_NEW_LEFT_NAVIGATION';
62
62
  export const ENABLE_PRODUCT_SUPPORT_VIDEOS = 'ENABLE_PRODUCT_SUPPORT_VIDEOS';
63
+ export const SUPPORT_ENGAGEMENT_MODULE = 'SUPPORT_ENGAGEMENT_MODULE';
63
64
  export const EMBEDDED = 'embedded';
64
65
  // --- Tag/Validation Constants ---
65
66
  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": "8.0.317",
4
+ "version": "8.0.319",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -334,6 +334,12 @@ export const getTemplateDetails = async ({id, channel}) => {
334
334
  return { ...compressedTemplatesData, response: decompressData};
335
335
  };
336
336
 
337
+ export const getDomainProperties = (channels, orgUnitId) => {
338
+ const queryString = channels?.map((channel) => `channel=${channel?.toUpperCase()}`)?.join('&');
339
+ const url = `${CAMPAIGNS_API_ORG_ENDPOINT}/meta/domainProperties?${queryString}`;
340
+ return request(url, getAPICallObject('GET', null, false, true, orgUnitId));
341
+ };
342
+
337
343
  export const getMediaDetails = async ({ id }) => {
338
344
  const url = `${API_ENDPOINT}/media/${id}`;
339
345
  return request(url, getAPICallObject('GET'));
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getSenderDetails,
3
+ getDomainProperties,
3
4
  uploadFile,
4
5
  getCdnTransformationConfig,
5
6
  createWhatsappTemplate,
@@ -52,6 +53,12 @@ describe('getSenderDetails -- Test with valid responses', () => {
52
53
  expect(getSenderDetails('WHATSAPP', -1)).toEqual(Promise.resolve()));
53
54
  });
54
55
 
56
+ describe('getDomainProperties -- Test with valid responses', () => {
57
+ it('Should return correct response', () => {
58
+ expect(getDomainProperties(['SMS', 'EMAIL'], null)).toEqual(Promise.resolve());
59
+ });
60
+ });
61
+
55
62
  describe('getCdnTransformationConfigs -- Test with valid responses', () => {
56
63
  it('Should return correct response', () =>
57
64
  expect(getCdnTransformationConfig()).toEqual(Promise.resolve()));
package/utils/common.js CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  ENABLE_WEBPUSH,
26
26
  SUPPORT_CK_EDITOR,
27
27
  ENABLE_NEW_MPUSH,
28
- ENABLE_NEW_EDITOR_FLOW_INAPP
28
+ ENABLE_NEW_EDITOR_FLOW_INAPP,
29
+ SUPPORT_ENGAGEMENT_MODULE,
29
30
  } from '../constants/unified';
30
31
  import { apiMessageFormatHandler } from './commonUtils';
31
32
 
@@ -96,6 +97,10 @@ export const hasSupportCKEditor = Auth.hasFeatureAccess.bind(
96
97
  null,
97
98
  SUPPORT_CK_EDITOR,
98
99
  );
100
+ export const hasSupportEngagementModule = Auth.hasFeatureAccess.bind(
101
+ null,
102
+ SUPPORT_ENGAGEMENT_MODULE,
103
+ );
99
104
 
100
105
  export const hasGiftVoucherFeature = Auth.hasFeatureAccess.bind(
101
106
  null,
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CommunicationFlow - Orchestrator Component
3
+ *
4
+ * Manages the step flow, validation, and data aggregation.
5
+ * Renders steps based on config and calls callbacks with aggregated data.
6
+ */
7
+
8
+ import React, {
9
+ useState, useCallback, useMemo, useEffect,
10
+ } from 'react';
11
+ import PropTypes from 'prop-types';
12
+ import { injectIntl } from 'react-intl';
13
+ import { compose } from 'redux';
14
+ import { connect } from 'react-redux';
15
+ import { createStructuredSelector } from 'reselect';
16
+ // import { CouponsCapContainer } from '@capillarytech/cap-coupons'; // Commented - cap-coupons flows disabled
17
+ import CapRow from '@capillarytech/cap-ui-library/CapRow';
18
+ import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
19
+ import CapButton from '@capillarytech/cap-ui-library/CapButton';
20
+ // import injectSaga from '../../utils/injectSaga'; // cap-coupons flows disabled
21
+ // import injectReducer from '../../utils/injectReducer';
22
+ import { makeSelectAuthenticated } from '../Cap/selectors';
23
+ import DynamicControlsStep from './steps/DynamicControlsStep';
24
+ import MessageTypeStep from './steps/MessageTypeStep';
25
+ import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
26
+ import ChannelSelectionStep from './steps/ChannelSelectionStep';
27
+ import {
28
+ STEPS,
29
+ CHANNEL_PRIORITY,
30
+ AB_TEST,
31
+ } from './constants';
32
+ import { getEnabledSteps } from './utils/getEnabledSteps';
33
+ import messages from './messages';
34
+ import './CommunicationFlow.scss';
35
+
36
+ // Inject couponsCap reducer and saga - commented out (cap-coupons flows disabled)
37
+ // const withCouponsReducer = injectReducer({
38
+ // key: 'couponsCap',
39
+ // reducer: CouponsCapContainer.couponsCapReducer,
40
+ // });
41
+ // const withCouponsSaga = injectSaga({
42
+ // key: 'couponsCapSaga',
43
+ // saga: CouponsCapContainer.couponsCapSaga,
44
+ // });
45
+
46
+ const CommunicationFlow = ({
47
+ config,
48
+ initialData,
49
+ onSave,
50
+ onCancel, // eslint-disable-line
51
+ onChange,
52
+ intl,
53
+ capData, // From Redux - contains user/org info needed by CouponsWrapper
54
+ }) => {
55
+ const { formatMessage } = intl || {};
56
+ const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
57
+ // Initialize step data from initialData or defaults
58
+ const [stepData, setStepData] = useState(() => {
59
+ const defaultMessageType = messageTypeData.defaultOption?.value || null;
60
+ return {
61
+ messageType: initialData?.messageType || defaultMessageType,
62
+ communicationStrategy: initialData?.communicationStrategy || null,
63
+ channel: initialData?.channel || config.channel || null,
64
+ channels: initialData?.channels || [],
65
+ selectedOfferDetails: initialData?.selectedOfferDetails || [],
66
+ contentItems: initialData?.contentItems || [],
67
+ deliverySetting: initialData?.deliverySetting || {},
68
+ dynamicControls: initialData?.dynamicControls || {},
69
+ };
70
+ });
71
+ const [validationErrors, setValidationErrors] = useState({});
72
+
73
+ // Memoize enabled steps
74
+ const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
75
+
76
+ /**
77
+ * Get aggregated data from all steps
78
+ */
79
+ const getAggregatedData = useCallback(() => {
80
+ const { communicationStrategy } = stepData;
81
+ const isMultiChannel = [CHANNEL_PRIORITY, AB_TEST].includes(communicationStrategy);
82
+
83
+ return {
84
+ messageType: stepData.messageType,
85
+ communicationStrategy: stepData.communicationStrategy,
86
+ channel: isMultiChannel ? null : stepData.channel,
87
+ channels: isMultiChannel ? stepData.channels : [],
88
+ selectedOfferDetails: stepData.selectedOfferDetails,
89
+ contentItems: stepData.contentItems || [],
90
+ deliverySetting: stepData.deliverySetting,
91
+ dynamicControls: stepData.dynamicControls,
92
+ };
93
+ }, [stepData]);
94
+
95
+ /**
96
+ * Handle step data change
97
+ */
98
+ const handleStepChange = useCallback((step, data) => {
99
+ setStepData((prevStepData) => ({
100
+ ...prevStepData,
101
+ ...data,
102
+ }));
103
+ setValidationErrors((prevErrors) => ({
104
+ ...prevErrors,
105
+ [step]: null, // Clear validation error for this step
106
+ }));
107
+ }, []);
108
+
109
+ // Call onChange callback when stepData changes
110
+ useEffect(() => {
111
+ if (onChange) {
112
+ onChange(getAggregatedData());
113
+ }
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [stepData]);
116
+
117
+ /**
118
+ * Render all enabled steps
119
+ */
120
+ const renderSteps = useCallback(() => enabledSteps.map((step) => {
121
+ const commonProps = {
122
+ value: stepData,
123
+ onChange: (data) => handleStepChange(step, data),
124
+ error: validationErrors[step],
125
+ };
126
+
127
+ switch (step) {
128
+ case STEPS.MESSAGE_TYPE:
129
+ return (
130
+ <CapRow key={step}>
131
+ <MessageTypeStep
132
+ {...commonProps}
133
+ value={stepData.messageType}
134
+ options={messageTypeData.options}
135
+ defaultOption={messageTypeData.defaultOption}
136
+ onChange={(messageType) => handleStepChange(step, { messageType })}
137
+ />
138
+ <CapDivider />
139
+ </CapRow>
140
+ );
141
+ case STEPS.COMMUNICATION_STRATEGY:
142
+ return (
143
+ <CapRow key={step}>
144
+ <CommunicationStrategyStep
145
+ {...commonProps}
146
+ value={stepData.communicationStrategy}
147
+ // messageType={stepData.messageType}
148
+ options={communicationStrategyData.options}
149
+ disabled={communicationStrategyData.disabled}
150
+ onChange={(communicationStrategy) => handleStepChange(step, { communicationStrategy })}
151
+ />
152
+ <CapDivider />
153
+ </CapRow>
154
+ );
155
+ case STEPS.CHANNEL_SELECTION:
156
+ // Only show ChannelSelectionStep if communication strategy is selected
157
+ if (!stepData.communicationStrategy) {
158
+ return null;
159
+ }
160
+ return (
161
+ <CapRow key={step}>
162
+ <ChannelSelectionStep
163
+ {...commonProps}
164
+ value={stepData}
165
+ channels={contentTemplateData.channels}
166
+ onChange={(data) => handleStepChange(step, data)}
167
+ channelsToHide={contentTemplateData.channelsToHide}
168
+ channelsToDisable={contentTemplateData.channelsToDisable}
169
+ creativesMode={config.mode || 'create'}
170
+ selectedOfferDetails={stepData.selectedOfferDetails}
171
+ incentivesData={config.features?.incentivesData}
172
+ deliverySettingsData={config.features?.deliverySettingsData}
173
+ config={config}
174
+ capData={capData}
175
+ />
176
+ <CapDivider />
177
+ </CapRow>
178
+ );
179
+ // This will be added back in when coupons/points are integrated so keeping it commented out for now
180
+ // case STEPS.INCENTIVES:
181
+ // return (
182
+ // <IncentivesStep
183
+ // key={step}
184
+ // {...commonProps}
185
+ // value={stepData.selectedOfferDetails}
186
+ // onChange={(selectedOfferDetails) => handleStepChange(step, { selectedOfferDetails })}
187
+ // />
188
+ // );
189
+ case STEPS.DYNAMIC_CONTROLS:
190
+ // Only show DynamicControlsStep if communication strategy is selected
191
+ if (!stepData.communicationStrategy) {
192
+ return null;
193
+ }
194
+ return (
195
+ <DynamicControlsStep
196
+ key={step}
197
+ {...commonProps}
198
+ value={{ dynamicControls: stepData.dynamicControls }}
199
+ controls={config.features?.dynamicControlsData?.controls || []}
200
+ onChange={(data) => handleStepChange(step, { dynamicControls: data.dynamicControls })}
201
+ />
202
+ );
203
+ default:
204
+ return null;
205
+ }
206
+ }), [enabledSteps, stepData, validationErrors, config, handleStepChange, messageTypeData, communicationStrategyData, contentTemplateData]);
207
+
208
+ return (
209
+ <CapRow className="communication-flow-container">
210
+ {renderSteps()}
211
+ {onSave && (
212
+ <CapRow className="communication-flow-container__footer">
213
+ <CapButton type="primary" onClick={() => onSave(getAggregatedData())}>
214
+ {formatMessage(messages.save)}
215
+ </CapButton>
216
+ </CapRow>
217
+ )}
218
+ </CapRow>
219
+ );
220
+ };
221
+
222
+ CommunicationFlow.propTypes = {
223
+ config: PropTypes.shape({
224
+ consumer: PropTypes.oneOf(['campaigns', 'loyalty', 'adiona']).isRequired,
225
+ channel: PropTypes.string,
226
+ mode: PropTypes.oneOf(['create', 'edit', 'preview']).isRequired,
227
+ channelsToHide: PropTypes.arrayOf(PropTypes.string),
228
+ channelsToDisable: PropTypes.arrayOf(PropTypes.string),
229
+ features: PropTypes.shape({
230
+ enableMessageType: PropTypes.bool,
231
+ enableCommunicationStrategy: PropTypes.bool,
232
+ messageTypeData: PropTypes.shape({
233
+ options: PropTypes.arrayOf(PropTypes.shape({
234
+ value: PropTypes.string.isRequired,
235
+ label: PropTypes.node.isRequired,
236
+ })),
237
+ defaultOption: PropTypes.shape({
238
+ value: PropTypes.string.isRequired,
239
+ label: PropTypes.node.isRequired,
240
+ }),
241
+ required: PropTypes.bool,
242
+ }),
243
+ communicationStrategyData: PropTypes.shape({
244
+ options: PropTypes.arrayOf(PropTypes.shape({
245
+ value: PropTypes.string.isRequired,
246
+ label: PropTypes.node.isRequired,
247
+ })),
248
+ defaultOption: PropTypes.shape({
249
+ value: PropTypes.string.isRequired,
250
+ label: PropTypes.node.isRequired,
251
+ }),
252
+ required: PropTypes.bool,
253
+ disabled: PropTypes.bool,
254
+ }),
255
+ contentTemplateData: PropTypes.shape({
256
+ required: PropTypes.bool,
257
+ channels: PropTypes.object,
258
+ }),
259
+ enableIncentives: PropTypes.bool,
260
+ enableDeliverySettings: PropTypes.bool,
261
+ enableOtherSettings: PropTypes.bool,
262
+ incentivesTypes: PropTypes.arrayOf(PropTypes.oneOf(['coupons', 'points', 'promotions', 'giftVouchers', 'badges'])),
263
+ }),
264
+ context: PropTypes.object,
265
+ }).isRequired,
266
+ initialData: PropTypes.object,
267
+ onSave: PropTypes.func.isRequired,
268
+ onCancel: PropTypes.func.isRequired,
269
+ onChange: PropTypes.func,
270
+ intl: PropTypes.object.isRequired,
271
+ capData: PropTypes.object,
272
+ };
273
+
274
+ CommunicationFlow.defaultProps = {
275
+ initialData: null,
276
+ onChange: null,
277
+ capData: {},
278
+ };
279
+
280
+ const mapStateToProps = createStructuredSelector({
281
+ capData: makeSelectAuthenticated(),
282
+ });
283
+
284
+ const withConnect = connect(mapStateToProps);
285
+
286
+ export default compose(
287
+ // withCouponsReducer, // cap-coupons flows disabled
288
+ // withCouponsSaga, // cap-coupons flows disabled
289
+ withConnect,
290
+ injectIntl,
291
+ )(CommunicationFlow);
@@ -0,0 +1,25 @@
1
+ @import '~@capillarytech/cap-ui-library/styles/_variables.scss';
2
+
3
+ // .slidebox-content-container is a DOM ancestor of .communication-flow-container
4
+ // (the outer CapSlideBox wraps this component), so it cannot be scoped as a
5
+ // descendant selector — it must remain at root level to match correctly.
6
+ .slidebox-content-container {
7
+ padding: 0 $CAP_SPACE_48 !important; // 48px left and right, 0 top and bottom
8
+ .slidebox-footer {
9
+ padding: 0 $CAP_SPACE_48 !important;
10
+ }
11
+ }
12
+
13
+ .communication-flow-container {
14
+ .step-divider {
15
+ margin: $CAP_SPACE_32 0;
16
+ }
17
+
18
+ .heading-style {
19
+ margin-bottom: $CAP_SPACE_08;
20
+ }
21
+
22
+ &__footer {
23
+ padding: $CAP_SPACE_16 0 $CAP_SPACE_08;
24
+ }
25
+ }
@@ -0,0 +1,255 @@
1
+ import React from 'react';
2
+
3
+ jest.mock('../../CreativesContainer', () => function MockCreativesContainer({
4
+ getCreativesData,
5
+ handleCloseCreatives,
6
+ creativesMode,
7
+ channel,
8
+ }) {
9
+ return (
10
+ <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
11
+ <button
12
+ type="button"
13
+ data-testid="creatives-save"
14
+ onClick={() => getCreativesData({ smsBody: 'Saved' })}
15
+ >
16
+ Save creative
17
+ </button>
18
+ <button type="button" data-testid="creatives-close" onClick={handleCloseCreatives}>
19
+ Close creative
20
+ </button>
21
+ </div>
22
+ );
23
+ });
24
+
25
+ import { Provider } from 'react-redux';
26
+ import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
27
+ import {
28
+ render as rtlRender, screen, waitFor, within,
29
+ } from '@testing-library/react';
30
+ import userEvent from '@testing-library/user-event';
31
+ import '@testing-library/jest-dom';
32
+ import { IntlProvider } from 'react-intl';
33
+ import history from '../../../utils/history';
34
+ import { initialReducer } from '../../../initialReducer';
35
+ import CommunicationFlow from '../CommunicationFlow';
36
+ import { getEnabledSteps } from '../utils/getEnabledSteps';
37
+ import {
38
+ CHANNELS,
39
+ CHANNEL_PRIORITY,
40
+ SINGLE_TEMPLATE,
41
+ STEPS,
42
+ } from '../constants';
43
+
44
+ let store;
45
+
46
+ beforeAll(() => {
47
+ store = configureStore({}, initialReducer, history);
48
+ });
49
+
50
+ /** Shared option lists for feature config */
51
+ const MESSAGE_OPTIONS = [
52
+ { value: 'promotional', label: 'Promotional' },
53
+ { value: 'transactional', label: 'Transactional' },
54
+ ];
55
+
56
+ const STRATEGY_OPTIONS = [
57
+ { value: SINGLE_TEMPLATE, label: 'Single template', isMultiChannel: false },
58
+ { value: CHANNEL_PRIORITY, label: 'Channel priority', isMultiChannel: true },
59
+ ];
60
+
61
+ const DYNAMIC_CONTROLS = [
62
+ { key: 'sendToControl', label: 'Send to control', description: 'Help text' },
63
+ ];
64
+
65
+ /** Base config for all CommunicationFlow tests */
66
+ const baseConfig = {
67
+ consumer: 'campaigns',
68
+ mode: 'create',
69
+ features: {},
70
+ };
71
+
72
+ const CAP_DATA = {};
73
+
74
+ /** Reusable feature presets merged into config.features */
75
+ const FEATURES = {
76
+ messageType: {
77
+ messageTypeData: {
78
+ required: true,
79
+ options: MESSAGE_OPTIONS,
80
+ defaultOption: { value: 'promotional', label: 'Promotional' },
81
+ },
82
+ },
83
+ strategyContent: {
84
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS, disabled: false },
85
+ contentTemplateData: { required: true, channels: CHANNELS },
86
+ },
87
+ strategyContentDynamic: {
88
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS, disabled: false },
89
+ contentTemplateData: { required: true, channels: CHANNELS },
90
+ dynamicControlsData: { required: true, controls: DYNAMIC_CONTROLS },
91
+ },
92
+ };
93
+
94
+ function renderFlow(ui) {
95
+ return rtlRender(
96
+ <Provider store={store}>
97
+ <IntlProvider locale="en" messages={{}} defaultLocale="en">
98
+ {ui}
99
+ </IntlProvider>
100
+ </Provider>,
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Renders connected CommunicationFlow with shared defaults.
106
+ * @param {object} [options]
107
+ * @param {object} [options.features] — config.features (use FEATURES.* or custom)
108
+ * @param {object|null} [options.initialData]
109
+ * @param {object} [options.capData]
110
+ * @param {jest.Mock} [options.onSave] — defaults to jest.fn()
111
+ * @param {jest.Mock} [options.onCancel] — defaults to jest.fn()
112
+ * @param {jest.Mock} [options.onChange]
113
+ */
114
+ function renderWithFlow({
115
+ features = {},
116
+ initialData = null,
117
+ capData = CAP_DATA,
118
+ onSave,
119
+ onCancel,
120
+ onChange,
121
+ ...rest
122
+ } = {}) {
123
+ return renderFlow(
124
+ <CommunicationFlow
125
+ config={{ ...baseConfig, features }}
126
+ initialData={initialData}
127
+ onSave={onSave ?? jest.fn()}
128
+ onCancel={onCancel ?? jest.fn()}
129
+ onChange={onChange}
130
+ capData={capData}
131
+ {...rest}
132
+ />,
133
+ );
134
+ }
135
+
136
+ async function selectCommunicationStrategy(label) {
137
+ const combo = screen.getByRole('combobox');
138
+ await userEvent.click(combo);
139
+ await waitFor(() => {
140
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
141
+ });
142
+ await userEvent.click(within(screen.getByRole('listbox')).getByText(label));
143
+ }
144
+
145
+ describe('getEnabledSteps', () => {
146
+ it('includes delivery settings when deliverySettingsData.required is true', () => {
147
+ const steps = getEnabledSteps({
148
+ ...baseConfig,
149
+ features: {
150
+ deliverySettingsData: { required: true },
151
+ },
152
+ });
153
+ expect(steps).toContain(STEPS.DELIVERY_SETTINGS);
154
+ });
155
+
156
+ it('omits delivery settings when deliverySettingsData.required is absent or false', () => {
157
+ expect(getEnabledSteps({ ...baseConfig, features: {} })).not.toContain(
158
+ STEPS.DELIVERY_SETTINGS,
159
+ );
160
+ expect(
161
+ getEnabledSteps({
162
+ ...baseConfig,
163
+ features: { deliverySettingsData: { required: false } },
164
+ }),
165
+ ).not.toContain(STEPS.DELIVERY_SETTINGS);
166
+ });
167
+ });
168
+
169
+ describe('CommunicationFlow', () => {
170
+ it('renders only steps whose features are required', () => {
171
+ renderWithFlow({ features: FEATURES.messageType });
172
+ expect(screen.getByText(/message type/i)).toBeInTheDocument();
173
+ expect(screen.queryByText(/communication strategy/i)).not.toBeInTheDocument();
174
+ expect(screen.queryByText(/content template/i)).not.toBeInTheDocument();
175
+ });
176
+
177
+ it('hides channel selection and dynamic controls until a communication strategy is set', async () => {
178
+ renderWithFlow({ features: FEATURES.strategyContentDynamic });
179
+
180
+ expect(screen.queryByText(/content template/i)).not.toBeInTheDocument();
181
+ expect(screen.queryByText(/other controls/i)).not.toBeInTheDocument();
182
+
183
+ await selectCommunicationStrategy('Single template');
184
+
185
+ await waitFor(() => {
186
+ expect(screen.getAllByText(/content template/i).length).toBeGreaterThan(0);
187
+ });
188
+ expect(screen.getByText(/other controls/i)).toBeInTheDocument();
189
+ });
190
+
191
+ it('calls onChange with aggregated data when step data updates', async () => {
192
+ const onChange = jest.fn();
193
+ renderWithFlow({ features: FEATURES.messageType, onChange });
194
+
195
+ onChange.mockClear();
196
+ await userEvent.click(screen.getByRole('radio', { name: /transactional/i }));
197
+
198
+ expect(onChange).toHaveBeenCalled();
199
+ const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
200
+ expect(payload.messageType).toBe('transactional');
201
+ expect(payload.communicationStrategy).toBeNull();
202
+ });
203
+
204
+ it('passes single-channel aggregate to onSave', async () => {
205
+ const onSave = jest.fn();
206
+ renderWithFlow({ features: FEATURES.strategyContent, onSave });
207
+
208
+ await selectCommunicationStrategy('Single template');
209
+
210
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
211
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
212
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
213
+
214
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument());
215
+ await userEvent.click(screen.getByRole('button', { name: /save creative/i }));
216
+
217
+ await waitFor(() => expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument());
218
+
219
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
220
+
221
+ expect(onSave).toHaveBeenCalledTimes(1);
222
+ const saved = onSave.mock.calls[0][0];
223
+ expect(saved.communicationStrategy).toBe(SINGLE_TEMPLATE);
224
+ expect(saved.channel).toBe('SMS');
225
+ expect(saved.channels).toEqual([]);
226
+ expect(saved.contentItems.length).toBeGreaterThanOrEqual(1);
227
+ });
228
+
229
+ it('clears channel and keeps channels list in aggregate for multi-channel strategy', async () => {
230
+ const onSave = jest.fn();
231
+ renderWithFlow({
232
+ features: FEATURES.strategyContent,
233
+ initialData: {
234
+ communicationStrategy: CHANNEL_PRIORITY,
235
+ channels: ['SMS', 'EMAIL'],
236
+ },
237
+ onSave,
238
+ });
239
+
240
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
241
+
242
+ const saved = onSave.mock.calls[0][0];
243
+ expect(saved.communicationStrategy).toBe(CHANNEL_PRIORITY);
244
+ expect(saved.channel).toBeNull();
245
+ expect(saved.channels).toEqual(['SMS', 'EMAIL']);
246
+ });
247
+
248
+ it('initializes message type from initialData when provided', () => {
249
+ renderWithFlow({
250
+ features: FEATURES.messageType,
251
+ initialData: { messageType: 'transactional' },
252
+ });
253
+ expect(screen.getByRole('radio', { name: /transactional/i })).toBeChecked();
254
+ });
255
+ });