@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
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { injectIntl } from 'react-intl';
3
+ import '@testing-library/jest-dom';
4
+ import { Provider } from 'react-redux';
5
+ import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
6
+ import history from '../../../utils/history';
7
+ import { initialReducer } from '../../../initialReducer';
8
+ import { MobilePushNew } from '..';
9
+ import { render, screen, fireEvent } from '../../../utils/test-utils';
10
+
11
+ jest.mock('../../TagList/index.js', () => ({
12
+ __esModule: true,
13
+ default: (props) => (
14
+ <div className="tag-mock" {...props}>
15
+ TagList
16
+ </div>
17
+ ),
18
+ }));
19
+
20
+ let store;
21
+ beforeAll(() => {
22
+ store = configureStore({}, initialReducer, history);
23
+ });
24
+
25
+ const ComponentToRender = injectIntl(MobilePushNew);
26
+ const renderComponent = (props) => render(
27
+ <Provider store={store}>
28
+ <ComponentToRender {...props} />
29
+ </Provider>,
30
+ );
31
+
32
+ // Only Android is marked supported so a single, unambiguous title/message
33
+ // input pair is rendered (avoids ambiguity from a second, iOS, tab/pane).
34
+ const accountData = {
35
+ configs: {
36
+ android: '1',
37
+ ios: '0',
38
+ },
39
+ };
40
+
41
+ const globalActions = {
42
+ fetchSchemaForEntity: jest.fn(),
43
+ };
44
+
45
+ describe('MobilePushNew', () => {
46
+ it('renders without crashing when onContentValidityChange is passed', async () => {
47
+ const onContentValidityChange = jest.fn();
48
+ renderComponent({
49
+ accountData,
50
+ templateData: {},
51
+ location: {},
52
+ injectedTags: {},
53
+ getDefaultTags: jest.fn(),
54
+ globalActions,
55
+ onContentValidityChange,
56
+ });
57
+
58
+ const titleInput = await screen.findByPlaceholderText('Enter title');
59
+ expect(titleInput).toBeInTheDocument();
60
+ });
61
+
62
+ it('reports content emptiness to the parent via onContentValidityChange', async () => {
63
+ const onContentValidityChange = jest.fn();
64
+ renderComponent({
65
+ accountData,
66
+ templateData: {},
67
+ location: {},
68
+ injectedTags: {},
69
+ getDefaultTags: jest.fn(),
70
+ globalActions,
71
+ onContentValidityChange,
72
+ });
73
+
74
+ const titleInput = await screen.findByPlaceholderText('Enter title');
75
+ const messageInput = await screen.findByPlaceholderText('Enter message');
76
+
77
+ // Both required Android fields start blank, so the first report should be "empty".
78
+ expect(onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
79
+ onContentValidityChange.mockClear();
80
+
81
+ fireEvent.change(titleInput, { target: { value: 'Hello title' } });
82
+ fireEvent.change(messageInput, { target: { value: 'Hello message' } });
83
+
84
+ expect(onContentValidityChange).toHaveBeenLastCalledWith({ isContentEmpty: false });
85
+ });
86
+
87
+ it('does not re-report validity when it has not changed (dedup guard)', async () => {
88
+ const onContentValidityChange = jest.fn();
89
+ renderComponent({
90
+ accountData,
91
+ templateData: {},
92
+ location: {},
93
+ injectedTags: {},
94
+ getDefaultTags: jest.fn(),
95
+ globalActions,
96
+ onContentValidityChange,
97
+ });
98
+
99
+ const titleInput = await screen.findByPlaceholderText('Enter title');
100
+ onContentValidityChange.mockClear();
101
+
102
+ // Still blank -> still empty -> should not report again.
103
+ fireEvent.change(titleInput, { target: { value: '' } });
104
+ expect(onContentValidityChange).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('does not call onContentValidityChange when the prop is not provided', async () => {
108
+ expect(() => renderComponent({
109
+ accountData,
110
+ templateData: {},
111
+ location: {},
112
+ injectedTags: {},
113
+ getDefaultTags: jest.fn(),
114
+ globalActions,
115
+ })).not.toThrow();
116
+
117
+ await screen.findByPlaceholderText('Enter title');
118
+ });
119
+ });
@@ -3,6 +3,8 @@ import {
3
3
  validateExternalLink,
4
4
  validateDeepLink,
5
5
  isDeepLink,
6
+ isPlatformFieldsMissing,
7
+ computeIsContentEmpty,
6
8
  } from "../utils";
7
9
  import { isUrl } from "../../Line/Container/Wrapper/utils";
8
10
 
@@ -396,6 +398,86 @@ describe("utils.js", () => {
396
398
  });
397
399
  });
398
400
 
401
+ describe("isPlatformFieldsMissing", () => {
402
+ it("returns false when the platform is not supported, regardless of content", () => {
403
+ expect(isPlatformFieldsMissing(false, { title: "", message: "" })).toBe(false);
404
+ expect(isPlatformFieldsMissing(undefined, undefined)).toBe(false);
405
+ });
406
+
407
+ it("returns true when supported and title is missing", () => {
408
+ expect(isPlatformFieldsMissing(true, { title: "", message: "hello" })).toBe(true);
409
+ expect(isPlatformFieldsMissing(true, { title: " ", message: "hello" })).toBe(true);
410
+ });
411
+
412
+ it("returns true when supported and message is missing", () => {
413
+ expect(isPlatformFieldsMissing(true, { title: "hello", message: "" })).toBe(true);
414
+ });
415
+
416
+ it("returns true when content is undefined and platform is supported", () => {
417
+ expect(isPlatformFieldsMissing(true, undefined)).toBe(true);
418
+ });
419
+
420
+ it("returns false when supported and both title and message are present", () => {
421
+ expect(isPlatformFieldsMissing(true, { title: "hello", message: "world" })).toBe(false);
422
+ });
423
+ });
424
+
425
+ describe("computeIsContentEmpty", () => {
426
+ it("returns false when neither platform is supported", () => {
427
+ expect(computeIsContentEmpty({
428
+ isAndroidSupported: false,
429
+ isIosSupported: false,
430
+ androidContent: {},
431
+ iosContent: {},
432
+ })).toBe(false);
433
+ });
434
+
435
+ it("returns true when the only supported platform (android) is missing fields", () => {
436
+ expect(computeIsContentEmpty({
437
+ isAndroidSupported: true,
438
+ isIosSupported: false,
439
+ androidContent: { title: "", message: "" },
440
+ iosContent: { title: "", message: "" },
441
+ })).toBe(true);
442
+ });
443
+
444
+ it("returns false when the only supported platform (android) has valid fields, even if iOS content is empty", () => {
445
+ expect(computeIsContentEmpty({
446
+ isAndroidSupported: true,
447
+ isIosSupported: false,
448
+ androidContent: { title: "t", message: "m" },
449
+ iosContent: { title: "", message: "" },
450
+ })).toBe(false);
451
+ });
452
+
453
+ it("returns true when the only supported platform (ios) is missing fields", () => {
454
+ expect(computeIsContentEmpty({
455
+ isAndroidSupported: false,
456
+ isIosSupported: true,
457
+ androidContent: { title: "", message: "" },
458
+ iosContent: { title: "", message: "" },
459
+ })).toBe(true);
460
+ });
461
+
462
+ it("returns true when both platforms are supported and one is missing fields", () => {
463
+ expect(computeIsContentEmpty({
464
+ isAndroidSupported: true,
465
+ isIosSupported: true,
466
+ androidContent: { title: "t", message: "m" },
467
+ iosContent: { title: "", message: "" },
468
+ })).toBe(true);
469
+ });
470
+
471
+ it("returns false when both platforms are supported and both have valid fields", () => {
472
+ expect(computeIsContentEmpty({
473
+ isAndroidSupported: true,
474
+ isIosSupported: true,
475
+ androidContent: { title: "t", message: "m" },
476
+ iosContent: { title: "t", message: "m" },
477
+ })).toBe(false);
478
+ });
479
+ });
480
+
399
481
  describe("Error handling", () => {
400
482
  it("should handle isUrl throwing an error", () => {
401
483
  isUrl.mockImplementation(() => {
@@ -81,4 +81,37 @@ export const validateDeepLink = (linkValue, formatMessage, messages) => {
81
81
  return null; // Empty deep link is valid (optional field)
82
82
  }
83
83
  return isDeepLink(linkValue.trim()) ? null : formatMessage(messages.invalidUrl);
84
- };
84
+ };
85
+
86
+ /**
87
+ * Determine whether the required title/message fields are missing for a
88
+ * supported platform (Android/iOS). Mirrors the Save-button gating logic so
89
+ * platforms that are not supported are never treated as "missing".
90
+ * @param {boolean} isSupported - Whether the platform is enabled/supported
91
+ * @param {Object} content - The platform content object ({ title, message })
92
+ * @returns {boolean} - True if the platform is supported and a required field is empty
93
+ */
94
+ export const isPlatformFieldsMissing = (isSupported, content) => (
95
+ !!isSupported && (!content?.title?.trim() || !content?.message?.trim())
96
+ );
97
+
98
+ /**
99
+ * Compute whether the Mobile Push content is empty/invalid for reporting to
100
+ * a parent component (e.g. via onContentValidityChange). Content is
101
+ * considered empty when any supported platform is missing required fields.
102
+ * @param {Object} params
103
+ * @param {boolean} params.isAndroidSupported
104
+ * @param {boolean} params.isIosSupported
105
+ * @param {Object} params.androidContent
106
+ * @param {Object} params.iosContent
107
+ * @returns {boolean}
108
+ */
109
+ export const computeIsContentEmpty = ({
110
+ isAndroidSupported,
111
+ isIosSupported,
112
+ androidContent,
113
+ iosContent,
114
+ }) => (
115
+ isPlatformFieldsMissing(isAndroidSupported, androidContent)
116
+ || isPlatformFieldsMissing(isIosSupported, iosContent)
117
+ );
@@ -72,7 +72,7 @@ export class MobilepushWrapper extends React.Component { // eslint-disable-line
72
72
  }
73
73
 
74
74
  render() {
75
- const {mobilePushCreateMode, step, getFormData, getLiquidTags, setIsLoadingContent, isGetFormData, query, isFullMode, showTemplateName, type, onValidationFail, onPreviewContentClicked, onTestContentClicked, templateData, eventContextTags = [], waitEventContextTags = {},showTestAndPreviewSlidebox, handleTestAndPreview, handleCloseTestAndPreview, restrictPersonalization, isAnonymousType, onPersonalizationTokensChange} = this.props;
75
+ const {mobilePushCreateMode, step, getFormData, getLiquidTags, setIsLoadingContent, isGetFormData, query, isFullMode, showTemplateName, type, onValidationFail, onPreviewContentClicked, onTestContentClicked, onContentValidityChange, templateData, eventContextTags = [], waitEventContextTags = {},showTestAndPreviewSlidebox, handleTestAndPreview, handleCloseTestAndPreview, restrictPersonalization, isAnonymousType, onPersonalizationTokensChange} = this.props;
76
76
  const {templateName} = this.state;
77
77
  const isShowMobilepushCreate = !isEmpty(mobilePushCreateMode);
78
78
  return (
@@ -118,6 +118,7 @@ export class MobilepushWrapper extends React.Component { // eslint-disable-line
118
118
  onValidationFail={onValidationFail}
119
119
  onPreviewContentClicked={onPreviewContentClicked}
120
120
  onTestContentClicked={onTestContentClicked}
121
+ onContentValidityChange={onContentValidityChange}
121
122
  templateData={templateData}
122
123
  hideTestAndPreviewBtn={this.props.hideTestAndPreviewBtn}
123
124
  eventContextTags={eventContextTags}
@@ -155,6 +156,7 @@ MobilepushWrapper.propTypes = {
155
156
  showTemplateName: PropTypes.func,
156
157
  type: PropTypes.string,
157
158
  onValidationFail: PropTypes.func,
159
+ onContentValidityChange: PropTypes.func,
158
160
  eventContextTags: PropTypes.array,
159
161
  waitEventContextTags: PropTypes.object,
160
162
  showLiquidErrorInFooter: PropTypes.func,
@@ -211,6 +211,7 @@ export const Rcs = (props) => {
211
211
  handleTestAndPreview: propsHandleTestAndPreview,
212
212
  handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
213
213
  handleClose,
214
+ onContentValidityChange,
214
215
  } = props || {};
215
216
  const { formatMessage } = intl;
216
217
  const { TextArea } = CapInput;
@@ -399,6 +400,39 @@ export const Rcs = (props) => {
399
400
  const isMediaTypeVideo = templateMediaType === RCS_MEDIA_TYPES.VIDEO;
400
401
  const isCarouselType = templateType === contentType.carousel;
401
402
 
403
+ /**
404
+ * Mirrors the "is card content missing" checks already used by isDisableDone()/isEditDisableDone()
405
+ * (title/description emptiness per media type; "any carousel card missing title or description" for
406
+ * carousel — same convention as isCarouselLibraryIncomplete()'s hasEmptyField check), but limited to
407
+ * just the message-content fields (not template name / buttons / CTA validation) so it reports pure
408
+ * content emptiness to the generic parent handler, the same way Sms's isMessageEmpty does not fold in
409
+ * template-name emptiness.
410
+ */
411
+ const isRcsCardContentEmpty = () => {
412
+ if (isCarouselType) {
413
+ return (carouselData || []).some((card) =>
414
+ ['title', 'description'].some((field) => !(card?.[field] || '').trim())
415
+ );
416
+ }
417
+ if (isMediaTypeText) {
418
+ return templateDesc.trim() === '';
419
+ }
420
+ return templateTitle.trim() === '' || templateDesc.trim() === '';
421
+ };
422
+
423
+ // Tracks the last validity value reported to the parent so this effect does not dispatch on every
424
+ // render. Intentionally undefined (not a boolean) so the first render always reports the real
425
+ // validity rather than assuming the form starts invalid — mirrors Sms/Edit's dedup guard.
426
+ const lastReportedRcsContentEmptyRef = useRef(undefined);
427
+ useEffect(() => {
428
+ if (typeof onContentValidityChange !== 'function') return;
429
+ const isContentEmpty = isRcsCardContentEmpty();
430
+ if (lastReportedRcsContentEmptyRef.current !== isContentEmpty) {
431
+ lastReportedRcsContentEmptyRef.current = isContentEmpty;
432
+ onContentValidityChange({ isContentEmpty });
433
+ }
434
+ }, [isCarouselType, isMediaTypeText, templateTitle, templateDesc, carouselData]);
435
+
402
436
  const clearCarouselCardMedia = (cardIndex, { clearImage = true, clearVideo = true, clearThumb = true } = {}) => {
403
437
  setCarouselData((prev = []) => {
404
438
  const updated = cloneDeep(prev);
@@ -287,9 +287,24 @@
287
287
  width: 100%;
288
288
  }
289
289
 
290
+ // These CapRows are flex containers, so without a definite width their
291
+ // children size to max-content and a long URL in a card widens the whole
292
+ // chain instead of truncating.
293
+ .rcs-carousel-section > .rcs-carousel-tab {
294
+ width: 100%;
295
+ min-width: 0;
296
+ }
297
+
290
298
  .rcs-carousel-tab {
291
299
  margin-top: 0.75rem;
292
300
 
301
+ // CapTab's outer wrapper has a hashed css-module class, so it can only be
302
+ // reached structurally; this row has just the one child.
303
+ > * {
304
+ width: 100%;
305
+ min-width: 0;
306
+ }
307
+
293
308
  // Tab list (.ant-tabs-nav-wrap) defaults to flex:auto and stretches to fill the row,
294
309
  // pushing tabBarExtraContent (the "+" add-card button) to the far right edge. Size it
295
310
  // to its own content instead so "+" sits right next to the last tab (Figma reference).
@@ -158,6 +158,7 @@ const renderHelper = (args) => {
158
158
  {...(args.omitGetDefaultTags ? {} : { getDefaultTags: true })}
159
159
  isDltEnabled={args.isDltEnabled || false}
160
160
  smsRegister={'DLT'}
161
+ onContentValidityChange={args.onContentValidityChange}
161
162
  />
162
163
  </Provider>,
163
164
  );
@@ -1923,6 +1924,72 @@ describe('Character Counting Functions', () => {
1923
1924
  });
1924
1925
  });
1925
1926
 
1927
+ describe('Rcs — onContentValidityChange reporting', () => {
1928
+ // Full-mode create with a fresh (non-hydrating) rcsData: templateDesc starts out empty,
1929
+ // same as the pre-existing "template title change" test's initial-state assumption.
1930
+ it('reports isContentEmpty: true on mount when the (default text_message) description is empty', () => {
1931
+ const onContentValidityChange = jest.fn();
1932
+ renderHelper({ onContentValidityChange });
1933
+
1934
+ expect(onContentValidityChange).toHaveBeenCalledWith(
1935
+ expect.objectContaining({ isContentEmpty: true }),
1936
+ );
1937
+ });
1938
+
1939
+ it('reports isContentEmpty: false once the description has content, then true again once cleared', () => {
1940
+ const onContentValidityChange = jest.fn();
1941
+ renderHelper({ onContentValidityChange });
1942
+ onContentValidityChange.mockClear();
1943
+
1944
+ const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
1945
+ act(() => {
1946
+ descTextArea.props().onChange({ target: { value: 'Hello there' } });
1947
+ });
1948
+ renderedComponent.update();
1949
+
1950
+ expect(onContentValidityChange).toHaveBeenCalledWith(
1951
+ expect.objectContaining({ isContentEmpty: false }),
1952
+ );
1953
+ onContentValidityChange.mockClear();
1954
+
1955
+ const descTextAreaAfter = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
1956
+ act(() => {
1957
+ descTextAreaAfter.props().onChange({ target: { value: ' ' } });
1958
+ });
1959
+ renderedComponent.update();
1960
+
1961
+ expect(onContentValidityChange).toHaveBeenCalledWith(
1962
+ expect.objectContaining({ isContentEmpty: true }),
1963
+ );
1964
+ });
1965
+
1966
+ it('does not throw and does not report when onContentValidityChange is not provided', () => {
1967
+ expect(() => {
1968
+ renderHelper({});
1969
+ const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
1970
+ act(() => {
1971
+ descTextArea.props().onChange({ target: { value: 'abc' } });
1972
+ });
1973
+ renderedComponent.update();
1974
+ }).not.toThrow();
1975
+ });
1976
+
1977
+ it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
1978
+ const onContentValidityChange = jest.fn();
1979
+ renderHelper({ onContentValidityChange });
1980
+ onContentValidityChange.mockClear();
1981
+
1982
+ const descTextArea = renderedComponent.find('CapInputTextArea#rcs_template_message_textarea').at(0);
1983
+ act(() => {
1984
+ // Still empty (still whitespace-only) — validity does not actually change.
1985
+ descTextArea.props().onChange({ target: { value: ' ' } });
1986
+ });
1987
+ renderedComponent.update();
1988
+
1989
+ expect(onContentValidityChange).not.toHaveBeenCalled();
1990
+ });
1991
+ });
1992
+
1926
1993
  describe('CapActionButton (mocked within RCS tests)', () => {
1927
1994
  it('should render the mocked CapActionButton placeholder', () => {
1928
1995
  const wrapper = mountWithIntl(
@@ -185,7 +185,9 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
185
185
  }
186
186
 
187
187
  componentDidUpdate() {
188
- if (!this.props.embeddedSmsFallback || typeof this.props.onEmbeddedSmsFooterValidity !== 'function') {
188
+ // Reporting itself is opt-in via the callback prop — not tied to embeddedSmsFallback,
189
+ // which is a separate, unrelated flag (tag-popover styling for the SMS-fallback surface).
190
+ if (typeof this.props.onEmbeddedSmsFooterValidity !== 'function') {
189
191
  return;
190
192
  }
191
193
  const validity = getSmsEmbeddedFooterValidity(this.state.formData, this.state.tabCount);
@@ -34,6 +34,7 @@ import v2EditSmsReducer from './reducer';
34
34
  import { v2SmsEditSagas } from './sagas';
35
35
  import * as globalActions from '../../Cap/actions';
36
36
  import TestAndPreviewSlidebox from '../../../v2Components/TestAndPreviewSlidebox';
37
+ import { getSmsEmbeddedFooterValidity } from '../smsFormDataHelpers';
37
38
 
38
39
  export class Edit extends React.Component { // eslint-disable-line react/prefer-stateless-function
39
40
  constructor(props) {
@@ -54,6 +55,11 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
54
55
  isTestAndPreviewMode: false,
55
56
  pendingGetFormData: false,
56
57
  };
58
+ // Tracks the last validity value reported to the parent so componentDidUpdate does not
59
+ // dispatch on every render. Intentionally undefined (not true) so the first render
60
+ // always reports the real validity rather than assuming the form starts invalid.
61
+ this._lastReportedSmsFooterTemplateNameEmpty = undefined;
62
+ this._lastReportedSmsFooterMessageEmpty = undefined;
57
63
  this.saveFormData = this.saveFormData.bind(this);
58
64
  this.onFormDataChange = this.onFormDataChange.bind(this);
59
65
  this.onTemplateNameChange = this.onTemplateNameChange.bind(this);
@@ -189,6 +195,24 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
189
195
  }
190
196
  }
191
197
 
198
+ componentDidUpdate() {
199
+ // Reports live form validity so the host (CreativesContainer) can disable Done/Preview
200
+ // and Test as soon as the message body is cleared — mirrors Sms/Create's own reporting.
201
+ if (typeof this.props.onEmbeddedSmsFooterValidity !== 'function') {
202
+ return;
203
+ }
204
+ const validity = getSmsEmbeddedFooterValidity(this.state.formData, this.state.tabCount);
205
+ const isTemplateNameEmpty = !!validity.isTemplateNameEmpty;
206
+ const isMessageEmpty = !!validity.isMessageEmpty;
207
+ if (
208
+ this._lastReportedSmsFooterTemplateNameEmpty === isTemplateNameEmpty &&
209
+ this._lastReportedSmsFooterMessageEmpty === isMessageEmpty
210
+ ) return;
211
+ this._lastReportedSmsFooterTemplateNameEmpty = isTemplateNameEmpty;
212
+ this._lastReportedSmsFooterMessageEmpty = isMessageEmpty;
213
+ this.props.onEmbeddedSmsFooterValidity(validity);
214
+ }
215
+
192
216
  componentWillUnmount() {
193
217
  if (this.pendingGetFormDataTimeout) {
194
218
  clearTimeout(this.pendingGetFormDataTimeout);
@@ -1138,6 +1162,7 @@ Edit.propTypes = {
1138
1162
  handleTestAndPreview: PropTypes.func,
1139
1163
  handleCloseTestAndPreview: PropTypes.func,
1140
1164
  isTestAndPreviewMode: PropTypes.bool,
1165
+ onEmbeddedSmsFooterValidity: PropTypes.func,
1141
1166
  };
1142
1167
 
1143
1168
  const mapStateToProps = createStructuredSelector({
@@ -0,0 +1,85 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Edit } from '../index';
4
+
5
+ // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
+ // only exercises the componentDidUpdate → onEmbeddedSmsFooterValidity wiring in isolation.
7
+
8
+ const baseProps = () => ({
9
+ actions: {
10
+ getTemplateDetails: jest.fn(),
11
+ setTemplateDetails: jest.fn(),
12
+ clearEditResponse: jest.fn(),
13
+ resetEditTemplate: jest.fn(),
14
+ editTemplate: jest.fn(),
15
+ },
16
+ globalActions: {
17
+ fetchSchemaForEntity: jest.fn(),
18
+ setInjectedTags: jest.fn(),
19
+ addMessageToQueue: jest.fn(),
20
+ },
21
+ Edit: {},
22
+ params: { id: 'tpl-1' },
23
+ location: { query: { type: 'embedded', module: 'library' } },
24
+ metaEntities: {},
25
+ intl: { formatMessage: (m) => m.defaultMessage || m.id },
26
+ isFullMode: false,
27
+ getFormSubscriptionData: jest.fn(),
28
+ });
29
+
30
+ describe('Sms/Edit — onEmbeddedSmsFooterValidity reporting', () => {
31
+ it('reports isMessageEmpty: true on mount when the message body is empty', () => {
32
+ const onEmbeddedSmsFooterValidity = jest.fn();
33
+ const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
34
+ wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
35
+
36
+ expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
37
+ expect.objectContaining({ isMessageEmpty: true }),
38
+ );
39
+ });
40
+
41
+ it('reports isMessageEmpty: false once the message body has content', () => {
42
+ const onEmbeddedSmsFooterValidity = jest.fn();
43
+ const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
44
+ wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
45
+ onEmbeddedSmsFooterValidity.mockClear();
46
+
47
+ wrapper.setState({ formData: { 0: { 'sms-editor': 'Hello there' }, base: { 'sms-editor': 'Hello there' } }, tabCount: 1 });
48
+
49
+ expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
50
+ expect.objectContaining({ isMessageEmpty: false }),
51
+ );
52
+ });
53
+
54
+ it('re-reports empty when an existing (edit-loaded) message is cleared out', () => {
55
+ const onEmbeddedSmsFooterValidity = jest.fn();
56
+ const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
57
+ wrapper.setState({ formData: { 0: { 'sms-editor': 'Existing content' }, base: { 'sms-editor': 'Existing content' } }, tabCount: 1 });
58
+ onEmbeddedSmsFooterValidity.mockClear();
59
+
60
+ wrapper.setState({ formData: { 0: { 'sms-editor': '' }, base: { 'sms-editor': '' } }, tabCount: 1 });
61
+
62
+ expect(onEmbeddedSmsFooterValidity).toHaveBeenCalledWith(
63
+ expect.objectContaining({ isMessageEmpty: true }),
64
+ );
65
+ });
66
+
67
+ it('does not throw and does not report when onEmbeddedSmsFooterValidity is not provided', () => {
68
+ const wrapper = shallow(<Edit {...baseProps()} />);
69
+ expect(() => {
70
+ wrapper.setState({ formData: { 0: { 'sms-editor': '' } }, tabCount: 1 });
71
+ }).not.toThrow();
72
+ });
73
+
74
+ it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
75
+ const onEmbeddedSmsFooterValidity = jest.fn();
76
+ const wrapper = shallow(<Edit {...baseProps()} onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity} />);
77
+ wrapper.setState({ formData: { 0: { 'sms-editor': '' } }, tabCount: 1 });
78
+ onEmbeddedSmsFooterValidity.mockClear();
79
+
80
+ // Unrelated state change — validity tuple (isTemplateNameEmpty/isMessageEmpty) is unchanged.
81
+ wrapper.setState({ currentTab: 1 });
82
+
83
+ expect(onEmbeddedSmsFooterValidity).not.toHaveBeenCalled();
84
+ });
85
+ });
@@ -1,4 +1,6 @@
1
- import React, { useState, useEffect, useCallback } from 'react';
1
+ import React, {
2
+ useState, useEffect, useCallback, useRef,
3
+ } from 'react';
2
4
  import { bindActionCreators } from 'redux';
3
5
  import { createStructuredSelector } from 'reselect';
4
6
  import { injectIntl, FormattedMessage } from 'react-intl';
@@ -139,6 +141,7 @@ export const Viber = (props) => {
139
141
  showTestAndPreviewSlidebox: propsShowTestAndPreviewSlidebox,
140
142
  handleTestAndPreview: propsHandleTestAndPreview,
141
143
  handleCloseTestAndPreview: propsHandleCloseTestAndPreview,
144
+ onContentValidityChange,
142
145
  } = props || {};
143
146
 
144
147
  const { formatMessage } = intl;
@@ -257,6 +260,26 @@ export const Viber = (props) => {
257
260
  }
258
261
  }, [viber.templateDetails || templateData]);
259
262
 
263
+ // Reports live message-content validity to a parent (e.g. CreativesContainer's
264
+ // slidebox) so it can disable its own Done/Preview-and-test buttons whenever the
265
+ // Viber message body is empty/whitespace-only. Mirrors the Sms Create/Edit
266
+ // componentDidUpdate pattern, using a ref (instead of an instance field) as the
267
+ // dedup guard so the callback only fires when the reported value actually changes -
268
+ // calling it unconditionally on every render risks an infinite update loop when the
269
+ // parent's setState triggers a re-render of this component.
270
+ const lastReportedContentEmptyRef = useRef(undefined);
271
+ useEffect(() => {
272
+ if (typeof onContentValidityChange !== 'function') {
273
+ return;
274
+ }
275
+ const isContentEmpty = (messageContent || '').trim() === '';
276
+ if (lastReportedContentEmptyRef.current === isContentEmpty) {
277
+ return;
278
+ }
279
+ lastReportedContentEmptyRef.current = isContentEmpty;
280
+ onContentValidityChange({ isContentEmpty });
281
+ }, [messageContent, onContentValidityChange]);
282
+
260
283
  // Text area Code start here
261
284
 
262
285
  // Tags Code start from here