@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
|
@@ -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).
|
|
@@ -158,6 +158,7 @@ const renderHelper = (args) => {
|
|
|
158
158
|
{...(args.omitGetDefaultTags ? {} : { getDefaultTags: true })}
|
|
159
159
|
isDltEnabled={args.isDltEnabled || false}
|
|
160
160
|
smsRegister={'DLT'}
|
|
161
|
+
onContentValidityChange={args.onContentValidityChange}
|
|
161
162
|
/>
|
|
162
163
|
</Provider>,
|
|
163
164
|
);
|
|
@@ -1923,6 +1924,72 @@ describe('Character Counting Functions', () => {
|
|
|
1923
1924
|
});
|
|
1924
1925
|
});
|
|
1925
1926
|
|
|
1927
|
+
describe('Rcs — onContentValidityChange reporting', () => {
|
|
1928
|
+
// Full-mode create with a fresh (non-hydrating) rcsData: templateDesc starts out empty,
|
|
1929
|
+
// same as the pre-existing "template title change" test's initial-state assumption.
|
|
1930
|
+
it('reports isContentEmpty: true on mount when the (default text_message) description is empty', () => {
|
|
1931
|
+
const onContentValidityChange = jest.fn();
|
|
1932
|
+
renderHelper({ onContentValidityChange });
|
|
1933
|
+
|
|
1934
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
1935
|
+
expect.objectContaining({ isContentEmpty: true }),
|
|
1936
|
+
);
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
it('reports isContentEmpty: false once the description has content, then true again once cleared', () => {
|
|
1940
|
+
const onContentValidityChange = jest.fn();
|
|
1941
|
+
renderHelper({ onContentValidityChange });
|
|
1942
|
+
onContentValidityChange.mockClear();
|
|
1943
|
+
|
|
1944
|
+
const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
|
|
1945
|
+
act(() => {
|
|
1946
|
+
descTextArea.props().onChange({ target: { value: 'Hello there' } });
|
|
1947
|
+
});
|
|
1948
|
+
renderedComponent.update();
|
|
1949
|
+
|
|
1950
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
1951
|
+
expect.objectContaining({ isContentEmpty: false }),
|
|
1952
|
+
);
|
|
1953
|
+
onContentValidityChange.mockClear();
|
|
1954
|
+
|
|
1955
|
+
const descTextAreaAfter = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
|
|
1956
|
+
act(() => {
|
|
1957
|
+
descTextAreaAfter.props().onChange({ target: { value: ' ' } });
|
|
1958
|
+
});
|
|
1959
|
+
renderedComponent.update();
|
|
1960
|
+
|
|
1961
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
1962
|
+
expect.objectContaining({ isContentEmpty: true }),
|
|
1963
|
+
);
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
it('does not throw and does not report when onContentValidityChange is not provided', () => {
|
|
1967
|
+
expect(() => {
|
|
1968
|
+
renderHelper({});
|
|
1969
|
+
const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
|
|
1970
|
+
act(() => {
|
|
1971
|
+
descTextArea.props().onChange({ target: { value: 'abc' } });
|
|
1972
|
+
});
|
|
1973
|
+
renderedComponent.update();
|
|
1974
|
+
}).not.toThrow();
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
|
|
1978
|
+
const onContentValidityChange = jest.fn();
|
|
1979
|
+
renderHelper({ onContentValidityChange });
|
|
1980
|
+
onContentValidityChange.mockClear();
|
|
1981
|
+
|
|
1982
|
+
const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
|
|
1983
|
+
act(() => {
|
|
1984
|
+
// Still empty (still whitespace-only) — validity does not actually change.
|
|
1985
|
+
descTextArea.props().onChange({ target: { value: ' ' } });
|
|
1986
|
+
});
|
|
1987
|
+
renderedComponent.update();
|
|
1988
|
+
|
|
1989
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1926
1993
|
describe('CapActionButton (mocked within RCS tests)', () => {
|
|
1927
1994
|
it('should render the mocked CapActionButton placeholder', () => {
|
|
1928
1995
|
const wrapper = mountWithIntl(
|
|
@@ -185,7 +185,9 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
componentDidUpdate() {
|
|
188
|
-
|
|
188
|
+
// Reporting itself is opt-in via the callback prop — not tied to embeddedSmsFallback,
|
|
189
|
+
// which is a separate, unrelated flag (tag-popover styling for the SMS-fallback surface).
|
|
190
|
+
if (typeof this.props.onEmbeddedSmsFooterValidity !== 'function') {
|
|
189
191
|
return;
|
|
190
192
|
}
|
|
191
193
|
const validity = getSmsEmbeddedFooterValidity(this.state.formData, this.state.tabCount);
|
|
@@ -34,6 +34,7 @@ import v2EditSmsReducer from './reducer';
|
|
|
34
34
|
import { v2SmsEditSagas } from './sagas';
|
|
35
35
|
import * as globalActions from '../../Cap/actions';
|
|
36
36
|
import TestAndPreviewSlidebox from '../../../v2Components/TestAndPreviewSlidebox';
|
|
37
|
+
import { getSmsEmbeddedFooterValidity } from '../smsFormDataHelpers';
|
|
37
38
|
|
|
38
39
|
export class Edit extends React.Component { // eslint-disable-line react/prefer-stateless-function
|
|
39
40
|
constructor(props) {
|
|
@@ -54,6 +55,11 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
|
|
|
54
55
|
isTestAndPreviewMode: false,
|
|
55
56
|
pendingGetFormData: false,
|
|
56
57
|
};
|
|
58
|
+
// Tracks the last validity value reported to the parent so componentDidUpdate does not
|
|
59
|
+
// dispatch on every render. Intentionally undefined (not true) so the first render
|
|
60
|
+
// always reports the real validity rather than assuming the form starts invalid.
|
|
61
|
+
this._lastReportedSmsFooterTemplateNameEmpty = undefined;
|
|
62
|
+
this._lastReportedSmsFooterMessageEmpty = undefined;
|
|
57
63
|
this.saveFormData = this.saveFormData.bind(this);
|
|
58
64
|
this.onFormDataChange = this.onFormDataChange.bind(this);
|
|
59
65
|
this.onTemplateNameChange = this.onTemplateNameChange.bind(this);
|
|
@@ -189,6 +195,24 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
|
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
componentDidUpdate() {
|
|
199
|
+
// Reports live form validity so the host (CreativesContainer) can disable Done/Preview
|
|
200
|
+
// and Test as soon as the message body is cleared — mirrors Sms/Create's own reporting.
|
|
201
|
+
if (typeof this.props.onEmbeddedSmsFooterValidity !== 'function') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const validity = getSmsEmbeddedFooterValidity(this.state.formData, this.state.tabCount);
|
|
205
|
+
const isTemplateNameEmpty = !!validity.isTemplateNameEmpty;
|
|
206
|
+
const isMessageEmpty = !!validity.isMessageEmpty;
|
|
207
|
+
if (
|
|
208
|
+
this._lastReportedSmsFooterTemplateNameEmpty === isTemplateNameEmpty &&
|
|
209
|
+
this._lastReportedSmsFooterMessageEmpty === isMessageEmpty
|
|
210
|
+
) return;
|
|
211
|
+
this._lastReportedSmsFooterTemplateNameEmpty = isTemplateNameEmpty;
|
|
212
|
+
this._lastReportedSmsFooterMessageEmpty = isMessageEmpty;
|
|
213
|
+
this.props.onEmbeddedSmsFooterValidity(validity);
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
componentWillUnmount() {
|
|
193
217
|
if (this.pendingGetFormDataTimeout) {
|
|
194
218
|
clearTimeout(this.pendingGetFormDataTimeout);
|
|
@@ -1138,6 +1162,7 @@ Edit.propTypes = {
|
|
|
1138
1162
|
handleTestAndPreview: PropTypes.func,
|
|
1139
1163
|
handleCloseTestAndPreview: PropTypes.func,
|
|
1140
1164
|
isTestAndPreviewMode: PropTypes.bool,
|
|
1165
|
+
onEmbeddedSmsFooterValidity: PropTypes.func,
|
|
1141
1166
|
};
|
|
1142
1167
|
|
|
1143
1168
|
const mapStateToProps = createStructuredSelector({
|
|
@@ -0,0 +1,85 @@
|
|
|
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 → onEmbeddedSmsFooterValidity wiring in isolation.
|
|
7
|
+
|
|
8
|
+
const baseProps = () => ({
|
|
9
|
+
actions: {
|
|
10
|
+
getTemplateDetails: jest.fn(),
|
|
11
|
+
setTemplateDetails: jest.fn(),
|
|
12
|
+
clearEditResponse: jest.fn(),
|
|
13
|
+
resetEditTemplate: jest.fn(),
|
|
14
|
+
editTemplate: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
globalActions: {
|
|
17
|
+
fetchSchemaForEntity: jest.fn(),
|
|
18
|
+
setInjectedTags: jest.fn(),
|
|
19
|
+
addMessageToQueue: jest.fn(),
|
|
20
|
+
},
|
|
21
|
+
Edit: {},
|
|
22
|
+
params: { id: 'tpl-1' },
|
|
23
|
+
location: { query: { type: 'embedded', module: 'library' } },
|
|
24
|
+
metaEntities: {},
|
|
25
|
+
intl: { formatMessage: (m) => m.defaultMessage || m.id },
|
|
26
|
+
isFullMode: false,
|
|
27
|
+
getFormSubscriptionData: jest.fn(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('Sms/Edit — onEmbeddedSmsFooterValidity reporting', () => {
|
|
31
|
+
it('reports isMessageEmpty: true on mount when the message body is empty', () => {
|
|
32
|
+
const onEmbeddedSmsFooterValidity = jest.fn();
|
|
33
|
+
const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
|
|
34
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
|
|
35
|
+
|
|
36
|
+
expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
|
|
37
|
+
expect.objectContaining({ isMessageEmpty: true }),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports isMessageEmpty: false once the message body has content', () => {
|
|
42
|
+
const onEmbeddedSmsFooterValidity = jest.fn();
|
|
43
|
+
const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
|
|
44
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
|
|
45
|
+
onEmbeddedSmsFooterValidity.mockClear();
|
|
46
|
+
|
|
47
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': 'Hello there' }, base: { 'sms-editor': 'Hello there' } }, tabCount: 1 });
|
|
48
|
+
|
|
49
|
+
expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({ isMessageEmpty: false }),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('re-reports empty when an existing (edit-loaded) message is cleared out', () => {
|
|
55
|
+
const onEmbeddedSmsFooterValidity = jest.fn();
|
|
56
|
+
const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
|
|
57
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': 'Existing content' }, base: { 'sms-editor': 'Existing content' } }, tabCount: 1 });
|
|
58
|
+
onEmbeddedSmsFooterValidity.mockClear();
|
|
59
|
+
|
|
60
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
|
|
61
|
+
|
|
62
|
+
expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({ isMessageEmpty: true }),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not throw and does not report when onEmbeddedSmsFooterValidity is not provided', () => {
|
|
68
|
+
const wrapper = shallow(<Edit {...baseProps()} />);
|
|
69
|
+
expect(() => {
|
|
70
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': '' } }, tabCount: 1 });
|
|
71
|
+
}).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
|
|
75
|
+
const onEmbeddedSmsFooterValidity = jest.fn();
|
|
76
|
+
const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
|
|
77
|
+
wrapper.setState({ formData: { 0: { 'sms-editor': '' } }, tabCount: 1 });
|
|
78
|
+
onEmbeddedSmsFooterValidity.mockClear();
|
|
79
|
+
|
|
80
|
+
// Unrelated state change — validity tuple (isTemplateNameEmpty/isMessageEmpty) is unchanged.
|
|
81
|
+
wrapper.setState({ currentTab: 1 });
|
|
82
|
+
|
|
83
|
+
expect(onEmbeddedSmsFooterValidity).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useState, useEffect, useCallback, useRef,
|
|
3
|
+
} from 'react';
|
|
2
4
|
import { bindActionCreators } from 'redux';
|
|
3
5
|
import { createStructuredSelector } from 'reselect';
|
|
4
6
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
@@ -139,6 +141,7 @@ export const Viber = (props) => {
|
|
|
139
141
|
showTestAndPreviewSlidebox: propsShowTestAndPreviewSlidebox,
|
|
140
142
|
handleTestAndPreview: propsHandleTestAndPreview,
|
|
141
143
|
handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
|
|
144
|
+
onContentValidityChange,
|
|
142
145
|
} = props || {};
|
|
143
146
|
|
|
144
147
|
const { formatMessage } = intl;
|
|
@@ -257,6 +260,26 @@ export const Viber = (props) => {
|
|
|
257
260
|
}
|
|
258
261
|
}, [viber.templateDetails || templateData]);
|
|
259
262
|
|
|
263
|
+
// Reports live message-content validity to a parent (e.g. CreativesContainer's
|
|
264
|
+
// slidebox) so it can disable its own Done/Preview-and-test buttons whenever the
|
|
265
|
+
// Viber message body is empty/whitespace-only. Mirrors the Sms Create/Edit
|
|
266
|
+
// componentDidUpdate pattern, using a ref (instead of an instance field) as the
|
|
267
|
+
// dedup guard so the callback only fires when the reported value actually changes -
|
|
268
|
+
// calling it unconditionally on every render risks an infinite update loop when the
|
|
269
|
+
// parent's setState triggers a re-render of this component.
|
|
270
|
+
const lastReportedContentEmptyRef = useRef(undefined);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (typeof onContentValidityChange !== 'function') {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const isContentEmpty = (messageContent || '').trim() === '';
|
|
276
|
+
if (lastReportedContentEmptyRef.current === isContentEmpty) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
lastReportedContentEmptyRef.current = isContentEmpty;
|
|
280
|
+
onContentValidityChange({ isContentEmpty });
|
|
281
|
+
}, [messageContent, onContentValidityChange]);
|
|
282
|
+
|
|
260
283
|
// Text area Code start here
|
|
261
284
|
|
|
262
285
|
// Tags Code start from here
|