@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import get from 'lodash/get';
|
|
2
2
|
import { extractTemplateVariables } from '../../utils/templateVarUtils';
|
|
3
|
-
import { SMS_MUSTACHE_TAG_PATTERN } from './constants';
|
|
3
|
+
import { SMS_MUSTACHE_TAG_PATTERN, WHATSAPP_SLOT_KEY_PATTERN } from './constants';
|
|
4
4
|
|
|
5
5
|
/** Show token path from extract-tags (fullPath preferred, falls back to name). */
|
|
6
6
|
export const getPersonalizationTagColumnLabel = (tagNode) =>
|
|
@@ -61,6 +61,64 @@ export const mergeSyntheticMustacheTags = (apiTags = [], rawText = '') => {
|
|
|
61
61
|
return [...(apiTags ?? []), ...synthetic];
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A WhatsApp carousel card's body as the user sees it: the slot-resolved text when Overview
|
|
66
|
+
* mapping produced one, else the raw body. Mirrors the templateHeader/headerTemplate pair.
|
|
67
|
+
*/
|
|
68
|
+
export const getWhatsappCarouselCardBodyText = (card) => {
|
|
69
|
+
const resolvedBody = Array.isArray(card?.updatedBodyText) ? card.updatedBodyText.join('') : '';
|
|
70
|
+
return resolvedBody || (card?.bodyText ?? '');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Carousel card bodies joined — they live outside templateMsg, so a tag scan misses them. */
|
|
74
|
+
export const getWhatsappCarouselTagExtractionText = (formDataObj) => (
|
|
75
|
+
Array.isArray(formDataObj?.carouselData) ? formDataObj.carouselData : [])
|
|
76
|
+
.map(getWhatsappCarouselCardBodyText)
|
|
77
|
+
.filter((bodyText) => hasTrimmedText(bodyText))
|
|
78
|
+
.join(' ');
|
|
79
|
+
|
|
80
|
+
/** WhatsApp header + body + carousel card bodies, scanned for unmapped `{{N}}` slots. */
|
|
81
|
+
export const getWhatsappTagExtractionText = (formDataObj) => (
|
|
82
|
+
`${formDataObj?.templateHeader || ''} ${formDataObj?.templateMsg || ''} ${getWhatsappCarouselTagExtractionText(formDataObj)}`.trim()
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/** Zalo params left empty on Overview, surfaced as fillable test values (keyed by param name). */
|
|
86
|
+
export const mergeZaloUnfilledParamTags = (apiTags = [], formDataObj = {}) => {
|
|
87
|
+
const existingNames = new Set((apiTags ?? []).map((tag) => tag?.name));
|
|
88
|
+
const unfilledParamTags = (formDataObj?.templateListParams || [])
|
|
89
|
+
.filter((param) => param?.name && !String(param?.value ?? '').trim() && !existingNames.has(param.name))
|
|
90
|
+
.map((param) => ({ name: param.name, metaData: { userDriven: false }, children: [] }));
|
|
91
|
+
return [...(apiTags ?? []), ...unfilledParamTags];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Slot key -> the variable name it holds. WhatsApp keys a slot by token and occurrence index
|
|
96
|
+
* (`<token>_<index>`); RCS keys it by the variable's own name, per card.
|
|
97
|
+
*/
|
|
98
|
+
const getSlotKeyVarName = (slotKey) => String(slotKey).replace(WHATSAPP_SLOT_KEY_PATTERN, '$1');
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A slot map holds what the user mapped on Overview, per variable of the template.
|
|
102
|
+
* Slots left blank there take the value entered in Test & Preview for the same variable, so
|
|
103
|
+
* createMessageMeta gets a complete map instead of empty slots. Mapped slots are left untouched.
|
|
104
|
+
*/
|
|
105
|
+
export const fillSlotMapFromCustomValues = (slotMap, customValues = {}) => {
|
|
106
|
+
if (!slotMap || typeof slotMap !== 'object') return slotMap;
|
|
107
|
+
const customValueByVarName = {};
|
|
108
|
+
Object.keys(customValues || {}).forEach((tagPath) => {
|
|
109
|
+
customValueByVarName[tagPath.split('.').pop()] = customValues?.[tagPath];
|
|
110
|
+
});
|
|
111
|
+
if (Object.keys(customValueByVarName).length === 0) return slotMap;
|
|
112
|
+
const hasValue = (value) => value != null && String(value).trim() !== '';
|
|
113
|
+
const filledSlotMap = { ...slotMap };
|
|
114
|
+
Object.keys(filledSlotMap).forEach((slotKey) => {
|
|
115
|
+
if (hasValue(filledSlotMap[slotKey])) return;
|
|
116
|
+
const customValue = customValueByVarName?.[getSlotKeyVarName(slotKey)];
|
|
117
|
+
if (hasValue(customValue)) filledSlotMap[slotKey] = String(customValue);
|
|
118
|
+
});
|
|
119
|
+
return filledSlotMap;
|
|
120
|
+
};
|
|
121
|
+
|
|
64
122
|
/** RCS createMessageMeta: normalize media shape (mediaUrl, thumbnailUrl, height string). */
|
|
65
123
|
export const normalizeRcsTestCardMedia = (media) => {
|
|
66
124
|
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
|
});
|
|
@@ -3176,13 +3176,11 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3176
3176
|
templateMsgText += `\n${formatMessage(messages.templateMessageUnsubscribeText)}`;
|
|
3177
3177
|
}
|
|
3178
3178
|
|
|
3179
|
-
//
|
|
3180
|
-
const templateEditorValue =
|
|
3181
|
-
? editData.templateEditor
|
|
3182
|
-
: templateMsgText;
|
|
3179
|
+
// Raw body, slots intact — pairs with varMapped below; templateMsg carries the resolved text.
|
|
3180
|
+
const templateEditorValue = tempMsgArray.length > 0 ? tempMsgArray.join("") : templateMsgText;
|
|
3183
3181
|
|
|
3184
|
-
//
|
|
3185
|
-
const varMappedValue =
|
|
3182
|
+
// Live slot map (`{{1}}_1` -> mapped tag/value); `editData` has no varMapped, so it was always {}.
|
|
3183
|
+
const varMappedValue = { ...varMap };
|
|
3186
3184
|
|
|
3187
3185
|
return {
|
|
3188
3186
|
// Preview structure (for WhatsAppPreviewContent component)
|
|
@@ -3220,7 +3218,8 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3220
3218
|
mediaType: templateMediaType,
|
|
3221
3219
|
isPreviewUrl: false,
|
|
3222
3220
|
headerVarMapped: headerVarMappedData,
|
|
3223
|
-
|
|
3221
|
+
// Raw header, slots intact — pairs with headerVarMapped; templateHeader carries the resolved text.
|
|
3222
|
+
headerTemplate: tempHeaderData.length > 0 ? tempHeaderData.join("") : templateHeader,
|
|
3224
3223
|
whatsappVideoSrcAndPreview,
|
|
3225
3224
|
whatsappDocSource,
|
|
3226
3225
|
whatsappDocParams,
|
|
@@ -3239,6 +3238,8 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3239
3238
|
docPreview,
|
|
3240
3239
|
isEditFlow,
|
|
3241
3240
|
updatedSmsEditor,
|
|
3241
|
+
tempMsgArray,
|
|
3242
|
+
tempHeaderData,
|
|
3242
3243
|
templateMessage,
|
|
3243
3244
|
unsubscribeRequired,
|
|
3244
3245
|
isAuthenticationTemplate,
|
|
@@ -3268,6 +3269,10 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3268
3269
|
accountData,
|
|
3269
3270
|
]);
|
|
3270
3271
|
|
|
3272
|
+
// Set when opened from listing "Preview and test"; closing then returns to the listing.
|
|
3273
|
+
const isTestAndPreviewFromListingRef = useRef(false);
|
|
3274
|
+
const hasAutoOpenedTestAndPreviewRef = useRef(false);
|
|
3275
|
+
|
|
3271
3276
|
// Handle Test and Preview button click
|
|
3272
3277
|
const handleTestAndPreview = useCallback(() => {
|
|
3273
3278
|
setShowTestAndPreviewSlidebox(true);
|
|
@@ -3284,7 +3289,20 @@ const isAuthenticationTemplate = isEqual(templateCategory, WHATSAPP_CATEGORIES.a
|
|
|
3284
3289
|
if (propsHandleCloseTestAndPreview) {
|
|
3285
3290
|
propsHandleCloseTestAndPreview();
|
|
3286
3291
|
}
|
|
3287
|
-
|
|
3292
|
+
if (isTestAndPreviewFromListingRef.current && handleClose) {
|
|
3293
|
+
isTestAndPreviewFromListingRef.current = false;
|
|
3294
|
+
handleClose();
|
|
3295
|
+
}
|
|
3296
|
+
}, [propsHandleCloseTestAndPreview, handleClose]);
|
|
3297
|
+
|
|
3298
|
+
// Listing "Preview and test": open Test & Preview once the template is hydrated.
|
|
3299
|
+
useEffect(() => {
|
|
3300
|
+
if (!isFullMode || !templateData?.openTestAndPreview || hasAutoOpenedTestAndPreviewRef.current) return;
|
|
3301
|
+
if (spin || !isEditFlow || templateStatus !== WHATSAPP_STATUSES.approved) return;
|
|
3302
|
+
hasAutoOpenedTestAndPreviewRef.current = true;
|
|
3303
|
+
isTestAndPreviewFromListingRef.current = true;
|
|
3304
|
+
handleTestAndPreview();
|
|
3305
|
+
}, [isFullMode, templateData?.openTestAndPreview, spin, isEditFlow, templateStatus, handleTestAndPreview]);
|
|
3288
3306
|
|
|
3289
3307
|
return (
|
|
3290
3308
|
<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: 57295127 bytes
|
|
4
|
+
sha256: 3ca4752bd0410b9d7f0e57070855228ef817cc04531f23794bf27e2647b4f7a2
|
|
@@ -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
|
+
});
|