@capillarytech/creatives-library 9.0.59-alpha.2 → 9.0.60
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/constants/unified.js +2 -3
- package/package.json +1 -1
- package/services/api.js +1 -3
- package/utils/common.js +0 -8
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
- package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
- package/v2Components/FormBuilder/Functional/constants.js +8 -46
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
- package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
- package/v2Components/FormBuilder/_formBuilder.scss +0 -11
- package/v2Components/FormBuilder/index.js +13 -20
- package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +13 -32
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -36
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -0
- package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
- package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +1 -5
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +28 -0
- package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +29 -0
- package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +6 -13
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* monolith, frozen) and the new functional implementation (./Functional)
|
|
7
7
|
* based on the per-channel, per-org feature flag.
|
|
8
8
|
*
|
|
9
|
-
* Phase 1
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* flag — keeps using Classic with byte-identical behavior. The flags default
|
|
9
|
+
* Phase 1 migrates SMS only, behind `ENABLE_NEW_FORMBUILDER_SMS`. The new path
|
|
10
|
+
* is taken ONLY when (a) the org has that flag AND (b) this FormBuilder instance
|
|
11
|
+
* is rendering the SMS channel. Every other channel — and every org without the
|
|
12
|
+
* flag — keeps using Classic with byte-identical behavior. The flag defaults
|
|
14
13
|
* OFF, so until rollout this gate always renders Classic.
|
|
15
14
|
*
|
|
16
15
|
* This component is a pure pass-through: no defaultProps, no prop mapping, no
|
|
@@ -22,30 +21,24 @@
|
|
|
22
21
|
import React, { useState } from 'react';
|
|
23
22
|
import ClassicFormBuilder from './Classic';
|
|
24
23
|
import FunctionalFormBuilder from './Functional';
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
hasNewFormBuilderEnabledForMpush,
|
|
28
|
-
} from '../../utils/common';
|
|
29
|
-
import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
|
|
24
|
+
import { hasNewFormBuilderEnabledForSms } from '../../utils/common';
|
|
25
|
+
import { SMS } from '../../v2Containers/CreativesContainer/constants';
|
|
30
26
|
|
|
31
27
|
const FormBuilder = (props) => {
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const [newBuilderFlags] = useState(() => {
|
|
28
|
+
// The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
|
|
29
|
+
const [isSmsNewBuilderOrg] = useState(() => {
|
|
35
30
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
[MOBILE_PUSH]: Boolean(hasNewFormBuilderEnabledForMpush()),
|
|
39
|
-
};
|
|
31
|
+
console.log('### ENABLE_NEW_FORMBUILDER_SMS enabled for org:', hasNewFormBuilderEnabledForSms());
|
|
32
|
+
return Boolean(hasNewFormBuilderEnabledForSms());
|
|
40
33
|
} catch (e) {
|
|
41
|
-
return
|
|
34
|
+
return false;
|
|
42
35
|
}
|
|
43
36
|
});
|
|
44
37
|
|
|
45
|
-
// Derived every render: the schema (and therefore the channel) arrives asynchronously.
|
|
46
38
|
const channel = (props?.channel || props.schema?.channel)?.toUpperCase();
|
|
47
39
|
|
|
48
|
-
|
|
40
|
+
const useNewFormBuilder = isSmsNewBuilderOrg && channel === SMS;
|
|
41
|
+
return useNewFormBuilder ? (
|
|
49
42
|
<FunctionalFormBuilder {...props} />
|
|
50
43
|
) : (
|
|
51
44
|
<ClassicFormBuilder {...props} />
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* implementations are mocked with trivial stubs so this stays a fast unit test
|
|
6
6
|
* of the gate, independent of the 4,400-line monolith.
|
|
7
7
|
*
|
|
8
|
-
* Contract pinned here
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
8
|
+
* Contract pinned here:
|
|
9
|
+
* - flag OFF -> Classic, for every channel (the default until rollout)
|
|
10
|
+
* - flag ON + SMS channel (via `channel` prop OR `schema.channel`) -> Functional
|
|
11
|
+
* - flag ON + non-SMS channel -> Classic (other channels untouched)
|
|
12
12
|
* - flag ON + channel unknown (async schema not yet arrived) -> Classic
|
|
13
13
|
* - flag read throws (bootstrap/test race) -> Classic, no crash
|
|
14
14
|
*/
|
|
@@ -25,26 +25,20 @@ jest.mock('../Functional', () => (props) => (
|
|
|
25
25
|
<div data-testid="impl-functional">functional:{(props.schema && props.schema.channel) || props.channel || ''}</div>
|
|
26
26
|
));
|
|
27
27
|
|
|
28
|
-
// Mock only the flag
|
|
28
|
+
// Mock only the one flag reader the gate uses; keep the rest of common.js intact.
|
|
29
29
|
jest.mock('../../../utils/common', () => ({
|
|
30
30
|
...jest.requireActual('../../../utils/common'),
|
|
31
31
|
hasNewFormBuilderEnabledForSms: jest.fn(),
|
|
32
|
-
hasNewFormBuilderEnabledForMpush: jest.fn(),
|
|
33
32
|
}));
|
|
34
33
|
|
|
35
34
|
// eslint-disable-next-line import/first
|
|
36
35
|
import FormBuilder from '../index';
|
|
37
36
|
// eslint-disable-next-line import/first
|
|
38
|
-
import { hasNewFormBuilderEnabledForSms
|
|
37
|
+
import { hasNewFormBuilderEnabledForSms } from '../../../utils/common';
|
|
39
38
|
|
|
40
39
|
const renderGate = (props = {}) => render(<FormBuilder {...props} />);
|
|
41
40
|
|
|
42
41
|
describe('FormBuilder entry gate — routing', () => {
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
hasNewFormBuilderEnabledForSms.mockReturnValue(false);
|
|
45
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(false);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
42
|
describe('flag OFF (default)', () => {
|
|
49
43
|
beforeEach(() => hasNewFormBuilderEnabledForSms.mockReturnValue(false));
|
|
50
44
|
|
|
@@ -99,73 +93,8 @@ describe('FormBuilder entry gate — routing', () => {
|
|
|
99
93
|
});
|
|
100
94
|
});
|
|
101
95
|
|
|
102
|
-
describe('MPUSH flag (ENABLE_NEW_FORMBUILDER_MPUSH — Phase 2)', () => {
|
|
103
|
-
it('flag OFF: MOBILEPUSH renders Classic', () => {
|
|
104
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
105
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('flag ON: routes MOBILEPUSH to Functional (schema.channel)', () => {
|
|
109
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
110
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
111
|
-
expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
|
|
112
|
-
expect(screen.queryByTestId('impl-classic')).toBeNull();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('flag ON: is case-insensitive and honors the channel prop', () => {
|
|
116
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
117
|
-
renderGate({ channel: 'mobilepush', schema: {} });
|
|
118
|
-
expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('flags are independent: MPUSH flag ON does not move SMS (and vice versa)', () => {
|
|
122
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
123
|
-
renderGate({ schema: { channel: 'SMS' } });
|
|
124
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('SMS flag ON does not move MOBILEPUSH', () => {
|
|
128
|
-
hasNewFormBuilderEnabledForSms.mockReturnValue(true);
|
|
129
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
130
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('both flags ON route each channel to Functional', () => {
|
|
134
|
-
hasNewFormBuilderEnabledForSms.mockReturnValue(true);
|
|
135
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
136
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
137
|
-
expect(screen.getByTestId('impl-functional')).toHaveTextContent('functional:MOBILEPUSH');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('unmigrated channels stay on Classic even with both flags ON', () => {
|
|
141
|
-
hasNewFormBuilderEnabledForSms.mockReturnValue(true);
|
|
142
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
143
|
-
renderGate({ schema: { channel: 'EMAIL' } });
|
|
144
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe('the gate is purely org-flag controlled (no localStorage override)', () => {
|
|
149
|
-
afterEach(() => {
|
|
150
|
-
window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('a localStorage key does NOT enable the new path when the org flag is OFF', () => {
|
|
154
|
-
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
|
|
155
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
156
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('a localStorage key does NOT disable the new path when the org flag is ON', () => {
|
|
160
|
-
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
161
|
-
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'false');
|
|
162
|
-
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
163
|
-
expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
96
|
describe('resilience', () => {
|
|
168
|
-
it('falls back to Classic if
|
|
97
|
+
it('falls back to Classic if the flag read throws (window.capAuth not initialized)', () => {
|
|
169
98
|
hasNewFormBuilderEnabledForSms.mockImplementation(() => {
|
|
170
99
|
throw new Error('capAuth not initialized');
|
|
171
100
|
});
|
|
@@ -173,14 +102,5 @@ describe('FormBuilder entry gate — routing', () => {
|
|
|
173
102
|
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
174
103
|
expect(screen.queryByTestId('impl-functional')).toBeNull();
|
|
175
104
|
});
|
|
176
|
-
|
|
177
|
-
it('one flag reader throwing routes EVERY channel to Classic (whole map falls back)', () => {
|
|
178
|
-
hasNewFormBuilderEnabledForSms.mockReturnValue(true);
|
|
179
|
-
hasNewFormBuilderEnabledForMpush.mockImplementation(() => {
|
|
180
|
-
throw new Error('capAuth not initialized');
|
|
181
|
-
});
|
|
182
|
-
renderGate({ schema: { channel: 'SMS' } });
|
|
183
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
184
|
-
});
|
|
185
105
|
});
|
|
186
106
|
});
|
|
@@ -20,7 +20,7 @@ import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
|
|
|
20
20
|
import injectReducer from '../../utils/injectReducer';
|
|
21
21
|
import { makeSelectAuthenticated } from '../Cap/selectors';
|
|
22
22
|
import capReducer from '../Cap/reducer';
|
|
23
|
-
import { createCommDefinition, editCommDefinition
|
|
23
|
+
import { createCommDefinition, editCommDefinition } from '../../services/api';
|
|
24
24
|
import DynamicControlsStep from './steps/DynamicControlsStep';
|
|
25
25
|
import MessageTypeStep from './steps/MessageTypeStep';
|
|
26
26
|
import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
SINGLE_TEMPLATE,
|
|
45
45
|
} from './constants';
|
|
46
46
|
import { getEnabledSteps } from './utils/getEnabledSteps';
|
|
47
|
-
import
|
|
47
|
+
import useFetchCommDefinition from './hooks/useFetchCommDefinition';
|
|
48
48
|
import messages from './messages';
|
|
49
49
|
import './CommunicationFlow.scss';
|
|
50
50
|
|
|
@@ -79,23 +79,18 @@ const CommunicationFlow = ({
|
|
|
79
79
|
const { formatMessage } = intl || {};
|
|
80
80
|
const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// below, and mapCommDefinitionToStepData). Captured once at mount: initialData/config aren't
|
|
86
|
-
// expected to change identity across the component's lifetime for this decision.
|
|
82
|
+
// Other consumers may provide only commDefinitionId and rely on CommunicationFlow to fetch
|
|
83
|
+
// content; captured once at mount so it also seeds messageType/communicationStrategy below,
|
|
84
|
+
// before useFetchCommDefinition's effect (mount-only) has a chance to run.
|
|
87
85
|
const [shouldFetchCommDefinition] = useState(
|
|
88
86
|
() => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
|
|
89
87
|
);
|
|
90
|
-
const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetchCommDefinition);
|
|
91
88
|
|
|
92
89
|
// Initialize step data from initialData or defaults
|
|
93
90
|
const [stepData, setStepData] = useState(() => {
|
|
94
91
|
const defaultMessageType = messageTypeData.defaultOption?.value || MESSAGE_TYPES_OPTIONS?.[1]?.value || null;
|
|
95
92
|
return {
|
|
96
|
-
// Pre-set
|
|
97
|
-
// renders once communicationStrategy is set) is already mounted — showing its own
|
|
98
|
-
// loading state — while the fetch below is in flight, instead of staying blank/unmounted.
|
|
93
|
+
// Pre-set CCS's only supported strategy so Channel Selection mounts and shows its loading state while the fetch is in flight.
|
|
99
94
|
messageType: initialData?.messageType || (shouldFetchCommDefinition ? 'transactional' : defaultMessageType),
|
|
100
95
|
communicationStrategy: initialData?.communicationStrategy || (shouldFetchCommDefinition ? SINGLE_TEMPLATE : null),
|
|
101
96
|
channel: initialData?.channel || config.channel || null,
|
|
@@ -109,27 +104,13 @@ const CommunicationFlow = ({
|
|
|
109
104
|
const [validationErrors, setValidationErrors] = useState({});
|
|
110
105
|
|
|
111
106
|
// Fetch-by-id: populate stepData from the CommDefinition itself when the consumer only
|
|
112
|
-
// supplied a commDefinitionId (see shouldFetchCommDefinition above).
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
getCommDefinition,
|
|
120
|
-
getCommDefinitionVersion,
|
|
121
|
-
});
|
|
122
|
-
if (!cancelled && mapped) {
|
|
123
|
-
setStepData((prevStepData) => ({ ...prevStepData, ...mapped }));
|
|
124
|
-
}
|
|
125
|
-
} catch (error) {
|
|
126
|
-
console.error('[CommunicationFlow] fetchAndMapCommDefinition error:', error);
|
|
127
|
-
} finally {
|
|
128
|
-
if (!cancelled) setIsFetchingCommDefinition(false);
|
|
129
|
-
}
|
|
130
|
-
})();
|
|
131
|
-
return () => { cancelled = true; };
|
|
132
|
-
}, []);
|
|
107
|
+
// supplied a commDefinitionId (see shouldFetchCommDefinition above). Shared with
|
|
108
|
+
// CommunicationFlowCard.js via useFetchCommDefinition.
|
|
109
|
+
const isFetchingCommDefinition = useFetchCommDefinition({
|
|
110
|
+
existingCommDefinitionId: config?.context?.existingCommDefinitionId,
|
|
111
|
+
hasLocalContent: !!initialData?.contentItems?.length,
|
|
112
|
+
onFetched: (mapped) => setStepData((prevStepData) => ({ ...prevStepData, ...mapped })),
|
|
113
|
+
});
|
|
133
114
|
|
|
134
115
|
// Memoize enabled steps
|
|
135
116
|
const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
|
|
@@ -27,8 +27,7 @@ import {
|
|
|
27
27
|
VIBER, SMS, EMAIL, WHATSAPP, RCS, ZALO,
|
|
28
28
|
} from '../CreativesContainer/constants';
|
|
29
29
|
import getContentBody from './utils/getContentBody';
|
|
30
|
-
import
|
|
31
|
-
import { fetchAndMapCommDefinition } from './utils/mapCommDefinitionToStepData';
|
|
30
|
+
import useFetchCommDefinition from './hooks/useFetchCommDefinition';
|
|
32
31
|
import messages from './messages';
|
|
33
32
|
import addCreativesIllustration from '../Assets/images/addCreativesIllustration.svg';
|
|
34
33
|
import './CommunicationFlow.scss';
|
|
@@ -93,39 +92,14 @@ const CommunicationFlowCard = ({
|
|
|
93
92
|
}
|
|
94
93
|
}, [initialData]);
|
|
95
94
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(
|
|
105
|
-
() => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
const existingCommDefinitionId = config?.context?.existingCommDefinitionId;
|
|
110
|
-
if (!existingCommDefinitionId || initialData?.contentItems?.length) return undefined;
|
|
111
|
-
let cancelled = false;
|
|
112
|
-
(async () => {
|
|
113
|
-
try {
|
|
114
|
-
const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
|
|
115
|
-
getCommDefinition,
|
|
116
|
-
getCommDefinitionVersion,
|
|
117
|
-
});
|
|
118
|
-
if (!cancelled && mapped) {
|
|
119
|
-
setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped }));
|
|
120
|
-
}
|
|
121
|
-
} catch (error) {
|
|
122
|
-
console.error('[CommunicationFlowCard] fetchAndMapCommDefinition error:', error);
|
|
123
|
-
} finally {
|
|
124
|
-
if (!cancelled) setIsFetchingCommDefinition(false);
|
|
125
|
-
}
|
|
126
|
-
})();
|
|
127
|
-
return () => { cancelled = true; };
|
|
128
|
-
}, []);
|
|
95
|
+
// Other consumers may provide only commDefinitionId; fetch it here so the summary card shows
|
|
96
|
+
// configured content immediately instead of "Add creatives". Shared with CommunicationFlow.js
|
|
97
|
+
// via useFetchCommDefinition.
|
|
98
|
+
const isFetchingCommDefinition = useFetchCommDefinition({
|
|
99
|
+
existingCommDefinitionId: config?.context?.existingCommDefinitionId,
|
|
100
|
+
hasLocalContent: !!initialData?.contentItems?.length,
|
|
101
|
+
onFetched: (mapped) => setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped })),
|
|
102
|
+
});
|
|
129
103
|
|
|
130
104
|
const handleSave = useCallback((data) => {
|
|
131
105
|
setSavedData(data);
|
|
@@ -241,7 +215,7 @@ const CommunicationFlowCard = ({
|
|
|
241
215
|
className="communication-flow-card-loading"
|
|
242
216
|
bodyStyle={{ padding: 0, height: 152 }}
|
|
243
217
|
>
|
|
244
|
-
<CapRow type="flex" justify="center" align="middle"
|
|
218
|
+
<CapRow type="flex" justify="center" align="middle" className="loading-card-body-row">
|
|
245
219
|
<CapSpin spinning />
|
|
246
220
|
</CapRow>
|
|
247
221
|
</CapCard>
|
|
@@ -3,6 +3,12 @@ import React from 'react';
|
|
|
3
3
|
jest.mock('../../../services/api', () => ({
|
|
4
4
|
createCommDefinition: jest.fn(),
|
|
5
5
|
editCommDefinition: jest.fn(),
|
|
6
|
+
getCommDefinition: jest.fn(),
|
|
7
|
+
getCommDefinitionVersion: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('../utils/mapCommDefinitionToStepData', () => ({
|
|
11
|
+
fetchAndMapCommDefinition: jest.fn(),
|
|
6
12
|
}));
|
|
7
13
|
|
|
8
14
|
jest.mock('@capillarytech/cap-ui-library/CapNotification', () => ({
|
|
@@ -47,6 +53,7 @@ import { initialReducer } from '../../../initialReducer';
|
|
|
47
53
|
import CommunicationFlow from '../CommunicationFlow';
|
|
48
54
|
import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
|
|
49
55
|
import { createCommDefinition, editCommDefinition } from '../../../services/api';
|
|
56
|
+
import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
|
|
50
57
|
import { getEnabledSteps } from '../utils/getEnabledSteps';
|
|
51
58
|
import {
|
|
52
59
|
CHANNELS,
|
|
@@ -921,6 +928,82 @@ describe('handleSave — CCS flow', () => {
|
|
|
921
928
|
});
|
|
922
929
|
});
|
|
923
930
|
|
|
931
|
+
describe('CommunicationFlow — fetch-by-id (config.context.existingCommDefinitionId, no local content)', () => {
|
|
932
|
+
const ccsConfig = { ...baseConfig, context: { name: 'Order Placed Notification' }, features: {} };
|
|
933
|
+
const configWithId = {
|
|
934
|
+
...ccsConfig,
|
|
935
|
+
context: { ...ccsConfig.context, existingCommDefinitionId: 'cd_fetch_1' },
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
beforeEach(() => {
|
|
939
|
+
fetchAndMapCommDefinition.mockReset();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
afterEach(() => {
|
|
943
|
+
jest.clearAllMocks();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('fetches and merges the mapped data into stepData when only a commDefinitionId is supplied', async () => {
|
|
947
|
+
fetchAndMapCommDefinition.mockResolvedValue({
|
|
948
|
+
channel: 'SMS',
|
|
949
|
+
contentItems: [{ contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } }],
|
|
950
|
+
ccsCommDefinition: { id: 'cd_fetch_1', referenceId: 'ref_1', version: 1, status: 'DRAFT' },
|
|
951
|
+
});
|
|
952
|
+
const onChange = jest.fn();
|
|
953
|
+
renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
|
|
954
|
+
|
|
955
|
+
await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledWith(
|
|
956
|
+
'cd_fetch_1',
|
|
957
|
+
expect.objectContaining({
|
|
958
|
+
getCommDefinition: expect.any(Function),
|
|
959
|
+
getCommDefinitionVersion: expect.any(Function),
|
|
960
|
+
}),
|
|
961
|
+
));
|
|
962
|
+
|
|
963
|
+
await waitFor(() => {
|
|
964
|
+
const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
965
|
+
expect(payload.contentItems).toEqual([
|
|
966
|
+
{ contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } },
|
|
967
|
+
]);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('does not crash and leaves stepData unchanged when the fetch resolves with nothing mappable', async () => {
|
|
972
|
+
fetchAndMapCommDefinition.mockResolvedValue(null);
|
|
973
|
+
const onChange = jest.fn();
|
|
974
|
+
renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
|
|
975
|
+
|
|
976
|
+
await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
|
|
977
|
+
const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
978
|
+
expect(payload.contentItems).toEqual([]);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('does not crash when the fetch rejects', async () => {
|
|
982
|
+
fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
|
|
983
|
+
renderWithFlow({ features: {}, config: configWithId, initialData: null });
|
|
984
|
+
|
|
985
|
+
await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('does not fetch when initialData already has contentItems (e.g. CapNotify, which always pre-fetches itself)', async () => {
|
|
989
|
+
renderWithFlow({
|
|
990
|
+
features: {},
|
|
991
|
+
config: configWithId,
|
|
992
|
+
initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
996
|
+
expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it('does not fetch when config.context.existingCommDefinitionId is absent', async () => {
|
|
1000
|
+
renderWithFlow({ features: {}, config: ccsConfig, initialData: null });
|
|
1001
|
+
|
|
1002
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1003
|
+
expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
924
1007
|
describe('renderSteps — null returns and edge cases', () => {
|
|
925
1008
|
it('returns null for CHANNEL_SELECTION when communicationStrategy is null', () => {
|
|
926
1009
|
renderWithFlow({
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
2
|
+
import { waitFor } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../../../services/api', () => ({
|
|
5
|
+
getCommDefinition: jest.fn(),
|
|
6
|
+
getCommDefinitionVersion: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('../../utils/mapCommDefinitionToStepData', () => ({
|
|
10
|
+
fetchAndMapCommDefinition: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import useFetchCommDefinition from '../useFetchCommDefinition';
|
|
14
|
+
import { getCommDefinition, getCommDefinitionVersion } from '../../../../services/api';
|
|
15
|
+
import { fetchAndMapCommDefinition } from '../../utils/mapCommDefinitionToStepData';
|
|
16
|
+
|
|
17
|
+
describe('useFetchCommDefinition', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
fetchAndMapCommDefinition.mockReset();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('starts in a fetching state and calls onFetched with the mapped result once the fetch resolves', async () => {
|
|
23
|
+
fetchAndMapCommDefinition.mockResolvedValue({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
|
|
24
|
+
const onFetched = jest.fn();
|
|
25
|
+
|
|
26
|
+
const { result } = renderHook(() => useFetchCommDefinition({
|
|
27
|
+
existingCommDefinitionId: 'cd_1',
|
|
28
|
+
hasLocalContent: false,
|
|
29
|
+
onFetched,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
expect(result.current).toBe(true);
|
|
33
|
+
|
|
34
|
+
await waitFor(() => expect(result.current).toBe(false));
|
|
35
|
+
expect(fetchAndMapCommDefinition).toHaveBeenCalledWith('cd_1', {
|
|
36
|
+
getCommDefinition,
|
|
37
|
+
getCommDefinitionVersion,
|
|
38
|
+
});
|
|
39
|
+
expect(onFetched).toHaveBeenCalledWith({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not call onFetched when the fetch resolves with nothing mappable, but still stops fetching', async () => {
|
|
43
|
+
fetchAndMapCommDefinition.mockResolvedValue(null);
|
|
44
|
+
const onFetched = jest.fn();
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => useFetchCommDefinition({
|
|
47
|
+
existingCommDefinitionId: 'cd_1',
|
|
48
|
+
hasLocalContent: false,
|
|
49
|
+
onFetched,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
await waitFor(() => expect(result.current).toBe(false));
|
|
53
|
+
expect(onFetched).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not crash and stops fetching when the fetch rejects', async () => {
|
|
57
|
+
fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
|
|
58
|
+
const onFetched = jest.fn();
|
|
59
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => useFetchCommDefinition({
|
|
62
|
+
existingCommDefinitionId: 'cd_1',
|
|
63
|
+
hasLocalContent: false,
|
|
64
|
+
onFetched,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(result.current).toBe(false));
|
|
68
|
+
expect(onFetched).not.toHaveBeenCalled();
|
|
69
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
70
|
+
consoleErrorSpy.mockRestore();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('never fetches, and reports isFetchingCommDefinition as false throughout, when hasLocalContent is true', async () => {
|
|
74
|
+
const onFetched = jest.fn();
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook(() => useFetchCommDefinition({
|
|
77
|
+
existingCommDefinitionId: 'cd_1',
|
|
78
|
+
hasLocalContent: true,
|
|
79
|
+
onFetched,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
expect(result.current).toBe(false);
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
84
|
+
expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('never fetches when existingCommDefinitionId is absent', async () => {
|
|
88
|
+
const onFetched = jest.fn();
|
|
89
|
+
|
|
90
|
+
const { result } = renderHook(() => useFetchCommDefinition({
|
|
91
|
+
existingCommDefinitionId: undefined,
|
|
92
|
+
hasLocalContent: false,
|
|
93
|
+
onFetched,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
expect(result.current).toBe(false);
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
98
|
+
expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not call onFetched after unmount, even if the fetch resolves late', async () => {
|
|
102
|
+
let resolveFetch;
|
|
103
|
+
fetchAndMapCommDefinition.mockReturnValue(new Promise((resolve) => { resolveFetch = resolve; }));
|
|
104
|
+
const onFetched = jest.fn();
|
|
105
|
+
|
|
106
|
+
const { unmount } = renderHook(() => useFetchCommDefinition({
|
|
107
|
+
existingCommDefinitionId: 'cd_1',
|
|
108
|
+
hasLocalContent: false,
|
|
109
|
+
onFetched,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
unmount();
|
|
113
|
+
resolveFetch({ channel: 'SMS', contentItems: [{ contentId: 'cd_1' }] });
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
115
|
+
|
|
116
|
+
expect(onFetched).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared by CommunicationFlow.js and CommunicationFlowCard.js — both need to fetch + reverse-map
|
|
3
|
+
* a CommDefinition by id when a consumer supplies only config.context.existingCommDefinitionId
|
|
4
|
+
* and no local content copy (see mapCommDefinitionToStepData.js). Previously duplicated in both
|
|
5
|
+
* files; this hook is the single place that owns the fetch, its loading state, and error handling.
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useEffect } from 'react';
|
|
8
|
+
import { getCommDefinition, getCommDefinitionVersion } from '../../../services/api';
|
|
9
|
+
import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string|undefined} existingCommDefinitionId - config.context.existingCommDefinitionId
|
|
13
|
+
* @param {boolean} hasLocalContent - true when the consumer already supplied content
|
|
14
|
+
* (e.g. CapNotify, which always pre-fetches and passes full initialData itself) — skips the fetch.
|
|
15
|
+
* @param {(mapped: object) => void} onFetched - called once, only when the fetch resolves with
|
|
16
|
+
* mappable data; the caller merges `mapped` into its own state (stepData/savedData).
|
|
17
|
+
* @returns {boolean} isFetchingCommDefinition - true from mount until the fetch settles; always
|
|
18
|
+
* false when existingCommDefinitionId is absent or hasLocalContent is true.
|
|
19
|
+
*/
|
|
20
|
+
export default function useFetchCommDefinition({ existingCommDefinitionId, hasLocalContent, onFetched }) {
|
|
21
|
+
// Captured once at mount — existingCommDefinitionId/hasLocalContent aren't expected to change
|
|
22
|
+
// identity across the component's lifetime for this decision.
|
|
23
|
+
const [shouldFetch] = useState(() => !!existingCommDefinitionId && !hasLocalContent);
|
|
24
|
+
const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(shouldFetch);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!shouldFetch) return undefined;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
|
|
32
|
+
getCommDefinition,
|
|
33
|
+
getCommDefinitionVersion,
|
|
34
|
+
});
|
|
35
|
+
if (!cancelled && mapped) {
|
|
36
|
+
onFetched(mapped);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[useFetchCommDefinition] fetchAndMapCommDefinition error:', error);
|
|
40
|
+
} finally {
|
|
41
|
+
if (!cancelled) setIsFetchingCommDefinition(false);
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
return () => { cancelled = true; };
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return isFetchingCommDefinition;
|
|
49
|
+
}
|
|
@@ -55,11 +55,7 @@ const ChannelSelectionStep = ({
|
|
|
55
55
|
intl,
|
|
56
56
|
capData, // From Redux - contains user/org info needed by CouponsWrapper
|
|
57
57
|
config,
|
|
58
|
-
// True while
|
|
59
|
-
// config.context.existingCommDefinitionId in CommunicationFlow.js) — shown in place of the
|
|
60
|
-
// empty "Add creative" state so a consumer that only stored a commDefinitionId doesn't flash
|
|
61
|
-
// an empty content step before the fetched content arrives.
|
|
62
|
-
isContentLoading = false,
|
|
58
|
+
isContentLoading = false, // True while fetching existing CommDefinition content by id.
|
|
63
59
|
}) => {
|
|
64
60
|
const contentItems = value?.contentItems || [];
|
|
65
61
|
const [showCreativesContainer, setShowCreativesContainer] = useState(false);
|
package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js
CHANGED
|
@@ -138,6 +138,34 @@ describe('ChannelSelectionStep', () => {
|
|
|
138
138
|
expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it('shows a loading spinner instead of the "Add creative" empty state while isContentLoading is true', () => {
|
|
142
|
+
renderStep(
|
|
143
|
+
<ChannelSelectionStep
|
|
144
|
+
value={{ contentItems: [] }}
|
|
145
|
+
onChange={jest.fn()}
|
|
146
|
+
channels={CHANNELS}
|
|
147
|
+
isContentLoading
|
|
148
|
+
/>,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(screen.queryByRole('button', { name: /add creative/i })).not.toBeInTheDocument();
|
|
152
|
+
expect(document.querySelector('.content-loading')).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('shows the empty "Add creative" state (not the loading spinner) once isContentLoading is false, even with no content', () => {
|
|
156
|
+
renderStep(
|
|
157
|
+
<ChannelSelectionStep
|
|
158
|
+
value={{ contentItems: [] }}
|
|
159
|
+
onChange={jest.fn()}
|
|
160
|
+
channels={CHANNELS}
|
|
161
|
+
isContentLoading={false}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(screen.getByRole('button', { name: /add creative/i })).toBeInTheDocument();
|
|
166
|
+
expect(document.querySelector('.content-loading')).not.toBeInTheDocument();
|
|
167
|
+
});
|
|
168
|
+
|
|
141
169
|
it('shows validation error when error prop is set', () => {
|
|
142
170
|
renderStep(
|
|
143
171
|
<ChannelSelectionStep
|