@capillarytech/creatives-library 9.0.59-alpha.2 → 9.0.59
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 -3
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +2 -8
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +2 -9
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -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/package.json
CHANGED
package/services/api.js
CHANGED
|
@@ -722,9 +722,7 @@ export const sendTransactionalComm = (payload) => {
|
|
|
722
722
|
return request(url, getAPICallObject(HTTP_METHODS.POST, payload, false, false, false, true));
|
|
723
723
|
};
|
|
724
724
|
|
|
725
|
-
// CommunicationFlow
|
|
726
|
-
// CommunicationFlow/utils) — lets a consumer that only stored a commDefinitionId (no local
|
|
727
|
-
// content copy) hand CommunicationFlow just that id on edit; it fetches metadata + content itself.
|
|
725
|
+
// CommunicationFlow fetch-by-id path — lets consumers with only a commDefinitionId load metadata and content on edit.
|
|
728
726
|
export const getCommDefinition = (commDefinitionId, include) => {
|
|
729
727
|
const query = include ? `?include=${encodeURIComponent(include)}` : '';
|
|
730
728
|
const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}${query}`;
|
|
@@ -79,11 +79,7 @@ const CommunicationFlow = ({
|
|
|
79
79
|
const { formatMessage } = intl || {};
|
|
80
80
|
const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
// itself) may hand CommunicationFlow only a commDefinitionId — no local content copy at all
|
|
84
|
-
// — and rely on CommunicationFlow to fetch + populate its own content (see the fetch effect
|
|
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 content; capture this decision once at mount.
|
|
87
83
|
const [shouldFetchCommDefinition] = useState(
|
|
88
84
|
() => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
|
|
89
85
|
);
|
|
@@ -93,9 +89,7 @@ const CommunicationFlow = ({
|
|
|
93
89
|
const [stepData, setStepData] = useState(() => {
|
|
94
90
|
const defaultMessageType = messageTypeData.defaultOption?.value || MESSAGE_TYPES_OPTIONS?.[1]?.value || null;
|
|
95
91
|
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.
|
|
92
|
+
// Pre-set CCS's only supported strategy so Channel Selection mounts and shows its loading state while the fetch is in flight.
|
|
99
93
|
messageType: initialData?.messageType || (shouldFetchCommDefinition ? 'transactional' : defaultMessageType),
|
|
100
94
|
communicationStrategy: initialData?.communicationStrategy || (shouldFetchCommDefinition ? SINGLE_TEMPLATE : null),
|
|
101
95
|
channel: initialData?.channel || config.channel || null,
|
|
@@ -93,14 +93,7 @@ const CommunicationFlowCard = ({
|
|
|
93
93
|
}
|
|
94
94
|
}, [initialData]);
|
|
95
95
|
|
|
96
|
-
//
|
|
97
|
-
// itself) may hand this card only a commDefinitionId — no local content copy at all — and
|
|
98
|
-
// rely on CommunicationFlowCard to fetch + show the configured summary itself, instead of
|
|
99
|
-
// the empty "Add creatives" state. Mirrors CommunicationFlow.js's own fetch-by-id path
|
|
100
|
-
// (config.context.existingCommDefinitionId); doing it here too means the SUMMARY CARD
|
|
101
|
-
// reflects fetched content immediately, without the user having to open the slidebox and
|
|
102
|
-
// click Save first — CommunicationFlow's own fetch would only ever reach this card's
|
|
103
|
-
// savedData via that save round-trip, since nothing here listens for its internal onChange.
|
|
96
|
+
// Other consumers may provide only commDefinitionId; fetch it here so the summary card shows configured content immediately instead of "Add creatives".
|
|
104
97
|
const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(
|
|
105
98
|
() => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
|
|
106
99
|
);
|
|
@@ -241,7 +234,7 @@ const CommunicationFlowCard = ({
|
|
|
241
234
|
className="communication-flow-card-loading"
|
|
242
235
|
bodyStyle={{ padding: 0, height: 152 }}
|
|
243
236
|
>
|
|
244
|
-
<CapRow type="flex" justify="center" align="middle"
|
|
237
|
+
<CapRow type="flex" justify="center" align="middle" className="loading-card-body-row">
|
|
245
238
|
<CapSpin spinning />
|
|
246
239
|
</CapRow>
|
|
247
240
|
</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({
|
|
@@ -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
|
|
@@ -115,6 +115,35 @@ describe('mapCommDefinitionToStepData', () => {
|
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
it('maps a WEBPUSH CommDefinition using the dedicated reverse transform (nested messageContent.content shape)', () => {
|
|
119
|
+
const commDefinition = {
|
|
120
|
+
id: 'cd_webpush_1',
|
|
121
|
+
singleChannelStrategy: {
|
|
122
|
+
variant: {
|
|
123
|
+
channel: 'WEBPUSH',
|
|
124
|
+
webPushMessageContent: {
|
|
125
|
+
messageSubject: 'New offer',
|
|
126
|
+
accountId: 'acc_1',
|
|
127
|
+
content: { title: 'Title', body: 'Body text' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = mapCommDefinitionToStepData(commDefinition);
|
|
134
|
+
|
|
135
|
+
expect(result.contentItems[0].templateData).toEqual({
|
|
136
|
+
messageContent: {
|
|
137
|
+
content: {
|
|
138
|
+
messageSubject: 'New offer',
|
|
139
|
+
accountId: 'acc_1',
|
|
140
|
+
content: { title: 'Title', body: 'Body text' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
channel: 'WEBPUSH',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
118
147
|
it('reads the variant from commDefinition.version when top-level singleChannelStrategy is absent', () => {
|
|
119
148
|
const commDefinition = {
|
|
120
149
|
id: 'cd_4',
|
|
@@ -22,9 +22,7 @@ const CCS_TO_UI_CHANNEL_MAP = Object.entries(CCS_CHANNEL_NAME_MAP).reduce(
|
|
|
22
22
|
);
|
|
23
23
|
const reverseUiChannel = (ccsChannel) => CCS_TO_UI_CHANNEL_MAP[ccsChannel] || ccsChannel;
|
|
24
24
|
|
|
25
|
-
// Inverse of CCS_CONTENT_TRANSFORMS
|
|
26
|
-
// transform there need one here; every other channel's CCS content already matches the legacy
|
|
27
|
-
// editor's templateData shape and is passed through unchanged (see the default branch below).
|
|
25
|
+
// Inverse of CCS_CONTENT_TRANSFORMS — transforms CCS content back to the legacy editor's templateData shape.
|
|
28
26
|
const CCS_CONTENT_REVERSE_TRANSFORMS = {
|
|
29
27
|
EMAIL: (payload = {}) => ({
|
|
30
28
|
emailSubject: payload.messageSubject,
|
|
@@ -50,8 +48,7 @@ const getCommDefinitionVariant = (commDefinition = {}) => (
|
|
|
50
48
|
|| null
|
|
51
49
|
);
|
|
52
50
|
|
|
53
|
-
// Counts SUBMITTED audit entries to derive the latest version
|
|
54
|
-
// itself doesn't carry an explicit current version (mirrors cap-campaigns-v2's own helper).
|
|
51
|
+
// Counts SUBMITTED audit entries to derive the latest version when CommDefinition has no explicit current version.
|
|
55
52
|
export const getLatestVersionFromAuditInfos = (commDefinition = {}) => {
|
|
56
53
|
const submittedCount = (commDefinition.auditInfos || []).filter(
|
|
57
54
|
(entry) => entry?.action === 'SUBMITTED',
|
|
@@ -60,8 +57,7 @@ export const getLatestVersionFromAuditInfos = (commDefinition = {}) => {
|
|
|
60
57
|
return submittedCount - 1;
|
|
61
58
|
};
|
|
62
59
|
|
|
63
|
-
// Reverse
|
|
64
|
-
// handleSave) — used to pre-populate the Advanced Controls step when editing existing content.
|
|
60
|
+
// Reverse-maps CommunicationFlow's additionalSettings to pre-populate Advanced Controls when editing.
|
|
65
61
|
const mapAdditionalSettingsToDynamicControls = (additionalSettings = {}) => ({
|
|
66
62
|
useTinyUrl: additionalSettings.useTinyUrl ?? false,
|
|
67
63
|
sendToControlCustomers: additionalSettings.encryptUrl ?? false,
|
|
@@ -86,8 +82,7 @@ export const mapCommDefinitionToStepData = (commDefinition = {}) => {
|
|
|
86
82
|
|
|
87
83
|
const uiChannel = reverseUiChannel(ccsChannel);
|
|
88
84
|
const reverseTransform = CCS_CONTENT_REVERSE_TRANSFORMS[ccsChannel];
|
|
89
|
-
// Spread channel after content
|
|
90
|
-
// would otherwise overwrite `uiChannel` ("MOBILEPUSH") and break template rendering.
|
|
85
|
+
// Spread channel after content so CCS's raw `channel` ("PUSH") cannot overwrite `uiChannel` ("MOBILEPUSH").
|
|
91
86
|
const templateData = reverseTransform
|
|
92
87
|
? { ...reverseTransform(rawContent), channel: uiChannel }
|
|
93
88
|
: { ...rawContent, channel: uiChannel };
|
|
@@ -98,14 +93,12 @@ export const mapCommDefinitionToStepData = (commDefinition = {}) => {
|
|
|
98
93
|
const version = commDefinition.version || {};
|
|
99
94
|
|
|
100
95
|
return {
|
|
101
|
-
// CCS create/edit
|
|
102
|
-
// handleSave, which skips CHANNEL_PRIORITY/AB_TEST) — a fetched CommDefinition is always one.
|
|
96
|
+
// CCS create/edit currently supports only SINGLE strategy; fetched CommDefinitions are always single-strategy.
|
|
103
97
|
messageType: 'transactional',
|
|
104
98
|
communicationStrategy: 'SINGLE_TEMPLATE',
|
|
105
99
|
channel: uiChannel,
|
|
106
100
|
channels: [],
|
|
107
|
-
// contentId must be truthy
|
|
108
|
-
// editingContentId; a falsy id collapses the reopen back to an empty template picker.
|
|
101
|
+
// contentId must be truthy so ChannelSelectionStep can resolve editingContentId instead of reopening the empty template picker.
|
|
109
102
|
contentItems: [
|
|
110
103
|
{ contentId: commDefinition.id, channel: uiChannel, templateData },
|
|
111
104
|
],
|