@capillarytech/creatives-library 9.0.72 → 9.1.0

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 (53) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CapTagListWithInput/__tests__/CapTagListWithInput.test.js +58 -1
  3. package/v2Components/CapTagListWithInput/index.js +64 -35
  4. package/v2Components/CapTagListWithInput/index.scss +9 -0
  5. package/v2Components/CommonTestAndPreview/UnifiedPreview/EmailPreviewContent.js +8 -0
  6. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +7 -0
  7. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -0
  8. package/v2Components/CommonTestAndPreview/actions.js +1 -1
  9. package/v2Components/CommonTestAndPreview/index.js +29 -13
  10. package/v2Components/CommonTestAndPreview/sagas.js +2 -1
  11. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/EmailPreviewContent.test.js +27 -0
  12. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +40 -0
  13. package/v2Components/CommonTestAndPreview/tests/index.test.js +95 -0
  14. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +24 -0
  15. package/v2Components/EmailMobilePreview/index.js +8 -1
  16. package/v2Components/EmailMobilePreview/index.scss +12 -3
  17. package/v2Components/EmailMobilePreview/tests/index.test.js +31 -0
  18. package/v2Components/EmailPreviewV2/_emailPreviewV2.scss +37 -5
  19. package/v2Components/EmailPreviewV2/index.js +18 -6
  20. package/v2Components/EmailPreviewV2/messages.js +4 -0
  21. package/v2Components/EmailPreviewV2/tests/index.test.js +43 -0
  22. package/v2Components/FormBuilder/Classic.js +134 -14
  23. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +9 -0
  24. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +11 -12
  25. package/v2Components/FormBuilder/Functional/tests/mpush.singlePlatform.test.js +144 -0
  26. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +17 -5
  27. package/v2Components/FormBuilder/_formBuilder.scss +21 -0
  28. package/v2Components/FormBuilder/messages.js +12 -0
  29. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +45 -0
  30. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +2 -1
  31. package/v2Containers/CommunicationFlow/Tests/constants.test.js +4 -4
  32. package/v2Containers/CommunicationFlow/constants.js +1 -0
  33. package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +2 -1
  34. package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +1 -0
  35. package/v2Containers/CreativesContainer/index.js +7 -1
  36. package/v2Containers/CreativesContainer/tests/index.test.js +81 -0
  37. package/v2Containers/Email/index.js +31 -4
  38. package/v2Containers/Email/initialSchema.js +30 -0
  39. package/v2Containers/Email/messages.js +8 -0
  40. package/v2Containers/Email/tests/index.test.js +107 -0
  41. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +172 -8
  42. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +264 -5
  43. package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +29 -0
  44. package/v2Containers/EmailWrapper/constants.js +5 -0
  45. package/v2Containers/EmailWrapper/messages.js +24 -0
  46. package/v2Containers/MobilePush/Create/constants.js +1 -0
  47. package/v2Containers/MobilePush/Create/index.js +2 -2
  48. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +58 -0
  49. package/v2Containers/MobilePush/Edit/index.js +2 -2
  50. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +17 -0
  51. package/v2Containers/MobilePush/commonMethods.js +7 -8
  52. package/v2Containers/TagList/index.js +7 -0
  53. package/v2Containers/TagList/tests/TagList.test.js +42 -0
@@ -4,9 +4,9 @@
4
4
  * renderContainer 'tabs' case (Classic.js:4246-4328) with state-driven tab
5
5
  * activation — no DOM scans, no setTimeout — while keeping the exact container
6
6
  * contract: pane keys come from the bound tab's tabKey, unsupported panes are
7
- * skipped WITHOUT reserving an index (pane→tab binding shifts, matching the
8
- * class's paneIndex behavior), and the container's injected onTabChange still
9
- * receives the 1-based active index (bridged by the shell via onTabSwitch).
7
+ * skipped but every pane stays bound to its own tab (iOS keeps tab 1 when Android
8
+ * is hidden), and the container's injected onTabChange still receives the
9
+ * 1-based active index (bridged by the shell via onTabSwitch).
10
10
  */
11
11
 
12
12
  import React from 'react';
@@ -23,17 +23,14 @@ const TabsContainer = ({ container, renderContext }) => {
23
23
  const activeTabIndex = renderContext?.activeTabIndex ?? 0;
24
24
 
25
25
  const renderedPanes = [];
26
- let supportedOrdinal = 0; // class parity: only supported panes advance the index
27
26
  container.panes.forEach((pane, paneIndex) => {
28
27
  const isPaneSupported = typeof pane.isSupported === 'boolean' ? pane.isSupported : true;
29
28
  if (!isPaneSupported) return;
30
- const tabIndex = supportedOrdinal;
31
- supportedOrdinal += 1;
32
29
 
33
30
  // Fields inside this pane read/write their OWN tab; everything else in the
34
31
  // context is shared. (paneTabIndex is consumed by Section.renderField.)
35
- const paneContext = { ...renderContext, paneTabIndex: tabIndex };
36
- const paneTabKey = tabs[tabIndex]?.tabKey;
32
+ const paneContext = { ...renderContext, paneTabIndex: paneIndex };
33
+ const paneTabKey = tabs[paneIndex]?.tabKey;
37
34
  const headerSections = pane.sectionsHeaders || container.tabContent?.sections || [];
38
35
 
39
36
  renderedPanes.push({
@@ -57,11 +54,13 @@ const TabsContainer = ({ container, renderContext }) => {
57
54
  });
58
55
 
59
56
  // Must resolve through the SAME fallback chain as the pane keys — a divergent
60
- // fallback leaves no pane matching activeKey and CapTab renders nothing.
57
+ // fallback leaves no pane matching activeKey and CapTab renders nothing. An
58
+ // active tab whose pane is hidden falls back to the first visible pane.
61
59
  const activeKey = tabs[activeTabIndex]?.tabKey;
62
- const resolvedActiveKey = hasTabKey(activeKey)
63
- ? `${activeKey}`
64
- : (renderedPanes[activeTabIndex]?.key ?? renderedPanes[0]?.key ?? '1');
60
+ const preferredKey = hasTabKey(activeKey) ? `${activeKey}` : `${activeTabIndex}`;
61
+ const resolvedActiveKey = renderedPanes.some((renderedPane) => renderedPane.key === preferredKey)
62
+ ? preferredKey
63
+ : (renderedPanes[0]?.key ?? '1');
65
64
 
66
65
  return (
67
66
  <CapTab
@@ -0,0 +1,144 @@
1
+ /**
2
+ * MOBILEPUSH single-platform accounts (Functional FormBuilder): the containers mark the
3
+ * disabled platform's pane isSupported:false. The visible pane must stay bound to its own
4
+ * tab (Android = formData[0], iOS = formData[1]) for display, writes and the active tab.
5
+ */
6
+ import React from 'react';
7
+ import '@testing-library/jest-dom';
8
+ import _ from 'lodash';
9
+ import { Provider } from 'react-redux';
10
+ import { Router } from 'react-router-dom';
11
+ import { injectIntl } from 'react-intl';
12
+ import { configureStore } from '@capillarytech/vulcan-react-sdk/utils';
13
+ import history from '../../../../utils/history';
14
+ import { initialReducer } from '../../../../initialReducer';
15
+ import { render, fireEvent, waitFor } from '../../../../utils/test-utils';
16
+ import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
17
+ import FunctionalFormBuilder from '../index';
18
+
19
+ jest.mock('redux-auth-wrapper/history4/redirect', () => ({
20
+ connectedRouterRedirect: jest.fn(() => (Component) => Component),
21
+ }));
22
+ jest.mock('../../../../services/api', () => ({
23
+ ...jest.requireActual('../../../../services/api'),
24
+ getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
25
+ }));
26
+
27
+ const ComponentToRender = injectIntl(FunctionalFormBuilder);
28
+
29
+ const buildSchema = (hiddenPane) => {
30
+ const schema = _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition.textSchema);
31
+ schema.containers[0].panes[hiddenPane].isSupported = false;
32
+ return schema;
33
+ };
34
+
35
+ const buildProps = (overrides = {}) => ({
36
+ channel: 'MOBILEPUSH',
37
+ formData: {},
38
+ location: { pathname: '/mobilepush/create/text', query: { type: false, module: 'default' } },
39
+ usingTabContainer: true,
40
+ isFullMode: true,
41
+ isEdit: false,
42
+ startValidation: false,
43
+ checkValidation: false,
44
+ currentTab: 1,
45
+ tabCount: 2,
46
+ tabKey: '',
47
+ tags: [],
48
+ injectedTags: {},
49
+ onChange: jest.fn(),
50
+ onSubmit: jest.fn(),
51
+ onFormValidityChange: jest.fn(),
52
+ stopValidation: jest.fn(),
53
+ showLiquidErrorInFooter: jest.fn(),
54
+ setModalContent: jest.fn(),
55
+ handleCancelModal: jest.fn(),
56
+ ...overrides,
57
+ });
58
+
59
+ const renderBuilder = (props) => {
60
+ const store = configureStore({}, initialReducer, history);
61
+ const ui = (p) => (
62
+ <Router history={history}>
63
+ <Provider store={store}>
64
+ <div className="mobilepush-wrapper">
65
+ <ComponentToRender {...p} />
66
+ </div>
67
+ </Provider>
68
+ </Router>
69
+ );
70
+ const utils = render(ui(props));
71
+ return { ...utils, rerenderWith: (next) => utils.rerender(ui(next)) };
72
+ };
73
+
74
+ const lastFormData = (onChange) => onChange.mock.calls[onChange.mock.calls.length - 1][0];
75
+
76
+ describe('Functional MPUSH — single-platform accounts', () => {
77
+ it('iOS only: create shows the active iOS pane and typing lands in formData[1]', async () => {
78
+ const props = buildProps({ schema: buildSchema(0) });
79
+ const { rerenderWith, container } = renderBuilder(props);
80
+ rerenderWith({ ...props, currentTab: 2 });
81
+
82
+ await waitFor(() => expect(container.querySelector('.ant-tabs-tab-active')).toHaveTextContent('iOS'));
83
+ fireEvent.change(document.getElementById('message-title2'), { target: { value: 'test' } });
84
+
85
+ await waitFor(() => expect(document.getElementById('message-title2')).toHaveValue('test'));
86
+ await waitFor(() => expect(lastFormData(props.onChange)[1]['message-title2']).toBe('test'));
87
+ expect(lastFormData(props.onChange)[0]['message-title2']).toBeUndefined();
88
+ });
89
+
90
+ it('iOS only: edit shows the saved iOS content in the active pane', async () => {
91
+ const props = buildProps({ schema: buildSchema(0), isEdit: true });
92
+ const { rerenderWith, container } = renderBuilder(props);
93
+ const tab0 = { 'message-title': '', 'message-editor': '', base: true };
94
+ rerenderWith({ ...props, currentTab: 2 });
95
+ rerenderWith({
96
+ ...props,
97
+ currentTab: 2,
98
+ formData: {
99
+ 'template-name': 'T', 0: tab0, 1: { 'message-title2': 'I title', 'message-editor2': 'I body' }, base: tab0,
100
+ },
101
+ });
102
+
103
+ await waitFor(() => {
104
+ expect(container.querySelector('.ant-tabs-tab-active')).toHaveTextContent('iOS');
105
+ expect(document.getElementById('message-title2')).toHaveValue('I title');
106
+ expect(document.getElementById('message-editor2')).toHaveValue('I body');
107
+ });
108
+ });
109
+
110
+ it('Android only: edit shows the saved Android content in the active pane', async () => {
111
+ const props = buildProps({ schema: buildSchema(1), isEdit: true });
112
+ const { rerenderWith, container } = renderBuilder(props);
113
+ const tab0 = { 'message-title': 'A title', 'message-editor': 'A body', base: true };
114
+ rerenderWith({
115
+ ...props,
116
+ formData: {
117
+ 'template-name': 'T', 0: tab0, 1: { 'message-title2': '', 'message-editor2': '' }, base: tab0,
118
+ },
119
+ });
120
+
121
+ await waitFor(() => {
122
+ expect(container.querySelector('.ant-tabs-tab-active')).toHaveTextContent('Android');
123
+ expect(document.getElementById('message-title')).toHaveValue('A title');
124
+ expect(document.getElementById('message-editor')).toHaveValue('A body');
125
+ });
126
+ });
127
+
128
+ it('both platforms hidden: renders no platform tabs', () => {
129
+ const schema = buildSchema(0);
130
+ schema.containers[0].panes[1].isSupported = false;
131
+ const props = buildProps({ schema, currentTab: 2 });
132
+ const { container } = renderBuilder(props);
133
+
134
+ expect(container.querySelectorAll('.ant-tabs-tab').length).toBe(0);
135
+ });
136
+
137
+ it('a layout without a tabs container renders without switching tabs', () => {
138
+ const { standalone } = buildSchema(0);
139
+ const props = buildProps({ schema: { channel: 'MOBILEPUSH', standalone } });
140
+ const { container } = renderBuilder(props);
141
+
142
+ expect(container.querySelectorAll('.ant-tabs-tab').length).toBe(0);
143
+ });
144
+ });
@@ -47,16 +47,28 @@ describe('TabsContainer', () => {
47
47
  expect(c2.textContent).toBe('');
48
48
  });
49
49
 
50
- it('skips isSupported:false panes WITHOUT reserving a tab index (pane->tab binding shifts)', () => {
50
+ it('skips isSupported:false panes but keeps each visible pane bound to its own tab', () => {
51
51
  const container = buildContainer();
52
52
  container.panes[0].isSupported = false;
53
+ [0, 1].forEach((activeTabIndex) => {
54
+ const { container: dom, unmount } = render(
55
+ <TabsContainer container={container} renderContext={buildContext({ activeTabIndex })} />,
56
+ );
57
+ // only the iOS pane renders, bound to tab 1 (key 'kI'), and it is ACTIVE even
58
+ // when the active index still points at the hidden Android tab
59
+ expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(1);
60
+ expect(dom.querySelector('.ant-tabs-tab-active').getAttribute('data-node-key')).toBe('kI');
61
+ unmount();
62
+ });
63
+ });
64
+
65
+ it('renders no tabs when every pane is hidden', () => {
66
+ const container = buildContainer();
67
+ container.panes.forEach((pane) => { pane.isSupported = false; }); // eslint-disable-line no-param-reassign
53
68
  const { container: dom } = render(
54
69
  <TabsContainer container={container} renderContext={buildContext()} />,
55
70
  );
56
- // only the iOS pane renders, bound to tab 0 (key 'kA' — the shifted binding);
57
- // it must also be ACTIVE, proving the pane key and activeKey shift together
58
- expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(1);
59
- expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
71
+ expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(0);
60
72
  });
61
73
 
62
74
  it('renders the tab-header sections for every locale (Classic has no locale gating)', () => {
@@ -136,4 +136,25 @@
136
136
  }
137
137
  .error-boundary{
138
138
  border: $CAP_SPACE_02 solid $CAP_RED;
139
+ }
140
+
141
+ // Suffix controls (character counter, Remove link) rendered alongside EMAIL's
142
+ // Subject/Pre-header inputs by HighFreqTagInput (Classic.js).
143
+ .high-freq-tag-input-counter {
144
+ white-space: nowrap;
145
+ }
146
+
147
+ .high-freq-tag-input-remove-link {
148
+ white-space: nowrap;
149
+ cursor: pointer;
150
+ }
151
+
152
+ .high-freq-tag-input-prefix {
153
+ padding-right: $CAP_SPACE_08;
154
+ margin-right: $CAP_SPACE_08;
155
+ border-right: 1px solid $CAP_COLOR_16;
156
+ }
157
+
158
+ .high-freq-tag-input-prefix-label {
159
+ font-weight: 600;
139
160
  }
@@ -98,4 +98,16 @@ export default defineMessages({
98
98
  id: `creatives.componentsV2.FormBuilder.personalizationTagsErrorMessage`,
99
99
  defaultMessage: 'Personalization tags are not supported for anonymous customers, please remove the tags.',
100
100
  },
101
+ addPreheader: {
102
+ id: 'creatives.componentsV2.FormBuilder.addPreheader',
103
+ defaultMessage: '+ Pre-header',
104
+ },
105
+ removePreheader: {
106
+ id: 'creatives.componentsV2.FormBuilder.removePreheader',
107
+ defaultMessage: 'Remove',
108
+ },
109
+ preheaderInfoTooltip: {
110
+ id: 'creatives.componentsV2.FormBuilder.preheaderInfoTooltip',
111
+ defaultMessage: 'Shows next to the subject line in the inbox, use it to add context and improve open rates. Keep it to 40–85 characters.',
112
+ },
101
113
  });
@@ -457,3 +457,48 @@ describe('MPUSH characterization — imageSchema', () => {
457
457
  expect(props.onSubmit).not.toHaveBeenCalled();
458
458
  });
459
459
  });
460
+
461
+ describe('MPUSH — single-platform accounts', () => {
462
+ const tab0 = { 'message-title': 'A title', 'message-editor': 'A body', base: true };
463
+ const tab1 = { 'message-title2': 'I title', 'message-editor2': 'I body' };
464
+ const renderEdit = async (hiddenPane, currentTab) => {
465
+ const { schema } = buildMpushSchema();
466
+ schema.containers[0].panes[hiddenPane].isSupported = false;
467
+ const base = buildProps({ schema, isEdit: true, formData: {} });
468
+ const { rerenderWith } = renderMpush(base);
469
+ rerenderWith({ ...base, currentTab });
470
+ rerenderWith({
471
+ ...base, currentTab, formData: { 'template-name': 'T', 0: { ...tab0 }, 1: { ...tab1 }, base: tab0 },
472
+ });
473
+ };
474
+
475
+ it('iOS only (Android hidden): edit shows the saved iOS content', async () => {
476
+ await renderEdit(0, 2);
477
+ await waitFor(() => {
478
+ expect(document.getElementById('message-title2')).toHaveValue('I title');
479
+ expect(document.getElementById('message-editor2')).toHaveValue('I body');
480
+ });
481
+ expect(screen.queryByText('Android')).toBeNull();
482
+ });
483
+
484
+ it('Android only (iOS hidden): edit shows the saved Android content', async () => {
485
+ await renderEdit(1, 1);
486
+ await waitFor(() => {
487
+ expect(titleInput()).toHaveValue('A title');
488
+ expect(editorInput()).toHaveValue('A body');
489
+ });
490
+ expect(screen.queryByText('iOS')).toBeNull();
491
+ });
492
+
493
+ it('both platforms hidden: renders no tabs and leaves currentTab alone', () => {
494
+ const { schema } = buildMpushSchema();
495
+ schema.containers[0].panes[0].isSupported = false;
496
+ schema.containers[0].panes[1].isSupported = false;
497
+ const base = buildProps({ schema, formData: _.cloneDeep(seedTabs) });
498
+ const { rerenderWith } = renderMpush(base);
499
+ rerenderWith({ ...base, currentTab: 2 });
500
+
501
+ expect(screen.queryByText('Android')).toBeNull();
502
+ expect(screen.queryByText('iOS')).toBeNull();
503
+ });
504
+ });
@@ -479,7 +479,7 @@ describe('handleSave — CCS flow', () => {
479
479
  initialData: {
480
480
  contentItems: [{
481
481
  channel: 'EMAIL',
482
- templateData: { emailSubject: 'Order Confirmed', emailBody: '<html>Hi</html>' },
482
+ templateData: { emailSubject: 'Order Confirmed', emailBody: '<html>Hi</html>', emailPreHeader: 'Your order is on its way' },
483
483
  }],
484
484
  },
485
485
  onSave,
@@ -497,6 +497,7 @@ describe('handleSave — CCS flow', () => {
497
497
  channel: 'EMAIL',
498
498
  messageSubject: 'Order Confirmed',
499
499
  messageBody: '<html>Hi</html>',
500
+ messagePreHeader: 'Your order is on its way',
500
501
  },
501
502
  }),
502
503
  }),
@@ -10,20 +10,20 @@ describe('CCS_CONTENT_TRANSFORMS', () => {
10
10
  it('prefers emailBody over emailHtml when both are present', () => {
11
11
  expect(
12
12
  CCS_CONTENT_TRANSFORMS.EMAIL({
13
- emailSubject: 'Subject', emailBody: '<p>Body</p>', emailHtml: '<p>Html</p>',
13
+ emailSubject: 'Subject', emailBody: '<p>Body</p>', emailHtml: '<p>Html</p>', emailPreHeader: 'Preheader',
14
14
  }),
15
- ).toEqual({ messageSubject: 'Subject', messageBody: '<p>Body</p>' });
15
+ ).toEqual({ messageSubject: 'Subject', messageBody: '<p>Body</p>', messagePreHeader: 'Preheader' });
16
16
  });
17
17
 
18
18
  it('falls back to emailHtml when emailBody is absent', () => {
19
19
  expect(
20
20
  CCS_CONTENT_TRANSFORMS.EMAIL({ emailSubject: 'Subject', emailHtml: '<p>Html</p>' }),
21
- ).toEqual({ messageSubject: 'Subject', messageBody: '<p>Html</p>' });
21
+ ).toEqual({ messageSubject: 'Subject', messageBody: '<p>Html</p>', messagePreHeader: undefined });
22
22
  });
23
23
 
24
24
  it('defaults templateData to {} when called with no argument', () => {
25
25
  expect(CCS_CONTENT_TRANSFORMS.EMAIL()).toEqual({
26
- messageSubject: undefined, messageBody: undefined,
26
+ messageSubject: undefined, messageBody: undefined, messagePreHeader: undefined,
27
27
  });
28
28
  });
29
29
  });
@@ -65,6 +65,7 @@ export const CCS_CONTENT_TRANSFORMS = {
65
65
  EMAIL: (templateData = {}) => ({
66
66
  messageSubject: templateData.emailSubject,
67
67
  messageBody: templateData.emailBody || templateData.emailHtml,
68
+ messagePreHeader: templateData.emailPreHeader,
68
69
  }),
69
70
  SMS: (templateData = {}) => ({
70
71
  message: templateData.messageBody || '',
@@ -101,7 +101,7 @@ describe('mapCommDefinitionToStepData', () => {
101
101
  singleChannelStrategy: {
102
102
  variant: {
103
103
  channel: 'EMAIL',
104
- emailMessageContent: { messageSubject: 'Subject', messageBody: '<p>Body</p>' },
104
+ emailMessageContent: { messageSubject: 'Subject', messageBody: '<p>Body</p>', messagePreHeader: 'Preheader' },
105
105
  },
106
106
  },
107
107
  };
@@ -111,6 +111,7 @@ describe('mapCommDefinitionToStepData', () => {
111
111
  expect(result.contentItems[0].templateData).toEqual({
112
112
  emailSubject: 'Subject',
113
113
  emailBody: '<p>Body</p>',
114
+ emailPreHeader: 'Preheader',
114
115
  channel: 'EMAIL',
115
116
  });
116
117
  });
@@ -27,6 +27,7 @@ const CCS_CONTENT_REVERSE_TRANSFORMS = {
27
27
  EMAIL: (payload = {}) => ({
28
28
  emailSubject: payload.messageSubject,
29
29
  emailBody: payload.messageBody,
30
+ emailPreHeader: payload.messagePreHeader,
30
31
  }),
31
32
  SMS: (payload = {}) => ({
32
33
  messageBody: payload.message || '',
@@ -503,6 +503,7 @@ export class Creatives extends React.Component {
503
503
  case constants.EMAIL: {
504
504
  const formData = {
505
505
  subject: templateData.emailSubject,
506
+ preheader: templateData.emailPreHeader,
506
507
  html_content: templateData.emailBody,
507
508
  is_drag_drop: templateData.is_drag_drop,
508
509
  lang_id: templateData.lang_id,
@@ -1092,10 +1093,15 @@ export class Creatives extends React.Component {
1092
1093
  }
1093
1094
  const newHtmlContent = await updateImagesInHtml(html_content);
1094
1095
  templateData = {
1095
- ...templateData, ...emailBase, emailBody: newHtmlContent, emailSubject: (emailBase && emailBase.subject) ? emailBase.subject : '',
1096
+ ...templateData,
1097
+ ...emailBase,
1098
+ emailBody: newHtmlContent,
1099
+ emailSubject: emailBase?.subject || '',
1100
+ emailPreHeader: emailBase?.preheader || '',
1096
1101
  };
1097
1102
  delete templateData.html_content;
1098
1103
  delete templateData.subject;
1104
+ delete templateData.preheader;
1099
1105
  }
1100
1106
  break;
1101
1107
  case constants.MOBILE_PUSH:
@@ -376,4 +376,85 @@ describe('Test SlideBoxContent container', () => {
376
376
  expect(getCreativesData).toHaveBeenCalledTimes(1);
377
377
  expect(getCreativesData).toHaveBeenCalledWith({ channel: 'EMAIL'});
378
378
  });
379
+
380
+ it('EMAIL getFormData maps preheader to emailPreHeader for campaigns, alongside subject/body', async () => {
381
+ renderFunction('EMAIL', 'editTemplate', emailTemplates, emailEditTemplateData);
382
+ renderedComponent.instance().onEditTemplate();
383
+
384
+ const mockValue = {
385
+ validity: true,
386
+ value: {
387
+ name: 'email_creative_name',
388
+ type: 'EMAIL',
389
+ base: {
390
+ subject: 'Test subject',
391
+ preheader: 'Test preheader',
392
+ html_content: '<p>hello</p>',
393
+ },
394
+ },
395
+ };
396
+
397
+ renderedComponent.instance().getFormData(mockValue);
398
+ await tick();
399
+
400
+ expect(getCreativesData).toHaveBeenCalledTimes(1);
401
+ const sentData = getCreativesData.mock.calls[0][0];
402
+ expect(sentData.emailSubject).toBe('Test subject');
403
+ expect(sentData.emailPreHeader).toBe('Test preheader');
404
+ expect(sentData.emailBody).toBe('<p>hello</p>');
405
+ expect(sentData.subject).toBeUndefined();
406
+ expect(sentData.preheader).toBeUndefined();
407
+ expect(sentData.html_content).toBeUndefined();
408
+ });
409
+
410
+ it('EMAIL getFormData maps emailPreHeader to an empty string when not present', async () => {
411
+ renderFunction('EMAIL', 'editTemplate', emailTemplates, emailEditTemplateData);
412
+ renderedComponent.instance().onEditTemplate();
413
+
414
+ const mockValue = {
415
+ validity: true,
416
+ value: {
417
+ name: 'email_creative_name',
418
+ type: 'EMAIL',
419
+ base: {
420
+ subject: 'Test subject',
421
+ html_content: '<p>hello</p>',
422
+ },
423
+ },
424
+ };
425
+
426
+ renderedComponent.instance().getFormData(mockValue);
427
+ await tick();
428
+
429
+ expect(getCreativesData).toHaveBeenCalledTimes(1);
430
+ const sentData = getCreativesData.mock.calls[0][0];
431
+ expect(sentData.emailPreHeader).toBe('');
432
+ });
433
+
434
+ it('EMAIL getTemplateData maps emailPreHeader back to preheader when reopening a saved campaign message', () => {
435
+ renderedComponent = shallowWithIntl(
436
+ <Creatives
437
+ loyaltyMetaData={loyaltyMetaData}
438
+ Templates={emailTemplates}
439
+ channel="EMAIL"
440
+ slidBoxContent="editTemplate"
441
+ templateData={{
442
+ channel: 'EMAIL',
443
+ emailSubject: 'Reopened subject',
444
+ emailPreHeader: 'Reopened preheader',
445
+ emailBody: '<p>hello</p>',
446
+ }}
447
+ handleCloseCreatives={handleCloseCreatives}
448
+ getCreativesData={getCreativesData}
449
+ isFullMode={false}
450
+ templateActions={{
451
+ getCdnTransformationConfig,
452
+ }}
453
+ />,
454
+ );
455
+
456
+ const { templateData } = renderedComponent.instance().state;
457
+ expect(templateData.base.subject).toBe('Reopened subject');
458
+ expect(templateData.base.preheader).toBe('Reopened preheader');
459
+ });
379
460
  });
@@ -119,6 +119,10 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
119
119
  "template-subject": {
120
120
  onTagSelect: this.onTagSelect,
121
121
  },
122
+ // unified pre-header field with input and tag selection
123
+ "template-preheader": {
124
+ onTagSelect: this.onTagSelect,
125
+ },
122
126
  "template-version": {
123
127
  onSelect: this.handleVersionSelect,
124
128
  addVersion: this.addVersion,
@@ -645,6 +649,14 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
645
649
  formData['template-subject'] = subjectFromEditData;
646
650
  }
647
651
  }
652
+
653
+ // Ensure template-preheader is preserved at the top level
654
+ if ([undefined, ''].includes(formData['template-preheader'])) {
655
+ const preheaderFromEditData = _.get(nextProps, 'Email.templateDetails.versions.base.preheader', '');
656
+ if (preheaderFromEditData) {
657
+ formData['template-preheader'] = preheaderFromEditData;
658
+ }
659
+ }
648
660
 
649
661
  if (langIndex !== undefined && langIndex !== -1 && temp && temp.panes) {
650
662
  _.forEach(temp.panes, (pane, index) => {
@@ -903,11 +915,11 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
903
915
  }
904
916
 
905
917
  onTagSelect = (data, currentTab, sourceVal) => {
906
- // If tag is selected from Subject's TagList or unified subject field, insert into subject input
907
- if (sourceVal && (sourceVal.id === 'title-tagList' || sourceVal.id === 'template-subject')) {
918
+ // If tag is selected from Subject's TagList, unified subject field, or the pre-header field, insert into the relevant input
919
+ if (sourceVal && (sourceVal.id === 'title-tagList' || sourceVal.id === 'template-subject' || sourceVal.id === 'template-preheader')) {
908
920
  const tagToInsert = `{{${data}}}`;
909
921
  const formData = _.cloneDeep(this.state.formData);
910
- const fieldId = 'template-subject';
922
+ const fieldId = sourceVal.id === 'template-preheader' ? 'template-preheader' : 'template-subject';
911
923
  const input = document.getElementById(fieldId) || document.querySelector(`#${fieldId} input`);
912
924
  let subjectValue = formData[fieldId] || '';
913
925
  try {
@@ -1184,6 +1196,8 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
1184
1196
  formData['template-name'] = editData.name;
1185
1197
  const subject = _.get(editData, 'versions.base.subject', '');
1186
1198
  formData['template-subject'] = subject;
1199
+ const preheader = _.get(editData, 'versions.base.preheader', '');
1200
+ formData['template-preheader'] = preheader;
1187
1201
  formData.base = editData.versions.base;
1188
1202
 
1189
1203
  _.forEach(editData.versions.history, (data1, index) => {
@@ -1686,6 +1700,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
1686
1700
  newFormData.base = _.cloneDeep(tmpData);
1687
1701
  newFormData.secondary_templates.push({template_data: tmpData});
1688
1702
  newFormData.base.subject = formData['template-subject'];
1703
+ newFormData.base.preheader = formData['template-preheader'] || '';
1689
1704
  return newFormData;
1690
1705
  }
1691
1706
 
@@ -2531,7 +2546,14 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
2531
2546
  newSchema.standalone.sections = [];
2532
2547
  if (this.props.location.query.type === 'embedded' && (this.props.location.query.module === "loyalty" || this.props.location.query.module === "dvs" || this.props.location.query.module === "coupon_expiry" || this.props.location.query.module === "timeline" ||
2533
2548
  this.props.location.query.module === "library")) {
2534
- newSchema.standalone.sections.push(_.cloneDeep(schema.standalone.sections[0]));
2549
+ // Keep only Subject/Pre-header (matched by field id; template-name stays hidden).
2550
+ const standAloneFieldIdsToKeep = ['template-subject', 'template-preheader'];
2551
+ _.forEach(_.get(schema, 'standalone.sections', []), (section) => {
2552
+ const hasKeepableField = _.some(section.inputFields, (inputField) => _.some(inputField.cols, (col) => standAloneFieldIdsToKeep.includes(col.id)));
2553
+ if (hasKeepableField) {
2554
+ newSchema.standalone.sections.push(_.cloneDeep(section));
2555
+ }
2556
+ });
2535
2557
  }
2536
2558
 
2537
2559
  _.forEach(newSchema.containers, (container) => {
@@ -2590,6 +2612,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
2590
2612
  if ((this.props.location.query.isLanguageSupport === undefined || this.props.location.query.isLanguageSupport === 'false')) {
2591
2613
  // If formData['template-subject'] is empty, assign an empty string; otherwise, retain its current value or use data.base.subject.
2592
2614
  formData['template-subject'] = formData?.['template-subject'] === '' ? '' : (data?.base?.subject || '');
2615
+ formData['template-preheader'] = formData?.['template-preheader'] === '' ? '' : (data?.base?.preheader || '');
2593
2616
  const baseLanguage = this.props.currentOrgDetails.basic_details.base_language;
2594
2617
  let languageIndex = _.findIndex(this.supportedLanguages, {iso_code: baseLanguage});
2595
2618
 
@@ -2923,12 +2946,16 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
2923
2946
  if (index === "template-subject" && obj?.versions?.history?.[0]) {
2924
2947
  obj.versions.history[0].subject = newdata;
2925
2948
  }
2949
+ if (index === "template-preheader" && obj?.versions?.history?.[0]) {
2950
+ obj.versions.history[0].preheader = newdata;
2951
+ }
2926
2952
  });
2927
2953
  //const data = formData[`${this.state.currentTab - 1}`];
2928
2954
  obj.name = formData['template-name'];
2929
2955
  obj.versions.base = formData.base;
2930
2956
  obj.versions.base.tabKey = baseKey;
2931
2957
  obj.versions.base.subject = formData['template-subject'];
2958
+ obj.versions.base.preheader = formData['template-preheader'] || '';
2932
2959
  obj.versions.base.activeTab = formData?.base?.activeTab || baseLanguage;
2933
2960
  _.forEach(obj.versions.base.selectedLanguages, (language) => {
2934
2961
  if (obj.versions.base[language].is_drag_drop) {
@@ -42,6 +42,36 @@ export const response = {
42
42
  },
43
43
  ],
44
44
  },
45
+ {
46
+ type: "multicols",
47
+ inputFields: [
48
+ {
49
+ cols: [
50
+ {
51
+ id: "template-preheader",
52
+ placeholder: "e.g. Get 20% off your next order",
53
+ type: "cap-tag-list-with-input",
54
+ metaType: "text",
55
+ datatype: "string",
56
+ required: false,
57
+ label: "Pre-header",
58
+ styling: "semantic",
59
+ order: 1,
60
+ width: 16,
61
+ customComponent: true,
62
+ standalone: true,
63
+ onlyDisplay: false,
64
+ maxLength: 85,
65
+ errorMessage: "Unsubscribe tag is not allowed in Pre-header.",
66
+ supportedEvents: [
67
+ "onChange",
68
+ "onTagSelect",
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ },
45
75
  {
46
76
  type: "multicols",
47
77
  inputFields: [