@capillarytech/creatives-library 9.0.56 → 9.0.57-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/constants/unified.js +3 -2
- package/package.json +1 -1
- package/utils/common.js +8 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +340 -47
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +74 -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 +250 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +43 -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 +89 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +447 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +313 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +141 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +127 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +164 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +368 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +110 -0
- package/v2Components/FormBuilder/_formBuilder.scss +8 -0
- package/v2Components/FormBuilder/index.js +34 -12
- package/v2Components/FormBuilder/tests/entryGate.test.js +94 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library/embedded-mode validity parity — the campaigns-host Done/Preview gate.
|
|
3
|
+
*
|
|
4
|
+
* The container's isFormValid must track typing: Classic re-validates every change
|
|
5
|
+
* via the container round-trip (onChange -> container setState -> CWRP deep-unequal
|
|
6
|
+
* formData push -> validateForm -> onFormValidityChange, Classic.js:516-537), and the
|
|
7
|
+
* library-mode Done click early-returns on !state.isFormValid (Create/index.js:117).
|
|
8
|
+
* The shell mirrors this with a background validity emission on every field change
|
|
9
|
+
* under features.validateOnExternalChange (display stays gated on checkValidation).
|
|
10
|
+
*
|
|
11
|
+
* Also pins onContentValidityChange ({isContentEmpty}) — the footer's live
|
|
12
|
+
* Done/Preview-and-Test disable signal (CreativesContainer handleContentValidityChange).
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import '@testing-library/jest-dom';
|
|
16
|
+
import _ from 'lodash';
|
|
17
|
+
import { Router } from 'react-router-dom';
|
|
18
|
+
import { render, act, fireEvent } from '../../../../utils/test-utils';
|
|
19
|
+
import history from '../../../../utils/history';
|
|
20
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
21
|
+
import { Create } from '../../../../v2Containers/MobilePush/Create';
|
|
22
|
+
|
|
23
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
24
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
25
|
+
}));
|
|
26
|
+
jest.mock('../../../../services/api', () => ({
|
|
27
|
+
...jest.requireActual('../../../../services/api'),
|
|
28
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
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
|
+
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', flagValue);
|
|
92
|
+
jest.useFakeTimers();
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
jest.useRealTimers();
|
|
96
|
+
window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
|
|
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
|
+
utils.unmount();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
});
|