@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
@@ -12,7 +12,7 @@ import '@testing-library/jest-dom';
12
12
  import { IntlProvider } from 'react-intl';
13
13
  import EmailHTMLEditor from '../EmailHTMLEditor';
14
14
  import { validateLiquidTemplateContent } from '../../../../utils/commonUtils';
15
- import { validateTags } from '../../../../utils/tagValidations';
15
+ import { validateTags, hasUnsubscribeTag } from '../../../../utils/tagValidations';
16
16
  import { isEmailUnsubscribeTagOptional } from '../../../../utils/common';
17
17
  import { downloadHtmlFile } from '../../../../utils/downloadFile';
18
18
 
@@ -23,6 +23,7 @@ jest.mock('../../../../utils/commonUtils', () => ({
23
23
 
24
24
  jest.mock('../../../../utils/tagValidations', () => ({
25
25
  validateTags: jest.fn(),
26
+ hasUnsubscribeTag: jest.fn(() => false),
26
27
  }));
27
28
 
28
29
  jest.mock('../../../../utils/common', () => ({
@@ -217,11 +218,12 @@ jest.mock('../../../../v2Components/CapTagListWithInput', () => {
217
218
  React.useEffect(() => {
218
219
  setValue(props.inputValue || '');
219
220
  }, [props.inputValue]);
221
+ const isPreheader = props.inputId === 'template-preheader';
220
222
  return (
221
223
  <div data-testid="cap-tag-list-input">
222
224
  <input
223
- id="template-subject"
224
- data-testid="subject-input"
225
+ id={props.inputId || 'template-subject'}
226
+ data-testid={isPreheader ? 'preheader-input' : 'subject-input'}
225
227
  value={value}
226
228
  onChange={(e) => {
227
229
  setValue(e.target.value);
@@ -233,16 +235,35 @@ jest.mock('../../../../v2Components/CapTagListWithInput', () => {
233
235
  />
234
236
  <button
235
237
  onClick={() => props.onTagSelect && props.onTagSelect('customer.name')}
236
- data-testid="trigger-tag-select"
238
+ data-testid={isPreheader ? 'preheader-trigger-tag-select' : 'trigger-tag-select'}
237
239
  >
238
240
  Select Tag
239
241
  </button>
242
+ {isPreheader && (
243
+ <button
244
+ onClick={() => props.onTagSelect && props.onTagSelect('')}
245
+ data-testid="preheader-trigger-tag-select-empty"
246
+ >
247
+ Select Empty Tag
248
+ </button>
249
+ )}
240
250
  <button
241
251
  onClick={() => props.onContextChange && props.onContextChange('default')}
242
- data-testid="trigger-context-change"
252
+ data-testid={isPreheader ? 'preheader-trigger-context-change' : 'trigger-context-change'}
243
253
  >
244
254
  Change Context
245
255
  </button>
256
+ {props.extraContent}
257
+ {props.inputProps && props.inputProps.prefix}
258
+ {props.inputProps && props.inputProps.suffix}
259
+ <span data-testid={isPreheader ? 'preheader-exclude-tags' : 'subject-exclude-tags'}>
260
+ {JSON.stringify(props.excludeTags || [])}
261
+ </span>
262
+ {props.inputErrorMessage && (
263
+ <span data-testid={isPreheader ? 'preheader-error' : 'subject-error'}>
264
+ {props.inputErrorMessage}
265
+ </span>
266
+ )}
246
267
  </div>
247
268
  );
248
269
  };
@@ -407,6 +428,7 @@ describe('EmailHTMLEditor', () => {
407
428
  jest.clearAllMocks();
408
429
  validateLiquidTemplateContent.mockResolvedValue(true);
409
430
  validateTags.mockReturnValue({ valid: true });
431
+ hasUnsubscribeTag.mockReturnValue(false);
410
432
  isEmailUnsubscribeTagOptional.mockReturnValue(false);
411
433
  // Reset mock functions
412
434
  mockGetAllIssues.mockReturnValue([]);
@@ -754,6 +776,186 @@ describe('EmailHTMLEditor', () => {
754
776
  });
755
777
  });
756
778
 
779
+ describe('Pre-header Handling', () => {
780
+ const revealPreheader = () => {
781
+ fireEvent.click(screen.getByText('+ Pre-header'));
782
+ };
783
+
784
+ it('uses the updated placeholder text and 85-character limit', () => {
785
+ renderWithIntl();
786
+ revealPreheader();
787
+
788
+ expect(screen.getByTestId('preheader-input').placeholder).toBe('e.g. Get 20% off your next order');
789
+ expect(screen.getByText('0 / 85')).toBeInTheDocument();
790
+
791
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: 'Hello' } });
792
+ expect(screen.getByText('5 / 85')).toBeInTheDocument();
793
+ });
794
+
795
+ it('shows an info tooltip icon next to the Pre-header label', () => {
796
+ const { container } = renderWithIntl();
797
+ revealPreheader();
798
+
799
+ expect(container.querySelector('.cap-icon-v2-info-circle')).toBeInTheDocument();
800
+ });
801
+
802
+ it('inserts tag into Pre-header field at the cursor position', () => {
803
+ renderWithIntl();
804
+ revealPreheader();
805
+ const input = screen.getByTestId('preheader-input');
806
+ fireEvent.change(input, { target: { value: 'Hello ' } });
807
+ Object.defineProperty(input, 'selectionStart', { value: 6, writable: true });
808
+ Object.defineProperty(input, 'selectionEnd', { value: 6, writable: true });
809
+
810
+ fireEvent.click(screen.getByTestId('preheader-trigger-tag-select'));
811
+
812
+ expect(screen.getByTestId('preheader-input').value).toBe('Hello {{customer.name}}');
813
+ });
814
+
815
+ it('appends the tag when the Pre-header input has no selection', () => {
816
+ renderWithIntl();
817
+ revealPreheader();
818
+ const input = screen.getByTestId('preheader-input');
819
+ fireEvent.change(input, { target: { value: 'Hello' } });
820
+ Object.defineProperty(input, 'selectionStart', { value: undefined, writable: true });
821
+ Object.defineProperty(input, 'selectionEnd', { value: undefined, writable: true });
822
+
823
+ fireEvent.click(screen.getByTestId('preheader-trigger-tag-select'));
824
+
825
+ expect(screen.getByTestId('preheader-input').value).toBe('Hello{{customer.name}}');
826
+ });
827
+
828
+ it('appends the tag when the Pre-header input element is not found', () => {
829
+ renderWithIntl();
830
+ revealPreheader();
831
+ const input = screen.getByTestId('preheader-input');
832
+ fireEvent.change(input, { target: { value: 'Hello' } });
833
+ input.remove();
834
+
835
+ fireEvent.click(screen.getByTestId('preheader-trigger-tag-select'));
836
+ // Should handle gracefully without throwing
837
+ });
838
+
839
+ it('falls back to a safe append when reading the Pre-header input throws', () => {
840
+ renderWithIntl();
841
+ revealPreheader();
842
+ const input = screen.getByTestId('preheader-input');
843
+ fireEvent.change(input, { target: { value: 'Hello' } });
844
+ Object.defineProperty(input, 'selectionStart', {
845
+ configurable: true,
846
+ get() { throw new Error('boom'); },
847
+ });
848
+
849
+ expect(() => fireEvent.click(screen.getByTestId('preheader-trigger-tag-select'))).not.toThrow();
850
+ expect(screen.getByTestId('preheader-input').value).toBe('Hello{{customer.name}}');
851
+ });
852
+
853
+ it('does nothing when the Pre-header tag select fires without data', () => {
854
+ renderWithIntl();
855
+ revealPreheader();
856
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: 'Hello' } });
857
+
858
+ fireEvent.click(screen.getByTestId('preheader-trigger-tag-select-empty'));
859
+
860
+ expect(screen.getByTestId('preheader-input').value).toBe('Hello');
861
+ });
862
+
863
+ it('inserts a tag into an empty Pre-header field', () => {
864
+ renderWithIntl();
865
+ revealPreheader();
866
+
867
+ fireEvent.click(screen.getByTestId('preheader-trigger-tag-select'));
868
+
869
+ expect(screen.getByTestId('preheader-input').value).toBe('{{customer.name}}');
870
+ });
871
+
872
+ it('handles the Pre-header context-change trigger', () => {
873
+ renderWithIntl();
874
+ revealPreheader();
875
+
876
+ fireEvent.click(screen.getByTestId('preheader-trigger-context-change'));
877
+ // Exercises handleOnTagsContextChange for the Pre-header field
878
+ });
879
+
880
+ it('clears a stale Pre-header error once the user edits the field again', () => {
881
+ hasUnsubscribeTag.mockReturnValue(true);
882
+ const { rerender } = renderWithIntl({ isGetFormData: false });
883
+ fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
884
+ revealPreheader();
885
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: '{{unsubscribe}}' } });
886
+
887
+ rerender(
888
+ <IntlProvider locale="en" messages={{}}>
889
+ <EmailHTMLEditor {...defaultProps} isGetFormData />
890
+ </IntlProvider>
891
+ );
892
+ expect(screen.getByTestId('preheader-error')).toBeInTheDocument();
893
+
894
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: '{{unsubscribe}} extra' } });
895
+
896
+ expect(screen.queryByTestId('preheader-error')).not.toBeInTheDocument();
897
+ });
898
+
899
+ it('blocks save for an invalid Pre-header even when onValidationFail is not provided', () => {
900
+ hasUnsubscribeTag.mockReturnValue(true);
901
+ const emailActions = {
902
+ ...defaultProps.emailActions,
903
+ createTemplate: jest.fn((obj, callback) => callback({ templateId: { _id: '123', versions: {} } })),
904
+ };
905
+ const props = { ...defaultProps, emailActions };
906
+ delete props.onValidationFail;
907
+ const { rerender } = render(
908
+ <IntlProvider locale="en" messages={{}}>
909
+ <EmailHTMLEditor {...props} isGetFormData={false} />
910
+ </IntlProvider>
911
+ );
912
+ fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
913
+ revealPreheader();
914
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: '{{unsubscribe}}' } });
915
+
916
+ expect(() => rerender(
917
+ <IntlProvider locale="en" messages={{}}>
918
+ <EmailHTMLEditor {...props} isGetFormData />
919
+ </IntlProvider>
920
+ )).not.toThrow();
921
+
922
+ expect(screen.getByTestId('preheader-error')).toBeInTheDocument();
923
+ expect(emailActions.createTemplate).not.toHaveBeenCalled();
924
+ });
925
+
926
+ it('removes the Pre-header field, clearing its value and error, when Remove is clicked', () => {
927
+ hasUnsubscribeTag.mockReturnValue(true);
928
+ const { rerender } = renderWithIntl({ isGetFormData: false });
929
+ fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
930
+ revealPreheader();
931
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: '{{unsubscribe}}' } });
932
+ rerender(
933
+ <IntlProvider locale="en" messages={{}}>
934
+ <EmailHTMLEditor {...defaultProps} isGetFormData />
935
+ </IntlProvider>
936
+ );
937
+ expect(screen.getByTestId('preheader-error')).toBeInTheDocument();
938
+
939
+ fireEvent.click(screen.getByText('Remove'));
940
+
941
+ expect(screen.queryByTestId('preheader-input')).not.toBeInTheDocument();
942
+ expect(screen.queryByTestId('preheader-error')).not.toBeInTheDocument();
943
+ expect(screen.getByText('+ Pre-header')).toBeInTheDocument();
944
+ });
945
+
946
+ it('defaults injectedTags to {} for the Pre-header tag list when the prop is falsy', () => {
947
+ // null (not undefined) is required here: a static defaultProps entry only kicks in for
948
+ // undefined, so deleting the prop would never actually exercise the `|| {}` fallback.
949
+ expect(() => render(
950
+ <IntlProvider locale="en" messages={{}}>
951
+ <EmailHTMLEditor {...defaultProps} injectedTags={null} />
952
+ </IntlProvider>
953
+ )).not.toThrow();
954
+ fireEvent.click(screen.getByText('+ Pre-header'));
955
+ expect(screen.getByTestId('preheader-input')).toBeInTheDocument();
956
+ });
957
+ });
958
+
757
959
  describe('Content Change Handling', () => {
758
960
  it('updates content when HTMLEditor changes', () => {
759
961
  renderWithIntl();
@@ -899,6 +1101,63 @@ describe('EmailHTMLEditor', () => {
899
1101
  }, { timeout: 3000 });
900
1102
  });
901
1103
 
1104
+ it('hides the unsubscribe tag from the Pre-header Add Label dropdown but not Subject\'s', () => {
1105
+ renderWithIntl();
1106
+ fireEvent.click(screen.getByText('+ Pre-header'));
1107
+ expect(screen.getByTestId('preheader-exclude-tags')).toHaveTextContent(JSON.stringify(['unsubscribe']));
1108
+ expect(screen.getByTestId('subject-exclude-tags')).toHaveTextContent(JSON.stringify([]));
1109
+ });
1110
+
1111
+ it('blocks save and shows an inline error when Pre-header contains the unsubscribe tag', async () => {
1112
+ const onValidationFail = jest.fn();
1113
+ hasUnsubscribeTag.mockReturnValue(true);
1114
+ const { rerender } = renderWithIntl({ onValidationFail, isGetFormData: false });
1115
+ fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
1116
+ fireEvent.click(screen.getByText('+ Pre-header'));
1117
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: '{{unsubscribe}}' } });
1118
+
1119
+ rerender(
1120
+ <IntlProvider locale="en" messages={{}}>
1121
+ <EmailHTMLEditor {...defaultProps} onValidationFail={onValidationFail} isGetFormData />
1122
+ </IntlProvider>
1123
+ );
1124
+
1125
+ await waitFor(() => {
1126
+ expect(onValidationFail).toHaveBeenCalled();
1127
+ }, { timeout: 3000 });
1128
+ expect(defaultProps.emailActions.createTemplate).not.toHaveBeenCalled();
1129
+ expect(screen.getByTestId('preheader-error')).toHaveTextContent('Unsubscribe tag is not allowed in Pre-header.');
1130
+ });
1131
+
1132
+ it('allows save when Pre-header does not contain the unsubscribe tag', async () => {
1133
+ hasUnsubscribeTag.mockReturnValue(false);
1134
+ const emailActions = {
1135
+ ...defaultProps.emailActions,
1136
+ transformEmailTemplate: jest.fn((obj, callback) => callback(obj)),
1137
+ createTemplate: jest.fn((obj, callback) => {
1138
+ callback({ templateId: { _id: '123', versions: {} } });
1139
+ }),
1140
+ };
1141
+ const { rerender } = renderWithIntl({
1142
+ isGetFormData: false, templateName: 'New Template', emailActions, getLiquidTags: null,
1143
+ });
1144
+ fireEvent.change(screen.getByTestId('subject-input'), { target: { value: 'Valid Subject' } });
1145
+ fireEvent.click(screen.getByText('+ Pre-header'));
1146
+ fireEvent.change(screen.getByTestId('preheader-input'), { target: { value: 'A normal preheader' } });
1147
+ fireEvent.click(screen.getByTestId('trigger-content-change'));
1148
+
1149
+ rerender(
1150
+ <IntlProvider locale="en" messages={{}}>
1151
+ <EmailHTMLEditor {...defaultProps} isGetFormData templateName="New Template" emailActions={emailActions} getLiquidTags={null} />
1152
+ </IntlProvider>
1153
+ );
1154
+
1155
+ await waitFor(() => {
1156
+ expect(emailActions.createTemplate).toHaveBeenCalled();
1157
+ }, { timeout: 3000 });
1158
+ expect(screen.queryByTestId('preheader-error')).not.toBeInTheDocument();
1159
+ });
1160
+
902
1161
  it('blocks save when there are HTML validation errors', async () => {
903
1162
  const onValidationFail = jest.fn();
904
1163
  // Mock getAllIssues to return BLOCKING errors (sanitizer errors or client-side Liquid errors)
@@ -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) {
@@ -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({