@capillarytech/creatives-library 9.0.61-alpha.3 → 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.
- package/constants/unified.js +8 -2
- package/package.json +1 -1
- package/services/api.js +5 -0
- package/services/tests/api.test.js +44 -0
- package/utils/common.js +8 -0
- package/utils/downloadFile.js +57 -0
- package/utils/tests/downloadFile.test.js +219 -0
- package/v2Components/FormBuilder/Classic.js +43 -9
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +46 -8
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
- package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
- package/v2Components/FormBuilder/_formBuilder.scss +11 -0
- package/v2Components/FormBuilder/index.js +27 -17
- package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
- package/v2Containers/BeeEditor/index.js +8 -0
- package/v2Containers/Email/index.js +118 -16
- package/v2Containers/Email/initialSchema.js +25 -3
- package/v2Containers/Email/messages.js +16 -0
- package/v2Containers/Email/tests/index.test.js +597 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
- package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
- package/v2Containers/Templates/index.js +53 -0
- package/v2Containers/Templates/messages.js +8 -0
- package/v2Containers/Templates/tests/index.test.js +170 -0
- package/v2Containers/Viber/constants.js +0 -3
- package/v2Containers/Viber/sagas.js +3 -1
- package/v2Containers/Viber/tests/saga.test.js +21 -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
|
});
|
|
@@ -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
|
});
|
|
@@ -19,9 +19,6 @@ export const EMPTY_INJECTED_TAGS = {};
|
|
|
19
19
|
export const EMPTY_TAGS = [];
|
|
20
20
|
export const EMPTY_TEMPLATE_DATA = {};
|
|
21
21
|
|
|
22
|
-
// Cap + Viber each inject v2ViberSagas; module counter lets only the first task start takeLatest watchers (avoids duplicate API calls).
|
|
23
|
-
export let viberSagasActive = 0;
|
|
24
|
-
|
|
25
22
|
export const UPLOAD_VIBER_ASSET_REQUEST = 'app/v2Containers/Viber/UPLOAD_ASSET_REQUEST';
|
|
26
23
|
export const UPLOAD_VIBER_ASSET_SUCCESS = 'app/v2Containers/Viber/UPLOAD_ASSET_SUCCESS';
|
|
27
24
|
export const UPLOAD_VIBER_ASSET_FAILURE = 'app/v2Containers/Viber/UPLOAD_ASSET_FAILURE';
|
|
@@ -6,7 +6,6 @@ import { pollAssetStatus } from '../../sagas/assetPolling';
|
|
|
6
6
|
import { createPollingConfig } from '../../utils/asyncAssetUpload';
|
|
7
7
|
import { ASSET_STATUS } from '../../utils/assetStatusConstants';
|
|
8
8
|
import * as types from './constants';
|
|
9
|
-
import { viberSagasActive } from './constants';
|
|
10
9
|
import messages from './messages';
|
|
11
10
|
|
|
12
11
|
export function* uploadViberAsset(params) {
|
|
@@ -149,6 +148,9 @@ export default [
|
|
|
149
148
|
watchEditTemplate,
|
|
150
149
|
];
|
|
151
150
|
|
|
151
|
+
// Cap + Viber each inject v2ViberSagas; module counter lets only the first task start takeLatest watchers (avoids duplicate API calls).
|
|
152
|
+
let viberSagasActive = 0;
|
|
153
|
+
|
|
152
154
|
export function* v2ViberSagas() {
|
|
153
155
|
if (viberSagasActive > 0) {
|
|
154
156
|
return;
|
|
@@ -555,5 +555,26 @@ describe('Viber Sagas', () => {
|
|
|
555
555
|
|
|
556
556
|
describe('v2ViberSagas Combined', () => {
|
|
557
557
|
it('should initialize all Viber-related watcher sagas without error', () => expectSaga(sagas.v2ViberSagas).run());
|
|
558
|
+
|
|
559
|
+
// Double inject (Cap + Viber): only the first task registers watchers.
|
|
560
|
+
it('does not start the watchers again while another task already holds them', () => {
|
|
561
|
+
const first = sagas.v2ViberSagas();
|
|
562
|
+
expect(first.next().done).toBe(false);
|
|
563
|
+
|
|
564
|
+
const second = sagas.v2ViberSagas();
|
|
565
|
+
expect(second.next()).toEqual({ value: undefined, done: true });
|
|
566
|
+
|
|
567
|
+
first.return();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('releases the guard when the running task is cancelled, so a remount re-arms', () => {
|
|
571
|
+
const first = sagas.v2ViberSagas();
|
|
572
|
+
expect(first.next().done).toBe(false);
|
|
573
|
+
first.return();
|
|
574
|
+
|
|
575
|
+
const afterCancel = sagas.v2ViberSagas();
|
|
576
|
+
expect(afterCancel.next().done).toBe(false);
|
|
577
|
+
afterCancel.return();
|
|
578
|
+
});
|
|
558
579
|
});
|
|
559
580
|
});
|