@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
|
@@ -469,4 +469,107 @@ describe('Test Viber container', () => {
|
|
|
469
469
|
|
|
470
470
|
expect(await screen.findByText("URL can't be empty")).toBeInTheDocument();
|
|
471
471
|
});
|
|
472
|
+
|
|
473
|
+
describe('onContentValidityChange reporting (Done/Preview button gating in the slidebox)', () => {
|
|
474
|
+
it('reports isContentEmpty: true on mount for an empty create flow, then false once a message is typed', async () => {
|
|
475
|
+
const onContentValidityChange = jest.fn();
|
|
476
|
+
renderComponent({
|
|
477
|
+
actions: mockActions,
|
|
478
|
+
globalActions: mockGlobalActions,
|
|
479
|
+
templateData: { mode: 'create' },
|
|
480
|
+
viber: {
|
|
481
|
+
uploadedAssetData: {},
|
|
482
|
+
createTemplateInProgress: false,
|
|
483
|
+
},
|
|
484
|
+
location: {
|
|
485
|
+
pathname: '/sms/edit',
|
|
486
|
+
query: { type: false, module: 'default' },
|
|
487
|
+
search: '',
|
|
488
|
+
},
|
|
489
|
+
isFullMode: true,
|
|
490
|
+
handleClose: jest.fn(),
|
|
491
|
+
onContentValidityChange,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await waitFor(() => {
|
|
495
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const msgBox = screen.getAllByPlaceholderText(/Enter message/i)[0];
|
|
499
|
+
fireEvent.change(msgBox, { target: { value: 'hello there' } });
|
|
500
|
+
|
|
501
|
+
await waitFor(() => {
|
|
502
|
+
expect(onContentValidityChange).toHaveBeenLastCalledWith({ isContentEmpty: false });
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('does not re-report the same validity value (dedup guard)', async () => {
|
|
507
|
+
const onContentValidityChange = jest.fn();
|
|
508
|
+
renderComponent({
|
|
509
|
+
actions: mockActions,
|
|
510
|
+
globalActions: mockGlobalActions,
|
|
511
|
+
templateData: { mode: 'create' },
|
|
512
|
+
viber: {
|
|
513
|
+
uploadedAssetData: {},
|
|
514
|
+
createTemplateInProgress: false,
|
|
515
|
+
},
|
|
516
|
+
location: {
|
|
517
|
+
pathname: '/sms/edit',
|
|
518
|
+
query: { type: false, module: 'default' },
|
|
519
|
+
search: '',
|
|
520
|
+
},
|
|
521
|
+
isFullMode: true,
|
|
522
|
+
handleClose: jest.fn(),
|
|
523
|
+
onContentValidityChange,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await waitFor(() => {
|
|
527
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
528
|
+
});
|
|
529
|
+
const callsAfterMount = onContentValidityChange.mock.calls.length;
|
|
530
|
+
|
|
531
|
+
// Whitespace-only change: still empty, should not trigger another call.
|
|
532
|
+
const msgBox = screen.getAllByPlaceholderText(/Enter message/i)[0];
|
|
533
|
+
fireEvent.change(msgBox, { target: { value: ' ' } });
|
|
534
|
+
|
|
535
|
+
expect(onContentValidityChange).toHaveBeenCalledTimes(callsAfterMount);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('reports isContentEmpty: false for an edit flow pre-filled with message content, then true once cleared', async () => {
|
|
539
|
+
const onContentValidityChange = jest.fn();
|
|
540
|
+
renderComponent({
|
|
541
|
+
actions: mockActions,
|
|
542
|
+
globalActions: mockGlobalActions,
|
|
543
|
+
templateData: { mode: 'create' },
|
|
544
|
+
viber: {
|
|
545
|
+
uploadedAssetData: {},
|
|
546
|
+
createTemplateInProgress: false,
|
|
547
|
+
templateDetails: templateDetailsText,
|
|
548
|
+
},
|
|
549
|
+
location: {
|
|
550
|
+
pathname: '/sms/edit',
|
|
551
|
+
query: { type: false, module: 'default' },
|
|
552
|
+
search: '',
|
|
553
|
+
},
|
|
554
|
+
isFullMode: true,
|
|
555
|
+
params: { id: 'test' },
|
|
556
|
+
handleClose: jest.fn(),
|
|
557
|
+
metaEntities,
|
|
558
|
+
getDefaultTags,
|
|
559
|
+
injectedTags,
|
|
560
|
+
onContentValidityChange,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await waitFor(() => {
|
|
564
|
+
expect(onContentValidityChange).toHaveBeenLastCalledWith({ isContentEmpty: false });
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const msgBox = screen.getAllByPlaceholderText(/Enter message/i)[0];
|
|
568
|
+
fireEvent.change(msgBox, { target: { value: '' } });
|
|
569
|
+
|
|
570
|
+
await waitFor(() => {
|
|
571
|
+
expect(onContentValidityChange).toHaveBeenLastCalledWith({ isContentEmpty: true });
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
});
|
|
472
575
|
});
|
|
@@ -164,6 +164,7 @@ const WebPushCreate = ({
|
|
|
164
164
|
templateActions: templateActionsProps,
|
|
165
165
|
Templates,
|
|
166
166
|
restrictPersonalization = false,
|
|
167
|
+
onContentValidityChange,
|
|
167
168
|
}) => {
|
|
168
169
|
const { formatMessage } = intl;
|
|
169
170
|
const aiContentBotDisabled = isAiContentBotDisabled();
|
|
@@ -194,6 +195,10 @@ const WebPushCreate = ({
|
|
|
194
195
|
const messageCountRef = useRef(null);
|
|
195
196
|
const saveInitiatedRef = useRef(false);
|
|
196
197
|
const messageTextAreaRef = useRef(null);
|
|
198
|
+
// Tracks the last isContentEmpty value reported to the parent (CreativesContainer) so the
|
|
199
|
+
// effect below does not dispatch on every render. Intentionally undefined (not a boolean)
|
|
200
|
+
// so the first render always reports the real validity rather than assuming a starting value.
|
|
201
|
+
const lastReportedContentEmptyRef = useRef(undefined);
|
|
197
202
|
|
|
198
203
|
// Custom hooks
|
|
199
204
|
const { updateCharacterCount } = useCharacterCount(formatMessage, messages);
|
|
@@ -566,6 +571,24 @@ const WebPushCreate = ({
|
|
|
566
571
|
});
|
|
567
572
|
}, [isFullMode, templateName]);
|
|
568
573
|
|
|
574
|
+
// Reports live content validity to the parent (CreativesContainer) so it can disable the
|
|
575
|
+
// Done/Preview-and-test buttons as soon as the notification title or message is cleared.
|
|
576
|
+
// Both title and message are required fields for WebPush (see isSaveDisabled below), so the
|
|
577
|
+
// content is considered empty if either is blank/whitespace-only. Mirrors the Sms Create/Edit
|
|
578
|
+
// componentDidUpdate pattern — the ref-based dedup guard prevents an infinite render loop
|
|
579
|
+
// (parent setState -> re-render -> effect re-fires -> parent setState -> ...).
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (typeof onContentValidityChange !== 'function') {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const isContentEmpty = !notificationTitle.trim() || !message.trim();
|
|
585
|
+
if (lastReportedContentEmptyRef.current === isContentEmpty) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
lastReportedContentEmptyRef.current = isContentEmpty;
|
|
589
|
+
onContentValidityChange({ isContentEmpty });
|
|
590
|
+
}, [notificationTitle, message, onContentValidityChange]);
|
|
591
|
+
|
|
569
592
|
// Pure validator that returns boolean without setting error state
|
|
570
593
|
const validateFormSilent = () => {
|
|
571
594
|
const templateNameInvalid = isFullMode && validateTemplateName(templateName);
|
|
@@ -1175,6 +1198,7 @@ WebPushCreate.propTypes = {
|
|
|
1175
1198
|
waitEventContextTags: PropTypes.object,
|
|
1176
1199
|
templateActions: PropTypes.object,
|
|
1177
1200
|
restrictPersonalization: PropTypes.bool,
|
|
1201
|
+
onContentValidityChange: PropTypes.func,
|
|
1178
1202
|
};
|
|
1179
1203
|
|
|
1180
1204
|
WebPushCreate.defaultProps = {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the onContentValidityChange reporting wired into WebPushCreate.
|
|
3
|
+
*
|
|
4
|
+
* WebPushCreate is a functional component (not a class like Sms/Create and Sms/Edit), so the
|
|
5
|
+
* dedup pattern is implemented as a useEffect + useRef instead of componentDidUpdate + an
|
|
6
|
+
* instance field, but the contract with the parent (CreativesContainer) is identical:
|
|
7
|
+
* onContentValidityChange({ isContentEmpty }) is called only when isContentEmpty actually
|
|
8
|
+
* changes since the last report, with title AND message both required for WebPush.
|
|
9
|
+
*
|
|
10
|
+
* Note: all relative mock paths are resolved from THIS test file's location
|
|
11
|
+
* (app/v2Containers/WebPush/Create/tests/), not from the component — mirrors
|
|
12
|
+
* testAndPreviewIntegration.test.js in this same directory.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
17
|
+
import '@testing-library/jest-dom';
|
|
18
|
+
import { IntlProvider } from 'react-intl';
|
|
19
|
+
|
|
20
|
+
// ── Infrastructure / HOC mocks ──────────────────────────────────────────────
|
|
21
|
+
jest.mock('../../../../hoc/withCreatives', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: ({ WrappedComponent }) => WrappedComponent,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock('@capillarytech/vulcan-react-sdk/utils', () => ({
|
|
27
|
+
injectReducer: () => (Component) => Component,
|
|
28
|
+
injectSaga: () => (Component) => Component,
|
|
29
|
+
}));
|
|
30
|
+
jest.mock('@capillarytech/vulcan-react-sdk/utils/sagaInjectorTypes', () => ({
|
|
31
|
+
DAEMON: 'DAEMON',
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// ── Redux slices (paths relative to this test file) ─────────────────────────
|
|
35
|
+
jest.mock('../../../Templates/selectors', () => ({
|
|
36
|
+
makeSelectTemplates: () => () => {},
|
|
37
|
+
}));
|
|
38
|
+
jest.mock('../../../Templates/actions', () => ({}));
|
|
39
|
+
jest.mock('../../selectors', () => ({
|
|
40
|
+
makeSelectWebPush: () => () => ({}),
|
|
41
|
+
makeSelectCreateError: () => () => null,
|
|
42
|
+
makeSelectCreateTemplateInProgress: () => () => false,
|
|
43
|
+
makeSelectEditTemplateInProgress: () => () => false,
|
|
44
|
+
makeSelectEditError: () => () => null,
|
|
45
|
+
}));
|
|
46
|
+
jest.mock('../../actions', () => ({}));
|
|
47
|
+
jest.mock('../../reducer', () => () => ({}));
|
|
48
|
+
jest.mock('../../sagas', () => ({}));
|
|
49
|
+
jest.mock('../../../Templates/sagas', () => ({ v2TemplateSaga: {} }));
|
|
50
|
+
jest.mock('../../../Cap/selectors', () => ({
|
|
51
|
+
makeSelectMetaEntities: () => () => [],
|
|
52
|
+
setInjectedTags: () => () => [],
|
|
53
|
+
}));
|
|
54
|
+
jest.mock('../../../CreativesContainer/constants', () => ({
|
|
55
|
+
WEBPUSH: 'WEBPUSH',
|
|
56
|
+
}));
|
|
57
|
+
jest.mock('../../../CreativesContainer/messages', () => ({
|
|
58
|
+
testAndPreview: { id: 'testAndPreview', defaultMessage: 'Test & Preview' },
|
|
59
|
+
}));
|
|
60
|
+
jest.mock('../../../../utils/common', () => ({
|
|
61
|
+
isAiContentBotDisabled: () => false,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// ── Form section components ──────────────────────────────────────────────────
|
|
65
|
+
// TemplateNameSection stays a stub — it is only relevant in isFullMode, which these tests
|
|
66
|
+
// keep off (mirrors baseProps below) to focus purely on title/message emptiness.
|
|
67
|
+
jest.mock('../components/TemplateNameSection', () => ({
|
|
68
|
+
__esModule: true,
|
|
69
|
+
default: () => <div data-testid="template-name-section" />,
|
|
70
|
+
}));
|
|
71
|
+
// NotificationTitleSection and MessageSection are given lightweight controlled-input stand-ins
|
|
72
|
+
// so tests can drive real onChange handlers (handleNotificationTitleChange / handleMessageChange)
|
|
73
|
+
// inside WebPushCreate, exercising the actual state that feeds the isContentEmpty computation.
|
|
74
|
+
jest.mock('../components/NotificationTitleSection', () => ({
|
|
75
|
+
__esModule: true,
|
|
76
|
+
default: ({ value, onChange }) => (
|
|
77
|
+
<input data-testid="notification-title-input" value={value} onChange={onChange} />
|
|
78
|
+
),
|
|
79
|
+
}));
|
|
80
|
+
jest.mock('../components/MessageSection', () => ({
|
|
81
|
+
__esModule: true,
|
|
82
|
+
default: ({ value, onChange }) => (
|
|
83
|
+
<textarea data-testid="message-input" value={value} onChange={onChange} />
|
|
84
|
+
),
|
|
85
|
+
}));
|
|
86
|
+
jest.mock('../components/MediaSection', () => ({
|
|
87
|
+
__esModule: true,
|
|
88
|
+
default: () => <div data-testid="media-section" />,
|
|
89
|
+
}));
|
|
90
|
+
jest.mock('../components/BrandIconSection', () => ({
|
|
91
|
+
__esModule: true,
|
|
92
|
+
default: () => <div data-testid="brand-icon-section" />,
|
|
93
|
+
}));
|
|
94
|
+
jest.mock('../components/ButtonsLinksSection', () => ({
|
|
95
|
+
__esModule: true,
|
|
96
|
+
default: () => <div data-testid="buttons-links-section" />,
|
|
97
|
+
}));
|
|
98
|
+
jest.mock('../components/FormActions', () => ({
|
|
99
|
+
__esModule: true,
|
|
100
|
+
default: () => <div data-testid="form-actions" />,
|
|
101
|
+
}));
|
|
102
|
+
jest.mock('../preview/WebPushPreview', () => ({
|
|
103
|
+
__esModule: true,
|
|
104
|
+
default: () => <div data-testid="webpush-preview" />,
|
|
105
|
+
}));
|
|
106
|
+
jest.mock('../../../TagList', () => ({
|
|
107
|
+
__esModule: true,
|
|
108
|
+
default: () => <div data-testid="tag-list" />,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
112
|
+
jest.mock('../hooks/useCharacterCount', () => ({
|
|
113
|
+
useCharacterCount: () => ({ updateCharacterCount: jest.fn() }),
|
|
114
|
+
}));
|
|
115
|
+
jest.mock('../hooks/useButtonManagement', () => ({
|
|
116
|
+
useButtonManagement: () => ({ buttons: [], setButtons: jest.fn(), isAddingButton: false }),
|
|
117
|
+
}));
|
|
118
|
+
jest.mock('../hooks/useImageUpload', () => ({
|
|
119
|
+
useImageUpload: () => ({
|
|
120
|
+
imageSrc: '',
|
|
121
|
+
imageUrl: '',
|
|
122
|
+
isValidating: false,
|
|
123
|
+
isUploading: false,
|
|
124
|
+
setImageSrc: jest.fn(),
|
|
125
|
+
setImageUrl: jest.fn(),
|
|
126
|
+
uploadMethod: 'UPLOAD_IMAGE',
|
|
127
|
+
}),
|
|
128
|
+
}));
|
|
129
|
+
jest.mock('../hooks/useTagManagement', () => ({
|
|
130
|
+
useTagManagement: () => ({ tags: [], extractTags: jest.fn(), handleOnTagsContextChange: jest.fn(), validationConfig: {} }),
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
jest.mock('../../../../v2Components/TestAndPreviewSlidebox', () => ({
|
|
134
|
+
__esModule: true,
|
|
135
|
+
default: () => <div data-testid="test-and-preview-slidebox" />,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
// ── CapUI library ─────────────────────────────────────────────────────────────
|
|
139
|
+
jest.mock('@capillarytech/cap-ui-library/CapRow', () => ({
|
|
140
|
+
__esModule: true,
|
|
141
|
+
default: ({ children, className }) => <div className={className}>{children}</div>,
|
|
142
|
+
}));
|
|
143
|
+
jest.mock('@capillarytech/cap-ui-library/CapColumn', () => ({
|
|
144
|
+
__esModule: true,
|
|
145
|
+
default: ({ children, className }) => <div className={className}>{children}</div>,
|
|
146
|
+
}));
|
|
147
|
+
jest.mock('@capillarytech/cap-ui-library/CapButton', () => ({
|
|
148
|
+
__esModule: true,
|
|
149
|
+
default: ({ children, onClick, className, type }) => (
|
|
150
|
+
<button className={className} onClick={onClick} data-type={type}>
|
|
151
|
+
{children}
|
|
152
|
+
</button>
|
|
153
|
+
),
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
// ── Import component AFTER mocks ─────────────────────────────────────────────
|
|
157
|
+
// eslint-disable-next-line import/first
|
|
158
|
+
import WebPushCreateDefault from '../index';
|
|
159
|
+
|
|
160
|
+
const baseProps = () => ({
|
|
161
|
+
isFullMode: false,
|
|
162
|
+
handleClose: jest.fn(),
|
|
163
|
+
webPushActions: {
|
|
164
|
+
createTemplate: jest.fn(),
|
|
165
|
+
editTemplate: jest.fn(),
|
|
166
|
+
uploadImage: jest.fn(),
|
|
167
|
+
clearCreateError: jest.fn(),
|
|
168
|
+
clearEditError: jest.fn(),
|
|
169
|
+
clearCreateResponse: jest.fn(),
|
|
170
|
+
clearEditResponse: jest.fn(),
|
|
171
|
+
setSelectedAccount: jest.fn(),
|
|
172
|
+
clearState: jest.fn(),
|
|
173
|
+
},
|
|
174
|
+
createTemplateInProgress: false,
|
|
175
|
+
createTemplateError: null,
|
|
176
|
+
editTemplateInProgress: false,
|
|
177
|
+
editTemplateError: null,
|
|
178
|
+
accountData: null,
|
|
179
|
+
webPush: {},
|
|
180
|
+
onCreateComplete: jest.fn(),
|
|
181
|
+
getFormData: jest.fn(),
|
|
182
|
+
isGetFormData: false,
|
|
183
|
+
templateData: null,
|
|
184
|
+
creativesMode: 'create',
|
|
185
|
+
params: {},
|
|
186
|
+
globalActions: { getMetaEntities: jest.fn() },
|
|
187
|
+
location: { pathname: '/webpush', query: {}, search: '', state: {} },
|
|
188
|
+
metaEntities: [],
|
|
189
|
+
injectedTags: [],
|
|
190
|
+
getDefaultTags: jest.fn(),
|
|
191
|
+
supportedTags: [],
|
|
192
|
+
forwardedTags: [],
|
|
193
|
+
selectedOfferDetails: [],
|
|
194
|
+
eventContextTags: [],
|
|
195
|
+
waitEventContextTags: {},
|
|
196
|
+
templateActions: {},
|
|
197
|
+
Templates: { templates: [] },
|
|
198
|
+
restrictPersonalization: false,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const TestWrapper = ({ children }) => (
|
|
202
|
+
<IntlProvider locale="en" messages={{ testAndPreview: 'Test & Preview' }}>
|
|
203
|
+
{children}
|
|
204
|
+
</IntlProvider>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
describe('WebPushCreate — onContentValidityChange reporting', () => {
|
|
208
|
+
it('reports isContentEmpty: true on mount when title and message are both empty', () => {
|
|
209
|
+
const onContentValidityChange = jest.fn();
|
|
210
|
+
render(
|
|
211
|
+
<TestWrapper>
|
|
212
|
+
<WebPushCreateDefault {...baseProps()} onContentValidityChange={onContentValidityChange} />
|
|
213
|
+
</TestWrapper>,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reports isContentEmpty: false once both title and message have content', () => {
|
|
220
|
+
const onContentValidityChange = jest.fn();
|
|
221
|
+
render(
|
|
222
|
+
<TestWrapper>
|
|
223
|
+
<WebPushCreateDefault {...baseProps()} onContentValidityChange={onContentValidityChange} />
|
|
224
|
+
</TestWrapper>,
|
|
225
|
+
);
|
|
226
|
+
onContentValidityChange.mockClear();
|
|
227
|
+
|
|
228
|
+
fireEvent.change(screen.getByTestId('notification-title-input'), { target: { value: 'Hello' } });
|
|
229
|
+
fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'World' } });
|
|
230
|
+
|
|
231
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: false });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not re-report (still empty) if only the title is filled in while message stays empty', () => {
|
|
235
|
+
const onContentValidityChange = jest.fn();
|
|
236
|
+
render(
|
|
237
|
+
<TestWrapper>
|
|
238
|
+
<WebPushCreateDefault {...baseProps()} onContentValidityChange={onContentValidityChange} />
|
|
239
|
+
</TestWrapper>,
|
|
240
|
+
);
|
|
241
|
+
// Mount already reported isContentEmpty: true (both fields blank).
|
|
242
|
+
onContentValidityChange.mockClear();
|
|
243
|
+
|
|
244
|
+
fireEvent.change(screen.getByTestId('notification-title-input'), { target: { value: 'Hello' } });
|
|
245
|
+
|
|
246
|
+
// isContentEmpty is still true (message is required too and remains blank), so the dedup
|
|
247
|
+
// guard must suppress a duplicate report.
|
|
248
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('re-reports isContentEmpty: true when a previously-filled message is cleared out', () => {
|
|
252
|
+
const onContentValidityChange = jest.fn();
|
|
253
|
+
render(
|
|
254
|
+
<TestWrapper>
|
|
255
|
+
<WebPushCreateDefault {...baseProps()} onContentValidityChange={onContentValidityChange} />
|
|
256
|
+
</TestWrapper>,
|
|
257
|
+
);
|
|
258
|
+
fireEvent.change(screen.getByTestId('notification-title-input'), { target: { value: 'Hello' } });
|
|
259
|
+
fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'World' } });
|
|
260
|
+
onContentValidityChange.mockClear();
|
|
261
|
+
|
|
262
|
+
fireEvent.change(screen.getByTestId('message-input'), { target: { value: '' } });
|
|
263
|
+
|
|
264
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not throw and does not report when onContentValidityChange is not provided', () => {
|
|
268
|
+
expect(() => {
|
|
269
|
+
render(
|
|
270
|
+
<TestWrapper>
|
|
271
|
+
<WebPushCreateDefault {...baseProps()} />
|
|
272
|
+
</TestWrapper>,
|
|
273
|
+
);
|
|
274
|
+
fireEvent.change(screen.getByTestId('notification-title-input'), { target: { value: 'Hello' } });
|
|
275
|
+
}).not.toThrow();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does not dispatch again on a re-render that does not change isContentEmpty (dedup guard)', () => {
|
|
279
|
+
const onContentValidityChange = jest.fn();
|
|
280
|
+
render(
|
|
281
|
+
<TestWrapper>
|
|
282
|
+
<WebPushCreateDefault {...baseProps()} onContentValidityChange={onContentValidityChange} />
|
|
283
|
+
</TestWrapper>,
|
|
284
|
+
);
|
|
285
|
+
fireEvent.change(screen.getByTestId('notification-title-input'), { target: { value: 'Hello' } });
|
|
286
|
+
fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'World' } });
|
|
287
|
+
onContentValidityChange.mockClear();
|
|
288
|
+
|
|
289
|
+
// Further edits that keep both fields non-empty must not re-trigger the callback.
|
|
290
|
+
fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'World!' } });
|
|
291
|
+
|
|
292
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-boolean-value */
|
|
2
2
|
/* eslint-disable camelcase */
|
|
3
3
|
/* eslint-disable no-undef */
|
|
4
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
5
5
|
import { bindActionCreators } from 'redux';
|
|
6
6
|
import { createStructuredSelector } from 'reselect';
|
|
7
7
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
@@ -188,6 +188,7 @@ export const Whatsapp = (props) => {
|
|
|
188
188
|
handleTestAndPreview: propsHandleTestAndPreview,
|
|
189
189
|
handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
|
|
190
190
|
isTestAndPreviewMode: propsIsTestAndPreviewMode,
|
|
191
|
+
onContentValidityChange,
|
|
191
192
|
} = props || {};
|
|
192
193
|
const { formatMessage } = intl;
|
|
193
194
|
const { TextArea } = CapInput;
|
|
@@ -504,9 +505,7 @@ export const Whatsapp = (props) => {
|
|
|
504
505
|
if (templateHeaderArray?.length !== 0) {
|
|
505
506
|
let clonedVarMap = {};
|
|
506
507
|
if (!isEmpty(varMap)) {
|
|
507
|
-
// CCS
|
|
508
|
-
// rather than this UI's own `${token}_${index}` slot keys — reconcile so
|
|
509
|
-
// values land on the right segment instead of silently missing.
|
|
508
|
+
// Reconcile CCS sequential varMapped indices to the UI’s `${token}_${index}` slot keys so values map to the correct segments.
|
|
510
509
|
clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
|
|
511
510
|
} else {
|
|
512
511
|
templateHeaderArray?.forEach((headerValue, i) => {
|
|
@@ -706,6 +705,23 @@ export const Whatsapp = (props) => {
|
|
|
706
705
|
}
|
|
707
706
|
}, [carouselValidateTag, tags])
|
|
708
707
|
|
|
708
|
+
// Reports live "is content empty" validity to the parent slidebox so it can
|
|
709
|
+
// disable the Done/Preview-and-test buttons whenever the template message
|
|
710
|
+
// (the one field that is always required, regardless of template type) is
|
|
711
|
+
// cleared out. Mirrors the Sms Create/Edit componentDidUpdate pattern; the
|
|
712
|
+
// ref-based dedup avoids redundant parent setState calls / render loops.
|
|
713
|
+
const lastReportedTemplateMessageEmptyRef = useRef(undefined);
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
if (typeof onContentValidityChange !== 'function') {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const isContentEmpty = !templateMessage?.trim();
|
|
719
|
+
if (lastReportedTemplateMessageEmptyRef.current !== isContentEmpty) {
|
|
720
|
+
lastReportedTemplateMessageEmptyRef.current = isContentEmpty;
|
|
721
|
+
onContentValidityChange({ isContentEmpty });
|
|
722
|
+
}
|
|
723
|
+
}, [templateMessage, onContentValidityChange]);
|
|
724
|
+
|
|
709
725
|
const handleOnTagsContextChange = (data) => {
|
|
710
726
|
const { type } = location.query || {};
|
|
711
727
|
const isEmbedded = type === EMBEDDED;
|
|
@@ -1538,3 +1538,118 @@ describe('WhatsApp edit Done button whitespace validation', () => {
|
|
|
1538
1538
|
expect(getDoneButton().props().disabled).toBe(true);
|
|
1539
1539
|
});
|
|
1540
1540
|
});
|
|
1541
|
+
|
|
1542
|
+
describe('Whatsapp onContentValidityChange reporting', () => {
|
|
1543
|
+
let renderedComponent;
|
|
1544
|
+
|
|
1545
|
+
const createWhatsappTemplate = jest.fn();
|
|
1546
|
+
const getFormData = jest.fn();
|
|
1547
|
+
const clearCreateResponse = jest.fn();
|
|
1548
|
+
const getTemplateDetails = jest.fn();
|
|
1549
|
+
const resetEditTemplate = jest.fn();
|
|
1550
|
+
const fetchSchemaForEntity = jest.fn();
|
|
1551
|
+
const handleClose = jest.fn();
|
|
1552
|
+
const onCreateComplete = jest.fn();
|
|
1553
|
+
const formatMessage = jest.fn();
|
|
1554
|
+
const getMetaTags = jest.fn();
|
|
1555
|
+
const resetMetaTags = jest.fn();
|
|
1556
|
+
|
|
1557
|
+
const mountCreateFlow = (onContentValidityChange) => {
|
|
1558
|
+
renderedComponent = mountWithIntl(
|
|
1559
|
+
<Provider store={store}>
|
|
1560
|
+
<Whatsapp
|
|
1561
|
+
actions={{
|
|
1562
|
+
createWhatsappTemplate,
|
|
1563
|
+
clearCreateResponse,
|
|
1564
|
+
getTemplateDetails,
|
|
1565
|
+
resetEditTemplate,
|
|
1566
|
+
getMetaTags,
|
|
1567
|
+
resetMetaTags,
|
|
1568
|
+
}}
|
|
1569
|
+
globalActions={{ fetchSchemaForEntity }}
|
|
1570
|
+
onCreateComplete={onCreateComplete}
|
|
1571
|
+
handleClose={handleClose}
|
|
1572
|
+
getFormData={getFormData}
|
|
1573
|
+
params={{}}
|
|
1574
|
+
editData={{}}
|
|
1575
|
+
templateData={{}}
|
|
1576
|
+
accountData={mockData.accountData1}
|
|
1577
|
+
location={mockData.location}
|
|
1578
|
+
intl={{ formatMessage }}
|
|
1579
|
+
isFullMode={true}
|
|
1580
|
+
loadingTags={false}
|
|
1581
|
+
metaEntities={[]}
|
|
1582
|
+
getDefaultTags={true}
|
|
1583
|
+
onContentValidityChange={onContentValidityChange}
|
|
1584
|
+
Templates={{
|
|
1585
|
+
senderDetails: {
|
|
1586
|
+
status: 'SUCCESS',
|
|
1587
|
+
domainProperties: [
|
|
1588
|
+
{
|
|
1589
|
+
domainProperties: {
|
|
1590
|
+
connectionProperties: {
|
|
1591
|
+
sourceAccountIdentifier: '2000222347',
|
|
1592
|
+
baseUrl: 'https://api.gupshup.io/whatsapp/v1',
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
},
|
|
1596
|
+
],
|
|
1597
|
+
},
|
|
1598
|
+
}}
|
|
1599
|
+
/>
|
|
1600
|
+
</Provider>,
|
|
1601
|
+
);
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
const changeTemplateMessage = (val) => {
|
|
1605
|
+
renderedComponent
|
|
1606
|
+
.find('textarea#whatsapp-create-template-message-input')
|
|
1607
|
+
.at(0)
|
|
1608
|
+
.simulate('change', { target: { value: val } });
|
|
1609
|
+
renderedComponent.update();
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
it('reports isContentEmpty: true on mount when the message body is empty', () => {
|
|
1613
|
+
const onContentValidityChange = jest.fn();
|
|
1614
|
+
mountCreateFlow(onContentValidityChange);
|
|
1615
|
+
|
|
1616
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it('reports isContentEmpty: false once the message body has content', () => {
|
|
1620
|
+
const onContentValidityChange = jest.fn();
|
|
1621
|
+
mountCreateFlow(onContentValidityChange);
|
|
1622
|
+
onContentValidityChange.mockClear();
|
|
1623
|
+
|
|
1624
|
+
changeTemplateMessage('Hi there');
|
|
1625
|
+
|
|
1626
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: false });
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it('re-reports empty when the message body is cleared out', () => {
|
|
1630
|
+
const onContentValidityChange = jest.fn();
|
|
1631
|
+
mountCreateFlow(onContentValidityChange);
|
|
1632
|
+
changeTemplateMessage('Hi there');
|
|
1633
|
+
onContentValidityChange.mockClear();
|
|
1634
|
+
|
|
1635
|
+
changeTemplateMessage('');
|
|
1636
|
+
|
|
1637
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
it('does not throw and does not report when onContentValidityChange is not provided', () => {
|
|
1641
|
+
expect(() => mountCreateFlow(undefined)).not.toThrow();
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it('does not report again on a re-render that does not change validity (dedup guard)', () => {
|
|
1645
|
+
const onContentValidityChange = jest.fn();
|
|
1646
|
+
mountCreateFlow(onContentValidityChange);
|
|
1647
|
+
changeTemplateMessage('Hi there');
|
|
1648
|
+
onContentValidityChange.mockClear();
|
|
1649
|
+
|
|
1650
|
+
// Same non-empty value re-set — validity boolean is unchanged.
|
|
1651
|
+
changeTemplateMessage('Hi there again');
|
|
1652
|
+
|
|
1653
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable camelcase */
|
|
2
2
|
/* eslint-disable react/jsx-indent */
|
|
3
|
-
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
4
4
|
import { bindActionCreators } from 'redux';
|
|
5
5
|
import { createStructuredSelector } from 'reselect';
|
|
6
6
|
import { FormattedMessage } from 'react-intl';
|
|
@@ -82,6 +82,7 @@ export const Zalo = (props) => {
|
|
|
82
82
|
showTestAndPreviewSlidebox: propsShowTestAndPreviewSlidebox,
|
|
83
83
|
handleTestAndPreview: propsHandleTestAndPreview,
|
|
84
84
|
handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
|
|
85
|
+
onContentValidityChange,
|
|
85
86
|
} = props || {};
|
|
86
87
|
// Since zalo supports now multiple accounts, we need to get the hostname from the selected account or passed props.
|
|
87
88
|
const hostName = selectedZaloAccount?.hostName || zaloHostName;
|
|
@@ -368,6 +369,32 @@ export const Zalo = (props) => {
|
|
|
368
369
|
});
|
|
369
370
|
return disableCheck;
|
|
370
371
|
};
|
|
372
|
+
|
|
373
|
+
// Content is considered empty when every variable field's value is blank
|
|
374
|
+
// (i.e. the Zalo message body carries no text and no injected tags).
|
|
375
|
+
const isZaloContentEmpty = (listParams) => {
|
|
376
|
+
if (!Array.isArray(listParams) || listParams.length === 0) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
return listParams.every((listParam) => !(listParam?.value || '').trim());
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Tracks the last validity value reported to the parent so this effect does not
|
|
383
|
+
// dispatch on every render. Intentionally undefined (not true) so the first render
|
|
384
|
+
// always reports the real validity rather than assuming the form starts invalid.
|
|
385
|
+
const lastReportedZaloContentEmptyRef = useRef(undefined);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (typeof onContentValidityChange !== 'function') {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const isContentEmpty = isZaloContentEmpty(templateListParams);
|
|
391
|
+
if (lastReportedZaloContentEmptyRef.current === isContentEmpty) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
lastReportedZaloContentEmptyRef.current = isContentEmpty;
|
|
395
|
+
onContentValidityChange({ isContentEmpty });
|
|
396
|
+
}, [templateListParams, onContentValidityChange]);
|
|
397
|
+
|
|
371
398
|
const createPayload = () => {
|
|
372
399
|
const varMap = {};
|
|
373
400
|
templateListParams?.forEach((listParam) => {
|