@capillarytech/creatives-library 9.0.61-alpha.3 → 9.0.62
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/constants/unified.js +8 -2
- package/package.json +1 -1
- package/services/api.js +5 -0
- package/services/tests/api.test.js +44 -0
- package/utils/common.js +8 -0
- package/utils/downloadFile.js +57 -0
- package/utils/tests/downloadFile.test.js +219 -0
- package/v2Components/FormBuilder/Classic.js +43 -9
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +46 -8
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
- package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
- package/v2Components/FormBuilder/_formBuilder.scss +11 -0
- package/v2Components/FormBuilder/index.js +27 -17
- package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
- package/v2Containers/BeeEditor/index.js +8 -0
- package/v2Containers/Email/index.js +118 -16
- package/v2Containers/Email/initialSchema.js +25 -3
- package/v2Containers/Email/messages.js +16 -0
- package/v2Containers/Email/tests/index.test.js +597 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
- package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
- package/v2Containers/Templates/index.js +53 -0
- package/v2Containers/Templates/messages.js +8 -0
- package/v2Containers/Templates/tests/index.test.js +170 -0
- package/v2Containers/Viber/constants.js +0 -3
- package/v2Containers/Viber/sagas.js +3 -1
- package/v2Containers/Viber/tests/saga.test.js +21 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPUSH CTA flows parity — the REAL (unconnected) MobilePush Create container
|
|
3
|
+
* over the REAL gated FormBuilder, driving the CTA surface end-to-end through
|
|
4
|
+
* the DOM the way a user does:
|
|
5
|
+
*
|
|
6
|
+
* fill both platforms -> "Add action link to content" (add-pri-cta)
|
|
7
|
+
* -> deeplink option with/without params | External Link radio switch
|
|
8
|
+
* -> press Create (isGetFormData transition, full mode)
|
|
9
|
+
*
|
|
10
|
+
* Pins manual-QA fix #5: switching the link-type radio Deeplink <-> External
|
|
11
|
+
* Link REPLACES the `cta-deeplink-select` schema row with `cta-deeplink-text`.
|
|
12
|
+
* Classic's initialiseForm PRUNES formData keys that left the schema walk
|
|
13
|
+
* (Classic.js:1889-1897); before reconcileFormState ported that prune, the
|
|
14
|
+
* orphaned seeded-'' `cta-deeplink-select` failed validateMobilePush's empty
|
|
15
|
+
* rule and the container toasted "Validation error / Validation failed for
|
|
16
|
+
* android" on a form Classic saves.
|
|
17
|
+
*
|
|
18
|
+
* Outcome contract per flow: NO validation stop (onValidationFail), and the
|
|
19
|
+
* save proceeds — createTemplate fires, or the single-platform confirm modal
|
|
20
|
+
* opens (a legitimate Classic outcome when the iOS tab stayed empty).
|
|
21
|
+
*/
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import '@testing-library/jest-dom';
|
|
24
|
+
import _ from 'lodash';
|
|
25
|
+
import { notification } from 'antd';
|
|
26
|
+
import { Router } from 'react-router-dom';
|
|
27
|
+
import { render, act, fireEvent } from '../../../../utils/test-utils';
|
|
28
|
+
import history from '../../../../utils/history';
|
|
29
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
30
|
+
import { Create } from '../../../../v2Containers/MobilePush/Create';
|
|
31
|
+
|
|
32
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
33
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
34
|
+
}));
|
|
35
|
+
jest.mock('../../../../services/api', () => ({
|
|
36
|
+
...jest.requireActual('../../../../services/api'),
|
|
37
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
41
|
+
let mockMpushFlag = false;
|
|
42
|
+
jest.mock('../../../../utils/common', () => ({
|
|
43
|
+
...jest.requireActual('../../../../utils/common'),
|
|
44
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
48
|
+
|
|
49
|
+
const intl = {
|
|
50
|
+
formatMessage: (descriptor) => (descriptor && (descriptor.defaultMessage || descriptor.id)) || '',
|
|
51
|
+
locale: 'en',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DEEPLINKS = JSON.stringify([
|
|
55
|
+
{ name: 'Home Page', link: 'app://home', keys: ['user_id'] },
|
|
56
|
+
{ name: 'Offers Page', link: 'app://offers', keys: [] },
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const selectedAccount = {
|
|
60
|
+
id: 'acc-1',
|
|
61
|
+
name: 'Acc',
|
|
62
|
+
sourceTypeName: 'SOME_SDK',
|
|
63
|
+
sourceAccountIdentifier: 'lic-1',
|
|
64
|
+
configs: { android: '1', ios: '1', deeplink: DEEPLINKS },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const buildProps = (overrides = {}) => ({
|
|
68
|
+
intl,
|
|
69
|
+
params: { mode: 'text' },
|
|
70
|
+
route: { name: 'create' },
|
|
71
|
+
location: { pathname: '/mobilepush/create/text', query: { module: 'default' } },
|
|
72
|
+
router: { push: jest.fn() },
|
|
73
|
+
isFullMode: true,
|
|
74
|
+
isGetFormData: false,
|
|
75
|
+
isLoadingMetaEntities: false,
|
|
76
|
+
metaEntities: {},
|
|
77
|
+
Create: { createTemplateInProgress: false },
|
|
78
|
+
Edit: {},
|
|
79
|
+
Templates: { selectedWeChatAccount: selectedAccount },
|
|
80
|
+
// Any missing action becomes a jest.fn on demand (the container calls several).
|
|
81
|
+
actions: new Proxy({}, { get: (target, name) => {
|
|
82
|
+
if (!target[name]) target[name] = jest.fn(); // eslint-disable-line no-param-reassign
|
|
83
|
+
return target[name];
|
|
84
|
+
} }),
|
|
85
|
+
globalActions: {
|
|
86
|
+
fetchSchemaForEntity: jest.fn(),
|
|
87
|
+
addMessageToQueue: jest.fn(),
|
|
88
|
+
setInjectedTags: jest.fn(),
|
|
89
|
+
},
|
|
90
|
+
showTemplateName: jest.fn(),
|
|
91
|
+
getFormLibraryData: jest.fn(),
|
|
92
|
+
onValidationFail: jest.fn(),
|
|
93
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
94
|
+
onPersonalizationTokensChange: jest.fn(),
|
|
95
|
+
injectedTags: {},
|
|
96
|
+
defaultData: { 'template-name': 'cta_test_1' },
|
|
97
|
+
...overrides,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const setNativeValue = (el, value) => {
|
|
101
|
+
const proto = Object.getPrototypeOf(el);
|
|
102
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
103
|
+
setter.call(el, value);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const typeInto = (id, value) => {
|
|
107
|
+
const el = document.getElementById(id);
|
|
108
|
+
if (!el) throw new Error(`no #${id} in DOM`);
|
|
109
|
+
setNativeValue(el, value);
|
|
110
|
+
fireEvent.change(el, { target: { value } });
|
|
111
|
+
fireEvent.blur(el);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const runCreateFlow = ({
|
|
115
|
+
optionLabel = 'Offers Page', fillCustomValue = false, useCopy = false, iosCta = false,
|
|
116
|
+
externalLink = false,
|
|
117
|
+
} = {}) => {
|
|
118
|
+
const props = buildProps();
|
|
119
|
+
let containerRef = null;
|
|
120
|
+
const ui = (p) => (
|
|
121
|
+
<Router history={history}>
|
|
122
|
+
<Create ref={(r) => { if (r) containerRef = r; }} {...p} />
|
|
123
|
+
</Router>
|
|
124
|
+
);
|
|
125
|
+
const utils = render(ui(props));
|
|
126
|
+
const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
|
|
127
|
+
act(() => {
|
|
128
|
+
utils.rerender(ui({ ...props, metaEntities: meta }));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// 1. fill mandatory fields (template-name comes via defaultData in full mode).
|
|
132
|
+
act(() => { typeInto('message-title', 'Hello title'); });
|
|
133
|
+
act(() => { typeInto('message-editor', 'Hello body'); });
|
|
134
|
+
const tabHeaders = () => Array.from(document.querySelectorAll('.ant-tabs-tab'));
|
|
135
|
+
if (useCopy) {
|
|
136
|
+
// the user's habitual step: switch to iOS, "copy content from Android"
|
|
137
|
+
act(() => { fireEvent.click(tabHeaders()[1]); });
|
|
138
|
+
const copyLink = document.getElementById('copy-android-content2')
|
|
139
|
+
|| document.getElementById('copy-android-content');
|
|
140
|
+
if (!copyLink) throw new Error('no copy-android-content link found');
|
|
141
|
+
act(() => { fireEvent.click(copyLink); });
|
|
142
|
+
act(() => { fireEvent.click(tabHeaders()[0]); });
|
|
143
|
+
} else {
|
|
144
|
+
act(() => { fireEvent.click(tabHeaders()[1]); }); // switch to iOS
|
|
145
|
+
act(() => { typeInto('message-title2', 'Hello ios title'); });
|
|
146
|
+
act(() => { typeInto('message-editor2', 'Hello ios body'); });
|
|
147
|
+
act(() => { fireEvent.click(tabHeaders()[0]); }); // back to Android
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (iosCta) {
|
|
151
|
+
// run the whole CTA flow on the iOS pane instead (suffixed ids)
|
|
152
|
+
act(() => { fireEvent.click(tabHeaders()[1]); });
|
|
153
|
+
}
|
|
154
|
+
const ctaCheckboxId = iosCta ? 'add-pri-cta-ios' : 'add-pri-cta';
|
|
155
|
+
const selectId = iosCta ? 'cta-deeplink2-select' : 'cta-deeplink-select';
|
|
156
|
+
const customValueId = iosCta
|
|
157
|
+
? 'custom-value-user_id-cta-deeplink2-select'
|
|
158
|
+
: 'custom-value-user_id-cta-deeplink-select';
|
|
159
|
+
|
|
160
|
+
// 2. click "Add action link to content" — the checkbox carries no DOM id in
|
|
161
|
+
// either flow, so find it by label text inside the right platform pane.
|
|
162
|
+
const pane = document.querySelectorAll('.ant-tabs-tabpane')[iosCta ? 1 : 0] || document;
|
|
163
|
+
const label = Array.from(pane.querySelectorAll('label'))
|
|
164
|
+
.find((l) => /action link/i.test(l.textContent || ''));
|
|
165
|
+
const cb = (label && label.querySelector('input[type="checkbox"]'))
|
|
166
|
+
|| pane.querySelector('input[type="checkbox"]');
|
|
167
|
+
if (!cb) throw new Error(`no ${ctaCheckboxId} checkbox found`);
|
|
168
|
+
act(() => { fireEvent.click(cb); });
|
|
169
|
+
|
|
170
|
+
// the deeplink dropdown must have been spliced in
|
|
171
|
+
let selectHost = document.getElementById(selectId);
|
|
172
|
+
const info = { selectRendered: Boolean(selectHost) };
|
|
173
|
+
|
|
174
|
+
if (externalLink) {
|
|
175
|
+
// switch the link-type radio to "External Link": the dropdown row must be
|
|
176
|
+
// REPLACED by a text input (container onLinkTypeChange splice)
|
|
177
|
+
const radios = Array.from(pane.querySelectorAll('input[type="radio"]'));
|
|
178
|
+
const external = radios.find((rd) => rd.value === 'External Link');
|
|
179
|
+
if (!external) throw new Error(`no External Link radio (found ${radios.map((rd) => rd.value)})`);
|
|
180
|
+
act(() => { fireEvent.click(external); });
|
|
181
|
+
selectHost = document.getElementById(selectId);
|
|
182
|
+
info.selectStillRendered = Boolean(selectHost);
|
|
183
|
+
const textId = iosCta ? 'cta-deeplink2-text' : 'cta-deeplink-text';
|
|
184
|
+
info.externalInputRendered = Boolean(document.getElementById(textId));
|
|
185
|
+
if (info.externalInputRendered) {
|
|
186
|
+
act(() => { typeInto(textId, 'https://example.com/offer'); });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 3. open the CapSelect and choose the requested option
|
|
191
|
+
if (selectHost && !externalLink) {
|
|
192
|
+
const trigger = selectHost.querySelector('.ant-select-selector')
|
|
193
|
+
|| selectHost.closest('.ant-select')?.querySelector('.ant-select-selector')
|
|
194
|
+
|| selectHost;
|
|
195
|
+
act(() => { fireEvent.mouseDown(trigger); });
|
|
196
|
+
const options = Array.from(document.querySelectorAll('.ant-select-item-option'));
|
|
197
|
+
const wanted = options.find((o) => o.textContent.includes(optionLabel));
|
|
198
|
+
if (wanted) act(() => { fireEvent.click(wanted); });
|
|
199
|
+
info.pickedOption = Boolean(wanted);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For a deeplink with params, the containers splice in a custom-value input.
|
|
203
|
+
const customInput = document.getElementById(customValueId);
|
|
204
|
+
info.customInputRendered = Boolean(customInput);
|
|
205
|
+
if (customInput && fillCustomValue) {
|
|
206
|
+
act(() => { typeInto(customValueId, '42'); });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 4. press Create: isGetFormData transition in full mode -> startValidation
|
|
210
|
+
act(() => {
|
|
211
|
+
utils.rerender(ui({ ...props, metaEntities: meta, isGetFormData: true }));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const result = {
|
|
215
|
+
...info,
|
|
216
|
+
createCalled: props.actions.createTemplate.mock.calls.length,
|
|
217
|
+
validationFailCalled: props.onValidationFail.mock.calls.length,
|
|
218
|
+
modalShown: containerRef ? containerRef.state.showModal : null,
|
|
219
|
+
errorTab0: containerRef
|
|
220
|
+
? Object.entries(containerRef.state.errorData?.[0] || {}).filter(([, v]) => v === true).map(([k]) => k)
|
|
221
|
+
: null,
|
|
222
|
+
};
|
|
223
|
+
utils.unmount();
|
|
224
|
+
return result;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Success = the save flow proceeds: either createTemplate fired, or the
|
|
228
|
+
// single-platform confirm modal opened. A validation STOP (onValidationFail)
|
|
229
|
+
// is the bug class this suite pins.
|
|
230
|
+
const expectSaveProceeds = (r) => {
|
|
231
|
+
expect(r.validationFailCalled).toBe(0);
|
|
232
|
+
expect(r.createCalled + (r.modalShown ? 1 : 0)).toBeGreaterThanOrEqual(1);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const expectDeeplinkPicked = (r) => {
|
|
236
|
+
expect(r.selectRendered).toBe(true);
|
|
237
|
+
expect(r.pickedOption).toBe(true);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
describe.each([
|
|
241
|
+
['Classic', 'false'],
|
|
242
|
+
['Functional', 'true'],
|
|
243
|
+
])('CTA flows parity — %s', (flow, flagValue) => {
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
mockMpushFlag = flagValue === 'true';
|
|
246
|
+
});
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
mockMpushFlag = false;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('deeplink option WITHOUT params: no extra input, save proceeds', () => {
|
|
252
|
+
const r = runCreateFlow({ optionLabel: 'Offers Page' });
|
|
253
|
+
expectDeeplinkPicked(r);
|
|
254
|
+
expect(r.customInputRendered).toBe(false);
|
|
255
|
+
expectSaveProceeds(r);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('deeplink option WITH params: custom-value input renders, save proceeds', () => {
|
|
259
|
+
const r = runCreateFlow({ optionLabel: 'Home Page', fillCustomValue: true });
|
|
260
|
+
expectDeeplinkPicked(r);
|
|
261
|
+
expect(r.customInputRendered).toBe(true);
|
|
262
|
+
expectSaveProceeds(r);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('copy-content flow + CTA: save proceeds', () => {
|
|
266
|
+
const r = runCreateFlow({ optionLabel: 'Offers Page', useCopy: true });
|
|
267
|
+
expectDeeplinkPicked(r);
|
|
268
|
+
expectSaveProceeds(r);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('External Link radio: dropdown replaced by text input, orphaned select key does not fail validation', () => {
|
|
272
|
+
const r = runCreateFlow({ externalLink: true });
|
|
273
|
+
expect(r.selectRendered).toBe(true);
|
|
274
|
+
expect(r.selectStillRendered).toBe(false);
|
|
275
|
+
expect(r.externalInputRendered).toBe(true);
|
|
276
|
+
expectSaveProceeds(r);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// iOS-pane CTA runs only on the Functional flow: Classic's tab switching scans
|
|
280
|
+
// the real DOM (Classic.js:2526-2551), which a jsdom tab-header click does not
|
|
281
|
+
// drive, so its container-side splice targets the wrong pane in this harness
|
|
282
|
+
// (artifact, not a product behavior).
|
|
283
|
+
if (flow === 'Functional') {
|
|
284
|
+
it('iOS-pane CTA (suffixed ids): save proceeds', () => {
|
|
285
|
+
const r = runCreateFlow({ optionLabel: 'Offers Page', iosCta: true });
|
|
286
|
+
expectDeeplinkPicked(r);
|
|
287
|
+
expectSaveProceeds(r);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Copy with EMPTY source fields must warn ("Cannot copy empty field values."):
|
|
292
|
+
// the container handler reads its own formData copy (Create/index.js:1456-1465),
|
|
293
|
+
// seeded by the builder's init onChange push (Classic.js:1906-1911 -> 740-748).
|
|
294
|
+
it('copy from Android with empty fields shows the cannot-copy warning', () => {
|
|
295
|
+
const warnSpy = jest.spyOn(notification, 'warning').mockImplementation(() => {});
|
|
296
|
+
try {
|
|
297
|
+
const props = buildProps();
|
|
298
|
+
const ui = (p) => (
|
|
299
|
+
<Router history={history}>
|
|
300
|
+
<Create {...p} />
|
|
301
|
+
</Router>
|
|
302
|
+
);
|
|
303
|
+
const utils = render(ui(props));
|
|
304
|
+
const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
|
|
305
|
+
act(() => {
|
|
306
|
+
utils.rerender(ui({ ...props, metaEntities: meta }));
|
|
307
|
+
});
|
|
308
|
+
act(() => { fireEvent.click(document.querySelectorAll('.ant-tabs-tab')[1]); });
|
|
309
|
+
const copyLink = document.getElementById('copy-android-content2')
|
|
310
|
+
|| document.getElementById('copy-android-content');
|
|
311
|
+
if (!copyLink) throw new Error('no copy-android-content link found');
|
|
312
|
+
act(() => { fireEvent.click(copyLink); });
|
|
313
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
314
|
+
expect(warnSpy.mock.calls[0][0].description).toBe('Cannot copy empty field values.');
|
|
315
|
+
utils.unmount();
|
|
316
|
+
} finally {
|
|
317
|
+
warnSpy.mockRestore();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPUSH edit parity — the REAL (unconnected) MobilePush Edit container driving
|
|
3
|
+
* the REAL gated FormBuilder, with prop transitions exactly like production:
|
|
4
|
+
* mount (account selected, no metaEntities yet)
|
|
5
|
+
* -> metaEntities arrive (fullSchema + schema set, Edit CWRP 227-249)
|
|
6
|
+
* -> templateDetails arrive (handleEditSchemaOnPropsChange -> setEditState
|
|
7
|
+
* -> getSchemaForFormData -> injectEvents -> formData/tabCount setStates)
|
|
8
|
+
*
|
|
9
|
+
* This pins the bug the synthetic shell-level harnesses could NOT see: the
|
|
10
|
+
* container's internal setState cascade commits the shell BEFORE a prior
|
|
11
|
+
* dispatch has rendered, so a shell-side reconcile computed from stateRef was
|
|
12
|
+
* STALE and clobbered the just-hydrated template (fields rendered but empty).
|
|
13
|
+
* Fixed by running reconciliation inside the reducer (reconcileSchema action)
|
|
14
|
+
* and emitting background validity from the settled state.
|
|
15
|
+
*/
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import '@testing-library/jest-dom';
|
|
18
|
+
import _ from 'lodash';
|
|
19
|
+
import { Router } from 'react-router-dom';
|
|
20
|
+
import { render, act } from '../../../../utils/test-utils';
|
|
21
|
+
import history from '../../../../utils/history';
|
|
22
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
23
|
+
import { Edit } from '../../../../v2Containers/MobilePush/Edit';
|
|
24
|
+
|
|
25
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
26
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
27
|
+
}));
|
|
28
|
+
jest.mock('../../../../services/api', () => ({
|
|
29
|
+
...jest.requireActual('../../../../services/api'),
|
|
30
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
34
|
+
let mockMpushFlag = false;
|
|
35
|
+
jest.mock('../../../../utils/common', () => ({
|
|
36
|
+
...jest.requireActual('../../../../utils/common'),
|
|
37
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
41
|
+
|
|
42
|
+
const intl = {
|
|
43
|
+
formatMessage: (descriptor) => (descriptor && (descriptor.defaultMessage || descriptor.id)) || '',
|
|
44
|
+
locale: 'en',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const templateDetails = () => ({
|
|
48
|
+
name: 'test_push_12',
|
|
49
|
+
mode: 'text',
|
|
50
|
+
definition: { mode: 'text' },
|
|
51
|
+
versions: {
|
|
52
|
+
base: {
|
|
53
|
+
ANDROID: { title: 'Saved A title', message: 'Saved A body' },
|
|
54
|
+
IOS: { title: 'Saved I title', message: 'Saved I body' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const selectedAccount = {
|
|
60
|
+
id: 'acc-1',
|
|
61
|
+
name: 'Acc',
|
|
62
|
+
configs: { android: '1', ios: '1', deeplink: '[]' },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const buildProps = (overrides = {}) => ({
|
|
66
|
+
intl,
|
|
67
|
+
params: { id: '123' },
|
|
68
|
+
location: { pathname: '/mobilepush/edit/123', query: { module: 'default' } },
|
|
69
|
+
router: { push: jest.fn() },
|
|
70
|
+
isFullMode: true,
|
|
71
|
+
isLoadingMetaEntities: false,
|
|
72
|
+
metaEntities: {},
|
|
73
|
+
templateDetails: null,
|
|
74
|
+
Edit: { weCrmAccounts: [] },
|
|
75
|
+
Create: {},
|
|
76
|
+
Templates: { selectedWeChatAccount: selectedAccount },
|
|
77
|
+
// Any missing action becomes a jest.fn on demand (the container calls several).
|
|
78
|
+
actions: new Proxy({}, { get: (target, name) => {
|
|
79
|
+
if (!target[name]) target[name] = jest.fn(); // eslint-disable-line no-param-reassign
|
|
80
|
+
return target[name];
|
|
81
|
+
} }),
|
|
82
|
+
globalActions: {
|
|
83
|
+
fetchSchemaForEntity: jest.fn(),
|
|
84
|
+
addMessageToQueue: jest.fn(),
|
|
85
|
+
setInjectedTags: jest.fn(),
|
|
86
|
+
},
|
|
87
|
+
showTemplateName: jest.fn(),
|
|
88
|
+
getFormLibraryData: jest.fn(),
|
|
89
|
+
onValidationFail: jest.fn(),
|
|
90
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
91
|
+
onPersonalizationTokensChange: jest.fn(),
|
|
92
|
+
injectedTags: {},
|
|
93
|
+
...overrides,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const read = () => {
|
|
97
|
+
const editor = document.getElementById('message-editor');
|
|
98
|
+
const title = document.getElementById('message-title');
|
|
99
|
+
return {
|
|
100
|
+
titleValue: title ? title.value : '(no #message-title in DOM)',
|
|
101
|
+
editorValue: editor ? editor.value : '(no #message-editor in DOM)',
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const runEditContainer = () => {
|
|
106
|
+
const props = buildProps();
|
|
107
|
+
const ui = (p) => (
|
|
108
|
+
<Router history={history}>
|
|
109
|
+
<Edit {...p} />
|
|
110
|
+
</Router>
|
|
111
|
+
);
|
|
112
|
+
const utils = render(ui(props));
|
|
113
|
+
// metaEntities arrive (bundled schema)
|
|
114
|
+
act(() => {
|
|
115
|
+
utils.rerender(ui({ ...props, metaEntities: { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } } }));
|
|
116
|
+
});
|
|
117
|
+
// template details arrive
|
|
118
|
+
act(() => {
|
|
119
|
+
utils.rerender(ui({
|
|
120
|
+
...props,
|
|
121
|
+
metaEntities: { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } },
|
|
122
|
+
templateDetails: templateDetails(),
|
|
123
|
+
}));
|
|
124
|
+
});
|
|
125
|
+
const result = read();
|
|
126
|
+
utils.unmount();
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
describe('SCRATCH v3: REAL Edit container over the gated FormBuilder', () => {
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
mockMpushFlag = false;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('Functional path (flag on) populates the fields', () => {
|
|
136
|
+
mockMpushFlag = true;
|
|
137
|
+
const r = runEditContainer();
|
|
138
|
+
expect(r.titleValue).toBe('Saved A title');
|
|
139
|
+
expect(r.editorValue).toBe('Saved A body');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('Classic path (flag off) populates the fields', () => {
|
|
143
|
+
mockMpushFlag = false;
|
|
144
|
+
const r = runEditContainer();
|
|
145
|
+
expect(r.titleValue).toBe('Saved A title');
|
|
146
|
+
expect(r.editorValue).toBe('Saved A body');
|
|
147
|
+
});
|
|
148
|
+
});
|