@capillarytech/creatives-library 9.0.56 → 9.0.57-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 +3 -2
- package/package.json +1 -1
- package/utils/common.js +8 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +334 -46
- 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.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,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the MPUSH field renderers — each renderer is invoked as a plain
|
|
3
|
+
* function and its element tree inspected, exercising the conditional branches
|
|
4
|
+
* (error gating, message resolution, value-resolution chains, upload handlers)
|
|
5
|
+
* without mounting the heavy subtrees.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
MpushInputField, MpushTextAreaField, MpushCheckboxField, RadioGroupField,
|
|
9
|
+
SelectField, UploadField, MpushDivField, IconField, MobilePushPreviewField,
|
|
10
|
+
createMpushRegistry,
|
|
11
|
+
} from '../renderers/mpushRenderers';
|
|
12
|
+
import { TagListField } from '../renderers/smsRenderers';
|
|
13
|
+
import { ERROR_VALUE } from '../channels/mobilepush/config';
|
|
14
|
+
import { isAiContentBotDisabled } from '../../../../utils/common';
|
|
15
|
+
|
|
16
|
+
jest.mock('../../../../utils/common', () => ({ isAiContentBotDisabled: jest.fn(() => true) }));
|
|
17
|
+
|
|
18
|
+
const intl = { formatMessage: (d) => d.defaultMessage || d.id };
|
|
19
|
+
|
|
20
|
+
const kids = (el) => {
|
|
21
|
+
const c = el.props.children;
|
|
22
|
+
return (Array.isArray(c) ? c : [c]).filter(Boolean);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('MpushInputField', () => {
|
|
26
|
+
const field = { id: 'secondary-cta-0-label', width: 18, errorMessage: 'Label required' };
|
|
27
|
+
|
|
28
|
+
it('message resolution: personalization > brace > schema errorMessage; hidden without checkValidation', () => {
|
|
29
|
+
const base = { field, onChange: () => {}, renderContext: { checkValidation: true, intl } };
|
|
30
|
+
expect(kids(MpushInputField({ ...base, value: '', error: true }))[0].props.errorMessage)
|
|
31
|
+
.toBe('Label required');
|
|
32
|
+
expect(kids(MpushInputField({ ...base, value: 'a {{b', error: ERROR_VALUE.BRACKET }))[0].props.errorMessage)
|
|
33
|
+
.toContain('curly braces');
|
|
34
|
+
expect(kids(MpushInputField({
|
|
35
|
+
...base,
|
|
36
|
+
value: 'hi {{first_name}}',
|
|
37
|
+
error: true,
|
|
38
|
+
renderContext: { checkValidation: true, intl, restrictPersonalization: true },
|
|
39
|
+
}))[0].props.errorMessage).toContain('Personalization');
|
|
40
|
+
expect(kids(MpushInputField({
|
|
41
|
+
...base, value: '', error: true, renderContext: { checkValidation: false, intl },
|
|
42
|
+
}))[0].props.errorMessage).toBe('');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('MpushTextAreaField', () => {
|
|
47
|
+
const field = { id: 'message-editor', width: 18, errorMessage: 'Message required' };
|
|
48
|
+
|
|
49
|
+
it('inline message shows only under checkValidation; AskAira renders when AI enabled', () => {
|
|
50
|
+
const base = { field, onChange: jest.fn(), renderContext: { checkValidation: true, intl } };
|
|
51
|
+
expect(kids(MpushTextAreaField({ ...base, value: '', error: true }))[0].props.errorMessage)
|
|
52
|
+
.toBe('Message required');
|
|
53
|
+
isAiContentBotDisabled.mockReturnValueOnce(false);
|
|
54
|
+
const el = MpushTextAreaField({ ...base, value: 'hi', error: false });
|
|
55
|
+
const bot = kids(el).find((c) => typeof c.props?.setText === 'function');
|
|
56
|
+
expect(bot).toBeTruthy();
|
|
57
|
+
bot.props.setText('generated');
|
|
58
|
+
expect(base.onChange).toHaveBeenCalledWith('generated');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('falls back to AI-disabled when the flag reader throws', () => {
|
|
62
|
+
isAiContentBotDisabled.mockImplementationOnce(() => { throw new Error('no auth'); });
|
|
63
|
+
const el = MpushTextAreaField({
|
|
64
|
+
field, value: 'hi', error: false, onChange: () => {}, renderContext: { checkValidation: false, intl },
|
|
65
|
+
});
|
|
66
|
+
expect(kids(el).length).toBe(1); // textarea only, no bot
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('MpushCheckboxField', () => {
|
|
71
|
+
const field = {
|
|
72
|
+
id: 'add-pri-cta', width: 18, submitAction: 'addPrimaryCta', errorMessage: 'err',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it('toggle commits the value AND fires the injected submit-action', () => {
|
|
76
|
+
const onChange = jest.fn();
|
|
77
|
+
const onEvent = jest.fn();
|
|
78
|
+
const el = MpushCheckboxField({
|
|
79
|
+
field, value: false, error: false, onChange, onEvent, renderContext: {},
|
|
80
|
+
});
|
|
81
|
+
kids(el)[0].props.onChange({ target: { checked: true } });
|
|
82
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
83
|
+
expect(onEvent).toHaveBeenCalledWith('addPrimaryCta', true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('no submit-action => only the value commit; disabled + hoverText wraps in a tooltip', () => {
|
|
87
|
+
const onEvent = jest.fn();
|
|
88
|
+
const plain = MpushCheckboxField({
|
|
89
|
+
field: { id: 'x' }, value: false, error: false, onChange: () => {}, onEvent, renderContext: {},
|
|
90
|
+
});
|
|
91
|
+
kids(plain)[0].props.onChange({ target: { checked: true } });
|
|
92
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
93
|
+
|
|
94
|
+
const wrapped = MpushCheckboxField({
|
|
95
|
+
field: { ...field, disabled: true, hoverText: 'not configured' },
|
|
96
|
+
value: false,
|
|
97
|
+
error: false,
|
|
98
|
+
onChange: () => {},
|
|
99
|
+
renderContext: {},
|
|
100
|
+
});
|
|
101
|
+
expect(kids(wrapped)[0].props.title).toBe('not configured');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('RadioGroupField', () => {
|
|
106
|
+
const field = {
|
|
107
|
+
id: 'cta-deeplink', width: 18, options: ['Deeplink', 'External Link'], value: 'Deeplink', errorMessage: 'pick one',
|
|
108
|
+
};
|
|
109
|
+
const build = (legacyFormData, overrides = {}) => RadioGroupField({
|
|
110
|
+
field,
|
|
111
|
+
error: false,
|
|
112
|
+
onChange: () => {},
|
|
113
|
+
onEvent: () => {},
|
|
114
|
+
renderContext: { legacyFormData, activeTabIndex: 0, ...overrides },
|
|
115
|
+
...overrides,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('value resolution chain: tab value -> root value -> schema default (Classic handleSetRadioValue)', () => {
|
|
119
|
+
expect(kids(build({ 0: { 'cta-deeplink': 'External Link' } }))[0].props.value).toBe('External Link');
|
|
120
|
+
expect(kids(build({ 0: {}, 'cta-deeplink': 'External Link' }))[0].props.value).toBe('External Link');
|
|
121
|
+
expect(kids(build({ 0: { 'cta-deeplink': '' } }))[0].props.value).toBe('Deeplink'); // '' falls through
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('change commits the value and fires the injected onChange with the commit signature', () => {
|
|
125
|
+
const onChange = jest.fn();
|
|
126
|
+
const onEvent = jest.fn();
|
|
127
|
+
const el = RadioGroupField({
|
|
128
|
+
field, error: false, onChange, onEvent, renderContext: { legacyFormData: { 0: {} } },
|
|
129
|
+
});
|
|
130
|
+
kids(el)[0].props.onChange({ target: { value: 'External Link' } });
|
|
131
|
+
expect(onChange).toHaveBeenCalledWith('External Link');
|
|
132
|
+
expect(onEvent).toHaveBeenCalledWith('onChange', 'External Link');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('SelectField', () => {
|
|
137
|
+
const field = {
|
|
138
|
+
id: 'cta-deeplink-select', width: 18, options: [{ label: 'A', value: 'a' }], errorMessage: 'Select a deeplink',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it('formData `${id}-options` overrides the schema options; selection fires both callbacks', () => {
|
|
142
|
+
const onChange = jest.fn();
|
|
143
|
+
const onEvent = jest.fn();
|
|
144
|
+
const override = [{ label: 'B', value: 'b' }];
|
|
145
|
+
const el = SelectField({
|
|
146
|
+
field,
|
|
147
|
+
value: '',
|
|
148
|
+
error: false,
|
|
149
|
+
onChange,
|
|
150
|
+
onEvent,
|
|
151
|
+
renderContext: { legacyFormData: { 'cta-deeplink-select-options': override } },
|
|
152
|
+
});
|
|
153
|
+
const [select] = kids(el);
|
|
154
|
+
expect(select.props.options).toBe(override);
|
|
155
|
+
select.props.onSelect('b');
|
|
156
|
+
expect(onChange).toHaveBeenCalledWith('b');
|
|
157
|
+
expect(onEvent).toHaveBeenCalledWith('onSelect', 'b');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('error span shows only under checkValidation', () => {
|
|
161
|
+
const base = { field, value: '', error: true, onChange: () => {}, onEvent: () => {} };
|
|
162
|
+
expect(kids(SelectField({ ...base, renderContext: { checkValidation: true } })).length).toBe(2);
|
|
163
|
+
expect(kids(SelectField({ ...base, renderContext: { checkValidation: false } })).length).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('UploadField', () => {
|
|
168
|
+
const field = {
|
|
169
|
+
id: 'image-upload', width: 6, label: 'Add image', showPreview: true,
|
|
170
|
+
supportedExtensions: '.png,.jpg', submitAction: 'onUpload',
|
|
171
|
+
previewProps: { placeholder: 'No image', errorMessage: 'Image required' },
|
|
172
|
+
};
|
|
173
|
+
// CapColumn > [WithLabel(preview), form > [input, CapButton]]
|
|
174
|
+
const parts = (el) => {
|
|
175
|
+
const [preview, form] = kids(el);
|
|
176
|
+
const [input, button] = kids(form);
|
|
177
|
+
return { preview, input, button };
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it('error display honors checkValidation OR startValidation (the one Classic exception)', () => {
|
|
181
|
+
const base = { field, value: '', error: true, onEvent: () => {} };
|
|
182
|
+
expect(parts(UploadField({ ...base, renderContext: { startValidation: true } })).preview.props.ifError).toBe(true);
|
|
183
|
+
expect(parts(UploadField({ ...base, renderContext: { checkValidation: true } })).preview.props.ifError).toBe(true);
|
|
184
|
+
expect(parts(UploadField({ ...base, renderContext: {} })).preview.props.ifError).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('file change: no file => no event; wrong extension fires the wrong-file event and CONTINUES (Classic parity)', () => {
|
|
188
|
+
const createObjectURL = jest.fn(() => 'blob:x');
|
|
189
|
+
window.URL.createObjectURL = createObjectURL;
|
|
190
|
+
const onEvent = jest.fn();
|
|
191
|
+
const { input } = parts(UploadField({
|
|
192
|
+
field, value: '', error: false, onEvent, renderContext: {},
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
input.props.onChange({ target: { files: [] } });
|
|
196
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
197
|
+
|
|
198
|
+
const file = { name: 'notes.txt', size: 100 };
|
|
199
|
+
input.props.onChange({ target: { files: [file], value: null } });
|
|
200
|
+
expect(onEvent).toHaveBeenCalledWith('onUpload', { file, type: 'wrong file' });
|
|
201
|
+
expect(createObjectURL).toHaveBeenCalledWith(file); // no early return
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('image load reports dimensions and the >5MB error flag', () => {
|
|
205
|
+
window.URL.createObjectURL = jest.fn(() => 'blob:x');
|
|
206
|
+
const images = [];
|
|
207
|
+
const RealImage = global.Image;
|
|
208
|
+
global.Image = class { constructor() { images.push(this); } };
|
|
209
|
+
try {
|
|
210
|
+
const onEvent = jest.fn();
|
|
211
|
+
const { input } = parts(UploadField({
|
|
212
|
+
field, value: '', error: false, onEvent, renderContext: {},
|
|
213
|
+
}));
|
|
214
|
+
const file = { name: 'big.png', size: 6e6 };
|
|
215
|
+
input.props.onChange({ target: { files: [file], value: null } });
|
|
216
|
+
const img = images[images.length - 1];
|
|
217
|
+
img.width = 300;
|
|
218
|
+
img.height = 200;
|
|
219
|
+
img.onload();
|
|
220
|
+
expect(onEvent).toHaveBeenCalledWith('onUpload', {
|
|
221
|
+
file, type: 'image', fileParams: { width: 300, height: 200, error: true },
|
|
222
|
+
});
|
|
223
|
+
} finally {
|
|
224
|
+
global.Image = RealImage;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('the add-photo button opens the hidden file input (and no-ops when it is absent)', () => {
|
|
229
|
+
const { button } = parts(UploadField({
|
|
230
|
+
field, value: '', error: false, onEvent: () => {}, renderContext: {},
|
|
231
|
+
}));
|
|
232
|
+
const preventDefault = jest.fn();
|
|
233
|
+
button.props.onClick({ preventDefault }); // no #image-upload in DOM -> no-op branch
|
|
234
|
+
expect(preventDefault).toHaveBeenCalled();
|
|
235
|
+
|
|
236
|
+
const host = document.createElement('form');
|
|
237
|
+
host.id = 'image-upload';
|
|
238
|
+
const fileInput = document.createElement('input');
|
|
239
|
+
fileInput.id = 'fileName';
|
|
240
|
+
host.appendChild(fileInput);
|
|
241
|
+
document.body.appendChild(host);
|
|
242
|
+
const click = jest.spyOn(fileInput, 'click');
|
|
243
|
+
try {
|
|
244
|
+
button.props.onClick({ preventDefault: () => {} });
|
|
245
|
+
expect(click).toHaveBeenCalled();
|
|
246
|
+
button.props.onClick(); // the no-event guard branch
|
|
247
|
+
expect(click).toHaveBeenCalledTimes(2);
|
|
248
|
+
} finally {
|
|
249
|
+
document.body.removeChild(host);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('renders the image when a value exists, the placeholder otherwise', () => {
|
|
254
|
+
const withImage = parts(UploadField({
|
|
255
|
+
field, value: 'https://cdn/img.png', error: false, onEvent: () => {}, renderContext: {},
|
|
256
|
+
}));
|
|
257
|
+
const withoutImage = parts(UploadField({
|
|
258
|
+
field, value: '', error: false, onEvent: () => {}, renderContext: {},
|
|
259
|
+
}));
|
|
260
|
+
expect(withImage.preview).toBeTruthy();
|
|
261
|
+
expect(withoutImage.preview).toBeTruthy();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('MpushDivField', () => {
|
|
266
|
+
const field = { id: 'copy-android-content', value: 'Copy content', submitAction: 'onClick' };
|
|
267
|
+
|
|
268
|
+
it('value resolves from the ACTIVE tab first; DOM id gets the pane suffix on pane 2', () => {
|
|
269
|
+
const el = MpushDivField({
|
|
270
|
+
field,
|
|
271
|
+
onEvent: () => {},
|
|
272
|
+
renderContext: { legacyFormData: { 0: { 'copy-android-content': 'from tab' } }, activeTabIndex: 0, paneTabIndex: 1 },
|
|
273
|
+
});
|
|
274
|
+
expect(el.props.children).toBe('from tab');
|
|
275
|
+
expect(el.props.id).toBe('copy-android-content2');
|
|
276
|
+
const androidPane = MpushDivField({
|
|
277
|
+
field, onEvent: () => {}, renderContext: { legacyFormData: {}, activeTabIndex: 0, paneTabIndex: 0 },
|
|
278
|
+
});
|
|
279
|
+
expect(androidPane.props.id).toBe('copy-android-content');
|
|
280
|
+
expect(androidPane.props.children).toBe('Copy content'); // schema fallback
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('click fires the submit-action; onInput takes the tail onChange path', () => {
|
|
284
|
+
const onEvent = jest.fn();
|
|
285
|
+
const el = MpushDivField({ field, onEvent, renderContext: {} });
|
|
286
|
+
el.props.onClick('payload');
|
|
287
|
+
expect(onEvent).toHaveBeenCalledWith('onClick', 'payload');
|
|
288
|
+
el.props.onInput({ stopPropagation: () => {}, target: { textContent: 'typed' } });
|
|
289
|
+
expect(onEvent).toHaveBeenCalledWith('onChange', 'typed');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('IconField', () => {
|
|
294
|
+
it('click fires the submit-action; no action => no event', () => {
|
|
295
|
+
const onEvent = jest.fn();
|
|
296
|
+
IconField({ field: { id: 'cta-deeplink-delete', submitAction: 'onDelete' }, onEvent })
|
|
297
|
+
.props.onClick('x');
|
|
298
|
+
expect(onEvent).toHaveBeenCalledWith('onDelete', 'x');
|
|
299
|
+
IconField({ field: { id: 'plain-icon' }, onEvent: jest.fn() }).props.onClick('x');
|
|
300
|
+
expect(onEvent).toHaveBeenCalledTimes(1);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('tag-list rendering (shared TagListField)', () => {
|
|
305
|
+
it('CLASSIC PARITY PIN: column takes field.width and field.style (semantic path, Classic.js:3730-3769)', () => {
|
|
306
|
+
// the container-spliced CTA tagLists position "Add label" via style
|
|
307
|
+
// (marginRight 24%/10%) — Classic's semantic tag-list case honors both.
|
|
308
|
+
const field = {
|
|
309
|
+
id: 'title-tagList',
|
|
310
|
+
label: 'Add label',
|
|
311
|
+
offset: 3,
|
|
312
|
+
style: { marginRight: '10%', display: 'flex', justifyContent: 'end', width: '100%' },
|
|
313
|
+
};
|
|
314
|
+
const el = TagListField({ field, onEvent: jest.fn(), renderContext: {} });
|
|
315
|
+
expect(el.props.span).toBe(''); // no width -> '' exactly like Classic
|
|
316
|
+
expect(el.props.offset).toBe(3);
|
|
317
|
+
expect(el.props.style).toEqual(field.style);
|
|
318
|
+
expect(el.props.children.props.label).toBe('Add label');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('bridges onTagSelect through onEvent', () => {
|
|
322
|
+
const onEvent = jest.fn();
|
|
323
|
+
const el = TagListField({ field: { id: 'title-tagList' }, onEvent, renderContext: {} });
|
|
324
|
+
el.props.children.props.onTagSelect({ definition: { value: 'x' } });
|
|
325
|
+
expect(onEvent).toHaveBeenCalledWith('onTagSelect', { definition: { value: 'x' } });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('MobilePushPreviewField', () => {
|
|
330
|
+
const field = {
|
|
331
|
+
id: 'mobile-push-preview',
|
|
332
|
+
content: {
|
|
333
|
+
title: 'message-title', message: 'message-editor', secondaryCta1: 'secondary-cta-0-label', secondaryCta2: 'secondary-cta-1-label', appName: 'My App',
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
it('builds both platform contents from the ACTIVE tab (Classic quirk) with CTA labels', () => {
|
|
338
|
+
const el = MobilePushPreviewField({
|
|
339
|
+
field,
|
|
340
|
+
renderContext: {
|
|
341
|
+
intl,
|
|
342
|
+
activeTabIndex: 0,
|
|
343
|
+
legacyFormData: {
|
|
344
|
+
0: {
|
|
345
|
+
'message-title': 'T', 'message-editor': 'B', image: 'img.png', 'secondary-cta-0-label': 'Buy', 'secondary-cta-1-label': 'Skip',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
const [preview] = kids(el);
|
|
351
|
+
expect(preview.props.content.androidContent).toEqual(preview.props.content.iosContent);
|
|
352
|
+
expect(preview.props.content.androidContent.header).toBe('T');
|
|
353
|
+
expect(preview.props.content.androidContent.actions).toEqual([{ label: 'Buy' }, { label: 'Skip' }]);
|
|
354
|
+
expect(preview.props.showDeviceToggle).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('returns null when the active tab is absent', () => {
|
|
358
|
+
expect(MobilePushPreviewField({ field, renderContext: { legacyFormData: {} } })).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('createMpushRegistry', () => {
|
|
363
|
+
it('resolves every MPUSH field type to a renderer', () => {
|
|
364
|
+
const registry = createMpushRegistry();
|
|
365
|
+
['input', 'textarea', 'checkbox', 'radioGroup', 'select', 'upload', 'div', 'icon', 'mobile-push-preview']
|
|
366
|
+
.forEach((type) => expect(registry.resolve(type)).toBeTruthy());
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -37,4 +37,21 @@ describe('SchemaForm', () => {
|
|
|
37
37
|
const { getAllByTestId } = render(<SchemaForm schema={schema} renderContext={renderContext} />);
|
|
38
38
|
expect(getAllByTestId('stub').length).toBe(2);
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it('skips inactive and non-tabs containers; renders an active tabs container (id-less key fallback)', () => {
|
|
42
|
+
const schema = {
|
|
43
|
+
containers: [
|
|
44
|
+
{ id: 'off', type: 'tabs', isActive: false, panes: [{ sections: [] }] },
|
|
45
|
+
{ id: 'other', type: 'accordion' },
|
|
46
|
+
{ type: 'tabs', panes: [{ sections: [] }] }, // no id -> index key fallback
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<SchemaForm
|
|
51
|
+
schema={schema}
|
|
52
|
+
renderContext={{ ...renderContext, onTabSwitch: () => {}, intl: { locale: 'en' } }}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(container.querySelectorAll('.ant-tabs').length).toBe(1);
|
|
56
|
+
});
|
|
40
57
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for TabsContainer — pane/key binding, unsupported-pane skipping,
|
|
3
|
+
* header hiding for ja-JP, and the tab-switch bridge.
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
7
|
+
import TabsContainer from '../layout/TabsContainer';
|
|
8
|
+
|
|
9
|
+
const Stub = () => <span data-testid="stub" />;
|
|
10
|
+
const registry = { resolve: () => Stub };
|
|
11
|
+
|
|
12
|
+
const buildContext = (overrides = {}) => ({
|
|
13
|
+
registry,
|
|
14
|
+
state: { root: {}, tabs: [{ fields: {}, tabKey: 'kA' }, { fields: {}, tabKey: 'kI' }] },
|
|
15
|
+
errorData: {},
|
|
16
|
+
activeTabIndex: 0,
|
|
17
|
+
onFieldChange: () => {},
|
|
18
|
+
onFieldBlur: () => {},
|
|
19
|
+
onEvent: () => {},
|
|
20
|
+
onTabSwitch: jest.fn(),
|
|
21
|
+
intl: { locale: 'en', formatMessage: (d) => d.defaultMessage || d.id },
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const headerSection = (label) => ({
|
|
26
|
+
type: 'col-label',
|
|
27
|
+
inputFields: [{ id: `hdr-${label}`, type: 'div', value: label }],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const buildContainer = (overrides = {}) => ({
|
|
31
|
+
id: 'pane',
|
|
32
|
+
type: 'tabs',
|
|
33
|
+
panes: [
|
|
34
|
+
{ sectionsHeaders: [headerSection('Android')], sections: [] },
|
|
35
|
+
{ sectionsHeaders: [headerSection('iOS')], sections: [] },
|
|
36
|
+
],
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('TabsContainer', () => {
|
|
41
|
+
it('renders nothing without panes', () => {
|
|
42
|
+
const { container } = render(<TabsContainer container={null} renderContext={buildContext()} />);
|
|
43
|
+
expect(container.textContent).toBe('');
|
|
44
|
+
const { container: c2 } = render(
|
|
45
|
+
<TabsContainer container={{ id: 'pane', type: 'tabs', panes: [] }} renderContext={buildContext()} />,
|
|
46
|
+
);
|
|
47
|
+
expect(c2.textContent).toBe('');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('skips isSupported:false panes WITHOUT reserving a tab index (pane->tab binding shifts)', () => {
|
|
51
|
+
const container = buildContainer();
|
|
52
|
+
container.panes[0].isSupported = false;
|
|
53
|
+
const { container: dom } = render(
|
|
54
|
+
<TabsContainer container={container} renderContext={buildContext()} />,
|
|
55
|
+
);
|
|
56
|
+
// only the iOS pane renders, bound to tab 0 (key 'kA' — the shifted binding)
|
|
57
|
+
expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('hides the tab-header sections for the ja-JP locale', () => {
|
|
61
|
+
const context = buildContext({ intl: { locale: 'ja-JP', formatMessage: (d) => d.id } });
|
|
62
|
+
const { queryAllByTestId } = render(
|
|
63
|
+
<TabsContainer container={buildContainer()} renderContext={context} />,
|
|
64
|
+
);
|
|
65
|
+
expect(queryAllByTestId('stub').length).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('falls back to container.tabContent.sections when a pane has no sectionsHeaders', () => {
|
|
69
|
+
const container = buildContainer({
|
|
70
|
+
panes: [{ sections: [] }, { sections: [] }],
|
|
71
|
+
tabContent: { sections: [headerSection('shared')] },
|
|
72
|
+
});
|
|
73
|
+
const { getAllByTestId } = render(
|
|
74
|
+
<TabsContainer container={container} renderContext={buildContext()} />,
|
|
75
|
+
);
|
|
76
|
+
expect(getAllByTestId('stub').length).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('a tab-header click routes through onTabSwitch with the clicked tabKey', () => {
|
|
80
|
+
const context = buildContext();
|
|
81
|
+
const { container: dom } = render(
|
|
82
|
+
<TabsContainer container={buildContainer()} renderContext={context} />,
|
|
83
|
+
);
|
|
84
|
+
fireEvent.click(dom.querySelectorAll('.ant-tabs-tab')[1]);
|
|
85
|
+
expect(context.onTabSwitch).toHaveBeenCalledWith(expect.objectContaining({ id: 'pane' }), 'kI');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('an out-of-range active index falls back to the first pane key', () => {
|
|
89
|
+
const context = buildContext({
|
|
90
|
+
activeTabIndex: 5,
|
|
91
|
+
state: { root: {}, tabs: [{ fields: {} }, { fields: {} }] },
|
|
92
|
+
});
|
|
93
|
+
const { container: dom } = render(
|
|
94
|
+
<TabsContainer container={buildContainer()} renderContext={context} />,
|
|
95
|
+
);
|
|
96
|
+
expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('key-less tabs fall back to the pane index for both pane keys and the active key', () => {
|
|
100
|
+
const context = buildContext({
|
|
101
|
+
state: { root: {}, tabs: [{ fields: {} }, { fields: {} }] },
|
|
102
|
+
});
|
|
103
|
+
const { container: dom } = render(
|
|
104
|
+
<TabsContainer container={buildContainer()} renderContext={context} />,
|
|
105
|
+
);
|
|
106
|
+
// both panes render and one is active — the fallback chains agree
|
|
107
|
+
expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(2);
|
|
108
|
+
expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -67,6 +67,14 @@
|
|
|
67
67
|
margin: auto;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
// Functional MPUSH upload field (values identical to Classic's inline styles).
|
|
71
|
+
.fb-upload-file-input {
|
|
72
|
+
display: none;
|
|
73
|
+
}
|
|
74
|
+
.fb-upload-placeholder {
|
|
75
|
+
width: 100%;
|
|
76
|
+
text-align: center;
|
|
77
|
+
}
|
|
70
78
|
.disabled {
|
|
71
79
|
pointer-events: none;
|
|
72
80
|
border-bottom: 1px solid #C2C2C2 !important;
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* monolith, frozen) and the new functional implementation (./Functional)
|
|
7
7
|
* based on the per-channel, per-org feature flag.
|
|
8
8
|
*
|
|
9
|
-
* Phase 1
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Phase 1 migrated SMS behind `ENABLE_NEW_FORMBUILDER_SMS`; Phase 2 migrates
|
|
10
|
+
* MOBILEPUSH behind `ENABLE_NEW_FORMBUILDER_MPUSH`. The new path is taken ONLY
|
|
11
|
+
* when (a) the org has that channel's flag AND (b) this FormBuilder instance is
|
|
12
|
+
* rendering that channel. Every other channel — and every org without the
|
|
13
|
+
* flag — keeps using Classic with byte-identical behavior. The flags default
|
|
13
14
|
* OFF, so until rollout this gate always renders Classic.
|
|
14
15
|
*
|
|
15
16
|
* This component is a pure pass-through: no defaultProps, no prop mapping, no
|
|
@@ -21,24 +22,45 @@
|
|
|
21
22
|
import React, { useState } from 'react';
|
|
22
23
|
import ClassicFormBuilder from './Classic';
|
|
23
24
|
import FunctionalFormBuilder from './Functional';
|
|
24
|
-
import {
|
|
25
|
-
|
|
25
|
+
import {
|
|
26
|
+
hasNewFormBuilderEnabledForSms,
|
|
27
|
+
hasNewFormBuilderEnabledForMpush,
|
|
28
|
+
} from '../../utils/common';
|
|
29
|
+
import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
|
|
30
|
+
|
|
31
|
+
// Session-level QA override / kill switch (see "Feature Flag Rollout Strategy" in
|
|
32
|
+
// the design docs): a localStorage key named after the flag ('true'/'false')
|
|
33
|
+
// beats the org flag for THIS browser session only. Lets QA exercise the new
|
|
34
|
+
// path before org provisioning, and support disable it for a single affected
|
|
35
|
+
// user without a config change. Any storage failure falls back to the org flag.
|
|
36
|
+
const readChannelFlag = (orgFlagReader, flagName) => {
|
|
37
|
+
try {
|
|
38
|
+
const override = window.localStorage.getItem(flagName);
|
|
39
|
+
if (override === 'true') return true;
|
|
40
|
+
if (override === 'false') return false;
|
|
41
|
+
} catch (e) { /* storage unavailable -> org flag */ }
|
|
42
|
+
return Boolean(orgFlagReader());
|
|
43
|
+
};
|
|
26
44
|
|
|
27
45
|
const FormBuilder = (props) => {
|
|
28
46
|
// The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
|
|
29
|
-
const [
|
|
47
|
+
const [newBuilderFlags] = useState(() => {
|
|
30
48
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
const flags = {
|
|
50
|
+
[SMS]: readChannelFlag(hasNewFormBuilderEnabledForSms, 'ENABLE_NEW_FORMBUILDER_SMS'),
|
|
51
|
+
[MOBILE_PUSH]: readChannelFlag(hasNewFormBuilderEnabledForMpush, 'ENABLE_NEW_FORMBUILDER_MPUSH'),
|
|
52
|
+
};
|
|
53
|
+
console.log('### FormBuilder V3 per-channel flags for org:', flags);
|
|
54
|
+
return flags;
|
|
33
55
|
} catch (e) {
|
|
34
|
-
return
|
|
56
|
+
return {};
|
|
35
57
|
}
|
|
36
58
|
});
|
|
37
59
|
|
|
60
|
+
// Derived every render: the schema (and therefore the channel) arrives asynchronously.
|
|
38
61
|
const channel = (props?.channel || props.schema?.channel)?.toUpperCase();
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
return useNewFormBuilder ? (
|
|
63
|
+
return newBuilderFlags[channel] ? (
|
|
42
64
|
<FunctionalFormBuilder {...props} />
|
|
43
65
|
) : (
|
|
44
66
|
<ClassicFormBuilder {...props} />
|