@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.
- package/package.json +1 -1
- package/services/api.js +1 -1
- package/utils/templateVarUtils.js +1 -17
- 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/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -2
- package/v2Components/CommonTestAndPreview/index.js +3 -6
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -0
- package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -14
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -2
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +35 -3
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
- package/v2Containers/CommunicationFlow/constants.js +1 -3
- package/v2Containers/CommunicationFlow/messages.js +4 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +5 -1
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +64 -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/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +3 -3
- 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 +20 -4
- 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
|
@@ -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
|
|
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({
|