@capillarytech/creatives-library 8.0.353-alpha.2 → 8.0.353-alpha.5

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.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CommonTestAndPreview/UnifiedPreview/PreviewHeader.js +17 -0
  3. package/v2Components/CommonTestAndPreview/UnifiedPreview/WebPushPreviewContent.js +169 -0
  4. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +70 -0
  5. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +44 -5
  6. package/v2Components/CommonTestAndPreview/constants.js +2 -0
  7. package/v2Components/CommonTestAndPreview/index.js +51 -2
  8. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/PreviewHeader.test.js +159 -0
  9. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/WebPushPreviewContent.test.js +522 -0
  10. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +255 -0
  11. package/v2Components/CommonTestAndPreview/tests/constants.test.js +2 -1
  12. package/v2Components/CommonTestAndPreview/tests/index.test.js +194 -0
  13. package/v2Components/FormBuilder/index.js +10 -48
  14. package/v2Components/TestAndPreviewSlidebox/index.js +2 -2
  15. package/v2Containers/App/constants.js +3 -0
  16. package/v2Containers/App/tests/constants.test.js +61 -0
  17. package/v2Containers/CreativesContainer/index.js +25 -61
  18. package/v2Containers/Email/index.js +2 -33
  19. package/v2Containers/Templates/index.js +68 -2
  20. package/v2Containers/Templates/tests/webpush.test.js +375 -0
  21. package/v2Containers/WebPush/Create/index.js +91 -8
  22. package/v2Containers/WebPush/Create/index.scss +7 -0
  23. package/v2Containers/WebPush/Create/tests/getTemplateContent.test.js +338 -0
  24. package/v2Containers/WebPush/Create/tests/testAndPreviewIntegration.test.js +325 -0
@@ -1,43 +1,4 @@
1
1
  import React from 'react';
2
-
3
- // Isolated input for the email template name field.
4
- // Manages its own value in local state so keystrokes only re-render this
5
- // small component, not the entire CreativesContainer → Email → FormBuilder tree.
6
- class TemplateNameInputField extends React.Component {
7
- constructor(props) {
8
- super(props);
9
- this.state = { localValue: props.initialValue || '' };
10
- }
11
-
12
- componentDidUpdate(prevProps) {
13
- // Sync from props only when the external value changed AND the user hasn't
14
- // diverged from the previous prop value. This handles async data-load in edit
15
- // mode without overwriting what the user is actively typing.
16
- if (
17
- prevProps.initialValue !== this.props.initialValue &&
18
- this.state.localValue === (prevProps.initialValue || '')
19
- ) {
20
- this.setState({ localValue: this.props.initialValue || '' });
21
- }
22
- }
23
-
24
- handleChange = (ev) => {
25
- const { value } = ev.currentTarget;
26
- this.setState({ localValue: value });
27
- this.props.onChange(value);
28
- };
29
-
30
- render() {
31
- const { onChange: _onChange, initialValue: _initialValue, ...rest } = this.props;
32
- return (
33
- <CapInput
34
- {...rest}
35
- value={this.state.localValue}
36
- onChange={this.handleChange}
37
- />
38
- );
39
- }
40
- }
41
2
  import PropTypes from 'prop-types';
42
3
  import {
43
4
  CAP_SPACE_16, CAP_SPACE_32, CAP_SPACE_56, CAP_SPACE_64,
@@ -230,10 +191,7 @@ export class Creatives extends React.Component {
230
191
  // Performance optimized template name update
231
192
  performTemplateNameUpdate = (value, formData, onFormDataChange) => {
232
193
  const isEmptyTemplateName = !value.trim();
233
- // _highFreqField signals Email's onFormDataChange that only a high-frequency
234
- // standalone field changed, enabling the fast-path cache in getFormDataForBuilder
235
- // and skipping the expensive FormBuilder re-render + validateForm cascade.
236
- const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true, _highFreqField: 'template-name' };
194
+ const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true };
237
195
 
238
196
  this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
239
197
  onFormDataChange(newFormData);
@@ -1795,24 +1753,30 @@ export class Creatives extends React.Component {
1795
1753
  } />
1796
1754
  )
1797
1755
 
1798
- templateNameComponentInput = ({ formData, onFormDataChange, name }) => (
1799
- <TemplateNameInputField
1800
- initialValue={name}
1801
- suffix={<span />}
1802
- onBlur={() => {
1803
- this.setState({ isEditName: false }, () => {
1804
- this.showTemplateName({ formData, onFormDataChange });
1805
- });
1806
- }}
1807
- onChange={(value) => {
1808
- const isEmptyTemplateName = !value.trim();
1809
- if (this.state.isTemplateNameEmpty !== isEmptyTemplateName) {
1810
- this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
1811
- }
1812
- this.debouncedTemplateNameUpdate(value, formData, onFormDataChange);
1813
- }}
1814
- />
1815
- )
1756
+ templateNameComponentInput = ({ formData, onFormDataChange, name }) => {
1757
+ // Use local state for immediate UI feedback, fallback to prop value
1758
+ const displayValue = this.state.localTemplateName !== '' ? this.state.localTemplateName : name;
1759
+
1760
+ return (
1761
+ <CapInput
1762
+ value={displayValue}
1763
+ suffix={<span />}
1764
+ onBlur={() => {
1765
+ this.setState({
1766
+ isEditName: false,
1767
+ localTemplateName: '', // Clear local state on blur
1768
+ }, () => {
1769
+ this.showTemplateName({ formData, onFormDataChange });
1770
+ });
1771
+ }}
1772
+ onChange={(ev) => {
1773
+ const { value } = ev.currentTarget;
1774
+ // Use optimized update for better performance
1775
+ this.updateTemplateNameImmediately(value, formData, onFormDataChange);
1776
+ }}
1777
+ />
1778
+ );
1779
+ }
1816
1780
 
1817
1781
  showTemplateName = ({ formData, onFormDataChange }) => { //gets called from email/index after template data is fetched
1818
1782
  const {
@@ -795,16 +795,9 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
795
795
  delete window?.[CREATIVES_S3_ASSET_FILESIZES];
796
796
  }
797
797
 
798
- // performFormDataUpdate in FormBuilder passes `val` as the 4th arg to props.onChange.
799
- // CreativesContainer.performTemplateNameUpdate passes _highFreqField on the formData object.
800
- // Both paths set _highFreqUpdate so getFormDataForBuilder can use the fast-path cache.
801
- onFormDataChange = (updatedFormData, tabCount, currentTab, val) => {
798
+ onFormDataChange = (updatedFormData, tabCount, currentTab) => {
802
799
  // this.transformFormData(formData);
803
800
  const formData = {...updatedFormData};
804
- // Consume and clean up the CC-path signal before storing in state
805
- const highFreqField = (val && val.id) || updatedFormData._highFreqField;
806
- delete formData._highFreqField;
807
-
808
801
  const {defaultData = {}, isFullMode, showTemplateName} = this.props;
809
802
  const templateName = formData['template-name'];
810
803
  const defaultTemplateName = _.get(defaultData, 'template-name', "");
@@ -816,9 +809,6 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
816
809
  formData['template-name'] = templateName;
817
810
  }
818
811
 
819
- // Must be set before setState so getFormDataForBuilder reads it during the triggered re-render.
820
- const HIGH_FREQ_FIELDS = ['template-name', 'template-subject'];
821
- this._highFreqUpdate = !!(highFreqField && HIGH_FREQ_FIELDS.includes(highFreqField));
822
812
 
823
813
  this.setState({formData, tabCount, isSchemaChanged: false}, () => {
824
814
  if (this.props.isFullMode && showTemplateName) {
@@ -831,27 +821,6 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
831
821
  //this.resetCkEditorInstance(currentTab, formData);
832
822
  }
833
823
 
834
- // Returns a formData object safe to pass to FormBuilder.
835
- // For high-frequency field updates (template-name / template-subject) patches only
836
- // those fields into the existing cache, avoiding an expensive _.cloneDeep of the
837
- // entire email formData (HTML content, tabs, language variants) on every keystroke.
838
- // All other operations (tab changes, language add/delete, etc.) still get a full clone.
839
- getFormDataForBuilder = () => {
840
- const formData = this.state.formData;
841
- if (this._highFreqUpdate && this._formDataBuilderCache) {
842
- this._formDataBuilderCache = {
843
- ...this._formDataBuilderCache,
844
- 'template-name': formData['template-name'],
845
- 'template-subject': formData['template-subject'],
846
- 'isTemplateNameEdited': formData['isTemplateNameEdited'],
847
- };
848
- } else {
849
- this._formDataBuilderCache = _.cloneDeep(formData);
850
- }
851
- this._highFreqUpdate = false;
852
- return this._formDataBuilderCache;
853
- }
854
-
855
824
  onChange = (evt) => {
856
825
  const {isFullMode, showTemplateName} = this.props;
857
826
  const formData = _.cloneDeep(this.state.formData);
@@ -3160,7 +3129,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
3160
3129
  onChange={this.onFormDataChange}
3161
3130
  currentTab={this.state.currentTab}
3162
3131
  parent={this}
3163
- formData={this.getFormDataForBuilder()}
3132
+ formData={_.cloneDeep(this.state.formData)}
3164
3133
  location={this.props.location}
3165
3134
  tabKey={this.state.tabKey}
3166
3135
  tags={tags}
@@ -104,6 +104,9 @@ import {
104
104
  VIBER as VIBER_CHANNEL,
105
105
  FACEBOOK as FACEBOOK_CHANNEL,
106
106
  CREATE,
107
+ EXTERNAL_URL,
108
+ URL,
109
+ SITE_URL,
107
110
  } from '../App/constants';
108
111
  import {MAX_WHATSAPP_TEMPLATES, WARNING_WHATSAPP_TEMPLATES , ACCOUNT_MAPPING_ON_CHANNEL, noFilteredWhatsappZaloTemplatesTitle, noFilteredWhatsappZaloTemplatesDesc, noApprovedWhatsappZaloTemplatesTitle, noApprovedWhatsappTemplatesDesc, zaloDescIllustration, noApprovedRcsTemplatesTitle, noApprovedRcsTemplatesDesc, ARCHIVE_STATUS_ACTIVE, ARCHIVE_STATUS_ARCHIVED, ARCHIVE_REFRESH_TYPE_ARCHIVE, ARCHIVE_REFRESH_TYPE_UNARCHIVE} from './constants';
109
112
  import { COPY_OF, EMBEDDED } from '../../constants/unified';
@@ -1399,6 +1402,69 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1399
1402
  };
1400
1403
  }
1401
1404
 
1405
+ case WEBPUSH: {
1406
+ // WebPush content is stored in creatives format (brandIcon, onClickAction, ctas)
1407
+ // Must be transformed to campaign/test-message format matching getTemplateContent() in WebPush/Create/index.js
1408
+ const webpushContent = get(baseContent, 'content.webpush', {});
1409
+ const title = webpushContent?.title || '';
1410
+ const message = webpushContent?.message || '';
1411
+ const accountId = get(template, 'definition.accountId', null);
1412
+ const templateName = template?.name || '';
1413
+
1414
+ // iconImageUrl stored as brandIcon in creatives format
1415
+ const iconImageUrl = webpushContent?.brandIcon || webpushContent?.iconImageUrl || undefined;
1416
+
1417
+ // cta stored as onClickAction in creatives format (type: URL|SITE_URL, url)
1418
+ // or already as cta in campaign format (type: EXTERNAL_URL|SITE_URL, actionLink)
1419
+ let cta = null;
1420
+ const onClickAction = webpushContent?.onClickAction;
1421
+ const existingCta = webpushContent?.cta;
1422
+ if (onClickAction) {
1423
+ const ctaType = onClickAction.type === URL ? EXTERNAL_URL : (onClickAction.type || SITE_URL);
1424
+ cta = { type: ctaType, actionLink: onClickAction.url || '' };
1425
+ } else if (existingCta) {
1426
+ cta = { type: existingCta.type || EXTERNAL_URL, actionLink: existingCta.actionLink || '' };
1427
+ }
1428
+
1429
+ // expandableDetails: image → media[], ctas[] → mapped ctas
1430
+ const image = webpushContent?.image;
1431
+ const rawCtas = webpushContent?.ctas;
1432
+ const existingExpandable = webpushContent?.expandableDetails;
1433
+ let expandableDetails = null;
1434
+ const hasImage = !!image;
1435
+ const hasCtas = Array.isArray(rawCtas) && rawCtas.length > 0;
1436
+ if (hasImage || hasCtas) {
1437
+ expandableDetails = {
1438
+ media: hasImage ? [{ url: image, type: IMAGE }] : [],
1439
+ ctas: hasCtas ? rawCtas.map((ctaItem) => ({
1440
+ type: ctaItem?.type === URL ? EXTERNAL_URL : (ctaItem?.type || EXTERNAL_URL),
1441
+ action: ctaItem?.action || '',
1442
+ title: ctaItem?.actionText || ctaItem?.title || '',
1443
+ actionLink: ctaItem?.actionLink || '',
1444
+ })) : [],
1445
+ };
1446
+ } else if (existingExpandable) {
1447
+ expandableDetails = {
1448
+ media: existingExpandable?.media || [],
1449
+ ctas: existingExpandable?.ctas || [],
1450
+ };
1451
+ }
1452
+
1453
+ return {
1454
+ channel: WEBPUSH,
1455
+ accountId,
1456
+ content: {
1457
+ title,
1458
+ message,
1459
+ ...(iconImageUrl ? { iconImageUrl } : {}),
1460
+ ...(cta ? { cta } : {}),
1461
+ ...(expandableDetails ? { expandableDetails } : {}),
1462
+ },
1463
+ messageSubject: templateName || title,
1464
+ offers: [],
1465
+ };
1466
+ }
1467
+
1402
1468
  default:
1403
1469
  console.warn(`Unsupported channel for content extraction: ${channelUpper}`);
1404
1470
  return null;
@@ -1411,7 +1477,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1411
1477
  * @returns {Boolean} - True if channel supports Test and Preview
1412
1478
  */
1413
1479
  isTestAndPreviewSupported = () => {
1414
- const supportedChannels = [EMAIL, SMS, INAPP, MOBILE_PUSH, VIBER, ZALO];
1480
+ const supportedChannels = [EMAIL, SMS, INAPP, MOBILE_PUSH, VIBER, ZALO, WEBPUSH];
1415
1481
  return supportedChannels.includes(this.state.channel.toUpperCase());
1416
1482
  }
1417
1483
 
@@ -1971,7 +2037,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1971
2037
  // Show preview icon only for channels that don't support Test and Preview
1972
2038
  (() => {
1973
2039
  // Channels that have Test and Preview integrated
1974
- const testAndPreviewChannels = [EMAIL, SMS, WHATSAPP, RCS, INAPP, MOBILE_PUSH, VIBER, ZALO];
2040
+ const testAndPreviewChannels = [EMAIL, SMS, WHATSAPP, RCS, INAPP, MOBILE_PUSH, VIBER, ZALO, WEBPUSH];
1975
2041
  const isTestAndPreviewSupported = testAndPreviewChannels.includes(currentChannel.toUpperCase());
1976
2042
 
1977
2043
  // Don't show preview icon if channel supports Test and Preview
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Tests for Templates container - WEBPUSH channel additions
3
+ *
4
+ * Covers:
5
+ * - extractTemplateContentForPreview for WEBPUSH channel
6
+ * - isTestAndPreviewSupported with WEBPUSH channel
7
+ */
8
+
9
+ import React from 'react';
10
+ import { shallowWithIntl } from '../../../helpers/intl-enzym-test-helpers';
11
+ import { Templates } from '../index';
12
+
13
+ jest.mock('../../CreativesContainer', () => ({
14
+ __esModule: true,
15
+ default: (props) => (
16
+ <div className="creatives-container-mock" {...props}>
17
+ CreativesContainer
18
+ </div>
19
+ ),
20
+ }));
21
+
22
+ // Helper: build a valid template for WEBPUSH
23
+ // extractTemplateContentForPreview reads:
24
+ // - template.versions.base.content.webpush → webpushContent
25
+ // - template.definition.accountId → accountId
26
+ // - template.name → templateName
27
+ const makeTemplate = (webpush = {}, extra = {}) => ({
28
+ name: 'My Template',
29
+ definition: { accountId: 'acc-123' },
30
+ versions: {
31
+ base: {
32
+ content: { webpush },
33
+ },
34
+ },
35
+ ...extra,
36
+ });
37
+
38
+ describe('Templates - WEBPUSH channel', () => {
39
+ const mockActions = {
40
+ getWeCrmAccounts: jest.fn(),
41
+ setChannelAccount: jest.fn(),
42
+ getAllTemplates: jest.fn(),
43
+ getUserList: jest.fn(),
44
+ getSenderDetails: jest.fn(),
45
+ resetTemplate: jest.fn(),
46
+ setArchivedMode: jest.fn(),
47
+ clearTemplateSelection: jest.fn(),
48
+ toggleTemplateSelection: jest.fn(),
49
+ };
50
+
51
+ const baseProps = {
52
+ route: { name: 'webpush' },
53
+ Templates: { templates: [] },
54
+ actions: mockActions,
55
+ location: { pathname: '/webpush', query: {}, search: '' },
56
+ EmailCreate: { duplicateTemplateInProgress: false },
57
+ isFullMode: false,
58
+ intl: { formatMessage: jest.fn((msg) => msg.defaultMessage || '') },
59
+ };
60
+
61
+ beforeEach(() => {
62
+ jest.clearAllMocks();
63
+ });
64
+
65
+ const renderComponent = (channel = 'webpush') => {
66
+ const props = { ...baseProps, route: { name: channel } };
67
+ return shallowWithIntl(<Templates {...props} />);
68
+ };
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+ describe('isTestAndPreviewSupported', () => {
72
+ it('should return true for WEBPUSH channel', () => {
73
+ const component = renderComponent('webpush');
74
+ component.setState({ channel: 'webpush' });
75
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
76
+ });
77
+
78
+ it('should return true for WEBPUSH in uppercase state', () => {
79
+ const component = renderComponent('webpush');
80
+ component.setState({ channel: 'WEBPUSH' });
81
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
82
+ });
83
+
84
+ it('should return true for EMAIL channel', () => {
85
+ const component = renderComponent('webpush');
86
+ component.setState({ channel: 'email' });
87
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
88
+ });
89
+
90
+ it('should return true for SMS channel', () => {
91
+ const component = renderComponent('webpush');
92
+ component.setState({ channel: 'sms' });
93
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
94
+ });
95
+
96
+ it('should return true for INAPP channel', () => {
97
+ const component = renderComponent('webpush');
98
+ component.setState({ channel: 'inapp' });
99
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
100
+ });
101
+
102
+ it('should return true for VIBER channel', () => {
103
+ const component = renderComponent('webpush');
104
+ component.setState({ channel: 'viber' });
105
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
106
+ });
107
+
108
+ it('should return true for ZALO channel', () => {
109
+ const component = renderComponent('webpush');
110
+ component.setState({ channel: 'zalo' });
111
+ expect(component.instance().isTestAndPreviewSupported()).toBe(true);
112
+ });
113
+
114
+ it('should return false for WHATSAPP channel', () => {
115
+ const component = renderComponent('webpush');
116
+ component.setState({ channel: 'whatsapp' });
117
+ expect(component.instance().isTestAndPreviewSupported()).toBe(false);
118
+ });
119
+
120
+ it('should return false for RCS channel', () => {
121
+ const component = renderComponent('webpush');
122
+ component.setState({ channel: 'rcs' });
123
+ expect(component.instance().isTestAndPreviewSupported()).toBe(false);
124
+ });
125
+ });
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────
128
+ describe('extractTemplateContentForPreview - WEBPUSH channel', () => {
129
+ it('should return null when template has no versions.base', () => {
130
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
131
+ const component = renderComponent('webpush');
132
+ const result = component.instance().extractTemplateContentForPreview(
133
+ { name: 'T', definition: {} },
134
+ 'WEBPUSH'
135
+ );
136
+ expect(result).toBeNull();
137
+ consoleSpy.mockRestore();
138
+ });
139
+
140
+ it('should extract title and message from versions.base.content.webpush', () => {
141
+ const component = renderComponent('webpush');
142
+ const template = makeTemplate({ title: 'Push Title', message: 'Push body' });
143
+
144
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
145
+ expect(result).toBeDefined();
146
+ expect(result.channel).toBe('WEBPUSH');
147
+ expect(result.accountId).toBe('acc-123');
148
+ expect(result.content.title).toBe('Push Title');
149
+ expect(result.content.message).toBe('Push body');
150
+ expect(result.messageSubject).toBe('My Template');
151
+ expect(result.offers).toEqual([]);
152
+ });
153
+
154
+ it('should fall back to title for messageSubject when template name is empty', () => {
155
+ const component = renderComponent('webpush');
156
+ const template = makeTemplate({ title: 'Title Only', message: 'M' }, { name: '' });
157
+
158
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
159
+ expect(result.messageSubject).toBe('Title Only');
160
+ });
161
+
162
+ it('should extract brandIcon as iconImageUrl', () => {
163
+ const component = renderComponent('webpush');
164
+ const template = makeTemplate({
165
+ title: 'T',
166
+ message: 'M',
167
+ brandIcon: 'https://example.com/brand.png',
168
+ });
169
+
170
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
171
+ expect(result.content.iconImageUrl).toBe('https://example.com/brand.png');
172
+ });
173
+
174
+ it('should use iconImageUrl field when brandIcon is absent', () => {
175
+ const component = renderComponent('webpush');
176
+ const template = makeTemplate({
177
+ title: 'T',
178
+ message: 'M',
179
+ iconImageUrl: 'https://example.com/icon.png',
180
+ });
181
+
182
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
183
+ expect(result.content.iconImageUrl).toBe('https://example.com/icon.png');
184
+ });
185
+
186
+ it('should NOT include iconImageUrl when neither brandIcon nor iconImageUrl present', () => {
187
+ const component = renderComponent('webpush');
188
+ const template = makeTemplate({ title: 'T', message: 'M' });
189
+
190
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
191
+ expect(result.content.iconImageUrl).toBeUndefined();
192
+ });
193
+
194
+ it('should convert onClickAction type URL to EXTERNAL_URL cta', () => {
195
+ const component = renderComponent('webpush');
196
+ const template = makeTemplate({
197
+ title: 'T',
198
+ message: 'M',
199
+ onClickAction: { type: 'URL', url: 'https://example.com' },
200
+ });
201
+
202
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
203
+ expect(result.content.cta).toEqual({ type: 'EXTERNAL_URL', actionLink: 'https://example.com' });
204
+ });
205
+
206
+ it('should preserve onClickAction type SITE_URL as-is', () => {
207
+ const component = renderComponent('webpush');
208
+ const template = makeTemplate({
209
+ title: 'T',
210
+ message: 'M',
211
+ onClickAction: { type: 'SITE_URL', url: 'https://site.com' },
212
+ });
213
+
214
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
215
+ expect(result.content.cta).toEqual({ type: 'SITE_URL', actionLink: 'https://site.com' });
216
+ });
217
+
218
+ it('should use existingCta when onClickAction is absent', () => {
219
+ const component = renderComponent('webpush');
220
+ const template = makeTemplate({
221
+ title: 'T',
222
+ message: 'M',
223
+ cta: { type: 'EXTERNAL_URL', actionLink: 'https://existing.com' },
224
+ });
225
+
226
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
227
+ expect(result.content.cta).toEqual({ type: 'EXTERNAL_URL', actionLink: 'https://existing.com' });
228
+ });
229
+
230
+ it('should default existingCta type to EXTERNAL_URL when type is missing', () => {
231
+ const component = renderComponent('webpush');
232
+ const template = makeTemplate({
233
+ title: 'T',
234
+ message: 'M',
235
+ cta: { actionLink: 'https://example.com' },
236
+ });
237
+
238
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
239
+ expect(result.content.cta.type).toBe('EXTERNAL_URL');
240
+ });
241
+
242
+ it('should NOT include cta when neither onClickAction nor cta present', () => {
243
+ const component = renderComponent('webpush');
244
+ const template = makeTemplate({ title: 'T', message: 'M' });
245
+
246
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
247
+ expect(result.content.cta).toBeUndefined();
248
+ });
249
+
250
+ it('should build expandableDetails from image and ctas', () => {
251
+ const component = renderComponent('webpush');
252
+ const template = makeTemplate({
253
+ title: 'T',
254
+ message: 'M',
255
+ image: 'https://example.com/image.jpg',
256
+ ctas: [{ type: 'URL', actionText: 'Click', actionLink: 'https://a.com', action: '' }],
257
+ });
258
+
259
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
260
+ expect(result.content.expandableDetails).toBeDefined();
261
+ expect(result.content.expandableDetails.media).toEqual([
262
+ { url: 'https://example.com/image.jpg', type: 'IMAGE' },
263
+ ]);
264
+ expect(result.content.expandableDetails.ctas[0].title).toBe('Click');
265
+ expect(result.content.expandableDetails.ctas[0].type).toBe('EXTERNAL_URL');
266
+ });
267
+
268
+ it('should convert CTA type URL → EXTERNAL_URL in expandableDetails.ctas', () => {
269
+ const component = renderComponent('webpush');
270
+ const template = makeTemplate({
271
+ title: 'T',
272
+ message: 'M',
273
+ ctas: [{ type: 'URL', actionText: 'Go', actionLink: 'https://x.com' }],
274
+ });
275
+
276
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
277
+ expect(result.content.expandableDetails.ctas[0].type).toBe('EXTERNAL_URL');
278
+ });
279
+
280
+ it('should keep EXTERNAL_URL type unchanged in expandableDetails.ctas', () => {
281
+ const component = renderComponent('webpush');
282
+ const template = makeTemplate({
283
+ title: 'T',
284
+ message: 'M',
285
+ ctas: [{ type: 'EXTERNAL_URL', title: 'Go', actionLink: 'https://x.com' }],
286
+ });
287
+
288
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
289
+ expect(result.content.expandableDetails.ctas[0].type).toBe('EXTERNAL_URL');
290
+ });
291
+
292
+ it('should build image-only expandableDetails when no ctas', () => {
293
+ const component = renderComponent('webpush');
294
+ const template = makeTemplate({
295
+ title: 'T',
296
+ message: 'M',
297
+ image: 'https://example.com/img.jpg',
298
+ });
299
+
300
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
301
+ expect(result.content.expandableDetails.media).toHaveLength(1);
302
+ expect(result.content.expandableDetails.ctas).toEqual([]);
303
+ });
304
+
305
+ it('should build ctas-only expandableDetails when no image', () => {
306
+ const component = renderComponent('webpush');
307
+ const template = makeTemplate({
308
+ title: 'T',
309
+ message: 'M',
310
+ ctas: [{ type: 'EXTERNAL_URL', actionText: 'Btn', actionLink: 'https://b.com' }],
311
+ });
312
+
313
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
314
+ expect(result.content.expandableDetails.media).toEqual([]);
315
+ expect(result.content.expandableDetails.ctas).toHaveLength(1);
316
+ });
317
+
318
+ it('should use existingExpandable when no image and no ctas', () => {
319
+ const component = renderComponent('webpush');
320
+ const existingExpandable = {
321
+ media: [{ url: 'https://example.com/existing.jpg', type: 'IMAGE' }],
322
+ ctas: [{ title: 'Existing', actionLink: 'https://existing.com', type: 'EXTERNAL_URL' }],
323
+ };
324
+ const template = makeTemplate({
325
+ title: 'T',
326
+ message: 'M',
327
+ expandableDetails: existingExpandable,
328
+ });
329
+
330
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
331
+ expect(result.content.expandableDetails).toEqual(existingExpandable);
332
+ });
333
+
334
+ it('should NOT include expandableDetails when none present', () => {
335
+ const component = renderComponent('webpush');
336
+ const template = makeTemplate({ title: 'T', message: 'M' });
337
+
338
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
339
+ expect(result.content.expandableDetails).toBeUndefined();
340
+ });
341
+
342
+ it('should handle empty webpush content gracefully', () => {
343
+ const component = renderComponent('webpush');
344
+ const template = makeTemplate({}, { name: '' });
345
+
346
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
347
+ expect(result).toBeDefined();
348
+ expect(result.content.title).toBe('');
349
+ expect(result.content.message).toBe('');
350
+ });
351
+
352
+ it('should handle missing definition.accountId gracefully (returns null)', () => {
353
+ const component = renderComponent('webpush');
354
+ const template = {
355
+ name: 'T',
356
+ versions: { base: { content: { webpush: { title: 'T', message: 'M' } } } },
357
+ // no definition
358
+ };
359
+
360
+ const result = component.instance().extractTemplateContentForPreview(template, 'WEBPUSH');
361
+ expect(result).toBeDefined();
362
+ expect(result.accountId).toBeNull();
363
+ });
364
+
365
+ it('should return null for unsupported channel', () => {
366
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
367
+ const component = renderComponent('webpush');
368
+ const template = makeTemplate({ title: 'T', message: 'M' });
369
+
370
+ const result = component.instance().extractTemplateContentForPreview(template, 'UNSUPPORTED_CHANNEL');
371
+ expect(result).toBeNull();
372
+ consoleSpy.mockRestore();
373
+ });
374
+ });
375
+ });