@capillarytech/creatives-library 9.0.56-alpha.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.56-alpha.6",
4
+ "version": "9.0.56-alpha.7",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
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, proxied through cap-creatives-api; used by CommunicationFlow's Save, while legacy messageMeta functions remain for CreativesContainer's save flow.
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,23 +115,7 @@ 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
- */
118
+ // Reconcile slot-format (`${token}_${index}`) and CCS-format (`"0"`, `"1"`, ...) var maps into the editor's `${token}_${segmentIndex}` slot-key format.
135
119
  export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
136
120
  if (Object.keys(rawVarMap ?? {}).length === 0) return {};
137
121
  const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
@@ -140,8 +140,7 @@ const UnifiedPreview = ({
140
140
  }
141
141
 
142
142
  case CHANNELS.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.
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: { commDefinitionId, referenceId } routes "Send test" to CCS's send/transaction endpoint.
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: send the test through CCS's own send/transaction endpoint against the alert's
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
- // Anchors the Save footer to the bottom of the slidebox's scrollable content
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
- // CommunicationStrategyStep's own trailing divider is hidden until content
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((c) => c.key === key);
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('still calls onSave (without ccsCommDefinition) when createCommDefinition rejects', async () => {
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(onSave).toHaveBeenCalledTimes(1));
709
- expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
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 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.
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
  };
@@ -503,7 +503,8 @@ const ChannelSelectionStep = ({
503
503
  handleCloseCreatives={handleCloseCreatives}
504
504
  isFullMode={false}
505
505
  hostName={editingZaloHostName}
506
- messageDetails={{ type: 'default' }}
506
+ messageDetails={{ type: (config?.context?.module || '').toLowerCase() || 'default' }}
507
+ location={{ query: { type: 'embedded', module: config?.context?.module } }}
507
508
  templateData={editingContentId ? (() => {
508
509
  const saved = contentItems.find((c) => c.contentId === editingContentId)?.templateData;
509
510
  if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
@@ -39,6 +39,8 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
39
39
  channel,
40
40
  templateData,
41
41
  hostName,
42
+ messageDetails,
43
+ location,
42
44
  }) {
43
45
  return (
44
46
  <div
@@ -47,6 +49,8 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
47
49
  data-creatives-channel={channel}
48
50
  data-template-data={JSON.stringify(templateData)}
49
51
  data-host-name={hostName}
52
+ data-message-details={JSON.stringify(messageDetails)}
53
+ data-location={JSON.stringify(location)}
50
54
  >
51
55
  <button
52
56
  type="button"
@@ -1791,6 +1795,66 @@ describe('ChannelSelectionStep', () => {
1791
1795
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1792
1796
  });
1793
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
+
1794
1858
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1795
1859
 
1796
1860
  it('FTP channel is filtered out of the channel dropdown', async () => {
@@ -181,7 +181,7 @@ const getEmailDomainValue = (entity) => {
181
181
  return emailData.domainProperties?.id ?? emailData.domainId ?? emailData.id ?? '';
182
182
  };
183
183
 
184
- // CCS's domainGatewayMapId is the domainProperties API's outer `id`, distinct from domainProperties.id (the numeric domainId); it identifies the domain-gateway mapping for the selected domain.
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 here is the domain NAME (for display only) - buildChannelSettingFromFieldValues needs the numeric domainId/domainGatewayMapId, sourced separately below.
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
- // emailDomainId (numeric, from auto-save defaults via parseEntityForDisplay) takes precedence over emailDomain, which may be a numeric ID in SenderDetails state or a display name from parseEntityForDisplay.
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,
@@ -504,9 +504,7 @@ export const Whatsapp = (props) => {
504
504
  if (templateHeaderArray?.length !== 0) {
505
505
  let clonedVarMap = {};
506
506
  if (!isEmpty(varMap)) {
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.
507
+ // Reconcile CCS sequential varMapped indices to the UI’s `${token}_${index}` slot keys so values map to the correct segments.
510
508
  clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
511
509
  } else {
512
510
  templateHeaderArray?.forEach((headerValue, i) => {