@capillarytech/creatives-library 9.1.2 → 9.1.4-alpha.sprint-testpreview.27

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.
@@ -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
- }, [propsHandleCloseTestAndPreview]);
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 (["sms", "line", VIBER_CHANNEL, FACEBOOK_CHANNEL, RCS_LOWERCASE].includes(this.state.channel.toLowerCase())) {
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
- // Get templateEditor from editData or construct from templateMessage
3180
- const templateEditorValue = isEditFlow && editData?.templateEditor
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
- // Get varMapped from editData or empty object
3185
- const varMappedValue = editData?.varMapped || {};
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
- headerTemplate: isEditFlow ? updatedHeaderData.join("") : templateHeader,
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
- }, [propsHandleCloseTestAndPreview]);
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: 57265074 bytes
4
- sha256: 0c93028e0289bec64b42e30d37d66d0dddeaff992fb34ccc59499c9c2015f430
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
+ });