@capillarytech/creatives-library 9.0.73-alpha.sprint-testpreview.22 → 9.1.1

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 (66) 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 +36 -75
  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 +92 -97
  14. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +24 -0
  15. package/v2Components/CommonTestAndPreview/tests/utils.test.js +0 -66
  16. package/v2Components/CommonTestAndPreview/utils.js +0 -14
  17. package/v2Components/EmailMobilePreview/index.js +8 -1
  18. package/v2Components/EmailMobilePreview/index.scss +12 -3
  19. package/v2Components/EmailMobilePreview/tests/index.test.js +31 -0
  20. package/v2Components/EmailPreviewV2/_emailPreviewV2.scss +37 -5
  21. package/v2Components/EmailPreviewV2/index.js +18 -6
  22. package/v2Components/EmailPreviewV2/messages.js +4 -0
  23. package/v2Components/EmailPreviewV2/tests/index.test.js +43 -0
  24. package/v2Components/FormBuilder/Classic.js +134 -14
  25. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +9 -0
  26. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +11 -12
  27. package/v2Components/FormBuilder/Functional/tests/mpush.singlePlatform.test.js +144 -0
  28. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +17 -5
  29. package/v2Components/FormBuilder/_formBuilder.scss +21 -0
  30. package/v2Components/FormBuilder/messages.js +12 -0
  31. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +45 -0
  32. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +2 -1
  33. package/v2Containers/CommunicationFlow/Tests/constants.test.js +4 -4
  34. package/v2Containers/CommunicationFlow/constants.js +1 -0
  35. package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +2 -1
  36. package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +1 -0
  37. package/v2Containers/CreativesContainer/SlideBoxContent.js +0 -2
  38. package/v2Containers/CreativesContainer/index.js +7 -1
  39. package/v2Containers/CreativesContainer/tests/index.test.js +81 -0
  40. package/v2Containers/Email/index.js +31 -4
  41. package/v2Containers/Email/initialSchema.js +30 -0
  42. package/v2Containers/Email/messages.js +8 -0
  43. package/v2Containers/Email/tests/index.test.js +107 -0
  44. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +172 -8
  45. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +264 -5
  46. package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +29 -0
  47. package/v2Containers/EmailWrapper/constants.js +5 -0
  48. package/v2Containers/EmailWrapper/messages.js +24 -0
  49. package/v2Containers/MobilePush/Create/constants.js +1 -0
  50. package/v2Containers/MobilePush/Create/index.js +2 -2
  51. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +58 -0
  52. package/v2Containers/MobilePush/Edit/index.js +2 -2
  53. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +17 -0
  54. package/v2Containers/MobilePush/commonMethods.js +7 -8
  55. package/v2Containers/Rcs/index.js +1 -27
  56. package/v2Containers/TagList/index.js +7 -0
  57. package/v2Containers/TagList/tests/TagList.test.js +42 -0
  58. package/v2Containers/Templates/index.js +4 -9
  59. package/v2Containers/Templates/tests/index.test.js +35 -107
  60. package/v2Containers/Viber/index.scss +31 -0
  61. package/v2Containers/Whatsapp/index.js +1 -18
  62. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +2 -2
  63. package/v2Containers/Zalo/index.js +0 -18
  64. package/v2Containers/Rcs/tests/testAndPreviewFromListing.test.js +0 -132
  65. package/v2Containers/Whatsapp/tests/testAndPreviewFromListing.test.js +0 -91
  66. package/v2Containers/Zalo/tests/testAndPreviewFromListing.test.js +0 -98
@@ -9,3 +9,32 @@
9
9
  .html-editor-options-icon {
10
10
  cursor: pointer;
11
11
  }
12
+
13
+ // Subject/Pre-header field rows
14
+ .email-html-editor-field {
15
+ margin-bottom: $CAP_SPACE_16;
16
+ }
17
+
18
+ .email-html-editor-add-preheader-btn {
19
+ white-space: nowrap;
20
+ }
21
+
22
+ // Subject/Pre-header inline prefix label and Pre-header suffix (counter + Remove)
23
+ .email-html-editor-prefix {
24
+ padding-right: $CAP_SPACE_08;
25
+ margin-right: $CAP_SPACE_08;
26
+ border-right: 0.0625rem solid $CAP_COLOR_16;
27
+ }
28
+
29
+ .email-html-editor-prefix-label {
30
+ font-weight: 600;
31
+ }
32
+
33
+ .email-html-editor-counter {
34
+ white-space: nowrap;
35
+ }
36
+
37
+ .email-html-editor-remove-link {
38
+ white-space: nowrap;
39
+ cursor: pointer;
40
+ }
@@ -4,6 +4,8 @@
4
4
  *
5
5
  */
6
6
 
7
+ import { UNSUBSCRIBE_TAG } from '../../constants/unified';
8
+
7
9
  export const DEFAULT_ACTION = "app/EmailWrapper/DEFAULT_ACTION";
8
10
  export const EMAIL_CREATE_MODES = {
9
11
  UPLOAD: "upload",
@@ -17,3 +19,6 @@ export const STEPS = {
17
19
  TEMPLATE_SELECTION: "templateSelection",
18
20
  CREATE_TEMPLATE_CONTENT: "createTemplateContent",
19
21
  };
22
+
23
+ // Unsubscribe is not a valid Pre-header tag — hide it from the "Add Label" dropdown there.
24
+ export const PREHEADER_EXCLUDED_TAGS = [UNSUBSCRIBE_TAG];
@@ -122,6 +122,30 @@ export default defineMessages({
122
122
  id: 'creatives.containersV2.EmailWrapper.enterEmailSubject',
123
123
  defaultMessage: 'Enter Email Subject',
124
124
  },
125
+ preheader: {
126
+ id: 'creatives.containersV2.EmailWrapper.preheader',
127
+ defaultMessage: 'Pre-header',
128
+ },
129
+ enterEmailPreheader: {
130
+ id: 'creatives.containersV2.EmailWrapper.enterEmailPreheader',
131
+ defaultMessage: 'e.g. Get 20% off your next order',
132
+ },
133
+ preheaderInfoTooltip: {
134
+ id: 'creatives.containersV2.EmailWrapper.preheaderInfoTooltip',
135
+ 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.',
136
+ },
137
+ addPreheader: {
138
+ id: 'creatives.containersV2.EmailWrapper.addPreheader',
139
+ defaultMessage: '+ Pre-header',
140
+ },
141
+ removePreheader: {
142
+ id: 'creatives.containersV2.EmailWrapper.removePreheader',
143
+ defaultMessage: 'Remove',
144
+ },
145
+ unsubscribeNotAllowedInPreheader: {
146
+ id: 'creatives.containersV2.EmailWrapper.unsubscribeNotAllowedInPreheader',
147
+ defaultMessage: 'Unsubscribe tag is not allowed in Pre-header.',
148
+ },
125
149
  editorType: {
126
150
  id: 'creatives.containersV2.EmailWrapper.editorType',
127
151
  defaultMessage: 'Editor type',
@@ -23,3 +23,4 @@ export const GET_IOS_CTAS_SUCCESS = 'app/v2Containers/PushMessage/Create/GET_IOS
23
23
  export const GET_IOS_CTAS_FAILURE = 'app/v2Containers/PushMessage/Create/GET_IOS_CTAS_FAILURE';
24
24
  export const RESET_STORE = 'app/v2Containers/PushMessage/Create/RESET_STORE';
25
25
  export const EXTERNAL_LINK_LOWERCASE = 'external link';
26
+ export const MOBILE_PUSH_PLATFORM_COUNT = 2;
@@ -81,7 +81,7 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
81
81
  if (typeof this.props.onContentValidityChange !== 'function') {
82
82
  return;
83
83
  }
84
- const validity = getMobilePushEmbeddedContentValidity(this.state?.formData, this.state?.tabCount);
84
+ const validity = getMobilePushEmbeddedContentValidity(this.state?.formData);
85
85
  const isContentEmpty = !!validity.isContentEmpty;
86
86
  if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
87
87
  this._lastReportedMobilePushContentEmpty = isContentEmpty;
@@ -187,7 +187,7 @@ export class Create extends React.Component { // eslint-disable-line react/prefe
187
187
  this.setState({showIosCtaTable: true});
188
188
  }
189
189
  if (nextProps.metaEntities && nextProps.metaEntities.layouts && nextProps.metaEntities.layouts.length > 0 && isEmpty(this.state.schema)) {
190
- const schema = this.props.params.mode === "text" ? nextProps.metaEntities.layouts[0].definition.textSchema : nextProps.metaEntities.layouts[0].definition.imageSchema;
190
+ const schema = cloneDeep(this.props.params.mode === "text" ? nextProps.metaEntities.layouts[0].definition.textSchema : nextProps.metaEntities.layouts[0].definition.imageSchema);
191
191
  const isAndroidSupported = get(this, "props.Templates.selectedWeChatAccount.configs.android") === '1';
192
192
  const isIosSupported = get(this, "props.Templates.selectedWeChatAccount.configs.ios") === '1';
193
193
  if (!isAndroidSupported) {
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import { shallow } from 'enzyme';
3
+ import { cloneDeep } from 'lodash';
3
4
  import { Create } from '../index';
5
+ import { response as mpushSchemaResponse } from '../../initialSchema';
4
6
 
5
7
  // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
8
  // only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
@@ -56,6 +58,25 @@ describe('MobilePush/Create — onContentValidityChange reporting', () => {
56
58
  );
57
59
  });
58
60
 
61
+ it('reports isContentEmpty: false when only iOS has content', () => {
62
+ const onContentValidityChange = jest.fn();
63
+ const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
64
+ wrapper.setState({
65
+ formData: { 0: { 'message-title': '', 'message-editor': '' }, 1: {} },
66
+ tabCount: 1,
67
+ });
68
+ onContentValidityChange.mockClear();
69
+
70
+ wrapper.setState({
71
+ formData: { 0: { 'message-title': '', 'message-editor': '' }, 1: { 'message-title2': 'test', 'message-editor2': 'test' } },
72
+ tabCount: 1,
73
+ });
74
+
75
+ expect(onContentValidityChange).toHaveBeenCalledWith(
76
+ expect.objectContaining({ isContentEmpty: false }),
77
+ );
78
+ });
79
+
59
80
  it('re-reports empty when a previously entered message is cleared out', () => {
60
81
  const onContentValidityChange = jest.fn();
61
82
  const wrapper = shallow(<Create {...baseProps()} onContentValidityChange={onContentValidityChange} />);
@@ -94,3 +115,40 @@ describe('MobilePush/Create — onContentValidityChange reporting', () => {
94
115
  expect(onContentValidityChange).not.toHaveBeenCalled();
95
116
  });
96
117
  });
118
+
119
+ describe('MobilePush/Create — shared layout schema', () => {
120
+ it('hides Android on its own schema copy, leaving the store layout untouched', () => {
121
+ const storeSchema = cloneDeep(mpushSchemaResponse.metaEntities[0].definition.textSchema);
122
+ const standaloneSectionCount = storeSchema.standalone.sections.length;
123
+ const wrapper = shallow(<Create
124
+ {...baseProps()}
125
+ params={{ mode: 'text' }}
126
+ Templates={{ selectedWeChatAccount: { id: 669, configs: { android: '0', ios: '1' } } }}
127
+ />);
128
+ wrapper.instance().injectEvents = jest.fn();
129
+ wrapper.setProps({ metaEntities: { layouts: [{ definition: { textSchema: storeSchema } }], tags: {} } });
130
+
131
+ const [ownSchema] = wrapper.instance().injectEvents.mock.calls[0];
132
+ expect(ownSchema).not.toBe(storeSchema);
133
+ expect(ownSchema.containers[0].panes[0].isSupported).toBe(false);
134
+ expect(wrapper.state('currentTab')).toBe(2);
135
+ expect(storeSchema.containers[0].panes[0].isSupported).toBe(true);
136
+ expect(storeSchema.standalone.sections).toHaveLength(standaloneSectionCount);
137
+ });
138
+
139
+ it('uses its own copy of the image schema in image mode', () => {
140
+ const storeSchema = cloneDeep(mpushSchemaResponse.metaEntities[0].definition.imageSchema);
141
+ const wrapper = shallow(<Create
142
+ {...baseProps()}
143
+ params={{ mode: 'image' }}
144
+ Templates={{ selectedWeChatAccount: { id: 703, configs: { android: '1', ios: '1' } } }}
145
+ />);
146
+ wrapper.instance().injectEvents = jest.fn();
147
+ wrapper.setProps({ metaEntities: { layouts: [{ definition: { imageSchema: storeSchema } }], tags: {} } });
148
+
149
+ const [ownSchema] = wrapper.instance().injectEvents.mock.calls[0];
150
+ expect(ownSchema).not.toBe(storeSchema);
151
+ expect(ownSchema.containers[0].panes[0].isSupported).not.toBe(false);
152
+ expect(ownSchema.containers[0].panes[1].isSupported).not.toBe(false);
153
+ });
154
+ });
@@ -136,7 +136,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
136
136
  if (typeof this.props.onContentValidityChange !== 'function') {
137
137
  return;
138
138
  }
139
- const validity = getMobilePushEmbeddedContentValidity(this.state.formData, this.state.tabCount);
139
+ const validity = getMobilePushEmbeddedContentValidity(this.state.formData);
140
140
  const isContentEmpty = !!validity.isContentEmpty;
141
141
  if (this._lastReportedMobilePushContentEmpty === isContentEmpty) return;
142
142
  this._lastReportedMobilePushContentEmpty = isContentEmpty;
@@ -242,7 +242,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
242
242
  this.props.actions.getMobilepushTemplatesList('mobilepush', params);
243
243
  }
244
244
  if (nextProps.metaEntities && nextProps.metaEntities.layouts && nextProps.metaEntities.layouts.length > 0 && _.isEmpty(this.state.fullSchema)) {
245
- const definition = nextProps.metaEntities.layouts[0].definition;
245
+ const definition = cloneDeep(nextProps.metaEntities.layouts[0].definition);
246
246
  const initialSchema = definition?.textSchema || definition?.imageSchema || {};
247
247
  this.setState({fullSchema: definition, schema: initialSchema}, () => {
248
248
  // Use this.props (latest) in callback to avoid race: templateDetails may have arrived by now
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import { shallow } from 'enzyme';
3
+ import { cloneDeep } from 'lodash';
3
4
  import { Edit } from '../index';
5
+ import { response as mpushSchemaResponse } from '../../initialSchema';
4
6
 
5
7
  // Shallow-render the plain class (bypassing the withCreatives/Redux/saga HOC) so this test
6
8
  // only exercises the componentDidUpdate → onContentValidityChange wiring in isolation.
@@ -130,3 +132,18 @@ describe('MobilePush/Edit — onContentValidityChange reporting', () => {
130
132
  expect(onContentValidityChange).not.toHaveBeenCalled();
131
133
  });
132
134
  });
135
+
136
+ describe('MobilePush/Edit — shared layout schema', () => {
137
+ it('keeps its own copy of the layout definition, leaving the store layout untouched', () => {
138
+ const storeDefinition = cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
139
+ const wrapper = shallow(<Edit {...baseProps()} />);
140
+ wrapper.instance().handleEditSchemaOnPropsChange = jest.fn();
141
+ wrapper.setProps({ metaEntities: { layouts: [{ definition: storeDefinition }], tags: {} } });
142
+
143
+ const ownDefinition = wrapper.state('fullSchema');
144
+ expect(ownDefinition).not.toBe(storeDefinition);
145
+ expect(ownDefinition).toEqual(storeDefinition);
146
+ ownDefinition.textSchema.containers[0].panes[0].isSupported = false;
147
+ expect(storeDefinition.textSchema.containers[0].panes[0].isSupported).not.toBe(false);
148
+ });
149
+ });
@@ -2,6 +2,7 @@ import {get, filter, cloneDeep} from 'lodash';
2
2
  import React from 'react';
3
3
  import {FormattedMessage} from 'react-intl';
4
4
  import messages from './Create/messages';
5
+ import { MOBILE_PUSH_PLATFORM_COUNT } from './Create/constants';
5
6
  import { extractContent } from '../../utils/commonUtils';
6
7
  function getPrimaryCtaFields({inputFields, fieldIndex, deepLinkOptions, tab}) {
7
8
  const currentTab = tab || this.state.currentTab;
@@ -308,17 +309,15 @@ function getMobilePushTabContent(tabIndex, tabData) {
308
309
  /**
309
310
  * Live "is content empty" check for the legacy Mobile Push Edit/Create forms — the
310
311
  * Mobile Push analogue of Sms's `getSmsEmbeddedFooterValidity` (see
311
- * app/v2Containers/Sms/smsFormDataHelpers.js). Checks every active tab (Android, and
312
- * iOS when tabCount > 1); Mobile Push allows saving with just one active platform
313
- * filled in, so the overall content is empty only when EVERY active tab's
314
- * title+message are both empty.
312
+ * app/v2Containers/Sms/smsFormDataHelpers.js). Checks both platform tabs (Android =
313
+ * formData[0], iOS = formData[1]); Mobile Push allows saving with just one platform
314
+ * filled in, so the overall content is empty only when BOTH tabs' title+message
315
+ * are empty.
315
316
  * @param {object} formData FormBuilder state (same shape as this.state.formData)
316
- * @param {number} [tabCount] Total number of active tabs (1 = Android only, 2 = Android + iOS)
317
317
  * @returns {{ isContentEmpty: boolean }}
318
318
  */
319
- function getMobilePushEmbeddedContentValidity(formData, tabCount) {
320
- const count = tabCount != null && tabCount > 1 ? tabCount : 1;
321
- for (let i = 0; i < count; i++) {
319
+ function getMobilePushEmbeddedContentValidity(formData) {
320
+ for (let i = 0; i < MOBILE_PUSH_PLATFORM_COUNT; i++) {
322
321
  const content = getMobilePushTabContent(i, formData?.[i]);
323
322
  const trimmed = content != null && content !== '' ? String(content).trim() : '';
324
323
  if (trimmed) {
@@ -312,8 +312,6 @@ export const Rcs = (props) => {
312
312
 
313
313
  // TestAndPreviewSlidebox state
314
314
  const [showTestAndPreviewSlidebox, setShowTestAndPreviewSlidebox] = useState(false);
315
- // Set when opened from listing "Preview and test"; closing then returns to the listing.
316
- const isTestAndPreviewFromListingRef = useRef(false);
317
315
 
318
316
  // Handle Test and Preview button click
319
317
  const handleTestAndPreview = useCallback(() => {
@@ -330,11 +328,7 @@ export const Rcs = (props) => {
330
328
  if (propsHandleCloseTestAndPreview) {
331
329
  propsHandleCloseTestAndPreview();
332
330
  }
333
- if (isTestAndPreviewFromListingRef.current && handleClose) {
334
- isTestAndPreviewFromListingRef.current = false;
335
- handleClose();
336
- }
337
- }, [propsHandleCloseTestAndPreview, handleClose]);
331
+ }, [propsHandleCloseTestAndPreview]);
338
332
 
339
333
  // Helper to get RCS orientation from selectedDimension
340
334
  const getRcsOrientation = () => {
@@ -1443,26 +1437,6 @@ export const Rcs = (props) => {
1443
1437
  }
1444
1438
  }, [rcsHydrationDetails, isFullMode, isHostInfoBip]);
1445
1439
 
1446
- // Listing "Preview and test": open Test & Preview once the template is hydrated.
1447
- const hasAutoOpenedTestAndPreviewRef = useRef(false);
1448
- useEffect(() => {
1449
- if (!isFullMode || !templateData?.openTestAndPreview || hasAutoOpenedTestAndPreviewRef.current) return;
1450
- if (spin || !isEditFlow || smsFallbackData === undefined) return;
1451
- if (!(isHostInfoBip || templateStatus === RCS_STATUSES.approved)) return;
1452
- hasAutoOpenedTestAndPreviewRef.current = true;
1453
- isTestAndPreviewFromListingRef.current = true;
1454
- handleTestAndPreview();
1455
- }, [
1456
- isFullMode,
1457
- templateData?.openTestAndPreview,
1458
- spin,
1459
- isEditFlow,
1460
- smsFallbackData,
1461
- isHostInfoBip,
1462
- templateStatus,
1463
- handleTestAndPreview,
1464
- ]);
1465
-
1466
1440
  useEffect(() => {
1467
1441
  if (templateType === contentType.text_message) {
1468
1442
  setTemplateMediaType(RCS_MEDIA_TYPES.NONE);
@@ -287,6 +287,10 @@ export class TagList extends React.Component { // eslint-disable-line react/pref
287
287
  //filtering BADGES_RELATED_TAGS if org does not have BADGES feature enabled
288
288
  excludedTags.push(...BADGES_RELATED_TAGS);
289
289
  }
290
+ if (this.props.excludeTags?.length) {
291
+ // Caller-specified exclusions (e.g. unsubscribe tag not allowed in the Pre-header field)
292
+ excludedTags.push(...this.props.excludeTags);
293
+ }
290
294
  //Form tags object with tag headers
291
295
  _.forEach(tagsList, (temp) => {
292
296
  const tag = temp.definition;
@@ -491,12 +495,15 @@ TagList.defaultProps = {
491
495
  userLocale: 'en',
492
496
  eventContextTags: [],
493
497
  waitEventContextTags: {},
498
+ excludeTags: [],
494
499
  };
495
500
 
496
501
  TagList.propTypes = {
497
502
  onTagSelect: PropTypes.func,
498
503
  tags: PropTypes.array,
499
504
  injectedTags: PropTypes.object,
505
+ // Tag values (e.g. 'unsubscribe') to hide from the Add Label dropdown for this instance
506
+ excludeTags: PropTypes.array,
500
507
  label: PropTypes.string,
501
508
  onContextChange: PropTypes.func,
502
509
  moduleFilterEnabled: PropTypes.bool,
@@ -105,6 +105,48 @@ describe("TagList test : UNIT", () => {
105
105
  expect(btn).toBeDisabled();
106
106
  });
107
107
 
108
+ it('hides a tag named in excludeTags from the Add Label dropdown, keeping other tags visible', () => {
109
+ const unsubscribeTag = {
110
+ _id: 'unsubscribe-tag-id',
111
+ type: 'TAG',
112
+ definition: {
113
+ label: { en: 'Unsubscribe' },
114
+ value: 'unsubscribe',
115
+ subtags: [],
116
+ 'tag-header': false,
117
+ supportedModules: [{ context: 'default', layout: 'email', mandatory: false }],
118
+ },
119
+ };
120
+ initializeTagList({
121
+ tags: [...TagListData.tags, unsubscribeTag],
122
+ excludeTags: ['unsubscribe'],
123
+ moduleFilterEnabled: false,
124
+ });
125
+ addLabelBtnAssertion();
126
+ expect(queryByText(/Unsubscribe/i)).not.toBeInTheDocument();
127
+ expect(getByText(/Optout/i)).toBeInTheDocument();
128
+ });
129
+
130
+ it('does not filter any tag when excludeTags is not provided (default behavior unaffected)', () => {
131
+ const unsubscribeTag = {
132
+ _id: 'unsubscribe-tag-id',
133
+ type: 'TAG',
134
+ definition: {
135
+ label: { en: 'Unsubscribe' },
136
+ value: 'unsubscribe',
137
+ subtags: [],
138
+ 'tag-header': false,
139
+ supportedModules: [{ context: 'default', layout: 'email', mandatory: false }],
140
+ },
141
+ };
142
+ initializeTagList({
143
+ tags: [...TagListData.tags, unsubscribeTag],
144
+ moduleFilterEnabled: false,
145
+ });
146
+ addLabelBtnAssertion();
147
+ expect(getByText(/Unsubscribe/i)).toBeInTheDocument();
148
+ });
149
+
108
150
  it('calls transformCouponTags when selectedOfferDetails and coupon tags are present', () => {
109
151
  const spy = jest.spyOn(TagList.prototype, 'transformCouponTags');
110
152
  initializeTagList({
@@ -253,6 +253,7 @@ const duplicateEnum = {
253
253
  line: "lineActions",
254
254
  [VIBER_CHANNEL]: "viberActions",
255
255
  [FACEBOOK_CHANNEL]: "facebookActions",
256
+ [RCS_LOWERCASE]: "rcsActions",
256
257
  };
257
258
  export class Templates extends React.Component { // eslint-disable-line react/prefer-stateless-function
258
259
  constructor(props) {
@@ -1788,9 +1789,6 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1788
1789
  }
1789
1790
  }
1790
1791
  creativesParams.type = this.state.channel.toUpperCase();
1791
- if (routeParams.openTestAndPreview) {
1792
- creativesParams.openTestAndPreview = true;
1793
- }
1794
1792
  }
1795
1793
  }
1796
1794
  }
@@ -2279,9 +2277,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
2279
2277
  onClick={() => {
2280
2278
  if ([ZALO, WHATSAPP, RCS].includes(this.state.channel.toUpperCase()) || (isTraiDltFeature && this.state.channel.toUpperCase() === SMS)) {
2281
2279
  handlers.handleEditClick(undefined, template, undefined, undefined, {
2282
- account: this.state.selectedAccount,
2283
- // Test panel fills unmapped variables for these; SMS DLT still needs Overview.
2284
- openTestAndPreview: [RCS, WHATSAPP, ZALO].includes(this.state.channel.toUpperCase()),
2280
+ account: this.state.selectedAccount
2285
2281
  })
2286
2282
  } else {
2287
2283
  this.handleTestAndPreviewFromListing(template)
@@ -2292,7 +2288,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
2292
2288
  </CapMenu.Item>
2293
2289
  )}
2294
2290
  {/* Duplicate menu item */}
2295
- {![WECHAT, WHATSAPP, ZALO, RCS].includes(
2291
+ {![WECHAT, WHATSAPP, ZALO].includes(
2296
2292
  this.state.channel.toUpperCase()
2297
2293
  ) &&
2298
2294
  !isTraiDltFeature && !template.isArchived && this.gatedMenuItem({
@@ -3703,7 +3699,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
3703
3699
  };
3704
3700
  this.getAllTemplates({params, resetPage: true});
3705
3701
  });
3706
- } else if ([SMS_LOWERCASE, LINE_LOWERCASE, VIBER_CHANNEL, FACEBOOK_CHANNEL].includes(this.state.channel.toLowerCase())) {
3702
+ } else if (["sms", "line", VIBER_CHANNEL, FACEBOOK_CHANNEL, RCS_LOWERCASE].includes(this.state.channel.toLowerCase())) {
3707
3703
  this.commonDuplicate(duplicateEnum[this.state.channel.toLowerCase()], duplicateObj);
3708
3704
  }
3709
3705
  }
@@ -3862,7 +3858,6 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
3862
3858
  this.setState({routeParams: {
3863
3859
  pathname: pathName,
3864
3860
  query: getQuery,
3865
- ...(options?.openTestAndPreview && { openTestAndPreview: true }),
3866
3861
  }});
3867
3862
  }
3868
3863
  }
@@ -5,7 +5,6 @@ import { Templates } from '../index';
5
5
  import mockdata from '../../mockdata';
6
6
  import { rcsTemplates, senderDetailsRcs } from './mockData';
7
7
  import { exportEmailTemplateHtml } from '../../../utils/downloadFile';
8
- import { PERMISSIONS } from '../../../utils/permissions';
9
8
 
10
9
  const { whatsappTemplates: TemplatesProp, getCreativesParamsOutput, zaloTemplates: ZaloTemplateProp } = mockdata;
11
10
 
@@ -28,41 +27,6 @@ jest.mock('../../CreativesContainer', () => ({
28
27
  ),
29
28
  }));
30
29
 
31
- // Recursively walks a plain-object/array/React-element tree (as returned by
32
- // getTemplateDataForGrid, which is never mounted here) looking for a node whose
33
- // className includes the given substring - robust against the exact nesting of
34
- // CapDropdown > CapMenu > CapMenu.Item without hardcoding array indices.
35
- function findByClassName(node, className, seen = new Set()) {
36
- if (!node || typeof node !== 'object') return null;
37
- if (seen.has(node)) return null;
38
- seen.add(node);
39
- if (Array.isArray(node)) {
40
- for (let i = 0; i < node.length; i += 1) {
41
- const found = findByClassName(node[i], className, seen);
42
- if (found) return found;
43
- }
44
- return null;
45
- }
46
- // React element (has .props) - check its className, then search its own prop values.
47
- if (node.props) {
48
- if (typeof node.props.className === 'string' && node.props.className.includes(className)) {
49
- return node;
50
- }
51
- return findByClassName(node.props, className, seen);
52
- }
53
- // Plain object (e.g. a getTemplateDataForGrid card-data entry: {key, title, extra, hoverOption, ...})
54
- // or a React element's .props object - search every own value.
55
- const values = Object.values(node);
56
- for (let i = 0; i < values.length; i += 1) {
57
- const value = values[i];
58
- if (value && typeof value === 'object') {
59
- const found = findByClassName(value, className, seen);
60
- if (found) return found;
61
- }
62
- }
63
- return null;
64
- }
65
-
66
30
  describe('Test Templates container', () => {
67
31
  const getWeCrmAccounts = jest.fn();
68
32
  const setChannelAccount = jest.fn();
@@ -307,6 +271,41 @@ describe('Test Templates container', () => {
307
271
  describe('Export HTML (EMAIL channel)', () => {
308
272
  const emailTemplate = { _id: 'email_1', name: 'My Email Template', versions: { base: {} } };
309
273
 
274
+ // Recursively walks a plain-object/array/React-element tree (as returned by
275
+ // getTemplateDataForGrid, which is never mounted here) looking for a node whose
276
+ // className includes the given substring - robust against the exact nesting of
277
+ // CapDropdown > CapMenu > CapMenu.Item without hardcoding array indices.
278
+ function findByClassName(node, className, seen = new Set()) {
279
+ if (!node || typeof node !== 'object') return null;
280
+ if (seen.has(node)) return null;
281
+ seen.add(node);
282
+ if (Array.isArray(node)) {
283
+ for (let i = 0; i < node.length; i += 1) {
284
+ const found = findByClassName(node[i], className, seen);
285
+ if (found) return found;
286
+ }
287
+ return null;
288
+ }
289
+ // React element (has .props) - check its className, then search its own prop values.
290
+ if (node.props) {
291
+ if (typeof node.props.className === 'string' && node.props.className.includes(className)) {
292
+ return node;
293
+ }
294
+ return findByClassName(node.props, className, seen);
295
+ }
296
+ // Plain object (e.g. a getTemplateDataForGrid card-data entry: {key, title, extra, hoverOption, ...})
297
+ // or a React element's .props object - search every own value.
298
+ const values = Object.values(node);
299
+ for (let i = 0; i < values.length; i += 1) {
300
+ const value = values[i];
301
+ if (value && typeof value === 'object') {
302
+ const found = findByClassName(value, className, seen);
303
+ if (found) return found;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
310
309
  beforeEach(() => {
311
310
  jest.clearAllMocks();
312
311
  // jest config sets resetMocks: true, wiping the default implementation baked into
@@ -427,75 +426,4 @@ describe('Test Templates container', () => {
427
426
  expect(findByClassName(cardDataList, 'export-html-email')).toBeNull();
428
427
  });
429
428
  });
430
-
431
- describe('RCS listing menu', () => {
432
- const approvedRcsTemplate = {
433
- _id: 'rcs_1',
434
- name: 'RCS Template',
435
- versions: { base: { content: { RCS: { rcsContent: { cardContent: [{ Status: 'approved' }] } } } } },
436
- };
437
- const handlers = { handlePreviewClick: jest.fn(), handleEditClick: jest.fn() };
438
- const getCardData = () => renderedComponent.instance().getTemplateDataForGrid({
439
- templates: [approvedRcsTemplate],
440
- handlers,
441
- filterContent: null,
442
- channel: 'rcs',
443
- isLoading: false,
444
- isInitialLoading: false,
445
- loadingTip: '',
446
- previewTemplateId: null,
447
- });
448
-
449
- beforeEach(() => {
450
- RenderFunctionFor('rcs', true);
451
- renderedComponent.setProps({
452
- isFullMode: true,
453
- isDltFromRcs: false,
454
- Global: { user: { accessiblePermissions: [PERMISSIONS.CREATIVE_EDIT] } },
455
- actions: { ...renderedComponent.props().actions, resetTemplateData: jest.fn() },
456
- });
457
- });
458
-
459
- it('does not render the Duplicate menu item for RCS', () => {
460
- expect(findByClassName(getCardData(), 'duplicate-rcs')).toBeNull();
461
- });
462
-
463
- it('"Preview and test" opens the editor with openTestAndPreview', () => {
464
- const menuItem = findByClassName(getCardData(), 'test-and-preview-rcs');
465
- expect(menuItem).not.toBeNull();
466
-
467
- menuItem.props.onClick();
468
- expect(handlers.handleEditClick).toHaveBeenCalledWith(
469
- undefined,
470
- approvedRcsTemplate,
471
- undefined,
472
- undefined,
473
- expect.objectContaining({ openTestAndPreview: true }),
474
- );
475
- });
476
-
477
- it('handleEditClick keeps openTestAndPreview in routeParams', () => {
478
- renderedComponent.instance().handleEditClick(undefined, approvedRcsTemplate, undefined, undefined, {
479
- openTestAndPreview: true,
480
- });
481
- expect(renderedComponent.state('routeParams')).toEqual(
482
- expect.objectContaining({ pathname: '/rcs/edit/rcs_1/', openTestAndPreview: true }),
483
- );
484
- });
485
-
486
- it('handleEditClick omits openTestAndPreview for Overview', () => {
487
- renderedComponent.instance().handleEditClick(undefined, approvedRcsTemplate);
488
- expect(renderedComponent.state('routeParams')).not.toHaveProperty('openTestAndPreview');
489
- });
490
-
491
- it('getCreativesParams forwards openTestAndPreview to the editor', () => {
492
- renderedComponent.setState({ routeParams: { pathname: '/rcs/edit/rcs_1/', openTestAndPreview: true } });
493
- expect(renderedComponent.instance().getCreativesParams()).toEqual(
494
- expect.objectContaining({ mode: 'edit', _id: 'rcs_1', type: 'RCS', openTestAndPreview: true }),
495
- );
496
-
497
- renderedComponent.setState({ routeParams: { pathname: '/rcs/edit/rcs_1/' } });
498
- expect(renderedComponent.instance().getCreativesParams()).not.toHaveProperty('openTestAndPreview');
499
- });
500
- });
501
429
  });
@@ -12,6 +12,11 @@
12
12
  margin-bottom: $CAP_SPACE_06;
13
13
  }
14
14
  .cap-custom-image-upload {
15
+ padding-top: $CAP_SPACE_20;
16
+ form {
17
+ padding: 0;
18
+ }
19
+
15
20
  .dragger-button.re-upload {
16
21
  top: -11rem;
17
22
  position: relative;
@@ -131,6 +136,32 @@
131
136
  margin-top: $CAP_SPACE_16;
132
137
  }
133
138
 
139
+ .viber-carousel-tab {
140
+ .ant-tabs-nav .ant-tabs-nav-wrap {
141
+ flex: none;
142
+ }
143
+
144
+ .ant-tabs-nav-list .ant-tabs-tab {
145
+ padding: $CAP_SPACE_08;
146
+ justify-content: center;
147
+ font-size: $FONT_SIZE_L;
148
+
149
+ + .ant-tabs-tab {
150
+ margin-left: 0;
151
+ }
152
+ }
153
+
154
+ .ant-tabs-extra-content {
155
+ display: flex;
156
+ align-items: center;
157
+
158
+ .ant-divider-vertical {
159
+ height: $CAP_SPACE_24;
160
+ margin: 0 $CAP_SPACE_12;
161
+ }
162
+ }
163
+ }
164
+
134
165
  .viber-carousel-card {
135
166
  border: 1px solid $CAP_G06;
136
167
  border-radius: $CAP_SPACE_04;