@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.
- package/package.json +1 -1
- package/services/api.js +1 -1
- package/utils/templateVarUtils.js +1 -17
- package/v2Components/CapActionButton/index.js +73 -60
- package/v2Components/CapActionButton/index.scss +44 -28
- package/v2Components/CapActionButton/messages.js +7 -3
- package/v2Components/CapActionButton/tests/index.test.js +17 -1
- package/v2Components/CapWhatsappCTA/messages.js +4 -0
- package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
- package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
- package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -2
- package/v2Components/CommonTestAndPreview/index.js +3 -6
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -0
- package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -14
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -2
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +35 -3
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
- package/v2Containers/CommunicationFlow/constants.js +1 -3
- package/v2Containers/CommunicationFlow/messages.js +4 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +5 -1
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +64 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +19 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +8 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +26 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +3 -3
- package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
- package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
- package/v2Containers/CreativesContainer/index.js +27 -0
- package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
- package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +13 -0
- package/v2Containers/MobilePush/Create/index.js +18 -1
- package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
- package/v2Containers/MobilePush/Edit/index.js +18 -1
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
- package/v2Containers/MobilePush/commonMethods.js +49 -1
- package/v2Containers/MobilePushNew/index.js +29 -4
- package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
- package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
- package/v2Containers/MobilePushNew/utils.js +34 -1
- package/v2Containers/MobilepushWrapper/index.js +3 -1
- package/v2Containers/Rcs/index.js +34 -0
- package/v2Containers/Rcs/index.scss +15 -0
- package/v2Containers/Rcs/tests/index.test.js +67 -0
- package/v2Containers/Sms/Create/index.js +3 -1
- package/v2Containers/Sms/Edit/index.js +25 -0
- package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
- package/v2Containers/Viber/index.js +24 -1
- package/v2Containers/Viber/tests/index.test.js +103 -0
- package/v2Containers/WebPush/Create/index.js +24 -0
- package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
- package/v2Containers/Whatsapp/index.js +20 -4
- package/v2Containers/Whatsapp/tests/index.test.js +115 -0
- package/v2Containers/Zalo/index.js +28 -1
- 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((
|
|
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('
|
|
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(
|
|
709
|
-
expect(onSave).
|
|
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
|
|
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) {
|
package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js
CHANGED
|
@@ -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 () => {
|
package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
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)
|
|
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
|
|
94
|
-
//
|
|
95
|
-
|
|
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
|
|
package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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;
|