@capillarytech/creatives-library 9.0.70-alpha.sprint-cap-213712.13 → 9.0.70

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 (39) hide show
  1. package/package.json +1 -1
  2. package/v2Components/FormBuilder/Classic.js +3 -0
  3. package/v2Containers/CreativesContainer/SlideBoxContent.js +39 -30
  4. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  5. package/v2Containers/CreativesContainer/constants.js +1 -0
  6. package/v2Containers/CreativesContainer/index.js +26 -1
  7. package/v2Containers/CreativesContainer/messages.js +4 -0
  8. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +40 -12
  9. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +10 -0
  10. package/v2Containers/CreativesContainer/tests/switchToEditAfterCreate.test.js +152 -0
  11. package/v2Containers/Email/index.js +41 -6
  12. package/v2Containers/Email/tests/index.test.js +123 -0
  13. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +32 -12
  14. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +34 -0
  15. package/v2Containers/InApp/index.js +19 -23
  16. package/v2Containers/InApp/tests/index.test.js +52 -9
  17. package/v2Containers/InappAdvance/index.js +19 -24
  18. package/v2Containers/InappAdvance/tests/index.test.js +39 -15
  19. package/v2Containers/Line/Container/index.js +18 -8
  20. package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +129 -108
  21. package/v2Containers/Line/Container/tests/index.test.js +68 -0
  22. package/v2Containers/MobilePush/Create/index.js +24 -5
  23. package/v2Containers/MobilePush/Create/test/stayOpenOnSave.test.js +80 -0
  24. package/v2Containers/MobilePush/Edit/index.js +15 -9
  25. package/v2Containers/MobilePush/Edit/test/stayOpenOnSave.test.js +60 -0
  26. package/v2Containers/MobilePushNew/index.js +41 -7
  27. package/v2Containers/MobilePushNew/tests/index.test.js +61 -0
  28. package/v2Containers/Sms/Create/index.js +32 -14
  29. package/v2Containers/Sms/Create/test/stayOpenOnSave.test.js +92 -0
  30. package/v2Containers/Sms/Edit/index.js +22 -14
  31. package/v2Containers/Sms/Edit/tests/index.test.js +66 -0
  32. package/v2Containers/Templates/index.js +17 -4
  33. package/v2Containers/Viber/index.js +15 -6
  34. package/v2Containers/Viber/tests/index.test.js +97 -28
  35. package/v2Containers/WebPush/Create/components/FormActions.js +1 -1
  36. package/v2Containers/WebPush/Create/components/FormActions.test.js +3 -3
  37. package/v2Containers/WebPush/Create/index.js +32 -6
  38. package/v2Containers/WebPush/Create/messages.js +0 -4
  39. package/v2Containers/WebPush/Create/tests/stayOpenOnSave.test.js +268 -0
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Edit } from '../index';
4
+
5
+ jest.mock('@capillarytech/cap-ui-library', () => ({
6
+ ...jest.requireActual('@capillarytech/cap-ui-library'),
7
+ CapNotification: { success: jest.fn(), error: jest.fn() },
8
+ }));
9
+
10
+ const baseProps = (overrides = {}) => ({
11
+ actions: {
12
+ getWeCrmAccounts: jest.fn(),
13
+ setTemplateDetails: jest.fn(),
14
+ getTemplateDetails: jest.fn(),
15
+ clearEditResponse: jest.fn(),
16
+ clearData: jest.fn(),
17
+ editTemplate: jest.fn(),
18
+ },
19
+ globalActions: { fetchSchemaForEntity: jest.fn(), addMessageToQueue: jest.fn() },
20
+ Templates: { selectedWeChatAccount: { id: 'acc-1' } },
21
+ Edit: {},
22
+ Create: {},
23
+ params: {},
24
+ templateData: {},
25
+ location: { query: { type: 'library', module: 'default' }, pathname: '/mobilepush/edit/' },
26
+ route: { name: 'mobilepush' },
27
+ router: { push: jest.fn() },
28
+ metaEntities: {},
29
+ intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
30
+ isFullMode: true,
31
+ getFormLibraryData: jest.fn(),
32
+ onValidationFail: jest.fn(),
33
+ ...overrides,
34
+ });
35
+
36
+ describe('MobilePush/Edit — full mode keeps the slidebox open after saving', () => {
37
+ it('keeps the form (and its _id) and asks the host to stay open', () => {
38
+ const props = baseProps();
39
+ const wrapper = shallow(<Edit {...props} />);
40
+ const instance = wrapper.instance();
41
+ instance.getTransformedData = () => ({ _id: 'mp-9', name: 'Push', definition: { mode: 'text' } });
42
+ const formData = { 0: { 'message-title': 'Hi' } };
43
+ wrapper.setState({ formData, editData: { _id: 'mp-9' } });
44
+
45
+ instance.onUpdateTemplateComplete({ templateId: 'mp-9' });
46
+
47
+ expect(wrapper.state('formData')).toEqual(formData);
48
+ expect(wrapper.state('editData')).toEqual({ _id: 'mp-9' });
49
+ expect(props.getFormLibraryData).toHaveBeenCalledWith({ validity: true, keepSlideBoxOpen: true });
50
+ });
51
+
52
+ it('resets the host Save trigger when the update fails so the user can retry', () => {
53
+ const props = baseProps({ Edit: { editTemplateError: false } });
54
+ const wrapper = shallow(<Edit {...props} />);
55
+
56
+ wrapper.setProps({ Edit: { editTemplateError: true, editTemplateErrorMessage: 'boom' } });
57
+
58
+ expect(props.onValidationFail).toHaveBeenCalled();
59
+ });
60
+ });
@@ -8,7 +8,9 @@ import {
8
8
  injectReducer,
9
9
  injectSaga,
10
10
  } from "@capillarytech/vulcan-react-sdk/utils";
11
- import { cloneDeep, get, isEmpty } from "lodash";
11
+ import {
12
+ cloneDeep, get, isEmpty, isPlainObject,
13
+ } from "lodash";
12
14
  import CapCheckbox from "@capillarytech/cap-ui-library/CapCheckbox";
13
15
  import CapInput from "@capillarytech/cap-ui-library/CapInput";
14
16
  import CapButton from "@capillarytech/cap-ui-library/CapButton";
@@ -541,6 +543,9 @@ export const MobilePushNew = ({
541
543
  const [showTestAndPreviewSlidebox, setShowTestAndPreviewSlidebox] = useState(false);
542
544
 
543
545
  const [iosContent, setIosContent] = useState(INITIAL_CONTENT);
546
+ // Full mode: id of the template created in this session — the form stays as is and later saves update it
547
+ const [savedTemplateId, setSavedTemplateId] = useState('');
548
+ const prevIsGetFormDataRef = useRef(false);
544
549
  // Refs to track object references for debugging
545
550
  const prevEditDataRef = useRef(null);
546
551
  const prevUploadedAssetDataRef = useRef(null);
@@ -2405,9 +2410,10 @@ export const MobilePushNew = ({
2405
2410
  intl,
2406
2411
  });
2407
2412
 
2408
- // Add template ID for edit mode
2409
- if ((isEditMode || computedCreativesMode === 'edit') && templateId) {
2410
- payload._id = templateId;
2413
+ // Update when editing, or when continuing a template created earlier in this session (full mode)
2414
+ const isUpdate = ((isEditMode || computedCreativesMode === 'edit') && !!templateId) || !!savedTemplateId;
2415
+ if (isUpdate) {
2416
+ payload._id = templateId || savedTemplateId;
2411
2417
  }
2412
2418
 
2413
2419
  const content = getContent(payload);
@@ -2427,7 +2433,7 @@ export const MobilePushNew = ({
2427
2433
  channel: MOBILE_PUSH_CHANNEL,
2428
2434
  timeTaken,
2429
2435
  content,
2430
- mode: (isEditMode || computedCreativesMode === 'edit') ? EDIT : CREATE,
2436
+ mode: isUpdate ? EDIT : CREATE,
2431
2437
  imageAdded: definitionMode === IMAGE.toLowerCase(),
2432
2438
  });
2433
2439
  // --- BEGIN: Library mode communication fix ---
@@ -2460,6 +2466,29 @@ export const MobilePushNew = ({
2460
2466
  }
2461
2467
  // --- END: Library mode communication fix ---
2462
2468
 
2469
+ // Full mode stays open; later saves update the template (host handles edit toast/refresh).
2470
+ if (isFullMode) {
2471
+ if (!isUpdate && response && (response.templateId || response._id)) {
2472
+ const message = `${intl.formatMessage(messages.templateCreateSuccess)}`;
2473
+ CapNotification.success({ key: 'createSuccess', message });
2474
+ const savedResponse = response.templateId || response._id;
2475
+ const newTemplateId = (isPlainObject(savedResponse) ? savedResponse._id : savedResponse) || '';
2476
+ setSavedTemplateId(newTemplateId);
2477
+ setTimeout(() => {
2478
+ mobilePushActions.clearCreateResponse();
2479
+ }, 100);
2480
+ if (getFormLibraryData) {
2481
+ getFormLibraryData({ _id: newTemplateId, validity: true, keepSlideBoxOpen: true });
2482
+ }
2483
+ } else if (isUpdate) {
2484
+ // Let the host's edit-response handler read the response before it is cleared
2485
+ setTimeout(() => {
2486
+ mobilePushActions.clearEditResponse();
2487
+ }, 100);
2488
+ }
2489
+ return;
2490
+ }
2491
+
2463
2492
  // Show success toast and refresh list after create (not edit)
2464
2493
  if ((!isEditMode || computedCreativesMode !== 'edit') && response && (response.templateId || response._id)) {
2465
2494
  const message = `${intl.formatMessage(messages.templateCreateSuccess)}`;
@@ -2505,7 +2534,7 @@ export const MobilePushNew = ({
2505
2534
 
2506
2535
  // --- Only call create/edit API in full mode ---
2507
2536
  if (isFullMode) {
2508
- if (isEditMode || computedCreativesMode === 'edit') {
2537
+ if (isUpdate) {
2509
2538
  mobilePushActions.editTemplate(payload, (response) => {
2510
2539
  // Guard: If response is an Error object, show error notification and return
2511
2540
  if (response instanceof Error) {
@@ -2579,6 +2608,8 @@ export const MobilePushNew = ({
2579
2608
  isCarouselDataValid,
2580
2609
  isFullMode,
2581
2610
  templateId,
2611
+ savedTemplateId,
2612
+ computedCreativesMode,
2582
2613
  ]);
2583
2614
 
2584
2615
  // Helper to sync content between platforms
@@ -2931,7 +2962,10 @@ export const MobilePushNew = ({
2931
2962
  // In library mode: run liquid validation (extractTags) first; on success liquidMiddleWare calls handleSave
2932
2963
  // In full mode: call handleSave directly
2933
2964
  useEffect(() => {
2934
- if (isGetFormData) {
2965
+ // Save once per Save click (false → true), not on every dependency change.
2966
+ const isNewSaveClick = isGetFormData && !prevIsGetFormDataRef.current;
2967
+ prevIsGetFormDataRef.current = isGetFormData;
2968
+ if (isNewSaveClick) {
2935
2969
  if (!isFullMode) {
2936
2970
  liquidMiddleWare();
2937
2971
  } else {
@@ -117,3 +117,64 @@ describe('MobilePushNew', () => {
117
117
  await screen.findByPlaceholderText('Enter title');
118
118
  });
119
119
  });
120
+
121
+ describe('MobilePushNew — full mode keeps the slidebox open after saving', () => {
122
+ const buildProps = (overrides = {}) => ({
123
+ accountData: {
124
+ ...accountData, accountId: 'acc-1', licenseCode: 'lic-1', sourceType: 'FCM',
125
+ },
126
+ templateData: {},
127
+ location: {},
128
+ injectedTags: {},
129
+ getDefaultTags: jest.fn(),
130
+ globalActions: { ...globalActions, addMessageToQueue: jest.fn() },
131
+ isFullMode: true,
132
+ isGetFormData: false,
133
+ getFormLibraryData: jest.fn(),
134
+ onValidationFail: jest.fn(),
135
+ onCreateComplete: jest.fn(),
136
+ handleClose: jest.fn(),
137
+ mobilePushActions: {
138
+ createTemplate: jest.fn((payload, callback) => callback({ templateId: 'mp-1' })),
139
+ editTemplate: jest.fn((payload, callback) => callback({ templateId: { _id: 'mp-1' } })),
140
+ clearCreateResponse: jest.fn(),
141
+ clearEditResponse: jest.fn(),
142
+ getWeCrmAccounts: jest.fn(),
143
+ getTemplateDetails: jest.fn(),
144
+ setTemplateDetails: jest.fn(),
145
+ uploadAsset: jest.fn(),
146
+ clearAsset: jest.fn(),
147
+ clearData: jest.fn(),
148
+ getIosCtas: jest.fn(),
149
+ },
150
+ ...overrides,
151
+ });
152
+ const ComponentWithStore = (props) => (
153
+ <Provider store={store}>
154
+ <ComponentToRender {...props} />
155
+ </Provider>
156
+ );
157
+
158
+ it('creates on the first save, stays open, and updates the same template on the next save', async () => {
159
+ const props = buildProps();
160
+ const { rerender } = render(<ComponentWithStore {...props} />);
161
+
162
+ fireEvent.change(await screen.findByPlaceholderText('Enter creative name'), { target: { value: 'Promo push' } });
163
+ fireEvent.change(await screen.findByPlaceholderText('Enter title'), { target: { value: 'Hello' } });
164
+ fireEvent.change(await screen.findByPlaceholderText('Enter message'), { target: { value: 'World' } });
165
+
166
+ rerender(<ComponentWithStore {...props} isGetFormData />); // Save click 1
167
+ expect(props.mobilePushActions.createTemplate).toHaveBeenCalledTimes(1);
168
+ expect(props.mobilePushActions.createTemplate.mock.calls[0][0]._id).toBeUndefined();
169
+ expect(props.getFormLibraryData).toHaveBeenCalledWith({ _id: 'mp-1', validity: true, keepSlideBoxOpen: true });
170
+ expect(props.handleClose).not.toHaveBeenCalled();
171
+ expect(props.onCreateComplete).not.toHaveBeenCalled();
172
+ expect(screen.getByPlaceholderText('Enter title')).toHaveValue('Hello'); // form kept as is
173
+
174
+ rerender(<ComponentWithStore {...props} isGetFormData={false} />);
175
+ rerender(<ComponentWithStore {...props} isGetFormData />); // Save click 2
176
+ expect(props.mobilePushActions.editTemplate).toHaveBeenCalledTimes(1);
177
+ expect(props.mobilePushActions.editTemplate.mock.calls[0][0]._id).toBe('mp-1');
178
+ expect(props.handleClose).not.toHaveBeenCalled();
179
+ });
180
+ });
@@ -18,6 +18,7 @@ import './_smsCreate.scss';
18
18
  import FormBuilder from '../../../v2Components/FormBuilder';
19
19
  import * as actions from './actions';
20
20
  import messages from './messages';
21
+ import editMessages from '../Edit/messages';
21
22
  import * as charCount from '../../../utils/smsCharCount';
22
23
  import {checkUnicode, updateCharCount} from '../../../utils/smsCharCountV2';
23
24
  import {getMessageObject} from '../../../utils/messageUtils';
@@ -29,7 +30,7 @@ import { EMBEDDED, FULL } from '../../SmsTrai/Edit/constants';
29
30
  import {showError} from '../commonMethods';
30
31
  import withCreatives from '../../../hoc/withCreatives';
31
32
  import { GA } from '@capillarytech/cap-ui-utils';
32
- import { CREATE, TRACK_CREATE_SMS } from '../../App/constants';
33
+ import { CREATE, EDIT, TRACK_CREATE_SMS } from '../../App/constants';
33
34
  import injectReducer from '../../../utils/injectReducer';
34
35
  import v2SmsCreateReducer from './reducer';
35
36
  import * as globalActions from '../../Cap/actions';
@@ -57,6 +58,8 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
57
58
  showTestAndPreviewSlidebox: false,
58
59
  isTestAndPreviewMode: false,
59
60
  pendingGetFormData: false,
61
+ // Full mode: id of the template created in this session — the form stays as is and later saves update it
62
+ savedTemplateId: '',
60
63
  };
61
64
  // Tracks the last validity value reported to SmsFallback so componentDidUpdate
62
65
  // does not dispatch on every render and create an infinite update loop.
@@ -263,30 +266,41 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
263
266
  });
264
267
  const {
265
268
  formData,
269
+ savedTemplateId,
266
270
  } = this.state;
267
- if (editResponse.templateId) {
271
+ const isUpdate = !!savedTemplateId; // a later save of the template created in this session
272
+ if (editResponse?.templateId) {
273
+ const savedResponse = editResponse.templateId; // the template or just its id
274
+ const templateId = (_.isPlainObject(savedResponse) ? savedResponse._id : savedResponse) || savedTemplateId;
268
275
  gtmPush('creativeDetails', {
269
- id: editResponse.templateId,
276
+ id: templateId,
270
277
  name: formData?.['template-name'],
271
278
  channel: SMS,
272
279
  timeTaken,
273
280
  content: formData?.base?.['sms-editor'],
274
- mode: CREATE,
281
+ mode: isUpdate ? EDIT : CREATE,
275
282
  });
276
- this.resetSchema();
277
- this.setState({
278
- formData: {},
279
- tabCount: 1,
280
- currentTab: 1,
281
- editData: {},
282
- });
283
- const message = this.props.intl.formatMessage(messages.smsTemplateCreatedSuccess);
283
+ const message = this.props.intl.formatMessage(isUpdate ? editMessages.templateEditedSuccessfully : messages.smsTemplateCreatedSuccess);
284
284
  CapNotification.success({key: 'createMessageSuccess', message});
285
285
  const module = this.props.location.query.module ? this.props.location.query.module : 'default';
286
286
  const type = this.props.location.query.type;
287
287
  if (this.props.isFullMode) {
288
- this.props.getFormSubscriptionData({ value: editResponse.templateId, validity: true});
288
+ // Stay open: later saves update this template; first save switches the header.
289
+ this.setState({ savedTemplateId: templateId });
290
+ this.props.getFormSubscriptionData({
291
+ _id: templateId,
292
+ validity: true,
293
+ keepSlideBoxOpen: true,
294
+ ...(isUpdate ? {} : { continuedAsEdit: true }),
295
+ });
289
296
  } else {
297
+ this.resetSchema();
298
+ this.setState({
299
+ formData: {},
300
+ tabCount: 1,
301
+ currentTab: 1,
302
+ editData: {},
303
+ });
290
304
  this.props.router.push({
291
305
  pathname: `/sms/`,
292
306
  query: type === EMBEDDED ? {type: EMBEDDED, module} : {module},
@@ -995,7 +1009,8 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
995
1009
  }
996
1010
 
997
1011
  validateAndSaveFormData = () => {
998
- this.setState({startValidation: true});
1012
+ // Re-arm per Save click (saves only on startValidation false → true).
1013
+ this.setState({startValidation: false}, () => this.setState({startValidation: true}));
999
1014
  }
1000
1015
  stopValidation = () => {
1001
1016
  this.setState({startValidation: false});
@@ -1083,6 +1098,9 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
1083
1098
  }
1084
1099
  obj.name = formData['template-name'];
1085
1100
  obj.versions.base = formData.base;
1101
+ if (this.state.savedTemplateId) {
1102
+ obj._id = this.state.savedTemplateId; // created earlier in this session: update it
1103
+ }
1086
1104
 
1087
1105
  this.props.actions.createTemplate(obj, this.onCreateTemplateComplete);
1088
1106
  }
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Create } from '../index';
4
+
5
+ jest.mock('@capillarytech/cap-ui-library', () => ({
6
+ ...jest.requireActual('@capillarytech/cap-ui-library'),
7
+ CapNotification: { success: jest.fn(), error: jest.fn() },
8
+ }));
9
+
10
+ // Shallow-render the plain class (bypassing the withCreatives/Redux HOC) to exercise the
11
+ // full-mode save flow: the form stays open and a create continues in place as an update.
12
+ const baseProps = (overrides = {}) => ({
13
+ actions: { createTemplate: jest.fn() },
14
+ globalActions: { fetchSchemaForEntity: jest.fn(), addMessageToQueue: jest.fn() },
15
+ Create: {},
16
+ location: { query: { module: 'default' }, pathname: '/sms/create' },
17
+ route: { name: 'sms' },
18
+ router: { push: jest.fn() },
19
+ metaEntities: {},
20
+ intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
21
+ isFullMode: true,
22
+ getFormSubscriptionData: jest.fn(),
23
+ ...overrides,
24
+ });
25
+
26
+ const formData = {
27
+ 0: { 'sms-editor': 'Hello {{optout}}', base: true },
28
+ base: { 'sms-editor': 'Hello {{optout}}', base: true },
29
+ 'template-name': 'Promo',
30
+ };
31
+
32
+ describe('Sms/Create — full mode keeps the slidebox open after saving', () => {
33
+ it('create: keeps the form, remembers the id, and asks the host to stay open + switch the header', () => {
34
+ const props = baseProps();
35
+ const wrapper = shallow(<Create {...props} />);
36
+ wrapper.setState({ formData, tabCount: 1 });
37
+ const instance = wrapper.instance();
38
+
39
+ instance.saveFormData();
40
+ expect(props.actions.createTemplate.mock.calls[0][0]._id).toBeUndefined();
41
+ instance.onCreateTemplateComplete({ templateId: 'sms-1' });
42
+
43
+ expect(wrapper.state('savedTemplateId')).toBe('sms-1');
44
+ expect(wrapper.state('formData')).toEqual(formData);
45
+ expect(props.getFormSubscriptionData).toHaveBeenCalledWith({
46
+ _id: 'sms-1', validity: true, keepSlideBoxOpen: true, continuedAsEdit: true,
47
+ });
48
+ expect(props.router.push).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('the next save updates the created template (and does not re-send the header switch)', () => {
52
+ const props = baseProps();
53
+ const wrapper = shallow(<Create {...props} />);
54
+ wrapper.setState({ formData, tabCount: 1, savedTemplateId: 'sms-1' });
55
+ const instance = wrapper.instance();
56
+
57
+ instance.saveFormData();
58
+ expect(props.actions.createTemplate.mock.calls[0][0]._id).toBe('sms-1');
59
+ instance.onCreateTemplateComplete({ templateId: { _id: 'sms-1' } });
60
+
61
+ expect(props.getFormSubscriptionData).toHaveBeenCalledWith({ _id: 'sms-1', validity: true, keepSlideBoxOpen: true });
62
+ });
63
+
64
+ it('re-arms validation on every Save click (false, then true)', () => {
65
+ const wrapper = shallow(<Create {...baseProps()} />);
66
+ wrapper.setState({ startValidation: true }); // left set by the previous save
67
+ const instance = wrapper.instance();
68
+ const updates = [];
69
+ const originalSetState = instance.setState.bind(instance);
70
+ instance.setState = (update, callback) => {
71
+ if (update && typeof update === 'object' && 'startValidation' in update) updates.push(update.startValidation);
72
+ return originalSetState(update, callback);
73
+ };
74
+
75
+ instance.validateAndSaveFormData();
76
+
77
+ expect(updates).toEqual([false, true]);
78
+ expect(wrapper.state('startValidation')).toBe(true);
79
+ });
80
+
81
+ it('outside full mode keeps the old behaviour (form reset and navigation)', () => {
82
+ const props = baseProps({ isFullMode: false, metaEntities: { layouts: [{ definition: {} }] } });
83
+ const wrapper = shallow(<Create {...props} />);
84
+ wrapper.setState({ formData, tabCount: 1 });
85
+
86
+ wrapper.instance().onCreateTemplateComplete({ templateId: 'sms-1' });
87
+
88
+ expect(wrapper.state('formData')).toEqual({});
89
+ expect(props.router.push).toHaveBeenCalled();
90
+ expect(props.getFormSubscriptionData).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -151,8 +151,10 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
151
151
  this.setEditState(nextProps.templateDetails);
152
152
  }
153
153
  if (nextProps.metaEntities && nextProps.metaEntities.layouts && nextProps.metaEntities.layouts.length > 0 && _.isEmpty(this.state.schema)) {
154
- this.setState({schema: nextProps.metaEntities.layouts[0].definition, loadingStatus: this.state.loadingStatus + 1}, () => {
155
- this.injectEvents(nextProps.metaEntities.layouts[0].definition);
154
+ // Copy the shared SMS layout (also used by Sms/Create) so Edit's handlers don't leak into it.
155
+ const schema = _.cloneDeep(nextProps.metaEntities.layouts[0].definition);
156
+ this.setState({schema, loadingStatus: this.state.loadingStatus + 1}, () => {
157
+ this.injectEvents(schema);
156
158
  });
157
159
  if (this.props.location.query.module !== 'library' || (this.props.location.query.module === 'library' && this.props.getDefaultTags)) {
158
160
  const query = {
@@ -170,7 +172,8 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
170
172
  }
171
173
  }
172
174
  }
173
- if (nextProps.Edit.editResponse && nextProps.Edit.editResponse.templateId) {
175
+ // Full mode stays in edit mode after saving — handled in onUpdateTemplateComplete
176
+ if (!this.props.isFullMode && nextProps.Edit.editResponse && nextProps.Edit.editResponse.templateId) {
174
177
  this.resetSchema();
175
178
  this.setState({
176
179
  formData: {},
@@ -269,7 +272,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
269
272
  const {
270
273
  formData
271
274
  } = this.state;
272
- if (editResponse.templateId) {
275
+ if (editResponse?.templateId) {
273
276
  gtmPush('creativeDetails', {
274
277
  id: editResponse.templateId,
275
278
  name: formData?.['template-name'],
@@ -278,22 +281,23 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
278
281
  content: formData?.base?.['sms-editor'],
279
282
  mode: EDIT
280
283
  })
281
- this.resetSchema();
282
- this.setState({
283
- formData: {},
284
- tabCount: 1,
285
- currentTab: 1,
286
- editData: {},
287
- });
288
284
  const message = this.props.intl.formatMessage(messages.templateEditedSuccessfully);
289
285
  CapNotification.success({key: 'editTemplateSuccess', message});
290
286
  this.props.actions.clearEditResponse();
291
287
  const module = this.props.location.query.module ? this.props.location.query.module : 'default';
292
288
  const type = this.props.location.query.type;
293
289
  if (this.props.isFullMode) {
294
- const {versions, ...rest} = editResponse.templateId;
295
- this.props.getFormSubscriptionData({ value: versions, ...rest, validity: true});
290
+ // Stay in edit mode with the saved content; the slidebox is closed only via its close icon
291
+ const {versions, ...rest} = _.isPlainObject(editResponse.templateId) ? editResponse.templateId : {};
292
+ this.props.getFormSubscriptionData({ value: versions, ...rest, validity: true, keepSlideBoxOpen: true });
296
293
  } else {
294
+ this.resetSchema();
295
+ this.setState({
296
+ formData: {},
297
+ tabCount: 1,
298
+ currentTab: 1,
299
+ editData: {},
300
+ });
297
301
  this.props.router.push({
298
302
  pathname: `/sms/`,
299
303
  query: type === 'embedded' ? {type: 'embedded', module} : {module},
@@ -539,7 +543,8 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
539
543
  }
540
544
 
541
545
  resetSchema() {
542
- this.setState({ schema: this.props.metaEntities.layouts[0].definition });
546
+ // Back to Edit's own injected layout (a fresh copy, since versions mutate the active schema)
547
+ this.setState({ schema: _.cloneDeep(this.injectedSchema || this.props.metaEntities?.layouts?.[0]?.definition) });
543
548
  }
544
549
 
545
550
  validateFormData(formData) {
@@ -569,6 +574,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
569
574
  tempContainer = this.injectContainer(tempContainer);
570
575
  return tempContainer;
571
576
  });
577
+ this.injectedSchema = _.cloneDeep(schema); // pristine injected layout for resetSchema
572
578
  this.setState({schema, loadingStatus: this.state.loadingStatus + 1});
573
579
  return schema;
574
580
  }
@@ -982,6 +988,8 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
982
988
  //Logic to save in db etc
983
989
  //saveFormData gets called only when validation result is true
984
990
 
991
+ // Reset so the next Save click re-triggers FormBuilder (it only reacts to false → true).
992
+ this.stopValidation();
985
993
  const editData = _.cloneDeep(this.state.editData);
986
994
  const formData = _.cloneDeep(this.state.formData);
987
995
  editData.name = formData['template-name'];
@@ -83,3 +83,69 @@ describe('Sms/Edit — onEmbeddedSmsFooterValidity reporting', () => {
83
83
  expect(onEmbeddedSmsFooterValidity).not.toHaveBeenCalled();
84
84
  });
85
85
  });
86
+
87
+ describe('Sms/Edit — full mode saves repeatedly while the slidebox stays open', () => {
88
+ const fullModeProps = () => ({
89
+ ...baseProps(),
90
+ isFullMode: true,
91
+ location: { query: { module: 'default' } },
92
+ });
93
+ const savedState = {
94
+ editData: { _id: 'tpl-1', name: 'old', versions: { base: {}, history: [{}] } },
95
+ formData: { 'template-name': 'new', 0: { 'sms-editor': 'Hi' }, base: { 'sms-editor': 'Hi' } },
96
+ };
97
+
98
+ it('resets startValidation on submit so the next Save click triggers validation + API again', () => {
99
+ const props = fullModeProps();
100
+ const wrapper = shallow(<Edit {...props} />);
101
+ wrapper.setState(savedState);
102
+
103
+ wrapper.instance().validateAndSaveFormData();
104
+ expect(wrapper.state('startValidation')).toBe(true);
105
+ wrapper.instance().saveFormData(); // FormBuilder's onSubmit after validation passes
106
+ expect(props.actions.editTemplate).toHaveBeenCalledTimes(1);
107
+ expect(wrapper.state('startValidation')).toBe(false);
108
+
109
+ // Second Save: a fresh false → true edge for FormBuilder
110
+ wrapper.instance().validateAndSaveFormData();
111
+ expect(wrapper.state('startValidation')).toBe(true);
112
+ wrapper.instance().saveFormData();
113
+ expect(props.actions.editTemplate).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it('keeps the form and asks the host to keep the slidebox open after a successful save', () => {
117
+ const props = fullModeProps();
118
+ const wrapper = shallow(<Edit {...props} />);
119
+ wrapper.setState(savedState);
120
+
121
+ wrapper.instance().onUpdateTemplateComplete({ templateId: { _id: 'tpl-1', versions: { base: {} } } });
122
+
123
+ expect(wrapper.state('formData')).toEqual(savedState.formData);
124
+ expect(props.getFormSubscriptionData).toHaveBeenCalledWith(
125
+ expect.objectContaining({ _id: 'tpl-1', validity: true, keepSlideBoxOpen: true }),
126
+ );
127
+ });
128
+ });
129
+
130
+ describe('Sms/Edit — does not mutate the shared SMS layout', () => {
131
+ it('injects into its own copy so Edit handlers never leak into Create', () => {
132
+ const sharedLayout = {
133
+ standalone: {
134
+ sections: [{
135
+ type: 'multicols',
136
+ inputFields: [{ cols: [{ id: 'save-button', type: 'button', value: 'Save' }] }],
137
+ }],
138
+ },
139
+ containers: [],
140
+ };
141
+ const props = { ...baseProps(), isFullMode: true, location: { query: { module: 'default' } } };
142
+ const wrapper = shallow(<Edit {...props} />);
143
+
144
+ wrapper.setProps({ metaEntities: { layouts: [{ definition: sharedLayout }] } });
145
+
146
+ const ownSaveButton = wrapper.state('schema').standalone.sections[0].inputFields[0].cols[0];
147
+ expect(ownSaveButton.value).toBe('Save');
148
+ expect(ownSaveButton.injectedEvents).toBeDefined();
149
+ expect(sharedLayout.standalone.sections[0].inputFields[0].cols[0].injectedEvents).toBeUndefined();
150
+ });
151
+ });
@@ -726,9 +726,12 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
726
726
  });
727
727
 
728
728
  this.getAllTemplates({params, resetPage: true});
729
-
730
- this.props.mobilePushNewActions.clearData();
731
-
729
+
730
+ // Open form still uses this data; cleared on close (onCreateComplete).
731
+ if (!this.state.routeParams) {
732
+ this.props.mobilePushNewActions.clearData();
733
+ }
734
+
732
735
  this.props.mobilePushNewActions.clearEditResponse();
733
736
 
734
737
  // Reset flag after a delay to allow for next edit
@@ -1728,6 +1731,10 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1728
1731
  }
1729
1732
 
1730
1733
  onCreateComplete = (isReloadTemplates) => {
1734
+ // Push edit data kept for the open slidebox (see the MobilePushNew edit-response handling) is cleared on close
1735
+ if (this.state.channel?.toLowerCase() === MOBILE_PUSH_LOWERCASE && this.props.mobilePushNewActions?.clearData) {
1736
+ this.props.mobilePushNewActions.clearData();
1737
+ }
1731
1738
  this.setState({routeParams: null, previewOpen: false}, () => {
1732
1739
  if (this.isFullMode() && isReloadTemplates) {
1733
1740
  this.getAllTemplates({params: {}}, true);
@@ -5205,9 +5212,15 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
5205
5212
  messages={messages}
5206
5213
  isFullMode={isFullMode}
5207
5214
  templateData={creativesParams}
5208
- getCreativesData={() => {
5215
+ getCreativesData={(creativesData) => {
5216
+ // Saved but slidebox stays open: refresh the list without closing.
5217
+ if (creativesData?.keepSlideBoxOpen) {
5218
+ this.getAllTemplates({params: {}}, true);
5219
+ return;
5220
+ }
5209
5221
  this.onCreateComplete(true);
5210
5222
  }}
5223
+ switchToEditAfterCreate={this.canPerform(PERMISSIONS.CREATIVE_EDIT)}
5211
5224
  channel={route.name.toUpperCase()}
5212
5225
  handleCloseCreatives={this.onCreateComplete}
5213
5226
  selectedWeChatAccount={get(