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

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.6",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -115,6 +115,38 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
115
115
  return variables;
116
116
  };
117
117
 
118
+ /**
119
+ * Reconciles a var-value map from an external source (e.g. a saved CommDefinition) into this
120
+ * UI's own `${token}_${segmentIndex}` slot-key format used by the WhatsApp/RCS editors' own
121
+ * text-merge logic.
122
+ *
123
+ * Two shapes are recognized:
124
+ * - Slot format: keys already look like `${token}_${index}` (contain an underscore) — used as-is.
125
+ * - CCS format: keys are plain sequential occurrence indices ("0", "1", ...), one per variable in
126
+ * template order — unrelated to this UI's own array-position indexing (which depends on how many
127
+ * plain-text segments fall between variables) — so it's remapped by walking `segments` in order
128
+ * and assigning the Nth variable occurrence to `rawVarMap[N]`.
129
+ *
130
+ * @param {Object} rawVarMap
131
+ * @param {string[]} segments - text+var segments, e.g. from `splitContentByOrderedVarTokens`
132
+ * @param {RegExp} regex - matches a variable token
133
+ * @returns {Object} `${token}_${segmentIndex}` -> value
134
+ */
135
+ export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
136
+ if (Object.keys(rawVarMap ?? {}).length === 0) return {};
137
+ const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
138
+ if (isSlotFormat) return { ...rawVarMap };
139
+ const slotMap = {};
140
+ let occurrenceIndex = 0;
141
+ (segments ?? []).forEach((segment, segmentIndex) => {
142
+ if (typeof segment === 'string' && (segment.match(regex) || []).length > 0) {
143
+ slotMap[`${segment}_${segmentIndex}`] = rawVarMap[occurrenceIndex] ?? '';
144
+ occurrenceIndex += 1;
145
+ }
146
+ });
147
+ return slotMap;
148
+ };
149
+
118
150
  /**
119
151
  * Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
120
152
  * 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
  });
@@ -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,12 @@ const ChannelSelectionStep = ({
487
502
  getCreativesData={handleCreativesData}
488
503
  handleCloseCreatives={handleCloseCreatives}
489
504
  isFullMode={false}
505
+ hostName={editingZaloHostName}
490
506
  messageDetails={{ type: 'default' }}
491
507
  templateData={editingContentId ? (() => {
492
508
  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
509
  if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
497
- return { ...saved.messageContent.content, type: WEBPUSH };
510
+ return { ...saved.messageContent.content, type: WEBPUSH, channel: WEBPUSH };
498
511
  }
499
512
  return saved;
500
513
  })() : null}
@@ -37,9 +37,17 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
37
37
  handleCloseCreatives,
38
38
  creativesMode,
39
39
  channel,
40
+ templateData,
41
+ hostName,
40
42
  }) {
41
43
  return (
42
- <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
44
+ <div
45
+ data-testid="creatives-mock"
46
+ data-creatives-mode={creativesMode}
47
+ data-creatives-channel={channel}
48
+ data-template-data={JSON.stringify(templateData)}
49
+ data-host-name={hostName}
50
+ >
43
51
  <button
44
52
  type="button"
45
53
  data-testid="creatives-save"
@@ -62,7 +70,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
62
70
  });
63
71
 
64
72
  jest.mock('../../DeliverySettingsStep', () => ({
65
- DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
73
+ DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
66
74
  return (
67
75
  <div data-testid="delivery-settings-section">
68
76
  <button
@@ -72,6 +80,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
72
80
  >
73
81
  Apply delivery
74
82
  </button>
83
+ <button
84
+ type="button"
85
+ data-testid="domain-properties-loaded"
86
+ onClick={() => onDomainPropertiesLoaded?.({
87
+ ZALO: [{
88
+ id: 267284,
89
+ domainProperties: {
90
+ id: 4977,
91
+ domainName: 'Gapit_Automation',
92
+ connectionProperties: { oa_id: '300086756699856746' },
93
+ hostName: 'gapitzalotrans',
94
+ },
95
+ }],
96
+ })}
97
+ >
98
+ Load domain properties
99
+ </button>
75
100
  </div>
76
101
  );
77
102
  },
@@ -1683,6 +1708,89 @@ describe('ChannelSelectionStep', () => {
1683
1708
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
1684
1709
  });
1685
1710
 
1711
+ 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 () => {
1712
+ renderStep(
1713
+ <ChannelSelectionStep
1714
+ value={{
1715
+ contentItems: [{
1716
+ contentId: 'wp-edit-channel',
1717
+ channel: 'WEBPUSH',
1718
+ templateData: {
1719
+ channel: 'WEBPUSH',
1720
+ messageContent: {
1721
+ content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
1722
+ },
1723
+ },
1724
+ }],
1725
+ }}
1726
+ onChange={jest.fn()}
1727
+ channels={CHANNELS}
1728
+ />,
1729
+ );
1730
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1731
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1732
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1733
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1734
+ const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
1735
+ expect(passedTemplateData.channel).toBe('WEBPUSH');
1736
+ expect(passedTemplateData.type).toBe('WEBPUSH');
1737
+ expect(passedTemplateData.messageSubject).toBe('dasd');
1738
+ expect(passedTemplateData.accountId).toBe(13792);
1739
+ });
1740
+
1741
+ // ── ZALO edit: hostName resolved from domainProperties ────────────────────────
1742
+
1743
+ 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 () => {
1744
+ renderStep(
1745
+ <ChannelSelectionStep
1746
+ value={{
1747
+ contentItems: [{
1748
+ contentId: 'zalo-edit',
1749
+ channel: 'ZALO',
1750
+ templateData: {
1751
+ channel: 'ZALO',
1752
+ accountId: '300086756699856746',
1753
+ accountName: 'gapit_automation_account',
1754
+ token: 'zalo-token',
1755
+ templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
1756
+ },
1757
+ }],
1758
+ }}
1759
+ onChange={jest.fn()}
1760
+ channels={CHANNELS}
1761
+ deliverySettingsData={{ required: false }}
1762
+ />,
1763
+ );
1764
+ await userEvent.click(screen.getByTestId('domain-properties-loaded'));
1765
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1766
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1767
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1768
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1769
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
1770
+ });
1771
+
1772
+ it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
1773
+ renderStep(
1774
+ <ChannelSelectionStep
1775
+ value={{
1776
+ contentItems: [{
1777
+ contentId: 'zalo-edit-no-match',
1778
+ channel: 'ZALO',
1779
+ templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
1780
+ }],
1781
+ }}
1782
+ onChange={jest.fn()}
1783
+ channels={CHANNELS}
1784
+ deliverySettingsData={{ required: false }}
1785
+ />,
1786
+ );
1787
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1788
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1789
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1790
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1791
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1792
+ });
1793
+
1686
1794
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1687
1795
 
1688
1796
  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
 
@@ -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,10 @@ export const Whatsapp = (props) => {
504
504
  if (templateHeaderArray?.length !== 0) {
505
505
  let clonedVarMap = {};
506
506
  if (!isEmpty(varMap)) {
507
- clonedVarMap = cloneDeep(varMap);
507
+ // CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
508
+ // rather than this UI's own `${token}_${index}` slot keys — reconcile so
509
+ // values land on the right segment instead of silently missing.
510
+ clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
508
511
  } else {
509
512
  templateHeaderArray?.forEach((headerValue, i) => {
510
513
  if (headerValue?.match(regex)?.length > 0) {
@@ -562,7 +565,9 @@ export const Whatsapp = (props) => {
562
565
  if (tempMsgArray.length !== 0) {
563
566
  const { varMapped = {} } = editContent;
564
567
  if (!isEmpty(varMapped)) {
565
- varMap = cloneDeep(varMapped);
568
+ // CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
569
+ // rather than this UI's own `${token}_${index}` slot keys
570
+ varMap = reconcileVarMapToSlotFormat(varMapped, tempMsgArray, validVarRegex);
566
571
  } else {
567
572
  //computing and setting varMap for first edit
568
573
  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",