@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 +1 -1
- package/v2Components/CommonTestAndPreview/constants.js +3 -0
- package/v2Components/CommonTestAndPreview/index.js +107 -17
- package/v2Components/CommonTestAndPreview/tests/index.test.js +162 -2
- package/v2Components/CommonTestAndPreview/tests/utils.test.js +145 -0
- package/v2Components/CommonTestAndPreview/utils.js +59 -1
- package/v2Containers/CreativesContainer/SlideBoxContent.js +2 -0
- package/v2Containers/Rcs/index.js +27 -1
- package/v2Containers/Rcs/tests/testAndPreviewFromListing.test.js +132 -0
- package/v2Containers/Templates/index.js +9 -4
- package/v2Containers/Templates/tests/index.test.js +107 -35
- package/v2Containers/Whatsapp/index.js +26 -8
- package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +2 -2
- package/v2Containers/Whatsapp/tests/testAndPreviewFromListing.test.js +91 -0
- package/v2Containers/Zalo/index.js +18 -0
- package/v2Containers/Zalo/tests/testAndPreviewFromListing.test.js +98 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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 && {
|
|
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:
|
|
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
|
-
|
|
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] =
|
|
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 {
|
|
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
|
});
|