@capillarytech/creatives-library 9.0.69 → 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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/v2Components/FormBuilder/Classic.js +3 -0
  3. package/v2Containers/CreativesContainer/SlideBoxContent.js +37 -29
  4. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  5. package/v2Containers/CreativesContainer/index.js +26 -1
  6. package/v2Containers/CreativesContainer/messages.js +4 -0
  7. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +40 -12
  8. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +10 -0
  9. package/v2Containers/CreativesContainer/tests/switchToEditAfterCreate.test.js +152 -0
  10. package/v2Containers/Email/index.js +41 -6
  11. package/v2Containers/Email/tests/index.test.js +123 -0
  12. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +32 -12
  13. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +34 -0
  14. package/v2Containers/InApp/index.js +19 -23
  15. package/v2Containers/InApp/tests/index.test.js +52 -9
  16. package/v2Containers/InappAdvance/index.js +19 -24
  17. package/v2Containers/InappAdvance/tests/index.test.js +39 -15
  18. package/v2Containers/Line/Container/index.js +18 -8
  19. package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +129 -108
  20. package/v2Containers/Line/Container/tests/index.test.js +68 -0
  21. package/v2Containers/MobilePush/Create/index.js +24 -5
  22. package/v2Containers/MobilePush/Create/test/stayOpenOnSave.test.js +80 -0
  23. package/v2Containers/MobilePush/Edit/index.js +15 -9
  24. package/v2Containers/MobilePush/Edit/test/stayOpenOnSave.test.js +60 -0
  25. package/v2Containers/MobilePushNew/index.js +41 -7
  26. package/v2Containers/MobilePushNew/tests/index.test.js +61 -0
  27. package/v2Containers/Sms/Create/index.js +31 -13
  28. package/v2Containers/Sms/Create/test/stayOpenOnSave.test.js +92 -0
  29. package/v2Containers/Sms/Edit/index.js +20 -12
  30. package/v2Containers/Sms/Edit/tests/index.test.js +66 -0
  31. package/v2Containers/Templates/index.js +17 -4
  32. package/v2Containers/Viber/index.js +15 -6
  33. package/v2Containers/Viber/tests/index.test.js +97 -28
  34. package/v2Containers/WebPush/Create/components/FormActions.js +1 -1
  35. package/v2Containers/WebPush/Create/components/FormActions.test.js +3 -3
  36. package/v2Containers/WebPush/Create/index.js +32 -6
  37. package/v2Containers/WebPush/Create/messages.js +0 -4
  38. 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
- const {versions, ...rest} = createResponse.templateId;
1028
- this.props.getFormSubscriptionData({ value: versions, ...rest, validity: true});
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
- const templateDetails = this.props.Email.templateDetails;
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
- exportHtmlTooltip={getExportHtmlDisabledTooltip(this.state.isEdit, this.state.exportHtmlContentLocked, this.props.intl.formatMessage)}
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
  /> : ''}
@@ -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
- const finalTemplateName = isEditModeForSave ? (extractedTemplateName || '') : (templateName || '');
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
- const { versions, ...rest } = createResponse.templateId;
893
- getFormdata({ value: versions, ...rest, validity: true });
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
- if (!isEditMode || hasContentChanged || !loadedHtmlContent) {
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(loadedHtmlContent, `${sanitizeFileName(extractedTemplateName)}.html`);
1118
- }, [isEditMode, hasContentChanged, loadedHtmlContent, extractedTemplateName, formatMessage]);
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={!isEditMode || hasContentChanged} onClick={handleExportHtml}>
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: isEditFlow
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
- onCreateComplete();
986
- } else {
987
- setSpin(false);
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.id,
1006
+ _id: params?.id || savedTemplateId,
998
1007
  },
999
1008
  (resp, errorMsg) => {
1000
1009
  actionCallback({ resp, errorMsg });
1010
+ setSpin(false);
1001
1011
  if (!errorMsg) {
1002
- onCreateComplete();
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 (isEditFlow) {
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
- if (isEditFlow) {
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(/create/i);
99
- const hasUpdateButton = screen.queryByText(/update/i);
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: /update/i,
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(/create/i);
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: /update/i,
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(/update/i);
224
- const hasCreateButton = screen.queryByText(/create/i);
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(/update/i) || screen.queryByText(/creative layout/i);
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
+ });