@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
@@ -6,7 +6,7 @@
6
6
  * - Configured state: shows a two-column CapCard with pen icon, channel preview (left), dynamic controls (right)
7
7
  */
8
8
 
9
- import React, { useState, useCallback } from 'react';
9
+ import React, { useState, useCallback, useEffect } from 'react';
10
10
  import PropTypes from 'prop-types';
11
11
  import { injectIntl } from 'react-intl';
12
12
  import { CAP_G09 } from '@capillarytech/cap-ui-library/styled/variables';
@@ -82,6 +82,14 @@ const CommunicationFlowCard = ({
82
82
  const [showSlideBox, setShowSlideBox] = useState(false);
83
83
  const [savedData, setSavedData] = useState(initialData || null);
84
84
 
85
+ // On a hard reload of an edit page, initialData is available but savedData is not yet set.
86
+ // This effect ensures that savedData is initialized from initialData in that case.
87
+ useEffect(() => {
88
+ if (initialData && !savedData) {
89
+ setSavedData(initialData);
90
+ }
91
+ }, [initialData]);
92
+
85
93
  const handleSave = useCallback((data) => {
86
94
  setSavedData(data);
87
95
  setShowSlideBox(false);
@@ -166,7 +174,7 @@ const CommunicationFlowCard = ({
166
174
  <CapColumn span={12} className="card-body-right-col">
167
175
  <CapLabel type="label8">{formatMessage(messages.dynamicControlsTitle)}</CapLabel>
168
176
  {dynamicControlKeys.map((key) => {
169
- const controlConfig = controls.find((c) => c.key === key);
177
+ const controlConfig = controls.find((control) => control.key === key);
170
178
  if (!controlConfig) return null;
171
179
  return (
172
180
  <CapRow key={key} type="flex" justify="space-between" className="control-row">
@@ -693,7 +693,7 @@ describe('handleSave — CCS flow', () => {
693
693
  expect(createCommDefinition).not.toHaveBeenCalled();
694
694
  });
695
695
 
696
- it('still calls onSave (without ccsCommDefinition) when createCommDefinition rejects', async () => {
696
+ it('blocks save and shows a generic error when createCommDefinition rejects (regression: this used to silently call onSave without ccsCommDefinition and no user-facing feedback — Send for approval would then just be disabled with zero explanation why)', async () => {
697
697
  createCommDefinition.mockRejectedValue(new Error('Network error'));
698
698
  const onSave = jest.fn();
699
699
  renderWithFlow({
@@ -705,8 +705,40 @@ describe('handleSave — CCS flow', () => {
705
705
 
706
706
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
707
707
 
708
- await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
709
- expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
708
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
709
+ expect(onSave).not.toHaveBeenCalled();
710
+ });
711
+
712
+ it('blocks save and shows a generic error when createCommDefinition resolves with a network-level failure (api.js resolves isError instead of rejecting on a 401/403/etc)', async () => {
713
+ createCommDefinition.mockResolvedValue({ isError: true, status: 401 });
714
+ const onSave = jest.fn();
715
+ renderWithFlow({
716
+ features: {},
717
+ config: ccsConfig,
718
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
719
+ onSave,
720
+ });
721
+
722
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
723
+
724
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
725
+ expect(onSave).not.toHaveBeenCalled();
726
+ });
727
+
728
+ it('blocks save and shows a generic error when createCommDefinition resolves with no usable data (e.g. missing id)', async () => {
729
+ createCommDefinition.mockResolvedValue({ response: { data: {} } });
730
+ const onSave = jest.fn();
731
+ renderWithFlow({
732
+ features: {},
733
+ config: ccsConfig,
734
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
735
+ onSave,
736
+ });
737
+
738
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
739
+
740
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
741
+ expect(onSave).not.toHaveBeenCalled();
710
742
  });
711
743
 
712
744
  it('blocks save and shows an error when createCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
@@ -652,4 +652,62 @@ describe('CommunicationFlowCard', () => {
652
652
  expect(screen.getByText('Message:')).toBeInTheDocument();
653
653
  });
654
654
  });
655
+
656
+ describe('async initialData (edit page hard reload)', () => {
657
+ // On a hard reload of an edit page, the consumer's own by-id fetch hasn't
658
+ // resolved at first mount, so initialData starts as null and arrives a
659
+ // render or two later. savedData must pick it up then, not just at mount.
660
+ it('shows the summary card once initialData arrives after mount, instead of staying stuck on the empty "Add creatives" state', () => {
661
+ const { rerender } = render(
662
+ <IntlProvider locale="en" messages={{}} defaultLocale="en">
663
+ <CommunicationFlowCard
664
+ config={baseConfig}
665
+ initialData={null}
666
+ onSave={jest.fn()}
667
+ onCancel={jest.fn()}
668
+ onChange={jest.fn()}
669
+ cap={{}}
670
+ />
671
+ </IntlProvider>,
672
+ );
673
+ expect(screen.getByText('Add creatives')).toBeInTheDocument();
674
+
675
+ rerender(
676
+ <IntlProvider locale="en" messages={{}} defaultLocale="en">
677
+ <CommunicationFlowCard
678
+ config={baseConfig}
679
+ initialData={makeSavedData()}
680
+ onSave={jest.fn()}
681
+ onCancel={jest.fn()}
682
+ onChange={jest.fn()}
683
+ cap={{}}
684
+ />
685
+ </IntlProvider>,
686
+ );
687
+ expect(screen.getByText('Message:')).toBeInTheDocument();
688
+ expect(screen.queryByText('Add creatives')).not.toBeInTheDocument();
689
+ });
690
+
691
+ it('does not clobber content the user already saved locally when a later render passes a different initialData', () => {
692
+ const { rerender } = renderCard({ initialData: null });
693
+ fireEvent.click(screen.getByText('Add creatives'));
694
+ fireEvent.click(screen.getByTestId('flow-save'));
695
+ expect(screen.getByText('Message:')).toBeInTheDocument();
696
+
697
+ rerender(
698
+ <IntlProvider locale="en" messages={{}} defaultLocale="en">
699
+ <CommunicationFlowCard
700
+ config={baseConfig}
701
+ initialData={makeSavedData({ contentItems: [{ channel: 'EMAIL', templateData: {} }] })}
702
+ onSave={jest.fn()}
703
+ onCancel={jest.fn()}
704
+ onChange={jest.fn()}
705
+ cap={{}}
706
+ />
707
+ </IntlProvider>,
708
+ );
709
+ // Locally-saved SMS content must survive; the later initialData prop must not overwrite it.
710
+ expect(screen.getByText('SMS')).toBeInTheDocument();
711
+ });
712
+ });
655
713
  });
@@ -99,9 +99,7 @@ export const CCS_CHANNEL_NAME_MAP = {
99
99
  MOBILEPUSH: 'PUSH',
100
100
  };
101
101
 
102
- // Channel config single source of truth for CreativesContainer and TemplatesV2.
103
- // paneKey: TemplatesV2 defaultPanes key used by channelsToHide.
104
- // channelProp: CreativesContainer channel prop that must match pane.key for the tab to be active.
102
+ // Channel config is the shared source of truth: paneKey maps TemplatesV2 panes, channelProp maps CreativesContainer tabs.
105
103
  export const CHANNELS = [
106
104
  {
107
105
  value: 'SMS',
@@ -367,4 +367,8 @@ export default {
367
367
  id: `${prefix}.duplicateReferenceIdError`,
368
368
  defaultMessage: 'This reference ID is already in use in your organization. Please use a different reference ID.',
369
369
  },
370
+ genericSaveError: {
371
+ id: `${prefix}.genericSaveError`,
372
+ defaultMessage: 'Something went wrong while saving your content. Please try again.',
373
+ },
370
374
  };
@@ -251,6 +251,7 @@ const ChannelSelectionStep = ({
251
251
  };
252
252
 
253
253
  const handleChannelsVisibleChange = (visible) => {
254
+ if (visible && renderChannelDropdownOverlay() === null) return;
254
255
  setShowChannelsMenu(visible);
255
256
  };
256
257
 
@@ -438,6 +439,7 @@ const ChannelSelectionStep = ({
438
439
  overlay={renderChannelDropdownOverlay() || <CapMenu />}
439
440
  onVisibleChange={handleChannelsVisibleChange}
440
441
  visible={showChannelsMenu}
442
+ trigger={['click']}
441
443
  >
442
444
  <CapButton
443
445
  isAddBtn
@@ -482,6 +484,7 @@ const ChannelSelectionStep = ({
482
484
  deliverySetting={value?.deliverySetting}
483
485
  onDeliverySettingChange={(deliverySetting) => onChange({ deliverySetting })}
484
486
  onDomainPropertiesLoaded={setDomainPropertiesData}
487
+ orgUnitId={config?.context?.ouId}
485
488
  intl={intl}
486
489
  />
487
490
  )}
@@ -503,7 +506,8 @@ const ChannelSelectionStep = ({
503
506
  handleCloseCreatives={handleCloseCreatives}
504
507
  isFullMode={false}
505
508
  hostName={editingZaloHostName}
506
- messageDetails={{ type: 'default' }}
509
+ messageDetails={{ type: (config?.context?.module || '').toLowerCase() || 'default' }}
510
+ location={{ query: { type: 'embedded', module: config?.context?.module } }}
507
511
  templateData={editingContentId ? (() => {
508
512
  const saved = contentItems.find((c) => c.contentId === editingContentId)?.templateData;
509
513
  if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
@@ -39,6 +39,8 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
39
39
  channel,
40
40
  templateData,
41
41
  hostName,
42
+ messageDetails,
43
+ location,
42
44
  }) {
43
45
  return (
44
46
  <div
@@ -47,6 +49,8 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
47
49
  data-creatives-channel={channel}
48
50
  data-template-data={JSON.stringify(templateData)}
49
51
  data-host-name={hostName}
52
+ data-message-details={JSON.stringify(messageDetails)}
53
+ data-location={JSON.stringify(location)}
50
54
  >
51
55
  <button
52
56
  type="button"
@@ -1791,6 +1795,66 @@ describe('ChannelSelectionStep', () => {
1791
1795
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1792
1796
  });
1793
1797
 
1798
+ // ── Tag-context pass-through (config.context.module) ──────────────────────────
1799
+
1800
+ it('passes config.context.module through to messageDetails.type (lowercased) and location.query.module, unchanged, for tag-fetch scoping (regression: this used to be hardcoded to messageDetails={{type: \'default\'}} with no location prop at all, so every consumer\'s tag-fetch was scoped generically instead of per-consumer)', async () => {
1801
+ renderStep(
1802
+ <ChannelSelectionStep
1803
+ value={{ contentItems: [] }}
1804
+ onChange={jest.fn()}
1805
+ channels={CHANNELS}
1806
+ config={{ context: { module: 'CAMPAIGNS' } }}
1807
+ />,
1808
+ );
1809
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1810
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1811
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1812
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1813
+
1814
+ const mock = screen.getByTestId('creatives-mock');
1815
+ expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'campaigns' });
1816
+ expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
1817
+ query: { type: 'embedded', module: 'CAMPAIGNS' },
1818
+ });
1819
+ });
1820
+
1821
+ it('passes through whatever module value a coupons/cartPromotions-style config sets, with no special-casing on our end', async () => {
1822
+ renderStep(
1823
+ <ChannelSelectionStep
1824
+ value={{ contentItems: [] }}
1825
+ onChange={jest.fn()}
1826
+ channels={CHANNELS}
1827
+ config={{ context: { module: 'coupons' } }}
1828
+ />,
1829
+ );
1830
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1831
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1832
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1833
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1834
+
1835
+ const mock = screen.getByTestId('creatives-mock');
1836
+ expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'coupons' });
1837
+ expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
1838
+ query: { type: 'embedded', module: 'coupons' },
1839
+ });
1840
+ });
1841
+
1842
+ it('falls back to messageDetails={{type: "default"}} when config.context.module is absent (unset consumers keep prior behavior)', async () => {
1843
+ renderStep(
1844
+ <ChannelSelectionStep
1845
+ value={{ contentItems: [] }}
1846
+ onChange={jest.fn()}
1847
+ channels={CHANNELS}
1848
+ />,
1849
+ );
1850
+ await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
1851
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1852
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
1853
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1854
+
1855
+ expect(JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-message-details'))).toEqual({ type: 'default' });
1856
+ });
1857
+
1794
1858
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1795
1859
 
1796
1860
  it('FTP channel is filtered out of the channel dropdown', async () => {
@@ -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,7 +181,7 @@ const getEmailDomainValue = (entity) => {
181
181
  return emailData.domainProperties?.id ?? emailData.domainId ?? emailData.id ?? '';
182
182
  };
183
183
 
184
- // CCS's domainGatewayMapId is the domainProperties API's outer `id`, distinct from domainProperties.id (the numeric domainId); it identifies the domain-gateway mapping for the selected domain.
184
+ // CCS domainGatewayMapId is the domainProperties API's outer id, not domainProperties.id; it identifies the selected domain's gateway mapping.
185
185
  const getEmailDomainGatewayMapIdByDomainId = (entity, domainId) => {
186
186
  const domainEntry = getEmailDomainById(entity, domainId);
187
187
  return domainEntry?.id ?? '';
@@ -582,7 +582,7 @@ export const parseEntityForDisplay = (entity, context = {}) => {
582
582
  senderDetails: entity,
583
583
  smsSenderId: getSmsSenderIdValue(entity),
584
584
  smsDomain: getSmsDomainValue(entity),
585
- // emailDomain here is the domain NAME (for display only) - buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId, sourced separately below.
585
+ // emailDomain is the display-only domain name; buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId sourced separately.
586
586
  emailDomain: getEmailDomainNameForDisplay(entity),
587
587
  emailDomainId: getEmailDomainValue(entity),
588
588
  emailDomainGatewayMapId: getEmailDomainGatewayMapIdByDomainId(entity, getEmailDomainValue(entity)),
@@ -611,7 +611,7 @@ export const buildChannelSettingFromFieldValues = (fieldValues = {}) => {
611
611
  }
612
612
  if (fieldValues.emailDomain != null || fieldValues.emailSenderId != null || fieldValues.emailSenderName != null || fieldValues.emailReplyToId != null) {
613
613
  channelSetting.EMAIL = {
614
- // emailDomainId (numeric, from auto-save defaults via parseEntityForDisplay) takes precedence over emailDomain, which may be a numeric ID in SenderDetails state or a display name from parseEntityForDisplay.
614
+ // Prefer numeric emailDomainId from auto-save defaults; emailDomain may be either a SenderDetails ID or display name.
615
615
  domainId: fieldValues.emailDomainId ?? fieldValues.emailDomain,
616
616
  domainGatewayMapId: fieldValues.emailDomainGatewayMapId,
617
617
  senderId: fieldValues.emailSenderId,
@@ -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;