@capillarytech/creatives-library 9.0.56-alpha.4 → 9.0.56-alpha.6
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 +8 -6
- package/utils/templateVarUtils.js +32 -0
- package/utils/tests/templateVarUtils.test.js +44 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +2 -1
- package/v2Components/CommonTestAndPreview/index.js +1 -1
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +23 -25
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +2 -4
- package/v2Containers/CommunicationFlow/constants.js +10 -8
- package/v2Containers/CommunicationFlow/index.js +5 -7
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +17 -4
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +110 -2
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +3 -0
- package/v2Containers/CreativesContainer/index.js +1 -1
- package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
- package/v2Containers/Whatsapp/index.js +8 -3
- package/v2Containers/mockdata.js +25 -0
package/package.json
CHANGED
package/services/api.js
CHANGED
|
@@ -701,21 +701,23 @@ export const createCentralCommsMetaId = (payload, metaType = TRANSACTION) => {
|
|
|
701
701
|
return request(url, getAPICallObject('POST', payload, false, false, false, true));
|
|
702
702
|
};
|
|
703
703
|
|
|
704
|
-
// CCS CommDefinition API
|
|
704
|
+
// CCS CommDefinition API, proxied through cap-creatives-api; used by CommunicationFlow's Save, while legacy messageMeta functions remain for CreativesContainer's save flow.
|
|
705
|
+
const COMM_DEFINITIONS_PATH = `${API_ENDPOINT}/comm-definitions`;
|
|
706
|
+
|
|
705
707
|
export const createCommDefinition = (payload) => {
|
|
706
|
-
const url =
|
|
708
|
+
const url = COMM_DEFINITIONS_PATH;
|
|
707
709
|
return request(url, getAPICallObject('POST', payload, false, false, false, true));
|
|
708
710
|
};
|
|
709
711
|
|
|
710
|
-
// Opens a new DRAFT version
|
|
712
|
+
// Opens a new DRAFT version on an existing CommDefinition, preserving its id/referenceId.
|
|
711
713
|
export const editCommDefinition = (commDefinitionId, payload) => {
|
|
712
|
-
const url = `${
|
|
714
|
+
const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}/edit`;
|
|
713
715
|
return request(url, getAPICallObject('POST', payload, false, false, false, true));
|
|
714
716
|
};
|
|
715
717
|
|
|
716
|
-
//
|
|
718
|
+
// notify/ui's "Send test" uses this instead of the legacy createMessageMeta/sendTestMessage flow.
|
|
717
719
|
export const sendTransactionalComm = (payload) => {
|
|
718
|
-
const url = `${
|
|
720
|
+
const url = `${COMM_DEFINITIONS_PATH}/send/transaction`;
|
|
719
721
|
return request(url, getAPICallObject('POST', payload, false, false, false, true));
|
|
720
722
|
};
|
|
721
723
|
|
|
@@ -115,6 +115,38 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
|
|
|
115
115
|
return variables;
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Reconciles a var-value map from an external source (e.g. a saved CommDefinition) into this
|
|
120
|
+
* UI's own `${token}_${segmentIndex}` slot-key format used by the WhatsApp/RCS editors' own
|
|
121
|
+
* text-merge logic.
|
|
122
|
+
*
|
|
123
|
+
* Two shapes are recognized:
|
|
124
|
+
* - Slot format: keys already look like `${token}_${index}` (contain an underscore) — used as-is.
|
|
125
|
+
* - CCS format: keys are plain sequential occurrence indices ("0", "1", ...), one per variable in
|
|
126
|
+
* template order — unrelated to this UI's own array-position indexing (which depends on how many
|
|
127
|
+
* plain-text segments fall between variables) — so it's remapped by walking `segments` in order
|
|
128
|
+
* and assigning the Nth variable occurrence to `rawVarMap[N]`.
|
|
129
|
+
*
|
|
130
|
+
* @param {Object} rawVarMap
|
|
131
|
+
* @param {string[]} segments - text+var segments, e.g. from `splitContentByOrderedVarTokens`
|
|
132
|
+
* @param {RegExp} regex - matches a variable token
|
|
133
|
+
* @returns {Object} `${token}_${segmentIndex}` -> value
|
|
134
|
+
*/
|
|
135
|
+
export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
|
|
136
|
+
if (Object.keys(rawVarMap ?? {}).length === 0) return {};
|
|
137
|
+
const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
|
|
138
|
+
if (isSlotFormat) return { ...rawVarMap };
|
|
139
|
+
const slotMap = {};
|
|
140
|
+
let occurrenceIndex = 0;
|
|
141
|
+
(segments ?? []).forEach((segment, segmentIndex) => {
|
|
142
|
+
if (typeof segment === 'string' && (segment.match(regex) || []).length > 0) {
|
|
143
|
+
slotMap[`${segment}_${segmentIndex}`] = rawVarMap[occurrenceIndex] ?? '';
|
|
144
|
+
occurrenceIndex += 1;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return slotMap;
|
|
148
|
+
};
|
|
149
|
+
|
|
118
150
|
/**
|
|
119
151
|
* Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
|
|
120
152
|
* Handles both exact matches and dot-path suffixes (e.g. `tag.FORMAT_1` → name `FORMAT_1`).
|
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
getFallbackResolvedContentForCardDisplay,
|
|
7
7
|
isDltHashVarToken,
|
|
8
8
|
isAnyTemplateVarToken,
|
|
9
|
+
reconcileVarMapToSlotFormat,
|
|
9
10
|
} from '../templateVarUtils';
|
|
10
11
|
|
|
12
|
+
const MUSTACHE_VAR_REGEX = /\{\{\d+\}\}/g;
|
|
13
|
+
|
|
11
14
|
describe('templateVarUtils', () => {
|
|
12
15
|
describe('splitContentByOrderedVarTokens', () => {
|
|
13
16
|
it('pushes remainder when next token is not found in string', () => {
|
|
@@ -201,4 +204,45 @@ describe('templateVarUtils', () => {
|
|
|
201
204
|
expect(getFallbackResolvedContent('{#a#}', {}, { a: '' })).toBe('{#a#}');
|
|
202
205
|
});
|
|
203
206
|
});
|
|
207
|
+
|
|
208
|
+
describe('reconcileVarMapToSlotFormat', () => {
|
|
209
|
+
it('returns an empty object for an empty/absent rawVarMap', () => {
|
|
210
|
+
expect(reconcileVarMapToSlotFormat({}, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
|
|
211
|
+
expect(reconcileVarMapToSlotFormat(undefined, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('uses slot-format keys (containing an underscore) as-is', () => {
|
|
215
|
+
const rawVarMap = { '{{1}}_1': 'test', '{{2}}_3': 'test2' };
|
|
216
|
+
expect(
|
|
217
|
+
reconcileVarMapToSlotFormat(rawVarMap, ['x', '{{1}}', 'y', '{{2}}'], MUSTACHE_VAR_REGEX),
|
|
218
|
+
).toEqual(rawVarMap);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('remaps CCS-format sequential-occurrence-index keys ("0","1",...) onto this UI\'s own slot keys (regression: CCS sends plain occurrence indices, not this UI\'s `${token}_${arrayIndex}` format, which silently blanked WhatsApp edit fields and desynced the segment array)', () => {
|
|
222
|
+
// Mirrors the real CCS response: "Hi! Here is your latest tier progress summary with us.\n\nTo
|
|
223
|
+
// move up to the next tier, you still need to spend {{1}} more,\nearn {{2}} more points, ..."
|
|
224
|
+
// — varMapped keys "0".."10" map to {{1}}.."{{11}}" in template order.
|
|
225
|
+
const segments = [
|
|
226
|
+
'spend ',
|
|
227
|
+
'{{1}}',
|
|
228
|
+
' more,\nearn ',
|
|
229
|
+
'{{2}}',
|
|
230
|
+
' more points',
|
|
231
|
+
];
|
|
232
|
+
const rawVarMap = { 0: 'dasda', 1: 'dasd' };
|
|
233
|
+
expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
|
|
234
|
+
'{{1}}_1': 'dasda',
|
|
235
|
+
'{{2}}_3': 'dasd',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('defaults a missing occurrence-index entry to an empty string rather than dropping the slot', () => {
|
|
240
|
+
const segments = ['a ', '{{1}}', ' b ', '{{2}}'];
|
|
241
|
+
const rawVarMap = { 0: 'filled' }; // no entry for occurrence index 1
|
|
242
|
+
expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
|
|
243
|
+
'{{1}}_1': 'filled',
|
|
244
|
+
'{{2}}_3': '',
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
204
248
|
});
|
|
@@ -140,7 +140,8 @@ const UnifiedPreview = ({
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
case CHANNELS.LINE:
|
|
143
|
-
// LINE
|
|
143
|
+
// LINE carries a single plain-text message today, so it reuses the SMS chat-bubble
|
|
144
|
+
// preview. Rich types (image/video/sticker/imageMap/carousel/flex) aren't rendered yet.
|
|
144
145
|
return (
|
|
145
146
|
<SmsPreviewContent
|
|
146
147
|
content={typeof content === 'string' ? content : (content?.resolvedBody || '')}
|
|
@@ -209,7 +209,7 @@ const CommonTestAndPreview = (props) => {
|
|
|
209
209
|
wecrmAccounts = [],
|
|
210
210
|
isLoadingSenderDetails = false,
|
|
211
211
|
orgUnitId = -1,
|
|
212
|
-
// notify/ui only: { commDefinitionId, referenceId } —
|
|
212
|
+
// notify/ui only: { commDefinitionId, referenceId } — routes "Send test" to CCS's send/transaction endpoint.
|
|
213
213
|
ccsSendTransaction,
|
|
214
214
|
// Email-specific props
|
|
215
215
|
beeInstance,
|
|
@@ -46,11 +46,8 @@ import { getEnabledSteps } from './utils/getEnabledSteps';
|
|
|
46
46
|
import messages from './messages';
|
|
47
47
|
import './CommunicationFlow.scss';
|
|
48
48
|
|
|
49
|
-
// Self-injects the same 'cap' reducer CreativesContainer already injects under this key
|
|
50
|
-
//
|
|
51
|
-
// consumer's own store already provides it. Guarantees makeSelectAuthenticated() below never
|
|
52
|
-
// reads an undefined domain, regardless of whether a given consumer (e.g. coupons,
|
|
53
|
-
// cartPromotions) wires up its own 'cap' slice.
|
|
49
|
+
// Self-injects the same 'cap' reducer CreativesContainer already injects under this key;
|
|
50
|
+
// idempotent, so it's a no-op if the consumer's own store already provides it.
|
|
54
51
|
const withCapReducer = injectReducer({ key: 'cap', reducer: capReducer });
|
|
55
52
|
|
|
56
53
|
const getDeliveryChannels = (contentItems) => {
|
|
@@ -64,7 +61,7 @@ const hasDeliverySettingForChannel = (channelSetting, channel) => {
|
|
|
64
61
|
return !!settings && Object.values(settings).some((v) => v !== null && v !== '' && v !== undefined);
|
|
65
62
|
};
|
|
66
63
|
|
|
67
|
-
//
|
|
64
|
+
// Duplicate referenceId within the org returns a 409 CCS error envelope.
|
|
68
65
|
const isDuplicateReferenceIdError = (res) => res?.success === false && res?.status?.message === REFERENCE_ID_EXISTS;
|
|
69
66
|
|
|
70
67
|
const CommunicationFlow = ({
|
|
@@ -162,12 +159,10 @@ const CommunicationFlow = ({
|
|
|
162
159
|
if (shouldUseCCS) {
|
|
163
160
|
const isMultiChannel = [CHANNEL_PRIORITY, AB_TEST].includes(aggregatedData.communicationStrategy);
|
|
164
161
|
const contentItem = (aggregatedData.contentItems || [])[0];
|
|
165
|
-
// Consumer-supplied
|
|
166
|
-
// has no name input of its own, so this is mandatory input from config.context.
|
|
162
|
+
// Consumer-supplied (e.g. CapNotify's Alert Name field) — CommunicationFlow has no name input of its own.
|
|
167
163
|
const name = config?.context?.name;
|
|
168
164
|
|
|
169
|
-
// CCS create
|
|
170
|
-
// carry multiple content items with no CCS equivalent yet.
|
|
165
|
+
// CCS create only supports SINGLE strategy; CHANNEL_PRIORITY/AB_TEST have no CCS equivalent yet.
|
|
171
166
|
if (!isMultiChannel && contentItem && name) {
|
|
172
167
|
const rawChannel = (contentItem.channel || '').toUpperCase();
|
|
173
168
|
const channel = CCS_CHANNEL_NAME_MAP[rawChannel] || rawChannel;
|
|
@@ -177,8 +172,7 @@ const CommunicationFlow = ({
|
|
|
177
172
|
const contentPayload = contentTransform ? contentTransform(contentItem.templateData) : contentItem.templateData;
|
|
178
173
|
const { dynamicControls = {} } = aggregatedData;
|
|
179
174
|
const channelSettings = aggregatedData.deliverySetting?.channelSetting?.[channel] || {};
|
|
180
|
-
//
|
|
181
|
-
// approval) — sent as '' rather than auto-generated/omitted when left blank.
|
|
175
|
+
// Optional fields — sent as '' rather than omitted when left blank.
|
|
182
176
|
const referenceId = config?.context?.referenceId || '';
|
|
183
177
|
const description = config?.context?.description || '';
|
|
184
178
|
|
|
@@ -199,29 +193,35 @@ const CommunicationFlow = ({
|
|
|
199
193
|
singleChannelStrategy: {
|
|
200
194
|
variant: {
|
|
201
195
|
channel,
|
|
202
|
-
// `channel`
|
|
196
|
+
// `channel` spread last so CCS's normalized enum overrides the UI's internal identifier.
|
|
203
197
|
...(contentKey && { [contentKey]: { ...contentPayload, channel } }),
|
|
204
198
|
...(deliveryKey && { [deliveryKey]: { channelSettings: { ...channelSettings, channel } } }),
|
|
205
199
|
},
|
|
206
200
|
},
|
|
207
201
|
};
|
|
208
202
|
|
|
209
|
-
//
|
|
203
|
+
// existingCommDefinitionId (set when editing, e.g. a rejected alert) creates a new
|
|
204
|
+
// DRAFT version on the same CommDefinition instead of a new one.
|
|
210
205
|
const { existingCommDefinitionId } = config?.context || {};
|
|
206
|
+
const {
|
|
207
|
+
referenceId: payloadReferenceId,
|
|
208
|
+
description: payloadDescription,
|
|
209
|
+
strategyType,
|
|
210
|
+
settings,
|
|
211
|
+
singleChannelStrategy,
|
|
212
|
+
} = payload;
|
|
211
213
|
|
|
212
214
|
try {
|
|
213
215
|
const res = existingCommDefinitionId
|
|
214
216
|
? await editCommDefinition(existingCommDefinitionId, {
|
|
215
|
-
referenceId:
|
|
216
|
-
description:
|
|
217
|
-
strategyType
|
|
218
|
-
settings
|
|
219
|
-
singleChannelStrategy
|
|
217
|
+
referenceId: payloadReferenceId,
|
|
218
|
+
description: payloadDescription,
|
|
219
|
+
strategyType,
|
|
220
|
+
settings,
|
|
221
|
+
singleChannelStrategy,
|
|
220
222
|
})
|
|
221
223
|
: await createCommDefinition(payload);
|
|
222
224
|
if (isDuplicateReferenceIdError(res)) {
|
|
223
|
-
// Duplicate referenceId in this org — block the save so the user can
|
|
224
|
-
// change it, rather than silently proceeding without a CCS comm.
|
|
225
225
|
setSaveError(formatMessage(messages.duplicateReferenceIdError));
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
@@ -291,10 +291,8 @@ const CommunicationFlow = ({
|
|
|
291
291
|
</CapRow>
|
|
292
292
|
);
|
|
293
293
|
case STEPS.COMMUNICATION_STRATEGY: {
|
|
294
|
-
// No
|
|
295
|
-
//
|
|
296
|
-
// see the `.communication-strategy-row` spacing rule in CommunicationFlow.scss,
|
|
297
|
-
// which is not tied to whether the divider itself renders.
|
|
294
|
+
// No divider when there's no content yet, but the spacing above the Channel
|
|
295
|
+
// card must stay either way — see .communication-strategy-row in the scss.
|
|
298
296
|
const templateStepFollows = enabledSteps.includes(STEPS.CHANNEL_SELECTION);
|
|
299
297
|
const hasContent = stepData.contentItems?.length > 0;
|
|
300
298
|
return (
|
|
@@ -111,10 +111,8 @@ const CommunicationFlowCard = ({
|
|
|
111
111
|
const senderIdValue = getSenderIdValue(firstItem, savedData);
|
|
112
112
|
const controls = config?.features?.dynamicControlsData?.controls || DYNAMIC_CONTROLS_CONFIG;
|
|
113
113
|
const dynamicControlKeys = Object.keys(savedData?.dynamicControls || {});
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// false render their own "Advanced controls" section elsewhere, so this summary
|
|
117
|
-
// card's right column would otherwise show a redundant, always-empty control list.
|
|
114
|
+
// Consumers that set showDynamicControls: false render their own "Advanced
|
|
115
|
+
// controls" section elsewhere, so skip this card's redundant control list.
|
|
118
116
|
const showDynamicControls = config?.features?.showDynamicControls !== false;
|
|
119
117
|
const channel = firstItem.channel?.toUpperCase();
|
|
120
118
|
const senderLabel = [WHATSAPP, RCS].includes(channel)
|
|
@@ -38,15 +38,16 @@ export const CHANNEL_PRIORITY = 'CHANNEL_PRIORITY';
|
|
|
38
38
|
export const AB_TEST = 'AB_TEST';
|
|
39
39
|
export const SINGLE_TEMPLATE = 'SINGLE_TEMPLATE';
|
|
40
40
|
|
|
41
|
-
// CCS
|
|
41
|
+
// CCS create is only supported for SINGLE (CHANNEL_PRIORITY/AB_TEST skip CCS entirely).
|
|
42
42
|
export const CCS_STRATEGY_TYPE_SINGLE = 'SINGLE';
|
|
43
43
|
|
|
44
|
-
// Mirrors CCS SingleChannelStrategy field names
|
|
44
|
+
// Mirrors CCS SingleChannelStrategy field names; kept separate from the legacy
|
|
45
|
+
// messageMeta CHANNEL_CONTENT_KEY_MAP/CHANNEL_DELIVERY_KEY_MAP.
|
|
45
46
|
export const CCS_CHANNEL_CONTENT_KEY_MAP = {
|
|
46
47
|
SMS: 'smsMessageContent',
|
|
47
48
|
EMAIL: 'emailMessageContent',
|
|
48
49
|
WHATSAPP: 'whatsappMessageContent',
|
|
49
|
-
// CCS's channel enum
|
|
50
|
+
// CCS's channel enum for mobile push is 'PUSH', but content/delivery keys stay 'mpush*'.
|
|
50
51
|
PUSH: 'mpushMessageContent',
|
|
51
52
|
INAPP: 'inAppMessageContent',
|
|
52
53
|
ANDROID: 'androidMessageContent',
|
|
@@ -58,17 +59,16 @@ export const CCS_CHANNEL_CONTENT_KEY_MAP = {
|
|
|
58
59
|
RCS: 'rcsMessageContent',
|
|
59
60
|
};
|
|
60
61
|
|
|
61
|
-
//
|
|
62
|
+
// Transforms legacy messageMeta field names to CCS's before sending, for channels
|
|
63
|
+
// where the two shapes don't already match.
|
|
62
64
|
export const CCS_CONTENT_TRANSFORMS = {
|
|
63
65
|
EMAIL: (templateData = {}) => ({
|
|
64
66
|
messageSubject: templateData.emailSubject,
|
|
65
67
|
messageBody: templateData.emailBody || templateData.emailHtml,
|
|
66
68
|
}),
|
|
67
|
-
// SMS: legacy stores text as `messageBody`, while CCS expects `message`; map the field explicitly to avoid CCS returning smsMessageContent.message as null.
|
|
68
69
|
SMS: (templateData = {}) => ({
|
|
69
70
|
message: templateData.messageBody || '',
|
|
70
71
|
}),
|
|
71
|
-
// WEBPUSH: the legacy editor nests data under messageContent.content; flatten it to CCS's { messageSubject, accountId, content } shape and drop the legacy-only `offers` field.
|
|
72
72
|
WEBPUSH: (templateData = {}) => {
|
|
73
73
|
const inner = templateData.messageContent?.content || templateData;
|
|
74
74
|
return {
|
|
@@ -94,12 +94,14 @@ export const CCS_CHANNEL_DELIVERY_KEY_MAP = {
|
|
|
94
94
|
RCS: 'rcsDeliverySettings',
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
//
|
|
97
|
+
// UI uses `MOBILEPUSH`; CCS uses `PUSH` for the channel enum. Other channels pass through.
|
|
98
98
|
export const CCS_CHANNEL_NAME_MAP = {
|
|
99
99
|
MOBILEPUSH: 'PUSH',
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
// Channel config — single source of truth for CreativesContainer and TemplatesV2
|
|
102
|
+
// Channel config — single source of truth for CreativesContainer and TemplatesV2.
|
|
103
|
+
// paneKey: TemplatesV2 defaultPanes key used by channelsToHide.
|
|
104
|
+
// channelProp: CreativesContainer channel prop that must match pane.key for the tab to be active.
|
|
103
105
|
export const CHANNELS = [
|
|
104
106
|
{
|
|
105
107
|
value: 'SMS',
|
|
@@ -80,15 +80,13 @@ CommunicationFlowContainer.propTypes = {
|
|
|
80
80
|
controls: PropTypes.array,
|
|
81
81
|
}),
|
|
82
82
|
}),
|
|
83
|
-
context: PropTypes.object, // ouId, campaignId, programId
|
|
84
|
-
//
|
|
85
|
-
useCCS: PropTypes.bool, // If false, skips the CCS createCommDefinition call on save. Defaults to true.
|
|
83
|
+
context: PropTypes.object, // ouId, campaignId, programId, name, referenceId, description
|
|
84
|
+
useCCS: PropTypes.bool, // skips the CCS createCommDefinition call on save when false; defaults to true
|
|
86
85
|
}).isRequired,
|
|
87
86
|
initialData: PropTypes.object, // for edit/preview mode
|
|
88
|
-
onSave: PropTypes.func.isRequired, // (data) => void
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
onChange: PropTypes.func, // (data) => void - optional, called on data changes
|
|
87
|
+
onSave: PropTypes.func.isRequired, // (data) => void, data.ccsCommDefinition set when CCS create succeeded
|
|
88
|
+
onCancel: PropTypes.func.isRequired, // () => void
|
|
89
|
+
onChange: PropTypes.func, // (data) => void
|
|
92
90
|
};
|
|
93
91
|
|
|
94
92
|
CommunicationFlowContainer.defaultProps = {
|
|
@@ -64,6 +64,7 @@ const ChannelSelectionStep = ({
|
|
|
64
64
|
const [showIncentivesMenuMap, setShowIncentivesMenuMap] = useState({});
|
|
65
65
|
const [showTestAndPreview, setShowTestAndPreview] = useState(false);
|
|
66
66
|
const [testAndPreviewItem, setTestAndPreviewItem] = useState(null);
|
|
67
|
+
const [domainPropertiesData, setDomainPropertiesData] = useState(null);
|
|
67
68
|
const { formatMessage } = intl || {};
|
|
68
69
|
|
|
69
70
|
// Available channels (filter out hidden ones)
|
|
@@ -77,6 +78,19 @@ const ChannelSelectionStep = ({
|
|
|
77
78
|
const selectedChannelLowerCase = selectedChannel?.toLowerCase();
|
|
78
79
|
return CHANNELS.find((channel) => channel?.value?.toLowerCase() === selectedChannelLowerCase || channel?.channelProp === selectedChannelLowerCase) || null;
|
|
79
80
|
}, [selectedChannel]);
|
|
81
|
+
|
|
82
|
+
const editingZaloHostName = useMemo(() => {
|
|
83
|
+
if (!editingContentId) return '';
|
|
84
|
+
const editingItem = contentItems.find((c) => c.contentId === editingContentId);
|
|
85
|
+
if (editingItem?.channel?.toUpperCase() !== ZALO) return '';
|
|
86
|
+
const accountId = editingItem?.templateData?.accountId;
|
|
87
|
+
if (!accountId) return '';
|
|
88
|
+
const zaloDomains = domainPropertiesData?.ZALO || [];
|
|
89
|
+
const matchedDomain = zaloDomains.find(
|
|
90
|
+
(domain) => String(domain?.domainProperties?.connectionProperties?.oa_id) === String(accountId),
|
|
91
|
+
);
|
|
92
|
+
return matchedDomain?.domainProperties?.hostName || '';
|
|
93
|
+
}, [editingContentId, contentItems, domainPropertiesData]);
|
|
80
94
|
/**
|
|
81
95
|
* Handle CreativesContainer close
|
|
82
96
|
*/
|
|
@@ -467,6 +481,7 @@ const ChannelSelectionStep = ({
|
|
|
467
481
|
deliverySettingsData={deliverySettingsData}
|
|
468
482
|
deliverySetting={value?.deliverySetting}
|
|
469
483
|
onDeliverySettingChange={(deliverySetting) => onChange({ deliverySetting })}
|
|
484
|
+
onDomainPropertiesLoaded={setDomainPropertiesData}
|
|
470
485
|
intl={intl}
|
|
471
486
|
/>
|
|
472
487
|
)}
|
|
@@ -487,14 +502,12 @@ const ChannelSelectionStep = ({
|
|
|
487
502
|
getCreativesData={handleCreativesData}
|
|
488
503
|
handleCloseCreatives={handleCloseCreatives}
|
|
489
504
|
isFullMode={false}
|
|
505
|
+
hostName={editingZaloHostName}
|
|
490
506
|
messageDetails={{ type: 'default' }}
|
|
491
507
|
templateData={editingContentId ? (() => {
|
|
492
508
|
const saved = contentItems.find((c) => c.contentId === editingContentId)?.templateData;
|
|
493
|
-
// getTemplateData in CreativesContainer reads 'content', 'accountId', 'messageSubject' at
|
|
494
|
-
// top-level and needs 'type' for SlideBoxContent to set isEditWebPush. Our stored WEBPUSH
|
|
495
|
-
// templateData wraps those fields inside messageContent.content, so extract them here.
|
|
496
509
|
if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
|
|
497
|
-
return { ...saved.messageContent.content, type: WEBPUSH };
|
|
510
|
+
return { ...saved.messageContent.content, type: WEBPUSH, channel: WEBPUSH };
|
|
498
511
|
}
|
|
499
512
|
return saved;
|
|
500
513
|
})() : null}
|
package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js
CHANGED
|
@@ -37,9 +37,17 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
|
|
|
37
37
|
handleCloseCreatives,
|
|
38
38
|
creativesMode,
|
|
39
39
|
channel,
|
|
40
|
+
templateData,
|
|
41
|
+
hostName,
|
|
40
42
|
}) {
|
|
41
43
|
return (
|
|
42
|
-
<div
|
|
44
|
+
<div
|
|
45
|
+
data-testid="creatives-mock"
|
|
46
|
+
data-creatives-mode={creativesMode}
|
|
47
|
+
data-creatives-channel={channel}
|
|
48
|
+
data-template-data={JSON.stringify(templateData)}
|
|
49
|
+
data-host-name={hostName}
|
|
50
|
+
>
|
|
43
51
|
<button
|
|
44
52
|
type="button"
|
|
45
53
|
data-testid="creatives-save"
|
|
@@ -62,7 +70,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
|
|
|
62
70
|
});
|
|
63
71
|
|
|
64
72
|
jest.mock('../../DeliverySettingsStep', () => ({
|
|
65
|
-
DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
|
|
73
|
+
DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
|
|
66
74
|
return (
|
|
67
75
|
<div data-testid="delivery-settings-section">
|
|
68
76
|
<button
|
|
@@ -72,6 +80,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
|
|
|
72
80
|
>
|
|
73
81
|
Apply delivery
|
|
74
82
|
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
data-testid="domain-properties-loaded"
|
|
86
|
+
onClick={() => onDomainPropertiesLoaded?.({
|
|
87
|
+
ZALO: [{
|
|
88
|
+
id: 267284,
|
|
89
|
+
domainProperties: {
|
|
90
|
+
id: 4977,
|
|
91
|
+
domainName: 'Gapit_Automation',
|
|
92
|
+
connectionProperties: { oa_id: '300086756699856746' },
|
|
93
|
+
hostName: 'gapitzalotrans',
|
|
94
|
+
},
|
|
95
|
+
}],
|
|
96
|
+
})}
|
|
97
|
+
>
|
|
98
|
+
Load domain properties
|
|
99
|
+
</button>
|
|
75
100
|
</div>
|
|
76
101
|
);
|
|
77
102
|
},
|
|
@@ -1683,6 +1708,89 @@ describe('ChannelSelectionStep', () => {
|
|
|
1683
1708
|
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
|
|
1684
1709
|
});
|
|
1685
1710
|
|
|
1711
|
+
it('edit WEBPUSH item includes `channel: WEBPUSH` (not just `type`) in the unwrapped templateData (regression: CreativesContainer.getTemplateData\'s switch reads templateData.channel, not templateData.type — without it the switch never matched WEBPUSH and the edit screen rendered blank below its header)', async () => {
|
|
1712
|
+
renderStep(
|
|
1713
|
+
<ChannelSelectionStep
|
|
1714
|
+
value={{
|
|
1715
|
+
contentItems: [{
|
|
1716
|
+
contentId: 'wp-edit-channel',
|
|
1717
|
+
channel: 'WEBPUSH',
|
|
1718
|
+
templateData: {
|
|
1719
|
+
channel: 'WEBPUSH',
|
|
1720
|
+
messageContent: {
|
|
1721
|
+
content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
|
|
1722
|
+
},
|
|
1723
|
+
},
|
|
1724
|
+
}],
|
|
1725
|
+
}}
|
|
1726
|
+
onChange={jest.fn()}
|
|
1727
|
+
channels={CHANNELS}
|
|
1728
|
+
/>,
|
|
1729
|
+
);
|
|
1730
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1731
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1732
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1733
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1734
|
+
const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
|
|
1735
|
+
expect(passedTemplateData.channel).toBe('WEBPUSH');
|
|
1736
|
+
expect(passedTemplateData.type).toBe('WEBPUSH');
|
|
1737
|
+
expect(passedTemplateData.messageSubject).toBe('dasd');
|
|
1738
|
+
expect(passedTemplateData.accountId).toBe(13792);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
// ── ZALO edit: hostName resolved from domainProperties ────────────────────────
|
|
1742
|
+
|
|
1743
|
+
it('resolves a Zalo item\'s hostName from the domainProperties fetch by matching accountId to connectionProperties.oa_id, and passes it to CreativesContainer (regression: CCS\'s zaloMessageContent never carries hostName, and without it Zalo/index.js\'s getTemplateInfoById guard never passes, so the edit view never loads live template data)', async () => {
|
|
1744
|
+
renderStep(
|
|
1745
|
+
<ChannelSelectionStep
|
|
1746
|
+
value={{
|
|
1747
|
+
contentItems: [{
|
|
1748
|
+
contentId: 'zalo-edit',
|
|
1749
|
+
channel: 'ZALO',
|
|
1750
|
+
templateData: {
|
|
1751
|
+
channel: 'ZALO',
|
|
1752
|
+
accountId: '300086756699856746',
|
|
1753
|
+
accountName: 'gapit_automation_account',
|
|
1754
|
+
token: 'zalo-token',
|
|
1755
|
+
templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
|
|
1756
|
+
},
|
|
1757
|
+
}],
|
|
1758
|
+
}}
|
|
1759
|
+
onChange={jest.fn()}
|
|
1760
|
+
channels={CHANNELS}
|
|
1761
|
+
deliverySettingsData={{ required: false }}
|
|
1762
|
+
/>,
|
|
1763
|
+
);
|
|
1764
|
+
await userEvent.click(screen.getByTestId('domain-properties-loaded'));
|
|
1765
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1766
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1767
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1768
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1769
|
+
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
|
|
1773
|
+
renderStep(
|
|
1774
|
+
<ChannelSelectionStep
|
|
1775
|
+
value={{
|
|
1776
|
+
contentItems: [{
|
|
1777
|
+
contentId: 'zalo-edit-no-match',
|
|
1778
|
+
channel: 'ZALO',
|
|
1779
|
+
templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
|
|
1780
|
+
}],
|
|
1781
|
+
}}
|
|
1782
|
+
onChange={jest.fn()}
|
|
1783
|
+
channels={CHANNELS}
|
|
1784
|
+
deliverySettingsData={{ required: false }}
|
|
1785
|
+
/>,
|
|
1786
|
+
);
|
|
1787
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1788
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1789
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1790
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1791
|
+
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1686
1794
|
// ── FTP channel filtered from dropdown ───────────────────────────────────────
|
|
1687
1795
|
|
|
1688
1796
|
it('FTP channel is filtered out of the channel dropdown', async () => {
|
package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js
CHANGED
|
@@ -33,6 +33,7 @@ const DeliverySettingsSection = ({
|
|
|
33
33
|
deliverySettingsData,
|
|
34
34
|
deliverySetting = {},
|
|
35
35
|
onDeliverySettingChange,
|
|
36
|
+
onDomainPropertiesLoaded,
|
|
36
37
|
intl,
|
|
37
38
|
}) => {
|
|
38
39
|
const [showSlidebox, setShowSlidebox] = useState(false);
|
|
@@ -102,6 +103,7 @@ const DeliverySettingsSection = ({
|
|
|
102
103
|
)
|
|
103
104
|
: raw;
|
|
104
105
|
setDomainPropertiesData(entity);
|
|
106
|
+
onDomainPropertiesLoaded?.(entity);
|
|
105
107
|
}
|
|
106
108
|
} catch (err) {
|
|
107
109
|
if (!cancelled) setDomainPropertiesData(null);
|
|
@@ -316,6 +318,7 @@ DeliverySettingsSection.propTypes = {
|
|
|
316
318
|
deliverySettingsData: PropTypes.object,
|
|
317
319
|
deliverySetting: PropTypes.object,
|
|
318
320
|
onDeliverySettingChange: PropTypes.func,
|
|
321
|
+
onDomainPropertiesLoaded: PropTypes.func,
|
|
319
322
|
intl: PropTypes.object.isRequired,
|
|
320
323
|
};
|
|
321
324
|
|
|
@@ -412,7 +412,7 @@ export class Creatives extends React.Component {
|
|
|
412
412
|
this.setState({ isGetFormData: false });
|
|
413
413
|
};
|
|
414
414
|
|
|
415
|
-
mapCarouselDataToCreatives = (cards) => cards.map((card) => {
|
|
415
|
+
mapCarouselDataToCreatives = (cards) => (cards || []).map((card) => {
|
|
416
416
|
const {
|
|
417
417
|
cardVarMapped, bodyTemplate, media, buttons, mediaType,
|
|
418
418
|
} = card || {};
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
whatsappGetCreativeData2,
|
|
11
11
|
whatsappGetTemplateData1,
|
|
12
12
|
whatsappGetTemplateData2,
|
|
13
|
+
whatsappGetTemplateDataNullCards,
|
|
13
14
|
rcsTemplates,
|
|
14
15
|
rcsEditTemplateData,
|
|
15
16
|
smsEditTemplateData,
|
|
@@ -77,6 +78,17 @@ describe('Test SlideBoxContent container', () => {
|
|
|
77
78
|
expect(handleCloseCreatives).toHaveBeenCalledWith(true);
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
it('does not throw when templateConfigs.cards is explicitly null (regression: CCS sends null rather than omitting the field on non-carousel WhatsApp templates, and mapCarouselDataToCreatives called .map() straight off it)', () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
renderFunction(
|
|
84
|
+
'WHATSAPP',
|
|
85
|
+
'editTemplate',
|
|
86
|
+
whatsappTemplates,
|
|
87
|
+
whatsappGetTemplateDataNullCards,
|
|
88
|
+
),
|
|
89
|
+
).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
80
92
|
it('it should clear the url, on channel change from new whatsapp to another', () => {
|
|
81
93
|
renderFunction(
|
|
82
94
|
'WHATSAPP',
|
|
@@ -119,7 +119,7 @@ import { ANDROID } from '../../v2Components/CommonTestAndPreview/constants';
|
|
|
119
119
|
import CapImageUpload from '../../v2Components/CapImageUpload';
|
|
120
120
|
import TagList from '../TagList';
|
|
121
121
|
import { validateTags } from '../../utils/tagValidations';
|
|
122
|
-
import { splitContentByOrderedVarTokens } from '../../utils/templateVarUtils';
|
|
122
|
+
import { splitContentByOrderedVarTokens, reconcileVarMapToSlotFormat } from '../../utils/templateVarUtils';
|
|
123
123
|
import { capitalizeString } from '../../utils/Formatter';
|
|
124
124
|
import CapWhatsappCTA from '../../v2Components/CapWhatsappCTA';
|
|
125
125
|
import {
|
|
@@ -504,7 +504,10 @@ export const Whatsapp = (props) => {
|
|
|
504
504
|
if (templateHeaderArray?.length !== 0) {
|
|
505
505
|
let clonedVarMap = {};
|
|
506
506
|
if (!isEmpty(varMap)) {
|
|
507
|
-
|
|
507
|
+
// CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
|
|
508
|
+
// rather than this UI's own `${token}_${index}` slot keys — reconcile so
|
|
509
|
+
// values land on the right segment instead of silently missing.
|
|
510
|
+
clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
|
|
508
511
|
} else {
|
|
509
512
|
templateHeaderArray?.forEach((headerValue, i) => {
|
|
510
513
|
if (headerValue?.match(regex)?.length > 0) {
|
|
@@ -562,7 +565,9 @@ export const Whatsapp = (props) => {
|
|
|
562
565
|
if (tempMsgArray.length !== 0) {
|
|
563
566
|
const { varMapped = {} } = editContent;
|
|
564
567
|
if (!isEmpty(varMapped)) {
|
|
565
|
-
|
|
568
|
+
// CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
|
|
569
|
+
// rather than this UI's own `${token}_${index}` slot keys
|
|
570
|
+
varMap = reconcileVarMapToSlotFormat(varMapped, tempMsgArray, validVarRegex);
|
|
566
571
|
} else {
|
|
567
572
|
//computing and setting varMap for first edit
|
|
568
573
|
for (let i = 0; i < tempMsgArray.length; i += 1) {
|
package/v2Containers/mockdata.js
CHANGED
|
@@ -1111,6 +1111,31 @@ export default {
|
|
|
1111
1111
|
},
|
|
1112
1112
|
accountName: "WhatsappAccount",
|
|
1113
1113
|
},
|
|
1114
|
+
// CCS sends an explicit `cards: null` (not omitted) for a non-carousel WhatsApp
|
|
1115
|
+
// template — regression fixture for a crash reading `.map` off null.
|
|
1116
|
+
whatsappGetTemplateDataNullCards: {
|
|
1117
|
+
channel: "WHATSAPP",
|
|
1118
|
+
storeType: "REGISTERED_STORE",
|
|
1119
|
+
accountId: 12721,
|
|
1120
|
+
messagePartsCount: 1,
|
|
1121
|
+
messageBody: "Hey test, this is a plain WhatsApp template with no carousel.",
|
|
1122
|
+
templateConfigs: {
|
|
1123
|
+
name: "creatives_whatsapp6",
|
|
1124
|
+
language: "en",
|
|
1125
|
+
varMapped: {
|
|
1126
|
+
"{{1}}_1": "test",
|
|
1127
|
+
},
|
|
1128
|
+
template: "Hey {{1}}, this is a plain WhatsApp template with no carousel.",
|
|
1129
|
+
id: "creatives_whatsapp6",
|
|
1130
|
+
category: "MARKETING",
|
|
1131
|
+
buttonType: "NONE",
|
|
1132
|
+
buttons: null,
|
|
1133
|
+
mediaType: "TEXT",
|
|
1134
|
+
whatsappMedia: null,
|
|
1135
|
+
cards: null,
|
|
1136
|
+
},
|
|
1137
|
+
accountName: "WhatsappAccount",
|
|
1138
|
+
},
|
|
1114
1139
|
whatsappGetCreativeData1: {
|
|
1115
1140
|
value: {
|
|
1116
1141
|
name: "creatives_whatsapp6",
|