@capillarytech/creatives-library 9.0.56-alpha.5 → 9.0.56-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.56-alpha.5",
4
+ "version": "9.0.56-alpha.7",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -701,7 +701,7 @@ 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, proxied through cap-creatives-api; used by CommunicationFlow's Save, while legacy messageMeta functions remain for CreativesContainer's save flow.
704
+ // Use the CCS CommDefinition API for CommunicationFlow Save; keep legacy messageMeta APIs for CreativesContainer.
705
705
  const COMM_DEFINITIONS_PATH = `${API_ENDPOINT}/comm-definitions`;
706
706
 
707
707
  export const createCommDefinition = (payload) => {
@@ -115,6 +115,22 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
115
115
  return variables;
116
116
  };
117
117
 
118
+ // Reconcile slot-format (`${token}_${index}`) and CCS-format (`"0"`, `"1"`, ...) var maps into the editor's `${token}_${segmentIndex}` slot-key format.
119
+ export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
120
+ if (Object.keys(rawVarMap ?? {}).length === 0) return {};
121
+ const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
122
+ if (isSlotFormat) return { ...rawVarMap };
123
+ const slotMap = {};
124
+ let occurrenceIndex = 0;
125
+ (segments ?? []).forEach((segment, segmentIndex) => {
126
+ if (typeof segment === 'string' && (segment.match(regex) || []).length > 0) {
127
+ slotMap[`${segment}_${segmentIndex}`] = rawVarMap[occurrenceIndex] ?? '';
128
+ occurrenceIndex += 1;
129
+ }
130
+ });
131
+ return slotMap;
132
+ };
133
+
118
134
  /**
119
135
  * Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
120
136
  * Handles both exact matches and dot-path suffixes (e.g. `tag.FORMAT_1` → name `FORMAT_1`).
@@ -6,8 +6,11 @@ import {
6
6
  getFallbackResolvedContentForCardDisplay,
7
7
  isDltHashVarToken,
8
8
  isAnyTemplateVarToken,
9
+ reconcileVarMapToSlotFormat,
9
10
  } from '../templateVarUtils';
10
11
 
12
+ const MUSTACHE_VAR_REGEX = /\{\{\d+\}\}/g;
13
+
11
14
  describe('templateVarUtils', () => {
12
15
  describe('splitContentByOrderedVarTokens', () => {
13
16
  it('pushes remainder when next token is not found in string', () => {
@@ -201,4 +204,45 @@ describe('templateVarUtils', () => {
201
204
  expect(getFallbackResolvedContent('{#a#}', {}, { a: '' })).toBe('{#a#}');
202
205
  });
203
206
  });
207
+
208
+ describe('reconcileVarMapToSlotFormat', () => {
209
+ it('returns an empty object for an empty/absent rawVarMap', () => {
210
+ expect(reconcileVarMapToSlotFormat({}, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
211
+ expect(reconcileVarMapToSlotFormat(undefined, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
212
+ });
213
+
214
+ it('uses slot-format keys (containing an underscore) as-is', () => {
215
+ const rawVarMap = { '{{1}}_1': 'test', '{{2}}_3': 'test2' };
216
+ expect(
217
+ reconcileVarMapToSlotFormat(rawVarMap, ['x', '{{1}}', 'y', '{{2}}'], MUSTACHE_VAR_REGEX),
218
+ ).toEqual(rawVarMap);
219
+ });
220
+
221
+ it('remaps CCS-format sequential-occurrence-index keys ("0","1",...) onto this UI\'s own slot keys (regression: CCS sends plain occurrence indices, not this UI\'s `${token}_${arrayIndex}` format, which silently blanked WhatsApp edit fields and desynced the segment array)', () => {
222
+ // Mirrors the real CCS response: "Hi! Here is your latest tier progress summary with us.\n\nTo
223
+ // move up to the next tier, you still need to spend {{1}} more,\nearn {{2}} more points, ..."
224
+ // — varMapped keys "0".."10" map to {{1}}.."{{11}}" in template order.
225
+ const segments = [
226
+ 'spend ',
227
+ '{{1}}',
228
+ ' more,\nearn ',
229
+ '{{2}}',
230
+ ' more points',
231
+ ];
232
+ const rawVarMap = { 0: 'dasda', 1: 'dasd' };
233
+ expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
234
+ '{{1}}_1': 'dasda',
235
+ '{{2}}_3': 'dasd',
236
+ });
237
+ });
238
+
239
+ it('defaults a missing occurrence-index entry to an empty string rather than dropping the slot', () => {
240
+ const segments = ['a ', '{{1}}', ' b ', '{{2}}'];
241
+ const rawVarMap = { 0: 'filled' }; // no entry for occurrence index 1
242
+ expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
243
+ '{{1}}_1': 'filled',
244
+ '{{2}}_3': '',
245
+ });
246
+ });
247
+ });
204
248
  });
@@ -140,8 +140,7 @@ const UnifiedPreview = ({
140
140
  }
141
141
 
142
142
  case CHANNELS.LINE:
143
- // LINE carries a single plain-text message today, so it reuses the SMS chat-bubble
144
- // preview. Rich types (image/video/sticker/imageMap/carousel/flex) aren't rendered yet.
143
+ // LINE currently supports plain text only, so reuse the SMS bubble preview; rich message types are not rendered yet.
145
144
  return (
146
145
  <SmsPreviewContent
147
146
  content={typeof content === 'string' ? content : (content?.resolvedBody || '')}
@@ -209,7 +209,7 @@ const CommonTestAndPreview = (props) => {
209
209
  wecrmAccounts = [],
210
210
  isLoadingSenderDetails = false,
211
211
  orgUnitId = -1,
212
- // notify/ui only: { commDefinitionId, referenceId } routes "Send test" to CCS's send/transaction endpoint.
212
+ // notify/ui only:{commDefinitionId, referenceId} routes "Send test" to CCS's send/transaction endpoint.
213
213
  ccsSendTransaction,
214
214
  // Email-specific props
215
215
  beeInstance,
@@ -3624,14 +3624,11 @@ const CommonTestAndPreview = (props) => {
3624
3624
  setIsCustomerDataLoading(false);
3625
3625
  }
3626
3626
  };
3627
-
3627
+
3628
3628
  /**
3629
3629
  * Handle send test message
3630
3630
  */
3631
- // notify/ui: send the test through CCS's own send/transaction endpoint against the alert's
3632
- // real CommDefinition, instead of the legacy createMessageMeta/sendTestMessage flow below.
3633
- // Dispatched via the same action/saga pattern as every other API call in this component,
3634
- // rather than calling Api.sendTransactionalComm directly.
3631
+ // notify/ui sends tests via CCS's CommDefinition send/transaction endpoint using the standard action/saga flow, not the legacy messageMeta flow.
3635
3632
  const handleCcsSendTestMessage = () => {
3636
3633
  const allUserIds = [];
3637
3634
  selectedTestEntities.forEach((entityId) => {
@@ -221,6 +221,10 @@ const CommunicationFlow = ({
221
221
  singleChannelStrategy,
222
222
  })
223
223
  : await createCommDefinition(payload);
224
+ if (res?.isError) {
225
+ setSaveError(formatMessage(messages.genericSaveError));
226
+ return;
227
+ }
224
228
  if (isDuplicateReferenceIdError(res)) {
225
229
  setSaveError(formatMessage(messages.duplicateReferenceIdError));
226
230
  return;
@@ -234,6 +238,9 @@ const CommunicationFlow = ({
234
238
  version: data.version?.version ?? data.version ?? 1,
235
239
  status: data.status,
236
240
  };
241
+ } else {
242
+ setSaveError(formatMessage(messages.genericSaveError));
243
+ return;
237
244
  }
238
245
  } else if (data?.id) {
239
246
  ccsCommDefinition = {
@@ -242,9 +249,14 @@ const CommunicationFlow = ({
242
249
  version: data.version?.version ?? 1,
243
250
  status: data.status,
244
251
  };
252
+ } else {
253
+ setSaveError(formatMessage(messages.genericSaveError));
254
+ return;
245
255
  }
246
256
  } catch (error) {
247
257
  console.error('[CommunicationFlow] CCS createCommDefinition error:', error);
258
+ setSaveError(formatMessage(messages.genericSaveError));
259
+ return;
248
260
  }
249
261
  } else if (!name) {
250
262
  console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — config.context.name is required');
@@ -128,16 +128,7 @@
128
128
  }
129
129
 
130
130
  .communication-flow-container {
131
- // Anchors the Save footer to the bottom of the slidebox's scrollable content
132
- // area regardless of how much step content is above it. <CapRow useLegacy>
133
- // carries cap-ui-library's `.ant-row-legacy` rule (`display: block !important`),
134
- // which silently defeats a plain `display:flex`/`min-height` here unless the
135
- // selector is compounded with `.ant-row-legacy` and marked `!important` too.
136
- // Deliberately position-based, not flex + margin-top: auto — a flex version
137
- // makes this element (and its .ant-row-legacy children) a flex container, and
138
- // flexbox's default align-items: stretch leaks into deeply nested descendants
139
- // (e.g. ChannelSelectionStep's fixed-width empty-state card), stretching them
140
- // to fill the container instead of keeping their own size.
131
+ // Anchor the Save footer to the bottom without flex, avoiding .ant-row-legacy overrides and flexbox stretching nested fixed-size content.
141
132
  &.ant-row-legacy {
142
133
  position: relative !important;
143
134
  min-height: calc(100vh - 6.571rem) !important;
@@ -148,10 +139,7 @@
148
139
  margin: $CAP_SPACE_32 0;
149
140
  }
150
141
 
151
- // CommunicationStrategyStep's own trailing divider is hidden until content
152
- // exists (no dangling line above an empty "Add creative" state), but the gap
153
- // it would have provided (.step-divider's margin) must stay — otherwise the
154
- // section sits flush against the Channel card in the empty state.
142
+ // Hide the divider when empty but preserve its margin gap so the section doesn’t sit flush against the Channel card.
155
143
  .communication-strategy-row--no-divider {
156
144
  margin-bottom: $CAP_SPACE_32;
157
145
  }
@@ -166,7 +166,7 @@ const CommunicationFlowCard = ({
166
166
  <CapColumn span={12} className="card-body-right-col">
167
167
  <CapLabel type="label8">{formatMessage(messages.dynamicControlsTitle)}</CapLabel>
168
168
  {dynamicControlKeys.map((key) => {
169
- const controlConfig = controls.find((c) => c.key === key);
169
+ const controlConfig = controls.find((control) => control.key === key);
170
170
  if (!controlConfig) return null;
171
171
  return (
172
172
  <CapRow key={key} type="flex" justify="space-between" className="control-row">
@@ -693,7 +693,7 @@ describe('handleSave — CCS flow', () => {
693
693
  expect(createCommDefinition).not.toHaveBeenCalled();
694
694
  });
695
695
 
696
- it('still calls onSave (without ccsCommDefinition) when createCommDefinition rejects', async () => {
696
+ it('blocks save and shows a generic error when createCommDefinition rejects (regression: this used to silently call onSave without ccsCommDefinition and no user-facing feedback — Send for approval would then just be disabled with zero explanation why)', async () => {
697
697
  createCommDefinition.mockRejectedValue(new Error('Network error'));
698
698
  const onSave = jest.fn();
699
699
  renderWithFlow({
@@ -705,8 +705,40 @@ describe('handleSave — CCS flow', () => {
705
705
 
706
706
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
707
707
 
708
- await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
709
- expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
708
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
709
+ expect(onSave).not.toHaveBeenCalled();
710
+ });
711
+
712
+ it('blocks save and shows a generic error when createCommDefinition resolves with a network-level failure (api.js resolves isError instead of rejecting on a 401/403/etc)', async () => {
713
+ createCommDefinition.mockResolvedValue({ isError: true, status: 401 });
714
+ const onSave = jest.fn();
715
+ renderWithFlow({
716
+ features: {},
717
+ config: ccsConfig,
718
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
719
+ onSave,
720
+ });
721
+
722
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
723
+
724
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
725
+ expect(onSave).not.toHaveBeenCalled();
726
+ });
727
+
728
+ it('blocks save and shows a generic error when createCommDefinition resolves with no usable data (e.g. missing id)', async () => {
729
+ createCommDefinition.mockResolvedValue({ response: { data: {} } });
730
+ const onSave = jest.fn();
731
+ renderWithFlow({
732
+ features: {},
733
+ config: ccsConfig,
734
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
735
+ onSave,
736
+ });
737
+
738
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
739
+
740
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
741
+ expect(onSave).not.toHaveBeenCalled();
710
742
  });
711
743
 
712
744
  it('blocks save and shows an error when createCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
@@ -99,9 +99,7 @@ export const CCS_CHANNEL_NAME_MAP = {
99
99
  MOBILEPUSH: 'PUSH',
100
100
  };
101
101
 
102
- // Channel config single source of truth for CreativesContainer and TemplatesV2.
103
- // paneKey: TemplatesV2 defaultPanes key used by channelsToHide.
104
- // channelProp: CreativesContainer channel prop that must match pane.key for the tab to be active.
102
+ // Channel config is the shared source of truth: paneKey maps TemplatesV2 panes, channelProp maps CreativesContainer tabs.
105
103
  export const CHANNELS = [
106
104
  {
107
105
  value: 'SMS',
@@ -367,4 +367,8 @@ export default {
367
367
  id: `${prefix}.duplicateReferenceIdError`,
368
368
  defaultMessage: 'This reference ID is already in use in your organization. Please use a different reference ID.',
369
369
  },
370
+ genericSaveError: {
371
+ id: `${prefix}.genericSaveError`,
372
+ defaultMessage: 'Something went wrong while saving your content. Please try again.',
373
+ },
370
374
  };
@@ -64,6 +64,7 @@ const ChannelSelectionStep = ({
64
64
  const [showIncentivesMenuMap, setShowIncentivesMenuMap] = useState({});
65
65
  const [showTestAndPreview, setShowTestAndPreview] = useState(false);
66
66
  const [testAndPreviewItem, setTestAndPreviewItem] = useState(null);
67
+ const [domainPropertiesData, setDomainPropertiesData] = useState(null);
67
68
  const { formatMessage } = intl || {};
68
69
 
69
70
  // Available channels (filter out hidden ones)
@@ -77,6 +78,19 @@ const ChannelSelectionStep = ({
77
78
  const selectedChannelLowerCase = selectedChannel?.toLowerCase();
78
79
  return CHANNELS.find((channel) => channel?.value?.toLowerCase() === selectedChannelLowerCase || channel?.channelProp === selectedChannelLowerCase) || null;
79
80
  }, [selectedChannel]);
81
+
82
+ const editingZaloHostName = useMemo(() => {
83
+ if (!editingContentId) return '';
84
+ const editingItem = contentItems.find((c) => c.contentId === editingContentId);
85
+ if (editingItem?.channel?.toUpperCase() !== ZALO) return '';
86
+ const accountId = editingItem?.templateData?.accountId;
87
+ if (!accountId) return '';
88
+ const zaloDomains = domainPropertiesData?.ZALO || [];
89
+ const matchedDomain = zaloDomains.find(
90
+ (domain) => String(domain?.domainProperties?.connectionProperties?.oa_id) === String(accountId),
91
+ );
92
+ return matchedDomain?.domainProperties?.hostName || '';
93
+ }, [editingContentId, contentItems, domainPropertiesData]);
80
94
  /**
81
95
  * Handle CreativesContainer close
82
96
  */
@@ -467,6 +481,7 @@ const ChannelSelectionStep = ({
467
481
  deliverySettingsData={deliverySettingsData}
468
482
  deliverySetting={value?.deliverySetting}
469
483
  onDeliverySettingChange={(deliverySetting) => onChange({ deliverySetting })}
484
+ onDomainPropertiesLoaded={setDomainPropertiesData}
470
485
  intl={intl}
471
486
  />
472
487
  )}
@@ -487,14 +502,13 @@ const ChannelSelectionStep = ({
487
502
  getCreativesData={handleCreativesData}
488
503
  handleCloseCreatives={handleCloseCreatives}
489
504
  isFullMode={false}
490
- messageDetails={{ type: 'default' }}
505
+ hostName={editingZaloHostName}
506
+ messageDetails={{ type: (config?.context?.module || '').toLowerCase() || 'default' }}
507
+ location={{ query: { type: 'embedded', module: config?.context?.module } }}
491
508
  templateData={editingContentId ? (() => {
492
509
  const saved = contentItems.find((c) => c.contentId === editingContentId)?.templateData;
493
- // getTemplateData in CreativesContainer reads 'content', 'accountId', 'messageSubject' at
494
- // top-level and needs 'type' for SlideBoxContent to set isEditWebPush. Our stored WEBPUSH
495
- // templateData wraps those fields inside messageContent.content, so extract them here.
496
510
  if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
497
- return { ...saved.messageContent.content, type: WEBPUSH };
511
+ return { ...saved.messageContent.content, type: WEBPUSH, channel: WEBPUSH };
498
512
  }
499
513
  return saved;
500
514
  })() : null}
@@ -37,9 +37,21 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
37
37
  handleCloseCreatives,
38
38
  creativesMode,
39
39
  channel,
40
+ templateData,
41
+ hostName,
42
+ messageDetails,
43
+ location,
40
44
  }) {
41
45
  return (
42
- <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
46
+ <div
47
+ data-testid="creatives-mock"
48
+ data-creatives-mode={creativesMode}
49
+ data-creatives-channel={channel}
50
+ data-template-data={JSON.stringify(templateData)}
51
+ data-host-name={hostName}
52
+ data-message-details={JSON.stringify(messageDetails)}
53
+ data-location={JSON.stringify(location)}
54
+ >
43
55
  <button
44
56
  type="button"
45
57
  data-testid="creatives-save"
@@ -62,7 +74,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
62
74
  });
63
75
 
64
76
  jest.mock('../../DeliverySettingsStep', () => ({
65
- DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
77
+ DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
66
78
  return (
67
79
  <div data-testid="delivery-settings-section">
68
80
  <button
@@ -72,6 +84,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
72
84
  >
73
85
  Apply delivery
74
86
  </button>
87
+ <button
88
+ type="button"
89
+ data-testid="domain-properties-loaded"
90
+ onClick={() => onDomainPropertiesLoaded?.({
91
+ ZALO: [{
92
+ id: 267284,
93
+ domainProperties: {
94
+ id: 4977,
95
+ domainName: 'Gapit_Automation',
96
+ connectionProperties: { oa_id: '300086756699856746' },
97
+ hostName: 'gapitzalotrans',
98
+ },
99
+ }],
100
+ })}
101
+ >
102
+ Load domain properties
103
+ </button>
75
104
  </div>
76
105
  );
77
106
  },
@@ -1683,6 +1712,149 @@ describe('ChannelSelectionStep', () => {
1683
1712
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
1684
1713
  });
1685
1714
 
1715
+ it('edit WEBPUSH item includes `channel: WEBPUSH` (not just `type`) in the unwrapped templateData (regression: CreativesContainer.getTemplateData\'s switch reads templateData.channel, not templateData.type — without it the switch never matched WEBPUSH and the edit screen rendered blank below its header)', async () => {
1716
+ renderStep(
1717
+ <ChannelSelectionStep
1718
+ value={{
1719
+ contentItems: [{
1720
+ contentId: 'wp-edit-channel',
1721
+ channel: 'WEBPUSH',
1722
+ templateData: {
1723
+ channel: 'WEBPUSH',
1724
+ messageContent: {
1725
+ content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
1726
+ },
1727
+ },
1728
+ }],
1729
+ }}
1730
+ onChange={jest.fn()}
1731
+ channels={CHANNELS}
1732
+ />,
1733
+ );
1734
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1735
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1736
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1737
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1738
+ const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
1739
+ expect(passedTemplateData.channel).toBe('WEBPUSH');
1740
+ expect(passedTemplateData.type).toBe('WEBPUSH');
1741
+ expect(passedTemplateData.messageSubject).toBe('dasd');
1742
+ expect(passedTemplateData.accountId).toBe(13792);
1743
+ });
1744
+
1745
+ // ── ZALO edit: hostName resolved from domainProperties ────────────────────────
1746
+
1747
+ it('resolves a Zalo item\'s hostName from the domainProperties fetch by matching accountId to connectionProperties.oa_id, and passes it to CreativesContainer (regression: CCS\'s zaloMessageContent never carries hostName, and without it Zalo/index.js\'s getTemplateInfoById guard never passes, so the edit view never loads live template data)', async () => {
1748
+ renderStep(
1749
+ <ChannelSelectionStep
1750
+ value={{
1751
+ contentItems: [{
1752
+ contentId: 'zalo-edit',
1753
+ channel: 'ZALO',
1754
+ templateData: {
1755
+ channel: 'ZALO',
1756
+ accountId: '300086756699856746',
1757
+ accountName: 'gapit_automation_account',
1758
+ token: 'zalo-token',
1759
+ templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
1760
+ },
1761
+ }],
1762
+ }}
1763
+ onChange={jest.fn()}
1764
+ channels={CHANNELS}
1765
+ deliverySettingsData={{ required: false }}
1766
+ />,
1767
+ );
1768
+ await userEvent.click(screen.getByTestId('domain-properties-loaded'));
1769
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1770
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1771
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1772
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1773
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
1774
+ });
1775
+
1776
+ it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
1777
+ renderStep(
1778
+ <ChannelSelectionStep
1779
+ value={{
1780
+ contentItems: [{
1781
+ contentId: 'zalo-edit-no-match',
1782
+ channel: 'ZALO',
1783
+ templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
1784
+ }],
1785
+ }}
1786
+ onChange={jest.fn()}
1787
+ channels={CHANNELS}
1788
+ deliverySettingsData={{ required: false }}
1789
+ />,
1790
+ );
1791
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1792
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1793
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1794
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1795
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1796
+ });
1797
+
1798
+ // ── Tag-context pass-through (config.context.module) ──────────────────────────
1799
+
1800
+ it('passes config.context.module through to messageDetails.type (lowercased) and location.query.module, unchanged, for tag-fetch scoping (regression: this used to be hardcoded to messageDetails={{type: \'default\'}} with no location prop at all, so every consumer\'s tag-fetch was scoped generically instead of per-consumer)', async () => {
1801
+ renderStep(
1802
+ <ChannelSelectionStep
1803
+ value={{ contentItems: [] }}
1804
+ onChange={jest.fn()}
1805
+ channels={CHANNELS}
1806
+ config={{ context: { module: 'CAMPAIGNS' } }}
1807
+ />,
1808
+ );
1809
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1810
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1811
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1812
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1813
+
1814
+ const mock = screen.getByTestId('creatives-mock');
1815
+ expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'campaigns' });
1816
+ expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
1817
+ query: { type: 'embedded', module: 'CAMPAIGNS' },
1818
+ });
1819
+ });
1820
+
1821
+ it('passes through whatever module value a coupons/cartPromotions-style config sets, with no special-casing on our end', async () => {
1822
+ renderStep(
1823
+ <ChannelSelectionStep
1824
+ value={{ contentItems: [] }}
1825
+ onChange={jest.fn()}
1826
+ channels={CHANNELS}
1827
+ config={{ context: { module: 'coupons' } }}
1828
+ />,
1829
+ );
1830
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1831
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1832
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1833
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1834
+
1835
+ const mock = screen.getByTestId('creatives-mock');
1836
+ expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'coupons' });
1837
+ expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
1838
+ query: { type: 'embedded', module: 'coupons' },
1839
+ });
1840
+ });
1841
+
1842
+ it('falls back to messageDetails={{type: "default"}} when config.context.module is absent (unset consumers keep prior behavior)', async () => {
1843
+ renderStep(
1844
+ <ChannelSelectionStep
1845
+ value={{ contentItems: [] }}
1846
+ onChange={jest.fn()}
1847
+ channels={CHANNELS}
1848
+ />,
1849
+ );
1850
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1851
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1852
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1853
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1854
+
1855
+ expect(JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-message-details'))).toEqual({ type: 'default' });
1856
+ });
1857
+
1686
1858
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1687
1859
 
1688
1860
  it('FTP channel is filtered out of the channel dropdown', async () => {
@@ -33,6 +33,7 @@ const DeliverySettingsSection = ({
33
33
  deliverySettingsData,
34
34
  deliverySetting = {},
35
35
  onDeliverySettingChange,
36
+ onDomainPropertiesLoaded,
36
37
  intl,
37
38
  }) => {
38
39
  const [showSlidebox, setShowSlidebox] = useState(false);
@@ -102,6 +103,7 @@ const DeliverySettingsSection = ({
102
103
  )
103
104
  : raw;
104
105
  setDomainPropertiesData(entity);
106
+ onDomainPropertiesLoaded?.(entity);
105
107
  }
106
108
  } catch (err) {
107
109
  if (!cancelled) setDomainPropertiesData(null);
@@ -316,6 +318,7 @@ DeliverySettingsSection.propTypes = {
316
318
  deliverySettingsData: PropTypes.object,
317
319
  deliverySetting: PropTypes.object,
318
320
  onDeliverySettingChange: PropTypes.func,
321
+ onDomainPropertiesLoaded: PropTypes.func,
319
322
  intl: PropTypes.object.isRequired,
320
323
  };
321
324
 
@@ -181,7 +181,7 @@ const getEmailDomainValue = (entity) => {
181
181
  return emailData.domainProperties?.id ?? emailData.domainId ?? emailData.id ?? '';
182
182
  };
183
183
 
184
- // CCS's domainGatewayMapId is the domainProperties API's outer `id`, distinct from domainProperties.id (the numeric domainId); it identifies the domain-gateway mapping for the selected domain.
184
+ // CCS domainGatewayMapId is the domainProperties API's outer id, not domainProperties.id; it identifies the selected domain's gateway mapping.
185
185
  const getEmailDomainGatewayMapIdByDomainId = (entity, domainId) => {
186
186
  const domainEntry = getEmailDomainById(entity, domainId);
187
187
  return domainEntry?.id ?? '';
@@ -582,7 +582,7 @@ export const parseEntityForDisplay = (entity, context = {}) => {
582
582
  senderDetails: entity,
583
583
  smsSenderId: getSmsSenderIdValue(entity),
584
584
  smsDomain: getSmsDomainValue(entity),
585
- // emailDomain here is the domain NAME (for display only) - buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId, sourced separately below.
585
+ // emailDomain is the display-only domain name; buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId sourced separately.
586
586
  emailDomain: getEmailDomainNameForDisplay(entity),
587
587
  emailDomainId: getEmailDomainValue(entity),
588
588
  emailDomainGatewayMapId: getEmailDomainGatewayMapIdByDomainId(entity, getEmailDomainValue(entity)),
@@ -611,7 +611,7 @@ export const buildChannelSettingFromFieldValues = (fieldValues = {}) => {
611
611
  }
612
612
  if (fieldValues.emailDomain != null || fieldValues.emailSenderId != null || fieldValues.emailSenderName != null || fieldValues.emailReplyToId != null) {
613
613
  channelSetting.EMAIL = {
614
- // emailDomainId (numeric, from auto-save defaults via parseEntityForDisplay) takes precedence over emailDomain, which may be a numeric ID in SenderDetails state or a display name from parseEntityForDisplay.
614
+ // Prefer numeric emailDomainId from auto-save defaults; emailDomain may be either a SenderDetails ID or display name.
615
615
  domainId: fieldValues.emailDomainId ?? fieldValues.emailDomain,
616
616
  domainGatewayMapId: fieldValues.emailDomainGatewayMapId,
617
617
  senderId: fieldValues.emailSenderId,
@@ -412,7 +412,7 @@ export class Creatives extends React.Component {
412
412
  this.setState({ isGetFormData: false });
413
413
  };
414
414
 
415
- mapCarouselDataToCreatives = (cards) => cards.map((card) => {
415
+ mapCarouselDataToCreatives = (cards) => (cards || []).map((card) => {
416
416
  const {
417
417
  cardVarMapped, bodyTemplate, media, buttons, mediaType,
418
418
  } = card || {};
@@ -10,6 +10,7 @@ const {
10
10
  whatsappGetCreativeData2,
11
11
  whatsappGetTemplateData1,
12
12
  whatsappGetTemplateData2,
13
+ whatsappGetTemplateDataNullCards,
13
14
  rcsTemplates,
14
15
  rcsEditTemplateData,
15
16
  smsEditTemplateData,
@@ -77,6 +78,17 @@ describe('Test SlideBoxContent container', () => {
77
78
  expect(handleCloseCreatives).toHaveBeenCalledWith(true);
78
79
  });
79
80
 
81
+ it('does not throw when templateConfigs.cards is explicitly null (regression: CCS sends null rather than omitting the field on non-carousel WhatsApp templates, and mapCarouselDataToCreatives called .map() straight off it)', () => {
82
+ expect(() =>
83
+ renderFunction(
84
+ 'WHATSAPP',
85
+ 'editTemplate',
86
+ whatsappTemplates,
87
+ whatsappGetTemplateDataNullCards,
88
+ ),
89
+ ).not.toThrow();
90
+ });
91
+
80
92
  it('it should clear the url, on channel change from new whatsapp to another', () => {
81
93
  renderFunction(
82
94
  'WHATSAPP',
@@ -119,7 +119,7 @@ import { ANDROID } from '../../v2Components/CommonTestAndPreview/constants';
119
119
  import CapImageUpload from '../../v2Components/CapImageUpload';
120
120
  import TagList from '../TagList';
121
121
  import { validateTags } from '../../utils/tagValidations';
122
- import { splitContentByOrderedVarTokens } from '../../utils/templateVarUtils';
122
+ import { splitContentByOrderedVarTokens, reconcileVarMapToSlotFormat } from '../../utils/templateVarUtils';
123
123
  import { capitalizeString } from '../../utils/Formatter';
124
124
  import CapWhatsappCTA from '../../v2Components/CapWhatsappCTA';
125
125
  import {
@@ -504,7 +504,8 @@ export const Whatsapp = (props) => {
504
504
  if (templateHeaderArray?.length !== 0) {
505
505
  let clonedVarMap = {};
506
506
  if (!isEmpty(varMap)) {
507
- clonedVarMap = cloneDeep(varMap);
507
+ // Reconcile CCS sequential varMapped indices to the UI’s `${token}_${index}` slot keys so values map to the correct segments.
508
+ clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
508
509
  } else {
509
510
  templateHeaderArray?.forEach((headerValue, i) => {
510
511
  if (headerValue?.match(regex)?.length > 0) {
@@ -562,7 +563,9 @@ export const Whatsapp = (props) => {
562
563
  if (tempMsgArray.length !== 0) {
563
564
  const { varMapped = {} } = editContent;
564
565
  if (!isEmpty(varMapped)) {
565
- varMap = cloneDeep(varMapped);
566
+ // CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
567
+ // rather than this UI's own `${token}_${index}` slot keys
568
+ varMap = reconcileVarMapToSlotFormat(varMapped, tempMsgArray, validVarRegex);
566
569
  } else {
567
570
  //computing and setting varMap for first edit
568
571
  for (let i = 0; i < tempMsgArray.length; i += 1) {
@@ -1111,6 +1111,31 @@ export default {
1111
1111
  },
1112
1112
  accountName: "WhatsappAccount",
1113
1113
  },
1114
+ // CCS sends an explicit `cards: null` (not omitted) for a non-carousel WhatsApp
1115
+ // template — regression fixture for a crash reading `.map` off null.
1116
+ whatsappGetTemplateDataNullCards: {
1117
+ channel: "WHATSAPP",
1118
+ storeType: "REGISTERED_STORE",
1119
+ accountId: 12721,
1120
+ messagePartsCount: 1,
1121
+ messageBody: "Hey test, this is a plain WhatsApp template with no carousel.",
1122
+ templateConfigs: {
1123
+ name: "creatives_whatsapp6",
1124
+ language: "en",
1125
+ varMapped: {
1126
+ "{{1}}_1": "test",
1127
+ },
1128
+ template: "Hey {{1}}, this is a plain WhatsApp template with no carousel.",
1129
+ id: "creatives_whatsapp6",
1130
+ category: "MARKETING",
1131
+ buttonType: "NONE",
1132
+ buttons: null,
1133
+ mediaType: "TEXT",
1134
+ whatsappMedia: null,
1135
+ cards: null,
1136
+ },
1137
+ accountName: "WhatsappAccount",
1138
+ },
1114
1139
  whatsappGetCreativeData1: {
1115
1140
  value: {
1116
1141
  name: "creatives_whatsapp6",