@capillarytech/creatives-library 9.0.68 → 9.0.70-alpha.sprint-cap-modalintact.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/v2Components/FormBuilder/Classic.js +3 -0
- package/v2Containers/CreativesContainer/SlideBoxContent.js +37 -29
- package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
- package/v2Containers/CreativesContainer/index.js +26 -1
- package/v2Containers/CreativesContainer/messages.js +4 -0
- package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +40 -12
- package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +10 -0
- package/v2Containers/CreativesContainer/tests/switchToEditAfterCreate.test.js +152 -0
- package/v2Containers/Email/index.js +41 -6
- package/v2Containers/Email/messages.js +2 -2
- package/v2Containers/Email/tests/index.test.js +123 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +32 -12
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +34 -0
- package/v2Containers/InApp/index.js +19 -23
- package/v2Containers/InApp/tests/index.test.js +52 -9
- package/v2Containers/InappAdvance/index.js +19 -24
- package/v2Containers/InappAdvance/tests/index.test.js +39 -15
- package/v2Containers/Line/Container/index.js +18 -8
- package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +129 -108
- package/v2Containers/Line/Container/tests/index.test.js +68 -0
- package/v2Containers/MobilePush/Create/index.js +24 -5
- package/v2Containers/MobilePush/Create/test/stayOpenOnSave.test.js +80 -0
- package/v2Containers/MobilePush/Edit/index.js +15 -9
- package/v2Containers/MobilePush/Edit/test/stayOpenOnSave.test.js +60 -0
- package/v2Containers/MobilePushNew/index.js +41 -7
- package/v2Containers/MobilePushNew/tests/index.test.js +61 -0
- package/v2Containers/Sms/Create/index.js +31 -13
- package/v2Containers/Sms/Create/test/stayOpenOnSave.test.js +92 -0
- package/v2Containers/Sms/Edit/index.js +20 -12
- package/v2Containers/Sms/Edit/tests/index.test.js +66 -0
- package/v2Containers/Templates/index.js +17 -4
- package/v2Containers/Viber/index.js +15 -6
- package/v2Containers/Viber/tests/index.test.js +97 -28
- package/v2Containers/WebPush/Create/components/FormActions.js +1 -1
- package/v2Containers/WebPush/Create/components/FormActions.test.js +3 -3
- package/v2Containers/WebPush/Create/index.js +32 -6
- package/v2Containers/WebPush/Create/messages.js +0 -4
- package/v2Containers/WebPush/Create/tests/stayOpenOnSave.test.js +268 -0
|
@@ -65,6 +65,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
65
65
|
change: this.onChange,
|
|
66
66
|
},
|
|
67
67
|
isEdit: false,
|
|
68
|
+
// Full mode: last successfully saved template (create continues in place as an update; Export HTML source)
|
|
69
|
+
savedTemplate: null,
|
|
68
70
|
schema: {},
|
|
69
71
|
loading: false,
|
|
70
72
|
isFormValid: true,
|
|
@@ -333,6 +335,13 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
333
335
|
// this.showNext();
|
|
334
336
|
// }
|
|
335
337
|
const {isFullMode, isGetFormData} = nextProps;
|
|
338
|
+
// New Save click: allow one API call and re-arm startValidation (saves only on false → true)
|
|
339
|
+
if (isFullMode && isGetFormData && !this.props.isGetFormData) {
|
|
340
|
+
this.isSaveClickPending = true;
|
|
341
|
+
if (this.state.startValidation) {
|
|
342
|
+
this.setState({ startValidation: false });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
336
345
|
if (isFullMode && isGetFormData && !_.isEqual(isGetFormData, this.props.isGetFormData) && !this.state.isDragDrop) {
|
|
337
346
|
// Only for CK editor: when Done is clicked in full mode, trigger the same chain as library
|
|
338
347
|
// so that FormBuilder validation and saveFormData run reliably (getFormData -> saveValidationData -> startValidation).
|
|
@@ -986,7 +995,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
986
995
|
});
|
|
987
996
|
}
|
|
988
997
|
onUpdateTemplateComplete = (createResponse) => {
|
|
989
|
-
const {isEdit, formData = {} } = _.cloneDeep(this.state) || {};
|
|
998
|
+
const {isEdit: isEditMode, savedTemplate: previouslySavedTemplate, formData = {} } = _.cloneDeep(this.state) || {};
|
|
999
|
+
const isEdit = isEditMode || !!previouslySavedTemplate; // an update, also when continuing an in-session create
|
|
990
1000
|
const base = formData.base || {};
|
|
991
1001
|
const { activeTab } = base;
|
|
992
1002
|
const isDragDrop = _.get(base[base.activeTab], 'is_drag_drop');
|
|
@@ -1024,17 +1034,30 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
1024
1034
|
const isLanguageSupport = this.props.location.query.isLanguageSupport || false;
|
|
1025
1035
|
const isBEESupport = (this.props.location.query.isBEESupport !== "false") || false;
|
|
1026
1036
|
if (this.props.isFullMode) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1037
|
+
// Stay open: next save updates this template; Export HTML uses the saved content.
|
|
1038
|
+
const savedResponse = createResponse.templateId;
|
|
1039
|
+
const {versions, ...rest} = _.isPlainObject(savedResponse) ? savedResponse : {};
|
|
1040
|
+
const savedTemplateId = rest._id || (_.isString(savedResponse) ? savedResponse : '')
|
|
1041
|
+
|| previouslySavedTemplate?._id || this.props.Email.templateDetails?._id;
|
|
1042
|
+
const savedTemplate = { ...this.lastSavePayload, _id: savedTemplateId, name: rest.name || this.lastSavePayload?.name };
|
|
1043
|
+
this.originalBaseTemplateContent = _.get(savedTemplate, ['versions', 'base', this.getBaseLanguage(), 'template-content']);
|
|
1044
|
+
this.setState({ savedTemplate, exportHtmlContentLocked: false });
|
|
1045
|
+
this.props.actions.clearCRUDResponse();
|
|
1046
|
+
this.props.getFormSubscriptionData({ value: versions, ...rest, _id: savedTemplateId, validity: true, keepSlideBoxOpen: true });
|
|
1029
1047
|
} else {
|
|
1030
1048
|
this.props.router.push({
|
|
1031
1049
|
pathname: `/email`,
|
|
1032
1050
|
query: type === 'embedded' ? {type: 'embedded', module, isLanguageSupport, isBEESupport} : {module, isLanguageSupport, isBEESupport},
|
|
1033
1051
|
});
|
|
1034
1052
|
}
|
|
1053
|
+
} else if (this.props.isFullMode && createResponse?.error && this.props.onValidationFail) {
|
|
1054
|
+
// Save failed (saga shows the error): reset the host's Save trigger so the user can retry
|
|
1055
|
+
this.props.onValidationFail();
|
|
1035
1056
|
}
|
|
1036
1057
|
}
|
|
1037
1058
|
|
|
1059
|
+
getBaseLanguage = () => this.props.currentOrgDetails?.basic_details?.base_language || DEFAULT_LOCALE;
|
|
1060
|
+
|
|
1038
1061
|
getSupportedLanguages = (props) => {
|
|
1039
1062
|
const {currentOrgDetails} = props;
|
|
1040
1063
|
let supportedLanguages = [];
|
|
@@ -2065,7 +2088,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
2065
2088
|
}
|
|
2066
2089
|
|
|
2067
2090
|
exportHtml = () => {
|
|
2068
|
-
|
|
2091
|
+
// After a full-mode save the stored details are stale (or absent for a create): export what was just saved
|
|
2092
|
+
const templateDetails = this.state.savedTemplate || this.props.Email.templateDetails;
|
|
2069
2093
|
if (_.isEmpty(templateDetails) || this.state.exportHtmlContentLocked) {
|
|
2070
2094
|
CapNotification.error({ message: this.props.intl.formatMessage(messages.exportHtmlError) });
|
|
2071
2095
|
return;
|
|
@@ -2460,7 +2484,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
2460
2484
|
}
|
|
2461
2485
|
}
|
|
2462
2486
|
if (containerInputFieldCol.id === "tab-options-popover") {
|
|
2463
|
-
const canExportHtml = this.state.isEdit && !this.state.exportHtmlContentLocked;
|
|
2487
|
+
const canExportHtml = (this.state.isEdit || !!this.state.savedTemplate) && !this.state.exportHtmlContentLocked;
|
|
2464
2488
|
this.setExportHtmlLabelStyle(containerInputFieldCol, {
|
|
2465
2489
|
opacity: canExportHtml ? 1 : 0.5,
|
|
2466
2490
|
cursor: canExportHtml ? 'pointer' : 'not-allowed',
|
|
@@ -2849,6 +2873,13 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
2849
2873
|
this.setState({gettingFormData: false, startValidation: false, loading: true});
|
|
2850
2874
|
return;
|
|
2851
2875
|
}
|
|
2876
|
+
if (this.props.isFullMode) {
|
|
2877
|
+
// Exactly one API call per Save click (create saga is takeLatest: a duplicate would swallow the real response)
|
|
2878
|
+
if (!this.isSaveClickPending) {
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
this.isSaveClickPending = false;
|
|
2882
|
+
}
|
|
2852
2883
|
const formData = _.cloneDeep(passedData);
|
|
2853
2884
|
const { currentTab } = _.cloneDeep(this.state);
|
|
2854
2885
|
delete formData[currentTab - 1][formData[currentTab - 1].activeTab][`BEEeditor${currentTab > 1 ? currentTab : ''}json`];
|
|
@@ -2860,6 +2891,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
2860
2891
|
};
|
|
2861
2892
|
if (this.state.isEdit) {
|
|
2862
2893
|
obj._id = this.props.Email.templateDetails?._id || '';
|
|
2894
|
+
} else if (this.state.savedTemplate?._id) {
|
|
2895
|
+
obj._id = this.state.savedTemplate._id; // created earlier in this session: update it
|
|
2863
2896
|
}
|
|
2864
2897
|
obj.type = 'EMAIL';
|
|
2865
2898
|
const baseLanguage = this.props.currentOrgDetails.basic_details.base_language;
|
|
@@ -2905,6 +2938,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
2905
2938
|
|
|
2906
2939
|
// if (saveCount === 0) {
|
|
2907
2940
|
this.props.actions.transformEmailTemplate(obj, (newEmail) => {
|
|
2941
|
+
this.lastSavePayload = newEmail;
|
|
2908
2942
|
this.props.actions.createTemplate(newEmail, this.onUpdateTemplateComplete);
|
|
2909
2943
|
});
|
|
2910
2944
|
// } else {
|
|
@@ -3276,7 +3310,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
|
|
|
3276
3310
|
getBEEData={this.getBEEData}
|
|
3277
3311
|
onContentChange={this.handleBeeContentChange}
|
|
3278
3312
|
exportHtmlLocked={this.state.exportHtmlContentLocked}
|
|
3279
|
-
|
|
3313
|
+
exportHtmlEnabled={!!this.state.savedTemplate && !this.state.exportHtmlContentLocked}
|
|
3314
|
+
exportHtmlTooltip={getExportHtmlDisabledTooltip(this.state.isEdit || !!this.state.savedTemplate, this.state.exportHtmlContentLocked, this.props.intl.formatMessage)}
|
|
3280
3315
|
isTestAndPreviewMode={this.state.isTestAndPreviewMode} // Add flag to prevent validation
|
|
3281
3316
|
isTransactional={this.props.isTransactional}
|
|
3282
3317
|
/> : ''}
|
|
@@ -28,11 +28,11 @@ export default defineMessages({
|
|
|
28
28
|
},
|
|
29
29
|
"exportHtmlDisabledCreateTooltip": {
|
|
30
30
|
id: 'creatives.containersV2.Email.exportHtmlDisabledCreateTooltip',
|
|
31
|
-
defaultMessage: "
|
|
31
|
+
defaultMessage: "Your template is empty. Add content, then save it to export as HTML.",
|
|
32
32
|
},
|
|
33
33
|
"exportHtmlDisabledEditedTooltip": {
|
|
34
34
|
id: 'creatives.containersV2.Email.exportHtmlDisabledEditedTooltip',
|
|
35
|
-
defaultMessage: "
|
|
35
|
+
defaultMessage: "You have unsaved edits. Save the template to export the latest HTML.",
|
|
36
36
|
},
|
|
37
37
|
"alphabeticallyOption": {
|
|
38
38
|
id: 'creatives.containersV2.Email.alphabeticallyOption',
|
|
@@ -594,4 +594,127 @@ describe('Email container', () => {
|
|
|
594
594
|
expect(switchLabel.style.display).toBe(DISPLAY_NONE);
|
|
595
595
|
});
|
|
596
596
|
});
|
|
597
|
+
describe('full mode: saves keep the slidebox open (create continues in place as an update)', () => {
|
|
598
|
+
const buildFormData = () => ({
|
|
599
|
+
0: { activeTab: 'en', en: { 'template-content': '<p>hi</p>' }, selectedLanguages: ['en'], base: true, tabKey: '7' },
|
|
600
|
+
base: { activeTab: 'en', en: { 'template-content': '<p>hi</p>' }, selectedLanguages: ['en'] },
|
|
601
|
+
'template-name': 'Welcome',
|
|
602
|
+
'template-subject': 'Hello',
|
|
603
|
+
});
|
|
604
|
+
const fullModeProps = (overrides = {}) => {
|
|
605
|
+
const props = buildProps({
|
|
606
|
+
isFullMode: true,
|
|
607
|
+
isGetFormData: false,
|
|
608
|
+
location: { query: { module: 'library', type: 'embedded' }, pathname: '/email/create' },
|
|
609
|
+
getFormSubscriptionData: jest.fn(),
|
|
610
|
+
onValidationFail: jest.fn(),
|
|
611
|
+
...overrides,
|
|
612
|
+
});
|
|
613
|
+
props.actions.transformEmailTemplate = jest.fn((obj, cb) => cb(obj));
|
|
614
|
+
return props;
|
|
615
|
+
};
|
|
616
|
+
const clickSave = (wrapper) => {
|
|
617
|
+
wrapper.setProps({ isGetFormData: false });
|
|
618
|
+
wrapper.setProps({ isGetFormData: true });
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
it.each([
|
|
622
|
+
['a template object', { _id: 'email-1', name: 'Welcome' }],
|
|
623
|
+
['a plain id string', 'email-1'],
|
|
624
|
+
])('create: remembers the new id (%s), stays open, and the next save updates it', (_label, templateId) => {
|
|
625
|
+
const props = fullModeProps();
|
|
626
|
+
const wrapper = shallowWithIntl(<Email {...props} />);
|
|
627
|
+
const instance = wrapper.instance();
|
|
628
|
+
|
|
629
|
+
clickSave(wrapper);
|
|
630
|
+
instance.saveFormData(buildFormData());
|
|
631
|
+
expect(props.actions.createTemplate.mock.calls[0][0]._id).toBeUndefined();
|
|
632
|
+
instance.onUpdateTemplateComplete({ templateId });
|
|
633
|
+
|
|
634
|
+
expect(wrapper.state('savedTemplate')).toEqual(expect.objectContaining({ _id: 'email-1', name: 'Welcome' }));
|
|
635
|
+
expect(props.actions.clearCRUDResponse).toHaveBeenCalled();
|
|
636
|
+
expect(props.getFormSubscriptionData).toHaveBeenCalledWith(
|
|
637
|
+
expect.objectContaining({ _id: 'email-1', validity: true, keepSlideBoxOpen: true }),
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
clickSave(wrapper);
|
|
641
|
+
instance.saveFormData(buildFormData());
|
|
642
|
+
expect(props.actions.createTemplate).toHaveBeenCalledTimes(2);
|
|
643
|
+
expect(props.actions.createTemplate.mock.calls[1][0]._id).toBe('email-1');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('edit: sends the template _id and stays open', () => {
|
|
647
|
+
const templateDetails = { _id: 'email-9', name: 'Old' };
|
|
648
|
+
const props = fullModeProps({ Email: { templateDetails } });
|
|
649
|
+
const wrapper = shallowWithIntl(<Email {...props} />);
|
|
650
|
+
wrapper.setState({ isEdit: true });
|
|
651
|
+
|
|
652
|
+
clickSave(wrapper);
|
|
653
|
+
wrapper.instance().saveFormData(buildFormData());
|
|
654
|
+
expect(props.actions.createTemplate.mock.calls[0][0]._id).toBe('email-9');
|
|
655
|
+
wrapper.instance().onUpdateTemplateComplete({ templateId: templateDetails });
|
|
656
|
+
expect(props.getFormSubscriptionData).toHaveBeenCalledWith(expect.objectContaining({ keepSlideBoxOpen: true }));
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('sends exactly one API call per Save click even if the editor submits repeatedly (BEE)', () => {
|
|
660
|
+
const props = fullModeProps();
|
|
661
|
+
const wrapper = shallowWithIntl(<Email {...props} />);
|
|
662
|
+
|
|
663
|
+
clickSave(wrapper);
|
|
664
|
+
wrapper.instance().saveFormData(buildFormData());
|
|
665
|
+
wrapper.instance().saveFormData(buildFormData());
|
|
666
|
+
wrapper.instance().saveFormData(buildFormData());
|
|
667
|
+
expect(props.actions.createTemplate).toHaveBeenCalledTimes(1);
|
|
668
|
+
|
|
669
|
+
wrapper.instance().saveFormData(buildFormData()); // no click → ignored
|
|
670
|
+
expect(props.actions.createTemplate).toHaveBeenCalledTimes(1);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('re-arms validation on each new Save click (reset before the save triggers run)', () => {
|
|
674
|
+
const wrapper = shallowWithIntl(<Email {...fullModeProps()} />);
|
|
675
|
+
wrapper.setState({ startValidation: true }); // left set by the previous save
|
|
676
|
+
const instance = wrapper.instance();
|
|
677
|
+
const events = [];
|
|
678
|
+
const originalSetState = instance.setState.bind(instance);
|
|
679
|
+
instance.setState = (update, callback) => {
|
|
680
|
+
if (update && typeof update === 'object' && 'startValidation' in update) events.push(`startValidation:${update.startValidation}`);
|
|
681
|
+
return originalSetState(update, callback);
|
|
682
|
+
};
|
|
683
|
+
const originalGetFormData = instance.getFormData;
|
|
684
|
+
instance.getFormData = (...args) => { events.push('trigger'); return originalGetFormData(...args); };
|
|
685
|
+
|
|
686
|
+
wrapper.setProps({ isGetFormData: true });
|
|
687
|
+
|
|
688
|
+
expect(events[0]).toBe('startValidation:false');
|
|
689
|
+
expect(events).toContain('trigger');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('enables Export HTML after the save and exports the saved content', () => {
|
|
693
|
+
const props = fullModeProps();
|
|
694
|
+
const wrapper = shallowWithIntl(<Email {...props} />);
|
|
695
|
+
wrapper.setState({ exportHtmlContentLocked: true });
|
|
696
|
+
clickSave(wrapper);
|
|
697
|
+
wrapper.instance().saveFormData(buildFormData());
|
|
698
|
+
wrapper.instance().onUpdateTemplateComplete({ templateId: 'email-1' });
|
|
699
|
+
|
|
700
|
+
expect(wrapper.state('exportHtmlContentLocked')).toBe(false);
|
|
701
|
+
expect(wrapper.instance().originalBaseTemplateContent).toBe('<p>hi</p>');
|
|
702
|
+
exportEmailTemplateHtml.mockResolvedValue();
|
|
703
|
+
wrapper.instance().exportHtml();
|
|
704
|
+
expect(exportEmailTemplateHtml).toHaveBeenCalledWith(
|
|
705
|
+
expect.objectContaining({ _id: 'email-1', name: 'Welcome' }), 'Welcome.html', 'en',
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('resets the host Save trigger when the save fails so the user can retry', () => {
|
|
710
|
+
const props = fullModeProps();
|
|
711
|
+
const wrapper = shallowWithIntl(<Email {...props} />);
|
|
712
|
+
|
|
713
|
+
wrapper.instance().onUpdateTemplateComplete({ error: true, message: 'boom' });
|
|
714
|
+
|
|
715
|
+
expect(props.onValidationFail).toHaveBeenCalled();
|
|
716
|
+
expect(props.getFormSubscriptionData).not.toHaveBeenCalled();
|
|
717
|
+
expect(wrapper.state('savedTemplate')).toBeNull();
|
|
718
|
+
});
|
|
719
|
+
});
|
|
597
720
|
});
|
|
@@ -107,6 +107,8 @@ const EmailHTMLEditor = (props) => {
|
|
|
107
107
|
const [loadedHtmlContent, setLoadedHtmlContent] = useState(''); // Stable content for HTMLEditor initialization
|
|
108
108
|
const [hasContentChanged, setHasContentChanged] = useState(false);
|
|
109
109
|
const baselineContentRef = useRef(null);
|
|
110
|
+
// Full mode: last successfully saved template { _id, name, tabKey, html } — a create continues in place as an update
|
|
111
|
+
const [savedTemplate, setSavedTemplate] = useState(null);
|
|
110
112
|
const [subject, setSubject] = useState('');
|
|
111
113
|
const [subjectError, setSubjectError] = useState('');
|
|
112
114
|
// State for template name (extracted from template data in Edit mode)
|
|
@@ -503,7 +505,7 @@ const EmailHTMLEditor = (props) => {
|
|
|
503
505
|
const handleContentChange = useCallback((content) => {
|
|
504
506
|
setHtmlContent(content);
|
|
505
507
|
|
|
506
|
-
if (isEditMode && !hasContentChanged && baselineContentRef.current !== null && content !== baselineContentRef.current) {
|
|
508
|
+
if ((isEditMode || savedTemplate) && !hasContentChanged && baselineContentRef.current !== null && content !== baselineContentRef.current) {
|
|
507
509
|
setHasContentChanged(true);
|
|
508
510
|
}
|
|
509
511
|
|
|
@@ -535,7 +537,7 @@ const EmailHTMLEditor = (props) => {
|
|
|
535
537
|
setTagValidationError(null);
|
|
536
538
|
}
|
|
537
539
|
}
|
|
538
|
-
}, [tags, injectedTags, location, getDefaultTags, eventContextTags, waitEventContextTags, showLiquidErrorInFooter, isEditMode, hasContentChanged]);
|
|
540
|
+
}, [tags, injectedTags, location, getDefaultTags, eventContextTags, waitEventContextTags, showLiquidErrorInFooter, isEditMode, hasContentChanged, savedTemplate]);
|
|
539
541
|
|
|
540
542
|
// Store the last validation state received from HTMLEditor
|
|
541
543
|
const lastValidationStateRef = useRef(null);
|
|
@@ -798,7 +800,7 @@ const EmailHTMLEditor = (props) => {
|
|
|
798
800
|
const templateDataFromRedux = Email?.templateDetails || Email?.BEETemplate;
|
|
799
801
|
// IMPORTANT: Only use currentTemplateId from URL/params - don't fallback to Redux data
|
|
800
802
|
// which might be stale from previous template in create mode
|
|
801
|
-
const templateId = currentTemplateId;
|
|
803
|
+
const templateId = currentTemplateId || savedTemplate?._id; // created earlier in this session: update it
|
|
802
804
|
const isEditModeForSave = !!templateId;
|
|
803
805
|
|
|
804
806
|
const langId = get(currentOrgDetails, `basic_details.languages.${baseLanguage}.lang_id`, '');
|
|
@@ -812,6 +814,8 @@ const EmailHTMLEditor = (props) => {
|
|
|
812
814
|
if (existingTabKey) {
|
|
813
815
|
tabKey = existingTabKey;
|
|
814
816
|
}
|
|
817
|
+
} else if (savedTemplate?.tabKey) {
|
|
818
|
+
tabKey = savedTemplate.tabKey;
|
|
815
819
|
}
|
|
816
820
|
|
|
817
821
|
const languageData = {
|
|
@@ -842,7 +846,8 @@ const EmailHTMLEditor = (props) => {
|
|
|
842
846
|
subject: subject || '',
|
|
843
847
|
};
|
|
844
848
|
|
|
845
|
-
|
|
849
|
+
// Opened in edit mode: name from the loaded template; created in this session: the name entered for it
|
|
850
|
+
const finalTemplateName = isEditMode ? (extractedTemplateName || '') : (templateName || '');
|
|
846
851
|
|
|
847
852
|
const obj = {
|
|
848
853
|
type: EMAIL,
|
|
@@ -889,8 +894,18 @@ const EmailHTMLEditor = (props) => {
|
|
|
889
894
|
const isBEESupport = (location?.query?.isBEESupport !== "false") || false;
|
|
890
895
|
|
|
891
896
|
if (getFormdata) {
|
|
892
|
-
|
|
893
|
-
|
|
897
|
+
// Stay open: next save updates this template; saved content is the Export HTML source.
|
|
898
|
+
const savedResponse = createResponse.templateId;
|
|
899
|
+
const { versions, ...rest } = _.isPlainObject(savedResponse) ? savedResponse : {};
|
|
900
|
+
const savedTemplateId = rest._id || (_.isString(savedResponse) ? savedResponse : '') || templateId;
|
|
901
|
+
setSavedTemplate({
|
|
902
|
+
_id: savedTemplateId, name: finalTemplateName, tabKey, html: htmlContent || '',
|
|
903
|
+
});
|
|
904
|
+
baselineContentRef.current = htmlContent || '';
|
|
905
|
+
setHasContentChanged(false);
|
|
906
|
+
getFormdata({
|
|
907
|
+
value: versions, ...rest, _id: savedTemplateId, validity: true, keepSlideBoxOpen: true,
|
|
908
|
+
});
|
|
894
909
|
} else {
|
|
895
910
|
const queryParams = type === 'embedded'
|
|
896
911
|
? {
|
|
@@ -1002,6 +1017,8 @@ const EmailHTMLEditor = (props) => {
|
|
|
1002
1017
|
currentOrgDetails,
|
|
1003
1018
|
Email,
|
|
1004
1019
|
currentTemplateId,
|
|
1020
|
+
savedTemplate,
|
|
1021
|
+
isEditMode,
|
|
1005
1022
|
templateDataProp,
|
|
1006
1023
|
templateName,
|
|
1007
1024
|
emailActions,
|
|
@@ -1103,19 +1120,22 @@ const EmailHTMLEditor = (props) => {
|
|
|
1103
1120
|
}
|
|
1104
1121
|
}, [showTemplateName, isFullMode, templateName, extractedTemplateName, isEditMode, handleFormDataChange]);
|
|
1105
1122
|
|
|
1123
|
+
// Export HTML: edit mode, or after a full-mode save in this session (the saved content is then the source)
|
|
1124
|
+
const canExportHtml = (isEditMode || !!savedTemplate) && !hasContentChanged;
|
|
1106
1125
|
const exportHtmlTooltip = useMemo(
|
|
1107
|
-
() => getExportHtmlDisabledTooltip(isEditMode, hasContentChanged, formatMessage),
|
|
1108
|
-
[isEditMode, hasContentChanged, formatMessage]
|
|
1126
|
+
() => getExportHtmlDisabledTooltip(isEditMode || !!savedTemplate, hasContentChanged, formatMessage),
|
|
1127
|
+
[isEditMode, savedTemplate, hasContentChanged, formatMessage]
|
|
1109
1128
|
);
|
|
1110
1129
|
|
|
1111
1130
|
// No backend call: this non-Bee editor's loaded content is already the full rendered HTML.
|
|
1112
1131
|
const handleExportHtml = useCallback(() => {
|
|
1113
|
-
|
|
1132
|
+
const exportContent = savedTemplate ? savedTemplate.html : loadedHtmlContent;
|
|
1133
|
+
if (!canExportHtml || !exportContent) {
|
|
1114
1134
|
CapNotification.error({ message: formatMessage(emailMessages.exportHtmlError) });
|
|
1115
1135
|
return;
|
|
1116
1136
|
}
|
|
1117
|
-
downloadHtmlFile(
|
|
1118
|
-
}, [
|
|
1137
|
+
downloadHtmlFile(exportContent, `${sanitizeFileName(savedTemplate?.name || extractedTemplateName)}.html`);
|
|
1138
|
+
}, [canExportHtml, savedTemplate, loadedHtmlContent, extractedTemplateName, formatMessage]);
|
|
1119
1139
|
|
|
1120
1140
|
return (
|
|
1121
1141
|
<CapSpin spinning={isLoading} tip={spinTip}>
|
|
@@ -1159,7 +1179,7 @@ const EmailHTMLEditor = (props) => {
|
|
|
1159
1179
|
<CapMenu className="html-editor-options-menu">
|
|
1160
1180
|
<CapTooltip title={exportHtmlTooltip} placement="topLeft">
|
|
1161
1181
|
<span>
|
|
1162
|
-
<CapMenu.Item className="export-html-inhouse" disabled={!
|
|
1182
|
+
<CapMenu.Item className="export-html-inhouse" disabled={!canExportHtml} onClick={handleExportHtml}>
|
|
1163
1183
|
<FormattedMessage {...emailMessages.exportHtmlButton} />
|
|
1164
1184
|
</CapMenu.Item>
|
|
1165
1185
|
</span>
|
|
@@ -1462,6 +1462,40 @@ describe('EmailHTMLEditor', () => {
|
|
|
1462
1462
|
}, { timeout: 3000 });
|
|
1463
1463
|
});
|
|
1464
1464
|
|
|
1465
|
+
it('full mode create: stays open and the next save updates the just-created template', async () => {
|
|
1466
|
+
const emailActions = {
|
|
1467
|
+
...defaultProps.emailActions,
|
|
1468
|
+
transformEmailTemplate: jest.fn((obj, callback) => callback(obj)),
|
|
1469
|
+
createTemplate: jest.fn((obj, callback) => callback({ templateId: '123' })),
|
|
1470
|
+
};
|
|
1471
|
+
const getFormdata = jest.fn();
|
|
1472
|
+
const renderEditor = (isGetFormData) => (
|
|
1473
|
+
<IntlProvider locale="en" messages={{}}>
|
|
1474
|
+
<EmailHTMLEditor {...defaultProps} isGetFormData={isGetFormData} isEditEmail={false} templateName="New Template" emailActions={emailActions} getFormdata={getFormdata} getLiquidTags={null} />
|
|
1475
|
+
</IntlProvider>
|
|
1476
|
+
);
|
|
1477
|
+
const { rerender } = renderWithIntl({
|
|
1478
|
+
isGetFormData: false, isEditEmail: false, templateName: 'New Template', emailActions, getFormdata, getLiquidTags: null,
|
|
1479
|
+
});
|
|
1480
|
+
fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
|
|
1481
|
+
fireEvent.click(screen.getByTestId('trigger-content-change'));
|
|
1482
|
+
|
|
1483
|
+
rerender(renderEditor(true));
|
|
1484
|
+
await waitFor(() => expect(emailActions.createTemplate).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
|
1485
|
+
const firstPayload = emailActions.createTemplate.mock.calls[0][0];
|
|
1486
|
+
expect(firstPayload._id).toBeUndefined();
|
|
1487
|
+
expect(getFormdata).toHaveBeenCalledWith(expect.objectContaining({ _id: '123', validity: true, keepSlideBoxOpen: true }));
|
|
1488
|
+
|
|
1489
|
+
// Host resets the trigger after the save; the next Save click updates the same template
|
|
1490
|
+
rerender(renderEditor(false));
|
|
1491
|
+
rerender(renderEditor(true));
|
|
1492
|
+
await waitFor(() => expect(emailActions.createTemplate).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
|
1493
|
+
const secondPayload = emailActions.createTemplate.mock.calls[1][0];
|
|
1494
|
+
expect(secondPayload._id).toBe('123');
|
|
1495
|
+
expect(secondPayload.name).toBe('New Template');
|
|
1496
|
+
expect(secondPayload.versions.base.tabKey).toBe(firstPayload.versions.base.tabKey);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1465
1499
|
it('handles create template success without getFormdata', async () => {
|
|
1466
1500
|
const emailActions = {
|
|
1467
1501
|
...defaultProps.emailActions,
|
|
@@ -3,6 +3,7 @@ import React, {
|
|
|
3
3
|
} from "react";
|
|
4
4
|
import isEmpty from 'lodash/isEmpty';
|
|
5
5
|
import get from 'lodash/get';
|
|
6
|
+
import isPlainObject from 'lodash/isPlainObject';
|
|
6
7
|
import { bindActionCreators } from "redux";
|
|
7
8
|
import { createStructuredSelector } from "reselect";
|
|
8
9
|
import { injectIntl, FormattedMessage } from "react-intl";
|
|
@@ -163,6 +164,9 @@ export const InApp = (props) => {
|
|
|
163
164
|
const [tags, updateTags] = useState([]);
|
|
164
165
|
//for edit only
|
|
165
166
|
const [isEditFlow, setEditFlow] = useState(false);
|
|
167
|
+
// Full mode: id of the template created in this session — the form stays as is and later saves update it
|
|
168
|
+
const [savedTemplateId, setSavedTemplateId] = useState('');
|
|
169
|
+
const isUpdate = isEditFlow || !!savedTemplateId;
|
|
166
170
|
const [templateDate, setTemplateDate] = useState("");
|
|
167
171
|
|
|
168
172
|
//buttons
|
|
@@ -965,7 +969,7 @@ export const InApp = (props) => {
|
|
|
965
969
|
const actionCallback = ({ errorMsg }) => {
|
|
966
970
|
if (!errorMsg) {
|
|
967
971
|
CapNotification.success({
|
|
968
|
-
message:
|
|
972
|
+
message: isUpdate
|
|
969
973
|
? formatMessage(messages.inAppEditNotification)
|
|
970
974
|
: formatMessage(messages.inAppCreateNotification),
|
|
971
975
|
});
|
|
@@ -977,14 +981,19 @@ export const InApp = (props) => {
|
|
|
977
981
|
}
|
|
978
982
|
};
|
|
979
983
|
|
|
984
|
+
// Full mode stays open; a create continues in place as an edit.
|
|
980
985
|
const onCreateInApp = () => {
|
|
981
986
|
setSpin(true);
|
|
982
987
|
actions.createInAppTemplate(createPayload(), (resp, errorMsg) => {
|
|
983
988
|
actionCallback({ resp, errorMsg });
|
|
989
|
+
setSpin(false);
|
|
984
990
|
if (!errorMsg) {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
991
|
+
const savedResponse = resp?.templateId;
|
|
992
|
+
const newTemplateId = (isPlainObject(savedResponse) ? savedResponse._id : savedResponse) || '';
|
|
993
|
+
setSavedTemplateId(newTemplateId);
|
|
994
|
+
getFormData({
|
|
995
|
+
_id: newTemplateId, validity: true, keepSlideBoxOpen: true, continuedAsEdit: true,
|
|
996
|
+
});
|
|
988
997
|
}
|
|
989
998
|
});
|
|
990
999
|
};
|
|
@@ -994,14 +1003,13 @@ export const InApp = (props) => {
|
|
|
994
1003
|
actions.editTemplate(
|
|
995
1004
|
{
|
|
996
1005
|
...createPayload(),
|
|
997
|
-
_id: params
|
|
1006
|
+
_id: params?.id || savedTemplateId,
|
|
998
1007
|
},
|
|
999
1008
|
(resp, errorMsg) => {
|
|
1000
1009
|
actionCallback({ resp, errorMsg });
|
|
1010
|
+
setSpin(false);
|
|
1001
1011
|
if (!errorMsg) {
|
|
1002
|
-
|
|
1003
|
-
} else {
|
|
1004
|
-
setSpin(false);
|
|
1012
|
+
getFormData({ validity: true, keepSlideBoxOpen: true });
|
|
1005
1013
|
}
|
|
1006
1014
|
},
|
|
1007
1015
|
);
|
|
@@ -1070,7 +1078,7 @@ export const InApp = (props) => {
|
|
|
1070
1078
|
|
|
1071
1079
|
const onDoneCallback = () => {
|
|
1072
1080
|
if (isFullMode) {
|
|
1073
|
-
if (
|
|
1081
|
+
if (isUpdate) {
|
|
1074
1082
|
return onEditInApp();
|
|
1075
1083
|
}
|
|
1076
1084
|
return onCreateInApp();
|
|
@@ -1557,20 +1565,8 @@ export const InApp = (props) => {
|
|
|
1557
1565
|
disabled={shouldUseHTMLEditor ? isDisableDone(null) : isDisableDone(panes)}
|
|
1558
1566
|
className="inapp-create-btn"
|
|
1559
1567
|
>
|
|
1560
|
-
{
|
|
1561
|
-
|
|
1562
|
-
return isFullMode ? (
|
|
1563
|
-
<FormattedMessage {...messages.update} />
|
|
1564
|
-
) : (
|
|
1565
|
-
<FormattedMessage {...globalMessages.done} />
|
|
1566
|
-
);
|
|
1567
|
-
}
|
|
1568
|
-
return isFullMode ? (
|
|
1569
|
-
<FormattedMessage {...messages.create} />
|
|
1570
|
-
) : (
|
|
1571
|
-
<FormattedMessage {...globalMessages.done} />
|
|
1572
|
-
);
|
|
1573
|
-
})()}
|
|
1568
|
+
{/* Full mode saves without closing, so the button reads "Save" for create and edit */}
|
|
1569
|
+
<FormattedMessage {...(isFullMode ? globalMessages.save : globalMessages.done)} />
|
|
1574
1570
|
</CapButton>
|
|
1575
1571
|
{/* {!isFullMode && ( */}
|
|
1576
1572
|
<CapButton
|
|
@@ -6,7 +6,7 @@ import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
|
|
|
6
6
|
import history from '../../../utils/history';
|
|
7
7
|
import { initialReducer } from '../../../initialReducer';
|
|
8
8
|
import { InApp } from '../index';
|
|
9
|
-
import { render, screen, fireEvent, within, waitFor } from '../../../utils/test-utils';
|
|
9
|
+
import { render, screen, fireEvent, within, waitFor, act } from '../../../utils/test-utils';
|
|
10
10
|
import {
|
|
11
11
|
editData,
|
|
12
12
|
accountData,
|
|
@@ -95,8 +95,8 @@ describe('Test activity inApp container', () => {
|
|
|
95
95
|
// Wait for any element to appear (component should render after spinner stops)
|
|
96
96
|
const hasBeeEditor = screen.queryByTestId('bee-popup-editor-ANDROID');
|
|
97
97
|
const hasTabs = screen.queryByRole('tab', { name: /ios/i });
|
|
98
|
-
const hasCreateButton = screen.queryByText(
|
|
99
|
-
const hasUpdateButton = screen.queryByText(
|
|
98
|
+
const hasCreateButton = screen.queryByText(/^save$/i);
|
|
99
|
+
const hasUpdateButton = screen.queryByText(/^save$/i);
|
|
100
100
|
const hasCapDeviceContent = screen.queryByTestId('cap-device-content');
|
|
101
101
|
const hasLayoutOption = screen.queryByText(/creative layout/i);
|
|
102
102
|
expect(hasBeeEditor || hasTabs || hasCreateButton || hasUpdateButton || hasCapDeviceContent || hasLayoutOption).toBeTruthy();
|
|
@@ -125,7 +125,7 @@ describe('Test activity inApp container', () => {
|
|
|
125
125
|
|
|
126
126
|
// Find update button - might be in BEE editor or old UI
|
|
127
127
|
const updateBtn = screen.queryByRole('button', {
|
|
128
|
-
name:
|
|
128
|
+
name: /^save$/i,
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
if (updateBtn) {
|
|
@@ -165,7 +165,7 @@ describe('Test activity inApp container', () => {
|
|
|
165
165
|
const hasTabs = screen.queryByRole('tab', { name: /ios/i });
|
|
166
166
|
const hasBeeEditor = screen.queryByTestId('bee-popup-editor-ANDROID');
|
|
167
167
|
const hasDoneButton = screen.queryByText(/done/i);
|
|
168
|
-
const hasCreateButton = screen.queryByText(
|
|
168
|
+
const hasCreateButton = screen.queryByText(/^save$/i);
|
|
169
169
|
const hasLayoutOption = screen.queryByText(/creative layout/i);
|
|
170
170
|
expect(hasTabs || hasBeeEditor || hasDoneButton || hasCreateButton || hasLayoutOption).toBeTruthy();
|
|
171
171
|
}, { timeout: 5000 });
|
|
@@ -188,7 +188,7 @@ describe('Test activity inApp container', () => {
|
|
|
188
188
|
const doneBtn = screen.queryByRole('button', {
|
|
189
189
|
name: /done/i,
|
|
190
190
|
}) || screen.queryByRole('button', {
|
|
191
|
-
name:
|
|
191
|
+
name: /^save$/i,
|
|
192
192
|
});
|
|
193
193
|
expect(doneBtn).toBeEnabled();
|
|
194
194
|
});
|
|
@@ -220,8 +220,8 @@ describe('Test activity inApp container', () => {
|
|
|
220
220
|
const hasTabs = screen.queryByRole('tab', { name: /ios/i });
|
|
221
221
|
const hasBeeEditor = screen.queryByTestId('bee-popup-editor-ANDROID');
|
|
222
222
|
const hasLayoutOption = screen.queryByText(/top banner/i);
|
|
223
|
-
const hasUpdateButton = screen.queryByText(
|
|
224
|
-
const hasCreateButton = screen.queryByText(
|
|
223
|
+
const hasUpdateButton = screen.queryByText(/^save$/i);
|
|
224
|
+
const hasCreateButton = screen.queryByText(/^save$/i);
|
|
225
225
|
const hasLayoutHeading = screen.queryByText(/creative layout/i);
|
|
226
226
|
expect(hasTabs || hasBeeEditor || hasLayoutOption || hasUpdateButton || hasCreateButton || hasLayoutHeading).toBeTruthy();
|
|
227
227
|
}, { timeout: 5000 });
|
|
@@ -259,7 +259,7 @@ describe('Test activity inApp container', () => {
|
|
|
259
259
|
const htmlEditor = screen.queryByTestId('inapp-html-editor');
|
|
260
260
|
const tabs = screen.queryByRole('tab');
|
|
261
261
|
const hasAnyButton = screen.queryByRole('button');
|
|
262
|
-
const hasAnyText = screen.queryByText(/done/i) || screen.queryByText(
|
|
262
|
+
const hasAnyText = screen.queryByText(/done/i) || screen.queryByText(/^save$/i) || screen.queryByText(/creative layout/i);
|
|
263
263
|
expect(beeEditor || htmlEditor || tabs || hasAnyButton || hasAnyText).toBeTruthy();
|
|
264
264
|
}
|
|
265
265
|
});
|
|
@@ -284,3 +284,46 @@ describe('test for getCtaObject function', () => {
|
|
|
284
284
|
}]);
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
describe('InApp — full mode keeps the slidebox open after saving', () => {
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
jest.restoreAllMocks();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('edit: updates and asks the host to stay open instead of closing', async () => {
|
|
294
|
+
jest
|
|
295
|
+
.spyOn(commonUtils, 'validateInAppContent')
|
|
296
|
+
.mockImplementation((payload, options) => {
|
|
297
|
+
options.onSuccess();
|
|
298
|
+
return Promise.resolve(true);
|
|
299
|
+
});
|
|
300
|
+
const getFormData = jest.fn();
|
|
301
|
+
const onCreateComplete = jest.fn();
|
|
302
|
+
const actions = { ...mockActions, editTemplate: jest.fn() };
|
|
303
|
+
renderComponent({
|
|
304
|
+
actions,
|
|
305
|
+
globalActions: mockGlobalActions,
|
|
306
|
+
location,
|
|
307
|
+
editData,
|
|
308
|
+
accountData,
|
|
309
|
+
isFullMode: true,
|
|
310
|
+
params: { id: 'test' },
|
|
311
|
+
defaultData: {},
|
|
312
|
+
getFormData,
|
|
313
|
+
onCreateComplete,
|
|
314
|
+
metaEntities,
|
|
315
|
+
getDefaultTags,
|
|
316
|
+
injectedTags,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const saveBtn = await screen.findByRole('button', { name: /^save$/i }, { timeout: 5000 });
|
|
320
|
+
fireEvent.click(saveBtn);
|
|
321
|
+
await waitFor(() => expect(actions.editTemplate).toHaveBeenCalled(), { timeout: 3000 });
|
|
322
|
+
expect(actions.editTemplate.mock.calls[0][0]._id).toBe('test');
|
|
323
|
+
|
|
324
|
+
act(() => actions.editTemplate.mock.calls[0][1]({ templateId: { _id: 'test' } }));
|
|
325
|
+
|
|
326
|
+
expect(getFormData).toHaveBeenCalledWith({ validity: true, keepSlideBoxOpen: true });
|
|
327
|
+
expect(onCreateComplete).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
});
|