@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.72",
4
+ "version": "9.0.73-alpha.sprint-testpreview.22",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -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
- const tagRegex = new RegExp(`{{${escapedTagName}}}`, 'g');
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
- resolvedVarMapped[key] = resolveTagsInText(value, customValuesObj);
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] = resolveTagsInText(value, customValuesObj);
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 { render, screen, waitFor } from '@testing-library/react';
10
+ import {
11
+ render, screen, waitFor, act,
12
+ } from '@testing-library/react';
11
13
  import { IntlProvider } from 'react-intl';
12
14
  import CommonTestAndPreview from '../index';
13
15
  import { CHANNELS, MEDIA_TYPE_CAROUSEL } from '../constants';
@@ -70,12 +72,14 @@ jest.mock('../SendTestMessage', () => {
70
72
  };
71
73
  });
72
74
 
75
+ let lastPreviewSectionProps = null;
73
76
  jest.mock('../PreviewSection', () => {
74
77
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies
75
78
  const ReactLib = require('react');
76
79
  return {
77
80
  __esModule: true,
78
- default: function MockPreviewSection() {
81
+ default: function MockPreviewSection(props) {
82
+ lastPreviewSectionProps = props;
79
83
  return ReactLib.createElement('div', { 'data-testid': 'preview-section' }, 'Preview Section');
80
84
  },
81
85
  };
@@ -199,6 +203,7 @@ describe('CommonTestAndPreview', () => {
199
203
  lastSendTestMessageProps = null;
200
204
  lastLeftPanelContentProps = null;
201
205
  lastCustomValuesEditorProps = null;
206
+ lastPreviewSectionProps = null;
202
207
  // Reset all mock function implementations
203
208
  Object.values(mockActions).forEach((mockFn) => {
204
209
  if (jest.isMockFunction(mockFn)) {
@@ -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
- }, [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
  });
@@ -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
- }, [propsHandleCloseTestAndPreview]);
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: 57265074 bytes
4
- sha256: 0c93028e0289bec64b42e30d37d66d0dddeaff992fb34ccc59499c9c2015f430
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
+ });