@capillarytech/creatives-library 9.0.61 → 9.0.62

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.
Files changed (56) hide show
  1. package/constants/unified.js +8 -2
  2. package/package.json +1 -1
  3. package/services/api.js +5 -0
  4. package/services/tests/api.test.js +44 -0
  5. package/utils/common.js +8 -0
  6. package/utils/downloadFile.js +57 -0
  7. package/utils/tests/downloadFile.test.js +219 -0
  8. package/v2Components/FormBuilder/Classic.js +43 -9
  9. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
  10. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
  11. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
  12. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
  13. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
  14. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
  15. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
  16. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
  17. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
  18. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
  19. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
  20. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
  21. package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
  22. package/v2Components/FormBuilder/Functional/constants.js +46 -8
  23. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
  24. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
  25. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
  26. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
  27. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
  28. package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
  29. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
  30. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
  31. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
  32. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
  33. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
  34. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
  35. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
  36. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
  37. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
  38. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
  39. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
  40. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
  41. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
  42. package/v2Components/FormBuilder/_formBuilder.scss +11 -0
  43. package/v2Components/FormBuilder/index.js +27 -17
  44. package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
  45. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
  46. package/v2Containers/BeeEditor/index.js +8 -0
  47. package/v2Containers/Email/index.js +118 -16
  48. package/v2Containers/Email/initialSchema.js +25 -3
  49. package/v2Containers/Email/messages.js +16 -0
  50. package/v2Containers/Email/tests/index.test.js +597 -0
  51. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
  52. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
  53. package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
  54. package/v2Containers/Templates/index.js +53 -0
  55. package/v2Containers/Templates/messages.js +8 -0
  56. package/v2Containers/Templates/tests/index.test.js +170 -0
@@ -11,11 +11,16 @@ import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
11
11
  import CapRow from '@capillarytech/cap-ui-library/CapRow';
12
12
  import CapColumn from '@capillarytech/cap-ui-library/CapColumn';
13
13
  import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
14
+ import CapDropdown from '@capillarytech/cap-ui-library/CapDropdown';
15
+ import CapMenu from '@capillarytech/cap-ui-library/CapMenu';
16
+ import CapIcon from '@capillarytech/cap-ui-library/CapIcon';
17
+ import CapTooltip from '@capillarytech/cap-ui-library/CapTooltip';
14
18
  import HTMLEditor from '../../../v2Components/HtmlEditor';
15
19
  import CapTagListWithInput from '../../../v2Components/CapTagListWithInput';
16
20
  import formBuilderMessages from '../../../v2Components/FormBuilder/messages';
17
21
  import { validateLiquidTemplateContent } from '../../../utils/commonUtils';
18
22
  import { isEmailUnsubscribeTagOptional } from '../../../utils/common';
23
+ import { downloadHtmlFile, sanitizeFileName, getExportHtmlDisabledTooltip } from '../../../utils/downloadFile';
19
24
  import history from '../../../utils/history';
20
25
  import messages from '../messages';
21
26
  import emailMessages from '../../Email/messages';
@@ -25,6 +30,7 @@ import {
25
30
  } from '../../Whatsapp/constants';
26
31
  import { EMAIL } from '../../CreativesContainer/constants';
27
32
  import { OUTBOUND } from '../../../v2Components/FormBuilder/constants';
33
+ import './_emailHTMLEditor.scss';
28
34
 
29
35
  /**
30
36
  * EmailHTMLEditor Component
@@ -96,6 +102,8 @@ const EmailHTMLEditor = (props) => {
96
102
  // State for content and subject
97
103
  const [htmlContent, setHtmlContent] = useState('');
98
104
  const [loadedHtmlContent, setLoadedHtmlContent] = useState(''); // Stable content for HTMLEditor initialization
105
+ const [hasContentChanged, setHasContentChanged] = useState(false);
106
+ const baselineContentRef = useRef(null);
99
107
  const [subject, setSubject] = useState('');
100
108
  const [subjectError, setSubjectError] = useState('');
101
109
  // State for template name (extracted from template data in Edit mode)
@@ -109,6 +117,13 @@ const EmailHTMLEditor = (props) => {
109
117
  standardErrors: [],
110
118
  });
111
119
 
120
+ // Baseline for detecting a genuine edit vs. a Redux resync (drives the Export HTML lock).
121
+ useEffect(() => {
122
+ if (baselineContentRef.current === null && loadedHtmlContent) {
123
+ baselineContentRef.current = loadedHtmlContent;
124
+ }
125
+ }, [loadedHtmlContent]);
126
+
112
127
  // Merge tag validation errors (missing) into apiValidationErrors so they show in ValidationErrorDisplay
113
128
  const mergedApiValidationErrors = useMemo(() => {
114
129
  const tagMessages = [];
@@ -277,6 +292,8 @@ const EmailHTMLEditor = (props) => {
277
292
  setHtmlContent('');
278
293
  setSubject('');
279
294
  setExtractedTemplateName('');
295
+ baselineContentRef.current = null;
296
+ setHasContentChanged(false);
280
297
  }
281
298
 
282
299
  // Set last template ID on first load
@@ -483,6 +500,10 @@ const EmailHTMLEditor = (props) => {
483
500
  const handleContentChange = useCallback((content) => {
484
501
  setHtmlContent(content);
485
502
 
503
+ if (isEditMode && !hasContentChanged && baselineContentRef.current !== null && content !== baselineContentRef.current) {
504
+ setHasContentChanged(true);
505
+ }
506
+
486
507
  // Clear previous liquid/API validation errors so Done button can be enabled after user fixes content
487
508
  setApiValidationErrors({
488
509
  liquidErrors: [],
@@ -511,7 +532,7 @@ const EmailHTMLEditor = (props) => {
511
532
  setTagValidationError(null);
512
533
  }
513
534
  }
514
- }, [tags, injectedTags, location, getDefaultTags, eventContextTags, waitEventContextTags, showLiquidErrorInFooter]);
535
+ }, [tags, injectedTags, location, getDefaultTags, eventContextTags, waitEventContextTags, showLiquidErrorInFooter, isEditMode, hasContentChanged]);
515
536
 
516
537
  // Store the last validation state received from HTMLEditor
517
538
  const lastValidationStateRef = useRef(null);
@@ -1078,6 +1099,20 @@ const EmailHTMLEditor = (props) => {
1078
1099
  }
1079
1100
  }, [showTemplateName, isFullMode, templateName, extractedTemplateName, isEditMode, handleFormDataChange]);
1080
1101
 
1102
+ const exportHtmlTooltip = useMemo(
1103
+ () => getExportHtmlDisabledTooltip(isEditMode, hasContentChanged, formatMessage),
1104
+ [isEditMode, hasContentChanged, formatMessage]
1105
+ );
1106
+
1107
+ // No backend call: this non-Bee editor's loaded content is already the full rendered HTML.
1108
+ const handleExportHtml = useCallback(() => {
1109
+ if (!isEditMode || hasContentChanged || !loadedHtmlContent) {
1110
+ CapNotification.error({ message: formatMessage(emailMessages.exportHtmlError) });
1111
+ return;
1112
+ }
1113
+ downloadHtmlFile(loadedHtmlContent, `${sanitizeFileName(extractedTemplateName)}.html`);
1114
+ }, [isEditMode, hasContentChanged, loadedHtmlContent, extractedTemplateName, formatMessage]);
1115
+
1081
1116
  return (
1082
1117
  <CapSpin spinning={isLoading} tip={spinTip}>
1083
1118
  <CapRow>
@@ -1113,6 +1148,25 @@ const EmailHTMLEditor = (props) => {
1113
1148
  />
1114
1149
  </CapColumn>
1115
1150
 
1151
+ <CapColumn span={24} className="html-editor-options-column">
1152
+ <CapDropdown
1153
+ rootClassName="html-editor-options-dropdown"
1154
+ overlay={
1155
+ <CapMenu className="html-editor-options-menu">
1156
+ <CapTooltip title={exportHtmlTooltip} placement="topLeft">
1157
+ <span>
1158
+ <CapMenu.Item className="export-html-inhouse" disabled={!isEditMode || hasContentChanged} onClick={handleExportHtml}>
1159
+ <FormattedMessage {...emailMessages.exportHtmlButton} />
1160
+ </CapMenu.Item>
1161
+ </span>
1162
+ </CapTooltip>
1163
+ </CapMenu>
1164
+ }
1165
+ >
1166
+ <CapIcon type="more" rotate={90} className="html-editor-options-icon" />
1167
+ </CapDropdown>
1168
+ </CapColumn>
1169
+
1116
1170
  <HTMLEditor
1117
1171
  ref={htmlEditorRef}
1118
1172
  variant="email"
@@ -14,6 +14,7 @@ import EmailHTMLEditor from '../EmailHTMLEditor';
14
14
  import { validateLiquidTemplateContent } from '../../../../utils/commonUtils';
15
15
  import { validateTags } from '../../../../utils/tagValidations';
16
16
  import { isEmailUnsubscribeTagOptional } from '../../../../utils/common';
17
+ import { downloadHtmlFile } from '../../../../utils/downloadFile';
17
18
 
18
19
  // Mock dependencies
19
20
  jest.mock('../../../../utils/commonUtils', () => ({
@@ -38,6 +39,39 @@ jest.mock('@capillarytech/cap-ui-library/CapNotification', () => ({
38
39
  warning: jest.fn(),
39
40
  }));
40
41
 
42
+ jest.mock('../../../../utils/downloadFile', () => ({
43
+ downloadHtmlFile: jest.fn(),
44
+ sanitizeFileName: jest.fn((name) => (name || 'template').replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'template'),
45
+ getExportHtmlDisabledTooltip: jest.fn((isEditMode, isContentLocked) => {
46
+ if (!isEditMode) return 'Export while creating not supported';
47
+ if (isContentLocked) return 'Email edited and Export HTML not supported in active session now';
48
+ return '';
49
+ }),
50
+ }));
51
+
52
+ // CapDropdown/CapMenu render lazily via antd's hover-trigger popup portal, which isn't
53
+ // mounted in the DOM until opened. No existing test in this file exercises the Export
54
+ // HTML dropdown, so mocking these to render their overlay/children unconditionally is
55
+ // safe (matches the pattern already used in SmsFallback's tests) and lets these new
56
+ // tests query/click the menu item directly.
57
+ jest.mock('@capillarytech/cap-ui-library/CapDropdown', () => (props) => (
58
+ <div>
59
+ {props.children}
60
+ {props.overlay}
61
+ </div>
62
+ ));
63
+ jest.mock('@capillarytech/cap-ui-library/CapMenu', () => {
64
+ const Menu = ({ children, className }) => <div className={className}>{children}</div>;
65
+ Menu.Item = ({
66
+ children, onClick, disabled, className,
67
+ }) => (
68
+ <button type="button" className={className} disabled={disabled} onClick={onClick}>
69
+ {children}
70
+ </button>
71
+ );
72
+ return Menu;
73
+ });
74
+
41
75
  // Create mutable mock functions that can be controlled in tests
42
76
  const mockGetAllIssues = jest.fn(() => []);
43
77
  const mockGetValidationState = jest.fn(() => ({
@@ -381,6 +415,12 @@ describe('EmailHTMLEditor', () => {
381
415
  hasErrors: false,
382
416
  issueCounts: { errors: 0, warnings: 0, total: 0 },
383
417
  });
418
+ // jest config sets resetMocks: true, which wipes the default implementation
419
+ // baked into the jest.mock(...) factory before every test - re-establish it here.
420
+ // eslint-disable-next-line global-require
421
+ require('../../../../utils/downloadFile').sanitizeFileName.mockImplementation(
422
+ (name) => (name || 'template').replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'template'
423
+ );
384
424
  });
385
425
 
386
426
  describe('Default Parameter Values (lines 60-63)', () => {
@@ -2670,4 +2710,105 @@ describe('EmailHTMLEditor', () => {
2670
2710
  expect(validateLiquidTemplateContent).not.toHaveBeenCalled();
2671
2711
  });
2672
2712
  });
2713
+
2714
+ describe('Export HTML (baseline capture, handleExportHtml, dropdown disabled state)', () => {
2715
+ const editModeProps = (overrides = {}) => ({
2716
+ params: { id: 'template-123' },
2717
+ templateData: { name: 'My Email', 'template-content': '<p>Original</p>' },
2718
+ ...overrides,
2719
+ });
2720
+
2721
+ it('captures the baseline once loadedHtmlContent arrives, and only flags hasContentChanged once content actually diverges from it', async () => {
2722
+ renderWithIntl(editModeProps());
2723
+
2724
+ // Baseline captured from templateData ('<p>Original</p>'); Export HTML enabled, not yet disabled.
2725
+ const menuItemBefore = screen.getByText('Export HTML').closest('button');
2726
+ expect(menuItemBefore).not.toBeDisabled();
2727
+
2728
+ // Mocked HTMLEditor's content-change button always fires '<p>New content</p>', which differs from baseline.
2729
+ await act(async () => {
2730
+ fireEvent.click(screen.getByTestId('trigger-content-change'));
2731
+ });
2732
+
2733
+ const menuItemAfter = screen.getByText('Export HTML').closest('button');
2734
+ expect(menuItemAfter).toBeDisabled();
2735
+ });
2736
+
2737
+ it('downloads the loaded HTML when in edit mode with no content change', async () => {
2738
+ renderWithIntl(editModeProps());
2739
+
2740
+ await act(async () => {
2741
+ fireEvent.click(screen.getByText('Export HTML').closest('button'));
2742
+ });
2743
+
2744
+ expect(downloadHtmlFile).toHaveBeenCalledWith('<p>Original</p>', 'My Email.html');
2745
+ });
2746
+
2747
+ it('shows an error notification and does not download when loadedHtmlContent is empty', async () => {
2748
+ const CapNotification = require('@capillarytech/cap-ui-library/CapNotification');
2749
+ renderWithIntl(editModeProps({ templateData: { name: 'Empty Email' } }));
2750
+
2751
+ const menuItem = screen.getByText('Export HTML').closest('button');
2752
+ // loadedHtmlContent emptiness isn't part of the disabled condition, so the item stays clickable.
2753
+ expect(menuItem).not.toBeDisabled();
2754
+
2755
+ await act(async () => {
2756
+ fireEvent.click(menuItem);
2757
+ });
2758
+
2759
+ expect(downloadHtmlFile).not.toHaveBeenCalled();
2760
+ expect(CapNotification.error).toHaveBeenCalled();
2761
+ });
2762
+
2763
+ it('disables the Export HTML menu item (and cannot be clicked) when not in edit mode', async () => {
2764
+ renderWithIntl({ params: {}, templateData: null, isEditEmail: false });
2765
+
2766
+ const menuItem = screen.getByText('Export HTML').closest('button');
2767
+ expect(menuItem).toBeDisabled();
2768
+
2769
+ await act(async () => {
2770
+ fireEvent.click(menuItem);
2771
+ });
2772
+
2773
+ expect(downloadHtmlFile).not.toHaveBeenCalled();
2774
+ });
2775
+
2776
+ it('resets hasContentChanged and re-baselines against the new template when switching templates mid-session', async () => {
2777
+ const { rerender } = renderWithIntl(editModeProps({
2778
+ params: { id: 'template-A' },
2779
+ templateData: { name: 'Template A', 'template-content': '<p>Original A</p>' },
2780
+ }));
2781
+
2782
+ // Mark template A as edited (locks Export HTML).
2783
+ await act(async () => {
2784
+ fireEvent.click(screen.getByTestId('trigger-content-change'));
2785
+ });
2786
+ expect(screen.getByText('Export HTML').closest('button')).toBeDisabled();
2787
+
2788
+ // Switch to a different template (new params.id + new templateData) without unmounting.
2789
+ await act(async () => {
2790
+ rerender(
2791
+ <IntlProvider locale="en" messages={{}}>
2792
+ <EmailHTMLEditor
2793
+ {...defaultProps}
2794
+ {...editModeProps({
2795
+ params: { id: 'template-B' },
2796
+ templateData: { name: 'Template B', 'template-content': '<p>Original B</p>' },
2797
+ })}
2798
+ />
2799
+ </IntlProvider>
2800
+ );
2801
+ });
2802
+
2803
+ // hasContentChanged must reset - template B hasn't been edited yet.
2804
+ const menuItemAfterSwitch = screen.getByText('Export HTML').closest('button');
2805
+ expect(menuItemAfterSwitch).not.toBeDisabled();
2806
+
2807
+ // Exporting now must use template B's freshly re-baselined content, not template A's stale one.
2808
+ await act(async () => {
2809
+ fireEvent.click(menuItemAfterSwitch);
2810
+ });
2811
+ expect(downloadHtmlFile).toHaveBeenCalledWith('<p>Original B</p>', 'Template B.html');
2812
+ });
2813
+ });
2673
2814
  });
@@ -0,0 +1,11 @@
1
+ @import '~@capillarytech/cap-ui-library/styles/_variables.scss';
2
+
3
+ .html-editor-options-column {
4
+ display: flex;
5
+ justify-content: flex-end;
6
+ margin-bottom: $CAP_SPACE_04;
7
+ }
8
+
9
+ .html-editor-options-icon {
10
+ cursor: pointer;
11
+ }
@@ -119,6 +119,7 @@ import {
119
119
  } from '../App/constants';
120
120
  import {MAX_WHATSAPP_TEMPLATES, WARNING_WHATSAPP_TEMPLATES , ACCOUNT_MAPPING_ON_CHANNEL, noFilteredWhatsappZaloTemplatesTitle, noFilteredWhatsappZaloTemplatesDesc, noApprovedWhatsappZaloTemplatesTitle, noApprovedWhatsappTemplatesDesc, zaloDescIllustration, noApprovedRcsTemplatesTitle, noApprovedRcsTemplatesDesc, ARCHIVE_STATUS_ACTIVE, ARCHIVE_STATUS_ARCHIVED, ARCHIVE_REFRESH_TYPE_ARCHIVE, ARCHIVE_REFRESH_TYPE_UNARCHIVE} from './constants';
121
121
  import { COPY_OF, EMBEDDED } from '../../constants/unified';
122
+ import { sanitizeFileName, exportEmailTemplateHtml } from '../../utils/downloadFile';
122
123
  import {
123
124
  STATUS_OPTIONS,
124
125
  CATEGORY,
@@ -306,6 +307,9 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
306
307
  testAndPreviewContent: null,
307
308
  // Phase 18: EMAIL API call flag
308
309
  isWaitingForEmailTemplateDetails: false,
310
+ // Export HTML from listing
311
+ isWaitingForEmailTemplateExport: false,
312
+ selectedTemplateForExport: null,
309
313
  };
310
314
  // Timeout IDs for cleanup
311
315
  this._clearEditTimeout = null;
@@ -973,6 +977,26 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
973
977
  }
974
978
  }
975
979
 
980
+ // Handle EMAIL template details API response for Export HTML
981
+ if (this.state.isWaitingForEmailTemplateExport && this.state.selectedTemplateForExport) {
982
+ const wasInProgress = this.props.Templates.getTemplateDetailsInProgress;
983
+ const isNowInProgress = nextProps.Templates.getTemplateDetailsInProgress;
984
+
985
+ if (wasInProgress && !isNowInProgress) {
986
+ if (!isEmpty(nextProps.Templates.templateDetails) && !isEqual(nextProps.Templates.templateDetails, this.props.Templates.templateDetails)) {
987
+ this.processEmailHtmlExport(nextProps.Templates.templateDetails, this.state.selectedTemplateForExport);
988
+ } else {
989
+ CapNotification.error({
990
+ message: this.props.intl.formatMessage(messages.exportHtmlError),
991
+ });
992
+ }
993
+ this.setState({
994
+ isWaitingForEmailTemplateExport: false,
995
+ selectedTemplateForExport: null,
996
+ });
997
+ }
998
+ }
999
+
976
1000
  if (!isEmpty(nextProps.Templates.templateDetails) && !isEqual(nextProps.Templates.templateDetails, this.props.Templates.templateDetails)) {
977
1001
  this.setState({ previewTemplate: nextProps.Templates.templateDetails });
978
1002
  }
@@ -1644,6 +1668,26 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1644
1668
  });
1645
1669
  }
1646
1670
 
1671
+ // Fetches full template details (like Preview/Test) to know if the base language is Bee or CKEditor.
1672
+ handleExportHtmlFromListing = (template) => {
1673
+ if (!template._id) return;
1674
+ this.setState({
1675
+ selectedTemplateForExport: template,
1676
+ isWaitingForEmailTemplateExport: true,
1677
+ });
1678
+ this.props.actions.getTemplateDetails(template._id, EMAIL);
1679
+ }
1680
+
1681
+ // Downloads the base language's HTML directly (CKEditor) or converts Bee design JSON via backend first.
1682
+ processEmailHtmlExport = (templateDetails, template) => {
1683
+ const filename = `${sanitizeFileName(template?.name)}.html`;
1684
+ const baseLanguage = this.props.Global?.currentOrgDetails?.basic_details?.base_language || 'en';
1685
+ exportEmailTemplateHtml(templateDetails, filename, baseLanguage).catch((error) => {
1686
+ console.error('Error exporting EMAIL template HTML:', error);
1687
+ CapNotification.error({ message: this.props.intl.formatMessage(messages.exportHtmlError) });
1688
+ });
1689
+ }
1690
+
1647
1691
  /**
1648
1692
  * Phase 18: Extract EMAIL content from templateDetails API response
1649
1693
  * This function extracts content from the full template details response
@@ -2246,6 +2290,15 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
2246
2290
  onClick: () => this.duplicateTemplate(template),
2247
2291
  label: <FormattedMessage {...messages.duplicateButton} />,
2248
2292
  })}
2293
+ {/* Export HTML menu item - EMAIL only */}
2294
+ {this.state.channel.toUpperCase() === EMAIL && (
2295
+ <CapMenu.Item
2296
+ className="export-html-email template-action-menu-item"
2297
+ onClick={() => this.handleExportHtmlFromListing(template)}
2298
+ >
2299
+ <FormattedMessage {...messages.exportHtmlButton} />
2300
+ </CapMenu.Item>
2301
+ )}
2249
2302
  {/* Archive/Unarchive menu item (full mode only, not for Zalo, not for WhatsApp/RCS awaiting/pending) */}
2250
2303
  {commonUtil.hasCreativesArchivalEnabled() && (() => {
2251
2304
  const channelUp = this.state.channel.toUpperCase();
@@ -110,6 +110,14 @@ export default defineMessages({
110
110
  id: `${scope}.deleteButton`,
111
111
  defaultMessage: "Delete",
112
112
  },
113
+ "exportHtmlButton": {
114
+ id: `${scope}.exportHtmlButton`,
115
+ defaultMessage: "Export HTML",
116
+ },
117
+ "exportHtmlError": {
118
+ id: `${scope}.exportHtmlError`,
119
+ defaultMessage: "Failed to export template HTML. Please try again.",
120
+ },
113
121
  "mostRecentOption": {
114
122
  id: `${scope}.mostRecentOption`,
115
123
  defaultMessage: "Most recent",
@@ -4,9 +4,20 @@ import { shallowWithIntl } from '../../../helpers/intl-enzym-test-helpers';
4
4
  import { Templates } from '../index';
5
5
  import mockdata from '../../mockdata';
6
6
  import { rcsTemplates, senderDetailsRcs } from './mockData';
7
+ import { exportEmailTemplateHtml } from '../../../utils/downloadFile';
7
8
 
8
9
  const { whatsappTemplates: TemplatesProp, getCreativesParamsOutput, zaloTemplates: ZaloTemplateProp } = mockdata;
9
10
 
11
+ jest.mock('../../../utils/downloadFile', () => ({
12
+ sanitizeFileName: jest.fn((name) => (name || 'template').replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'template'),
13
+ exportEmailTemplateHtml: jest.fn(),
14
+ }));
15
+
16
+ jest.mock('@capillarytech/cap-ui-library', () => ({
17
+ ...jest.requireActual('@capillarytech/cap-ui-library'),
18
+ CapNotification: { error: jest.fn(), success: jest.fn(), warning: jest.fn() },
19
+ }));
20
+
10
21
  jest.mock('../../CreativesContainer', () => ({
11
22
  __esModule: true,
12
23
  default: (props) => (
@@ -256,4 +267,163 @@ describe('Test Templates container', () => {
256
267
  getCreativesParamsOutputRcs,
257
268
  );
258
269
  });
270
+
271
+ describe('Export HTML (EMAIL channel)', () => {
272
+ const emailTemplate = { _id: 'email_1', name: 'My Email Template', versions: { base: {} } };
273
+
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
+ beforeEach(() => {
310
+ jest.clearAllMocks();
311
+ // jest config sets resetMocks: true, wiping the default implementation baked into
312
+ // the jest.mock(...) factory before every test - re-establish it here.
313
+ // eslint-disable-next-line global-require
314
+ require('../../../utils/downloadFile').sanitizeFileName.mockImplementation(
315
+ (name) => (name || 'template').replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'template'
316
+ );
317
+ RenderFunctionFor('email', true);
318
+ renderedComponent.setProps({
319
+ isFullMode: true,
320
+ isDltFromRcs: false,
321
+ Global: { currentOrgDetails: { basic_details: { base_language: 'en' } } },
322
+ actions: { ...renderedComponent.props().actions, getTemplateDetails: jest.fn() },
323
+ Templates: { ...TemplatesProp, getTemplateDetailsInProgress: true, templateDetails: {} },
324
+ });
325
+ });
326
+
327
+ it('handleExportHtmlFromListing stores the selected template and fetches its full details', () => {
328
+ renderedComponent.instance().handleExportHtmlFromListing(emailTemplate);
329
+
330
+ expect(renderedComponent.state('isWaitingForEmailTemplateExport')).toBe(true);
331
+ expect(renderedComponent.state('selectedTemplateForExport')).toEqual(emailTemplate);
332
+ });
333
+
334
+ it('handleExportHtmlFromListing does nothing when the template has no _id', () => {
335
+ renderedComponent.instance().handleExportHtmlFromListing({ name: 'no id' });
336
+
337
+ expect(renderedComponent.state('isWaitingForEmailTemplateExport')).toBeFalsy();
338
+ });
339
+
340
+ it('componentWillReceiveProps calls processEmailHtmlExport once template details finish loading', () => {
341
+ renderedComponent.instance().handleExportHtmlFromListing(emailTemplate);
342
+ const processSpy = jest.spyOn(renderedComponent.instance(), 'processEmailHtmlExport').mockImplementation(() => {});
343
+
344
+ const templateDetails = { _id: 'email_1', versions: { base: { en: { 'template-content': '<p>hi</p>' } } } };
345
+ renderedComponent.setProps({
346
+ Templates: { ...TemplatesProp, getTemplateDetailsInProgress: false, templateDetails },
347
+ });
348
+
349
+ expect(processSpy).toHaveBeenCalledWith(templateDetails, emailTemplate);
350
+ expect(renderedComponent.state('isWaitingForEmailTemplateExport')).toBe(false);
351
+ expect(renderedComponent.state('selectedTemplateForExport')).toBeNull();
352
+ });
353
+
354
+ it('componentWillReceiveProps shows an error notification when template details come back empty', () => {
355
+ const CapNotification = require('@capillarytech/cap-ui-library').CapNotification;
356
+ renderedComponent.instance().handleExportHtmlFromListing(emailTemplate);
357
+ const processSpy = jest.spyOn(renderedComponent.instance(), 'processEmailHtmlExport').mockImplementation(() => {});
358
+
359
+ renderedComponent.setProps({
360
+ Templates: { ...TemplatesProp, getTemplateDetailsInProgress: false, templateDetails: {} },
361
+ });
362
+
363
+ expect(processSpy).not.toHaveBeenCalled();
364
+ expect(CapNotification.error).toHaveBeenCalled();
365
+ });
366
+
367
+ it('processEmailHtmlExport delegates to the shared exportEmailTemplateHtml helper', () => {
368
+ exportEmailTemplateHtml.mockResolvedValue();
369
+ const templateDetails = { versions: { base: { en: { 'template-content': '<p>hi</p>' } } } };
370
+
371
+ renderedComponent.instance().processEmailHtmlExport(templateDetails, emailTemplate);
372
+
373
+ expect(exportEmailTemplateHtml).toHaveBeenCalledWith(templateDetails, 'My Email Template.html', 'en');
374
+ });
375
+
376
+ it('processEmailHtmlExport shows an error notification when the shared helper rejects', async () => {
377
+ const CapNotification = require('@capillarytech/cap-ui-library').CapNotification;
378
+ const error = new Error('export failed');
379
+ exportEmailTemplateHtml.mockRejectedValue(error);
380
+
381
+ renderedComponent.instance().processEmailHtmlExport({}, emailTemplate);
382
+ // Let the promise rejection's .catch() microtask run.
383
+ await Promise.resolve().then(() => {}).then(() => {});
384
+
385
+ expect(CapNotification.error).toHaveBeenCalled();
386
+ });
387
+
388
+ it('renders an "Export HTML" menu item (EMAIL channel only) wired to handleExportHtmlFromListing', () => {
389
+ const instance = renderedComponent.instance();
390
+ const spy = jest.spyOn(instance, 'handleExportHtmlFromListing').mockImplementation(() => {});
391
+
392
+ const cardDataList = instance.getTemplateDataForGrid({
393
+ templates: [emailTemplate],
394
+ handlers: { handlePreviewClick: jest.fn(), handleEditClick: jest.fn() },
395
+ filterContent: null,
396
+ channel: 'email',
397
+ isLoading: false,
398
+ isInitialLoading: false,
399
+ loadingTip: '',
400
+ previewTemplateId: null,
401
+ });
402
+
403
+ const exportMenuItem = findByClassName(cardDataList, 'export-html-email');
404
+ expect(exportMenuItem).not.toBeNull();
405
+
406
+ exportMenuItem.props.onClick();
407
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ _id: 'email_1' }));
408
+ });
409
+
410
+ it('does not render the "Export HTML" menu item for a non-EMAIL channel', () => {
411
+ RenderFunctionFor('whatsapp', true);
412
+ renderedComponent.setProps({ isFullMode: true, isDltFromRcs: false });
413
+ const instance = renderedComponent.instance();
414
+
415
+ const cardDataList = instance.getTemplateDataForGrid({
416
+ templates: [{ _id: 'wa_1', name: 'WA Template' }],
417
+ handlers: { handlePreviewClick: jest.fn(), handleEditClick: jest.fn() },
418
+ filterContent: null,
419
+ channel: 'whatsapp',
420
+ isLoading: false,
421
+ isInitialLoading: false,
422
+ loadingTip: '',
423
+ previewTemplateId: null,
424
+ });
425
+
426
+ expect(findByClassName(cardDataList, 'export-html-email')).toBeNull();
427
+ });
428
+ });
259
429
  });