@capillarytech/creatives-library 9.1.3 → 9.1.4

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.1.3",
4
+ "version": "9.1.4",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -123,6 +123,9 @@ export const CUSTOM_VALUES_EDITOR_SECTION_FALLBACK_KEY = 'section';
123
123
  */
124
124
  export const SMS_MUSTACHE_TAG_PATTERN = /\{\{[^}]+\}\}/;
125
125
 
126
+ /** WhatsApp slot key `{{<var>}}_<index>`; captures `<var>`. */
127
+ export const WHATSAPP_SLOT_KEY_PATTERN = /^\{\{(.+?)\}\}_\d+$/;
128
+
126
129
  /**
127
130
  * Key on `smsFallbackContent` passed into Test & Preview — RCS slot map from SmsTraiEdit
128
131
  * (`onRcsFallbackEditorStateChange`), merged before tag extraction like the embedded SMS editor.
@@ -39,6 +39,10 @@ import {
39
39
  mapRcsSuggestionForTestMeta,
40
40
  getRcsPrimaryTagExtractionText,
41
41
  mergeSyntheticMustacheTags,
42
+ getWhatsappTagExtractionText,
43
+ getWhatsappCarouselCardBodyText,
44
+ mergeZaloUnfilledParamTags,
45
+ fillSlotMapFromCustomValues,
42
46
  } from './utils';
43
47
  import AddTestCustomerButton from './AddTestCustomer';
44
48
  import ExistingCustomerModal from './ExistingCustomerModal';
@@ -235,6 +239,8 @@ const CommonTestAndPreview = (props) => {
235
239
  const [isExtractingSmsFallbackTags, setIsExtractingSmsFallbackTags] = useState(false);
236
240
  const [customValues, setCustomValues] = useState({});
237
241
  const previewCustomValuesRef = useRef({});
242
+ // Values last applied to the preview (Update / Discard / customer prefill); drives client-side fill-in.
243
+ const [previewTagValues, setPreviewTagValues] = useState(null);
238
244
  const [showJSON, setShowJSON] = useState(false);
239
245
  const [tagsExtracted, setTagsExtracted] = useState(false);
240
246
 
@@ -595,6 +601,12 @@ const CommonTestAndPreview = (props) => {
595
601
  if (channel === CHANNELS.RCS) {
596
602
  return mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
597
603
  }
604
+ if (channel === CHANNELS.WHATSAPP) {
605
+ return mergeSyntheticMustacheTags(extractedTags, getWhatsappTagExtractionText(formData));
606
+ }
607
+ if (channel === CHANNELS.ZALO) {
608
+ return mergeZaloUnfilledParamTags(extractedTags, formData);
609
+ }
598
610
  return extractedTags ?? [];
599
611
  }, [
600
612
  channel,
@@ -680,7 +692,8 @@ const CommonTestAndPreview = (props) => {
680
692
  const tagName = tagPath.split('.').pop(); // Get the actual tag name from the path
681
693
  // Escape regex metacharacters in tagName (arbitrary JSON key) so it can't break the pattern.
682
694
  const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
683
- const tagRegex = new RegExp(`{{${escapedTagName}}}`, 'g');
695
+ // Braces escaped: unescaped `{{1}}` parses `{1}` as a quantifier and never matches numbered slots.
696
+ const tagRegex = new RegExp(`\\{\\{${escapedTagName}\\}\\}`, 'g');
684
697
  const rawValue = tagValues[tagPath];
685
698
  // Preserve legitimate falsy values (0, false); only an unset/blank slot keeps the placeholder.
686
699
  const hasValue = rawValue !== null && rawValue !== undefined && rawValue !== '';
@@ -1081,9 +1094,10 @@ const CommonTestAndPreview = (props) => {
1081
1094
  };
1082
1095
 
1083
1096
  case CHANNELS.WHATSAPP:
1097
+ // contentStr is templateMsg alone; carousel card bodies live in carouselData[].
1084
1098
  return {
1085
1099
  templateSubject: formDataObj?.title || '',
1086
- templateContent: contentStr,
1100
+ templateContent: getWhatsappTagExtractionText(formDataObj) || contentStr,
1087
1101
  };
1088
1102
 
1089
1103
  case CHANNELS.RCS: {
@@ -1180,9 +1194,10 @@ const CommonTestAndPreview = (props) => {
1180
1194
  * Updated to match API structure with ouId, sourceEntityId, module, deliverySettings, etc.
1181
1195
  */
1182
1196
 
1183
- const getCarouselMappedData = (carouselData = []) => carouselData.map((carousel) => {
1197
+ const getCarouselMappedData = (carouselData = [], customValuesObj = {}) => carouselData.map((carousel) => {
1184
1198
  const {
1185
- bodyText, imageUrl, videoUrl, videoPreviewImg, buttons, mediaType, cardVarMapped, bodyTemplate,
1199
+ imageUrl, videoUrl, videoPreviewImg, buttons, mediaType, cardVarMapped, bodyTemplate,
1200
+ varMap, tempBodyData,
1186
1201
  } = carousel || {};
1187
1202
  const buttonData = buttons.map((button, index) => {
1188
1203
  const {
@@ -1205,10 +1220,14 @@ const CommonTestAndPreview = (props) => {
1205
1220
  }
1206
1221
  return buttonObj;
1207
1222
  });
1223
+ // Overview state keys a card's slot map `varMap` / `tempBodyData`; the saved-template shape
1224
+ // uses cardVarMapped / bodyTemplate. Read both, as whatsappMedia does for the header.
1225
+ // varMap wins: the editor seeds it from carousel.varMap and never from cardVarMapped, so on a
1226
+ // library-saved template the saved map is stale relative to what the user sees on Overview.
1208
1227
  return {
1209
- body: bodyText,
1210
- cardVarMapped,
1211
- bodyTemplate,
1228
+ body: resolveTagsInText(getWhatsappCarouselCardBodyText(carousel), customValuesObj),
1229
+ cardVarMapped: fillSlotMapFromCustomValues(varMap ?? cardVarMapped, customValuesObj),
1230
+ bodyTemplate: Array.isArray(tempBodyData) ? tempBodyData.join('') : bodyTemplate,
1212
1231
  media: {
1213
1232
  ...(mediaType?.toLowerCase() === IMAGE.toLowerCase() && {
1214
1233
  url: imageUrl,
@@ -1280,7 +1299,9 @@ const CommonTestAndPreview = (props) => {
1280
1299
  ...(suggestionsFormattedForTestMeta.length > 0 && {
1281
1300
  suggestions: suggestionsFormattedForTestMeta,
1282
1301
  }),
1283
- ...(singleRcsCardPayload?.cardVarMapped && { cardVarMapped: singleRcsCardPayload.cardVarMapped }),
1302
+ ...(singleRcsCardPayload?.cardVarMapped && {
1303
+ cardVarMapped: fillSlotMapFromCustomValues(singleRcsCardPayload.cardVarMapped, customValuesObj),
1304
+ }),
1284
1305
  };
1285
1306
  });
1286
1307
  // Use the component-level smsFallbackContent prop (has rcsSmsFallbackVarMapped) so DLT
@@ -1525,7 +1546,7 @@ const CommonTestAndPreview = (props) => {
1525
1546
  const language = formDataObj?.language || formDataObj?.templateLanguage || 'en';
1526
1547
  const mediaType = formDataObj?.mediaType || formDataObj?.templateMediaType || MEDIA_TYPE_TEXT;
1527
1548
  const buttonType = formDataObj?.buttonType || BUTTON_TYPE_NONE;
1528
- const varMapped = formDataObj?.varMapped || {};
1549
+ const varMapped = fillSlotMapFromCustomValues(formDataObj?.varMapped || {}, customValuesObj);
1529
1550
  const isPreviewUrl = formDataObj?.isPreviewUrl || false;
1530
1551
 
1531
1552
  // Extract buttons from ctaData or quickReplyData
@@ -1558,9 +1579,12 @@ const CommonTestAndPreview = (props) => {
1558
1579
 
1559
1580
  // Build whatsappMedia structure
1560
1581
  const whatsappMedia = {
1561
- header: formDataObj?.templateHeader || formDataObj?.header || '',
1582
+ header: resolveTagsInText(formDataObj?.templateHeader || formDataObj?.header || '', customValuesObj),
1562
1583
  footer: formDataObj?.templateFooter || formDataObj?.footer || '',
1563
- headerVarMapped: formDataObj?.headerVarMapped || formDataObj?.headerVarMappedData || {},
1584
+ headerVarMapped: fillSlotMapFromCustomValues(
1585
+ formDataObj?.headerVarMapped || formDataObj?.headerVarMappedData || {},
1586
+ customValuesObj,
1587
+ ),
1564
1588
  headerTemplate: formDataObj?.headerTemplate || formDataObj?.templateHeader || '',
1565
1589
  buttons: modifiedButtons,
1566
1590
  language,
@@ -1633,7 +1657,7 @@ const CommonTestAndPreview = (props) => {
1633
1657
 
1634
1658
  // Add carousel data if mediaType is CAROUSEL
1635
1659
  if (mediaType === MEDIA_TYPE_CAROUSEL && formDataObj?.carouselData) {
1636
- templateConfigs.cards = getCarouselMappedData(formDataObj.carouselData);
1660
+ templateConfigs.cards = getCarouselMappedData(formDataObj.carouselData, customValuesObj);
1637
1661
  templateConfigs.mediaType = formDataObj?.carouselMediaType?.toUpperCase() || MEDIA_TYPE_IMAGE;
1638
1662
  }
1639
1663
 
@@ -2426,7 +2450,10 @@ const CommonTestAndPreview = (props) => {
2426
2450
  const resolvedVarMapped = {};
2427
2451
  Object.keys(templateConfigs.varMapped).forEach((key) => {
2428
2452
  const value = templateConfigs.varMapped[key];
2429
- resolvedVarMapped[key] = resolveTagsInText(value, customValuesObj);
2453
+ // Param left empty on Overview → use the value entered in Test & Preview.
2454
+ resolvedVarMapped[key] = value
2455
+ ? resolveTagsInText(value, customValuesObj)
2456
+ : (customValuesObj[key] ?? '');
2430
2457
  });
2431
2458
  templateConfigs.varMapped = resolvedVarMapped;
2432
2459
  }
@@ -2457,7 +2484,9 @@ const CommonTestAndPreview = (props) => {
2457
2484
  const resolvedVarMapped = {};
2458
2485
  Object.keys(varMapped).forEach((key) => {
2459
2486
  const value = varMapped[key];
2460
- resolvedVarMapped[key] = resolveTagsInText(value, customValuesObj);
2487
+ resolvedVarMapped[key] = value
2488
+ ? resolveTagsInText(value, customValuesObj)
2489
+ : (customValuesObj[key] ?? '');
2461
2490
  });
2462
2491
  varMapped = resolvedVarMapped;
2463
2492
  }
@@ -2547,10 +2576,31 @@ const CommonTestAndPreview = (props) => {
2547
2576
  *
2548
2577
  * IMPORTANT: Use raw content/formData initially, only use previewDataHtml if preview call was made
2549
2578
  */
2579
+ // Fills last-applied test values into raw template text when the preview API reply isn't usable.
2580
+ const resolvePreviewText = (text) => (
2581
+ typeof text === 'string' && previewTagValues ? resolveTagsInText(text, previewTagValues) : text
2582
+ );
2583
+
2550
2584
  // Memoized RCS carousel content: only recomputes when the API response or raw template changes,
2551
2585
  // NOT on every customValues keystroke. Uses previewCustomValuesRef captured at Update Preview time.
2552
2586
  const rcsCarouselContentObj = useMemo(() => {
2553
2587
  if (channel !== CHANNELS.RCS) return null;
2588
+ // Standalone card: fill entered values client-side; the preview API leaves numbered {{N}} unresolved.
2589
+ const fillStandaloneCard = (card) => {
2590
+ if (!hasPreviewCallBeenMade || !previewTagValues || !card || typeof card !== 'object' || Array.isArray(card.carouselData)) {
2591
+ return card;
2592
+ }
2593
+ const {
2594
+ templateHeader, templateMessage, rcsTitle, rcsDesc,
2595
+ } = card;
2596
+ return {
2597
+ ...card,
2598
+ templateHeader: resolvePreviewText(templateHeader),
2599
+ templateMessage: resolvePreviewText(templateMessage),
2600
+ rcsTitle: resolvePreviewText(rcsTitle),
2601
+ rcsDesc: resolvePreviewText(rcsDesc),
2602
+ };
2603
+ };
2554
2604
  let resolvedContent = null;
2555
2605
  if (hasPreviewCallBeenMade && previewDataHtml?.resolvedBody) {
2556
2606
  resolvedContent = previewDataHtml.resolvedBody;
@@ -2558,7 +2608,7 @@ const CommonTestAndPreview = (props) => {
2558
2608
  try { resolvedContent = JSON.parse(resolvedContent); } catch (e) { resolvedContent = null; }
2559
2609
  }
2560
2610
  }
2561
- if (resolvedContent && typeof resolvedContent === 'object') return resolvedContent;
2611
+ if (resolvedContent && typeof resolvedContent === 'object') return fillStandaloneCard(resolvedContent);
2562
2612
 
2563
2613
  let rawRcs = content;
2564
2614
  if (typeof rawRcs === 'string') {
@@ -2583,9 +2633,9 @@ const CommonTestAndPreview = (props) => {
2583
2633
  }),
2584
2634
  };
2585
2635
  }
2586
- return rawRcs || {};
2636
+ return fillStandaloneCard(rawRcs) || {};
2587
2637
  // eslint-disable-next-line react-hooks/exhaustive-deps
2588
- }, [channel, content, hasPreviewCallBeenMade, previewDataHtml, formData]);
2638
+ }, [channel, content, hasPreviewCallBeenMade, previewDataHtml, formData, previewTagValues]);
2589
2639
 
2590
2640
  const prepareUnifiedPreviewProps = () => {
2591
2641
  // Prepare content based on channel
@@ -2651,6 +2701,28 @@ const CommonTestAndPreview = (props) => {
2651
2701
  // Use raw content if no preview call was made or resolvedContent is not available
2652
2702
  contentObj = parsedContent || {};
2653
2703
  }
2704
+ // WhatsApp: fill entered values client-side; the preview API leaves numbered {{N}} unresolved.
2705
+ if (channel === CHANNELS.WHATSAPP && hasPreviewCallBeenMade) {
2706
+ const { templateHeader, templateMsg, carouselData } = contentObj;
2707
+ contentObj = {
2708
+ ...contentObj,
2709
+ templateHeader: resolvePreviewText(templateHeader),
2710
+ templateMsg: resolvePreviewText(templateMsg),
2711
+ // Carousel cards render bodyText/updatedBodyText directly, so resolve both shapes.
2712
+ ...(Array.isArray(carouselData) && {
2713
+ carouselData: carouselData.map((card) => {
2714
+ const { bodyText, updatedBodyText } = card || {};
2715
+ return {
2716
+ ...card,
2717
+ bodyText: resolvePreviewText(bodyText),
2718
+ ...(Array.isArray(updatedBodyText) && {
2719
+ updatedBodyText: updatedBodyText.map((segment) => resolvePreviewText(segment)),
2720
+ }),
2721
+ };
2722
+ }),
2723
+ }),
2724
+ };
2725
+ }
2654
2726
  } else if (channel === CHANNELS.RCS) {
2655
2727
  // rcsCarouselContentObj is a useMemo that handles both the API-response path and the
2656
2728
  // client-side fallback. It is stable across keystrokes (only recomputes when
@@ -3104,6 +3176,7 @@ const CommonTestAndPreview = (props) => {
3104
3176
  previousBeeContentRef.current = '';
3105
3177
  setBeeContent('');
3106
3178
  setPreviewDataHtml('');
3179
+ setPreviewTagValues(null);
3107
3180
  setSelectedCustomer(null);
3108
3181
  setRequiredTags([]);
3109
3182
  setOptionalTags([]);
@@ -3193,6 +3266,7 @@ const CommonTestAndPreview = (props) => {
3193
3266
  updatedValues,
3194
3267
  selectedCustomer
3195
3268
  );
3269
+ setPreviewTagValues(updatedValues);
3196
3270
  actions.updatePreviewRequested(payload);
3197
3271
  setHasPreviewCallBeenMade(true); // Mark that preview call was made
3198
3272
  void syncSmsFallbackPreview(updatedValues, selectedCustomer);
@@ -3262,6 +3336,7 @@ const CommonTestAndPreview = (props) => {
3262
3336
  setTagsExtracted(false);
3263
3337
  setPreviewDevice(DESKTOP);
3264
3338
  setPreviewDataHtml('');
3339
+ setPreviewTagValues(null);
3265
3340
  setHasPreviewCallBeenMade(false); // Reset preview call flag
3266
3341
  setViberPreviewTagValues(null);
3267
3342
  setSelectedTestEntities([]);
@@ -3300,6 +3375,7 @@ const CommonTestAndPreview = (props) => {
3300
3375
  */
3301
3376
  const handleClearSelection = () => {
3302
3377
  setSelectedCustomer(null);
3378
+ setPreviewTagValues(null);
3303
3379
  setHasPreviewCallBeenMade(false); // Reset flag when customer is cleared
3304
3380
  setViberPreviewTagValues(null);
3305
3381
 
@@ -3363,6 +3439,8 @@ const CommonTestAndPreview = (props) => {
3363
3439
  if (channel === CHANNELS.VIBER) {
3364
3440
  setViberPreviewTagValues({ ...emptyValues });
3365
3441
  }
3442
+ previewCustomValuesRef.current = emptyValues;
3443
+ setPreviewTagValues(emptyValues);
3366
3444
  actions.updatePreviewRequested(payload);
3367
3445
  setHasPreviewCallBeenMade(true); // Mark that preview call was made
3368
3446
  };
@@ -3376,6 +3454,7 @@ const CommonTestAndPreview = (props) => {
3376
3454
  // Capture customValues at click time so the carousel preview only updates here,
3377
3455
  // not on every subsequent keystroke.
3378
3456
  previewCustomValuesRef.current = customValues;
3457
+ setPreviewTagValues(customValues);
3379
3458
  // For RCS: always dispatch with RCS channel/content so previewDataHtml stays RCS-shaped,
3380
3459
  // even when the user triggers update from the SMS fallback tab.
3381
3460
  // SMS fallback preview is kept in sync by syncSmsFallbackPreview via smsFallbackPreviewText.
@@ -3475,6 +3554,13 @@ const CommonTestAndPreview = (props) => {
3475
3554
  if (channel === CHANNELS.SMS && !smsTemplateHasMustacheTags(smsEditorBody)) {
3476
3555
  smsTagSource = [];
3477
3556
  }
3557
+ // Unmapped variables become fillable test values, like RCS.
3558
+ if (channel === CHANNELS.WHATSAPP) {
3559
+ smsTagSource = mergeSyntheticMustacheTags(smsTagSource, getWhatsappTagExtractionText(formData));
3560
+ }
3561
+ if (channel === CHANNELS.ZALO) {
3562
+ smsTagSource = mergeZaloUnfilledParamTags(smsTagSource, formData);
3563
+ }
3478
3564
  const { required, optional, hasPersonalizationTags } = categorizeTags(smsTagSource);
3479
3565
  setRequiredTags(required);
3480
3566
  setOptionalTags(optional);
@@ -3494,6 +3580,10 @@ const CommonTestAndPreview = (props) => {
3494
3580
  const templateContent = currentTabData?.[activeTab]?.['template-content'];
3495
3581
  contentToExtract = templateContent || contentToExtract;
3496
3582
  }
3583
+ // Include carousel card bodies, else a carousel-only template scans as tag-free.
3584
+ if (channel === CHANNELS.WHATSAPP) {
3585
+ contentToExtract = getWhatsappTagExtractionText(formData || {}) || contentToExtract;
3586
+ }
3497
3587
  return contentToExtract;
3498
3588
  };
3499
3589
 
@@ -7,7 +7,9 @@
7
7
 
8
8
  import React from 'react';
9
9
  import PropTypes from 'prop-types';
10
- import { render, screen, waitFor } from '@testing-library/react';
10
+ import {
11
+ render, screen, waitFor, act,
12
+ } from '@testing-library/react';
11
13
  import { IntlProvider } from 'react-intl';
12
14
  import CommonTestAndPreview from '../index';
13
15
  import { CHANNELS, MEDIA_TYPE_CAROUSEL } from '../constants';
@@ -70,12 +72,14 @@ jest.mock('../SendTestMessage', () => {
70
72
  };
71
73
  });
72
74
 
75
+ let lastPreviewSectionProps = null;
73
76
  jest.mock('../PreviewSection', () => {
74
77
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies
75
78
  const ReactLib = require('react');
76
79
  return {
77
80
  __esModule: true,
78
- default: function MockPreviewSection() {
81
+ default: function MockPreviewSection(props) {
82
+ lastPreviewSectionProps = props;
79
83
  return ReactLib.createElement('div', { 'data-testid': 'preview-section' }, 'Preview Section');
80
84
  },
81
85
  };
@@ -199,6 +203,7 @@ describe('CommonTestAndPreview', () => {
199
203
  lastSendTestMessageProps = null;
200
204
  lastLeftPanelContentProps = null;
201
205
  lastCustomValuesEditorProps = null;
206
+ lastPreviewSectionProps = null;
202
207
  // Reset all mock function implementations
203
208
  Object.values(mockActions).forEach((mockFn) => {
204
209
  if (jest.isMockFunction(mockFn)) {
@@ -701,6 +706,41 @@ describe('CommonTestAndPreview', () => {
701
706
  expect(payload.whatsappMessageContent.templateConfigs.mediaType).toBe('VIDEO');
702
707
  });
703
708
 
709
+ it('should resolve entered values into card body and fill the card slot map', async () => {
710
+ const formData = {
711
+ templateMsg: 'Deals inside',
712
+ mediaType: MEDIA_TYPE_CAROUSEL,
713
+ carouselMediaType: 'IMAGE',
714
+ carouselData: [
715
+ {
716
+ bodyText: 'Save {{1}} today',
717
+ tempBodyData: ['Save ', '{{1}}', ' today'],
718
+ varMap: { '{{1}}_1': '' },
719
+ mediaType: 'image',
720
+ buttons: [],
721
+ },
722
+ ],
723
+ };
724
+ render(
725
+ <TestWrapper>
726
+ <CommonTestAndPreview {...defaultProps} channel={CHANNELS.WHATSAPP} formData={formData} />
727
+ </TestWrapper>
728
+ );
729
+ await waitFor(() => expect(lastCustomValuesEditorProps).not.toBeNull());
730
+ act(() => {
731
+ lastCustomValuesEditorProps.handleCustomValueChange('1', '20%');
732
+ });
733
+ await waitFor(() => {
734
+ expect(lastSendTestMessageProps).toBeDefined();
735
+ });
736
+ lastSendTestMessageProps.handleSendTestMessage();
737
+ const [payload] = mockActions.createMessageMetaRequested.mock.calls[0];
738
+ const { cards } = payload.whatsappMessageContent.templateConfigs;
739
+ expect(cards[0].body).toBe('Save 20% today');
740
+ expect(cards[0].bodyTemplate).toBe('Save {{1}} today');
741
+ expect(cards[0].cardVarMapped).toEqual({ '{{1}}_1': '20%' });
742
+ });
743
+
704
744
  it('should map button type PHONE_NUMBER to buttonObj with phoneNumber', async () => {
705
745
  const props = {
706
746
  ...defaultProps,
@@ -3877,5 +3917,125 @@ describe('CommonTestAndPreview', () => {
3877
3917
  expect(mockActions.updatePreviewRequested).toHaveBeenCalledTimes(1);
3878
3918
  });
3879
3919
  });
3920
+
3921
+ describe('client-side preview fill-in when the preview API reply is not an object', () => {
3922
+ const getPreviewContent = () => lastPreviewSectionProps.unifiedPreviewProps.content;
3923
+
3924
+ const enterValuesAndUpdatePreview = async (values) => {
3925
+ await waitFor(() => expect(lastCustomValuesEditorProps).not.toBeNull());
3926
+ act(() => {
3927
+ Object.entries(values).forEach(([tag, value]) => lastCustomValuesEditorProps.handleCustomValueChange(tag, value));
3928
+ });
3929
+ await act(async () => {
3930
+ await lastCustomValuesEditorProps.handleUpdatePreview();
3931
+ });
3932
+ };
3933
+
3934
+ const whatsappContent = {
3935
+ templateHeader: 'Order for {{1}}',
3936
+ templateMsg: 'Hi {{1}}, delivered by {{2}}',
3937
+ };
3938
+
3939
+ it('fills entered values into the WhatsApp header and message', async () => {
3940
+ render(
3941
+ <TestWrapper>
3942
+ <CommonTestAndPreview
3943
+ {...defaultProps}
3944
+ channel={CHANNELS.WHATSAPP}
3945
+ content={whatsappContent}
3946
+ formData={whatsappContent}
3947
+ />
3948
+ </TestWrapper>
3949
+ );
3950
+
3951
+ await enterValuesAndUpdatePreview({ 1: 'Sh', 2: 'Monday' });
3952
+
3953
+ expect(getPreviewContent().templateHeader).toBe('Order for Sh');
3954
+ expect(getPreviewContent().templateMsg).toBe('Hi Sh, delivered by Monday');
3955
+ });
3956
+
3957
+ it('fills entered values when the preview API returns an object with unresolved {{N}}', async () => {
3958
+ render(
3959
+ <TestWrapper>
3960
+ <CommonTestAndPreview
3961
+ {...defaultProps}
3962
+ channel={CHANNELS.WHATSAPP}
3963
+ content={whatsappContent}
3964
+ formData={whatsappContent}
3965
+ previewData={{ resolvedBody: JSON.stringify(whatsappContent) }}
3966
+ />
3967
+ </TestWrapper>
3968
+ );
3969
+
3970
+ await enterValuesAndUpdatePreview({ 1: 'Shash', 2: 'Name' });
3971
+
3972
+ expect(getPreviewContent().templateHeader).toBe('Order for Shash');
3973
+ expect(getPreviewContent().templateMsg).toBe('Hi Shash, delivered by Name');
3974
+ });
3975
+
3976
+ it('fills entered values into WhatsApp carousel card bodies', async () => {
3977
+ const carouselContent = {
3978
+ templateMsg: 'Deals inside',
3979
+ carouselData: [
3980
+ { bodyText: 'Card A {{1}}' },
3981
+ { bodyText: 'Card B {{2}}', updatedBodyText: ['Card B ', '{{2}}'] },
3982
+ ],
3983
+ };
3984
+ render(
3985
+ <TestWrapper>
3986
+ <CommonTestAndPreview
3987
+ {...defaultProps}
3988
+ channel={CHANNELS.WHATSAPP}
3989
+ content={carouselContent}
3990
+ formData={carouselContent}
3991
+ />
3992
+ </TestWrapper>
3993
+ );
3994
+
3995
+ await enterValuesAndUpdatePreview({ 1: 'Sh', 2: 'Monday' });
3996
+
3997
+ expect(getPreviewContent().carouselData[0].bodyText).toBe('Card A Sh');
3998
+ expect(getPreviewContent().carouselData[1].updatedBodyText.join('')).toBe('Card B Monday');
3999
+ });
4000
+
4001
+ it('restores WhatsApp placeholders after Discard custom values', async () => {
4002
+ render(
4003
+ <TestWrapper>
4004
+ <CommonTestAndPreview
4005
+ {...defaultProps}
4006
+ channel={CHANNELS.WHATSAPP}
4007
+ content={whatsappContent}
4008
+ formData={whatsappContent}
4009
+ />
4010
+ </TestWrapper>
4011
+ );
4012
+
4013
+ await enterValuesAndUpdatePreview({ 1: 'Sh', 2: 'Monday' });
4014
+ act(() => {
4015
+ lastCustomValuesEditorProps.handleDiscardCustomValues();
4016
+ });
4017
+
4018
+ expect(getPreviewContent().templateMsg).toBe('Hi {{1}}, delivered by {{2}}');
4019
+ });
4020
+
4021
+ it('fills entered values into an RCS standalone card', async () => {
4022
+ const rcsContent = { templateHeader: 'Store {{1}}', templateMessage: 'Visit store for {{1}} offer' };
4023
+ render(
4024
+ <TestWrapper>
4025
+ <CommonTestAndPreview
4026
+ {...defaultProps}
4027
+ channel={CHANNELS.RCS}
4028
+ content={rcsContent}
4029
+ formData={{ rcsTitle: 'Store {{1}}', rcsDesc: 'Visit store for {{1}} offer' }}
4030
+ />
4031
+ </TestWrapper>
4032
+ );
4033
+
4034
+ await enterValuesAndUpdatePreview({ 1: 'Goog' });
4035
+
4036
+ expect(getPreviewContent().templateHeader).toBe('Store Goog');
4037
+ expect(getPreviewContent().templateMessage).toBe('Visit store for Goog offer');
4038
+ });
4039
+ });
3880
4040
  });
3881
4041
 
@@ -10,6 +10,12 @@ import {
10
10
  hasTrimmedText,
11
11
  getCarouselButtonsWithTitles,
12
12
  getCarouselButtonSlotCount,
13
+ mergeSyntheticMustacheTags,
14
+ getWhatsappTagExtractionText,
15
+ getWhatsappCarouselCardBodyText,
16
+ getWhatsappCarouselTagExtractionText,
17
+ mergeZaloUnfilledParamTags,
18
+ fillSlotMapFromCustomValues,
13
19
  } from '../utils';
14
20
 
15
21
  describe('CommonTestAndPreview/utils', () => {
@@ -197,4 +203,143 @@ describe('CommonTestAndPreview/utils', () => {
197
203
  expect(getCarouselButtonSlotCount([{ buttons: [{ title: ' ' }] }])).toBe(0);
198
204
  });
199
205
  });
206
+
207
+ describe('getWhatsappTagExtractionText', () => {
208
+ it('joins header and message', () => {
209
+ expect(getWhatsappTagExtractionText({ templateHeader: 'Order {{1}}', templateMsg: 'Hi {{2}}' }))
210
+ .toBe('Order {{1}} Hi {{2}}');
211
+ });
212
+
213
+ it('handles missing fields', () => {
214
+ expect(getWhatsappTagExtractionText({ templateMsg: 'Hi {{1}}' })).toBe('Hi {{1}}');
215
+ expect(getWhatsappTagExtractionText(undefined)).toBe('');
216
+ });
217
+
218
+ it('surfaces unmapped WhatsApp slots as tags alongside API tags', () => {
219
+ const apiTags = [{ name: 'adv', metaData: { userDriven: false }, children: [] }];
220
+ const text = getWhatsappTagExtractionText({
221
+ templateHeader: 'Order {{1}}',
222
+ templateMsg: 'Hi {{adv}}, by {{2}}',
223
+ });
224
+ expect(mergeSyntheticMustacheTags(apiTags, text).map((tag) => tag.name)).toEqual(['adv', '1', '2']);
225
+ });
226
+
227
+ it('includes carousel card bodies', () => {
228
+ expect(getWhatsappTagExtractionText({
229
+ templateMsg: 'Hi {{1}}',
230
+ carouselData: [{ bodyText: 'Card A {{1}}' }, { bodyText: 'Card B {{2}}' }],
231
+ })).toBe('Hi {{1}} Card A {{1}} Card B {{2}}');
232
+ });
233
+
234
+ it('surfaces a carousel-only template as having tags', () => {
235
+ const text = getWhatsappTagExtractionText({
236
+ templateMsg: 'Hi there',
237
+ carouselData: [{ bodyText: 'Save {{1}} today' }],
238
+ });
239
+ expect(mergeSyntheticMustacheTags([], text).map((tag) => tag.name)).toEqual(['1']);
240
+ });
241
+ });
242
+
243
+ describe('getWhatsappCarouselCardBodyText', () => {
244
+ it('prefers the slot-resolved updatedBodyText', () => {
245
+ expect(getWhatsappCarouselCardBodyText({
246
+ bodyText: 'Hi {{1}}',
247
+ updatedBodyText: ['Hi ', '{{firstName}}'],
248
+ })).toBe('Hi {{firstName}}');
249
+ });
250
+
251
+ it('falls back to bodyText when no resolved text exists', () => {
252
+ expect(getWhatsappCarouselCardBodyText({ bodyText: 'Hi {{1}}', updatedBodyText: [] })).toBe('Hi {{1}}');
253
+ expect(getWhatsappCarouselCardBodyText({ bodyText: 'Hi {{1}}' })).toBe('Hi {{1}}');
254
+ expect(getWhatsappCarouselCardBodyText(undefined)).toBe('');
255
+ });
256
+ });
257
+
258
+ describe('getWhatsappCarouselTagExtractionText', () => {
259
+ it('skips blank card bodies', () => {
260
+ expect(getWhatsappCarouselTagExtractionText({
261
+ carouselData: [{ bodyText: ' ' }, { bodyText: 'Card B {{2}}' }, {}],
262
+ })).toBe('Card B {{2}}');
263
+ });
264
+
265
+ it('returns empty string for a non-carousel template', () => {
266
+ expect(getWhatsappCarouselTagExtractionText({ templateMsg: 'Hi' })).toBe('');
267
+ });
268
+ });
269
+
270
+ describe('mergeZaloUnfilledParamTags', () => {
271
+ const formData = {
272
+ templateListParams: [
273
+ { name: 'danh_xung', value: '' },
274
+ { name: 'name', value: '{{first_name}}' },
275
+ { name: 'ma_so', value: ' ' },
276
+ ],
277
+ };
278
+
279
+ it('adds a tag for each empty param', () => {
280
+ expect(mergeZaloUnfilledParamTags([], formData)).toEqual([
281
+ { name: 'danh_xung', metaData: { userDriven: false }, children: [] },
282
+ { name: 'ma_so', metaData: { userDriven: false }, children: [] },
283
+ ]);
284
+ });
285
+
286
+ it('keeps API tags and does not duplicate names', () => {
287
+ const apiTags = [{ name: 'danh_xung', metaData: { userDriven: true }, children: [] }];
288
+ const result = mergeZaloUnfilledParamTags(apiTags, formData);
289
+ expect(result.map((tag) => tag.name)).toEqual(['danh_xung', 'ma_so']);
290
+ expect(result[0]).toBe(apiTags[0]);
291
+ });
292
+
293
+ it('returns API tags when there are no params', () => {
294
+ expect(mergeZaloUnfilledParamTags(undefined, {})).toEqual([]);
295
+ });
296
+
297
+ it('handles missing args, unnamed params and null values', () => {
298
+ expect(mergeZaloUnfilledParamTags()).toEqual([]);
299
+ expect(mergeZaloUnfilledParamTags(null, {
300
+ templateListParams: [{ value: '' }, { name: 'a', value: null }, { name: 'b' }],
301
+ }).map((tag) => tag.name)).toEqual(['a', 'b']);
302
+ });
303
+ });
304
+
305
+ describe('fillSlotMapFromCustomValues', () => {
306
+ it('fills blank slots from custom values, keyed by tag name', () => {
307
+ expect(fillSlotMapFromCustomValues(
308
+ { '{{1}}_1': '', '{{2}}_3': ' ' },
309
+ { 1: 'Shash', 2: 'Name' },
310
+ )).toEqual({ '{{1}}_1': 'Shash', '{{2}}_3': 'Name' });
311
+ });
312
+
313
+ it('keeps slots already mapped on Overview', () => {
314
+ expect(fillSlotMapFromCustomValues(
315
+ { '{{1}}_1': '{{first_name}}', '{{adv}}_3': '' },
316
+ { first_name: 'ignored', adv: 'Promo' },
317
+ )).toEqual({ '{{1}}_1': '{{first_name}}', '{{adv}}_3': 'Promo' });
318
+ });
319
+
320
+ it('supports bare numeric and semantic slot keys (RCS cardVarMapped)', () => {
321
+ expect(fillSlotMapFromCustomValues({ 1: '', user_name: '' }, { 1: 'A', user_name: 'B' }))
322
+ .toEqual({ 1: 'A', user_name: 'B' });
323
+ });
324
+
325
+ it('matches custom values given as nested tag paths', () => {
326
+ expect(fillSlotMapFromCustomValues({ '{{first_name}}_0': '' }, { 'customer.first_name': 'Sh' }))
327
+ .toEqual({ '{{first_name}}_0': 'Sh' });
328
+ });
329
+
330
+ it('returns the map unchanged when there is nothing to fill', () => {
331
+ const slotMap = { '{{1}}_1': '' };
332
+ expect(fillSlotMapFromCustomValues(slotMap, {})).toBe(slotMap);
333
+ expect(fillSlotMapFromCustomValues(slotMap, { 2: 'x' })).toEqual(slotMap);
334
+ expect(fillSlotMapFromCustomValues(null, { 1: 'x' })).toBeNull();
335
+ expect(fillSlotMapFromCustomValues(undefined)).toBeUndefined();
336
+ });
337
+ });
338
+
339
+ describe('mergeSyntheticMustacheTags', () => {
340
+ it('handles null API tags', () => {
341
+ expect(mergeSyntheticMustacheTags(null, 'Hi {{1}}').map((tag) => tag.name)).toEqual(['1']);
342
+ expect(mergeSyntheticMustacheTags(null, 'no tags')).toEqual([]);
343
+ });
344
+ });
200
345
  });