@capillarytech/creatives-library 9.0.56-alpha.7 → 9.0.56-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- 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/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -1
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +58 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +3 -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/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 +19 -1
- 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
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,6 +181,8 @@ export function SlideBoxContent(props) {
|
|
|
181
181
|
onPersonalizationTokensChange,
|
|
182
182
|
isTestAndPreviewMode,
|
|
183
183
|
onHtmlEditorValidationStateChange,
|
|
184
|
+
onEmbeddedSmsFooterValidity,
|
|
185
|
+
onContentValidityChange,
|
|
184
186
|
} = props;
|
|
185
187
|
const localTemplatesConfig = props.localTemplatesConfig || pick(props, constants.LOCAL_TEMPLATE_CONFIG_KEYS);
|
|
186
188
|
const useLocalTemplates = !!get(localTemplatesConfig, 'useLocalTemplates');
|
|
@@ -456,6 +458,7 @@ export function SlideBoxContent(props) {
|
|
|
456
458
|
eventContextTags,
|
|
457
459
|
waitEventContextTags,
|
|
458
460
|
handleClose,
|
|
461
|
+
onContentValidityChange,
|
|
459
462
|
};
|
|
460
463
|
|
|
461
464
|
return (
|
|
@@ -602,6 +605,7 @@ export function SlideBoxContent(props) {
|
|
|
602
605
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
603
606
|
isTestAndPreviewMode={isTestAndPreviewMode}
|
|
604
607
|
onValidationFail={onValidationFail}
|
|
608
|
+
onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity}
|
|
605
609
|
/>
|
|
606
610
|
)}
|
|
607
611
|
{isEditFTP && (
|
|
@@ -644,6 +648,7 @@ export function SlideBoxContent(props) {
|
|
|
644
648
|
showTestAndPreviewSlidebox={showTestAndPreviewSlidebox}
|
|
645
649
|
handleTestAndPreview={handleTestAndPreview}
|
|
646
650
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
651
|
+
onContentValidityChange={onContentValidityChange}
|
|
647
652
|
/>
|
|
648
653
|
}
|
|
649
654
|
{
|
|
@@ -667,6 +672,7 @@ export function SlideBoxContent(props) {
|
|
|
667
672
|
templateData={templateData}
|
|
668
673
|
isGetFormData={isGetFormData}
|
|
669
674
|
getFormSubscriptionData={getFormData}
|
|
675
|
+
onEmbeddedSmsFooterValidity={onEmbeddedSmsFooterValidity}
|
|
670
676
|
getLiquidTags={getLiquidTags}
|
|
671
677
|
getDefaultTags={type}
|
|
672
678
|
isFullMode={isFullMode}
|
|
@@ -866,6 +872,7 @@ export function SlideBoxContent(props) {
|
|
|
866
872
|
restrictPersonalization={restrictPersonalization}
|
|
867
873
|
isAnonymousType={isAnonymousType}
|
|
868
874
|
onPersonalizationTokensChange={onPersonalizationTokensChange}
|
|
875
|
+
onContentValidityChange={onContentValidityChange}
|
|
869
876
|
/>
|
|
870
877
|
) : (
|
|
871
878
|
<MobilePushNew
|
|
@@ -897,6 +904,7 @@ export function SlideBoxContent(props) {
|
|
|
897
904
|
restrictPersonalization={restrictPersonalization}
|
|
898
905
|
isAnonymousType={isAnonymousType}
|
|
899
906
|
onPersonalizationTokensChange={onPersonalizationTokensChange}
|
|
907
|
+
onContentValidityChange={onContentValidityChange}
|
|
900
908
|
/>
|
|
901
909
|
)
|
|
902
910
|
)}
|
|
@@ -939,6 +947,7 @@ export function SlideBoxContent(props) {
|
|
|
939
947
|
restrictPersonalization={restrictPersonalization}
|
|
940
948
|
isAnonymousType={isAnonymousType}
|
|
941
949
|
onPersonalizationTokensChange={onPersonalizationTokensChange}
|
|
950
|
+
onContentValidityChange={onContentValidityChange}
|
|
942
951
|
/>
|
|
943
952
|
) : (
|
|
944
953
|
<MobilePushNew
|
|
@@ -976,6 +985,7 @@ export function SlideBoxContent(props) {
|
|
|
976
985
|
creativesMode={creativesMode}
|
|
977
986
|
restrictPersonalization={restrictPersonalization}
|
|
978
987
|
isAnonymousType={isAnonymousType}
|
|
988
|
+
onContentValidityChange={onContentValidityChange}
|
|
979
989
|
/>
|
|
980
990
|
)
|
|
981
991
|
)}
|
|
@@ -1085,6 +1095,7 @@ export function SlideBoxContent(props) {
|
|
|
1085
1095
|
showTestAndPreviewSlidebox={showTestAndPreviewSlidebox}
|
|
1086
1096
|
handleTestAndPreview={handleTestAndPreview}
|
|
1087
1097
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
1098
|
+
onContentValidityChange={onContentValidityChange}
|
|
1088
1099
|
createNew/>
|
|
1089
1100
|
)}
|
|
1090
1101
|
|
|
@@ -1107,6 +1118,7 @@ export function SlideBoxContent(props) {
|
|
|
1107
1118
|
eventContextTags={eventContextTags}
|
|
1108
1119
|
waitEventContextTags={waitEventContextTags}
|
|
1109
1120
|
showLiquidErrorInFooter={showLiquidErrorInFooter}
|
|
1121
|
+
onContentValidityChange={onContentValidityChange}
|
|
1110
1122
|
createNew/> }
|
|
1111
1123
|
|
|
1112
1124
|
{isCreateWhatsapp && (<Whatsapp
|
|
@@ -1120,6 +1132,7 @@ export function SlideBoxContent(props) {
|
|
|
1120
1132
|
handleTestAndPreview={handleTestAndPreview}
|
|
1121
1133
|
handleCloseTestAndPreview={handleCloseTestAndPreview}
|
|
1122
1134
|
isTestAndPreviewMode={isTestAndPreviewMode}
|
|
1135
|
+
onContentValidityChange={onContentValidityChange}
|
|
1123
1136
|
/>
|
|
1124
1137
|
)}
|
|
1125
1138
|
|
|
@@ -1145,6 +1158,7 @@ export function SlideBoxContent(props) {
|
|
|
1145
1158
|
search: '',
|
|
1146
1159
|
}}
|
|
1147
1160
|
showLiquidErrorInFooter={showLiquidErrorInFooter}
|
|
1161
|
+
onContentValidityChange={onContentValidityChange}
|
|
1148
1162
|
/>
|
|
1149
1163
|
)}
|
|
1150
1164
|
|
|
@@ -1270,6 +1284,7 @@ export function SlideBoxContent(props) {
|
|
|
1270
1284
|
restrictPersonalization={restrictPersonalization}
|
|
1271
1285
|
isAnonymousType={isAnonymousType}
|
|
1272
1286
|
waitEventContextTags={waitEventContextTags}
|
|
1287
|
+
onContentValidityChange={onContentValidityChange}
|
|
1273
1288
|
/>
|
|
1274
1289
|
)}
|
|
1275
1290
|
{isCreateRcs && (<Rcs
|
|
@@ -51,6 +51,8 @@ function SlideBoxFooter(props) {
|
|
|
51
51
|
hasPersonalizationTokenError: hasPersonalizationTokenErrorProp = false,
|
|
52
52
|
/** When set (e.g. SMS library create), overrides `creativesTemplatesSave` (“Done”) for the primary button */
|
|
53
53
|
primarySaveButtonMessage,
|
|
54
|
+
/** Live content-validity signal reported by the channel's own editor (currently SMS create) — true disables Save/Done. */
|
|
55
|
+
isContentInvalid = false,
|
|
54
56
|
} = props;
|
|
55
57
|
// Calculate if buttons should be disabled
|
|
56
58
|
// Only apply validation state checks for EMAIL channel in HTML Editor mode (not BEE/DragDrop)
|
|
@@ -193,7 +195,7 @@ function SlideBoxFooter(props) {
|
|
|
193
195
|
<CapRow useLegacy>
|
|
194
196
|
<CapButton
|
|
195
197
|
onClick={onSave}
|
|
196
|
-
disabled={isTemplateNameEmpty || fetchingCmsData || shouldDisableButtons || hasPersonalizationTokenError}
|
|
198
|
+
disabled={isTemplateNameEmpty || fetchingCmsData || shouldDisableButtons || hasPersonalizationTokenError || isContentInvalid}
|
|
197
199
|
>
|
|
198
200
|
{getSaveButtonLabel()}
|
|
199
201
|
</CapButton>
|
|
@@ -201,7 +203,7 @@ function SlideBoxFooter(props) {
|
|
|
201
203
|
<CapButton
|
|
202
204
|
type="secondary"
|
|
203
205
|
onClick={onTestAndPreview}
|
|
204
|
-
disabled={shouldDisableButtons || hasPersonalizationTokenError}
|
|
206
|
+
disabled={shouldDisableButtons || hasPersonalizationTokenError || isContentInvalid}
|
|
205
207
|
style={{ marginLeft: '8px' }}
|
|
206
208
|
>
|
|
207
209
|
<FormattedMessage {...messages.testAndPreview} />
|
|
@@ -272,6 +274,7 @@ SlideBoxFooter.propTypes = {
|
|
|
272
274
|
id: PropTypes.string,
|
|
273
275
|
defaultMessage: PropTypes.string,
|
|
274
276
|
}),
|
|
277
|
+
isContentInvalid: PropTypes.bool,
|
|
275
278
|
};
|
|
276
279
|
|
|
277
280
|
SlideBoxFooter.defaultProps = {
|
|
@@ -300,5 +303,6 @@ SlideBoxFooter.defaultProps = {
|
|
|
300
303
|
formData: [],
|
|
301
304
|
hasPersonalizationTokenError: false,
|
|
302
305
|
primarySaveButtonMessage: undefined,
|
|
306
|
+
isContentInvalid: false,
|
|
303
307
|
};
|
|
304
308
|
export default SlideBoxFooter;
|
|
@@ -197,6 +197,9 @@ export class Creatives extends React.Component {
|
|
|
197
197
|
errorsAcknowledged: false, // Flag to track if user has acknowledged errors by clicking redirection icon
|
|
198
198
|
},
|
|
199
199
|
hasPersonalizationTokenError: false, // Track personalization token errors in form
|
|
200
|
+
// Live content-validity signal for channels that report it (currently SMS create) —
|
|
201
|
+
// false by default so unwired channels are never blocked by this flag.
|
|
202
|
+
isContentInvalid: false,
|
|
200
203
|
};
|
|
201
204
|
this.creativesTemplateSteps = {
|
|
202
205
|
1: 'modeSelection',
|
|
@@ -1893,6 +1896,26 @@ export class Creatives extends React.Component {
|
|
|
1893
1896
|
this.setState({ isDiscardMessage: true });
|
|
1894
1897
|
}
|
|
1895
1898
|
|
|
1899
|
+
// Reported live by SMS's own content editor (Sms/Create and Sms/Edit's componentDidUpdate)
|
|
1900
|
+
// so the Done/Save button reflects an empty/invalid form immediately, instead of only
|
|
1901
|
+
// failing silently on click.
|
|
1902
|
+
handleEmbeddedSmsFooterValidity = (validity) => {
|
|
1903
|
+
const isContentInvalid = !!validity?.isMessageEmpty;
|
|
1904
|
+
if (this.state.isContentInvalid !== isContentInvalid) {
|
|
1905
|
+
this.setState({ isContentInvalid });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Generic counterpart of the above for every other channel wrapper (Email, MobilePush,
|
|
1910
|
+
// Zalo, WebPush, RCS, WhatsApp, Viber) — each reports { isContentEmpty } live from its own
|
|
1911
|
+
// form state so the Done/Preview-and-test buttons disable immediately, not just on click.
|
|
1912
|
+
handleContentValidityChange = (validity) => {
|
|
1913
|
+
const isContentInvalid = !!validity?.isContentEmpty;
|
|
1914
|
+
if (this.state.isContentInvalid !== isContentInvalid) {
|
|
1915
|
+
this.setState({ isContentInvalid });
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1896
1919
|
|
|
1897
1920
|
// NEW: Handler for Test and Preview button
|
|
1898
1921
|
handleTestAndPreview = () => {
|
|
@@ -2213,6 +2236,7 @@ export class Creatives extends React.Component {
|
|
|
2213
2236
|
isTestAndPreviewMode,
|
|
2214
2237
|
inAppEditorType,
|
|
2215
2238
|
htmlEditorValidationState,
|
|
2239
|
+
isContentInvalid,
|
|
2216
2240
|
} = this.state;
|
|
2217
2241
|
const useLocalTemplates = get(
|
|
2218
2242
|
this.props,
|
|
@@ -2410,6 +2434,8 @@ export class Creatives extends React.Component {
|
|
|
2410
2434
|
handleCloseTestAndPreview={this.handleCloseTestAndPreview}
|
|
2411
2435
|
isTestAndPreviewMode={(() => this.state.isTestAndPreviewMode)()}
|
|
2412
2436
|
onHtmlEditorValidationStateChange={this.updateHtmlEditorValidationState}
|
|
2437
|
+
onEmbeddedSmsFooterValidity={this.handleEmbeddedSmsFooterValidity}
|
|
2438
|
+
onContentValidityChange={this.handleContentValidityChange}
|
|
2413
2439
|
onPersonalizationTokensChange={this.handlePersonalizationTokensChange}
|
|
2414
2440
|
localTemplatesConfig={pick(this.props.localTemplatesConfig || this.props, constants.LOCAL_TEMPLATE_CONFIG_KEYS)}
|
|
2415
2441
|
/>
|
|
@@ -2432,6 +2458,7 @@ export class Creatives extends React.Component {
|
|
|
2432
2458
|
shouldShowDoneFooter={this.shouldShowDoneFooter}
|
|
2433
2459
|
fetchingCmsData={fetchingCmsData}
|
|
2434
2460
|
isTemplateNameEmpty={isTemplateNameEmpty}
|
|
2461
|
+
isContentInvalid={isContentInvalid}
|
|
2435
2462
|
isLiquidValidationError={isLiquidValidationError}
|
|
2436
2463
|
errorMessages={liquidErrorMessage}
|
|
2437
2464
|
currentTab={activeFormBuilderTab}
|
|
@@ -658,4 +658,33 @@ describe('SlideBoxFooter — isBEEEditor detection in create mode via emailCreat
|
|
|
658
658
|
expect(screen.getByRole('button', { name: /done/i })).toBeInTheDocument();
|
|
659
659
|
});
|
|
660
660
|
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe('SlideBoxFooter — isContentInvalid (live content-validity signal, e.g. empty SMS body)', () => {
|
|
664
|
+
it('disables Save/Done when isContentInvalid is true, independent of currentChannel/htmlEditorValidationState', () => {
|
|
665
|
+
renderComponent({
|
|
666
|
+
...baseFooterProps,
|
|
667
|
+
currentChannel: 'SMS',
|
|
668
|
+
isContentInvalid: true,
|
|
669
|
+
});
|
|
670
|
+
expect(screen.getByRole('button', { name: /update/i })).toBeDisabled();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('disables the Test & Preview button when isContentInvalid is true', () => {
|
|
674
|
+
renderComponent({
|
|
675
|
+
...baseFooterProps,
|
|
676
|
+
currentChannel: 'SMS',
|
|
677
|
+
isContentInvalid: true,
|
|
678
|
+
showTestAndPreviewButton: true,
|
|
679
|
+
});
|
|
680
|
+
expect(screen.getByRole('button', { name: /preview.*test/i })).toBeDisabled();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('leaves Save/Done enabled when isContentInvalid is false (default)', () => {
|
|
684
|
+
renderComponent({
|
|
685
|
+
...baseFooterProps,
|
|
686
|
+
currentChannel: 'SMS',
|
|
687
|
+
});
|
|
688
|
+
expect(screen.getByRole('button', { name: /update/i })).not.toBeDisabled();
|
|
689
|
+
});
|
|
661
690
|
});
|
|
@@ -28,10 +28,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click rcs
|
|
|
28
28
|
}
|
|
29
29
|
onCallTaskSubmit={[Function]}
|
|
30
30
|
onChannelChange={[Function]}
|
|
31
|
+
onContentValidityChange={[Function]}
|
|
31
32
|
onCreateComplete={[MockFunction]}
|
|
32
33
|
onCreateNew={[Function]}
|
|
33
34
|
onCreateNextStep={[Function]}
|
|
34
35
|
onEmailModeChange={[Function]}
|
|
36
|
+
onEmbeddedSmsFooterValidity={[Function]}
|
|
35
37
|
onEnterTemplateName={[Function]}
|
|
36
38
|
onFTPSubmit={[MockFunction]}
|
|
37
39
|
onFacebookSubmit={[MockFunction]}
|
|
@@ -122,10 +124,12 @@ exports[`Test SlideBoxContent container campaign message, add creative click wha
|
|
|
122
124
|
}
|
|
123
125
|
onCallTaskSubmit={[Function]}
|
|
124
126
|
onChannelChange={[Function]}
|
|
127
|
+
onContentValidityChange={[Function]}
|
|
125
128
|
onCreateComplete={[MockFunction]}
|
|
126
129
|
onCreateNew={[Function]}
|
|
127
130
|
onCreateNextStep={[Function]}
|
|
128
131
|
onEmailModeChange={[Function]}
|
|
132
|
+
onEmbeddedSmsFooterValidity={[Function]}
|
|
129
133
|
onEnterTemplateName={[Function]}
|
|
130
134
|
onFTPSubmit={[MockFunction]}
|
|
131
135
|
onFacebookSubmit={[MockFunction]}
|
|
@@ -216,10 +220,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
|
|
|
216
220
|
}
|
|
217
221
|
onCallTaskSubmit={[Function]}
|
|
218
222
|
onChannelChange={[Function]}
|
|
223
|
+
onContentValidityChange={[Function]}
|
|
219
224
|
onCreateComplete={[MockFunction]}
|
|
220
225
|
onCreateNew={[Function]}
|
|
221
226
|
onCreateNextStep={[Function]}
|
|
222
227
|
onEmailModeChange={[Function]}
|
|
228
|
+
onEmbeddedSmsFooterValidity={[Function]}
|
|
223
229
|
onEnterTemplateName={[Function]}
|
|
224
230
|
onFTPSubmit={[MockFunction]}
|
|
225
231
|
onFacebookSubmit={[MockFunction]}
|
|
@@ -281,6 +287,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit all data
|
|
|
281
287
|
}
|
|
282
288
|
}
|
|
283
289
|
isAnonymousType={false}
|
|
290
|
+
isContentInvalid={false}
|
|
284
291
|
isContinueButtonDisabled={false}
|
|
285
292
|
isCreatingTemplate={false}
|
|
286
293
|
isEmptyContent={false}
|
|
@@ -353,10 +360,12 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
|
|
|
353
360
|
}
|
|
354
361
|
onCallTaskSubmit={[Function]}
|
|
355
362
|
onChannelChange={[Function]}
|
|
363
|
+
onContentValidityChange={[Function]}
|
|
356
364
|
onCreateComplete={[MockFunction]}
|
|
357
365
|
onCreateNew={[Function]}
|
|
358
366
|
onCreateNextStep={[Function]}
|
|
359
367
|
onEmailModeChange={[Function]}
|
|
368
|
+
onEmbeddedSmsFooterValidity={[Function]}
|
|
360
369
|
onEnterTemplateName={[Function]}
|
|
361
370
|
onFTPSubmit={[MockFunction]}
|
|
362
371
|
onFacebookSubmit={[MockFunction]}
|
|
@@ -418,6 +427,7 @@ exports[`Test SlideBoxContent container campaign message, whatsapp edit min data
|
|
|
418
427
|
}
|
|
419
428
|
}
|
|
420
429
|
isAnonymousType={false}
|
|
430
|
+
isContentInvalid={false}
|
|
421
431
|
isContinueButtonDisabled={false}
|
|
422
432
|
isCreatingTemplate={false}
|
|
423
433
|
isEmptyContent={false}
|
|
@@ -490,10 +500,12 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
|
|
|
490
500
|
}
|
|
491
501
|
onCallTaskSubmit={[Function]}
|
|
492
502
|
onChannelChange={[Function]}
|
|
503
|
+
onContentValidityChange={[Function]}
|
|
493
504
|
onCreateComplete={[MockFunction]}
|
|
494
505
|
onCreateNew={[Function]}
|
|
495
506
|
onCreateNextStep={[Function]}
|
|
496
507
|
onEmailModeChange={[Function]}
|
|
508
|
+
onEmbeddedSmsFooterValidity={[Function]}
|
|
497
509
|
onEnterTemplateName={[Function]}
|
|
498
510
|
onFTPSubmit={[MockFunction]}
|
|
499
511
|
onFacebookSubmit={[MockFunction]}
|
|
@@ -555,6 +567,7 @@ exports[`Test SlideBoxContent container it should clear the url, on channel chan
|
|
|
555
567
|
}
|
|
556
568
|
}
|
|
557
569
|
isAnonymousType={false}
|
|
570
|
+
isContentInvalid={false}
|
|
558
571
|
isContinueButtonDisabled={false}
|
|
559
572
|
isCreatingTemplate={false}
|
|
560
573
|
isEmptyContent={false}
|
|
@@ -30,7 +30,7 @@ import getEventsMap from '../eventsMap';
|
|
|
30
30
|
import { GA } from '@capillarytech/cap-ui-utils';
|
|
31
31
|
import { CREATE, TRACK_CREATE_MPUSH } from '../../App/constants';
|
|
32
32
|
import { MOBILE_PUSH } from '../../CreativesContainer/constants';
|
|
33
|
-
import { getContent } from '../commonMethods';
|
|
33
|
+
import { getContent, getMobilePushEmbeddedContentValidity } from '../commonMethods';
|
|
34
34
|
import { getCdnUrl } from '../../../utils/cdnTransformation';
|
|
35
35
|
import injectReducer from '../../../utils/injectReducer';
|
|
36
36
|
import injectSaga from '../../../utils/injectSaga';
|
|
@@ -67,6 +67,22 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
|
|
|
67
67
|
}
|
|
68
68
|
this.hasFetchedInitialTagsRef = false;
|
|
69
69
|
this.lastFetchedTagContextRef = null;
|
|
70
|
+
// Tracks the last content-emptiness value reported to the parent so componentDidUpdate
|
|
71
|
+
// does not dispatch on every render. Intentionally undefined (not true) so the first
|
|
72
|
+
// render always reports the real validity rather than assuming the form starts invalid.
|
|
73
|
+
this._lastReportedMobilePushContentEmpty = undefined;
|
|
74
|
+
}
|
|
75
|
+
componentDidUpdate() {
|
|
76
|
+
// Reports live form validity so the host (CreativesContainer) can disable Done/Preview
|
|
77
|
+
// and Test as soon as the message content is cleared — mirrors Sms/Create's own reporting.
|
|
78
|
+
if (typeof this.props.onContentValidityChange !== 'function') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const validity = getMobilePushEmbeddedContentValidity(this.state?.formData, this.state?.tabCount);
|
|
82
|
+
const isContentEmpty = !!validity.isContentEmpty;
|
|
83
|
+
if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
|
|
84
|
+
this._lastReportedMobilePushContentEmpty = isContentEmpty;
|
|
85
|
+
this.props.onContentValidityChange({ isContentEmpty });
|
|
70
86
|
}
|
|
71
87
|
componentWillMount = () => {
|
|
72
88
|
if (this.props.route.name === 'view') {
|
|
@@ -2072,6 +2088,7 @@ Create.propTypes = {
|
|
|
2072
2088
|
getFormLibraryData: PropTypes.func,
|
|
2073
2089
|
onPreviewContentClicked: PropTypes.func,
|
|
2074
2090
|
onTestContentClicked: PropTypes.func,
|
|
2091
|
+
onContentValidityChange: PropTypes.func,
|
|
2075
2092
|
eventContextTags: PropTypes.array,
|
|
2076
2093
|
waitEventContextTags: PropTypes.object,
|
|
2077
2094
|
getLiquidTags: PropTypes.func,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { shallow } from 'enzyme';
|
|
3
|
+
import { Create } from '../index';
|
|
4
|
+
|
|
5
|
+
// Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
|
|
6
|
+
// only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
|
|
7
|
+
// Mirrors app/v2Containers/Sms/Create/index.js's own reporting and
|
|
8
|
+
// app/v2Containers/MobilePush/Edit/test/contentValidity.test.js.
|
|
9
|
+
|
|
10
|
+
const baseProps = () => ({
|
|
11
|
+
actions: {},
|
|
12
|
+
globalActions: {
|
|
13
|
+
fetchSchemaForEntity: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
Templates: { selectedWeChatAccount: { id: 'acc-1' } },
|
|
16
|
+
Create: {},
|
|
17
|
+
params: {},
|
|
18
|
+
location: { query: { type: 'library', module: 'default' } },
|
|
19
|
+
route: { name: 'mobilepush' },
|
|
20
|
+
router: { push: jest.fn() },
|
|
21
|
+
metaEntities: {},
|
|
22
|
+
intl: { formatMessage: (m) => (m && (m.defaultMessage || m.id)) || '' },
|
|
23
|
+
isFullMode: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('MobilePush/Create — onContentValidityChange reporting', () => {
|
|
27
|
+
it('reports isContentEmpty: true on mount when both Android title and message are empty', () => {
|
|
28
|
+
const onContentValidityChange = jest.fn();
|
|
29
|
+
const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
30
|
+
wrapper.setState({
|
|
31
|
+
formData: { 0: { 'message-title': '', 'message-editor': '' } },
|
|
32
|
+
tabCount: 1,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
36
|
+
expect.objectContaining({ isContentEmpty: true }),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reports isContentEmpty: false once the Android message has content', () => {
|
|
41
|
+
const onContentValidityChange = jest.fn();
|
|
42
|
+
const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
43
|
+
wrapper.setState({
|
|
44
|
+
formData: { 0: { 'message-title': '', 'message-editor': '' } },
|
|
45
|
+
tabCount: 1,
|
|
46
|
+
});
|
|
47
|
+
onContentValidityChange.mockClear();
|
|
48
|
+
|
|
49
|
+
wrapper.setState({
|
|
50
|
+
formData: { 0: { 'message-title': 'Hello', 'message-editor': 'World' } },
|
|
51
|
+
tabCount: 1,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({ isContentEmpty: false }),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('re-reports empty when a previously entered message is cleared out', () => {
|
|
60
|
+
const onContentValidityChange = jest.fn();
|
|
61
|
+
const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
62
|
+
wrapper.setState({
|
|
63
|
+
formData: { 0: { 'message-title': 'Existing', 'message-editor': 'content' } },
|
|
64
|
+
tabCount: 1,
|
|
65
|
+
});
|
|
66
|
+
onContentValidityChange.mockClear();
|
|
67
|
+
|
|
68
|
+
wrapper.setState({
|
|
69
|
+
formData: { 0: { 'message-title': '', 'message-editor': '' } },
|
|
70
|
+
tabCount: 1,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(onContentValidityChange).toHaveBeenCalledWith(
|
|
74
|
+
expect.objectContaining({ isContentEmpty: true }),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not throw and does not report when onContentValidityChange is not provided', () => {
|
|
79
|
+
const wrapper = shallow(<Create {...baseProps()} />);
|
|
80
|
+
expect(() => {
|
|
81
|
+
wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
|
|
82
|
+
}).not.toThrow();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not dispatch again on a re-render that does not change validity (dedup guard)', () => {
|
|
86
|
+
const onContentValidityChange = jest.fn();
|
|
87
|
+
const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
|
|
88
|
+
wrapper.setState({ formData: { 0: { 'message-title': '', 'message-editor': '' } }, tabCount: 1 });
|
|
89
|
+
onContentValidityChange.mockClear();
|
|
90
|
+
|
|
91
|
+
// Unrelated state change — content emptiness is unchanged.
|
|
92
|
+
wrapper.setState({ currentTab: 1 });
|
|
93
|
+
|
|
94
|
+
expect(onContentValidityChange).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|