@capillarytech/creatives-library 9.0.59-alpha.1 → 9.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.59-alpha.1",
4
+ "version": "9.0.59",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -722,9 +722,7 @@ export const sendTransactionalComm = (payload) => {
722
722
  return request(url, getAPICallObject(HTTP_METHODS.POST, payload, false, false, false, true));
723
723
  };
724
724
 
725
- // CommunicationFlow's own fetch-by-id path (see mapCommDefinitionToStepData in
726
- // CommunicationFlow/utils) — lets a consumer that only stored a commDefinitionId (no local
727
- // content copy) hand CommunicationFlow just that id on edit; it fetches metadata + content itself.
725
+ // CommunicationFlow fetch-by-id path lets consumers with only a commDefinitionId load metadata and content on edit.
728
726
  export const getCommDefinition = (commDefinitionId, include) => {
729
727
  const query = include ? `?include=${encodeURIComponent(include)}` : '';
730
728
  const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}${query}`;
@@ -79,11 +79,7 @@ const CommunicationFlow = ({
79
79
  const { formatMessage } = intl || {};
80
80
  const { messageTypeData = {}, communicationStrategyData = {}, contentTemplateData = {} } = config?.features || {};
81
81
 
82
- // A consumer other than CapNotify (which always pre-fetches and passes full initialData
83
- // itself) may hand CommunicationFlow only a commDefinitionId — no local content copy at all
84
- // — and rely on CommunicationFlow to fetch + populate its own content (see the fetch effect
85
- // below, and mapCommDefinitionToStepData). Captured once at mount: initialData/config aren't
86
- // expected to change identity across the component's lifetime for this decision.
82
+ // Other consumers may provide only commDefinitionId and rely on CommunicationFlow to fetch content; capture this decision once at mount.
87
83
  const [shouldFetchCommDefinition] = useState(
88
84
  () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
89
85
  );
@@ -93,9 +89,7 @@ const CommunicationFlow = ({
93
89
  const [stepData, setStepData] = useState(() => {
94
90
  const defaultMessageType = messageTypeData.defaultOption?.value || MESSAGE_TYPES_OPTIONS?.[1]?.value || null;
95
91
  return {
96
- // Pre-set to CCS's only supported strategy so the Channel Selection step (which only
97
- // renders once communicationStrategy is set) is already mounted — showing its own
98
- // loading state — while the fetch below is in flight, instead of staying blank/unmounted.
92
+ // Pre-set CCS's only supported strategy so Channel Selection mounts and shows its loading state while the fetch is in flight.
99
93
  messageType: initialData?.messageType || (shouldFetchCommDefinition ? 'transactional' : defaultMessageType),
100
94
  communicationStrategy: initialData?.communicationStrategy || (shouldFetchCommDefinition ? SINGLE_TEMPLATE : null),
101
95
  channel: initialData?.channel || config.channel || null,
@@ -20,12 +20,15 @@ import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
20
20
  import CapHeader from '@capillarytech/cap-ui-library/CapHeader';
21
21
  import CapSlideBox from '@capillarytech/cap-ui-library/CapSlideBox';
22
22
  import CapTooltip from '@capillarytech/cap-ui-library/CapTooltip';
23
+ import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
23
24
  import CommunicationFlow from './index';
24
25
  import { CHANNELS, DEFAULT_COMMUNICATION_STRATEGY_OPTIONS, DYNAMIC_CONTROLS_CONFIG } from './constants';
25
26
  import {
26
27
  VIBER, SMS, EMAIL, WHATSAPP, RCS, ZALO,
27
28
  } from '../CreativesContainer/constants';
28
29
  import getContentBody from './utils/getContentBody';
30
+ import { getCommDefinition, getCommDefinitionVersion } from '../../services/api';
31
+ import { fetchAndMapCommDefinition } from './utils/mapCommDefinitionToStepData';
29
32
  import messages from './messages';
30
33
  import addCreativesIllustration from '../Assets/images/addCreativesIllustration.svg';
31
34
  import './CommunicationFlow.scss';
@@ -90,6 +93,33 @@ const CommunicationFlowCard = ({
90
93
  }
91
94
  }, [initialData]);
92
95
 
96
+ // Other consumers may provide only commDefinitionId; fetch it here so the summary card shows configured content immediately instead of "Add creatives".
97
+ const [isFetchingCommDefinition, setIsFetchingCommDefinition] = useState(
98
+ () => !!config?.context?.existingCommDefinitionId && !initialData?.contentItems?.length,
99
+ );
100
+
101
+ useEffect(() => {
102
+ const existingCommDefinitionId = config?.context?.existingCommDefinitionId;
103
+ if (!existingCommDefinitionId || initialData?.contentItems?.length) return undefined;
104
+ let cancelled = false;
105
+ (async () => {
106
+ try {
107
+ const mapped = await fetchAndMapCommDefinition(existingCommDefinitionId, {
108
+ getCommDefinition,
109
+ getCommDefinitionVersion,
110
+ });
111
+ if (!cancelled && mapped) {
112
+ setSavedData((prevSavedData) => ({ ...prevSavedData, ...mapped }));
113
+ }
114
+ } catch (error) {
115
+ console.error('[CommunicationFlowCard] fetchAndMapCommDefinition error:', error);
116
+ } finally {
117
+ if (!cancelled) setIsFetchingCommDefinition(false);
118
+ }
119
+ })();
120
+ return () => { cancelled = true; };
121
+ }, []);
122
+
93
123
  const handleSave = useCallback((data) => {
94
124
  setSavedData(data);
95
125
  setShowSlideBox(false);
@@ -199,7 +229,16 @@ const CommunicationFlowCard = ({
199
229
 
200
230
  return (
201
231
  <>
202
- {!firstItem ? (
232
+ {isFetchingCommDefinition ? (
233
+ <CapCard
234
+ className="communication-flow-card-loading"
235
+ bodyStyle={{ padding: 0, height: 152 }}
236
+ >
237
+ <CapRow type="flex" justify="center" align="middle" className="loading-card-body-row">
238
+ <CapSpin spinning />
239
+ </CapRow>
240
+ </CapCard>
241
+ ) : !firstItem ? (
203
242
  <CapCard
204
243
  className="communication-flow-card-empty"
205
244
  bodyStyle={{ padding: 0, height: 152 }}
@@ -3,6 +3,12 @@ import React from 'react';
3
3
  jest.mock('../../../services/api', () => ({
4
4
  createCommDefinition: jest.fn(),
5
5
  editCommDefinition: jest.fn(),
6
+ getCommDefinition: jest.fn(),
7
+ getCommDefinitionVersion: jest.fn(),
8
+ }));
9
+
10
+ jest.mock('../utils/mapCommDefinitionToStepData', () => ({
11
+ fetchAndMapCommDefinition: jest.fn(),
6
12
  }));
7
13
 
8
14
  jest.mock('@capillarytech/cap-ui-library/CapNotification', () => ({
@@ -47,6 +53,7 @@ import { initialReducer } from '../../../initialReducer';
47
53
  import CommunicationFlow from '../CommunicationFlow';
48
54
  import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
49
55
  import { createCommDefinition, editCommDefinition } from '../../../services/api';
56
+ import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
50
57
  import { getEnabledSteps } from '../utils/getEnabledSteps';
51
58
  import {
52
59
  CHANNELS,
@@ -921,6 +928,82 @@ describe('handleSave — CCS flow', () => {
921
928
  });
922
929
  });
923
930
 
931
+ describe('CommunicationFlow — fetch-by-id (config.context.existingCommDefinitionId, no local content)', () => {
932
+ const ccsConfig = { ...baseConfig, context: { name: 'Order Placed Notification' }, features: {} };
933
+ const configWithId = {
934
+ ...ccsConfig,
935
+ context: { ...ccsConfig.context, existingCommDefinitionId: 'cd_fetch_1' },
936
+ };
937
+
938
+ beforeEach(() => {
939
+ fetchAndMapCommDefinition.mockReset();
940
+ });
941
+
942
+ afterEach(() => {
943
+ jest.clearAllMocks();
944
+ });
945
+
946
+ it('fetches and merges the mapped data into stepData when only a commDefinitionId is supplied', async () => {
947
+ fetchAndMapCommDefinition.mockResolvedValue({
948
+ channel: 'SMS',
949
+ contentItems: [{ contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } }],
950
+ ccsCommDefinition: { id: 'cd_fetch_1', referenceId: 'ref_1', version: 1, status: 'DRAFT' },
951
+ });
952
+ const onChange = jest.fn();
953
+ renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
954
+
955
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledWith(
956
+ 'cd_fetch_1',
957
+ expect.objectContaining({
958
+ getCommDefinition: expect.any(Function),
959
+ getCommDefinitionVersion: expect.any(Function),
960
+ }),
961
+ ));
962
+
963
+ await waitFor(() => {
964
+ const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
965
+ expect(payload.contentItems).toEqual([
966
+ { contentId: 'cd_fetch_1', channel: 'SMS', templateData: { messageBody: 'Hi' } },
967
+ ]);
968
+ });
969
+ });
970
+
971
+ it('does not crash and leaves stepData unchanged when the fetch resolves with nothing mappable', async () => {
972
+ fetchAndMapCommDefinition.mockResolvedValue(null);
973
+ const onChange = jest.fn();
974
+ renderWithFlow({ features: {}, config: configWithId, initialData: null, onChange });
975
+
976
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
977
+ const payload = onChange.mock.calls[onChange.mock.calls.length - 1][0];
978
+ expect(payload.contentItems).toEqual([]);
979
+ });
980
+
981
+ it('does not crash when the fetch rejects', async () => {
982
+ fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
983
+ renderWithFlow({ features: {}, config: configWithId, initialData: null });
984
+
985
+ await waitFor(() => expect(fetchAndMapCommDefinition).toHaveBeenCalledTimes(1));
986
+ });
987
+
988
+ it('does not fetch when initialData already has contentItems (e.g. CapNotify, which always pre-fetches itself)', async () => {
989
+ renderWithFlow({
990
+ features: {},
991
+ config: configWithId,
992
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
993
+ });
994
+
995
+ await new Promise((resolve) => setTimeout(resolve, 0));
996
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
997
+ });
998
+
999
+ it('does not fetch when config.context.existingCommDefinitionId is absent', async () => {
1000
+ renderWithFlow({ features: {}, config: ccsConfig, initialData: null });
1001
+
1002
+ await new Promise((resolve) => setTimeout(resolve, 0));
1003
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
1004
+ });
1005
+ });
1006
+
924
1007
  describe('renderSteps — null returns and edge cases', () => {
925
1008
  it('returns null for CHANNEL_SELECTION when communicationStrategy is null', () => {
926
1009
  renderWithFlow({
@@ -38,14 +38,25 @@ jest.mock('@capillarytech/cap-ui-library/CapIcon', () => function MockCapIcon({
38
38
 
39
39
  jest.mock('../utils/getContentBody', () => jest.fn());
40
40
 
41
- import { render, screen, fireEvent } from '@testing-library/react';
41
+ jest.mock('../../../services/api', () => ({
42
+ getCommDefinition: jest.fn(),
43
+ getCommDefinitionVersion: jest.fn(),
44
+ }));
45
+
46
+ jest.mock('../utils/mapCommDefinitionToStepData', () => ({
47
+ fetchAndMapCommDefinition: jest.fn(),
48
+ }));
49
+
50
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
42
51
  import '@testing-library/jest-dom';
43
52
  import { IntlProvider } from 'react-intl';
44
53
  import CommunicationFlowCard from '../CommunicationFlowCard';
45
54
  import getContentBody from '../utils/getContentBody';
55
+ import { fetchAndMapCommDefinition } from '../utils/mapCommDefinitionToStepData';
46
56
 
47
57
  beforeEach(() => {
48
58
  getContentBody.mockReturnValue(<span data-testid="content-body">Content</span>);
59
+ fetchAndMapCommDefinition.mockReset();
49
60
  });
50
61
 
51
62
  const baseConfig = {
@@ -711,3 +722,52 @@ describe('CommunicationFlowCard', () => {
711
722
  });
712
723
  });
713
724
  });
725
+
726
+ describe('CommunicationFlowCard — fetch-by-id (config.context.existingCommDefinitionId)', () => {
727
+ const configWithId = {
728
+ ...baseConfig,
729
+ context: { existingCommDefinitionId: 'cd_1' },
730
+ };
731
+
732
+ it('shows a loading state (not the empty "Add creatives" state) while fetching', () => {
733
+ fetchAndMapCommDefinition.mockReturnValue(new Promise(() => {})); // never resolves
734
+ renderCard({ config: configWithId, initialData: null });
735
+ expect(screen.queryByText('Add creatives')).not.toBeInTheDocument();
736
+ expect(screen.queryByText('Message:')).not.toBeInTheDocument();
737
+ });
738
+
739
+ it('shows the configured summary card once the fetch resolves, without opening the slidebox', async () => {
740
+ fetchAndMapCommDefinition.mockResolvedValue(makeSavedData());
741
+ renderCard({ config: configWithId, initialData: null });
742
+
743
+ await waitFor(() => expect(screen.getByText('Message:')).toBeInTheDocument());
744
+ expect(screen.queryByText('Add creatives')).not.toBeInTheDocument();
745
+ expect(screen.queryByTestId('slide-box')).not.toBeInTheDocument();
746
+ });
747
+
748
+ it('falls back to the empty "Add creatives" state when the fetch resolves with nothing mappable', async () => {
749
+ fetchAndMapCommDefinition.mockResolvedValue(null);
750
+ renderCard({ config: configWithId, initialData: null });
751
+
752
+ await waitFor(() => expect(screen.getByText('Add creatives')).toBeInTheDocument());
753
+ });
754
+
755
+ it('falls back to the empty "Add creatives" state when the fetch rejects', async () => {
756
+ fetchAndMapCommDefinition.mockRejectedValue(new Error('network error'));
757
+ renderCard({ config: configWithId, initialData: null });
758
+
759
+ await waitFor(() => expect(screen.getByText('Add creatives')).toBeInTheDocument());
760
+ });
761
+
762
+ it('does not fetch when initialData already has content (e.g. CapNotify, which always pre-fetches itself)', () => {
763
+ renderCard({ config: configWithId, initialData: makeSavedData() });
764
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
765
+ expect(screen.getByText('Message:')).toBeInTheDocument();
766
+ });
767
+
768
+ it('does not fetch when config.context.existingCommDefinitionId is absent', () => {
769
+ renderCard({ config: baseConfig, initialData: null });
770
+ expect(fetchAndMapCommDefinition).not.toHaveBeenCalled();
771
+ expect(screen.getByText('Add creatives')).toBeInTheDocument();
772
+ });
773
+ });
@@ -55,11 +55,7 @@ const ChannelSelectionStep = ({
55
55
  intl,
56
56
  capData, // From Redux - contains user/org info needed by CouponsWrapper
57
57
  config,
58
- // True while CommunicationFlow is fetching an existing CommDefinition's content by id (see
59
- // config.context.existingCommDefinitionId in CommunicationFlow.js) — shown in place of the
60
- // empty "Add creative" state so a consumer that only stored a commDefinitionId doesn't flash
61
- // an empty content step before the fetched content arrives.
62
- isContentLoading = false,
58
+ isContentLoading = false, // True while fetching existing CommDefinition content by id.
63
59
  }) => {
64
60
  const contentItems = value?.contentItems || [];
65
61
  const [showCreativesContainer, setShowCreativesContainer] = useState(false);
@@ -138,6 +138,34 @@ describe('ChannelSelectionStep', () => {
138
138
  expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
139
139
  });
140
140
 
141
+ it('shows a loading spinner instead of the "Add creative" empty state while isContentLoading is true', () => {
142
+ renderStep(
143
+ <ChannelSelectionStep
144
+ value={{ contentItems: [] }}
145
+ onChange={jest.fn()}
146
+ channels={CHANNELS}
147
+ isContentLoading
148
+ />,
149
+ );
150
+
151
+ expect(screen.queryByRole('button', { name: /add creative/i })).not.toBeInTheDocument();
152
+ expect(document.querySelector('.content-loading')).toBeInTheDocument();
153
+ });
154
+
155
+ it('shows the empty "Add creative" state (not the loading spinner) once isContentLoading is false, even with no content', () => {
156
+ renderStep(
157
+ <ChannelSelectionStep
158
+ value={{ contentItems: [] }}
159
+ onChange={jest.fn()}
160
+ channels={CHANNELS}
161
+ isContentLoading={false}
162
+ />,
163
+ );
164
+
165
+ expect(screen.getByRole('button', { name: /add creative/i })).toBeInTheDocument();
166
+ expect(document.querySelector('.content-loading')).not.toBeInTheDocument();
167
+ });
168
+
141
169
  it('shows validation error when error prop is set', () => {
142
170
  renderStep(
143
171
  <ChannelSelectionStep
@@ -115,6 +115,35 @@ describe('mapCommDefinitionToStepData', () => {
115
115
  });
116
116
  });
117
117
 
118
+ it('maps a WEBPUSH CommDefinition using the dedicated reverse transform (nested messageContent.content shape)', () => {
119
+ const commDefinition = {
120
+ id: 'cd_webpush_1',
121
+ singleChannelStrategy: {
122
+ variant: {
123
+ channel: 'WEBPUSH',
124
+ webPushMessageContent: {
125
+ messageSubject: 'New offer',
126
+ accountId: 'acc_1',
127
+ content: { title: 'Title', body: 'Body text' },
128
+ },
129
+ },
130
+ },
131
+ };
132
+
133
+ const result = mapCommDefinitionToStepData(commDefinition);
134
+
135
+ expect(result.contentItems[0].templateData).toEqual({
136
+ messageContent: {
137
+ content: {
138
+ messageSubject: 'New offer',
139
+ accountId: 'acc_1',
140
+ content: { title: 'Title', body: 'Body text' },
141
+ },
142
+ },
143
+ channel: 'WEBPUSH',
144
+ });
145
+ });
146
+
118
147
  it('reads the variant from commDefinition.version when top-level singleChannelStrategy is absent', () => {
119
148
  const commDefinition = {
120
149
  id: 'cd_4',
@@ -22,9 +22,7 @@ const CCS_TO_UI_CHANNEL_MAP = Object.entries(CCS_CHANNEL_NAME_MAP).reduce(
22
22
  );
23
23
  const reverseUiChannel = (ccsChannel) => CCS_TO_UI_CHANNEL_MAP[ccsChannel] || ccsChannel;
24
24
 
25
- // Inverse of CCS_CONTENT_TRANSFORMS (./constants.js) only channels with a dedicated forward
26
- // transform there need one here; every other channel's CCS content already matches the legacy
27
- // editor's templateData shape and is passed through unchanged (see the default branch below).
25
+ // Inverse of CCS_CONTENT_TRANSFORMS — transforms CCS content back to the legacy editor's templateData shape.
28
26
  const CCS_CONTENT_REVERSE_TRANSFORMS = {
29
27
  EMAIL: (payload = {}) => ({
30
28
  emailSubject: payload.messageSubject,
@@ -50,8 +48,7 @@ const getCommDefinitionVariant = (commDefinition = {}) => (
50
48
  || null
51
49
  );
52
50
 
53
- // Counts SUBMITTED audit entries to derive the latest version number when the CommDefinition
54
- // itself doesn't carry an explicit current version (mirrors cap-campaigns-v2's own helper).
51
+ // Counts SUBMITTED audit entries to derive the latest version when CommDefinition has no explicit current version.
55
52
  export const getLatestVersionFromAuditInfos = (commDefinition = {}) => {
56
53
  const submittedCount = (commDefinition.auditInfos || []).filter(
57
54
  (entry) => entry?.action === 'SUBMITTED',
@@ -60,8 +57,7 @@ export const getLatestVersionFromAuditInfos = (commDefinition = {}) => {
60
57
  return submittedCount - 1;
61
58
  };
62
59
 
63
- // Reverse of CommunicationFlow's own additionalSettings mapping (see CommunicationFlow.js's
64
- // handleSave) — used to pre-populate the Advanced Controls step when editing existing content.
60
+ // Reverse-maps CommunicationFlow's additionalSettings to pre-populate Advanced Controls when editing.
65
61
  const mapAdditionalSettingsToDynamicControls = (additionalSettings = {}) => ({
66
62
  useTinyUrl: additionalSettings.useTinyUrl ?? false,
67
63
  sendToControlCustomers: additionalSettings.encryptUrl ?? false,
@@ -86,8 +82,7 @@ export const mapCommDefinitionToStepData = (commDefinition = {}) => {
86
82
 
87
83
  const uiChannel = reverseUiChannel(ccsChannel);
88
84
  const reverseTransform = CCS_CONTENT_REVERSE_TRANSFORMS[ccsChannel];
89
- // Spread channel after content: CCS content may carry its own raw `channel` ("PUSH"), which
90
- // would otherwise overwrite `uiChannel` ("MOBILEPUSH") and break template rendering.
85
+ // Spread channel after content so CCS's raw `channel` ("PUSH") cannot overwrite `uiChannel` ("MOBILEPUSH").
91
86
  const templateData = reverseTransform
92
87
  ? { ...reverseTransform(rawContent), channel: uiChannel }
93
88
  : { ...rawContent, channel: uiChannel };
@@ -98,14 +93,12 @@ export const mapCommDefinitionToStepData = (commDefinition = {}) => {
98
93
  const version = commDefinition.version || {};
99
94
 
100
95
  return {
101
- // CCS create/edit only supports the SINGLE strategy today (see CommunicationFlow.js's
102
- // handleSave, which skips CHANNEL_PRIORITY/AB_TEST) — a fetched CommDefinition is always one.
96
+ // CCS create/edit currently supports only SINGLE strategy; fetched CommDefinitions are always single-strategy.
103
97
  messageType: 'transactional',
104
98
  communicationStrategy: 'SINGLE_TEMPLATE',
105
99
  channel: uiChannel,
106
100
  channels: [],
107
- // contentId must be truthy ChannelSelectionStep matches contentItems by it to resolve
108
- // editingContentId; a falsy id collapses the reopen back to an empty template picker.
101
+ // contentId must be truthy so ChannelSelectionStep can resolve editingContentId instead of reopening the empty template picker.
109
102
  contentItems: [
110
103
  { contentId: commDefinition.id, channel: uiChannel, templateData },
111
104
  ],