@capillarytech/creatives-library 9.0.56-alpha.7 → 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 (44) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CapActionButton/index.js +73 -60
  3. package/v2Components/CapActionButton/index.scss +44 -28
  4. package/v2Components/CapActionButton/messages.js +7 -3
  5. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  6. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  7. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  8. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  9. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -1
  10. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
  11. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +3 -0
  12. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
  13. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
  14. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  15. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
  16. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  17. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  18. package/v2Containers/CreativesContainer/index.js +27 -0
  19. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  20. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
  21. package/v2Containers/MobilePush/Create/index.js +18 -1
  22. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  23. package/v2Containers/MobilePush/Edit/index.js +18 -1
  24. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  25. package/v2Containers/MobilePush/commonMethods.js +49 -1
  26. package/v2Containers/MobilePushNew/index.js +29 -4
  27. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  28. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  29. package/v2Containers/MobilePushNew/utils.js +34 -1
  30. package/v2Containers/MobilepushWrapper/index.js +3 -1
  31. package/v2Containers/Rcs/index.js +34 -0
  32. package/v2Containers/Rcs/index.scss +15 -0
  33. package/v2Containers/Rcs/tests/index.test.js +67 -0
  34. package/v2Containers/Sms/Create/index.js +3 -1
  35. package/v2Containers/Sms/Edit/index.js +25 -0
  36. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  37. package/v2Containers/Viber/index.js +24 -1
  38. package/v2Containers/Viber/tests/index.test.js +103 -0
  39. package/v2Containers/WebPush/Create/index.js +24 -0
  40. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  41. package/v2Containers/Whatsapp/index.js +19 -1
  42. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  43. package/v2Containers/Zalo/index.js +28 -1
  44. package/v2Containers/Zalo/tests/index.test.js +99 -0
@@ -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({
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { injectIntl } from 'react-intl';
3
+ import '@testing-library/jest-dom';
4
+ import { Provider } from 'react-redux';
5
+ import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
6
+ import history from '../../../utils/history';
7
+ import { initialReducer } from '../../../initialReducer';
8
+ import { MobilePushNew } from '..';
9
+ import { render, screen, fireEvent } from '../../../utils/test-utils';
10
+
11
+ jest.mock('../../TagList/index.js', () => ({
12
+ __esModule: true,
13
+ default: (props) => (
14
+ <div className="tag-mock" {...props}>
15
+ TagList
16
+ </div>
17
+ ),
18
+ }));
19
+
20
+ let store;
21
+ beforeAll(() => {
22
+ store = configureStore({}, initialReducer, history);
23
+ });
24
+
25
+ const ComponentToRender = injectIntl(MobilePushNew);
26
+ const renderComponent = (props) => render(
27
+ <Provider store={store}>
28
+ <ComponentToRender {...props} />
29
+ </Provider>,
30
+ );
31
+
32
+ // Only Android is marked supported so a single, unambiguous title/message
33
+ // input pair is rendered (avoids ambiguity from a second, iOS, tab/pane).
34
+ const accountData = {
35
+ configs: {
36
+ android: '1',
37
+ ios: '0',
38
+ },
39
+ };
40
+
41
+ const globalActions = {
42
+ fetchSchemaForEntity: jest.fn(),
43
+ };
44
+
45
+ describe('MobilePushNew', () => {
46
+ it('renders without crashing when onContentValidityChange is passed', async () => {
47
+ const onContentValidityChange = jest.fn();
48
+ renderComponent({
49
+ accountData,
50
+ templateData: {},
51
+ location: {},
52
+ injectedTags: {},
53
+ getDefaultTags: jest.fn(),
54
+ globalActions,
55
+ onContentValidityChange,
56
+ });
57
+
58
+ const titleInput = await screen.findByPlaceholderText('Enter title');
59
+ expect(titleInput).toBeInTheDocument();
60
+ });
61
+
62
+ it('reports content emptiness to the parent via onContentValidityChange', async () => {
63
+ const onContentValidityChange = jest.fn();
64
+ renderComponent({
65
+ accountData,
66
+ templateData: {},
67
+ location: {},
68
+ injectedTags: {},
69
+ getDefaultTags: jest.fn(),
70
+ globalActions,
71
+ onContentValidityChange,
72
+ });
73
+
74
+ const titleInput = await screen.findByPlaceholderText('Enter title');
75
+ const messageInput = await screen.findByPlaceholderText('Enter message');
76
+
77
+ // Both required Android fields start blank, so the first report should be "empty".
78
+ expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
79
+ onContentValidityChange.mockClear();
80
+
81
+ fireEvent.change(titleInput, { target: { value: 'Hello title' } });
82
+ fireEvent.change(messageInput, { target: { value: 'Hello message' } });
83
+
84
+ expect(onContentValidityChange).toHaveBeenLastCalledWith({ isContentEmpty: false });
85
+ });
86
+
87
+ it('does not re-report validity when it has not changed (dedup guard)', async () => {
88
+ const onContentValidityChange = jest.fn();
89
+ renderComponent({
90
+ accountData,
91
+ templateData: {},
92
+ location: {},
93
+ injectedTags: {},
94
+ getDefaultTags: jest.fn(),
95
+ globalActions,
96
+ onContentValidityChange,
97
+ });
98
+
99
+ const titleInput = await screen.findByPlaceholderText('Enter title');
100
+ onContentValidityChange.mockClear();
101
+
102
+ // Still blank -> still empty -> should not report again.
103
+ fireEvent.change(titleInput, { target: { value: '' } });
104
+ expect(onContentValidityChange).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('does not call onContentValidityChange when the prop is not provided', async () => {
108
+ expect(() => renderComponent({
109
+ accountData,
110
+ templateData: {},
111
+ location: {},
112
+ injectedTags: {},
113
+ getDefaultTags: jest.fn(),
114
+ globalActions,
115
+ })).not.toThrow();
116
+
117
+ await screen.findByPlaceholderText('Enter title');
118
+ });
119
+ });
@@ -3,6 +3,8 @@ import {
3
3
  validateExternalLink,
4
4
  validateDeepLink,
5
5
  isDeepLink,
6
+ isPlatformFieldsMissing,
7
+ computeIsContentEmpty,
6
8
  } from "../utils";
7
9
  import { isUrl } from "../../Line/Container/Wrapper/utils";
8
10
 
@@ -396,6 +398,86 @@ describe("utils.js", () => {
396
398
  });
397
399
  });
398
400
 
401
+ describe("isPlatformFieldsMissing", () => {
402
+ it("returns false when the platform is not supported, regardless of content", () => {
403
+ expect(isPlatformFieldsMissing(false, { title: "", message: "" })).toBe(false);
404
+ expect(isPlatformFieldsMissing(undefined, undefined)).toBe(false);
405
+ });
406
+
407
+ it("returns true when supported and title is missing", () => {
408
+ expect(isPlatformFieldsMissing(true, { title: "", message: "hello" })).toBe(true);
409
+ expect(isPlatformFieldsMissing(true, { title: " ", message: "hello" })).toBe(true);
410
+ });
411
+
412
+ it("returns true when supported and message is missing", () => {
413
+ expect(isPlatformFieldsMissing(true, { title: "hello", message: "" })).toBe(true);
414
+ });
415
+
416
+ it("returns true when content is undefined and platform is supported", () => {
417
+ expect(isPlatformFieldsMissing(true, undefined)).toBe(true);
418
+ });
419
+
420
+ it("returns false when supported and both title and message are present", () => {
421
+ expect(isPlatformFieldsMissing(true, { title: "hello", message: "world" })).toBe(false);
422
+ });
423
+ });
424
+
425
+ describe("computeIsContentEmpty", () => {
426
+ it("returns false when neither platform is supported", () => {
427
+ expect(computeIsContentEmpty({
428
+ isAndroidSupported: false,
429
+ isIosSupported: false,
430
+ androidContent: {},
431
+ iosContent: {},
432
+ })).toBe(false);
433
+ });
434
+
435
+ it("returns true when the only supported platform (android) is missing fields", () => {
436
+ expect(computeIsContentEmpty({
437
+ isAndroidSupported: true,
438
+ isIosSupported: false,
439
+ androidContent: { title: "", message: "" },
440
+ iosContent: { title: "", message: "" },
441
+ })).toBe(true);
442
+ });
443
+
444
+ it("returns false when the only supported platform (android) has valid fields, even if iOS content is empty", () => {
445
+ expect(computeIsContentEmpty({
446
+ isAndroidSupported: true,
447
+ isIosSupported: false,
448
+ androidContent: { title: "t", message: "m" },
449
+ iosContent: { title: "", message: "" },
450
+ })).toBe(false);
451
+ });
452
+
453
+ it("returns true when the only supported platform (ios) is missing fields", () => {
454
+ expect(computeIsContentEmpty({
455
+ isAndroidSupported: false,
456
+ isIosSupported: true,
457
+ androidContent: { title: "", message: "" },
458
+ iosContent: { title: "", message: "" },
459
+ })).toBe(true);
460
+ });
461
+
462
+ it("returns true when both platforms are supported and one is missing fields", () => {
463
+ expect(computeIsContentEmpty({
464
+ isAndroidSupported: true,
465
+ isIosSupported: true,
466
+ androidContent: { title: "t", message: "m" },
467
+ iosContent: { title: "", message: "" },
468
+ })).toBe(true);
469
+ });
470
+
471
+ it("returns false when both platforms are supported and both have valid fields", () => {
472
+ expect(computeIsContentEmpty({
473
+ isAndroidSupported: true,
474
+ isIosSupported: true,
475
+ androidContent: { title: "t", message: "m" },
476
+ iosContent: { title: "t", message: "m" },
477
+ })).toBe(false);
478
+ });
479
+ });
480
+
399
481
  describe("Error handling", () => {
400
482
  it("should handle isUrl throwing an error", () => {
401
483
  isUrl.mockImplementation(() => {
@@ -81,4 +81,37 @@ export const validateDeepLink = (linkValue, formatMessage, messages) => {
81
81
  return null; // Empty deep link is valid (optional field)
82
82
  }
83
83
  return isDeepLink(linkValue.trim()) ? null : formatMessage(messages.invalidUrl);
84
- };
84
+ };
85
+
86
+ /**
87
+ * Determine whether the required title/message fields are missing for a
88
+ * supported platform (Android/iOS). Mirrors the Save-button gating logic so
89
+ * platforms that are not supported are never treated as "missing".
90
+ * @param {boolean} isSupported - Whether the platform is enabled/supported
91
+ * @param {Object} content - The platform content object ({ title, message })
92
+ * @returns {boolean} - True if the platform is supported and a required field is empty
93
+ */
94
+ export const isPlatformFieldsMissing = (isSupported, content) => (
95
+ !!isSupported && (!content?.title?.trim() || !content?.message?.trim())
96
+ );
97
+
98
+ /**
99
+ * Compute whether the Mobile Push content is empty/invalid for reporting to
100
+ * a parent component (e.g. via onContentValidityChange). Content is
101
+ * considered empty when any supported platform is missing required fields.
102
+ * @param {Object} params
103
+ * @param {boolean} params.isAndroidSupported
104
+ * @param {boolean} params.isIosSupported
105
+ * @param {Object} params.androidContent
106
+ * @param {Object} params.iosContent
107
+ * @returns {boolean}
108
+ */
109
+ export const computeIsContentEmpty = ({
110
+ isAndroidSupported,
111
+ isIosSupported,
112
+ androidContent,
113
+ iosContent,
114
+ }) => (
115
+ isPlatformFieldsMissing(isAndroidSupported, androidContent)
116
+ || isPlatformFieldsMissing(isIosSupported, iosContent)
117
+ );
@@ -72,7 +72,7 @@ export class MobilepushWrapper extends React.Component { // eslint-disable-line
72
72
  }
73
73
 
74
74
  render() {
75
- const {mobilePushCreateMode, step, getFormData, getLiquidTags, setIsLoadingContent, isGetFormData, query, isFullMode, showTemplateName, type, onValidationFail, onPreviewContentClicked, onTestContentClicked, templateData, eventContextTags = [], waitEventContextTags = {},showTestAndPreviewSlidebox, handleTestAndPreview, handleCloseTestAndPreview, restrictPersonalization, isAnonymousType, onPersonalizationTokensChange} = this.props;
75
+ const {mobilePushCreateMode, step, getFormData, getLiquidTags, setIsLoadingContent, isGetFormData, query, isFullMode, showTemplateName, type, onValidationFail, onPreviewContentClicked, onTestContentClicked, onContentValidityChange, templateData, eventContextTags = [], waitEventContextTags = {},showTestAndPreviewSlidebox, handleTestAndPreview, handleCloseTestAndPreview, restrictPersonalization, isAnonymousType, onPersonalizationTokensChange} = this.props;
76
76
  const {templateName} = this.state;
77
77
  const isShowMobilepushCreate = !isEmpty(mobilePushCreateMode);
78
78
  return (
@@ -118,6 +118,7 @@ export class MobilepushWrapper extends React.Component { // eslint-disable-line
118
118
  onValidationFail={onValidationFail}
119
119
  onPreviewContentClicked={onPreviewContentClicked}
120
120
  onTestContentClicked={onTestContentClicked}
121
+ onContentValidityChange={onContentValidityChange}
121
122
  templateData={templateData}
122
123
  hideTestAndPreviewBtn={this.props.hideTestAndPreviewBtn}
123
124
  eventContextTags={eventContextTags}
@@ -155,6 +156,7 @@ MobilepushWrapper.propTypes = {
155
156
  showTemplateName: PropTypes.func,
156
157
  type: PropTypes.string,
157
158
  onValidationFail: PropTypes.func,
159
+ onContentValidityChange: PropTypes.func,
158
160
  eventContextTags: PropTypes.array,
159
161
  waitEventContextTags: PropTypes.object,
160
162
  showLiquidErrorInFooter: PropTypes.func,
@@ -211,6 +211,7 @@ export const Rcs = (props) => {
211
211
  handleTestAndPreview: propsHandleTestAndPreview,
212
212
  handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
213
213
  handleClose,
214
+ onContentValidityChange,
214
215
  } = props || {};
215
216
  const { formatMessage } = intl;
216
217
  const { TextArea } = CapInput;
@@ -399,6 +400,39 @@ export const Rcs = (props) => {
399
400
  const isMediaTypeVideo = templateMediaType === RCS_MEDIA_TYPES.VIDEO;
400
401
  const isCarouselType = templateType === contentType.carousel;
401
402
 
403
+ /**
404
+ * Mirrors the "is card content missing" checks already used by isDisableDone()/isEditDisableDone()
405
+ * (title/description emptiness per media type; "any carousel card missing title or description" for
406
+ * carousel — same convention as isCarouselLibraryIncomplete()'s hasEmptyField check), but limited to
407
+ * just the message-content fields (not template name / buttons / CTA validation) so it reports pure
408
+ * content emptiness to the generic parent handler, the same way Sms's isMessageEmpty does not fold in
409
+ * template-name emptiness.
410
+ */
411
+ const isRcsCardContentEmpty = () => {
412
+ if (isCarouselType) {
413
+ return (carouselData || []).some((card) =>
414
+ ['title', 'description'].some((field) => !(card?.[field] || '').trim())
415
+ );
416
+ }
417
+ if (isMediaTypeText) {
418
+ return templateDesc.trim() === '';
419
+ }
420
+ return templateTitle.trim() === '' || templateDesc.trim() === '';
421
+ };
422
+
423
+ // Tracks the last validity value reported to the parent so this effect does not dispatch on every
424
+ // render. Intentionally undefined (not a boolean) so the first render always reports the real
425
+ // validity rather than assuming the form starts invalid — mirrors Sms/Edit's dedup guard.
426
+ const lastReportedRcsContentEmptyRef = useRef(undefined);
427
+ useEffect(() => {
428
+ if (typeof onContentValidityChange !== 'function') return;
429
+ const isContentEmpty = isRcsCardContentEmpty();
430
+ if (lastReportedRcsContentEmptyRef.current !== isContentEmpty) {
431
+ lastReportedRcsContentEmptyRef.current = isContentEmpty;
432
+ onContentValidityChange({ isContentEmpty });
433
+ }
434
+ }, [isCarouselType, isMediaTypeText, templateTitle, templateDesc, carouselData]);
435
+
402
436
  const clearCarouselCardMedia = (cardIndex, { clearImage = true, clearVideo = true, clearThumb = true } = {}) => {
403
437
  setCarouselData((prev = []) => {
404
438
  const updated = cloneDeep(prev);
@@ -287,9 +287,24 @@
287
287
  width: 100%;
288
288
  }
289
289
 
290
+ // These CapRows are flex containers, so without a definite width their
291
+ // children size to max-content and a long URL in a card widens the whole
292
+ // chain instead of truncating.
293
+ .rcs-carousel-section > .rcs-carousel-tab {
294
+ width: 100%;
295
+ min-width: 0;
296
+ }
297
+
290
298
  .rcs-carousel-tab {
291
299
  margin-top: 0.75rem;
292
300
 
301
+ // CapTab's outer wrapper has a hashed css-module class, so it can only be
302
+ // reached structurally; this row has just the one child.
303
+ > * {
304
+ width: 100%;
305
+ min-width: 0;
306
+ }
307
+
293
308
  // Tab list (.ant-tabs-nav-wrap) defaults to flex:auto and stretches to fill the row,
294
309
  // pushing tabBarExtraContent (the "+" add-card button) to the far right edge. Size it
295
310
  // to its own content instead so "+" sits right next to the last tab (Figma reference).