@capillarytech/creatives-library 9.0.72 → 9.0.73-alpha.sprint-testpreview.22
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/index.js +62 -7
- package/v2Components/CommonTestAndPreview/tests/index.test.js +102 -2
- package/v2Components/CommonTestAndPreview/tests/utils.test.js +66 -0
- package/v2Components/CommonTestAndPreview/utils.js +14 -0
- 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 +18 -1
- 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
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
mapRcsSuggestionForTestMeta,
|
|
40
40
|
getRcsPrimaryTagExtractionText,
|
|
41
41
|
mergeSyntheticMustacheTags,
|
|
42
|
+
getWhatsappTagExtractionText,
|
|
43
|
+
mergeZaloUnfilledParamTags,
|
|
42
44
|
} from './utils';
|
|
43
45
|
import AddTestCustomerButton from './AddTestCustomer';
|
|
44
46
|
import ExistingCustomerModal from './ExistingCustomerModal';
|
|
@@ -235,6 +237,8 @@ const CommonTestAndPreview = (props) => {
|
|
|
235
237
|
const [isExtractingSmsFallbackTags, setIsExtractingSmsFallbackTags] = useState(false);
|
|
236
238
|
const [customValues, setCustomValues] = useState({});
|
|
237
239
|
const previewCustomValuesRef = useRef({});
|
|
240
|
+
// Values last applied to the preview (Update / Discard / customer prefill); drives client-side fill-in.
|
|
241
|
+
const [previewTagValues, setPreviewTagValues] = useState(null);
|
|
238
242
|
const [showJSON, setShowJSON] = useState(false);
|
|
239
243
|
const [tagsExtracted, setTagsExtracted] = useState(false);
|
|
240
244
|
|
|
@@ -595,6 +599,12 @@ const CommonTestAndPreview = (props) => {
|
|
|
595
599
|
if (channel === CHANNELS.RCS) {
|
|
596
600
|
return mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
|
|
597
601
|
}
|
|
602
|
+
if (channel === CHANNELS.WHATSAPP) {
|
|
603
|
+
return mergeSyntheticMustacheTags(extractedTags, getWhatsappTagExtractionText(formData));
|
|
604
|
+
}
|
|
605
|
+
if (channel === CHANNELS.ZALO) {
|
|
606
|
+
return mergeZaloUnfilledParamTags(extractedTags, formData);
|
|
607
|
+
}
|
|
598
608
|
return extractedTags ?? [];
|
|
599
609
|
}, [
|
|
600
610
|
channel,
|
|
@@ -680,7 +690,8 @@ const CommonTestAndPreview = (props) => {
|
|
|
680
690
|
const tagName = tagPath.split('.').pop(); // Get the actual tag name from the path
|
|
681
691
|
// Escape regex metacharacters in tagName (arbitrary JSON key) so it can't break the pattern.
|
|
682
692
|
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
683
|
-
|
|
693
|
+
// Braces escaped: unescaped `{{1}}` parses `{1}` as a quantifier and never matches numbered slots.
|
|
694
|
+
const tagRegex = new RegExp(`\\{\\{${escapedTagName}\\}\\}`, 'g');
|
|
684
695
|
const rawValue = tagValues[tagPath];
|
|
685
696
|
// Preserve legitimate falsy values (0, false); only an unset/blank slot keeps the placeholder.
|
|
686
697
|
const hasValue = rawValue !== null && rawValue !== undefined && rawValue !== '';
|
|
@@ -1546,7 +1557,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
1546
1557
|
|
|
1547
1558
|
// Build whatsappMedia structure
|
|
1548
1559
|
const whatsappMedia = {
|
|
1549
|
-
header: formDataObj?.templateHeader || formDataObj?.header || '',
|
|
1560
|
+
header: resolveTagsInText(formDataObj?.templateHeader || formDataObj?.header || '', customValuesObj),
|
|
1550
1561
|
footer: formDataObj?.templateFooter || formDataObj?.footer || '',
|
|
1551
1562
|
headerVarMapped: formDataObj?.headerVarMapped || formDataObj?.headerVarMappedData || {},
|
|
1552
1563
|
headerTemplate: formDataObj?.headerTemplate || formDataObj?.templateHeader || '',
|
|
@@ -2414,7 +2425,10 @@ const CommonTestAndPreview = (props) => {
|
|
|
2414
2425
|
const resolvedVarMapped = {};
|
|
2415
2426
|
Object.keys(templateConfigs.varMapped).forEach((key) => {
|
|
2416
2427
|
const value = templateConfigs.varMapped[key];
|
|
2417
|
-
|
|
2428
|
+
// Param left empty on Overview → use the value entered in Test & Preview.
|
|
2429
|
+
resolvedVarMapped[key] = value
|
|
2430
|
+
? resolveTagsInText(value, customValuesObj)
|
|
2431
|
+
: (customValuesObj[key] ?? '');
|
|
2418
2432
|
});
|
|
2419
2433
|
templateConfigs.varMapped = resolvedVarMapped;
|
|
2420
2434
|
}
|
|
@@ -2445,7 +2459,9 @@ const CommonTestAndPreview = (props) => {
|
|
|
2445
2459
|
const resolvedVarMapped = {};
|
|
2446
2460
|
Object.keys(varMapped).forEach((key) => {
|
|
2447
2461
|
const value = varMapped[key];
|
|
2448
|
-
resolvedVarMapped[key] =
|
|
2462
|
+
resolvedVarMapped[key] = value
|
|
2463
|
+
? resolveTagsInText(value, customValuesObj)
|
|
2464
|
+
: (customValuesObj[key] ?? '');
|
|
2449
2465
|
});
|
|
2450
2466
|
varMapped = resolvedVarMapped;
|
|
2451
2467
|
}
|
|
@@ -2535,10 +2551,27 @@ const CommonTestAndPreview = (props) => {
|
|
|
2535
2551
|
*
|
|
2536
2552
|
* IMPORTANT: Use raw content/formData initially, only use previewDataHtml if preview call was made
|
|
2537
2553
|
*/
|
|
2554
|
+
// Fills last-applied test values into raw template text when the preview API reply isn't usable.
|
|
2555
|
+
const resolvePreviewText = (text) => (
|
|
2556
|
+
typeof text === 'string' && previewTagValues ? resolveTagsInText(text, previewTagValues) : text
|
|
2557
|
+
);
|
|
2558
|
+
|
|
2538
2559
|
// Memoized RCS carousel content: only recomputes when the API response or raw template changes,
|
|
2539
2560
|
// NOT on every customValues keystroke. Uses previewCustomValuesRef captured at Update Preview time.
|
|
2540
2561
|
const rcsCarouselContentObj = useMemo(() => {
|
|
2541
2562
|
if (channel !== CHANNELS.RCS) return null;
|
|
2563
|
+
// Standalone card: fill entered values client-side; the preview API leaves numbered {{N}} unresolved.
|
|
2564
|
+
const fillStandaloneCard = (card) => (
|
|
2565
|
+
hasPreviewCallBeenMade && previewTagValues && card && typeof card === 'object' && !Array.isArray(card.carouselData)
|
|
2566
|
+
? {
|
|
2567
|
+
...card,
|
|
2568
|
+
templateHeader: resolvePreviewText(card.templateHeader),
|
|
2569
|
+
templateMessage: resolvePreviewText(card.templateMessage),
|
|
2570
|
+
rcsTitle: resolvePreviewText(card.rcsTitle),
|
|
2571
|
+
rcsDesc: resolvePreviewText(card.rcsDesc),
|
|
2572
|
+
}
|
|
2573
|
+
: card
|
|
2574
|
+
);
|
|
2542
2575
|
let resolvedContent = null;
|
|
2543
2576
|
if (hasPreviewCallBeenMade && previewDataHtml?.resolvedBody) {
|
|
2544
2577
|
resolvedContent = previewDataHtml.resolvedBody;
|
|
@@ -2546,7 +2579,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
2546
2579
|
try { resolvedContent = JSON.parse(resolvedContent); } catch (e) { resolvedContent = null; }
|
|
2547
2580
|
}
|
|
2548
2581
|
}
|
|
2549
|
-
if (resolvedContent && typeof resolvedContent === 'object') return resolvedContent;
|
|
2582
|
+
if (resolvedContent && typeof resolvedContent === 'object') return fillStandaloneCard(resolvedContent);
|
|
2550
2583
|
|
|
2551
2584
|
let rawRcs = content;
|
|
2552
2585
|
if (typeof rawRcs === 'string') {
|
|
@@ -2571,9 +2604,9 @@ const CommonTestAndPreview = (props) => {
|
|
|
2571
2604
|
}),
|
|
2572
2605
|
};
|
|
2573
2606
|
}
|
|
2574
|
-
return rawRcs || {};
|
|
2607
|
+
return fillStandaloneCard(rawRcs) || {};
|
|
2575
2608
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2576
|
-
}, [channel, content, hasPreviewCallBeenMade, previewDataHtml, formData]);
|
|
2609
|
+
}, [channel, content, hasPreviewCallBeenMade, previewDataHtml, formData, previewTagValues]);
|
|
2577
2610
|
|
|
2578
2611
|
const prepareUnifiedPreviewProps = () => {
|
|
2579
2612
|
// Prepare content based on channel
|
|
@@ -2635,6 +2668,14 @@ const CommonTestAndPreview = (props) => {
|
|
|
2635
2668
|
// Use raw content if no preview call was made or resolvedContent is not available
|
|
2636
2669
|
contentObj = parsedContent || {};
|
|
2637
2670
|
}
|
|
2671
|
+
// WhatsApp: fill entered values client-side; the preview API leaves numbered {{N}} unresolved.
|
|
2672
|
+
if (channel === CHANNELS.WHATSAPP && hasPreviewCallBeenMade) {
|
|
2673
|
+
contentObj = {
|
|
2674
|
+
...contentObj,
|
|
2675
|
+
templateHeader: resolvePreviewText(contentObj.templateHeader),
|
|
2676
|
+
templateMsg: resolvePreviewText(contentObj.templateMsg),
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2638
2679
|
} else if (channel === CHANNELS.RCS) {
|
|
2639
2680
|
// rcsCarouselContentObj is a useMemo that handles both the API-response path and the
|
|
2640
2681
|
// client-side fallback. It is stable across keystrokes (only recomputes when
|
|
@@ -3088,6 +3129,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
3088
3129
|
previousBeeContentRef.current = '';
|
|
3089
3130
|
setBeeContent('');
|
|
3090
3131
|
setPreviewDataHtml('');
|
|
3132
|
+
setPreviewTagValues(null);
|
|
3091
3133
|
setSelectedCustomer(null);
|
|
3092
3134
|
setRequiredTags([]);
|
|
3093
3135
|
setOptionalTags([]);
|
|
@@ -3177,6 +3219,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
3177
3219
|
updatedValues,
|
|
3178
3220
|
selectedCustomer
|
|
3179
3221
|
);
|
|
3222
|
+
setPreviewTagValues(updatedValues);
|
|
3180
3223
|
actions.updatePreviewRequested(payload);
|
|
3181
3224
|
setHasPreviewCallBeenMade(true); // Mark that preview call was made
|
|
3182
3225
|
void syncSmsFallbackPreview(updatedValues, selectedCustomer);
|
|
@@ -3246,6 +3289,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
3246
3289
|
setTagsExtracted(false);
|
|
3247
3290
|
setPreviewDevice(DESKTOP);
|
|
3248
3291
|
setPreviewDataHtml('');
|
|
3292
|
+
setPreviewTagValues(null);
|
|
3249
3293
|
setHasPreviewCallBeenMade(false); // Reset preview call flag
|
|
3250
3294
|
setViberPreviewTagValues(null);
|
|
3251
3295
|
setSelectedTestEntities([]);
|
|
@@ -3284,6 +3328,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
3284
3328
|
*/
|
|
3285
3329
|
const handleClearSelection = () => {
|
|
3286
3330
|
setSelectedCustomer(null);
|
|
3331
|
+
setPreviewTagValues(null);
|
|
3287
3332
|
setHasPreviewCallBeenMade(false); // Reset flag when customer is cleared
|
|
3288
3333
|
setViberPreviewTagValues(null);
|
|
3289
3334
|
|
|
@@ -3347,6 +3392,8 @@ const CommonTestAndPreview = (props) => {
|
|
|
3347
3392
|
if (channel === CHANNELS.VIBER) {
|
|
3348
3393
|
setViberPreviewTagValues({ ...emptyValues });
|
|
3349
3394
|
}
|
|
3395
|
+
previewCustomValuesRef.current = emptyValues;
|
|
3396
|
+
setPreviewTagValues(emptyValues);
|
|
3350
3397
|
actions.updatePreviewRequested(payload);
|
|
3351
3398
|
setHasPreviewCallBeenMade(true); // Mark that preview call was made
|
|
3352
3399
|
};
|
|
@@ -3360,6 +3407,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
3360
3407
|
// Capture customValues at click time so the carousel preview only updates here,
|
|
3361
3408
|
// not on every subsequent keystroke.
|
|
3362
3409
|
previewCustomValuesRef.current = customValues;
|
|
3410
|
+
setPreviewTagValues(customValues);
|
|
3363
3411
|
// For RCS: always dispatch with RCS channel/content so previewDataHtml stays RCS-shaped,
|
|
3364
3412
|
// even when the user triggers update from the SMS fallback tab.
|
|
3365
3413
|
// SMS fallback preview is kept in sync by syncSmsFallbackPreview via smsFallbackPreviewText.
|
|
@@ -3459,6 +3507,13 @@ const CommonTestAndPreview = (props) => {
|
|
|
3459
3507
|
if (channel === CHANNELS.SMS && !smsTemplateHasMustacheTags(smsEditorBody)) {
|
|
3460
3508
|
smsTagSource = [];
|
|
3461
3509
|
}
|
|
3510
|
+
// Unmapped variables become fillable test values, like RCS.
|
|
3511
|
+
if (channel === CHANNELS.WHATSAPP) {
|
|
3512
|
+
smsTagSource = mergeSyntheticMustacheTags(smsTagSource, getWhatsappTagExtractionText(formData));
|
|
3513
|
+
}
|
|
3514
|
+
if (channel === CHANNELS.ZALO) {
|
|
3515
|
+
smsTagSource = mergeZaloUnfilledParamTags(smsTagSource, formData);
|
|
3516
|
+
}
|
|
3462
3517
|
const { required, optional, hasPersonalizationTags } = categorizeTags(smsTagSource);
|
|
3463
3518
|
setRequiredTags(required);
|
|
3464
3519
|
setOptionalTags(optional);
|
|
@@ -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)) {
|
|
@@ -3782,5 +3787,100 @@ describe('CommonTestAndPreview', () => {
|
|
|
3782
3787
|
expect(mockActions.updatePreviewRequested).toHaveBeenCalledTimes(1);
|
|
3783
3788
|
});
|
|
3784
3789
|
});
|
|
3790
|
+
|
|
3791
|
+
describe('client-side preview fill-in when the preview API reply is not an object', () => {
|
|
3792
|
+
const getPreviewContent = () => lastPreviewSectionProps.unifiedPreviewProps.content;
|
|
3793
|
+
|
|
3794
|
+
const enterValuesAndUpdatePreview = async (values) => {
|
|
3795
|
+
await waitFor(() => expect(lastCustomValuesEditorProps).not.toBeNull());
|
|
3796
|
+
act(() => {
|
|
3797
|
+
Object.entries(values).forEach(([tag, value]) => lastCustomValuesEditorProps.handleCustomValueChange(tag, value));
|
|
3798
|
+
});
|
|
3799
|
+
await act(async () => {
|
|
3800
|
+
await lastCustomValuesEditorProps.handleUpdatePreview();
|
|
3801
|
+
});
|
|
3802
|
+
};
|
|
3803
|
+
|
|
3804
|
+
const whatsappContent = {
|
|
3805
|
+
templateHeader: 'Order for {{1}}',
|
|
3806
|
+
templateMsg: 'Hi {{1}}, delivered by {{2}}',
|
|
3807
|
+
};
|
|
3808
|
+
|
|
3809
|
+
it('fills entered values into the WhatsApp header and message', async () => {
|
|
3810
|
+
render(
|
|
3811
|
+
<TestWrapper>
|
|
3812
|
+
<CommonTestAndPreview
|
|
3813
|
+
{...defaultProps}
|
|
3814
|
+
channel={CHANNELS.WHATSAPP}
|
|
3815
|
+
content={whatsappContent}
|
|
3816
|
+
formData={whatsappContent}
|
|
3817
|
+
/>
|
|
3818
|
+
</TestWrapper>
|
|
3819
|
+
);
|
|
3820
|
+
|
|
3821
|
+
await enterValuesAndUpdatePreview({ 1: 'Sh', 2: 'Monday' });
|
|
3822
|
+
|
|
3823
|
+
expect(getPreviewContent().templateHeader).toBe('Order for Sh');
|
|
3824
|
+
expect(getPreviewContent().templateMsg).toBe('Hi Sh, delivered by Monday');
|
|
3825
|
+
});
|
|
3826
|
+
|
|
3827
|
+
it('fills entered values when the preview API returns an object with unresolved {{N}}', async () => {
|
|
3828
|
+
render(
|
|
3829
|
+
<TestWrapper>
|
|
3830
|
+
<CommonTestAndPreview
|
|
3831
|
+
{...defaultProps}
|
|
3832
|
+
channel={CHANNELS.WHATSAPP}
|
|
3833
|
+
content={whatsappContent}
|
|
3834
|
+
formData={whatsappContent}
|
|
3835
|
+
previewData={{ resolvedBody: JSON.stringify(whatsappContent) }}
|
|
3836
|
+
/>
|
|
3837
|
+
</TestWrapper>
|
|
3838
|
+
);
|
|
3839
|
+
|
|
3840
|
+
await enterValuesAndUpdatePreview({ 1: 'Shash', 2: 'Name' });
|
|
3841
|
+
|
|
3842
|
+
expect(getPreviewContent().templateHeader).toBe('Order for Shash');
|
|
3843
|
+
expect(getPreviewContent().templateMsg).toBe('Hi Shash, delivered by Name');
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3846
|
+
it('restores WhatsApp placeholders after Discard custom values', async () => {
|
|
3847
|
+
render(
|
|
3848
|
+
<TestWrapper>
|
|
3849
|
+
<CommonTestAndPreview
|
|
3850
|
+
{...defaultProps}
|
|
3851
|
+
channel={CHANNELS.WHATSAPP}
|
|
3852
|
+
content={whatsappContent}
|
|
3853
|
+
formData={whatsappContent}
|
|
3854
|
+
/>
|
|
3855
|
+
</TestWrapper>
|
|
3856
|
+
);
|
|
3857
|
+
|
|
3858
|
+
await enterValuesAndUpdatePreview({ 1: 'Sh', 2: 'Monday' });
|
|
3859
|
+
act(() => {
|
|
3860
|
+
lastCustomValuesEditorProps.handleDiscardCustomValues();
|
|
3861
|
+
});
|
|
3862
|
+
|
|
3863
|
+
expect(getPreviewContent().templateMsg).toBe('Hi {{1}}, delivered by {{2}}');
|
|
3864
|
+
});
|
|
3865
|
+
|
|
3866
|
+
it('fills entered values into an RCS standalone card', async () => {
|
|
3867
|
+
const rcsContent = { templateHeader: 'Store {{1}}', templateMessage: 'Visit store for {{1}} offer' };
|
|
3868
|
+
render(
|
|
3869
|
+
<TestWrapper>
|
|
3870
|
+
<CommonTestAndPreview
|
|
3871
|
+
{...defaultProps}
|
|
3872
|
+
channel={CHANNELS.RCS}
|
|
3873
|
+
content={rcsContent}
|
|
3874
|
+
formData={{ rcsTitle: 'Store {{1}}', rcsDesc: 'Visit store for {{1}} offer' }}
|
|
3875
|
+
/>
|
|
3876
|
+
</TestWrapper>
|
|
3877
|
+
);
|
|
3878
|
+
|
|
3879
|
+
await enterValuesAndUpdatePreview({ 1: 'Goog' });
|
|
3880
|
+
|
|
3881
|
+
expect(getPreviewContent().templateHeader).toBe('Store Goog');
|
|
3882
|
+
expect(getPreviewContent().templateMessage).toBe('Visit store for Goog offer');
|
|
3883
|
+
});
|
|
3884
|
+
});
|
|
3785
3885
|
});
|
|
3786
3886
|
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
hasTrimmedText,
|
|
11
11
|
getCarouselButtonsWithTitles,
|
|
12
12
|
getCarouselButtonSlotCount,
|
|
13
|
+
mergeSyntheticMustacheTags,
|
|
14
|
+
getWhatsappTagExtractionText,
|
|
15
|
+
mergeZaloUnfilledParamTags,
|
|
13
16
|
} from '../utils';
|
|
14
17
|
|
|
15
18
|
describe('CommonTestAndPreview/utils', () => {
|
|
@@ -197,4 +200,67 @@ describe('CommonTestAndPreview/utils', () => {
|
|
|
197
200
|
expect(getCarouselButtonSlotCount([{ buttons: [{ title: ' ' }] }])).toBe(0);
|
|
198
201
|
});
|
|
199
202
|
});
|
|
203
|
+
|
|
204
|
+
describe('getWhatsappTagExtractionText', () => {
|
|
205
|
+
it('joins header and message', () => {
|
|
206
|
+
expect(getWhatsappTagExtractionText({ templateHeader: 'Order {{1}}', templateMsg: 'Hi {{2}}' }))
|
|
207
|
+
.toBe('Order {{1}} Hi {{2}}');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('handles missing fields', () => {
|
|
211
|
+
expect(getWhatsappTagExtractionText({ templateMsg: 'Hi {{1}}' })).toBe('Hi {{1}}');
|
|
212
|
+
expect(getWhatsappTagExtractionText(undefined)).toBe('');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('surfaces unmapped WhatsApp slots as tags alongside API tags', () => {
|
|
216
|
+
const apiTags = [{ name: 'adv', metaData: { userDriven: false }, children: [] }];
|
|
217
|
+
const text = getWhatsappTagExtractionText({
|
|
218
|
+
templateHeader: 'Order {{1}}',
|
|
219
|
+
templateMsg: 'Hi {{adv}}, by {{2}}',
|
|
220
|
+
});
|
|
221
|
+
expect(mergeSyntheticMustacheTags(apiTags, text).map((tag) => tag.name)).toEqual(['adv', '1', '2']);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('mergeZaloUnfilledParamTags', () => {
|
|
226
|
+
const formData = {
|
|
227
|
+
templateListParams: [
|
|
228
|
+
{ name: 'danh_xung', value: '' },
|
|
229
|
+
{ name: 'name', value: '{{first_name}}' },
|
|
230
|
+
{ name: 'ma_so', value: ' ' },
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
it('adds a tag for each empty param', () => {
|
|
235
|
+
expect(mergeZaloUnfilledParamTags([], formData)).toEqual([
|
|
236
|
+
{ name: 'danh_xung', metaData: { userDriven: false }, children: [] },
|
|
237
|
+
{ name: 'ma_so', metaData: { userDriven: false }, children: [] },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('keeps API tags and does not duplicate names', () => {
|
|
242
|
+
const apiTags = [{ name: 'danh_xung', metaData: { userDriven: true }, children: [] }];
|
|
243
|
+
const result = mergeZaloUnfilledParamTags(apiTags, formData);
|
|
244
|
+
expect(result.map((tag) => tag.name)).toEqual(['danh_xung', 'ma_so']);
|
|
245
|
+
expect(result[0]).toBe(apiTags[0]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns API tags when there are no params', () => {
|
|
249
|
+
expect(mergeZaloUnfilledParamTags(undefined, {})).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('handles missing args, unnamed params and null values', () => {
|
|
253
|
+
expect(mergeZaloUnfilledParamTags()).toEqual([]);
|
|
254
|
+
expect(mergeZaloUnfilledParamTags(null, {
|
|
255
|
+
templateListParams: [{ value: '' }, { name: 'a', value: null }, { name: 'b' }],
|
|
256
|
+
}).map((tag) => tag.name)).toEqual(['a', 'b']);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('mergeSyntheticMustacheTags', () => {
|
|
261
|
+
it('handles null API tags', () => {
|
|
262
|
+
expect(mergeSyntheticMustacheTags(null, 'Hi {{1}}').map((tag) => tag.name)).toEqual(['1']);
|
|
263
|
+
expect(mergeSyntheticMustacheTags(null, 'no tags')).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
200
266
|
});
|
|
@@ -61,6 +61,20 @@ export const mergeSyntheticMustacheTags = (apiTags = [], rawText = '') => {
|
|
|
61
61
|
return [...(apiTags ?? []), ...synthetic];
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/** WhatsApp header + body text, scanned for unmapped `{{N}}` slots. */
|
|
65
|
+
export const getWhatsappTagExtractionText = (formDataObj) => (
|
|
66
|
+
`${formDataObj?.templateHeader || ''} ${formDataObj?.templateMsg || ''}`.trim()
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** Zalo params left empty on Overview, surfaced as fillable test values (keyed by param name). */
|
|
70
|
+
export const mergeZaloUnfilledParamTags = (apiTags = [], formDataObj = {}) => {
|
|
71
|
+
const existingNames = new Set((apiTags ?? []).map((tag) => tag?.name));
|
|
72
|
+
const unfilledParamTags = (formDataObj?.templateListParams || [])
|
|
73
|
+
.filter((param) => param?.name && !String(param?.value ?? '').trim() && !existingNames.has(param.name))
|
|
74
|
+
.map((param) => ({ name: param.name, metaData: { userDriven: false }, children: [] }));
|
|
75
|
+
return [...(apiTags ?? []), ...unfilledParamTags];
|
|
76
|
+
};
|
|
77
|
+
|
|
64
78
|
/** RCS createMessageMeta: normalize media shape (mediaUrl, thumbnailUrl, height string). */
|
|
65
79
|
export const normalizeRcsTestCardMedia = (media) => {
|
|
66
80
|
if (!media || typeof media !== 'object') return undefined;
|
|
@@ -653,6 +653,7 @@ export function SlideBoxContent(props) {
|
|
|
653
653
|
showTestAndPreviewSlidebox={showTestAndPreviewSlidebox}
|
|
654
654
|
handleTestAndPreview={handleTestAndPreview}
|
|
655
655
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
656
|
+
handleClose={handleClose}
|
|
656
657
|
onContentValidityChange={onContentValidityChange}
|
|
657
658
|
/>
|
|
658
659
|
}
|
|
@@ -1157,6 +1158,7 @@ export function SlideBoxContent(props) {
|
|
|
1157
1158
|
handleTestAndPreview={handleTestAndPreview}
|
|
1158
1159
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
1159
1160
|
isTestAndPreviewMode={isTestAndPreviewMode}
|
|
1161
|
+
handleClose={handleClose}
|
|
1160
1162
|
params={{
|
|
1161
1163
|
id: templateData._id,
|
|
1162
1164
|
}}
|
|
@@ -312,6 +312,8 @@ export const Rcs = (props) => {
|
|
|
312
312
|
|
|
313
313
|
// TestAndPreviewSlidebox state
|
|
314
314
|
const [showTestAndPreviewSlidebox, setShowTestAndPreviewSlidebox] = useState(false);
|
|
315
|
+
// Set when opened from listing "Preview and test"; closing then returns to the listing.
|
|
316
|
+
const isTestAndPreviewFromListingRef = useRef(false);
|
|
315
317
|
|
|
316
318
|
// Handle Test and Preview button click
|
|
317
319
|
const handleTestAndPreview = useCallback(() => {
|
|
@@ -328,7 +330,11 @@ export const Rcs = (props) => {
|
|
|
328
330
|
if (propsHandleCloseTestAndPreview) {
|
|
329
331
|
propsHandleCloseTestAndPreview();
|
|
330
332
|
}
|
|
331
|
-
|
|
333
|
+
if (isTestAndPreviewFromListingRef.current && handleClose) {
|
|
334
|
+
isTestAndPreviewFromListingRef.current = false;
|
|
335
|
+
handleClose();
|
|
336
|
+
}
|
|
337
|
+
}, [propsHandleCloseTestAndPreview, handleClose]);
|
|
332
338
|
|
|
333
339
|
// Helper to get RCS orientation from selectedDimension
|
|
334
340
|
const getRcsOrientation = () => {
|
|
@@ -1437,6 +1443,26 @@ export const Rcs = (props) => {
|
|
|
1437
1443
|
}
|
|
1438
1444
|
}, [rcsHydrationDetails, isFullMode, isHostInfoBip]);
|
|
1439
1445
|
|
|
1446
|
+
// Listing "Preview and test": open Test & Preview once the template is hydrated.
|
|
1447
|
+
const hasAutoOpenedTestAndPreviewRef = useRef(false);
|
|
1448
|
+
useEffect(() => {
|
|
1449
|
+
if (!isFullMode || !templateData?.openTestAndPreview || hasAutoOpenedTestAndPreviewRef.current) return;
|
|
1450
|
+
if (spin || !isEditFlow || smsFallbackData === undefined) return;
|
|
1451
|
+
if (!(isHostInfoBip || templateStatus === RCS_STATUSES.approved)) return;
|
|
1452
|
+
hasAutoOpenedTestAndPreviewRef.current = true;
|
|
1453
|
+
isTestAndPreviewFromListingRef.current = true;
|
|
1454
|
+
handleTestAndPreview();
|
|
1455
|
+
}, [
|
|
1456
|
+
isFullMode,
|
|
1457
|
+
templateData?.openTestAndPreview,
|
|
1458
|
+
spin,
|
|
1459
|
+
isEditFlow,
|
|
1460
|
+
smsFallbackData,
|
|
1461
|
+
isHostInfoBip,
|
|
1462
|
+
templateStatus,
|
|
1463
|
+
handleTestAndPreview,
|
|
1464
|
+
]);
|
|
1465
|
+
|
|
1440
1466
|
useEffect(() => {
|
|
1441
1467
|
if (templateType === contentType.text_message) {
|
|
1442
1468
|
setTemplateMediaType(RCS_MEDIA_TYPES.NONE);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Provider } from 'react-redux';
|
|
3
|
+
import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
|
|
4
|
+
import { initialReducer } from '../../../initialReducer';
|
|
5
|
+
import { mountWithIntl } from '../../../../app/helpers/intl-enzym-test-helpers';
|
|
6
|
+
import { Rcs } from '../index';
|
|
7
|
+
import history from '../../../utils/history';
|
|
8
|
+
|
|
9
|
+
jest.mock('react-redux', () => {
|
|
10
|
+
const actual = jest.requireActual('react-redux');
|
|
11
|
+
return { ...actual, useDispatch: jest.fn(() => jest.fn()) };
|
|
12
|
+
});
|
|
13
|
+
jest.mock('../../../v2Containers/TagList/index.js', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
default: () => <div className="tag-mock">TagList</div>,
|
|
16
|
+
}));
|
|
17
|
+
jest.mock('../../../v2Components/CapImageUpload', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: () => <div className="cap-image-upload-mock" />,
|
|
20
|
+
}));
|
|
21
|
+
jest.mock('../../../v2Components/CapVideoUpload', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: () => <div className="cap-video-upload-mock" />,
|
|
24
|
+
}));
|
|
25
|
+
jest.mock('../../../v2Components/CapActionButton', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
default: () => <div className="cap-action-button-mock" />,
|
|
28
|
+
}));
|
|
29
|
+
jest.mock('@capillarytech/cap-ui-library/CapIcon', () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
default: () => <span className="cap-icon-mock" />,
|
|
32
|
+
}));
|
|
33
|
+
jest.mock('../../CreativesContainer', () => ({
|
|
34
|
+
__esModule: true,
|
|
35
|
+
default: () => <div className="creatives-container-mock" />,
|
|
36
|
+
}));
|
|
37
|
+
// Stub exposes only open state + close action.
|
|
38
|
+
jest.mock('../../../v2Components/TestAndPreviewSlidebox', () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
default: ({ show, onClose }) => (show ? <button type="button" className="mock-test-preview-close" onClick={onClose} /> : null),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
let store;
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
store = configureStore({}, initialReducer, history);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const buildTemplateDetails = (status) => ({
|
|
49
|
+
_id: 'rcs_1',
|
|
50
|
+
name: 'RcsTemplate',
|
|
51
|
+
type: 'RCS',
|
|
52
|
+
versions: {
|
|
53
|
+
base: {
|
|
54
|
+
content: {
|
|
55
|
+
RCS: {
|
|
56
|
+
rcsContent: {
|
|
57
|
+
cardType: 'STANDALONE',
|
|
58
|
+
cardSettings: { cardOrientation: 'VERTICAL' },
|
|
59
|
+
contentType: 'text_message',
|
|
60
|
+
cardContent: [{ title: '', description: 'Hello there', mediaType: 'NONE', Status: status }],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('Rcs - Preview and test from listing', () => {
|
|
69
|
+
const handleClose = jest.fn();
|
|
70
|
+
|
|
71
|
+
const renderEdit = (templateData, status = 'approved') => {
|
|
72
|
+
const wrapper = mountWithIntl(
|
|
73
|
+
<Provider store={store}>
|
|
74
|
+
<Rcs
|
|
75
|
+
actions={{
|
|
76
|
+
uploadRcsAsset: jest.fn(),
|
|
77
|
+
clearRcsMediaAsset: jest.fn(),
|
|
78
|
+
createTemplate: jest.fn(),
|
|
79
|
+
clearCreateResponse: jest.fn(),
|
|
80
|
+
// Details come in via rcsData; just release the spinner.
|
|
81
|
+
getTemplateDetails: jest.fn((id, setSpin) => setSpin && setSpin(false)),
|
|
82
|
+
editTemplate: jest.fn(),
|
|
83
|
+
clearEditResponse: jest.fn(),
|
|
84
|
+
}}
|
|
85
|
+
globalActions={{ fetchSchemaForEntity: jest.fn() }}
|
|
86
|
+
onCreateComplete={jest.fn()}
|
|
87
|
+
handleClose={handleClose}
|
|
88
|
+
intl={{ formatMessage: jest.fn() }}
|
|
89
|
+
location={{ pathname: '/rcs/edit', query: { type: false, module: 'default' }, search: '' }}
|
|
90
|
+
params={{ id: 'rcs_1' }}
|
|
91
|
+
rcsData={{ templateDetails: buildTemplateDetails(status) }}
|
|
92
|
+
templateData={templateData}
|
|
93
|
+
isFullMode
|
|
94
|
+
loadingTags={false}
|
|
95
|
+
metaEntities={[]}
|
|
96
|
+
getDefaultTags
|
|
97
|
+
smsRegister="DLT"
|
|
98
|
+
/>
|
|
99
|
+
</Provider>,
|
|
100
|
+
);
|
|
101
|
+
wrapper.update();
|
|
102
|
+
return wrapper;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it('auto-opens Test & Preview for an approved template and returns to the listing on close', () => {
|
|
106
|
+
const wrapper = renderEdit({ openTestAndPreview: true });
|
|
107
|
+
const closeButton = wrapper.find('button.mock-test-preview-close');
|
|
108
|
+
expect(closeButton.exists()).toBe(true);
|
|
109
|
+
|
|
110
|
+
closeButton.simulate('click');
|
|
111
|
+
expect(handleClose).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not auto-open without the listing flag', () => {
|
|
115
|
+
const wrapper = renderEdit({});
|
|
116
|
+
expect(wrapper.find('button.mock-test-preview-close').exists()).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not auto-open for a template that is not approved', () => {
|
|
120
|
+
const wrapper = renderEdit({ openTestAndPreview: true }, 'pending');
|
|
121
|
+
expect(wrapper.find('button.mock-test-preview-close').exists()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps the editor open when Test & Preview was opened from the footer', () => {
|
|
125
|
+
const wrapper = renderEdit({});
|
|
126
|
+
wrapper.find('button.rcs-test-preview-btn').last().simulate('click');
|
|
127
|
+
wrapper.update();
|
|
128
|
+
|
|
129
|
+
wrapper.find('button.mock-test-preview-close').simulate('click');
|
|
130
|
+
expect(handleClose).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -253,7 +253,6 @@ const duplicateEnum = {
|
|
|
253
253
|
line: "lineActions",
|
|
254
254
|
[VIBER_CHANNEL]: "viberActions",
|
|
255
255
|
[FACEBOOK_CHANNEL]: "facebookActions",
|
|
256
|
-
[RCS_LOWERCASE]: "rcsActions",
|
|
257
256
|
};
|
|
258
257
|
export class Templates extends React.Component { // eslint-disable-line react/prefer-stateless-function
|
|
259
258
|
constructor(props) {
|
|
@@ -1789,6 +1788,9 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
|
|
|
1789
1788
|
}
|
|
1790
1789
|
}
|
|
1791
1790
|
creativesParams.type = this.state.channel.toUpperCase();
|
|
1791
|
+
if (routeParams.openTestAndPreview) {
|
|
1792
|
+
creativesParams.openTestAndPreview = true;
|
|
1793
|
+
}
|
|
1792
1794
|
}
|
|
1793
1795
|
}
|
|
1794
1796
|
}
|
|
@@ -2277,7 +2279,9 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
|
|
|
2277
2279
|
onClick={() => {
|
|
2278
2280
|
if ([ZALO, WHATSAPP, RCS].includes(this.state.channel.toUpperCase()) || (isTraiDltFeature && this.state.channel.toUpperCase() === SMS)) {
|
|
2279
2281
|
handlers.handleEditClick(undefined, template, undefined, undefined, {
|
|
2280
|
-
account: this.state.selectedAccount
|
|
2282
|
+
account: this.state.selectedAccount,
|
|
2283
|
+
// Test panel fills unmapped variables for these; SMS DLT still needs Overview.
|
|
2284
|
+
openTestAndPreview: [RCS, WHATSAPP, ZALO].includes(this.state.channel.toUpperCase()),
|
|
2281
2285
|
})
|
|
2282
2286
|
} else {
|
|
2283
2287
|
this.handleTestAndPreviewFromListing(template)
|
|
@@ -2288,7 +2292,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
|
|
|
2288
2292
|
</CapMenu.Item>
|
|
2289
2293
|
)}
|
|
2290
2294
|
{/* Duplicate menu item */}
|
|
2291
|
-
{![WECHAT, WHATSAPP, ZALO].includes(
|
|
2295
|
+
{![WECHAT, WHATSAPP, ZALO, RCS].includes(
|
|
2292
2296
|
this.state.channel.toUpperCase()
|
|
2293
2297
|
) &&
|
|
2294
2298
|
!isTraiDltFeature && !template.isArchived && this.gatedMenuItem({
|
|
@@ -3699,7 +3703,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
|
|
|
3699
3703
|
};
|
|
3700
3704
|
this.getAllTemplates({params, resetPage: true});
|
|
3701
3705
|
});
|
|
3702
|
-
} else if ([
|
|
3706
|
+
} else if ([SMS_LOWERCASE, LINE_LOWERCASE, VIBER_CHANNEL, FACEBOOK_CHANNEL].includes(this.state.channel.toLowerCase())) {
|
|
3703
3707
|
this.commonDuplicate(duplicateEnum[this.state.channel.toLowerCase()], duplicateObj);
|
|
3704
3708
|
}
|
|
3705
3709
|
}
|
|
@@ -3858,6 +3862,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
|
|
|
3858
3862
|
this.setState({routeParams: {
|
|
3859
3863
|
pathname: pathName,
|
|
3860
3864
|
query: getQuery,
|
|
3865
|
+
...(options?.openTestAndPreview && { openTestAndPreview: true }),
|
|
3861
3866
|
}});
|
|
3862
3867
|
}
|
|
3863
3868
|
}
|
|
@@ -5,6 +5,7 @@ import { Templates } from '../index';
|
|
|
5
5
|
import mockdata from '../../mockdata';
|
|
6
6
|
import { rcsTemplates, senderDetailsRcs } from './mockData';
|
|
7
7
|
import { exportEmailTemplateHtml } from '../../../utils/downloadFile';
|
|
8
|
+
import { PERMISSIONS } from '../../../utils/permissions';
|
|
8
9
|
|
|
9
10
|
const { whatsappTemplates: TemplatesProp, getCreativesParamsOutput, zaloTemplates: ZaloTemplateProp } = mockdata;
|
|
10
11
|
|
|
@@ -27,6 +28,41 @@ jest.mock('../../CreativesContainer', () => ({
|
|
|
27
28
|
),
|
|
28
29
|
}));
|
|
29
30
|
|
|
31
|
+
// Recursively walks a plain-object/array/React-element tree (as returned by
|
|
32
|
+
// getTemplateDataForGrid, which is never mounted here) looking for a node whose
|
|
33
|
+
// className includes the given substring - robust against the exact nesting of
|
|
34
|
+
// CapDropdown > CapMenu > CapMenu.Item without hardcoding array indices.
|
|
35
|
+
function findByClassName(node, className, seen = new Set()) {
|
|
36
|
+
if (!node || typeof node !== 'object') return null;
|
|
37
|
+
if (seen.has(node)) return null;
|
|
38
|
+
seen.add(node);
|
|
39
|
+
if (Array.isArray(node)) {
|
|
40
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
41
|
+
const found = findByClassName(node[i], className, seen);
|
|
42
|
+
if (found) return found;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// React element (has .props) - check its className, then search its own prop values.
|
|
47
|
+
if (node.props) {
|
|
48
|
+
if (typeof node.props.className === 'string' && node.props.className.includes(className)) {
|
|
49
|
+
return node;
|
|
50
|
+
}
|
|
51
|
+
return findByClassName(node.props, className, seen);
|
|
52
|
+
}
|
|
53
|
+
// Plain object (e.g. a getTemplateDataForGrid card-data entry: {key, title, extra, hoverOption, ...})
|
|
54
|
+
// or a React element's .props object - search every own value.
|
|
55
|
+
const values = Object.values(node);
|
|
56
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
57
|
+
const value = values[i];
|
|
58
|
+
if (value && typeof value === 'object') {
|
|
59
|
+
const found = findByClassName(value, className, seen);
|
|
60
|
+
if (found) return found;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
describe('Test Templates container', () => {
|
|
31
67
|
const getWeCrmAccounts = jest.fn();
|
|
32
68
|
const setChannelAccount = jest.fn();
|
|
@@ -271,41 +307,6 @@ describe('Test Templates container', () => {
|
|
|
271
307
|
describe('Export HTML (EMAIL channel)', () => {
|
|
272
308
|
const emailTemplate = { _id: 'email_1', name: 'My Email Template', versions: { base: {} } };
|
|
273
309
|
|
|
274
|
-
// Recursively walks a plain-object/array/React-element tree (as returned by
|
|
275
|
-
// getTemplateDataForGrid, which is never mounted here) looking for a node whose
|
|
276
|
-
// className includes the given substring - robust against the exact nesting of
|
|
277
|
-
// CapDropdown > CapMenu > CapMenu.Item without hardcoding array indices.
|
|
278
|
-
function findByClassName(node, className, seen = new Set()) {
|
|
279
|
-
if (!node || typeof node !== 'object') return null;
|
|
280
|
-
if (seen.has(node)) return null;
|
|
281
|
-
seen.add(node);
|
|
282
|
-
if (Array.isArray(node)) {
|
|
283
|
-
for (let i = 0; i < node.length; i += 1) {
|
|
284
|
-
const found = findByClassName(node[i], className, seen);
|
|
285
|
-
if (found) return found;
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
// React element (has .props) - check its className, then search its own prop values.
|
|
290
|
-
if (node.props) {
|
|
291
|
-
if (typeof node.props.className === 'string' && node.props.className.includes(className)) {
|
|
292
|
-
return node;
|
|
293
|
-
}
|
|
294
|
-
return findByClassName(node.props, className, seen);
|
|
295
|
-
}
|
|
296
|
-
// Plain object (e.g. a getTemplateDataForGrid card-data entry: {key, title, extra, hoverOption, ...})
|
|
297
|
-
// or a React element's .props object - search every own value.
|
|
298
|
-
const values = Object.values(node);
|
|
299
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
300
|
-
const value = values[i];
|
|
301
|
-
if (value && typeof value === 'object') {
|
|
302
|
-
const found = findByClassName(value, className, seen);
|
|
303
|
-
if (found) return found;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
310
|
beforeEach(() => {
|
|
310
311
|
jest.clearAllMocks();
|
|
311
312
|
// jest config sets resetMocks: true, wiping the default implementation baked into
|
|
@@ -426,4 +427,75 @@ describe('Test Templates container', () => {
|
|
|
426
427
|
expect(findByClassName(cardDataList, 'export-html-email')).toBeNull();
|
|
427
428
|
});
|
|
428
429
|
});
|
|
430
|
+
|
|
431
|
+
describe('RCS listing menu', () => {
|
|
432
|
+
const approvedRcsTemplate = {
|
|
433
|
+
_id: 'rcs_1',
|
|
434
|
+
name: 'RCS Template',
|
|
435
|
+
versions: { base: { content: { RCS: { rcsContent: { cardContent: [{ Status: 'approved' }] } } } } },
|
|
436
|
+
};
|
|
437
|
+
const handlers = { handlePreviewClick: jest.fn(), handleEditClick: jest.fn() };
|
|
438
|
+
const getCardData = () => renderedComponent.instance().getTemplateDataForGrid({
|
|
439
|
+
templates: [approvedRcsTemplate],
|
|
440
|
+
handlers,
|
|
441
|
+
filterContent: null,
|
|
442
|
+
channel: 'rcs',
|
|
443
|
+
isLoading: false,
|
|
444
|
+
isInitialLoading: false,
|
|
445
|
+
loadingTip: '',
|
|
446
|
+
previewTemplateId: null,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
beforeEach(() => {
|
|
450
|
+
RenderFunctionFor('rcs', true);
|
|
451
|
+
renderedComponent.setProps({
|
|
452
|
+
isFullMode: true,
|
|
453
|
+
isDltFromRcs: false,
|
|
454
|
+
Global: { user: { accessiblePermissions: [PERMISSIONS.CREATIVE_EDIT] } },
|
|
455
|
+
actions: { ...renderedComponent.props().actions, resetTemplateData: jest.fn() },
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('does not render the Duplicate menu item for RCS', () => {
|
|
460
|
+
expect(findByClassName(getCardData(), 'duplicate-rcs')).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('"Preview and test" opens the editor with openTestAndPreview', () => {
|
|
464
|
+
const menuItem = findByClassName(getCardData(), 'test-and-preview-rcs');
|
|
465
|
+
expect(menuItem).not.toBeNull();
|
|
466
|
+
|
|
467
|
+
menuItem.props.onClick();
|
|
468
|
+
expect(handlers.handleEditClick).toHaveBeenCalledWith(
|
|
469
|
+
undefined,
|
|
470
|
+
approvedRcsTemplate,
|
|
471
|
+
undefined,
|
|
472
|
+
undefined,
|
|
473
|
+
expect.objectContaining({ openTestAndPreview: true }),
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('handleEditClick keeps openTestAndPreview in routeParams', () => {
|
|
478
|
+
renderedComponent.instance().handleEditClick(undefined, approvedRcsTemplate, undefined, undefined, {
|
|
479
|
+
openTestAndPreview: true,
|
|
480
|
+
});
|
|
481
|
+
expect(renderedComponent.state('routeParams')).toEqual(
|
|
482
|
+
expect.objectContaining({ pathname: '/rcs/edit/rcs_1/', openTestAndPreview: true }),
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('handleEditClick omits openTestAndPreview for Overview', () => {
|
|
487
|
+
renderedComponent.instance().handleEditClick(undefined, approvedRcsTemplate);
|
|
488
|
+
expect(renderedComponent.state('routeParams')).not.toHaveProperty('openTestAndPreview');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('getCreativesParams forwards openTestAndPreview to the editor', () => {
|
|
492
|
+
renderedComponent.setState({ routeParams: { pathname: '/rcs/edit/rcs_1/', openTestAndPreview: true } });
|
|
493
|
+
expect(renderedComponent.instance().getCreativesParams()).toEqual(
|
|
494
|
+
expect.objectContaining({ mode: 'edit', _id: 'rcs_1', type: 'RCS', openTestAndPreview: true }),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
renderedComponent.setState({ routeParams: { pathname: '/rcs/edit/rcs_1/' } });
|
|
498
|
+
expect(renderedComponent.instance().getCreativesParams()).not.toHaveProperty('openTestAndPreview');
|
|
499
|
+
});
|
|
500
|
+
});
|
|
429
501
|
});
|
|
@@ -3268,6 +3268,10 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3268
3268
|
accountData,
|
|
3269
3269
|
]);
|
|
3270
3270
|
|
|
3271
|
+
// Set when opened from listing "Preview and test"; closing then returns to the listing.
|
|
3272
|
+
const isTestAndPreviewFromListingRef = useRef(false);
|
|
3273
|
+
const hasAutoOpenedTestAndPreviewRef = useRef(false);
|
|
3274
|
+
|
|
3271
3275
|
// Handle Test and Preview button click
|
|
3272
3276
|
const handleTestAndPreview = useCallback(() => {
|
|
3273
3277
|
setShowTestAndPreviewSlidebox(true);
|
|
@@ -3284,7 +3288,20 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3284
3288
|
if (propsHandleCloseTestAndPreview) {
|
|
3285
3289
|
propsHandleCloseTestAndPreview();
|
|
3286
3290
|
}
|
|
3287
|
-
|
|
3291
|
+
if (isTestAndPreviewFromListingRef.current && handleClose) {
|
|
3292
|
+
isTestAndPreviewFromListingRef.current = false;
|
|
3293
|
+
handleClose();
|
|
3294
|
+
}
|
|
3295
|
+
}, [propsHandleCloseTestAndPreview, handleClose]);
|
|
3296
|
+
|
|
3297
|
+
// Listing "Preview and test": open Test & Preview once the template is hydrated.
|
|
3298
|
+
useEffect(() => {
|
|
3299
|
+
if (!isFullMode || !templateData?.openTestAndPreview || hasAutoOpenedTestAndPreviewRef.current) return;
|
|
3300
|
+
if (spin || !isEditFlow || templateStatus !== WHATSAPP_STATUSES.approved) return;
|
|
3301
|
+
hasAutoOpenedTestAndPreviewRef.current = true;
|
|
3302
|
+
isTestAndPreviewFromListingRef.current = true;
|
|
3303
|
+
handleTestAndPreview();
|
|
3304
|
+
}, [isFullMode, templateData?.openTestAndPreview, spin, isEditFlow, templateStatus, handleTestAndPreview]);
|
|
3288
3305
|
|
|
3289
3306
|
return (
|
|
3290
3307
|
<CapSpin spinning={spin || metaDataStatus === REQUEST}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 57271855 bytes
|
|
4
|
+
sha256: d1e9c0cd51e8b68c9acd1ddeee01866302a054ed135d594d34509d92a3e39124
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { cloneDeep } from 'lodash';
|
|
3
|
+
import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
|
|
4
|
+
import { Provider } from 'react-redux';
|
|
5
|
+
import history from '../../../utils/history';
|
|
6
|
+
import { initialReducer } from '../../../initialReducer';
|
|
7
|
+
import { mountWithIntl } from '../../../../app/helpers/intl-enzym-test-helpers';
|
|
8
|
+
import { Whatsapp } from '../index';
|
|
9
|
+
import { mockData } from './mockData';
|
|
10
|
+
|
|
11
|
+
jest.mock('../../../v2Containers/TagList/index.js', () => ({
|
|
12
|
+
__esModule: true,
|
|
13
|
+
default: () => <div className="tag-mock">TagList</div>,
|
|
14
|
+
}));
|
|
15
|
+
// Stub exposes only open state + close action.
|
|
16
|
+
jest.mock('../../../v2Components/TestAndPreviewSlidebox', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
default: ({ show, onClose }) => (show ? <button type="button" className="mock-test-preview-close" onClick={onClose} /> : null),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
let store;
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
store = configureStore({}, initialReducer, history);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Whatsapp - Preview and test from listing', () => {
|
|
27
|
+
const handleClose = jest.fn();
|
|
28
|
+
|
|
29
|
+
// Fresh fixture per render: the editor mutates nested template data.
|
|
30
|
+
const renderEdit = (templateData, editData = cloneDeep(mockData.editData1)) => {
|
|
31
|
+
const wrapper = mountWithIntl(
|
|
32
|
+
<Provider store={store}>
|
|
33
|
+
<Whatsapp
|
|
34
|
+
actions={{
|
|
35
|
+
createWhatsappTemplate: jest.fn(),
|
|
36
|
+
clearCreateResponse: jest.fn(),
|
|
37
|
+
getTemplateDetails: jest.fn(),
|
|
38
|
+
resetEditTemplate: jest.fn(),
|
|
39
|
+
getMetaTags: jest.fn(),
|
|
40
|
+
resetMetaTags: jest.fn(),
|
|
41
|
+
}}
|
|
42
|
+
globalActions={{ fetchSchemaForEntity: jest.fn() }}
|
|
43
|
+
onCreateComplete={jest.fn()}
|
|
44
|
+
handleClose={handleClose}
|
|
45
|
+
getFormData={jest.fn()}
|
|
46
|
+
params={{ id: editData.templateDetails._id }}
|
|
47
|
+
editData={editData}
|
|
48
|
+
templateData={templateData}
|
|
49
|
+
accountData={mockData.accountData1}
|
|
50
|
+
location={mockData.location}
|
|
51
|
+
intl={{ formatMessage: jest.fn() }}
|
|
52
|
+
isFullMode
|
|
53
|
+
loadingTags={false}
|
|
54
|
+
metaEntities={[]}
|
|
55
|
+
getDefaultTags
|
|
56
|
+
Templates={{}}
|
|
57
|
+
/>
|
|
58
|
+
</Provider>,
|
|
59
|
+
);
|
|
60
|
+
wrapper.update();
|
|
61
|
+
return wrapper;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
it('auto-opens Test & Preview for an approved template and returns to the listing on close', () => {
|
|
65
|
+
const wrapper = renderEdit({ openTestAndPreview: true });
|
|
66
|
+
const closeButton = wrapper.find('button.mock-test-preview-close');
|
|
67
|
+
expect(closeButton.exists()).toBe(true);
|
|
68
|
+
|
|
69
|
+
closeButton.simulate('click');
|
|
70
|
+
expect(handleClose).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not auto-open without the listing flag', () => {
|
|
74
|
+
const wrapper = renderEdit({});
|
|
75
|
+
expect(wrapper.find('button.mock-test-preview-close').exists()).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not auto-open for a template that is not approved', () => {
|
|
79
|
+
const wrapper = renderEdit({ openTestAndPreview: true }, cloneDeep(mockData.editData2));
|
|
80
|
+
expect(wrapper.find('button.mock-test-preview-close').exists()).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps the editor open when Test & Preview was opened from the footer', () => {
|
|
84
|
+
const wrapper = renderEdit({});
|
|
85
|
+
wrapper.find('button.whatsapp-test-preview-btn').simulate('click');
|
|
86
|
+
wrapper.update();
|
|
87
|
+
|
|
88
|
+
wrapper.find('button.mock-test-preview-close').simulate('click');
|
|
89
|
+
expect(handleClose).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -30,6 +30,7 @@ import './index.scss';
|
|
|
30
30
|
import {
|
|
31
31
|
SUCCESS_ALERT,
|
|
32
32
|
REQUEST,
|
|
33
|
+
SUCCESS,
|
|
33
34
|
EMBEDDED,
|
|
34
35
|
DEFAULT,
|
|
35
36
|
TAG,
|
|
@@ -82,6 +83,7 @@ export const Zalo = (props) => {
|
|
|
82
83
|
showTestAndPreviewSlidebox: propsShowTestAndPreviewSlidebox,
|
|
83
84
|
handleTestAndPreview: propsHandleTestAndPreview,
|
|
84
85
|
handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
|
|
86
|
+
handleClose,
|
|
85
87
|
onContentValidityChange,
|
|
86
88
|
} = props || {};
|
|
87
89
|
// Since zalo supports now multiple accounts, we need to get the hostname from the selected account or passed props.
|
|
@@ -95,6 +97,9 @@ export const Zalo = (props) => {
|
|
|
95
97
|
const [templateListParams, setTemplateListParams] = useState([]);
|
|
96
98
|
const [templatePreviewUrl, setTemplatePreviewUrl] = useState('');
|
|
97
99
|
const [showTestAndPreviewSlidebox, setShowTestAndPreviewSlidebox] = useState(false);
|
|
100
|
+
// Set when opened from listing "Preview and test"; closing then returns to the listing.
|
|
101
|
+
const isTestAndPreviewFromListingRef = useRef(false);
|
|
102
|
+
const hasAutoOpenedTestAndPreviewRef = useRef(false);
|
|
98
103
|
const [templateStatus, setZaloTemplateStatus] = useState(
|
|
99
104
|
ZALO_STATUSES.ENABLE,
|
|
100
105
|
);
|
|
@@ -510,8 +515,21 @@ export const Zalo = (props) => {
|
|
|
510
515
|
if (propsHandleCloseTestAndPreview) {
|
|
511
516
|
propsHandleCloseTestAndPreview();
|
|
512
517
|
}
|
|
518
|
+
if (isTestAndPreviewFromListingRef.current && handleClose) {
|
|
519
|
+
isTestAndPreviewFromListingRef.current = false;
|
|
520
|
+
handleClose();
|
|
521
|
+
}
|
|
513
522
|
};
|
|
514
523
|
|
|
524
|
+
// Listing "Preview and test": open Test & Preview once the template is loaded.
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!isFullMode || !templateData?.openTestAndPreview || hasAutoOpenedTestAndPreviewRef.current) return;
|
|
527
|
+
if (zaloTemplateInfoStatus !== SUCCESS || !templateId) return;
|
|
528
|
+
hasAutoOpenedTestAndPreviewRef.current = true;
|
|
529
|
+
isTestAndPreviewFromListingRef.current = true;
|
|
530
|
+
handleTestAndPreview();
|
|
531
|
+
}, [isFullMode, templateData?.openTestAndPreview, zaloTemplateInfoStatus, templateId]);
|
|
532
|
+
|
|
515
533
|
return (
|
|
516
534
|
<CapSpin spinning={[zaloTemplateInfoStatus, metaDataStatus].includes(REQUEST)}>
|
|
517
535
|
<CapRow type="flex" className="cap-zalo-creatives">
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { injectIntl } from 'react-intl';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { Provider } from 'react-redux';
|
|
5
|
+
import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
|
|
6
|
+
import history from '../../../utils/history';
|
|
7
|
+
import { initialReducer } from '../../../initialReducer';
|
|
8
|
+
import { Zalo } from '..';
|
|
9
|
+
import { SUCCESS } from '../constants';
|
|
10
|
+
import { metaEntities, location, getDefaultTags, injectedTags, accountData } from './mockData';
|
|
11
|
+
import { render, screen, fireEvent } from '../../../utils/test-utils';
|
|
12
|
+
|
|
13
|
+
jest.mock('../../../v2Containers/TagList/index.js', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
default: () => <div className="tag-mock">TagList</div>,
|
|
16
|
+
}));
|
|
17
|
+
// Stub exposes only open state + close action.
|
|
18
|
+
jest.mock('../../../v2Components/TestAndPreviewSlidebox', () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
default: ({ show, onClose }) => (show ? <button type="button" data-testid="mock-test-preview-close" onClick={onClose} /> : null),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
let store;
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
store = configureStore({}, initialReducer, history);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const buildEditData = () => ({
|
|
29
|
+
zaloTemplateInfoStatus: SUCCESS,
|
|
30
|
+
zaloTemplateInfoValue: {
|
|
31
|
+
_id: 'zalo_1',
|
|
32
|
+
name: 'Zalo Template',
|
|
33
|
+
versions: {
|
|
34
|
+
base: {
|
|
35
|
+
content: {
|
|
36
|
+
zalo: {
|
|
37
|
+
templateId: '1',
|
|
38
|
+
listParams: [
|
|
39
|
+
{ name: 'danh_xung', require: true, type: 'STRING', maxLength: 30, minLength: 0, error: '', value: '' },
|
|
40
|
+
],
|
|
41
|
+
previewUrl: 'https://account.zalo.cloud/preview',
|
|
42
|
+
status: 'ENABLE',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
zaloTemplateInfoError: null,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ComponentToRender = injectIntl(Zalo);
|
|
52
|
+
|
|
53
|
+
describe('Zalo - Preview and test from listing', () => {
|
|
54
|
+
const handleClose = jest.fn();
|
|
55
|
+
const handleTestAndPreview = jest.fn();
|
|
56
|
+
|
|
57
|
+
const renderZalo = (templateData) => render(
|
|
58
|
+
<Provider store={store}>
|
|
59
|
+
<ComponentToRender
|
|
60
|
+
actions={{ getTemplateInfoById: jest.fn(), resetTemplateInfoData: jest.fn() }}
|
|
61
|
+
globalActions={{ fetchSchemaForEntity: jest.fn() }}
|
|
62
|
+
editData={buildEditData()}
|
|
63
|
+
metaEntities={metaEntities}
|
|
64
|
+
location={location}
|
|
65
|
+
getDefaultTags={getDefaultTags}
|
|
66
|
+
injectedTags={injectedTags}
|
|
67
|
+
accountData={accountData}
|
|
68
|
+
templateData={templateData}
|
|
69
|
+
isFullMode
|
|
70
|
+
handleClose={handleClose}
|
|
71
|
+
handleTestAndPreview={handleTestAndPreview}
|
|
72
|
+
/>
|
|
73
|
+
</Provider>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it('auto-opens Test & Preview and returns to the listing on close', async () => {
|
|
77
|
+
renderZalo({ _id: 'zalo_1', templateConfigs: { id: '', varMapped: {} }, openTestAndPreview: true });
|
|
78
|
+
const closeButton = await screen.findByTestId('mock-test-preview-close');
|
|
79
|
+
expect(handleTestAndPreview).toHaveBeenCalledTimes(1);
|
|
80
|
+
|
|
81
|
+
fireEvent.click(closeButton);
|
|
82
|
+
expect(handleClose).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not auto-open without the listing flag', () => {
|
|
86
|
+
renderZalo({ _id: 'zalo_1', templateConfigs: { id: '', varMapped: {} } });
|
|
87
|
+
expect(screen.queryByTestId('mock-test-preview-close')).not.toBeInTheDocument();
|
|
88
|
+
expect(handleTestAndPreview).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps the editor open when Test & Preview was opened from the footer', async () => {
|
|
92
|
+
const { container } = renderZalo({ _id: 'zalo_1', templateConfigs: { id: '', varMapped: {} } });
|
|
93
|
+
fireEvent.click(container.querySelector('.test-and-preview-button-zalo'));
|
|
94
|
+
|
|
95
|
+
fireEvent.click(await screen.findByTestId('mock-test-preview-close'));
|
|
96
|
+
expect(handleClose).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|