@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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/services/api.js +1 -1
  3. package/utils/templateVarUtils.js +1 -17
  4. package/v2Components/CapActionButton/index.js +73 -60
  5. package/v2Components/CapActionButton/index.scss +44 -28
  6. package/v2Components/CapActionButton/messages.js +7 -3
  7. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  8. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  9. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  10. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  11. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -2
  12. package/v2Components/CommonTestAndPreview/index.js +3 -6
  13. package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -0
  14. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -14
  15. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -2
  16. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +35 -3
  17. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
  18. package/v2Containers/CommunicationFlow/constants.js +1 -3
  19. package/v2Containers/CommunicationFlow/messages.js +4 -0
  20. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +5 -1
  21. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +64 -0
  22. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
  23. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
  24. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  25. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
  26. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +3 -3
  27. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  28. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  29. package/v2Containers/CreativesContainer/index.js +27 -0
  30. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  31. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
  32. package/v2Containers/MobilePush/Create/index.js +18 -1
  33. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  34. package/v2Containers/MobilePush/Edit/index.js +18 -1
  35. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  36. package/v2Containers/MobilePush/commonMethods.js +49 -1
  37. package/v2Containers/MobilePushNew/index.js +29 -4
  38. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  39. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  40. package/v2Containers/MobilePushNew/utils.js +34 -1
  41. package/v2Containers/MobilepushWrapper/index.js +3 -1
  42. package/v2Containers/Rcs/index.js +34 -0
  43. package/v2Containers/Rcs/index.scss +15 -0
  44. package/v2Containers/Rcs/tests/index.test.js +67 -0
  45. package/v2Containers/Sms/Create/index.js +3 -1
  46. package/v2Containers/Sms/Edit/index.js +25 -0
  47. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  48. package/v2Containers/Viber/index.js +24 -1
  49. package/v2Containers/Viber/tests/index.test.js +103 -0
  50. package/v2Containers/WebPush/Create/index.js +24 -0
  51. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  52. package/v2Containers/Whatsapp/index.js +20 -4
  53. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  54. package/v2Containers/Zalo/index.js +28 -1
  55. 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's varMapped can use plain sequential occurrence indices ("0","1",...)
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) => {