@capillarytech/creatives-library 9.0.59-alpha.2 → 9.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/constants/unified.js +2 -3
  2. package/package.json +1 -1
  3. package/services/api.js +1 -3
  4. package/utils/common.js +0 -8
  5. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
  6. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  7. package/v2Components/FormBuilder/Functional/constants.js +8 -46
  8. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  9. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  10. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  11. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  12. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  13. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  14. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  15. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  16. package/v2Components/FormBuilder/_formBuilder.scss +0 -11
  17. package/v2Components/FormBuilder/index.js +13 -20
  18. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
  19. package/v2Containers/CommunicationFlow/CommunicationFlow.js +13 -32
  20. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -36
  21. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -0
  22. package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
  23. package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
  24. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +1 -5
  25. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +28 -0
  26. package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +29 -0
  27. package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +6 -13
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  33. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  34. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  35. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  36. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  37. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  38. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  39. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  40. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  41. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  42. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  43. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  44. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  45. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  46. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  47. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  48. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  49. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  50. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -1,306 +0,0 @@
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
- });
@@ -1,147 +0,0 @@
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 (genuine parent push) then CTA check/uncheck — the create
126
- // flow must not adopt the hardcoded tabCount={2} or the check scans the empty iOS tab.
127
- act(() => {
128
- containerRef.setState((prev) => ({
129
- formData: { ...prev.formData, 0: { ...prev.formData[0], image: 'blob:uploaded' } },
130
- }));
131
- });
132
- act(() => { jest.advanceTimersByTime(1000); });
133
- const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
134
- const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
135
- const cta = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
136
- act(() => { fireEvent.click(cta); });
137
- act(() => { jest.advanceTimersByTime(1000); });
138
- act(() => { fireEvent.click(cta); });
139
- act(() => { jest.advanceTimersByTime(1000); });
140
-
141
- expect(containerRef.state.tabCount).toBe(1); // create flow never adopts the prop
142
- const finalCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
143
- expect(finalCalls[finalCalls.length - 1]).toEqual({ isContentEmpty: false }); // buttons stay enabled
144
-
145
- utils.unmount();
146
- });
147
- });
@@ -1,200 +0,0 @@
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
- });