@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.
- package/package.json +1 -1
- package/v2Components/CapTagListWithInput/__tests__/CapTagListWithInput.test.js +58 -1
- package/v2Components/CapTagListWithInput/index.js +64 -35
- package/v2Components/CapTagListWithInput/index.scss +9 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/EmailPreviewContent.js +8 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +7 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +1 -0
- package/v2Components/CommonTestAndPreview/actions.js +1 -1
- package/v2Components/CommonTestAndPreview/index.js +29 -13
- package/v2Components/CommonTestAndPreview/sagas.js +2 -1
- package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/EmailPreviewContent.test.js +27 -0
- package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +40 -0
- package/v2Components/CommonTestAndPreview/tests/index.test.js +95 -0
- package/v2Components/CommonTestAndPreview/tests/sagas.test.js +24 -0
- package/v2Components/EmailMobilePreview/index.js +8 -1
- package/v2Components/EmailMobilePreview/index.scss +12 -3
- package/v2Components/EmailMobilePreview/tests/index.test.js +31 -0
- package/v2Components/EmailPreviewV2/_emailPreviewV2.scss +37 -5
- package/v2Components/EmailPreviewV2/index.js +18 -6
- package/v2Components/EmailPreviewV2/messages.js +4 -0
- package/v2Components/EmailPreviewV2/tests/index.test.js +43 -0
- package/v2Components/FormBuilder/Classic.js +134 -14
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +9 -0
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +11 -12
- package/v2Components/FormBuilder/Functional/tests/mpush.singlePlatform.test.js +144 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +17 -5
- package/v2Components/FormBuilder/_formBuilder.scss +21 -0
- package/v2Components/FormBuilder/messages.js +12 -0
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +45 -0
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +2 -1
- package/v2Containers/CommunicationFlow/Tests/constants.test.js +4 -4
- package/v2Containers/CommunicationFlow/constants.js +1 -0
- package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +2 -1
- package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +1 -0
- package/v2Containers/CreativesContainer/index.js +7 -1
- package/v2Containers/CreativesContainer/tests/index.test.js +81 -0
- package/v2Containers/Email/index.js +31 -4
- package/v2Containers/Email/initialSchema.js +30 -0
- package/v2Containers/Email/messages.js +8 -0
- package/v2Containers/Email/tests/index.test.js +107 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +172 -8
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +264 -5
- package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +29 -0
- package/v2Containers/EmailWrapper/constants.js +5 -0
- package/v2Containers/EmailWrapper/messages.js +24 -0
- package/v2Containers/MobilePush/Create/constants.js +1 -0
- package/v2Containers/MobilePush/Create/index.js +2 -2
- package/v2Containers/MobilePush/Create/test/contentValidity.test.js +58 -0
- package/v2Containers/MobilePush/Edit/index.js +2 -2
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +17 -0
- package/v2Containers/MobilePush/commonMethods.js +7 -8
- package/v2Containers/TagList/index.js +7 -0
- 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=
|
|
224
|
-
data-testid=
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
312
|
-
* iOS
|
|
313
|
-
* filled in, so the overall content is empty only when
|
|
314
|
-
*
|
|
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
|
|
320
|
-
|
|
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({
|