@capillarytech/creatives-library 9.0.61 → 9.0.63-alpha.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/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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPUSH engine additions — unit tests for the tab machinery built in Phase 2:
|
|
3
|
+
* SET_ACTIVE_TAB, hydrate tab-position preservation, multi-tab seeding from a
|
|
4
|
+
* tabs-container schema, and reconcileFormState (the CTA-splice / schema-swap path).
|
|
5
|
+
*/
|
|
6
|
+
import formReducer, {
|
|
7
|
+
createInitialState,
|
|
8
|
+
hydrate,
|
|
9
|
+
setActiveTab,
|
|
10
|
+
} from '../core/store/formReducer';
|
|
11
|
+
import initializeFormState, { reconcileFormState } from '../core/schema/initializeFormState';
|
|
12
|
+
|
|
13
|
+
const field = (id, type = 'input', extra = {}) => ({ id, type, ...extra });
|
|
14
|
+
|
|
15
|
+
const multicols = (cols) => ({ type: 'multicols', inputFields: [{ cols }] });
|
|
16
|
+
|
|
17
|
+
const tabsSchema = ({ androidCols, iosCols, standaloneCols = [] } = {}) => ({
|
|
18
|
+
channel: 'MOBILEPUSH',
|
|
19
|
+
standalone: { sections: [multicols(standaloneCols)] },
|
|
20
|
+
containers: [{
|
|
21
|
+
id: 'pane',
|
|
22
|
+
type: 'tabs',
|
|
23
|
+
panes: [
|
|
24
|
+
{ isSupported: true, sections: [multicols(androidCols)] },
|
|
25
|
+
{ isSupported: true, sections: [multicols(iosCols)] },
|
|
26
|
+
],
|
|
27
|
+
}],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const baseSchema = () => tabsSchema({
|
|
31
|
+
standaloneCols: [field('template-name', 'input', { standalone: true })],
|
|
32
|
+
androidCols: [field('message-title'), field('message-editor', 'textarea'), field('add-pri-cta', 'checkbox')],
|
|
33
|
+
iosCols: [field('message-title2'), field('message-editor2', 'textarea')],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('formReducer — SET_ACTIVE_TAB', () => {
|
|
37
|
+
const twoTabState = () => ({
|
|
38
|
+
...createInitialState('MOBILEPUSH'),
|
|
39
|
+
tabs: [{ fields: {}, base: true, tabKey: 'a' }, { fields: {}, tabKey: 'b' }],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('switches the active tab (0-based)', () => {
|
|
43
|
+
const next = formReducer(twoTabState(), setActiveTab(1));
|
|
44
|
+
expect(next.meta.activeTabIndex).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is an identity no-op when unchanged or out of range', () => {
|
|
48
|
+
const state = twoTabState();
|
|
49
|
+
expect(formReducer(state, setActiveTab(0))).toBe(state);
|
|
50
|
+
expect(formReducer(state, setActiveTab(2))).toBe(state);
|
|
51
|
+
expect(formReducer(state, setActiveTab(-1))).toBe(state);
|
|
52
|
+
expect(formReducer(state, setActiveTab('1'))).toBe(state);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('HYDRATE preserves the user’s tab position when the incoming state has it defaulted', () => {
|
|
56
|
+
const onIos = formReducer(twoTabState(), setActiveTab(1));
|
|
57
|
+
const incoming = {
|
|
58
|
+
...createInitialState('MOBILEPUSH'),
|
|
59
|
+
tabs: [{ fields: { x: '1' }, base: true, tabKey: 'a' }, { fields: {}, tabKey: 'b' }],
|
|
60
|
+
};
|
|
61
|
+
const next = formReducer(onIos, hydrate(incoming));
|
|
62
|
+
expect(next.meta.activeTabIndex).toBe(1); // still on iOS
|
|
63
|
+
expect(next.tabs[0].fields.x).toBe('1'); // but carries the new data
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('HYDRATE falls back to the incoming position when the previous tab no longer exists', () => {
|
|
67
|
+
const onIos = formReducer(twoTabState(), setActiveTab(1));
|
|
68
|
+
const singleTab = { ...createInitialState('MOBILEPUSH'), tabs: [{ fields: {}, base: true, tabKey: 'a' }] };
|
|
69
|
+
const next = formReducer(onIos, hydrate(singleTab));
|
|
70
|
+
expect(next.meta.activeTabIndex).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Classic guarantees hydrated formData always has tab keys (resetTabKeys on
|
|
74
|
+
// every edit/parent push, Classic.js:453-461). Edit's setEditState builds tabs
|
|
75
|
+
// WITHOUT tabKeys (Edit/index.js:983-986); CapTab panes are keyed by them, so
|
|
76
|
+
// a key-less hydrate would render no active pane (the edit-hydration bug).
|
|
77
|
+
it('HYDRATE preserves the existing tabKey at the same position when the incoming tab has none', () => {
|
|
78
|
+
const incoming = {
|
|
79
|
+
...createInitialState('MOBILEPUSH'),
|
|
80
|
+
tabs: [
|
|
81
|
+
{ fields: { 'message-editor': 'Saved A body' }, base: true }, // no tabKey (Edit shape)
|
|
82
|
+
{ fields: { 'message-editor2': 'Saved I body' } },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
const next = formReducer(twoTabState(), hydrate(incoming));
|
|
86
|
+
expect(next.tabs[0].tabKey).toBe('a');
|
|
87
|
+
expect(next.tabs[1].tabKey).toBe('b');
|
|
88
|
+
expect(next.tabs[0].fields['message-editor']).toBe('Saved A body');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('HYDRATE assigns fresh, distinct tabKeys when neither side has one', () => {
|
|
92
|
+
const incoming = {
|
|
93
|
+
...createInitialState('MOBILEPUSH'),
|
|
94
|
+
tabs: [{ fields: {}, base: true }, { fields: {} }],
|
|
95
|
+
};
|
|
96
|
+
const next = formReducer(createInitialState('MOBILEPUSH'), hydrate(incoming));
|
|
97
|
+
expect(next.tabs[0].tabKey).toBeTruthy();
|
|
98
|
+
expect(next.tabs[1].tabKey).toBeTruthy();
|
|
99
|
+
expect(next.tabs[0].tabKey).not.toBe(next.tabs[1].tabKey);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('HYDRATE keeps incoming tabKeys untouched (identity when nothing to fill)', () => {
|
|
103
|
+
const incoming = {
|
|
104
|
+
...createInitialState('MOBILEPUSH'),
|
|
105
|
+
tabs: [{ fields: {}, base: true, tabKey: 'k0' }, { fields: {}, tabKey: 'k1' }],
|
|
106
|
+
};
|
|
107
|
+
const next = formReducer(twoTabState(), hydrate(incoming));
|
|
108
|
+
expect(next.tabs[0].tabKey).toBe('k0');
|
|
109
|
+
expect(next.tabs[1].tabKey).toBe('k1');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('initializeFormState — tabs-container seeding', () => {
|
|
114
|
+
it('seeds one tab per pane; pane fields go to their own tab; standalone to root', () => {
|
|
115
|
+
const state = initializeFormState(baseSchema(), { generateId: (() => { let i = 0; return () => `k${i++}`; })() });
|
|
116
|
+
expect(state.tabs).toHaveLength(2);
|
|
117
|
+
expect(state.tabs[0].base).toBe(true);
|
|
118
|
+
expect(state.tabs[0].tabKey).toBe('k0');
|
|
119
|
+
expect(state.tabs[1].base).toBeUndefined();
|
|
120
|
+
expect(state.tabs[0].fields).toEqual({ 'message-title': '', 'message-editor': '', 'add-pri-cta': false });
|
|
121
|
+
expect(state.tabs[1].fields).toEqual({ 'message-title2': '', 'message-editor2': '' });
|
|
122
|
+
expect(state.root).toEqual({ 'template-name': '' });
|
|
123
|
+
expect(state.errors.tabs).toHaveLength(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('keeps the single-tab (SMS) shape when the schema has no containers', () => {
|
|
127
|
+
const state = initializeFormState({
|
|
128
|
+
channel: 'SMS',
|
|
129
|
+
standalone: { sections: [multicols([field('template-name', 'input', { standalone: true }), field('sms-editor', 'textarea')])] },
|
|
130
|
+
});
|
|
131
|
+
expect(state.tabs).toHaveLength(1);
|
|
132
|
+
expect(state.tabs[0].fields).toEqual({ 'sms-editor': '' });
|
|
133
|
+
expect(state.root).toEqual({ 'template-name': '' });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('reconcileFormState — runtime schema mutation (CTA splices / schema swap)', () => {
|
|
138
|
+
const initial = () => {
|
|
139
|
+
const state = initializeFormState(baseSchema(), { generateId: (() => { let i = 0; return () => `k${i++}`; })() });
|
|
140
|
+
state.root['template-name'] = 'My Push';
|
|
141
|
+
state.tabs[0].fields['message-title'] = 'typed title';
|
|
142
|
+
state.tabs[0].fields['add-pri-cta'] = true;
|
|
143
|
+
return state;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
it('seeds newly-spliced fields while preserving every typed value and tabKey', () => {
|
|
147
|
+
const grown = tabsSchema({
|
|
148
|
+
standaloneCols: [field('template-name', 'input', { standalone: true })],
|
|
149
|
+
androidCols: [
|
|
150
|
+
field('message-title'), field('message-editor', 'textarea'), field('add-pri-cta', 'checkbox'),
|
|
151
|
+
field('cta-deeplink', 'radioGroup', { value: 'Deeplink' }), field('cta-deeplink-select', 'select'),
|
|
152
|
+
],
|
|
153
|
+
iosCols: [field('message-title2'), field('message-editor2', 'textarea')],
|
|
154
|
+
});
|
|
155
|
+
const next = reconcileFormState(initial(), grown);
|
|
156
|
+
expect(next.tabs[0].fields['message-title']).toBe('typed title'); // preserved
|
|
157
|
+
expect(next.tabs[0].fields['add-pri-cta']).toBe(true); // preserved
|
|
158
|
+
expect(next.tabs[0].fields['cta-deeplink']).toBe('Deeplink'); // seeded (schema default)
|
|
159
|
+
expect(next.tabs[0].fields['cta-deeplink-select']).toBe(''); // seeded
|
|
160
|
+
expect(next.tabs[0].tabKey).toBe('k0'); // stable (resetTabKeys=false parity)
|
|
161
|
+
expect(next.errors.tabs[0]['cta-deeplink-select']).toBe(false); // error map pre-seeded
|
|
162
|
+
expect(next.root['template-name']).toBe('My Push');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('QUIRK: prunes root keys the schema no longer knows — but always restores template-name', () => {
|
|
166
|
+
const state = initial();
|
|
167
|
+
state.root['mobilepush-template'] = 'tpl-1'; // schema-known only in the loyalty variant
|
|
168
|
+
const withoutStandalone = tabsSchema({
|
|
169
|
+
standaloneCols: [], // host deleted the standalone section (removeStandAlone)
|
|
170
|
+
androidCols: [field('message-title'), field('message-editor', 'textarea'), field('add-pri-cta', 'checkbox')],
|
|
171
|
+
iosCols: [field('message-title2'), field('message-editor2', 'textarea')],
|
|
172
|
+
});
|
|
173
|
+
const next = reconcileFormState(state, withoutStandalone);
|
|
174
|
+
expect(next.root['template-name']).toBe('My Push'); // restored despite pruning
|
|
175
|
+
expect(next.root['mobilepush-template']).toBeUndefined(); // pruned (not in schema walk)
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('prunes tab fields the schema no longer knows (Classic.js:1889-1897), keeping name/imagePreview/image', () => {
|
|
179
|
+
// Load-bearing for the CTA radio flows: switching Deeplink <-> External Link
|
|
180
|
+
// REPLACES the cta-deeplink-select row — the orphaned seeded-'' key must go,
|
|
181
|
+
// or validateMobilePush's empty rule fails a form Classic saves
|
|
182
|
+
// ("Validation failed for android" toast on the create press).
|
|
183
|
+
const state = initial();
|
|
184
|
+
state.tabs[0].fields['custom-value-userId-cta-deeplink-select'] = '42'; // schema row was removed
|
|
185
|
+
state.tabs[0].fields['cta-deeplink-select'] = ''; // orphaned seed after radio switch
|
|
186
|
+
state.tabs[0].fields.image = 'https://cdn/img.png'; // exception list survivor
|
|
187
|
+
state.errors.tabs = [{ 'cta-deeplink-select': true }, {}];
|
|
188
|
+
const next = reconcileFormState(state, baseSchema());
|
|
189
|
+
expect(next.tabs[0].fields['custom-value-userId-cta-deeplink-select']).toBeUndefined();
|
|
190
|
+
expect(next.tabs[0].fields['cta-deeplink-select']).toBeUndefined();
|
|
191
|
+
expect(next.tabs[0].fields.image).toBe('https://cdn/img.png');
|
|
192
|
+
expect(next.tabs[0].fields['message-title']).toBe('typed title'); // schema-known values kept
|
|
193
|
+
// pruned fields lose their error flags too — a re-spliced field starts fresh
|
|
194
|
+
expect(next.errors.tabs[0]['cta-deeplink-select']).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('preserves meta (activeTabIndex) across reconciles', () => {
|
|
198
|
+
const state = { ...initial(), meta: { channel: 'MOBILEPUSH', baseTabIndex: 0, activeTabIndex: 1 } };
|
|
199
|
+
const next = reconcileFormState(state, baseSchema());
|
|
200
|
+
expect(next.meta.activeTabIndex).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('prune keep-set follows Classic formElements rules per section type', () => {
|
|
204
|
+
// parent -> col-label: kept even when onlyDisplay; template-label-/tagList- excluded.
|
|
205
|
+
// multicols: onlyDisplay cols NOT collected (their values are pruned).
|
|
206
|
+
const schema = {
|
|
207
|
+
channel: 'MOBILEPUSH',
|
|
208
|
+
containers: [{
|
|
209
|
+
id: 'pane',
|
|
210
|
+
type: 'tabs',
|
|
211
|
+
panes: [{
|
|
212
|
+
sections: [
|
|
213
|
+
{
|
|
214
|
+
type: 'parent',
|
|
215
|
+
childSections: [{
|
|
216
|
+
type: 'col-label',
|
|
217
|
+
inputFields: [
|
|
218
|
+
{ id: 'kept-label-field', type: 'div', onlyDisplay: true },
|
|
219
|
+
{ id: 'template-label-x', type: 'div' },
|
|
220
|
+
{ id: 'tagList-y', type: 'tag-list' },
|
|
221
|
+
],
|
|
222
|
+
}],
|
|
223
|
+
},
|
|
224
|
+
multicols([field('message-title'), { id: 'display-only', type: 'div', onlyDisplay: true }]),
|
|
225
|
+
],
|
|
226
|
+
}, { sections: [] }],
|
|
227
|
+
}],
|
|
228
|
+
};
|
|
229
|
+
const state = initial();
|
|
230
|
+
state.tabs[0].fields['kept-label-field'] = 'stays'; // col-label + onlyDisplay => kept
|
|
231
|
+
state.tabs[0].fields['display-only'] = 'goes'; // multicols + onlyDisplay => pruned
|
|
232
|
+
state.tabs[0].fields['tagList-y'] = 'goes'; // excluded id => pruned
|
|
233
|
+
const next = reconcileFormState(state, schema);
|
|
234
|
+
expect(next.tabs[0].fields['kept-label-field']).toBe('stays');
|
|
235
|
+
expect(next.tabs[0].fields['display-only']).toBeUndefined();
|
|
236
|
+
expect(next.tabs[0].fields['tagList-y']).toBeUndefined();
|
|
237
|
+
expect(next.tabs[0].fields['message-title']).toBe('typed title');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('falls back to a fresh initializeFormState when there is no prior state', () => {
|
|
241
|
+
const next = reconcileFormState(null, baseSchema());
|
|
242
|
+
expect(next.tabs.length).toBe(2);
|
|
243
|
+
expect(next.tabs[0].fields['message-title']).toBe('');
|
|
244
|
+
expect(next.tabs[0].base).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('keeps the channel and tab names when the schema omits them', () => {
|
|
248
|
+
const state = initial();
|
|
249
|
+
state.tabs[0].name = 'Android';
|
|
250
|
+
const schema = baseSchema();
|
|
251
|
+
delete schema.channel;
|
|
252
|
+
const next = reconcileFormState(state, schema);
|
|
253
|
+
expect(next.meta.channel).toBe('MOBILEPUSH'); // preserved from state
|
|
254
|
+
expect(next.tabs[0].name).toBe('Android');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('CLASSIC PARITY PIN: pane sectionsHeaders are NOT in the prune keep-set', () => {
|
|
258
|
+
// Classic's initializeContainers walks pane.sections only — header fields are
|
|
259
|
+
// never seeded and never in formElements, so a header-key value is dropped on
|
|
260
|
+
// every re-init. Deliberate: do NOT add sectionsHeaders to collectFormElements
|
|
261
|
+
// (every real header field is an onlyDisplay label; see initialSchema).
|
|
262
|
+
const schema = baseSchema();
|
|
263
|
+
schema.containers[0].panes[0].sectionsHeaders = [
|
|
264
|
+
multicols([field('header-input')]), // hypothetical non-display header field
|
|
265
|
+
];
|
|
266
|
+
const state = initial();
|
|
267
|
+
state.tabs[0].fields['header-input'] = 'typed';
|
|
268
|
+
const next = reconcileFormState(state, schema);
|
|
269
|
+
expect(next.tabs[0].fields['header-input']).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('seeds a newly-spliced checkbox with false (defaultValueFor)', () => {
|
|
273
|
+
const grown = baseSchema();
|
|
274
|
+
grown.containers[0].panes[0].sections.push(multicols([field('add-sec-cta-2', 'checkbox')]));
|
|
275
|
+
const next = reconcileFormState(initial(), grown);
|
|
276
|
+
expect(next.tabs[0].fields['add-sec-cta-2']).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('formReducer — guards', () => {
|
|
281
|
+
it('RECONCILE_SCHEMA without a reconciler or schema is an identity no-op', () => {
|
|
282
|
+
const state = createInitialState('MOBILEPUSH');
|
|
283
|
+
expect(formReducer(state, { type: 'fb/RECONCILE_SCHEMA', schema: {} })).toBe(state);
|
|
284
|
+
expect(formReducer(state, { type: 'fb/RECONCILE_SCHEMA', reconciler: () => ({}) })).toBe(state);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('unknown actions and SET_ACTIVE_TAB on an empty tab list are identity no-ops', () => {
|
|
288
|
+
const state = createInitialState('MOBILEPUSH');
|
|
289
|
+
expect(formReducer(state, { type: 'fb/UNKNOWN' })).toBe(state);
|
|
290
|
+
expect(formReducer(state, setActiveTab(1))).toBe(state); // no tabs -> only 0 allowed
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('HYDRATE fills only the key-less tabs in a mixed-key push', () => {
|
|
294
|
+
const prev = {
|
|
295
|
+
...createInitialState('MOBILEPUSH'),
|
|
296
|
+
tabs: [{ fields: {}, tabKey: 'keep-a' }, { fields: {}, tabKey: 'keep-b' }],
|
|
297
|
+
};
|
|
298
|
+
const incoming = {
|
|
299
|
+
...createInitialState('MOBILEPUSH'),
|
|
300
|
+
tabs: [{ fields: {}, tabKey: 'pushed-a' }, { fields: {} }],
|
|
301
|
+
};
|
|
302
|
+
const next = formReducer(prev, hydrate(incoming));
|
|
303
|
+
expect(next.tabs[0].tabKey).toBe('pushed-a'); // incoming key untouched
|
|
304
|
+
expect(next.tabs[1].tabKey).toBe('keep-b'); // key-less slot preserved from prev
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library/embedded-mode validity parity — the campaigns-host Done/Preview gates.
|
|
3
|
+
* Pins that the container's isFormValid tracks typing (Classic re-validates each change
|
|
4
|
+
* via the container round-trip, Classic.js:516-537; the Done click early-returns on
|
|
5
|
+
* !isFormValid) and the onContentValidityChange({isContentEmpty}) footer signal.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import '@testing-library/jest-dom';
|
|
9
|
+
import _ from 'lodash';
|
|
10
|
+
import { Router } from 'react-router-dom';
|
|
11
|
+
import { render, act, fireEvent } from '../../../../utils/test-utils';
|
|
12
|
+
import history from '../../../../utils/history';
|
|
13
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
14
|
+
import { Create } from '../../../../v2Containers/MobilePush/Create';
|
|
15
|
+
|
|
16
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
17
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('../../../../services/api', () => ({
|
|
20
|
+
...jest.requireActual('../../../../services/api'),
|
|
21
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
25
|
+
let mockMpushFlag = false;
|
|
26
|
+
jest.mock('../../../../utils/common', () => ({
|
|
27
|
+
...jest.requireActual('../../../../utils/common'),
|
|
28
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
32
|
+
|
|
33
|
+
const intl = {
|
|
34
|
+
formatMessage: (d) => (d && (d.defaultMessage || d.id)) || '',
|
|
35
|
+
locale: 'en',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const selectedAccount = {
|
|
39
|
+
id: 'acc-1',
|
|
40
|
+
name: 'Acc',
|
|
41
|
+
sourceTypeName: 'SOME_SDK',
|
|
42
|
+
sourceAccountIdentifier: 'lic-1',
|
|
43
|
+
configs: { android: '1', ios: '1', deeplink: '[]' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const buildProps = () => ({
|
|
47
|
+
intl,
|
|
48
|
+
params: { mode: 'text' },
|
|
49
|
+
route: { name: 'create' },
|
|
50
|
+
location: { pathname: '/mobilepush/create/text', query: { module: 'default', type: 'embedded' } },
|
|
51
|
+
router: { push: jest.fn() },
|
|
52
|
+
isFullMode: false,
|
|
53
|
+
isGetFormData: false,
|
|
54
|
+
isLoadingMetaEntities: false,
|
|
55
|
+
metaEntities: {},
|
|
56
|
+
Create: { createTemplateInProgress: false },
|
|
57
|
+
Edit: {},
|
|
58
|
+
Templates: { selectedWeChatAccount: selectedAccount },
|
|
59
|
+
actions: new Proxy({}, { get: (t, n) => { if (!t[n]) t[n] = jest.fn(); return t[n]; } }), // eslint-disable-line no-param-reassign
|
|
60
|
+
globalActions: {
|
|
61
|
+
fetchSchemaForEntity: jest.fn(),
|
|
62
|
+
addMessageToQueue: jest.fn(),
|
|
63
|
+
setInjectedTags: jest.fn(),
|
|
64
|
+
},
|
|
65
|
+
getFormLibraryData: jest.fn(),
|
|
66
|
+
onValidationFail: jest.fn(),
|
|
67
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
68
|
+
onPersonalizationTokensChange: jest.fn(),
|
|
69
|
+
onContentValidityChange: jest.fn(),
|
|
70
|
+
getLiquidTags: jest.fn(),
|
|
71
|
+
injectedTags: {},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const setNativeValue = (el, value) => {
|
|
75
|
+
const proto = Object.getPrototypeOf(el);
|
|
76
|
+
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value);
|
|
77
|
+
};
|
|
78
|
+
const typeInto = (id, value) => {
|
|
79
|
+
const el = document.getElementById(id);
|
|
80
|
+
if (!el) throw new Error(`no #${id} in DOM`);
|
|
81
|
+
setNativeValue(el, value);
|
|
82
|
+
fireEvent.change(el, { target: { value } });
|
|
83
|
+
fireEvent.blur(el);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe.each([
|
|
87
|
+
['Classic', 'false'],
|
|
88
|
+
['Functional', 'true'],
|
|
89
|
+
])('library-mode validity parity — %s', (flow, flagValue) => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
mockMpushFlag = flagValue === 'true';
|
|
92
|
+
jest.useFakeTimers();
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
jest.useRealTimers();
|
|
96
|
+
mockMpushFlag = false;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('isFormValid flips true after typing valid content (Done gate) and content-emptiness reports live', () => {
|
|
100
|
+
let containerRef = null;
|
|
101
|
+
const props = buildProps();
|
|
102
|
+
const ui = (p) => (
|
|
103
|
+
<Router history={history}>
|
|
104
|
+
<Create ref={(r) => { if (r) containerRef = r; }} {...p} />
|
|
105
|
+
</Router>
|
|
106
|
+
);
|
|
107
|
+
const utils = render(ui(props));
|
|
108
|
+
const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
|
|
109
|
+
act(() => { utils.rerender(ui({ ...props, metaEntities: meta })); });
|
|
110
|
+
act(() => { jest.runOnlyPendingTimers(); });
|
|
111
|
+
|
|
112
|
+
// mount: empty form — invalid, content empty
|
|
113
|
+
expect(containerRef.state.isFormValid).toBe(false);
|
|
114
|
+
expect(props.onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
115
|
+
|
|
116
|
+
act(() => { typeInto('message-title', 'Hello title'); });
|
|
117
|
+
act(() => { typeInto('message-editor', 'Hello body'); });
|
|
118
|
+
act(() => { jest.advanceTimersByTime(1000); }); // flush debounced emissions
|
|
119
|
+
|
|
120
|
+
// the Done gate must see the typed content: valid + non-empty
|
|
121
|
+
expect(containerRef.state.isFormValid).toBe(true);
|
|
122
|
+
const contentCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
|
|
123
|
+
expect(contentCalls[contentCalls.length - 1]).toEqual({ isContentEmpty: false });
|
|
124
|
+
|
|
125
|
+
// user repro: image write-back through the REAL prop path (Create's CWRP copies
|
|
126
|
+
// uploadedAssetData.metaInfo.public_url into the current tab, Create/index.js:167-172)
|
|
127
|
+
// then CTA check/uncheck — the create flow must not adopt the hardcoded tabCount={2}
|
|
128
|
+
// or the check scans the empty iOS tab.
|
|
129
|
+
act(() => {
|
|
130
|
+
utils.rerender(ui({
|
|
131
|
+
...props,
|
|
132
|
+
metaEntities: meta,
|
|
133
|
+
Create: { createTemplateInProgress: false, uploadedAssetData: { metaInfo: { public_url: 'blob:uploaded' } } },
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
136
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
137
|
+
expect(containerRef.state.formData[0].image).toBe('blob:uploaded'); // write-back landed
|
|
138
|
+
const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
|
|
139
|
+
const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
|
|
140
|
+
const cta = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
|
|
141
|
+
act(() => { fireEvent.click(cta); });
|
|
142
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
143
|
+
act(() => { fireEvent.click(cta); });
|
|
144
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
145
|
+
|
|
146
|
+
expect(containerRef.state.tabCount).toBe(1); // create flow never adopts the prop
|
|
147
|
+
const finalCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
|
|
148
|
+
expect(finalCalls[finalCalls.length - 1]).toEqual({ isContentEmpty: false }); // buttons stay enabled
|
|
149
|
+
|
|
150
|
+
utils.unmount();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell event-bridge branches under MPUSH: discardValues local re-init,
|
|
3
|
+
* onTagSelect signature, tab-switch bridging, tags-change background validity,
|
|
4
|
+
* and the container-owned confirmation modal (Cancel path).
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import '@testing-library/jest-dom';
|
|
8
|
+
import _ from 'lodash';
|
|
9
|
+
import { Router } from 'react-router-dom';
|
|
10
|
+
import { render, act, fireEvent } from '../../../../utils/test-utils';
|
|
11
|
+
import history from '../../../../utils/history';
|
|
12
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
13
|
+
import FunctionalFormBuilder from '../index';
|
|
14
|
+
|
|
15
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
16
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
17
|
+
}));
|
|
18
|
+
jest.mock('../../../../services/api', () => ({
|
|
19
|
+
...jest.requireActual('../../../../services/api'),
|
|
20
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const buildFormData = () => ({
|
|
24
|
+
'template-name': 'My Push',
|
|
25
|
+
0: {
|
|
26
|
+
'message-title': 'T A', 'message-editor': 'B A', base: true, tabKey: 'tab-android',
|
|
27
|
+
},
|
|
28
|
+
1: { 'message-title2': 'T I', 'message-editor2': 'B I', tabKey: 'tab-ios' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// textSchema with injectable event divs in the android pane + container events.
|
|
32
|
+
const buildSchema = ({ onDiscard, onTagSelect, onTabChange } = {}) => {
|
|
33
|
+
const schema = _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition.textSchema);
|
|
34
|
+
const container = schema.containers[0];
|
|
35
|
+
container.injectedEvents = { onTabChange: onTabChange || jest.fn() };
|
|
36
|
+
const inputFields = container.panes[0]
|
|
37
|
+
.sections[0].childSections[0].childSections[0].inputFields;
|
|
38
|
+
inputFields.push(
|
|
39
|
+
{
|
|
40
|
+
cols: [{
|
|
41
|
+
id: 'discard-div', type: 'div', value: 'Discard', submitAction: 'discardValues', injectedEvents: { discardValues: onDiscard || jest.fn() },
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
cols: [{
|
|
46
|
+
id: 'tagsel-div', type: 'div', value: 'Tags', submitAction: 'onTagSelect', injectedEvents: { onTagSelect: onTagSelect || jest.fn() },
|
|
47
|
+
}],
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
return schema;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const buildProps = (schema, overrides = {}) => ({
|
|
54
|
+
channel: 'MOBILEPUSH',
|
|
55
|
+
schema,
|
|
56
|
+
location: { pathname: '/mobilepush/create/text', query: { type: false, module: 'default' } },
|
|
57
|
+
usingTabContainer: true,
|
|
58
|
+
isFullMode: true,
|
|
59
|
+
currentTab: 1,
|
|
60
|
+
tabCount: 2,
|
|
61
|
+
tabKey: '',
|
|
62
|
+
formData: buildFormData(),
|
|
63
|
+
startValidation: false,
|
|
64
|
+
checkValidation: false,
|
|
65
|
+
tags: [],
|
|
66
|
+
injectedTags: {},
|
|
67
|
+
actions: { getLiquidTags: jest.fn() },
|
|
68
|
+
onChange: jest.fn(),
|
|
69
|
+
onSubmit: jest.fn(),
|
|
70
|
+
onFormValidityChange: jest.fn(),
|
|
71
|
+
stopValidation: jest.fn(),
|
|
72
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
73
|
+
setModalContent: jest.fn(),
|
|
74
|
+
handleCancelModal: jest.fn(),
|
|
75
|
+
parent: {},
|
|
76
|
+
...overrides,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const mount = (props) => {
|
|
80
|
+
const ui = (p) => (
|
|
81
|
+
<Router history={history}>
|
|
82
|
+
<FunctionalFormBuilder {...p} />
|
|
83
|
+
</Router>
|
|
84
|
+
);
|
|
85
|
+
const utils = render(ui(props));
|
|
86
|
+
return { ...utils, ui };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe('MPUSH shell event bridge', () => {
|
|
90
|
+
it('discardValues: notifies the container with the tail signature and re-emits validity from the re-initialized state', () => {
|
|
91
|
+
const onDiscard = jest.fn();
|
|
92
|
+
const props = buildProps(buildSchema({ onDiscard }));
|
|
93
|
+
mount(props);
|
|
94
|
+
const emissionsBefore = props.onFormValidityChange.mock.calls.length;
|
|
95
|
+
|
|
96
|
+
act(() => { fireEvent.click(document.getElementById('discard-div')); });
|
|
97
|
+
|
|
98
|
+
expect(onDiscard).toHaveBeenCalledWith(expect.anything(), 'discard-div');
|
|
99
|
+
// the re-init validity emission: an empty template is invalid
|
|
100
|
+
const calls = props.onFormValidityChange.mock.calls;
|
|
101
|
+
expect(calls.length).toBeGreaterThan(emissionsBefore);
|
|
102
|
+
expect(calls[calls.length - 1][0]).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('onTagSelect bridges with (data, 1-based currentTab, field)', () => {
|
|
106
|
+
const onTagSelect = jest.fn();
|
|
107
|
+
const props = buildProps(buildSchema({ onTagSelect }));
|
|
108
|
+
mount(props);
|
|
109
|
+
act(() => { fireEvent.click(document.getElementById('tagsel-div')); });
|
|
110
|
+
expect(onTagSelect).toHaveBeenCalledWith(
|
|
111
|
+
expect.anything(), 1, expect.objectContaining({ id: 'tagsel-div' }),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a tab-header click dispatches the switch and bridges onTabChange with the 1-based index', () => {
|
|
116
|
+
const onTabChange = jest.fn();
|
|
117
|
+
const props = buildProps(buildSchema({ onTabChange }));
|
|
118
|
+
mount(props);
|
|
119
|
+
const headers = document.querySelectorAll('.ant-tabs-tab');
|
|
120
|
+
act(() => { fireEvent.click(headers[1]); });
|
|
121
|
+
expect(onTabChange).toHaveBeenCalledWith(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('a genuine tags-catalog change re-validates and emits (deep-compared, not identity)', () => {
|
|
125
|
+
const props = buildProps(buildSchema());
|
|
126
|
+
const { rerender, ui } = mount(props);
|
|
127
|
+
const before = props.onFormValidityChange.mock.calls.length;
|
|
128
|
+
// same content, new array identity -> NO extra emission
|
|
129
|
+
act(() => { rerender(ui({ ...props, tags: [] })); });
|
|
130
|
+
const afterEcho = props.onFormValidityChange.mock.calls.length;
|
|
131
|
+
// changed content -> one background emission (TagList dialect: {definition})
|
|
132
|
+
const tag = { definition: { value: 'first_name', label: { en: 'First name' } } };
|
|
133
|
+
act(() => { rerender(ui({ ...props, tags: [tag] })); });
|
|
134
|
+
expect(props.onFormValidityChange.mock.calls.length).toBeGreaterThan(afterEcho);
|
|
135
|
+
expect(afterEcho).toBe(before);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('the confirmation modal Cancel routes to the container handleCancelModal', () => {
|
|
139
|
+
const props = buildProps(buildSchema(), {
|
|
140
|
+
showModal: true,
|
|
141
|
+
modal: { id: 'android', title: 'Alert', body: 'iOS not configured', type: 'confirm' },
|
|
142
|
+
});
|
|
143
|
+
mount(props);
|
|
144
|
+
// the MODAL's footer cancel — not the schema's own cancel-button field
|
|
145
|
+
const footer = document.querySelector('.ant-modal-footer');
|
|
146
|
+
expect(footer).toBeTruthy();
|
|
147
|
+
const [cancel] = footer.querySelectorAll('button');
|
|
148
|
+
expect(cancel.textContent).toBe('Cancel'); // Classic's footer labels (messages.cancel/yes)
|
|
149
|
+
act(() => { fireEvent.click(cancel); });
|
|
150
|
+
expect(props.handleCancelModal).toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Classic seeds the container's formData copy on init (initialiseForm ->
|
|
154
|
+
// resetTabKeys -> onChange, Classic.js:1906-1911, 740-748); the copy-content
|
|
155
|
+
// handlers read that copy before any user input.
|
|
156
|
+
it('emits the seeded legacy formData (onChange) on init', () => {
|
|
157
|
+
const props = buildProps(buildSchema(), { formData: {} });
|
|
158
|
+
mount(props);
|
|
159
|
+
expect(props.onChange).toHaveBeenCalled();
|
|
160
|
+
const [legacy] = props.onChange.mock.calls[0];
|
|
161
|
+
expect(legacy[0]).toEqual(expect.objectContaining({ 'message-title': '' }));
|
|
162
|
+
expect(legacy[1]).toEqual(expect.objectContaining({ 'message-title2': '' }));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Classic mirrors the tabCount prop on a data push only in EDIT mode (Classic.js:446-447);
|
|
166
|
+
// adopting the create flow's hardcoded 2 made the emptiness check scan the empty iOS tab.
|
|
167
|
+
it('create flow keeps emitting tabCount 1 after a genuine parent push (image write-back); edit adopts the prop', () => {
|
|
168
|
+
const writeBack = (base) => ({
|
|
169
|
+
...base,
|
|
170
|
+
0: { ...base[0], image: 'blob:uploaded' }, // container's in-place upload write-back
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const typeTitle = (value) => {
|
|
174
|
+
const el = document.getElementById('message-title');
|
|
175
|
+
fireEvent.change(el, { target: { value } });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// CREATE (no isEdit): tabCount must stay 1
|
|
179
|
+
const createProps = buildProps(buildSchema(), { formData: {} });
|
|
180
|
+
const createMount = mount(createProps);
|
|
181
|
+
act(() => {
|
|
182
|
+
createMount.rerender(createMount.ui({ ...createProps, formData: writeBack(buildFormData()) }));
|
|
183
|
+
});
|
|
184
|
+
act(() => { typeTitle('after push'); }); // next emission carries the tabCount
|
|
185
|
+
const createEmit = createProps.onChange.mock.calls[createProps.onChange.mock.calls.length - 1];
|
|
186
|
+
expect(createEmit[1]).toBe(1);
|
|
187
|
+
createMount.unmount();
|
|
188
|
+
|
|
189
|
+
// EDIT: Classic adopts the prop on the data sync (Classic.js:447)
|
|
190
|
+
const editProps = buildProps(buildSchema(), { formData: {}, isEdit: true });
|
|
191
|
+
const editMount = mount(editProps);
|
|
192
|
+
act(() => {
|
|
193
|
+
editMount.rerender(editMount.ui({ ...editProps, formData: writeBack(buildFormData()) }));
|
|
194
|
+
});
|
|
195
|
+
act(() => { typeTitle('after push'); });
|
|
196
|
+
const editEmit = editProps.onChange.mock.calls[editProps.onChange.mock.calls.length - 1];
|
|
197
|
+
expect(editEmit[1]).toBe(2);
|
|
198
|
+
editMount.unmount();
|
|
199
|
+
});
|
|
200
|
+
});
|