@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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CapActionButton/index.js +73 -60
  3. package/v2Components/CapActionButton/index.scss +44 -28
  4. package/v2Components/CapActionButton/messages.js +7 -3
  5. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  6. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  7. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  8. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  9. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -1
  10. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
  11. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +3 -0
  12. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
  13. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
  14. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  15. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
  16. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  17. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  18. package/v2Containers/CreativesContainer/index.js +27 -0
  19. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  20. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
  21. package/v2Containers/MobilePush/Create/index.js +18 -1
  22. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  23. package/v2Containers/MobilePush/Edit/index.js +18 -1
  24. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  25. package/v2Containers/MobilePush/commonMethods.js +49 -1
  26. package/v2Containers/MobilePushNew/index.js +29 -4
  27. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  28. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  29. package/v2Containers/MobilePushNew/utils.js +34 -1
  30. package/v2Containers/MobilepushWrapper/index.js +3 -1
  31. package/v2Containers/Rcs/index.js +34 -0
  32. package/v2Containers/Rcs/index.scss +15 -0
  33. package/v2Containers/Rcs/tests/index.test.js +67 -0
  34. package/v2Containers/Sms/Create/index.js +3 -1
  35. package/v2Containers/Sms/Edit/index.js +25 -0
  36. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  37. package/v2Containers/Viber/index.js +24 -1
  38. package/v2Containers/Viber/tests/index.test.js +103 -0
  39. package/v2Containers/WebPush/Create/index.js +24 -0
  40. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  41. package/v2Containers/Whatsapp/index.js +19 -1
  42. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  43. package/v2Containers/Zalo/index.js +28 -1
  44. package/v2Containers/Zalo/tests/index.test.js +99 -0
@@ -34,11 +34,16 @@ const DeliverySettingsSection = ({
34
34
  deliverySetting = {},
35
35
  onDeliverySettingChange,
36
36
  onDomainPropertiesLoaded,
37
+ orgUnitId,
37
38
  intl,
38
39
  }) => {
39
40
  const [showSlidebox, setShowSlidebox] = useState(false);
40
41
  const [domainPropertiesData, setDomainPropertiesData] = useState(null);
41
42
  const [wecrmViberData, setWecrmViberData] = useState(null);
43
+ // Drives SenderDetails' loading spinner — true only while the domain-properties
44
+ // fetch below is actually in flight (previously nothing tracked this, so opening
45
+ // the slidebox before the background fetch resolved showed a blank panel).
46
+ const [isFetchingDomains, setIsFetchingDomains] = useState(false);
42
47
  const fetchInFlightRef = useRef(false);
43
48
  const lastChannelKeyRef = useRef('');
44
49
  const { formatMessage } = intl || {};
@@ -88,12 +93,16 @@ const DeliverySettingsSection = ({
88
93
  }
89
94
  if (fetchInFlightRef.current) return undefined;
90
95
  fetchInFlightRef.current = true;
96
+ setIsFetchingDomains(true);
91
97
  lastChannelKeyRef.current = deliveryChannelKey;
92
98
  let cancelled = false;
93
99
  const fetchData = async () => {
94
100
  try {
95
- const orgUnitId = loadItem('ouId') || loadItem('orgID');
96
- const response = await getDomainProperties(deliveryChannels, orgUnitId);
101
+ // Prefer the consumer-supplied config.context.ouId (same source Preview-and-test already
102
+ // uses) so both screens fetch domains for the same org unit; fall back to the logged-in
103
+ // user's own cached OU only when a consumer hasn't supplied one.
104
+ const resolvedOrgUnitId = orgUnitId ?? (loadItem('ouId') || loadItem('orgID'));
105
+ const response = await getDomainProperties(deliveryChannels, resolvedOrgUnitId);
97
106
  if (!cancelled) {
98
107
  const raw = response?.entity || response;
99
108
  // Normalize channel keys to uppercase (API may return Viber, viber, etc.)
@@ -108,7 +117,10 @@ const DeliverySettingsSection = ({
108
117
  } catch (err) {
109
118
  if (!cancelled) setDomainPropertiesData(null);
110
119
  } finally {
111
- if (!cancelled) fetchInFlightRef.current = false;
120
+ if (!cancelled) {
121
+ fetchInFlightRef.current = false;
122
+ setIsFetchingDomains(false);
123
+ }
112
124
  }
113
125
  };
114
126
  fetchData();
@@ -117,7 +129,7 @@ const DeliverySettingsSection = ({
117
129
  // Do NOT reset fetchInFlightRef here - it would allow duplicate fetches when effect re-runs.
118
130
  // fetchInFlightRef is reset in finally when the fetch completes.
119
131
  };
120
- }, [deliveryEnabled, deliveryChannelKey]); // eslint-disable-line react-hooks/exhaustive-deps
132
+ }, [deliveryEnabled, deliveryChannelKey, orgUnitId]); // eslint-disable-line react-hooks/exhaustive-deps
121
133
 
122
134
  // Fetch WeCRM accounts for VIBER when domainProperties has empty contactInfo
123
135
  const needsWecrmViber = deliveryChannels.includes('VIBER');
@@ -303,6 +315,7 @@ const DeliverySettingsSection = ({
303
315
  onClose={() => setShowSlidebox(false)}
304
316
  channels={deliveryChannels}
305
317
  preloadedDomainProperties={entityWithWecrmViber}
318
+ isLoadingDomainProperties={isFetchingDomains}
306
319
  savedFieldValues={parseChannelSettingForDisplay(deliverySetting?.channelSetting)}
307
320
  whatsappSourceAccountId={whatsappSourceAccountId}
308
321
  whatsappAccountName={whatsappAccNameFromContent || getWhatsappAccountName(entityWithWecrmViber, whatsappSourceAccountId)}
@@ -319,6 +332,7 @@ DeliverySettingsSection.propTypes = {
319
332
  deliverySetting: PropTypes.object,
320
333
  onDeliverySettingChange: PropTypes.func,
321
334
  onDomainPropertiesLoaded: PropTypes.func,
335
+ orgUnitId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
322
336
  intl: PropTypes.object.isRequired,
323
337
  };
324
338
 
@@ -327,6 +341,7 @@ DeliverySettingsSection.defaultProps = {
327
341
  deliverySettingsData: null,
328
342
  deliverySetting: null,
329
343
  onDeliverySettingChange: null,
344
+ orgUnitId: undefined,
330
345
  };
331
346
 
332
347
  export default injectIntl(DeliverySettingsSection);
@@ -42,6 +42,7 @@ const SenderDetails = ({
42
42
  onClose,
43
43
  channels = [],
44
44
  preloadedDomainProperties,
45
+ isLoadingDomainProperties,
45
46
  savedFieldValues,
46
47
  onSave,
47
48
  whatsappSourceAccountId,
@@ -90,14 +91,15 @@ const SenderDetails = ({
90
91
  setLoading(false);
91
92
  setError(null);
92
93
  } else {
93
- // preloadedDomainProperties is not yet available (or the parent's fetch failed).
94
- // Show nothing rather than an eternal spinner the parent controls data availability.
95
- setLoading(false);
94
+ // preloadedDomainProperties is not yet available either the parent's fetch is still in
95
+ // flight (show the spinner via isLoadingDomainProperties) or it failed/returned nothing
96
+ // (show nothing rather than an eternal spinner — the parent controls data availability).
97
+ setLoading(!!isLoadingDomainProperties);
96
98
  setEntity(null);
97
99
  setFieldValues({});
98
100
  setInitialFieldValues({});
99
101
  }
100
- }, [show, channels, preloadedDomainProperties, savedFieldValues, fieldConfigs, whatsappAccountName, wabaContext]);
102
+ }, [show, channels, preloadedDomainProperties, isLoadingDomainProperties, savedFieldValues, fieldConfigs, whatsappAccountName, wabaContext]);
101
103
 
102
104
  const handleFieldChange = useCallback((fieldKey, value) => {
103
105
  setFieldValues((prev) => {
@@ -306,6 +308,7 @@ SenderDetails.propTypes = {
306
308
  onClose: PropTypes.func.isRequired,
307
309
  channels: PropTypes.arrayOf(PropTypes.string),
308
310
  preloadedDomainProperties: PropTypes.object,
311
+ isLoadingDomainProperties: PropTypes.bool,
309
312
  savedFieldValues: PropTypes.object,
310
313
  onSave: PropTypes.func,
311
314
  whatsappSourceAccountId: PropTypes.string,
@@ -316,6 +319,7 @@ SenderDetails.propTypes = {
316
319
  SenderDetails.defaultProps = {
317
320
  channels: [],
318
321
  preloadedDomainProperties: null,
322
+ isLoadingDomainProperties: false,
319
323
  savedFieldValues: null,
320
324
  onSave: null,
321
325
  whatsappSourceAccountId: '',
@@ -153,6 +153,7 @@ function renderSection(props = {}) {
153
153
  deliverySettingsData = {},
154
154
  deliverySetting = {},
155
155
  onDeliverySettingChange,
156
+ orgUnitId,
156
157
  } = props;
157
158
  return render(
158
159
  <IntlProvider locale="en" messages={{}} defaultLocale="en">
@@ -161,6 +162,7 @@ function renderSection(props = {}) {
161
162
  deliverySettingsData={deliverySettingsData}
162
163
  deliverySetting={deliverySetting}
163
164
  onDeliverySettingChange={onDeliverySettingChange}
165
+ orgUnitId={orgUnitId}
164
166
  />
165
167
  </IntlProvider>,
166
168
  );
@@ -299,6 +301,15 @@ describe('DeliverySettingsSection — marketer flows', () => {
299
301
  await waitFor(() => expect(getDomainProperties).toHaveBeenCalledWith(['SMS'], 'fallback-org'));
300
302
  });
301
303
 
304
+ it('prefers the consumer-supplied orgUnitId (config.context.ouId) over local storage — same source Preview-and-test uses, so both fetch the same org unit', async () => {
305
+ loadItem.mockImplementation((key) => (key === 'ouId' || key === 'orgID' ? 'logged-in-user-ou' : null));
306
+ getDomainProperties.mockResolvedValue({ entity: apiEntity.SMS });
307
+
308
+ renderSection({ contentItems: [{ channel: 'SMS' }], orgUnitId: 'consumer-ou' });
309
+
310
+ await waitFor(() => expect(getDomainProperties).toHaveBeenCalledWith(['SMS'], 'consumer-ou'));
311
+ });
312
+
302
313
  it('accepts the whole response as the entity when `entity` is omitted (some clients)', async () => {
303
314
  getDomainProperties.mockResolvedValue(apiEntity.SMS);
304
315
 
@@ -9,6 +9,16 @@ import SenderDetails, { parseSenderDetailsFromEntity } from '../SenderDetails';
9
9
  import * as deliverySettingsConfig from '../deliverySettingsConfig';
10
10
  import { findVisibleSelectOption } from '../../../../../utils/test-utils';
11
11
 
12
+ // antd's Spin indicator doesn't render distinctly under jsdom (lazy-loaded), so its
13
+ // `spinning` prop isn't observable via the rendered DOM — mock it to surface the prop directly.
14
+ jest.mock('@capillarytech/cap-ui-library/CapSpin', () => function MockCapSpin({ spinning, children }) {
15
+ return (
16
+ <div data-testid="mock-cap-spin" data-spinning={String(!!spinning)}>
17
+ {children}
18
+ </div>
19
+ );
20
+ });
21
+
12
22
  // antd v6 Selects rely on real-time animations + portal mounts; under the full
13
23
  // parallel suite (jest -w 90%) the default 5s budget is too tight for
14
24
  // click → listbox-paint → option-click chains. Bump per-test timeout.
@@ -256,6 +266,22 @@ describe('SenderDetails', () => {
256
266
  expect(screen.queryByText('SMS Domain')).not.toBeInTheDocument();
257
267
  });
258
268
 
269
+ it('shows a loading spinner when preloaded data is missing and the parent reports the domain fetch is still in flight', () => {
270
+ renderSenderDetails({
271
+ preloadedDomainProperties: null,
272
+ isLoadingDomainProperties: true,
273
+ });
274
+ expect(screen.getByTestId('mock-cap-spin')).toHaveAttribute('data-spinning', 'true');
275
+ });
276
+
277
+ it('does not show a loading spinner when preloaded data is missing but the parent is not fetching (e.g. the fetch failed)', () => {
278
+ renderSenderDetails({
279
+ preloadedDomainProperties: null,
280
+ isLoadingDomainProperties: false,
281
+ });
282
+ expect(screen.getByTestId('mock-cap-spin')).toHaveAttribute('data-spinning', 'false');
283
+ });
284
+
259
285
  it('applies savedFieldValues over API defaults for SMS sender', async () => {
260
286
  renderSenderDetails({
261
287
  preloadedDomainProperties: ENTITIES.smsTwoSenders,
@@ -181,6 +181,8 @@ export function SlideBoxContent(props) {
181
181
  onPersonalizationTokensChange,
182
182
  isTestAndPreviewMode,
183
183
  onHtmlEditorValidationStateChange,
184
+ onEmbeddedSmsFooterValidity,
185
+ onContentValidityChange,
184
186
  } = props;
185
187
  const localTemplatesConfig = props.localTemplatesConfig || pick(props, constants.LOCAL_TEMPLATE_CONFIG_KEYS);
186
188
  const useLocalTemplates = !!get(localTemplatesConfig, 'useLocalTemplates');
@@ -456,6 +458,7 @@ export function SlideBoxContent(props) {
456
458
  eventContextTags,
457
459
  waitEventContextTags,
458
460
  handleClose,
461
+ onContentValidityChange,
459
462
  };
460
463
 
461
464
  return (
@@ -602,6 +605,7 @@ export function SlideBoxContent(props) {
602
605
  handleCloseTestAndPreview={handleCloseTestAndPreview}
603
606
  isTestAndPreviewMode={isTestAndPreviewMode}
604
607
  onValidationFail={onValidationFail}
608
+ onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity}
605
609
  />
606
610
  )}
607
611
  {isEditFTP && (
@@ -644,6 +648,7 @@ export function SlideBoxContent(props) {
644
648
  showTestAndPreviewSlidebox={showTestAndPreviewSlidebox}
645
649
  handleTestAndPreview={handleTestAndPreview}
646
650
  handleCloseTestAndPreview={handleCloseTestAndPreview}
651
+ onContentValidityChange={onContentValidityChange}
647
652
  />
648
653
  }
649
654
  {
@@ -667,6 +672,7 @@ export function SlideBoxContent(props) {
667
672
  templateData={templateData}
668
673
  isGetFormData={isGetFormData}
669
674
  getFormSubscriptionData={getFormData}
675
+ onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity}
670
676
  getLiquidTags={getLiquidTags}
671
677
  getDefaultTags={type}
672
678
  isFullMode={isFullMode}
@@ -866,6 +872,7 @@ export function SlideBoxContent(props) {
866
872
  restrictPersonalization={restrictPersonalization}
867
873
  isAnonymousType={isAnonymousType}
868
874
  onPersonalizationTokensChange={onPersonalizationTokensChange}
875
+ onContentValidityChange={onContentValidityChange}
869
876
  />
870
877
  ) : (
871
878
  <MobilePushNew
@@ -897,6 +904,7 @@ export function SlideBoxContent(props) {
897
904
  restrictPersonalization={restrictPersonalization}
898
905
  isAnonymousType={isAnonymousType}
899
906
  onPersonalizationTokensChange={onPersonalizationTokensChange}
907
+ onContentValidityChange={onContentValidityChange}
900
908
  />
901
909
  )
902
910
  )}
@@ -939,6 +947,7 @@ export function SlideBoxContent(props) {
939
947
  restrictPersonalization={restrictPersonalization}
940
948
  isAnonymousType={isAnonymousType}
941
949
  onPersonalizationTokensChange={onPersonalizationTokensChange}
950
+ onContentValidityChange={onContentValidityChange}
942
951
  />
943
952
  ) : (
944
953
  <MobilePushNew
@@ -976,6 +985,7 @@ export function SlideBoxContent(props) {
976
985
  creativesMode={creativesMode}
977
986
  restrictPersonalization={restrictPersonalization}
978
987
  isAnonymousType={isAnonymousType}
988
+ onContentValidityChange={onContentValidityChange}
979
989
  />
980
990
  )
981
991
  )}
@@ -1085,6 +1095,7 @@ export function SlideBoxContent(props) {
1085
1095
  showTestAndPreviewSlidebox={showTestAndPreviewSlidebox}
1086
1096
  handleTestAndPreview={handleTestAndPreview}
1087
1097
  handleCloseTestAndPreview={handleCloseTestAndPreview}
1098
+ onContentValidityChange={onContentValidityChange}
1088
1099
  createNew/>
1089
1100
  )}
1090
1101
 
@@ -1107,6 +1118,7 @@ export function SlideBoxContent(props) {
1107
1118
  eventContextTags={eventContextTags}
1108
1119
  waitEventContextTags={waitEventContextTags}
1109
1120
  showLiquidErrorInFooter={showLiquidErrorInFooter}
1121
+ onContentValidityChange={onContentValidityChange}
1110
1122
  createNew/> }
1111
1123
 
1112
1124
  {isCreateWhatsapp && (<Whatsapp
@@ -1120,6 +1132,7 @@ export function SlideBoxContent(props) {
1120
1132
  handleTestAndPreview={handleTestAndPreview}
1121
1133
  handleCloseTestAndPreview={handleCloseTestAndPreview}
1122
1134
  isTestAndPreviewMode={isTestAndPreviewMode}
1135
+ onContentValidityChange={onContentValidityChange}
1123
1136
  />
1124
1137
  )}
1125
1138
 
@@ -1145,6 +1158,7 @@ export function SlideBoxContent(props) {
1145
1158
  search: '',
1146
1159
  }}
1147
1160
  showLiquidErrorInFooter={showLiquidErrorInFooter}
1161
+ onContentValidityChange={onContentValidityChange}
1148
1162
  />
1149
1163
  )}
1150
1164
 
@@ -1270,6 +1284,7 @@ export function SlideBoxContent(props) {
1270
1284
  restrictPersonalization={restrictPersonalization}
1271
1285
  isAnonymousType={isAnonymousType}
1272
1286
  waitEventContextTags={waitEventContextTags}
1287
+ onContentValidityChange={onContentValidityChange}
1273
1288
  />
1274
1289
  )}
1275
1290
  {isCreateRcs && (<Rcs
@@ -51,6 +51,8 @@ function SlideBoxFooter(props) {
51
51
  hasPersonalizationTokenError: hasPersonalizationTokenErrorProp = false,
52
52
  /** When set (e.g. SMS library create), overrides `creativesTemplatesSave` (“Done”) for the primary button */
53
53
  primarySaveButtonMessage,
54
+ /** Live content-validity signal reported by the channel's own editor (currently SMS create) — true disables Save/Done. */
55
+ isContentInvalid = false,
54
56
  } = props;
55
57
  // Calculate if buttons should be disabled
56
58
  // Only apply validation state checks for EMAIL channel in HTML Editor mode (not BEE/DragDrop)
@@ -193,7 +195,7 @@ function SlideBoxFooter(props) {
193
195
  <CapRow useLegacy>
194
196
  <CapButton
195
197
  onClick={onSave}
196
- disabled={isTemplateNameEmpty || fetchingCmsData || shouldDisableButtons || hasPersonalizationTokenError}
198
+ disabled={isTemplateNameEmpty || fetchingCmsData || shouldDisableButtons || hasPersonalizationTokenError || isContentInvalid}
197
199
  >
198
200
  {getSaveButtonLabel()}
199
201
  </CapButton>
@@ -201,7 +203,7 @@ function SlideBoxFooter(props) {
201
203
  <CapButton
202
204
  type="secondary"
203
205
  onClick={onTestAndPreview}
204
- disabled={shouldDisableButtons || hasPersonalizationTokenError}
206
+ disabled={shouldDisableButtons || hasPersonalizationTokenError || isContentInvalid}
205
207
  style={{ marginLeft: '8px' }}
206
208
  >
207
209
  <FormattedMessage {...messages.testAndPreview} />
@@ -272,6 +274,7 @@ SlideBoxFooter.propTypes = {
272
274
  id: PropTypes.string,
273
275
  defaultMessage: PropTypes.string,
274
276
  }),
277
+ isContentInvalid: PropTypes.bool,
275
278
  };
276
279
 
277
280
  SlideBoxFooter.defaultProps = {
@@ -300,5 +303,6 @@ SlideBoxFooter.defaultProps = {
300
303
  formData: [],
301
304
  hasPersonalizationTokenError: false,
302
305
  primarySaveButtonMessage: undefined,
306
+ isContentInvalid: false,
303
307
  };
304
308
  export default SlideBoxFooter;
@@ -197,6 +197,9 @@ export class Creatives extends React.Component {
197
197
  errorsAcknowledged: false, // Flag to track if user has acknowledged errors by clicking redirection icon
198
198
  },
199
199
  hasPersonalizationTokenError: false, // Track personalization token errors in form
200
+ // Live content-validity signal for channels that report it (currently SMS create) —
201
+ // false by default so unwired channels are never blocked by this flag.
202
+ isContentInvalid: false,
200
203
  };
201
204
  this.creativesTemplateSteps = {
202
205
  1: 'modeSelection',
@@ -1893,6 +1896,26 @@ export class Creatives extends React.Component {
1893
1896
  this.setState({ isDiscardMessage: true });
1894
1897
  }
1895
1898
 
1899
+ // Reported live by SMS's own content editor (Sms/Create and Sms/Edit's componentDidUpdate)
1900
+ // so the Done/Save button reflects an empty/invalid form immediately, instead of only
1901
+ // failing silently on click.
1902
+ handleEmbeddedSmsFooterValidity = (validity) => {
1903
+ const isContentInvalid = !!validity?.isMessageEmpty;
1904
+ if (this.state.isContentInvalid !== isContentInvalid) {
1905
+ this.setState({ isContentInvalid });
1906
+ }
1907
+ }
1908
+
1909
+ // Generic counterpart of the above for every other channel wrapper (Email, MobilePush,
1910
+ // Zalo, WebPush, RCS, WhatsApp, Viber) — each reports { isContentEmpty } live from its own
1911
+ // form state so the Done/Preview-and-test buttons disable immediately, not just on click.
1912
+ handleContentValidityChange = (validity) => {
1913
+ const isContentInvalid = !!validity?.isContentEmpty;
1914
+ if (this.state.isContentInvalid !== isContentInvalid) {
1915
+ this.setState({ isContentInvalid });
1916
+ }
1917
+ }
1918
+
1896
1919
 
1897
1920
  // NEW: Handler for Test and Preview button
1898
1921
  handleTestAndPreview = () => {
@@ -2213,6 +2236,7 @@ export class Creatives extends React.Component {
2213
2236
  isTestAndPreviewMode,
2214
2237
  inAppEditorType,
2215
2238
  htmlEditorValidationState,
2239
+ isContentInvalid,
2216
2240
  } = this.state;
2217
2241
  const useLocalTemplates = get(
2218
2242
  this.props,
@@ -2410,6 +2434,8 @@ export class Creatives extends React.Component {
2410
2434
  handleCloseTestAndPreview={this.handleCloseTestAndPreview}
2411
2435
  isTestAndPreviewMode={(() => this.state.isTestAndPreviewMode)()}
2412
2436
  onHtmlEditorValidationStateChange={this.updateHtmlEditorValidationState}
2437
+ onEmbeddedSmsFooterValidity={this.handleEmbeddedSmsFooterValidity}
2438
+ onContentValidityChange={this.handleContentValidityChange}
2413
2439
  onPersonalizationTokensChange={this.handlePersonalizationTokensChange}
2414
2440
  localTemplatesConfig={pick(this.props.localTemplatesConfig || this.props, constants.LOCAL_TEMPLATE_CONFIG_KEYS)}
2415
2441
  />
@@ -2432,6 +2458,7 @@ export class Creatives extends React.Component {
2432
2458
  shouldShowDoneFooter={this.shouldShowDoneFooter}
2433
2459
  fetchingCmsData={fetchingCmsData}
2434
2460
  isTemplateNameEmpty={isTemplateNameEmpty}
2461
+ isContentInvalid={isContentInvalid}
2435
2462
  isLiquidValidationError={isLiquidValidationError}
2436
2463
  errorMessages={liquidErrorMessage}
2437
2464
  currentTab={activeFormBuilderTab}
@@ -658,4 +658,33 @@ describe('SlideBoxFooter — isBEEEditor detection in create mode via emailCreat
658
658
  expect(screen.getByRole('button', { name: /done/i })).toBeInTheDocument();
659
659
  });
660
660
  });
661
+ });
662
+
663
+ describe('SlideBoxFooter — isContentInvalid (live content-validity signal, e.g. empty SMS body)', () => {
664
+ it('disables Save/Done when isContentInvalid is true, independent of currentChannel/htmlEditorValidationState', () => {
665
+ renderComponent({
666
+ ...baseFooterProps,
667
+ currentChannel: 'SMS',
668
+ isContentInvalid: true,
669
+ });
670
+ expect(screen.getByRole('button', { name: /update/i })).toBeDisabled();
671
+ });
672
+
673
+ it('disables the Test & Preview button when isContentInvalid is true', () => {
674
+ renderComponent({
675
+ ...baseFooterProps,
676
+ currentChannel: 'SMS',
677
+ isContentInvalid: true,
678
+ showTestAndPreviewButton: true,
679
+ });
680
+ expect(screen.getByRole('button', { name: /preview.*test/i })).toBeDisabled();
681
+ });
682
+
683
+ it('leaves Save/Done enabled when isContentInvalid is false (default)', () => {
684
+ renderComponent({
685
+ ...baseFooterProps,
686
+ currentChannel: 'SMS',
687
+ });
688
+ expect(screen.getByRole('button', { name: /update/i })).not.toBeDisabled();
689
+ });
661
690
  });
@@ -28,10 +28,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click rcs
28
28
  }
29
29
  onCallTaskSubmit={[Function]}
30
30
  onChannelChange={[Function]}
31
+ onContentValidityChange={[Function]}
31
32
  onCreateComplete={[MockFunction]}
32
33
  onCreateNew={[Function]}
33
34
  onCreateNextStep={[Function]}
34
35
  onEmailModeChange={[Function]}
36
+ onEmbeddedSmsFooterValidity={[Function]}
35
37
  onEnterTemplateName={[Function]}
36
38
  onFTPSubmit={[MockFunction]}
37
39
  onFacebookSubmit={[MockFunction]}
@@ -122,10 +124,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click wha
122
124
  }
123
125
  onCallTaskSubmit={[Function]}
124
126
  onChannelChange={[Function]}
127
+ onContentValidityChange={[Function]}
125
128
  onCreateComplete={[MockFunction]}
126
129
  onCreateNew={[Function]}
127
130
  onCreateNextStep={[Function]}
128
131
  onEmailModeChange={[Function]}
132
+ onEmbeddedSmsFooterValidity={[Function]}
129
133
  onEnterTemplateName={[Function]}
130
134
  onFTPSubmit={[MockFunction]}
131
135
  onFacebookSubmit={[MockFunction]}
@@ -216,10 +220,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
216
220
  }
217
221
  onCallTaskSubmit={[Function]}
218
222
  onChannelChange={[Function]}
223
+ onContentValidityChange={[Function]}
219
224
  onCreateComplete={[MockFunction]}
220
225
  onCreateNew={[Function]}
221
226
  onCreateNextStep={[Function]}
222
227
  onEmailModeChange={[Function]}
228
+ onEmbeddedSmsFooterValidity={[Function]}
223
229
  onEnterTemplateName={[Function]}
224
230
  onFTPSubmit={[MockFunction]}
225
231
  onFacebookSubmit={[MockFunction]}
@@ -281,6 +287,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
281
287
  }
282
288
  }
283
289
  isAnonymousType={false}
290
+ isContentInvalid={false}
284
291
  isContinueButtonDisabled={false}
285
292
  isCreatingTemplate={false}
286
293
  isEmptyContent={false}
@@ -353,10 +360,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
353
360
  }
354
361
  onCallTaskSubmit={[Function]}
355
362
  onChannelChange={[Function]}
363
+ onContentValidityChange={[Function]}
356
364
  onCreateComplete={[MockFunction]}
357
365
  onCreateNew={[Function]}
358
366
  onCreateNextStep={[Function]}
359
367
  onEmailModeChange={[Function]}
368
+ onEmbeddedSmsFooterValidity={[Function]}
360
369
  onEnterTemplateName={[Function]}
361
370
  onFTPSubmit={[MockFunction]}
362
371
  onFacebookSubmit={[MockFunction]}
@@ -418,6 +427,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
418
427
  }
419
428
  }
420
429
  isAnonymousType={false}
430
+ isContentInvalid={false}
421
431
  isContinueButtonDisabled={false}
422
432
  isCreatingTemplate={false}
423
433
  isEmptyContent={false}
@@ -490,10 +500,12 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
490
500
  }
491
501
  onCallTaskSubmit={[Function]}
492
502
  onChannelChange={[Function]}
503
+ onContentValidityChange={[Function]}
493
504
  onCreateComplete={[MockFunction]}
494
505
  onCreateNew={[Function]}
495
506
  onCreateNextStep={[Function]}
496
507
  onEmailModeChange={[Function]}
508
+ onEmbeddedSmsFooterValidity={[Function]}
497
509
  onEnterTemplateName={[Function]}
498
510
  onFTPSubmit={[MockFunction]}
499
511
  onFacebookSubmit={[MockFunction]}
@@ -555,6 +567,7 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
555
567
  }
556
568
  }
557
569
  isAnonymousType={false}
570
+ isContentInvalid={false}
558
571
  isContinueButtonDisabled={false}
559
572
  isCreatingTemplate={false}
560
573
  isEmptyContent={false}
@@ -30,7 +30,7 @@ import getEventsMap from '../eventsMap';
30
30
  import { GA } from '@capillarytech/cap-ui-utils';
31
31
  import { CREATE, TRACK_CREATE_MPUSH } from '../../App/constants';
32
32
  import { MOBILE_PUSH } from '../../CreativesContainer/constants';
33
- import { getContent } from '../commonMethods';
33
+ import { getContent, getMobilePushEmbeddedContentValidity } from '../commonMethods';
34
34
  import { getCdnUrl } from '../../../utils/cdnTransformation';
35
35
  import injectReducer from '../../../utils/injectReducer';
36
36
  import injectSaga from '../../../utils/injectSaga';
@@ -67,6 +67,22 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
67
67
  }
68
68
  this.hasFetchedInitialTagsRef = false;
69
69
  this.lastFetchedTagContextRef = null;
70
+ // Tracks the last content-emptiness value reported to the parent so componentDidUpdate
71
+ // does not dispatch on every render. Intentionally undefined (not true) so the first
72
+ // render always reports the real validity rather than assuming the form starts invalid.
73
+ this._lastReportedMobilePushContentEmpty = undefined;
74
+ }
75
+ componentDidUpdate() {
76
+ // Reports live form validity so the host (CreativesContainer) can disable Done/Preview
77
+ // and Test as soon as the message content is cleared — mirrors Sms/Create's own reporting.
78
+ if (typeof this.props.onContentValidityChange !== 'function') {
79
+ return;
80
+ }
81
+ const validity = getMobilePushEmbeddedContentValidity(this.state?.formData, this.state?.tabCount);
82
+ const isContentEmpty = !!validity.isContentEmpty;
83
+ if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
84
+ this._lastReportedMobilePushContentEmpty = isContentEmpty;
85
+ this.props.onContentValidityChange({ isContentEmpty });
70
86
  }
71
87
  componentWillMount = () => {
72
88
  if (this.props.route.name === 'view') {
@@ -2072,6 +2088,7 @@ Create.propTypes = {
2072
2088
  getFormLibraryData: PropTypes.func,
2073
2089
  onPreviewContentClicked: PropTypes.func,
2074
2090
  onTestContentClicked: PropTypes.func,
2091
+ onContentValidityChange: PropTypes.func,
2075
2092
  eventContextTags: PropTypes.array,
2076
2093
  waitEventContextTags: PropTypes.object,
2077
2094
  getLiquidTags: PropTypes.func,
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { shallow } from 'enzyme';
3
+ import { Create } from '../index';
4
+
5
+ // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
+ // only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
7
+ // Mirrors app/v2Containers/Sms/Create/index.js's own reporting and
8
+ // app/v2Containers/MobilePush/Edit/test/contentValidity.test.js.
9
+
10
+ const baseProps = () => ({
11
+ actions: {},
12
+ globalActions: {
13
+ fetchSchemaForEntity: jest.fn(),
14
+ },
15
+ Templates: { selectedWeChatAccount: { id: 'acc-1' } },
16
+ Create: {},
17
+ params: {},
18
+ location: { query: { type: 'library', module: 'default' } },
19
+ route: { name: 'mobilepush' },
20
+ router: { push: jest.fn() },
21
+ metaEntities: {},
22
+ intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
23
+ isFullMode: false,
24
+ });
25
+
26
+ describe('MobilePush/Create — onContentValidityChange reporting', () => {
27
+ it('reports isContentEmpty: true on mount when both Android title and message are empty', () => {
28
+ const onContentValidityChange = jest.fn();
29
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
30
+ wrapper.setState({
31
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
32
+ tabCount: 1,
33
+ });
34
+
35
+ expect(onContentValidityChange).toHaveBeenCalledWith(
36
+ expect.objectContaining({ isContentEmpty: true }),
37
+ );
38
+ });
39
+
40
+ it('reports isContentEmpty: false once the Android message has content', () => {
41
+ const onContentValidityChange = jest.fn();
42
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
43
+ wrapper.setState({
44
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
45
+ tabCount: 1,
46
+ });
47
+ onContentValidityChange.mockClear();
48
+
49
+ wrapper.setState({
50
+ formData: { 0: { 'message-title': 'Hello', 'message-editor': 'World' } },
51
+ tabCount: 1,
52
+ });
53
+
54
+ expect(onContentValidityChange).toHaveBeenCalledWith(
55
+ expect.objectContaining({ isContentEmpty: false }),
56
+ );
57
+ });
58
+
59
+ it('re-reports empty when a previously entered message is cleared out', () => {
60
+ const onContentValidityChange = jest.fn();
61
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
62
+ wrapper.setState({
63
+ formData: { 0: { 'message-title': 'Existing', 'message-editor': 'content' } },
64
+ tabCount: 1,
65
+ });
66
+ onContentValidityChange.mockClear();
67
+
68
+ wrapper.setState({
69
+ formData: { 0: { 'message-title': '', 'message-editor': '' } },
70
+ tabCount: 1,
71
+ });
72
+
73
+ expect(onContentValidityChange).toHaveBeenCalledWith(
74
+ expect.objectContaining({ isContentEmpty: true }),
75
+ );
76
+ });
77
+
78
+ it('does not throw and does not report when onContentValidityChange is not provided', () => {
79
+ const wrapper = shallow(<Create {...baseProps()} />);
80
+ expect(() => {
81
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
82
+ }).not.toThrow();
83
+ });
84
+
85
+ it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
86
+ const onContentValidityChange = jest.fn();
87
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
88
+ wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
89
+ onContentValidityChange.mockClear();
90
+
91
+ // Unrelated state change — content emptiness is unchanged.
92
+ wrapper.setState({ currentTab: 1 });
93
+
94
+ expect(onContentValidityChange).not.toHaveBeenCalled();
95
+ });
96
+ });