@capillarytech/creatives-library 9.0.56-betaa.0 → 9.0.56

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 (116) hide show
  1. package/constants/unified.js +9 -4
  2. package/package.json +1 -1
  3. package/services/api.js +25 -4
  4. package/utils/common.js +0 -12
  5. package/utils/templateVarUtils.js +16 -0
  6. package/utils/tests/templateVarUtils.test.js +44 -0
  7. package/v2Components/CapActionButton/index.js +73 -60
  8. package/v2Components/CapActionButton/index.scss +44 -28
  9. package/v2Components/CapActionButton/messages.js +7 -3
  10. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  11. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  12. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  13. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  14. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +13 -0
  15. package/v2Components/CommonTestAndPreview/actions.js +13 -0
  16. package/v2Components/CommonTestAndPreview/constants.js +8 -0
  17. package/v2Components/CommonTestAndPreview/index.js +55 -3
  18. package/v2Components/CommonTestAndPreview/reducer.js +20 -0
  19. package/v2Components/CommonTestAndPreview/sagas.js +28 -0
  20. package/v2Components/CommonTestAndPreview/selectors.js +6 -0
  21. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +106 -0
  22. package/v2Components/CommonTestAndPreview/tests/actions.test.js +15 -0
  23. package/v2Components/CommonTestAndPreview/tests/constants.test.js +9 -1
  24. package/v2Components/CommonTestAndPreview/tests/index.test.js +101 -0
  25. package/v2Components/CommonTestAndPreview/tests/reducer.test.js +41 -0
  26. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +84 -0
  27. package/v2Components/CommonTestAndPreview/tests/selectors.test.js +12 -0
  28. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +46 -320
  29. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  30. package/v2Components/FormBuilder/Functional/constants.js +8 -43
  31. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  32. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  33. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  34. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  35. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  36. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  37. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  38. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  39. package/v2Components/FormBuilder/index.js +12 -34
  40. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -94
  41. package/v2Components/TestAndPreviewSlidebox/index.js +9 -0
  42. package/v2Containers/CommunicationFlow/CommunicationFlow.js +140 -75
  43. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +18 -2
  44. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +67 -28
  45. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +503 -51
  46. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +98 -4
  47. package/v2Containers/CommunicationFlow/Tests/constants.test.js +85 -0
  48. package/v2Containers/CommunicationFlow/constants.js +65 -4
  49. package/v2Containers/CommunicationFlow/index.js +16 -23
  50. package/v2Containers/CommunicationFlow/messages.js +11 -3
  51. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +27 -11
  52. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +4 -0
  53. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +176 -4
  54. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +22 -4
  55. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +14 -4
  56. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  57. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +51 -0
  58. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +11 -4
  59. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +19 -5
  60. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  61. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  62. package/v2Containers/CreativesContainer/index.js +29 -3
  63. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  64. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +18 -5
  65. package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
  66. package/v2Containers/MobilePush/Create/index.js +18 -1
  67. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  68. package/v2Containers/MobilePush/Edit/index.js +18 -1
  69. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  70. package/v2Containers/MobilePush/commonMethods.js +49 -1
  71. package/v2Containers/MobilePushNew/index.js +29 -4
  72. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  73. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  74. package/v2Containers/MobilePushNew/utils.js +34 -1
  75. package/v2Containers/MobilepushWrapper/index.js +3 -1
  76. package/v2Containers/Rcs/index.js +34 -0
  77. package/v2Containers/Rcs/index.scss +15 -0
  78. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +115 -19
  79. package/v2Containers/Rcs/tests/index.test.js +67 -0
  80. package/v2Containers/Sms/Create/index.js +3 -1
  81. package/v2Containers/Sms/Edit/index.js +25 -0
  82. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  83. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +27 -3
  84. package/v2Containers/Viber/index.js +24 -1
  85. package/v2Containers/Viber/tests/index.test.js +103 -0
  86. package/v2Containers/WebPush/Create/index.js +19 -0
  87. package/v2Containers/WebPush/Create/preview/WebPushPreview.js +17 -12
  88. package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +5 -0
  89. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  90. package/v2Containers/Whatsapp/index.js +25 -4
  91. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +306 -34
  92. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  93. package/v2Containers/Zalo/index.js +28 -1
  94. package/v2Containers/Zalo/tests/index.test.js +99 -0
  95. package/v2Containers/mockdata.js +25 -0
  96. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  97. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -82
  98. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  99. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  100. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -19
  101. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  102. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  103. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  104. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  105. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  106. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  107. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -89
  108. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -440
  109. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  110. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -282
  111. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -141
  112. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  113. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -151
  114. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -342
  115. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  116. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -37,9 +37,21 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
37
37
  handleCloseCreatives,
38
38
  creativesMode,
39
39
  channel,
40
+ templateData,
41
+ hostName,
42
+ messageDetails,
43
+ location,
40
44
  }) {
41
45
  return (
42
- <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
46
+ <div
47
+ data-testid="creatives-mock"
48
+ data-creatives-mode={creativesMode}
49
+ data-creatives-channel={channel}
50
+ data-template-data={JSON.stringify(templateData)}
51
+ data-host-name={hostName}
52
+ data-message-details={JSON.stringify(messageDetails)}
53
+ data-location={JSON.stringify(location)}
54
+ >
43
55
  <button
44
56
  type="button"
45
57
  data-testid="creatives-save"
@@ -62,7 +74,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
62
74
  });
63
75
 
64
76
  jest.mock('../../DeliverySettingsStep', () => ({
65
- DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
77
+ DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
66
78
  return (
67
79
  <div data-testid="delivery-settings-section">
68
80
  <button
@@ -72,6 +84,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
72
84
  >
73
85
  Apply delivery
74
86
  </button>
87
+ <button
88
+ type="button"
89
+ data-testid="domain-properties-loaded"
90
+ onClick={() => onDomainPropertiesLoaded?.({
91
+ ZALO: [{
92
+ id: 267284,
93
+ domainProperties: {
94
+ id: 4977,
95
+ domainName: 'Gapit_Automation',
96
+ connectionProperties: { oa_id: '300086756699856746' },
97
+ hostName: 'gapitzalotrans',
98
+ },
99
+ }],
100
+ })}
101
+ >
102
+ Load domain properties
103
+ </button>
75
104
  </div>
76
105
  );
77
106
  },
@@ -94,7 +123,7 @@ describe('ChannelSelectionStep', () => {
94
123
  console.log.mockRestore();
95
124
  });
96
125
 
97
- it('empty state: shows content template heading and add-template entry point', () => {
126
+ it('empty state: shows the Channel heading and add-template entry point', () => {
98
127
  const onChange = jest.fn();
99
128
  renderStep(
100
129
  <ChannelSelectionStep
@@ -104,7 +133,7 @@ describe('ChannelSelectionStep', () => {
104
133
  />,
105
134
  );
106
135
 
107
- expect(screen.getAllByText('Content template').length).toBeGreaterThanOrEqual(1);
136
+ expect(screen.getAllByText('Channel').length).toBeGreaterThanOrEqual(1);
108
137
  expect(screen.getByRole('button', { name: /add creative/i })).toBeInTheDocument();
109
138
  expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
110
139
  });
@@ -1683,6 +1712,149 @@ describe('ChannelSelectionStep', () => {
1683
1712
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
1684
1713
  });
1685
1714
 
1715
+ it('edit WEBPUSH item includes `channel: WEBPUSH` (not just `type`) in the unwrapped templateData (regression: CreativesContainer.getTemplateData\'s switch reads templateData.channel, not templateData.type — without it the switch never matched WEBPUSH and the edit screen rendered blank below its header)', async () => {
1716
+ renderStep(
1717
+ <ChannelSelectionStep
1718
+ value={{
1719
+ contentItems: [{
1720
+ contentId: 'wp-edit-channel',
1721
+ channel: 'WEBPUSH',
1722
+ templateData: {
1723
+ channel: 'WEBPUSH',
1724
+ messageContent: {
1725
+ content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
1726
+ },
1727
+ },
1728
+ }],
1729
+ }}
1730
+ onChange={jest.fn()}
1731
+ channels={CHANNELS}
1732
+ />,
1733
+ );
1734
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1735
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1736
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1737
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1738
+ const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
1739
+ expect(passedTemplateData.channel).toBe('WEBPUSH');
1740
+ expect(passedTemplateData.type).toBe('WEBPUSH');
1741
+ expect(passedTemplateData.messageSubject).toBe('dasd');
1742
+ expect(passedTemplateData.accountId).toBe(13792);
1743
+ });
1744
+
1745
+ // ── ZALO edit: hostName resolved from domainProperties ────────────────────────
1746
+
1747
+ it('resolves a Zalo item\'s hostName from the domainProperties fetch by matching accountId to connectionProperties.oa_id, and passes it to CreativesContainer (regression: CCS\'s zaloMessageContent never carries hostName, and without it Zalo/index.js\'s getTemplateInfoById guard never passes, so the edit view never loads live template data)', async () => {
1748
+ renderStep(
1749
+ <ChannelSelectionStep
1750
+ value={{
1751
+ contentItems: [{
1752
+ contentId: 'zalo-edit',
1753
+ channel: 'ZALO',
1754
+ templateData: {
1755
+ channel: 'ZALO',
1756
+ accountId: '300086756699856746',
1757
+ accountName: 'gapit_automation_account',
1758
+ token: 'zalo-token',
1759
+ templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
1760
+ },
1761
+ }],
1762
+ }}
1763
+ onChange={jest.fn()}
1764
+ channels={CHANNELS}
1765
+ deliverySettingsData={{ required: false }}
1766
+ />,
1767
+ );
1768
+ await userEvent.click(screen.getByTestId('domain-properties-loaded'));
1769
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1770
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1771
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1772
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1773
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
1774
+ });
1775
+
1776
+ it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
1777
+ renderStep(
1778
+ <ChannelSelectionStep
1779
+ value={{
1780
+ contentItems: [{
1781
+ contentId: 'zalo-edit-no-match',
1782
+ channel: 'ZALO',
1783
+ templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
1784
+ }],
1785
+ }}
1786
+ onChange={jest.fn()}
1787
+ channels={CHANNELS}
1788
+ deliverySettingsData={{ required: false }}
1789
+ />,
1790
+ );
1791
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1792
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1793
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1794
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1795
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1796
+ });
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
+
1686
1858
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1687
1859
 
1688
1860
  it('FTP channel is filtered out of the channel dropdown', async () => {
@@ -33,11 +33,17 @@ const DeliverySettingsSection = ({
33
33
  deliverySettingsData,
34
34
  deliverySetting = {},
35
35
  onDeliverySettingChange,
36
+ onDomainPropertiesLoaded,
37
+ orgUnitId,
36
38
  intl,
37
39
  }) => {
38
40
  const [showSlidebox, setShowSlidebox] = useState(false);
39
41
  const [domainPropertiesData, setDomainPropertiesData] = useState(null);
40
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);
41
47
  const fetchInFlightRef = useRef(false);
42
48
  const lastChannelKeyRef = useRef('');
43
49
  const { formatMessage } = intl || {};
@@ -87,12 +93,16 @@ const DeliverySettingsSection = ({
87
93
  }
88
94
  if (fetchInFlightRef.current) return undefined;
89
95
  fetchInFlightRef.current = true;
96
+ setIsFetchingDomains(true);
90
97
  lastChannelKeyRef.current = deliveryChannelKey;
91
98
  let cancelled = false;
92
99
  const fetchData = async () => {
93
100
  try {
94
- const orgUnitId = loadItem('ouId') || loadItem('orgID');
95
- 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);
96
106
  if (!cancelled) {
97
107
  const raw = response?.entity || response;
98
108
  // Normalize channel keys to uppercase (API may return Viber, viber, etc.)
@@ -102,11 +112,15 @@ const DeliverySettingsSection = ({
102
112
  )
103
113
  : raw;
104
114
  setDomainPropertiesData(entity);
115
+ onDomainPropertiesLoaded?.(entity);
105
116
  }
106
117
  } catch (err) {
107
118
  if (!cancelled) setDomainPropertiesData(null);
108
119
  } finally {
109
- if (!cancelled) fetchInFlightRef.current = false;
120
+ if (!cancelled) {
121
+ fetchInFlightRef.current = false;
122
+ setIsFetchingDomains(false);
123
+ }
110
124
  }
111
125
  };
112
126
  fetchData();
@@ -115,7 +129,7 @@ const DeliverySettingsSection = ({
115
129
  // Do NOT reset fetchInFlightRef here - it would allow duplicate fetches when effect re-runs.
116
130
  // fetchInFlightRef is reset in finally when the fetch completes.
117
131
  };
118
- }, [deliveryEnabled, deliveryChannelKey]); // eslint-disable-line react-hooks/exhaustive-deps
132
+ }, [deliveryEnabled, deliveryChannelKey, orgUnitId]); // eslint-disable-line react-hooks/exhaustive-deps
119
133
 
120
134
  // Fetch WeCRM accounts for VIBER when domainProperties has empty contactInfo
121
135
  const needsWecrmViber = deliveryChannels.includes('VIBER');
@@ -301,6 +315,7 @@ const DeliverySettingsSection = ({
301
315
  onClose={() => setShowSlidebox(false)}
302
316
  channels={deliveryChannels}
303
317
  preloadedDomainProperties={entityWithWecrmViber}
318
+ isLoadingDomainProperties={isFetchingDomains}
304
319
  savedFieldValues={parseChannelSettingForDisplay(deliverySetting?.channelSetting)}
305
320
  whatsappSourceAccountId={whatsappSourceAccountId}
306
321
  whatsappAccountName={whatsappAccNameFromContent || getWhatsappAccountName(entityWithWecrmViber, whatsappSourceAccountId)}
@@ -316,6 +331,8 @@ DeliverySettingsSection.propTypes = {
316
331
  deliverySettingsData: PropTypes.object,
317
332
  deliverySetting: PropTypes.object,
318
333
  onDeliverySettingChange: PropTypes.func,
334
+ onDomainPropertiesLoaded: PropTypes.func,
335
+ orgUnitId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
319
336
  intl: PropTypes.object.isRequired,
320
337
  };
321
338
 
@@ -324,6 +341,7 @@ DeliverySettingsSection.defaultProps = {
324
341
  deliverySettingsData: null,
325
342
  deliverySetting: null,
326
343
  onDeliverySettingChange: null,
344
+ orgUnitId: undefined,
327
345
  };
328
346
 
329
347
  export default injectIntl(DeliverySettingsSection);
@@ -29,6 +29,7 @@ import {
29
29
  getEmailDefaultsForDomainId,
30
30
  getSmsDefaultsForDomainId,
31
31
  getRcsAccountName,
32
+ getEmailDomainGatewayMapIdByDomainId,
32
33
  } from './deliverySettingsConfig';
33
34
  import messages from '../../messages';
34
35
  import './SenderDetails.scss';
@@ -41,6 +42,7 @@ const SenderDetails = ({
41
42
  onClose,
42
43
  channels = [],
43
44
  preloadedDomainProperties,
45
+ isLoadingDomainProperties,
44
46
  savedFieldValues,
45
47
  onSave,
46
48
  whatsappSourceAccountId,
@@ -89,14 +91,15 @@ const SenderDetails = ({
89
91
  setLoading(false);
90
92
  setError(null);
91
93
  } else {
92
- // preloadedDomainProperties is not yet available (or the parent's fetch failed).
93
- // Show nothing rather than an eternal spinner the parent controls data availability.
94
- 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);
95
98
  setEntity(null);
96
99
  setFieldValues({});
97
100
  setInitialFieldValues({});
98
101
  }
99
- }, [show, channels, preloadedDomainProperties, savedFieldValues, fieldConfigs, whatsappAccountName, wabaContext]);
102
+ }, [show, channels, preloadedDomainProperties, isLoadingDomainProperties, savedFieldValues, fieldConfigs, whatsappAccountName, wabaContext]);
100
103
 
101
104
  const handleFieldChange = useCallback((fieldKey, value) => {
102
105
  setFieldValues((prev) => {
@@ -151,6 +154,11 @@ const SenderDetails = ({
151
154
  saveValues.whatsappDomainId = matchedDomain?.domainProperties?.id ?? matchedDomain?.domainId ?? matchedDomain?.id;
152
155
  }
153
156
  }
157
+ // Include EMAIL domainGatewayMapId derived from the selected domain for persistence -
158
+ // CCS needs it alongside domainId, but it's not a user-facing field.
159
+ if (entity && fieldValues.emailDomain != null && fieldValues.emailDomain !== '') {
160
+ saveValues.emailDomainGatewayMapId = getEmailDomainGatewayMapIdByDomainId(entity, fieldValues.emailDomain);
161
+ }
154
162
  onSave(saveValues);
155
163
  }
156
164
  onClose();
@@ -300,6 +308,7 @@ SenderDetails.propTypes = {
300
308
  onClose: PropTypes.func.isRequired,
301
309
  channels: PropTypes.arrayOf(PropTypes.string),
302
310
  preloadedDomainProperties: PropTypes.object,
311
+ isLoadingDomainProperties: PropTypes.bool,
303
312
  savedFieldValues: PropTypes.object,
304
313
  onSave: PropTypes.func,
305
314
  whatsappSourceAccountId: PropTypes.string,
@@ -310,6 +319,7 @@ SenderDetails.propTypes = {
310
319
  SenderDetails.defaultProps = {
311
320
  channels: [],
312
321
  preloadedDomainProperties: null,
322
+ isLoadingDomainProperties: false,
313
323
  savedFieldValues: null,
314
324
  onSave: null,
315
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.
@@ -83,6 +93,7 @@ const ENTITIES = {
83
93
  emailTwoDomains: {
84
94
  EMAIL: [
85
95
  {
96
+ id: 'gwmap-a',
86
97
  domainProperties: {
87
98
  id: 'em-a',
88
99
  domainName: 'Mail Alpha',
@@ -101,6 +112,7 @@ const ENTITIES = {
101
112
  },
102
113
  },
103
114
  {
115
+ id: 'gwmap-b',
104
116
  domainProperties: {
105
117
  id: 'em-b',
106
118
  domainName: 'Mail Beta',
@@ -254,6 +266,22 @@ describe('SenderDetails', () => {
254
266
  expect(screen.queryByText('SMS Domain')).not.toBeInTheDocument();
255
267
  });
256
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
+
257
285
  it('applies savedFieldValues over API defaults for SMS sender', async () => {
258
286
  renderSenderDetails({
259
287
  preloadedDomainProperties: ENTITIES.smsTwoSenders,
@@ -322,6 +350,29 @@ describe('SenderDetails', () => {
322
350
  });
323
351
  });
324
352
 
353
+ it('persists emailDomainGatewayMapId derived from the selected email domain on save', async () => {
354
+ const { onSave } = renderSenderDetails({
355
+ channels: ['EMAIL'],
356
+ preloadedDomainProperties: ENTITIES.emailTwoDomains,
357
+ });
358
+ const root = document.querySelector('.sender-details');
359
+ await waitFor(() => expect(within(root).getByText('Mail Alpha')).toBeInTheDocument());
360
+
361
+ await openSelectAndChoose(root, 0, 'Mail Beta');
362
+ await waitFor(() => expect(within(root).getByText('beta@other.com')).toBeInTheDocument());
363
+
364
+ const saveBtn = within(root).getByRole('button', { name: /save changes/i });
365
+ await waitFor(() => expect(saveBtn).not.toBeDisabled());
366
+ fireEvent.click(saveBtn);
367
+
368
+ expect(onSave).toHaveBeenCalledWith(
369
+ expect.objectContaining({
370
+ emailDomain: 'em-b',
371
+ emailDomainGatewayMapId: 'gwmap-b',
372
+ }),
373
+ );
374
+ });
375
+
325
376
  it('resets email domain row back to the first gateway', async () => {
326
377
  renderSenderDetails({
327
378
  channels: ['EMAIL'],
@@ -438,6 +438,7 @@ describe('deliverySettingsConfig — user-facing flows', () => {
438
438
  smsDomain: 'd1',
439
439
  smsSenderId: 'g1',
440
440
  emailDomain: 'ed',
441
+ emailDomainGatewayMapId: 'gwmap-1',
441
442
  emailSenderId: 'mail@x.com',
442
443
  emailSenderName: 'N',
443
444
  emailReplyToId: 'r@x.com',
@@ -455,9 +456,10 @@ describe('deliverySettingsConfig — user-facing flows', () => {
455
456
  SMS: { domainId: 'd1', gsmSenderId: 'g1' },
456
457
  EMAIL: {
457
458
  domainId: 'ed',
458
- senderEmail: 'mail@x.com',
459
+ domainGatewayMapId: 'gwmap-1',
460
+ senderId: 'mail@x.com',
459
461
  senderLabel: 'N',
460
- senderReplyTo: 'r@x.com',
462
+ replyToId: 'r@x.com',
461
463
  },
462
464
  WHATSAPP: { domainId: 'wd', senderMobNum: '+w' },
463
465
  RCS: { senderMobNum: '+r', rcsSender: '+r' },
@@ -472,9 +474,10 @@ describe('deliverySettingsConfig — user-facing flows', () => {
472
474
  SMS: { domainId: 'd', gsmSenderId: 'g' },
473
475
  EMAIL: {
474
476
  domainId: 'ed',
475
- senderEmail: 'e@e.com',
477
+ domainGatewayMapId: 'gwmap-2',
478
+ senderId: 'e@e.com',
476
479
  senderLabel: 'L',
477
- senderReplyTo: 'r@e.com',
480
+ replyToId: 'r@e.com',
478
481
  },
479
482
  WHATSAPP: { senderMobNum: '+w', domainId: 'wid' },
480
483
  RCS: { rcsSender: '+r' },
@@ -486,6 +489,10 @@ describe('deliverySettingsConfig — user-facing flows', () => {
486
489
  smsDomain: 'd',
487
490
  smsSenderId: 'g',
488
491
  emailDomain: 'ed',
492
+ emailDomainGatewayMapId: 'gwmap-2',
493
+ emailSenderId: 'e@e.com',
494
+ emailSenderName: 'L',
495
+ emailReplyToId: 'r@e.com',
489
496
  whatsappDomainId: 'wid',
490
497
  viberSenderId: 'vx',
491
498
  });
@@ -181,6 +181,12 @@ const getEmailDomainValue = (entity) => {
181
181
  return emailData.domainProperties?.id ?? emailData.domainId ?? emailData.id ?? '';
182
182
  };
183
183
 
184
+ // CCS domainGatewayMapId is the domainProperties API's outer id, not domainProperties.id; it identifies the selected domain's gateway mapping.
185
+ const getEmailDomainGatewayMapIdByDomainId = (entity, domainId) => {
186
+ const domainEntry = getEmailDomainById(entity, domainId);
187
+ return domainEntry?.id ?? '';
188
+ };
189
+
184
190
  /** Domain name for summary display (parseEntityForDisplay) */
185
191
  const getEmailDomainNameForDisplay = (entity) => {
186
192
  const emailData = getEmailData(entity);
@@ -262,6 +268,8 @@ const getEmailDefaultsForDomain = (domainData) => {
262
268
  * context.fieldValues has current form state (e.g. selected emailDomain)
263
269
  * Aligned with adiona-ui/cap-campaigns-v2: default = contactInfo item with default:true, or first
264
270
  */
271
+ export { getEmailDomainGatewayMapIdByDomainId };
272
+
265
273
  export const getDefaultValueForField = (fieldKey, entity, context = {}) => {
266
274
  if (!entity) return '';
267
275
  const fieldValues = context?.fieldValues || {};
@@ -574,7 +582,10 @@ export const parseEntityForDisplay = (entity, context = {}) => {
574
582
  senderDetails: entity,
575
583
  smsSenderId: getSmsSenderIdValue(entity),
576
584
  smsDomain: getSmsDomainValue(entity),
585
+ // emailDomain is the display-only domain name; buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId sourced separately.
577
586
  emailDomain: getEmailDomainNameForDisplay(entity),
587
+ emailDomainId: getEmailDomainValue(entity),
588
+ emailDomainGatewayMapId: getEmailDomainGatewayMapIdByDomainId(entity, getEmailDomainValue(entity)),
578
589
  emailSenderId: getEmailSenderIdValue(entity),
579
590
  viberSenderId: getSenderIdFromContactInfo(entity, 'VIBER'),
580
591
  zaloSenderId: getSenderIdFromContactInfo(entity, 'ZALO'),
@@ -600,10 +611,12 @@ export const buildChannelSettingFromFieldValues = (fieldValues = {}) => {
600
611
  }
601
612
  if (fieldValues.emailDomain != null || fieldValues.emailSenderId != null || fieldValues.emailSenderName != null || fieldValues.emailReplyToId != null) {
602
613
  channelSetting.EMAIL = {
603
- domainId: fieldValues.emailDomain,
604
- senderEmail: fieldValues.emailSenderId,
614
+ // Prefer numeric emailDomainId from auto-save defaults; emailDomain may be either a SenderDetails ID or display name.
615
+ domainId: fieldValues.emailDomainId ?? fieldValues.emailDomain,
616
+ domainGatewayMapId: fieldValues.emailDomainGatewayMapId,
617
+ senderId: fieldValues.emailSenderId,
605
618
  senderLabel: fieldValues.emailSenderName,
606
- senderReplyTo: fieldValues.emailReplyToId,
619
+ replyToId: fieldValues.emailReplyToId,
607
620
  };
608
621
  }
609
622
  if (fieldValues.whatsappSenderNumber != null || fieldValues.whatsappAccount != null) {
@@ -673,9 +686,10 @@ export const parseChannelSettingForDisplay = (channelSetting = {}) => {
673
686
  }
674
687
  if (channelSetting.EMAIL) {
675
688
  out.emailDomain = channelSetting.EMAIL.domainId;
676
- out.emailSenderId = channelSetting.EMAIL.senderEmail;
689
+ out.emailDomainGatewayMapId = channelSetting.EMAIL.domainGatewayMapId;
690
+ out.emailSenderId = channelSetting.EMAIL.senderId;
677
691
  out.emailSenderName = channelSetting.EMAIL.senderLabel;
678
- out.emailReplyToId = channelSetting.EMAIL.senderReplyTo;
692
+ out.emailReplyToId = channelSetting.EMAIL.replyToId;
679
693
  }
680
694
  if (channelSetting.WHATSAPP) {
681
695
  out.whatsappSenderNumber = channelSetting.WHATSAPP.senderMobNum;