@capillarytech/creatives-library 9.0.56-alpha.5 → 9.0.56-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/services/api.js +1 -1
- package/utils/templateVarUtils.js +16 -0
- package/utils/tests/templateVarUtils.test.js +44 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -2
- package/v2Components/CommonTestAndPreview/index.js +3 -6
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -0
- package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -14
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +1 -1
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +35 -3
- package/v2Containers/CommunicationFlow/constants.js +1 -3
- package/v2Containers/CommunicationFlow/messages.js +4 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +19 -5
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +174 -2
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +3 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +3 -3
- package/v2Containers/CreativesContainer/index.js +1 -1
- package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
- package/v2Containers/Whatsapp/index.js +6 -3
- package/v2Containers/mockdata.js +25 -0
package/package.json
CHANGED
package/services/api.js
CHANGED
|
@@ -701,7 +701,7 @@ 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
|
+
// Use the CCS CommDefinition API for CommunicationFlow Save; keep legacy messageMeta APIs for CreativesContainer.
|
|
705
705
|
const COMM_DEFINITIONS_PATH = `${API_ENDPOINT}/comm-definitions`;
|
|
706
706
|
|
|
707
707
|
export const createCommDefinition = (payload) => {
|
|
@@ -115,6 +115,22 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
|
|
|
115
115
|
return variables;
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
// Reconcile slot-format (`${token}_${index}`) and CCS-format (`"0"`, `"1"`, ...) var maps into the editor's `${token}_${segmentIndex}` slot-key format.
|
|
119
|
+
export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
|
|
120
|
+
if (Object.keys(rawVarMap ?? {}).length === 0) return {};
|
|
121
|
+
const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
|
|
122
|
+
if (isSlotFormat) return { ...rawVarMap };
|
|
123
|
+
const slotMap = {};
|
|
124
|
+
let occurrenceIndex = 0;
|
|
125
|
+
(segments ?? []).forEach((segment, segmentIndex) => {
|
|
126
|
+
if (typeof segment === 'string' && (segment.match(regex) || []).length > 0) {
|
|
127
|
+
slotMap[`${segment}_${segmentIndex}`] = rawVarMap[occurrenceIndex] ?? '';
|
|
128
|
+
occurrenceIndex += 1;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return slotMap;
|
|
132
|
+
};
|
|
133
|
+
|
|
118
134
|
/**
|
|
119
135
|
* Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
|
|
120
136
|
* 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,8 +140,7 @@ const UnifiedPreview = ({
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
case CHANNELS.LINE:
|
|
143
|
-
// LINE
|
|
144
|
-
// preview. Rich types (image/video/sticker/imageMap/carousel/flex) aren't rendered yet.
|
|
143
|
+
// LINE currently supports plain text only, so reuse the SMS bubble preview; rich message types are not rendered yet.
|
|
145
144
|
return (
|
|
146
145
|
<SmsPreviewContent
|
|
147
146
|
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:
|
|
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,
|
|
@@ -3624,14 +3624,11 @@ const CommonTestAndPreview = (props) => {
|
|
|
3624
3624
|
setIsCustomerDataLoading(false);
|
|
3625
3625
|
}
|
|
3626
3626
|
};
|
|
3627
|
-
|
|
3627
|
+
|
|
3628
3628
|
/**
|
|
3629
3629
|
* Handle send test message
|
|
3630
3630
|
*/
|
|
3631
|
-
// notify/ui
|
|
3632
|
-
// real CommDefinition, instead of the legacy createMessageMeta/sendTestMessage flow below.
|
|
3633
|
-
// Dispatched via the same action/saga pattern as every other API call in this component,
|
|
3634
|
-
// rather than calling Api.sendTransactionalComm directly.
|
|
3631
|
+
// notify/ui sends tests via CCS's CommDefinition send/transaction endpoint using the standard action/saga flow, not the legacy messageMeta flow.
|
|
3635
3632
|
const handleCcsSendTestMessage = () => {
|
|
3636
3633
|
const allUserIds = [];
|
|
3637
3634
|
selectedTestEntities.forEach((entityId) => {
|
|
@@ -221,6 +221,10 @@ const CommunicationFlow = ({
|
|
|
221
221
|
singleChannelStrategy,
|
|
222
222
|
})
|
|
223
223
|
: await createCommDefinition(payload);
|
|
224
|
+
if (res?.isError) {
|
|
225
|
+
setSaveError(formatMessage(messages.genericSaveError));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
224
228
|
if (isDuplicateReferenceIdError(res)) {
|
|
225
229
|
setSaveError(formatMessage(messages.duplicateReferenceIdError));
|
|
226
230
|
return;
|
|
@@ -234,6 +238,9 @@ const CommunicationFlow = ({
|
|
|
234
238
|
version: data.version?.version ?? data.version ?? 1,
|
|
235
239
|
status: data.status,
|
|
236
240
|
};
|
|
241
|
+
} else {
|
|
242
|
+
setSaveError(formatMessage(messages.genericSaveError));
|
|
243
|
+
return;
|
|
237
244
|
}
|
|
238
245
|
} else if (data?.id) {
|
|
239
246
|
ccsCommDefinition = {
|
|
@@ -242,9 +249,14 @@ const CommunicationFlow = ({
|
|
|
242
249
|
version: data.version?.version ?? 1,
|
|
243
250
|
status: data.status,
|
|
244
251
|
};
|
|
252
|
+
} else {
|
|
253
|
+
setSaveError(formatMessage(messages.genericSaveError));
|
|
254
|
+
return;
|
|
245
255
|
}
|
|
246
256
|
} catch (error) {
|
|
247
257
|
console.error('[CommunicationFlow] CCS createCommDefinition error:', error);
|
|
258
|
+
setSaveError(formatMessage(messages.genericSaveError));
|
|
259
|
+
return;
|
|
248
260
|
}
|
|
249
261
|
} else if (!name) {
|
|
250
262
|
console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — config.context.name is required');
|
|
@@ -128,16 +128,7 @@
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
.communication-flow-container {
|
|
131
|
-
//
|
|
132
|
-
// area regardless of how much step content is above it. <CapRow useLegacy>
|
|
133
|
-
// carries cap-ui-library's `.ant-row-legacy` rule (`display: block !important`),
|
|
134
|
-
// which silently defeats a plain `display:flex`/`min-height` here unless the
|
|
135
|
-
// selector is compounded with `.ant-row-legacy` and marked `!important` too.
|
|
136
|
-
// Deliberately position-based, not flex + margin-top: auto — a flex version
|
|
137
|
-
// makes this element (and its .ant-row-legacy children) a flex container, and
|
|
138
|
-
// flexbox's default align-items: stretch leaks into deeply nested descendants
|
|
139
|
-
// (e.g. ChannelSelectionStep's fixed-width empty-state card), stretching them
|
|
140
|
-
// to fill the container instead of keeping their own size.
|
|
131
|
+
// Anchor the Save footer to the bottom without flex, avoiding .ant-row-legacy overrides and flexbox stretching nested fixed-size content.
|
|
141
132
|
&.ant-row-legacy {
|
|
142
133
|
position: relative !important;
|
|
143
134
|
min-height: calc(100vh - 6.571rem) !important;
|
|
@@ -148,10 +139,7 @@
|
|
|
148
139
|
margin: $CAP_SPACE_32 0;
|
|
149
140
|
}
|
|
150
141
|
|
|
151
|
-
//
|
|
152
|
-
// exists (no dangling line above an empty "Add creative" state), but the gap
|
|
153
|
-
// it would have provided (.step-divider's margin) must stay — otherwise the
|
|
154
|
-
// section sits flush against the Channel card in the empty state.
|
|
142
|
+
// Hide the divider when empty but preserve its margin gap so the section doesn’t sit flush against the Channel card.
|
|
155
143
|
.communication-strategy-row--no-divider {
|
|
156
144
|
margin-bottom: $CAP_SPACE_32;
|
|
157
145
|
}
|
|
@@ -166,7 +166,7 @@ const CommunicationFlowCard = ({
|
|
|
166
166
|
<CapColumn span={12} className="card-body-right-col">
|
|
167
167
|
<CapLabel type="label8">{formatMessage(messages.dynamicControlsTitle)}</CapLabel>
|
|
168
168
|
{dynamicControlKeys.map((key) => {
|
|
169
|
-
const controlConfig = controls.find((
|
|
169
|
+
const controlConfig = controls.find((control) => control.key === key);
|
|
170
170
|
if (!controlConfig) return null;
|
|
171
171
|
return (
|
|
172
172
|
<CapRow key={key} type="flex" justify="space-between" className="control-row">
|
|
@@ -693,7 +693,7 @@ describe('handleSave — CCS flow', () => {
|
|
|
693
693
|
expect(createCommDefinition).not.toHaveBeenCalled();
|
|
694
694
|
});
|
|
695
695
|
|
|
696
|
-
it('
|
|
696
|
+
it('blocks save and shows a generic error when createCommDefinition rejects (regression: this used to silently call onSave without ccsCommDefinition and no user-facing feedback — Send for approval would then just be disabled with zero explanation why)', async () => {
|
|
697
697
|
createCommDefinition.mockRejectedValue(new Error('Network error'));
|
|
698
698
|
const onSave = jest.fn();
|
|
699
699
|
renderWithFlow({
|
|
@@ -705,8 +705,40 @@ describe('handleSave — CCS flow', () => {
|
|
|
705
705
|
|
|
706
706
|
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
707
707
|
|
|
708
|
-
await waitFor(() => expect(
|
|
709
|
-
expect(onSave).
|
|
708
|
+
await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
|
|
709
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('blocks save and shows a generic error when createCommDefinition resolves with a network-level failure (api.js resolves isError instead of rejecting on a 401/403/etc)', async () => {
|
|
713
|
+
createCommDefinition.mockResolvedValue({ isError: true, status: 401 });
|
|
714
|
+
const onSave = jest.fn();
|
|
715
|
+
renderWithFlow({
|
|
716
|
+
features: {},
|
|
717
|
+
config: ccsConfig,
|
|
718
|
+
initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
|
|
719
|
+
onSave,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
723
|
+
|
|
724
|
+
await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
|
|
725
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('blocks save and shows a generic error when createCommDefinition resolves with no usable data (e.g. missing id)', async () => {
|
|
729
|
+
createCommDefinition.mockResolvedValue({ response: { data: {} } });
|
|
730
|
+
const onSave = jest.fn();
|
|
731
|
+
renderWithFlow({
|
|
732
|
+
features: {},
|
|
733
|
+
config: ccsConfig,
|
|
734
|
+
initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
|
|
735
|
+
onSave,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
739
|
+
|
|
740
|
+
await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
|
|
741
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
710
742
|
});
|
|
711
743
|
|
|
712
744
|
it('blocks save and shows an error when createCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
|
|
@@ -99,9 +99,7 @@ export const CCS_CHANNEL_NAME_MAP = {
|
|
|
99
99
|
MOBILEPUSH: 'PUSH',
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
// Channel config
|
|
103
|
-
// paneKey: TemplatesV2 defaultPanes key used by channelsToHide.
|
|
104
|
-
// channelProp: CreativesContainer channel prop that must match pane.key for the tab to be active.
|
|
102
|
+
// Channel config is the shared source of truth: paneKey maps TemplatesV2 panes, channelProp maps CreativesContainer tabs.
|
|
105
103
|
export const CHANNELS = [
|
|
106
104
|
{
|
|
107
105
|
value: 'SMS',
|
|
@@ -367,4 +367,8 @@ export default {
|
|
|
367
367
|
id: `${prefix}.duplicateReferenceIdError`,
|
|
368
368
|
defaultMessage: 'This reference ID is already in use in your organization. Please use a different reference ID.',
|
|
369
369
|
},
|
|
370
|
+
genericSaveError: {
|
|
371
|
+
id: `${prefix}.genericSaveError`,
|
|
372
|
+
defaultMessage: 'Something went wrong while saving your content. Please try again.',
|
|
373
|
+
},
|
|
370
374
|
};
|
|
@@ -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,13 @@ const ChannelSelectionStep = ({
|
|
|
487
502
|
getCreativesData={handleCreativesData}
|
|
488
503
|
handleCloseCreatives={handleCloseCreatives}
|
|
489
504
|
isFullMode={false}
|
|
490
|
-
|
|
505
|
+
hostName={editingZaloHostName}
|
|
506
|
+
messageDetails={{ type: (config?.context?.module || '').toLowerCase() || 'default' }}
|
|
507
|
+
location={{ query: { type: 'embedded', module: config?.context?.module } }}
|
|
491
508
|
templateData={editingContentId ? (() => {
|
|
492
509
|
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
510
|
if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
|
|
497
|
-
return { ...saved.messageContent.content, type: WEBPUSH };
|
|
511
|
+
return { ...saved.messageContent.content, type: WEBPUSH, channel: WEBPUSH };
|
|
498
512
|
}
|
|
499
513
|
return saved;
|
|
500
514
|
})() : null}
|
package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js
CHANGED
|
@@ -37,9 +37,21 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
|
|
|
37
37
|
handleCloseCreatives,
|
|
38
38
|
creativesMode,
|
|
39
39
|
channel,
|
|
40
|
+
templateData,
|
|
41
|
+
hostName,
|
|
42
|
+
messageDetails,
|
|
43
|
+
location,
|
|
40
44
|
}) {
|
|
41
45
|
return (
|
|
42
|
-
<div
|
|
46
|
+
<div
|
|
47
|
+
data-testid="creatives-mock"
|
|
48
|
+
data-creatives-mode={creativesMode}
|
|
49
|
+
data-creatives-channel={channel}
|
|
50
|
+
data-template-data={JSON.stringify(templateData)}
|
|
51
|
+
data-host-name={hostName}
|
|
52
|
+
data-message-details={JSON.stringify(messageDetails)}
|
|
53
|
+
data-location={JSON.stringify(location)}
|
|
54
|
+
>
|
|
43
55
|
<button
|
|
44
56
|
type="button"
|
|
45
57
|
data-testid="creatives-save"
|
|
@@ -62,7 +74,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
|
|
|
62
74
|
});
|
|
63
75
|
|
|
64
76
|
jest.mock('../../DeliverySettingsStep', () => ({
|
|
65
|
-
DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
|
|
77
|
+
DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
|
|
66
78
|
return (
|
|
67
79
|
<div data-testid="delivery-settings-section">
|
|
68
80
|
<button
|
|
@@ -72,6 +84,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
|
|
|
72
84
|
>
|
|
73
85
|
Apply delivery
|
|
74
86
|
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
data-testid="domain-properties-loaded"
|
|
90
|
+
onClick={() => onDomainPropertiesLoaded?.({
|
|
91
|
+
ZALO: [{
|
|
92
|
+
id: 267284,
|
|
93
|
+
domainProperties: {
|
|
94
|
+
id: 4977,
|
|
95
|
+
domainName: 'Gapit_Automation',
|
|
96
|
+
connectionProperties: { oa_id: '300086756699856746' },
|
|
97
|
+
hostName: 'gapitzalotrans',
|
|
98
|
+
},
|
|
99
|
+
}],
|
|
100
|
+
})}
|
|
101
|
+
>
|
|
102
|
+
Load domain properties
|
|
103
|
+
</button>
|
|
75
104
|
</div>
|
|
76
105
|
);
|
|
77
106
|
},
|
|
@@ -1683,6 +1712,149 @@ describe('ChannelSelectionStep', () => {
|
|
|
1683
1712
|
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
|
|
1684
1713
|
});
|
|
1685
1714
|
|
|
1715
|
+
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 () => {
|
|
1716
|
+
renderStep(
|
|
1717
|
+
<ChannelSelectionStep
|
|
1718
|
+
value={{
|
|
1719
|
+
contentItems: [{
|
|
1720
|
+
contentId: 'wp-edit-channel',
|
|
1721
|
+
channel: 'WEBPUSH',
|
|
1722
|
+
templateData: {
|
|
1723
|
+
channel: 'WEBPUSH',
|
|
1724
|
+
messageContent: {
|
|
1725
|
+
content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
|
|
1726
|
+
},
|
|
1727
|
+
},
|
|
1728
|
+
}],
|
|
1729
|
+
}}
|
|
1730
|
+
onChange={jest.fn()}
|
|
1731
|
+
channels={CHANNELS}
|
|
1732
|
+
/>,
|
|
1733
|
+
);
|
|
1734
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1735
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1736
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1737
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1738
|
+
const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
|
|
1739
|
+
expect(passedTemplateData.channel).toBe('WEBPUSH');
|
|
1740
|
+
expect(passedTemplateData.type).toBe('WEBPUSH');
|
|
1741
|
+
expect(passedTemplateData.messageSubject).toBe('dasd');
|
|
1742
|
+
expect(passedTemplateData.accountId).toBe(13792);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// ── ZALO edit: hostName resolved from domainProperties ────────────────────────
|
|
1746
|
+
|
|
1747
|
+
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 () => {
|
|
1748
|
+
renderStep(
|
|
1749
|
+
<ChannelSelectionStep
|
|
1750
|
+
value={{
|
|
1751
|
+
contentItems: [{
|
|
1752
|
+
contentId: 'zalo-edit',
|
|
1753
|
+
channel: 'ZALO',
|
|
1754
|
+
templateData: {
|
|
1755
|
+
channel: 'ZALO',
|
|
1756
|
+
accountId: '300086756699856746',
|
|
1757
|
+
accountName: 'gapit_automation_account',
|
|
1758
|
+
token: 'zalo-token',
|
|
1759
|
+
templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
|
|
1760
|
+
},
|
|
1761
|
+
}],
|
|
1762
|
+
}}
|
|
1763
|
+
onChange={jest.fn()}
|
|
1764
|
+
channels={CHANNELS}
|
|
1765
|
+
deliverySettingsData={{ required: false }}
|
|
1766
|
+
/>,
|
|
1767
|
+
);
|
|
1768
|
+
await userEvent.click(screen.getByTestId('domain-properties-loaded'));
|
|
1769
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1770
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1771
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1772
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1773
|
+
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
|
|
1777
|
+
renderStep(
|
|
1778
|
+
<ChannelSelectionStep
|
|
1779
|
+
value={{
|
|
1780
|
+
contentItems: [{
|
|
1781
|
+
contentId: 'zalo-edit-no-match',
|
|
1782
|
+
channel: 'ZALO',
|
|
1783
|
+
templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
|
|
1784
|
+
}],
|
|
1785
|
+
}}
|
|
1786
|
+
onChange={jest.fn()}
|
|
1787
|
+
channels={CHANNELS}
|
|
1788
|
+
deliverySettingsData={{ required: false }}
|
|
1789
|
+
/>,
|
|
1790
|
+
);
|
|
1791
|
+
await userEvent.click(screen.getByLabelText('Show more content options icon'));
|
|
1792
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1793
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
|
|
1794
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1795
|
+
expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
// ── Tag-context pass-through (config.context.module) ──────────────────────────
|
|
1799
|
+
|
|
1800
|
+
it('passes config.context.module through to messageDetails.type (lowercased) and location.query.module, unchanged, for tag-fetch scoping (regression: this used to be hardcoded to messageDetails={{type: \'default\'}} with no location prop at all, so every consumer\'s tag-fetch was scoped generically instead of per-consumer)', async () => {
|
|
1801
|
+
renderStep(
|
|
1802
|
+
<ChannelSelectionStep
|
|
1803
|
+
value={{ contentItems: [] }}
|
|
1804
|
+
onChange={jest.fn()}
|
|
1805
|
+
channels={CHANNELS}
|
|
1806
|
+
config={{ context: { module: 'CAMPAIGNS' } }}
|
|
1807
|
+
/>,
|
|
1808
|
+
);
|
|
1809
|
+
await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
|
|
1810
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1811
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
|
|
1812
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1813
|
+
|
|
1814
|
+
const mock = screen.getByTestId('creatives-mock');
|
|
1815
|
+
expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'campaigns' });
|
|
1816
|
+
expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
|
|
1817
|
+
query: { type: 'embedded', module: 'CAMPAIGNS' },
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
it('passes through whatever module value a coupons/cartPromotions-style config sets, with no special-casing on our end', async () => {
|
|
1822
|
+
renderStep(
|
|
1823
|
+
<ChannelSelectionStep
|
|
1824
|
+
value={{ contentItems: [] }}
|
|
1825
|
+
onChange={jest.fn()}
|
|
1826
|
+
channels={CHANNELS}
|
|
1827
|
+
config={{ context: { module: 'coupons' } }}
|
|
1828
|
+
/>,
|
|
1829
|
+
);
|
|
1830
|
+
await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
|
|
1831
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1832
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
|
|
1833
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1834
|
+
|
|
1835
|
+
const mock = screen.getByTestId('creatives-mock');
|
|
1836
|
+
expect(JSON.parse(mock.getAttribute('data-message-details'))).toEqual({ type: 'coupons' });
|
|
1837
|
+
expect(JSON.parse(mock.getAttribute('data-location'))).toEqual({
|
|
1838
|
+
query: { type: 'embedded', module: 'coupons' },
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it('falls back to messageDetails={{type: "default"}} when config.context.module is absent (unset consumers keep prior behavior)', async () => {
|
|
1843
|
+
renderStep(
|
|
1844
|
+
<ChannelSelectionStep
|
|
1845
|
+
value={{ contentItems: [] }}
|
|
1846
|
+
onChange={jest.fn()}
|
|
1847
|
+
channels={CHANNELS}
|
|
1848
|
+
/>,
|
|
1849
|
+
);
|
|
1850
|
+
await userEvent.click(screen.getByRole('button', { name: /add creative/i }));
|
|
1851
|
+
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1852
|
+
await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
|
|
1853
|
+
await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
|
|
1854
|
+
|
|
1855
|
+
expect(JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-message-details'))).toEqual({ type: 'default' });
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1686
1858
|
// ── FTP channel filtered from dropdown ───────────────────────────────────────
|
|
1687
1859
|
|
|
1688
1860
|
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
|
|
|
@@ -181,7 +181,7 @@ const getEmailDomainValue = (entity) => {
|
|
|
181
181
|
return emailData.domainProperties?.id ?? emailData.domainId ?? emailData.id ?? '';
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
-
// CCS
|
|
184
|
+
// CCS domainGatewayMapId is the domainProperties API's outer id, not domainProperties.id; it identifies the selected domain's gateway mapping.
|
|
185
185
|
const getEmailDomainGatewayMapIdByDomainId = (entity, domainId) => {
|
|
186
186
|
const domainEntry = getEmailDomainById(entity, domainId);
|
|
187
187
|
return domainEntry?.id ?? '';
|
|
@@ -582,7 +582,7 @@ export const parseEntityForDisplay = (entity, context = {}) => {
|
|
|
582
582
|
senderDetails: entity,
|
|
583
583
|
smsSenderId: getSmsSenderIdValue(entity),
|
|
584
584
|
smsDomain: getSmsDomainValue(entity),
|
|
585
|
-
// emailDomain
|
|
585
|
+
// emailDomain is the display-only domain name; buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId sourced separately.
|
|
586
586
|
emailDomain: getEmailDomainNameForDisplay(entity),
|
|
587
587
|
emailDomainId: getEmailDomainValue(entity),
|
|
588
588
|
emailDomainGatewayMapId: getEmailDomainGatewayMapIdByDomainId(entity, getEmailDomainValue(entity)),
|
|
@@ -611,7 +611,7 @@ export const buildChannelSettingFromFieldValues = (fieldValues = {}) => {
|
|
|
611
611
|
}
|
|
612
612
|
if (fieldValues.emailDomain != null || fieldValues.emailSenderId != null || fieldValues.emailSenderName != null || fieldValues.emailReplyToId != null) {
|
|
613
613
|
channelSetting.EMAIL = {
|
|
614
|
-
//
|
|
614
|
+
// Prefer numeric emailDomainId from auto-save defaults; emailDomain may be either a SenderDetails ID or display name.
|
|
615
615
|
domainId: fieldValues.emailDomainId ?? fieldValues.emailDomain,
|
|
616
616
|
domainGatewayMapId: fieldValues.emailDomainGatewayMapId,
|
|
617
617
|
senderId: fieldValues.emailSenderId,
|
|
@@ -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,8 @@ export const Whatsapp = (props) => {
|
|
|
504
504
|
if (templateHeaderArray?.length !== 0) {
|
|
505
505
|
let clonedVarMap = {};
|
|
506
506
|
if (!isEmpty(varMap)) {
|
|
507
|
-
|
|
507
|
+
// Reconcile CCS sequential varMapped indices to the UI’s `${token}_${index}` slot keys so values map to the correct segments.
|
|
508
|
+
clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
|
|
508
509
|
} else {
|
|
509
510
|
templateHeaderArray?.forEach((headerValue, i) => {
|
|
510
511
|
if (headerValue?.match(regex)?.length > 0) {
|
|
@@ -562,7 +563,9 @@ export const Whatsapp = (props) => {
|
|
|
562
563
|
if (tempMsgArray.length !== 0) {
|
|
563
564
|
const { varMapped = {} } = editContent;
|
|
564
565
|
if (!isEmpty(varMapped)) {
|
|
565
|
-
|
|
566
|
+
// CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
|
|
567
|
+
// rather than this UI's own `${token}_${index}` slot keys
|
|
568
|
+
varMap = reconcileVarMapToSlotFormat(varMapped, tempMsgArray, validVarRegex);
|
|
566
569
|
} else {
|
|
567
570
|
//computing and setting varMap for first edit
|
|
568
571
|
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",
|