@capillarytech/creatives-library 9.0.56-alpha.6 → 9.0.56-alpha.8

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 (55) hide show
  1. package/package.json +1 -1
  2. package/services/api.js +1 -1
  3. package/utils/templateVarUtils.js +1 -17
  4. package/v2Components/CapActionButton/index.js +73 -60
  5. package/v2Components/CapActionButton/index.scss +44 -28
  6. package/v2Components/CapActionButton/messages.js +7 -3
  7. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  8. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  9. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  10. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  11. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -2
  12. package/v2Components/CommonTestAndPreview/index.js +3 -6
  13. package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -0
  14. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -14
  15. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -2
  16. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +35 -3
  17. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
  18. package/v2Containers/CommunicationFlow/constants.js +1 -3
  19. package/v2Containers/CommunicationFlow/messages.js +4 -0
  20. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +5 -1
  21. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +64 -0
  22. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
  23. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
  24. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  25. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
  26. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +3 -3
  27. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  28. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  29. package/v2Containers/CreativesContainer/index.js +27 -0
  30. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  31. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
  32. package/v2Containers/MobilePush/Create/index.js +18 -1
  33. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  34. package/v2Containers/MobilePush/Edit/index.js +18 -1
  35. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  36. package/v2Containers/MobilePush/commonMethods.js +49 -1
  37. package/v2Containers/MobilePushNew/index.js +29 -4
  38. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  39. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  40. package/v2Containers/MobilePushNew/utils.js +34 -1
  41. package/v2Containers/MobilepushWrapper/index.js +3 -1
  42. package/v2Containers/Rcs/index.js +34 -0
  43. package/v2Containers/Rcs/index.scss +15 -0
  44. package/v2Containers/Rcs/tests/index.test.js +67 -0
  45. package/v2Containers/Sms/Create/index.js +3 -1
  46. package/v2Containers/Sms/Edit/index.js +25 -0
  47. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  48. package/v2Containers/Viber/index.js +24 -1
  49. package/v2Containers/Viber/tests/index.test.js +103 -0
  50. package/v2Containers/WebPush/Create/index.js +24 -0
  51. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  52. package/v2Containers/Whatsapp/index.js +20 -4
  53. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  54. package/v2Containers/Zalo/index.js +28 -1
  55. package/v2Containers/Zalo/tests/index.test.js +99 -0
@@ -197,6 +197,9 @@ export class Creatives extends React.Component {
197
197
  errorsAcknowledged: false, // Flag to track if user has acknowledged errors by clicking redirection icon
198
198
  },
199
199
  hasPersonalizationTokenError: false, // Track personalization token errors in form
200
+ // Live content-validity signal for channels that report it (currently SMS create) —
201
+ // false by default so unwired channels are never blocked by this flag.
202
+ isContentInvalid: false,
200
203
  };
201
204
  this.creativesTemplateSteps = {
202
205
  1: 'modeSelection',
@@ -1893,6 +1896,26 @@ export class Creatives extends React.Component {
1893
1896
  this.setState({ isDiscardMessage: true });
1894
1897
  }
1895
1898
 
1899
+ // Reported live by SMS's own content editor (Sms/Create and Sms/Edit's componentDidUpdate)
1900
+ // so the Done/Save button reflects an empty/invalid form immediately, instead of only
1901
+ // failing silently on click.
1902
+ handleEmbeddedSmsFooterValidity = (validity) => {
1903
+ const isContentInvalid = !!validity?.isMessageEmpty;
1904
+ if (this.state.isContentInvalid !== isContentInvalid) {
1905
+ this.setState({ isContentInvalid });
1906
+ }
1907
+ }
1908
+
1909
+ // Generic counterpart of the above for every other channel wrapper (Email, MobilePush,
1910
+ // Zalo, WebPush, RCS, WhatsApp, Viber) — each reports { isContentEmpty } live from its own
1911
+ // form state so the Done/Preview-and-test buttons disable immediately, not just on click.
1912
+ handleContentValidityChange = (validity) => {
1913
+ const isContentInvalid = !!validity?.isContentEmpty;
1914
+ if (this.state.isContentInvalid !== isContentInvalid) {
1915
+ this.setState({ isContentInvalid });
1916
+ }
1917
+ }
1918
+
1896
1919
 
1897
1920
  // NEW: Handler for Test and Preview button
1898
1921
  handleTestAndPreview = () => {
@@ -2213,6 +2236,7 @@ export class Creatives extends React.Component {
2213
2236
  isTestAndPreviewMode,
2214
2237
  inAppEditorType,
2215
2238
  htmlEditorValidationState,
2239
+ isContentInvalid,
2216
2240
  } = this.state;
2217
2241
  const useLocalTemplates = get(
2218
2242
  this.props,
@@ -2410,6 +2434,8 @@ export class Creatives extends React.Component {
2410
2434
  handleCloseTestAndPreview={this.handleCloseTestAndPreview}
2411
2435
  isTestAndPreviewMode={(() => this.state.isTestAndPreviewMode)()}
2412
2436
  onHtmlEditorValidationStateChange={this.updateHtmlEditorValidationState}
2437
+ onEmbeddedSmsFooterValidity={this.handleEmbeddedSmsFooterValidity}
2438
+ onContentValidityChange={this.handleContentValidityChange}
2413
2439
  onPersonalizationTokensChange={this.handlePersonalizationTokensChange}
2414
2440
  localTemplatesConfig={pick(this.props.localTemplatesConfig || this.props, constants.LOCAL_TEMPLATE_CONFIG_KEYS)}
2415
2441
  />
@@ -2432,6 +2458,7 @@ export class Creatives extends React.Component {
2432
2458
  shouldShowDoneFooter={this.shouldShowDoneFooter}
2433
2459
  fetchingCmsData={fetchingCmsData}
2434
2460
  isTemplateNameEmpty={isTemplateNameEmpty}
2461
+ isContentInvalid={isContentInvalid}
2435
2462
  isLiquidValidationError={isLiquidValidationError}
2436
2463
  errorMessages={liquidErrorMessage}
2437
2464
  currentTab={activeFormBuilderTab}
@@ -658,4 +658,33 @@ describe('SlideBoxFooter — isBEEEditor detection in create mode via emailCreat
658
658
  expect(screen.getByRole('button', { name: /done/i })).toBeInTheDocument();
659
659
  });
660
660
  });
661
+ });
662
+
663
+ describe('SlideBoxFooter — isContentInvalid (live content-validity signal, e.g. empty SMS body)', () => {
664
+ it('disables Save/Done when isContentInvalid is true, independent of currentChannel/htmlEditorValidationState', () => {
665
+ renderComponent({
666
+ ...baseFooterProps,
667
+ currentChannel: 'SMS',
668
+ isContentInvalid: true,
669
+ });
670
+ expect(screen.getByRole('button', { name: /update/i })).toBeDisabled();
671
+ });
672
+
673
+ it('disables the Test & Preview button when isContentInvalid is true', () => {
674
+ renderComponent({
675
+ ...baseFooterProps,
676
+ currentChannel: 'SMS',
677
+ isContentInvalid: true,
678
+ showTestAndPreviewButton: true,
679
+ });
680
+ expect(screen.getByRole('button', { name: /preview.*test/i })).toBeDisabled();
681
+ });
682
+
683
+ it('leaves Save/Done enabled when isContentInvalid is false (default)', () => {
684
+ renderComponent({
685
+ ...baseFooterProps,
686
+ currentChannel: 'SMS',
687
+ });
688
+ expect(screen.getByRole('button', { name: /update/i })).not.toBeDisabled();
689
+ });
661
690
  });
@@ -28,10 +28,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click rcs
28
28
  }
29
29
  onCallTaskSubmit={[Function]}
30
30
  onChannelChange={[Function]}
31
+ onContentValidityChange={[Function]}
31
32
  onCreateComplete={[MockFunction]}
32
33
  onCreateNew={[Function]}
33
34
  onCreateNextStep={[Function]}
34
35
  onEmailModeChange={[Function]}
36
+ onEmbeddedSmsFooterValidity={[Function]}
35
37
  onEnterTemplateName={[Function]}
36
38
  onFTPSubmit={[MockFunction]}
37
39
  onFacebookSubmit={[MockFunction]}
@@ -122,10 +124,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click wha
122
124
  }
123
125
  onCallTaskSubmit={[Function]}
124
126
  onChannelChange={[Function]}
127
+ onContentValidityChange={[Function]}
125
128
  onCreateComplete={[MockFunction]}
126
129
  onCreateNew={[Function]}
127
130
  onCreateNextStep={[Function]}
128
131
  onEmailModeChange={[Function]}
132
+ onEmbeddedSmsFooterValidity={[Function]}
129
133
  onEnterTemplateName={[Function]}
130
134
  onFTPSubmit={[MockFunction]}
131
135
  onFacebookSubmit={[MockFunction]}
@@ -216,10 +220,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
216
220
  }
217
221
  onCallTaskSubmit={[Function]}
218
222
  onChannelChange={[Function]}
223
+ onContentValidityChange={[Function]}
219
224
  onCreateComplete={[MockFunction]}
220
225
  onCreateNew={[Function]}
221
226
  onCreateNextStep={[Function]}
222
227
  onEmailModeChange={[Function]}
228
+ onEmbeddedSmsFooterValidity={[Function]}
223
229
  onEnterTemplateName={[Function]}
224
230
  onFTPSubmit={[MockFunction]}
225
231
  onFacebookSubmit={[MockFunction]}
@@ -281,6 +287,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
281
287
  }
282
288
  }
283
289
  isAnonymousType={false}
290
+ isContentInvalid={false}
284
291
  isContinueButtonDisabled={false}
285
292
  isCreatingTemplate={false}
286
293
  isEmptyContent={false}
@@ -353,10 +360,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
353
360
  }
354
361
  onCallTaskSubmit={[Function]}
355
362
  onChannelChange={[Function]}
363
+ onContentValidityChange={[Function]}
356
364
  onCreateComplete={[MockFunction]}
357
365
  onCreateNew={[Function]}
358
366
  onCreateNextStep={[Function]}
359
367
  onEmailModeChange={[Function]}
368
+ onEmbeddedSmsFooterValidity={[Function]}
360
369
  onEnterTemplateName={[Function]}
361
370
  onFTPSubmit={[MockFunction]}
362
371
  onFacebookSubmit={[MockFunction]}
@@ -418,6 +427,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
418
427
  }
419
428
  }
420
429
  isAnonymousType={false}
430
+ isContentInvalid={false}
421
431
  isContinueButtonDisabled={false}
422
432
  isCreatingTemplate={false}
423
433
  isEmptyContent={false}
@@ -490,10 +500,12 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
490
500
  }
491
501
  onCallTaskSubmit={[Function]}
492
502
  onChannelChange={[Function]}
503
+ onContentValidityChange={[Function]}
493
504
  onCreateComplete={[MockFunction]}
494
505
  onCreateNew={[Function]}
495
506
  onCreateNextStep={[Function]}
496
507
  onEmailModeChange={[Function]}
508
+ onEmbeddedSmsFooterValidity={[Function]}
497
509
  onEnterTemplateName={[Function]}
498
510
  onFTPSubmit={[MockFunction]}
499
511
  onFacebookSubmit={[MockFunction]}
@@ -555,6 +567,7 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
555
567
  }
556
568
  }
557
569
  isAnonymousType={false}
570
+ isContentInvalid={false}
558
571
  isContinueButtonDisabled={false}
559
572
  isCreatingTemplate={false}
560
573
  isEmptyContent={false}
@@ -30,7 +30,7 @@ import getEventsMap from '../eventsMap';
30
30
  import { GA } from '@capillarytech/cap-ui-utils';
31
31
  import { CREATE, TRACK_CREATE_MPUSH } from '../../App/constants';
32
32
  import { MOBILE_PUSH } from '../../CreativesContainer/constants';
33
- import { getContent } from '../commonMethods';
33
+ import { getContent, getMobilePushEmbeddedContentValidity } from '../commonMethods';
34
34
  import { getCdnUrl } from '../../../utils/cdnTransformation';
35
35
  import injectReducer from '../../../utils/injectReducer';
36
36
  import injectSaga from '../../../utils/injectSaga';
@@ -67,6 +67,22 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
67
67
  }
68
68
  this.hasFetchedInitialTagsRef = false;
69
69
  this.lastFetchedTagContextRef = null;
70
+ // Tracks the last content-emptiness value reported to the parent so componentDidUpdate
71
+ // does not dispatch on every render. Intentionally undefined (not true) so the first
72
+ // render always reports the real validity rather than assuming the form starts invalid.
73
+ this._lastReportedMobilePushContentEmpty = undefined;
74
+ }
75
+ componentDidUpdate() {
76
+ // Reports live form validity so the host (CreativesContainer) can disable Done/Preview
77
+ // and Test as soon as the message content is cleared — mirrors Sms/Create's own reporting.
78
+ if (typeof this.props.onContentValidityChange !== 'function') {
79
+ return;
80
+ }
81
+ const validity = getMobilePushEmbeddedContentValidity(this.state?.formData, this.state?.tabCount);
82
+ const isContentEmpty = !!validity.isContentEmpty;
83
+ if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
84
+ this._lastReportedMobilePushContentEmpty = isContentEmpty;
85
+ this.props.onContentValidityChange({ isContentEmpty });
70
86
  }
71
87
  componentWillMount = () => {
72
88
  if (this.props.route.name === 'view') {
@@ -2072,6 +2088,7 @@ Create.propTypes = {
2072
2088
  getFormLibraryData: PropTypes.func,
2073
2089
  onPreviewContentClicked: PropTypes.func,
2074
2090
  onTestContentClicked: PropTypes.func,
2091
+ onContentValidityChange: PropTypes.func,
2075
2092
  eventContextTags: PropTypes.array,
2076
2093
  waitEventContextTags: PropTypes.object,
2077
2094
  getLiquidTags: PropTypes.func,
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Create } from '../index';
4
+
5
+ // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
+ // only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
7
+ // Mirrors app/v2Containers/Sms/Create/index.js's own reporting and
8
+ // app/v2Containers/MobilePush/Edit/test/contentValidity.test.js.
9
+
10
+ const baseProps = () => ({
11
+ actions: {},
12
+ globalActions: {
13
+ fetchSchemaForEntity: jest.fn(),
14
+ },
15
+ Templates: { selectedWeChatAccount: { id: 'acc-1' } },
16
+ Create: {},
17
+ params: {},
18
+ location: { query: { type: 'library', module: 'default' } },
19
+ route: { name: 'mobilepush' },
20
+ router: { push: jest.fn() },
21
+ metaEntities: {},
22
+ intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
23
+ isFullMode: false,
24
+ });
25
+
26
+ describe('MobilePush/Create — onContentValidityChange reporting', () => {
27
+ it('reports isContentEmpty: true on mount when both Android title and message are empty', () => {
28
+ const onContentValidityChange = jest.fn();
29
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
30
+ wrapper.setState({
31
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
32
+ tabCount: 1,
33
+ });
34
+
35
+ expect(onContentValidityChange).toHaveBeenCalledWith(
36
+ expect.objectContaining({ isContentEmpty: true }),
37
+ );
38
+ });
39
+
40
+ it('reports isContentEmpty: false once the Android message has content', () => {
41
+ const onContentValidityChange = jest.fn();
42
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
43
+ wrapper.setState({
44
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
45
+ tabCount: 1,
46
+ });
47
+ onContentValidityChange.mockClear();
48
+
49
+ wrapper.setState({
50
+ formData: { 0: { 'message-title': 'Hello', 'message-editor': 'World' } },
51
+ tabCount: 1,
52
+ });
53
+
54
+ expect(onContentValidityChange).toHaveBeenCalledWith(
55
+ expect.objectContaining({ isContentEmpty: false }),
56
+ );
57
+ });
58
+
59
+ it('re-reports empty when a previously entered message is cleared out', () => {
60
+ const onContentValidityChange = jest.fn();
61
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
62
+ wrapper.setState({
63
+ formData: { 0: { 'message-title': 'Existing', 'message-editor': 'content' } },
64
+ tabCount: 1,
65
+ });
66
+ onContentValidityChange.mockClear();
67
+
68
+ wrapper.setState({
69
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
70
+ tabCount: 1,
71
+ });
72
+
73
+ expect(onContentValidityChange).toHaveBeenCalledWith(
74
+ expect.objectContaining({ isContentEmpty: true }),
75
+ );
76
+ });
77
+
78
+ it('does not throw and does not report when onContentValidityChange is not provided', () => {
79
+ const wrapper = shallow(<Create {...baseProps()} />);
80
+ expect(() => {
81
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
82
+ }).not.toThrow();
83
+ });
84
+
85
+ it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
86
+ const onContentValidityChange = jest.fn();
87
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
88
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
89
+ onContentValidityChange.mockClear();
90
+
91
+ // Unrelated state change — content emptiness is unchanged.
92
+ wrapper.setState({ currentTab: 1 });
93
+
94
+ expect(onContentValidityChange).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -28,7 +28,7 @@ import withCreatives from '../../../hoc/withCreatives';
28
28
  import { gtmPush } from '../../../utils/gtmTrackers';
29
29
 
30
30
  import getEventsMap from '../eventsMap';
31
- import {getPrimaryCtaFields, getSecondaryCtaFields, getLinkTypeFields, getContent} from '../commonMethods';
31
+ import {getPrimaryCtaFields, getSecondaryCtaFields, getLinkTypeFields, getContent, getMobilePushEmbeddedContentValidity} from '../commonMethods';
32
32
  import { GA } from '@capillarytech/cap-ui-utils';
33
33
  import { EDIT, TRACK_EDIT_MPUSH } from '../../App/constants';
34
34
  import { MOBILE_PUSH } from '../../CreativesContainer/constants';
@@ -85,6 +85,10 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
85
85
  this.hasFetchedInitialTagsRef = false;
86
86
  // Guard: avoid duplicate fetch when multiple TagList instances trigger same context
87
87
  this.lastFetchedTagContextRef = null;
88
+ // Tracks the last content-emptiness value reported to the parent so componentDidUpdate
89
+ // does not dispatch on every render. Intentionally undefined (not true) so the first
90
+ // render always reports the real validity rather than assuming the form starts invalid.
91
+ this._lastReportedMobilePushContentEmpty = undefined;
88
92
  }
89
93
  componentWillMount() {
90
94
  this.props.actions.getWeCrmAccounts("mobilepush");
@@ -126,6 +130,18 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
126
130
  parent.postMessage(JSON.stringify(response), '*');
127
131
  }
128
132
  }
133
+ componentDidUpdate() {
134
+ // Reports live form validity so the host (CreativesContainer) can disable Done/Preview
135
+ // and Test as soon as the message content is cleared — mirrors Sms/Edit's own reporting.
136
+ if (typeof this.props.onContentValidityChange !== 'function') {
137
+ return;
138
+ }
139
+ const validity = getMobilePushEmbeddedContentValidity(this.state.formData, this.state.tabCount);
140
+ const isContentEmpty = !!validity.isContentEmpty;
141
+ if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
142
+ this._lastReportedMobilePushContentEmpty = isContentEmpty;
143
+ this.props.onContentValidityChange({ isContentEmpty });
144
+ }
129
145
  componentWillReceiveProps(nextProps) {
130
146
  if (nextProps.params?.id !== this.props.params?.id) {
131
147
  this.hasFetchedInitialTagsRef = false;
@@ -2337,6 +2353,7 @@ Edit.propTypes = {
2337
2353
  onValidationFail: PropTypes.func,
2338
2354
  onPreviewContentClicked: PropTypes.func,
2339
2355
  onTestContentClicked: PropTypes.func,
2356
+ onContentValidityChange: PropTypes.func,
2340
2357
  creativesMode: PropTypes.string,
2341
2358
  eventContextTags: PropTypes.array,
2342
2359
  waitEventContextTags: PropTypes.object,
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Edit } from '../index';
4
+
5
+ // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
+ // only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
7
+ // Mirrors app/v2Containers/Sms/Edit/tests/index.test.js.
8
+
9
+ const baseProps = () => ({
10
+ actions: {
11
+ getWeCrmAccounts: jest.fn(),
12
+ getTemplateDetails: jest.fn(),
13
+ setTemplateDetails: jest.fn(),
14
+ },
15
+ globalActions: {
16
+ fetchSchemaForEntity: jest.fn(),
17
+ },
18
+ Templates: { selectedWeChatAccount: { id: 'acc-1' } },
19
+ Edit: {},
20
+ Create: {},
21
+ params: {},
22
+ location: { query: { type: 'library', module: 'default' } },
23
+ router: { push: jest.fn() },
24
+ metaEntities: {},
25
+ templateData: {},
26
+ intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
27
+ isFullMode: false,
28
+ });
29
+
30
+ describe('MobilePush/Edit — onContentValidityChange reporting', () => {
31
+ it('reports isContentEmpty: true on mount when both Android title and message are empty', () => {
32
+ const onContentValidityChange = jest.fn();
33
+ const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
34
+ wrapper.setState({
35
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
36
+ tabCount: 1,
37
+ });
38
+
39
+ expect(onContentValidityChange).toHaveBeenCalledWith(
40
+ expect.objectContaining({ isContentEmpty: true }),
41
+ );
42
+ });
43
+
44
+ it('reports isContentEmpty: false once the Android message has content', () => {
45
+ const onContentValidityChange = jest.fn();
46
+ const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
47
+ wrapper.setState({
48
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
49
+ tabCount: 1,
50
+ });
51
+ onContentValidityChange.mockClear();
52
+
53
+ wrapper.setState({
54
+ formData: { 0: { 'message-title': 'Hello', 'message-editor': 'World' } },
55
+ tabCount: 1,
56
+ });
57
+
58
+ expect(onContentValidityChange).toHaveBeenCalledWith(
59
+ expect.objectContaining({ isContentEmpty: false }),
60
+ );
61
+ });
62
+
63
+ it('re-reports empty when an existing (edit-loaded) message is cleared out', () => {
64
+ const onContentValidityChange = jest.fn();
65
+ const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
66
+ wrapper.setState({
67
+ formData: { 0: { 'message-title': 'Existing', 'message-editor': 'content' } },
68
+ tabCount: 1,
69
+ });
70
+ onContentValidityChange.mockClear();
71
+
72
+ wrapper.setState({
73
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
74
+ tabCount: 1,
75
+ });
76
+
77
+ expect(onContentValidityChange).toHaveBeenCalledWith(
78
+ expect.objectContaining({ isContentEmpty: true }),
79
+ );
80
+ });
81
+
82
+ it('treats content as empty when the iOS tab (tabCount 2) is cleared even if Android has content', () => {
83
+ const onContentValidityChange = jest.fn();
84
+ const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
85
+ wrapper.setState({
86
+ formData: {
87
+ 0: { 'message-title': 'Android title', 'message-editor': 'Android body' },
88
+ 1: { 'message-title2': '', 'message-editor2': '' },
89
+ },
90
+ tabCount: 2,
91
+ });
92
+
93
+ expect(onContentValidityChange).toHaveBeenCalledWith(
94
+ expect.objectContaining({ isContentEmpty: true }),
95
+ );
96
+ });
97
+
98
+ it('does not throw and does not report when onContentValidityChange is not provided', () => {
99
+ const wrapper = shallow(<Edit {...baseProps()} />);
100
+ expect(() => {
101
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
102
+ }).not.toThrow();
103
+ });
104
+
105
+ it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
106
+ const onContentValidityChange = jest.fn();
107
+ const wrapper = shallow(<Edit {...baseProps()} onContentValidityChange={onContentValidityChange} />);
108
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
109
+ onContentValidityChange.mockClear();
110
+
111
+ // Unrelated state change — content emptiness is unchanged.
112
+ wrapper.setState({ currentTab: 1 });
113
+
114
+ expect(onContentValidityChange).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -2,6 +2,7 @@ import {get, filter, cloneDeep} from 'lodash';
2
2
  import React from 'react';
3
3
  import {FormattedMessage} from 'react-intl';
4
4
  import messages from './Create/messages';
5
+ import { extractContent } from '../../utils/commonUtils';
5
6
  function getPrimaryCtaFields({inputFields, fieldIndex, deepLinkOptions, tab}) {
6
7
  const currentTab = tab || this.state.currentTab;
7
8
  const newInputFields = cloneDeep(inputFields);
@@ -282,6 +283,53 @@ function getContent(obj) {
282
283
  const iosMessage = ios.message || '';
283
284
  return `${androidTitle} ${androidMessage} ${iosTitle} ${iosMessage}`;
284
285
  }
286
+
287
+ /**
288
+ * Extracts the plain-text content of a single Mobile Push tab (Android or iOS),
289
+ * mirroring the shape-detection logic in `validateMobilePushContent`
290
+ * (app/utils/commonUtils.js) so "empty" is defined consistently in both places.
291
+ * @param {number} tabIndex 0 = Android, 1 = iOS
292
+ * @param {object} tabData formData[tabIndex]
293
+ * @returns {string}
294
+ */
295
+ function getMobilePushTabContent(tabIndex, tabData) {
296
+ if (!tabData || typeof tabData !== 'object') {
297
+ return '';
298
+ }
299
+ const titleKey = tabIndex === 1 ? 'message-title2' : 'message-title';
300
+ const messageKey = tabIndex === 1 ? 'message-editor2' : 'message-editor';
301
+ const isOldUiShape = titleKey in tabData || messageKey in tabData;
302
+ if (isOldUiShape) {
303
+ return [tabData[titleKey], tabData[messageKey]].filter(Boolean).join(' ');
304
+ }
305
+ return extractContent(tabData);
306
+ }
307
+
308
+ /**
309
+ * Live "is content empty" check for the legacy Mobile Push Edit/Create forms — the
310
+ * Mobile Push analogue of Sms's `getSmsEmbeddedFooterValidity` (see
311
+ * app/v2Containers/Sms/smsFormDataHelpers.js). Checks every active tab (Android, and
312
+ * iOS when tabCount > 1); if any active tab's title+message are both empty, the
313
+ * overall content is considered empty so Done/Preview-and-test can be disabled.
314
+ * @param {object} formData FormBuilder state (same shape as this.state.formData)
315
+ * @param {number} [tabCount] Total number of active tabs (1 = Android only, 2 = Android + iOS)
316
+ * @returns {{ isContentEmpty: boolean }}
317
+ */
318
+ function getMobilePushEmbeddedContentValidity(formData, tabCount) {
319
+ const count = tabCount != null && tabCount > 1 ? tabCount : 1;
320
+ let isContentEmpty = false;
321
+ for (let i = 0; i < count; i++) {
322
+ const content = getMobilePushTabContent(i, formData?.[i]);
323
+ const trimmed = content != null && content !== '' ? String(content).trim() : '';
324
+ if (!trimmed) {
325
+ isContentEmpty = true;
326
+ break;
327
+ }
328
+ }
329
+ return { isContentEmpty };
330
+ }
331
+
285
332
  export {
286
- getPrimaryCtaFields, getSecondaryCtaFields, getLinkTypeFields, getContent
333
+ getPrimaryCtaFields, getSecondaryCtaFields, getLinkTypeFields, getContent,
334
+ getMobilePushEmbeddedContentValidity
287
335
  };
@@ -86,7 +86,7 @@ import useUpload from "./hooks/useUpload";
86
86
  import { validateTags } from "../../utils/tagValidations";
87
87
  import { PlatformContentFields } from "./components";
88
88
  import { CREATE, EDIT, TRACK_CREATE_MPUSH } from "../App/constants";
89
- import { validateExternalLink, validateDeepLink } from "./utils";
89
+ import { validateExternalLink, validateDeepLink, isPlatformFieldsMissing } from "./utils";
90
90
  import messages from "./messages";
91
91
  import { EXTERNAL_URL, MOBILE_PUSH } from "../CreativesContainer/constants";
92
92
  import createMobilePushPayloadWithIntl from "../../utils/createMobilePushPayload";
@@ -477,7 +477,7 @@ const processPlatformContent = (contentType, expandableDetails, deepLink, proces
477
477
  };
478
478
  };
479
479
 
480
- const MobilePushNew = ({
480
+ export const MobilePushNew = ({
481
481
  isFullMode,
482
482
  intl,
483
483
  onEnterTemplateName,
@@ -509,6 +509,7 @@ const MobilePushNew = ({
509
509
  // new flag from parent - when true personalization via tags should be disabled
510
510
  restrictPersonalization = false,
511
511
  onPersonalizationTokensChange,
512
+ onContentValidityChange,
512
513
  }) => {
513
514
  const { formatMessage } = intl;
514
515
 
@@ -1758,8 +1759,30 @@ const MobilePushNew = ({
1758
1759
  ]);
1759
1760
 
1760
1761
  // Validation logic for template creation/update
1761
- const isAndroidFieldsMissing = isAndroidSupported && (!androidContent?.title?.trim() || !androidContent?.message?.trim());
1762
- const isIosFieldsMissing = isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim());
1762
+ const isAndroidFieldsMissing = isPlatformFieldsMissing(isAndroidSupported, androidContent);
1763
+ const isIosFieldsMissing = isPlatformFieldsMissing(isIosSupported, iosContent);
1764
+
1765
+ // Ref to dedupe reporting of content emptiness to the parent, so we only
1766
+ // call onContentValidityChange when the computed value actually changes.
1767
+ const lastReportedIsContentEmptyRef = useRef(undefined);
1768
+
1769
+ // Notify parent whenever content emptiness (based on required fields for
1770
+ // supported platforms) changes, mirroring the Save-button gating logic.
1771
+ useEffect(() => {
1772
+ if (typeof onContentValidityChange !== 'function') return;
1773
+ const isContentEmpty = isAndroidFieldsMissing || isIosFieldsMissing;
1774
+ if (lastReportedIsContentEmptyRef.current === isContentEmpty) return;
1775
+ lastReportedIsContentEmptyRef.current = isContentEmpty;
1776
+ onContentValidityChange({ isContentEmpty });
1777
+ }, [
1778
+ androidContent,
1779
+ iosContent,
1780
+ isAndroidSupported,
1781
+ isIosSupported,
1782
+ isAndroidFieldsMissing,
1783
+ isIosFieldsMissing,
1784
+ onContentValidityChange,
1785
+ ]);
1763
1786
 
1764
1787
  // Add changeSourceRef for debounced sync
1765
1788
  const changeSourceRef = useRef(null);
@@ -3148,6 +3171,7 @@ MobilePushNew.propTypes = {
3148
3171
  getTemplateDetailsInProgress: PropTypes.bool,
3149
3172
  onCreateComplete: PropTypes.func,
3150
3173
  onPersonalizationTokensChange: PropTypes.func,
3174
+ onContentValidityChange: PropTypes.func,
3151
3175
  };
3152
3176
 
3153
3177
  MobilePushNew.defaultProps = {
@@ -3179,6 +3203,7 @@ MobilePushNew.defaultProps = {
3179
3203
  getTemplateDetailsInProgress: false,
3180
3204
  onCreateComplete: () => {},
3181
3205
  onPersonalizationTokensChange: undefined,
3206
+ onContentValidityChange: undefined,
3182
3207
  };
3183
3208
 
3184
3209
  const mapStateToProps = createStructuredSelector({