@capillarytech/creatives-library 9.0.56-alpha.7 → 9.0.56-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/v2Components/CapActionButton/index.js +73 -60
- package/v2Components/CapActionButton/index.scss +44 -28
- package/v2Components/CapActionButton/messages.js +7 -3
- package/v2Components/CapActionButton/tests/index.test.js +17 -1
- package/v2Components/CapWhatsappCTA/messages.js +4 -0
- package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
- package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -1
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +3 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
- package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
- package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
- package/v2Containers/CreativesContainer/index.js +27 -0
- package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
- package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
- package/v2Containers/MobilePush/Create/index.js +18 -1
- package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
- package/v2Containers/MobilePush/Edit/index.js +18 -1
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
- package/v2Containers/MobilePush/commonMethods.js +49 -1
- package/v2Containers/MobilePushNew/index.js +29 -4
- package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
- package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
- package/v2Containers/MobilePushNew/utils.js +34 -1
- package/v2Containers/MobilepushWrapper/index.js +3 -1
- package/v2Containers/Rcs/index.js +34 -0
- package/v2Containers/Rcs/index.scss +15 -0
- package/v2Containers/Rcs/tests/index.test.js +67 -0
- package/v2Containers/Sms/Create/index.js +3 -1
- package/v2Containers/Sms/Edit/index.js +25 -0
- package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
- package/v2Containers/Viber/index.js +24 -1
- package/v2Containers/Viber/tests/index.test.js +103 -0
- package/v2Containers/WebPush/Create/index.js +24 -0
- package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
- package/v2Containers/Whatsapp/index.js +19 -1
- package/v2Containers/Whatsapp/tests/index.test.js +115 -0
- package/v2Containers/Zalo/index.js +28 -1
- package/v2Containers/Zalo/tests/index.test.js +99 -0
|
@@ -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;
|
|
@@ -704,6 +705,23 @@ export const Whatsapp = (props) => {
|
|
|
704
705
|
}
|
|
705
706
|
}, [carouselValidateTag, tags])
|
|
706
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
|
+
|
|
707
725
|
const handleOnTagsContextChange = (data) => {
|
|
708
726
|
const { type } = location.query || {};
|
|
709
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) => {
|
|
@@ -150,4 +150,103 @@ describe('Test activity zalo container', () => {
|
|
|
150
150
|
),
|
|
151
151
|
).toBeInTheDocument();
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
// Fully self-contained fixtures for the tests below: the "set template data" test above
|
|
155
|
+
// mutates the shared `editData`/`templateData` fixtures in place (handleSetValues mutates
|
|
156
|
+
// listParam objects directly, and that test also reassigns templateData.templateConfigs),
|
|
157
|
+
// so building independent literals here — rather than reusing or cloning the shared
|
|
158
|
+
// fixtures at test time (which would just clone the already-mutated state) — keeps these
|
|
159
|
+
// tests correct regardless of suite execution order.
|
|
160
|
+
const buildFreshEditData = () => ({
|
|
161
|
+
zaloTemplateInfoStatus: 'success',
|
|
162
|
+
zaloTemplateInfoValue: {
|
|
163
|
+
name: 'Fresh Template',
|
|
164
|
+
versions: {
|
|
165
|
+
base: {
|
|
166
|
+
content: {
|
|
167
|
+
zalo: {
|
|
168
|
+
templateId: '1',
|
|
169
|
+
templateName: 'Fresh Template',
|
|
170
|
+
listParams: [
|
|
171
|
+
{
|
|
172
|
+
name: 'danh_xung',
|
|
173
|
+
require: true,
|
|
174
|
+
type: 'STRING',
|
|
175
|
+
maxLength: 30,
|
|
176
|
+
minLength: 0,
|
|
177
|
+
acceptNull: false,
|
|
178
|
+
error: '',
|
|
179
|
+
value: '',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
previewUrl: 'https://account.zalo.cloud/preview',
|
|
183
|
+
status: 'ENABLE',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
type: 'ZALO',
|
|
189
|
+
},
|
|
190
|
+
zaloTemplateInfoError: null,
|
|
191
|
+
});
|
|
192
|
+
const buildFreshTemplateData = () => ({
|
|
193
|
+
_id: 999001,
|
|
194
|
+
templateConfigs: { id: '', varMapped: {} },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('reports content emptiness to the parent via onContentValidityChange', async () => {
|
|
198
|
+
const onContentValidityChange = jest.fn();
|
|
199
|
+
renderComponent({
|
|
200
|
+
actions: mockActions,
|
|
201
|
+
globalActions: mockGlobalActions,
|
|
202
|
+
editData: buildFreshEditData(),
|
|
203
|
+
metaEntities,
|
|
204
|
+
location,
|
|
205
|
+
getDefaultTags,
|
|
206
|
+
injectedTags,
|
|
207
|
+
accountData,
|
|
208
|
+
templateData: buildFreshTemplateData(),
|
|
209
|
+
isFullMode: true,
|
|
210
|
+
onContentValidityChange,
|
|
211
|
+
});
|
|
212
|
+
const inputBox = await screen.findAllByPlaceholderText(
|
|
213
|
+
'Add labels or text or combination of both',
|
|
214
|
+
);
|
|
215
|
+
// Initial values in the mock data are blank, so the first report should be "empty".
|
|
216
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
217
|
+
onContentValidityChange.mockClear();
|
|
218
|
+
|
|
219
|
+
fireEvent.change(inputBox[0], { target: { value: 'Hello, welcome' } });
|
|
220
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: false });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('does not re-report validity when it has not changed (dedup guard)', async () => {
|
|
224
|
+
const onContentValidityChange = jest.fn();
|
|
225
|
+
renderComponent({
|
|
226
|
+
actions: mockActions,
|
|
227
|
+
globalActions: mockGlobalActions,
|
|
228
|
+
editData: buildFreshEditData(),
|
|
229
|
+
metaEntities,
|
|
230
|
+
location,
|
|
231
|
+
getDefaultTags,
|
|
232
|
+
injectedTags,
|
|
233
|
+
accountData,
|
|
234
|
+
templateData: buildFreshTemplateData(),
|
|
235
|
+
isFullMode: true,
|
|
236
|
+
onContentValidityChange,
|
|
237
|
+
});
|
|
238
|
+
const inputBox = await screen.findAllByPlaceholderText(
|
|
239
|
+
'Add labels or text or combination of both',
|
|
240
|
+
);
|
|
241
|
+
onContentValidityChange.mockClear();
|
|
242
|
+
|
|
243
|
+
fireEvent.change(inputBox[0], { target: { value: 'Hello' } });
|
|
244
|
+
expect(onContentValidityChange).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: false });
|
|
246
|
+
onContentValidityChange.mockClear();
|
|
247
|
+
|
|
248
|
+
// Changing to another non-empty value keeps isContentEmpty === false, so no new report.
|
|
249
|
+
fireEvent.change(inputBox[0], { target: { value: 'Hello again' } });
|
|
250
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
153
252
|
});
|