@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.
- package/package.json +1 -1
- package/v2Components/CapActionButton/index.js +73 -60
- package/v2Components/CapActionButton/index.scss +44 -28
- package/v2Components/CapActionButton/messages.js +7 -3
- package/v2Components/CapActionButton/tests/index.test.js +17 -1
- package/v2Components/CapWhatsappCTA/messages.js +4 -0
- package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
- package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -1
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +3 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
- package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
- package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
- package/v2Containers/CreativesContainer/index.js +27 -0
- package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
- package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
- package/v2Containers/MobilePush/Create/index.js +18 -1
- package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
- package/v2Containers/MobilePush/Edit/index.js +18 -1
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
- package/v2Containers/MobilePush/commonMethods.js +49 -1
- package/v2Containers/MobilePushNew/index.js +29 -4
- package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
- package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
- package/v2Containers/MobilePushNew/utils.js +34 -1
- package/v2Containers/MobilepushWrapper/index.js +3 -1
- package/v2Containers/Rcs/index.js +34 -0
- package/v2Containers/Rcs/index.scss +15 -0
- package/v2Containers/Rcs/tests/index.test.js +67 -0
- package/v2Containers/Sms/Create/index.js +3 -1
- package/v2Containers/Sms/Edit/index.js +25 -0
- package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
- package/v2Containers/Viber/index.js +24 -1
- package/v2Containers/Viber/tests/index.test.js +103 -0
- package/v2Containers/WebPush/Create/index.js +24 -0
- package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
- package/v2Containers/Whatsapp/index.js +19 -1
- package/v2Containers/Whatsapp/tests/index.test.js +115 -0
- package/v2Containers/Zalo/index.js +28 -1
- 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
|
|
1762
|
-
const isIosFieldsMissing = isIosSupported
|
|
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).
|