@capillarytech/creatives-library 9.0.61 → 9.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/constants/unified.js +8 -2
  2. package/package.json +1 -1
  3. package/services/api.js +5 -0
  4. package/services/tests/api.test.js +44 -0
  5. package/utils/common.js +8 -0
  6. package/utils/downloadFile.js +57 -0
  7. package/utils/tests/downloadFile.test.js +219 -0
  8. package/v2Components/FormBuilder/Classic.js +43 -9
  9. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
  10. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
  11. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
  12. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
  13. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
  14. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
  15. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
  16. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
  17. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
  18. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
  19. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
  20. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
  21. package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
  22. package/v2Components/FormBuilder/Functional/constants.js +46 -8
  23. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
  24. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
  25. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
  26. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
  27. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
  28. package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
  29. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
  30. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
  31. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
  32. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
  33. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
  34. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
  35. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
  36. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
  37. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
  38. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
  39. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
  40. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
  41. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
  42. package/v2Components/FormBuilder/_formBuilder.scss +11 -0
  43. package/v2Components/FormBuilder/index.js +27 -17
  44. package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
  45. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
  46. package/v2Containers/BeeEditor/index.js +8 -0
  47. package/v2Containers/Email/index.js +118 -16
  48. package/v2Containers/Email/initialSchema.js +25 -3
  49. package/v2Containers/Email/messages.js +16 -0
  50. package/v2Containers/Email/tests/index.test.js +597 -0
  51. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
  52. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
  53. package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
  54. package/v2Containers/Templates/index.js +53 -0
  55. package/v2Containers/Templates/messages.js +8 -0
  56. package/v2Containers/Templates/tests/index.test.js +170 -0
@@ -0,0 +1,382 @@
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 { render } from '@testing-library/react';
8
+ import {
9
+ MpushInputField, MpushTextAreaField, MpushCheckboxField, RadioGroupField,
10
+ SelectField, UploadField, MpushDivField, IconField, MobilePushPreviewField,
11
+ createMpushRegistry,
12
+ } from '../renderers/mpushRenderers';
13
+ import { TagListField } from '../renderers/smsRenderers';
14
+ import { ERROR_VALUE } from '../channels/mobilepush/config';
15
+ import { isAiContentBotDisabled } from '../../../../utils/common';
16
+
17
+ jest.mock('../../../../utils/common', () => ({ isAiContentBotDisabled: jest.fn(() => true) }));
18
+
19
+ const intl = { formatMessage: (d) => d.defaultMessage || d.id };
20
+
21
+ const kids = (el) => {
22
+ const c = el.props.children;
23
+ return (Array.isArray(c) ? c : [c]).filter(Boolean);
24
+ };
25
+
26
+ describe('MpushInputField', () => {
27
+ const field = { id: 'secondary-cta-0-label', width: 18, errorMessage: 'Label required' };
28
+
29
+ it('message resolution: personalization > brace > schema errorMessage; hidden without checkValidation', () => {
30
+ const base = { field, onChange: () => {}, renderContext: { checkValidation: true, intl } };
31
+ expect(kids(MpushInputField({ ...base, value: '', error: true }))[0].props.errorMessage)
32
+ .toBe('Label required');
33
+ expect(kids(MpushInputField({ ...base, value: 'a {{b', error: ERROR_VALUE.BRACKET }))[0].props.errorMessage)
34
+ .toContain('curly braces');
35
+ expect(kids(MpushInputField({
36
+ ...base,
37
+ value: 'hi {{first_name}}',
38
+ error: true,
39
+ renderContext: { checkValidation: true, intl, restrictPersonalization: true },
40
+ }))[0].props.errorMessage).toContain('Personalization');
41
+ expect(kids(MpushInputField({
42
+ ...base, value: '', error: true, renderContext: { checkValidation: false, intl },
43
+ }))[0].props.errorMessage).toBe('');
44
+ });
45
+ });
46
+
47
+ describe('MpushTextAreaField', () => {
48
+ const field = { id: 'message-editor', width: 18, errorMessage: 'Message required' };
49
+
50
+ it('inline message shows only under checkValidation; AskAira renders when AI enabled', () => {
51
+ const base = { field, onChange: jest.fn(), renderContext: { checkValidation: true, intl } };
52
+ expect(kids(MpushTextAreaField({ ...base, value: '', error: true }))[0].props.errorMessage)
53
+ .toBe('Message required');
54
+ isAiContentBotDisabled.mockReturnValueOnce(false);
55
+ const el = MpushTextAreaField({ ...base, value: 'hi', error: false });
56
+ const bot = kids(el).find((c) => typeof c.props?.setText === 'function');
57
+ expect(bot).toBeTruthy();
58
+ bot.props.setText('generated');
59
+ expect(base.onChange).toHaveBeenCalledWith('generated');
60
+ });
61
+
62
+ it('falls back to AI-disabled when the flag reader throws', () => {
63
+ isAiContentBotDisabled.mockImplementationOnce(() => { throw new Error('no auth'); });
64
+ const el = MpushTextAreaField({
65
+ field, value: 'hi', error: false, onChange: () => {}, renderContext: { checkValidation: false, intl },
66
+ });
67
+ expect(kids(el).length).toBe(1); // textarea only, no bot
68
+ });
69
+ });
70
+
71
+ describe('MpushCheckboxField', () => {
72
+ const field = {
73
+ id: 'add-pri-cta', width: 18, submitAction: 'addPrimaryCta', errorMessage: 'err',
74
+ };
75
+
76
+ it('toggle commits the value AND fires the injected submit-action', () => {
77
+ const onChange = jest.fn();
78
+ const onEvent = jest.fn();
79
+ const el = MpushCheckboxField({
80
+ field, value: false, error: false, onChange, onEvent, renderContext: {},
81
+ });
82
+ kids(el)[0].props.onChange({ target: { checked: true } });
83
+ expect(onChange).toHaveBeenCalledWith(true);
84
+ expect(onEvent).toHaveBeenCalledWith('addPrimaryCta', true);
85
+ });
86
+
87
+ it('no submit-action => only the value commit; disabled + hoverText wraps in a tooltip', () => {
88
+ const onEvent = jest.fn();
89
+ const plain = MpushCheckboxField({
90
+ field: { id: 'x' }, value: false, error: false, onChange: () => {}, onEvent, renderContext: {},
91
+ });
92
+ kids(plain)[0].props.onChange({ target: { checked: true } });
93
+ expect(onEvent).not.toHaveBeenCalled();
94
+
95
+ const wrapped = MpushCheckboxField({
96
+ field: { ...field, disabled: true, hoverText: 'not configured' },
97
+ value: false,
98
+ error: false,
99
+ onChange: () => {},
100
+ renderContext: {},
101
+ });
102
+ expect(kids(wrapped)[0].props.title).toBe('not configured');
103
+ });
104
+ });
105
+
106
+ describe('RadioGroupField', () => {
107
+ const field = {
108
+ id: 'cta-deeplink', width: 18, options: ['Deeplink', 'External Link'], value: 'Deeplink', errorMessage: 'pick one',
109
+ };
110
+ const build = (legacyFormData, overrides = {}) => RadioGroupField({
111
+ field,
112
+ error: false,
113
+ onChange: () => {},
114
+ onEvent: () => {},
115
+ renderContext: { legacyFormData, activeTabIndex: 0, ...overrides },
116
+ ...overrides,
117
+ });
118
+
119
+ it('value resolution chain: tab value -> root value -> schema default (Classic handleSetRadioValue)', () => {
120
+ expect(kids(build({ 0: { 'cta-deeplink': 'External Link' } }))[0].props.value).toBe('External Link');
121
+ expect(kids(build({ 0: {}, 'cta-deeplink': 'External Link' }))[0].props.value).toBe('External Link');
122
+ expect(kids(build({ 0: { 'cta-deeplink': '' } }))[0].props.value).toBe('Deeplink'); // '' falls through
123
+ });
124
+
125
+ it('change commits the value and fires the injected onChange with the commit signature', () => {
126
+ const onChange = jest.fn();
127
+ const onEvent = jest.fn();
128
+ const el = RadioGroupField({
129
+ field, error: false, onChange, onEvent, renderContext: { legacyFormData: { 0: {} } },
130
+ });
131
+ kids(el)[0].props.onChange({ target: { value: 'External Link' } });
132
+ expect(onChange).toHaveBeenCalledWith('External Link');
133
+ expect(onEvent).toHaveBeenCalledWith('onChange', 'External Link');
134
+ });
135
+ });
136
+
137
+ describe('SelectField', () => {
138
+ const field = {
139
+ id: 'cta-deeplink-select', width: 18, options: [{ label: 'A', value: 'a' }], errorMessage: 'Select a deeplink',
140
+ };
141
+
142
+ it('formData `${id}-options` overrides the schema options; selection fires both callbacks', () => {
143
+ const onChange = jest.fn();
144
+ const onEvent = jest.fn();
145
+ const override = [{ label: 'B', value: 'b' }];
146
+ const el = SelectField({
147
+ field,
148
+ value: '',
149
+ error: false,
150
+ onChange,
151
+ onEvent,
152
+ renderContext: { legacyFormData: { 'cta-deeplink-select-options': override } },
153
+ });
154
+ const [select] = kids(el);
155
+ expect(select.props.options).toBe(override);
156
+ select.props.onSelect('b');
157
+ expect(onChange).toHaveBeenCalledWith('b');
158
+ expect(onEvent).toHaveBeenCalledWith('onSelect', 'b');
159
+ });
160
+
161
+ it('error span shows only under checkValidation', () => {
162
+ const base = { field, value: '', error: true, onChange: () => {}, onEvent: () => {} };
163
+ expect(kids(SelectField({ ...base, renderContext: { checkValidation: true } })).length).toBe(2);
164
+ expect(kids(SelectField({ ...base, renderContext: { checkValidation: false } })).length).toBe(1);
165
+ });
166
+ });
167
+
168
+ describe('UploadField', () => {
169
+ const field = {
170
+ id: 'image-upload', width: 6, label: 'Add image', showPreview: true,
171
+ supportedExtensions: '.png,.jpg', submitAction: 'onUpload',
172
+ previewProps: { placeholder: 'No image', errorMessage: 'Image required' },
173
+ };
174
+ // CapColumn > [WithLabel(preview), form > [input, CapButton]]
175
+ const parts = (el) => {
176
+ const [preview, form] = kids(el);
177
+ const [input, button] = kids(form);
178
+ return { preview, input, button };
179
+ };
180
+
181
+ it('error display honors checkValidation OR startValidation (the one Classic exception)', () => {
182
+ const base = { field, value: '', error: true, onEvent: () => {} };
183
+ expect(parts(UploadField({ ...base, renderContext: { startValidation: true } })).preview.props.ifError).toBe(true);
184
+ expect(parts(UploadField({ ...base, renderContext: { checkValidation: true } })).preview.props.ifError).toBe(true);
185
+ expect(parts(UploadField({ ...base, renderContext: {} })).preview.props.ifError).toBe(false);
186
+ });
187
+
188
+ it('file change: no file => no event; wrong extension is rejected locally (no event, no upload path)', () => {
189
+ const createObjectURL = jest.fn(() => 'blob:x');
190
+ window.URL.createObjectURL = createObjectURL;
191
+ window.URL.revokeObjectURL = jest.fn();
192
+ const onEvent = jest.fn();
193
+ const { input } = parts(UploadField({
194
+ field, value: '', error: false, onEvent, renderContext: {},
195
+ }));
196
+
197
+ input.props.onChange({ target: { files: [] } });
198
+ expect(onEvent).not.toHaveBeenCalled();
199
+
200
+ // the mapped container handler reads fileParams and would throw on the
201
+ // 'wrong file' payload — the renderer rejects locally instead
202
+ const file = { name: 'notes.txt', size: 100 };
203
+ input.props.onChange({ target: { files: [file], value: null } });
204
+ expect(onEvent).not.toHaveBeenCalled();
205
+ expect(createObjectURL).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('image load reports dimensions and the >5MB error flag, then releases the blob URL', () => {
209
+ window.URL.createObjectURL = jest.fn(() => 'blob:x');
210
+ const revokeObjectURL = jest.fn();
211
+ window.URL.revokeObjectURL = revokeObjectURL;
212
+ const images = [];
213
+ const RealImage = global.Image;
214
+ global.Image = class { constructor() { images.push(this); } };
215
+ try {
216
+ const onEvent = jest.fn();
217
+ const { input } = parts(UploadField({
218
+ field, value: '', error: false, onEvent, renderContext: {},
219
+ }));
220
+ const file = { name: 'big.png', size: 6e6 };
221
+ input.props.onChange({ target: { files: [file], value: null } });
222
+ const img = images[images.length - 1];
223
+ img.width = 300;
224
+ img.height = 200;
225
+ img.onload();
226
+ expect(onEvent).toHaveBeenCalledWith('onUpload', {
227
+ file, type: 'image', fileParams: { width: 300, height: 200, error: true },
228
+ });
229
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:x');
230
+ // failed image load: URL released, no event (Classic dispatches nothing)
231
+ input.props.onChange({ target: { files: [file], value: null } });
232
+ images[images.length - 1].onerror();
233
+ expect(revokeObjectURL).toHaveBeenCalledTimes(2);
234
+ } finally {
235
+ global.Image = RealImage;
236
+ }
237
+ });
238
+
239
+ it('the add-photo button opens the hidden file input (and no-ops when it is absent)', () => {
240
+ const { button } = parts(UploadField({
241
+ field, value: '', error: false, onEvent: () => {}, renderContext: {},
242
+ }));
243
+ const preventDefault = jest.fn();
244
+ button.props.onClick({ preventDefault }); // no #image-upload in DOM -> no-op branch
245
+ expect(preventDefault).toHaveBeenCalled();
246
+
247
+ const host = document.createElement('form');
248
+ host.id = 'image-upload';
249
+ const fileInput = document.createElement('input');
250
+ fileInput.id = 'fileName';
251
+ host.appendChild(fileInput);
252
+ document.body.appendChild(host);
253
+ const click = jest.spyOn(fileInput, 'click');
254
+ try {
255
+ button.props.onClick({ preventDefault: () => {} });
256
+ expect(click).toHaveBeenCalled();
257
+ button.props.onClick(); // the no-event guard branch
258
+ expect(click).toHaveBeenCalledTimes(2);
259
+ } finally {
260
+ document.body.removeChild(host);
261
+ }
262
+ });
263
+
264
+ it('renders the image when a value exists, the placeholder otherwise', () => {
265
+ const { container: withImage } = render(UploadField({
266
+ field, value: 'https://cdn/img.png', error: false, onEvent: () => {}, renderContext: {},
267
+ }));
268
+ expect(withImage.querySelector('.fb-upload-placeholder')).toBeNull();
269
+ expect(withImage.querySelector('.image-container img').getAttribute('src')).toBe('https://cdn/img.png');
270
+
271
+ const { container: withoutImage } = render(UploadField({
272
+ field, value: '', error: false, onEvent: () => {}, renderContext: {},
273
+ }));
274
+ expect(withoutImage.querySelector('.image-container img')).toBeNull();
275
+ expect(withoutImage.querySelector('.fb-upload-placeholder').textContent).toBe('No image');
276
+ });
277
+ });
278
+
279
+ describe('MpushDivField', () => {
280
+ const field = { id: 'copy-android-content', value: 'Copy content', submitAction: 'onClick' };
281
+
282
+ it('value resolves from the ACTIVE tab first; DOM id gets the pane suffix on pane 2', () => {
283
+ const el = MpushDivField({
284
+ field,
285
+ onEvent: () => {},
286
+ renderContext: { legacyFormData: { 0: { 'copy-android-content': 'from tab' } }, activeTabIndex: 0, paneTabIndex: 1 },
287
+ });
288
+ expect(el.props.children).toBe('from tab');
289
+ expect(el.props.id).toBe('copy-android-content2');
290
+ const androidPane = MpushDivField({
291
+ field, onEvent: () => {}, renderContext: { legacyFormData: {}, activeTabIndex: 0, paneTabIndex: 0 },
292
+ });
293
+ expect(androidPane.props.id).toBe('copy-android-content');
294
+ expect(androidPane.props.children).toBe('Copy content'); // schema fallback
295
+ });
296
+
297
+ it('click fires the submit-action; onInput takes the tail onChange path', () => {
298
+ const onEvent = jest.fn();
299
+ const el = MpushDivField({ field, onEvent, renderContext: {} });
300
+ el.props.onClick('payload');
301
+ expect(onEvent).toHaveBeenCalledWith('onClick', 'payload');
302
+ el.props.onInput({ stopPropagation: () => {}, target: { textContent: 'typed' } });
303
+ expect(onEvent).toHaveBeenCalledWith('onChange', 'typed');
304
+ });
305
+ });
306
+
307
+ describe('IconField', () => {
308
+ it('click fires the submit-action; no action => no event', () => {
309
+ const onEvent = jest.fn();
310
+ IconField({ field: { id: 'cta-deeplink-delete', submitAction: 'onDelete' }, onEvent })
311
+ .props.onClick('x');
312
+ expect(onEvent).toHaveBeenCalledWith('onDelete', 'x');
313
+ IconField({ field: { id: 'plain-icon' }, onEvent }).props.onClick('x');
314
+ expect(onEvent).toHaveBeenCalledTimes(1);
315
+ });
316
+ });
317
+
318
+ describe('tag-list rendering (shared TagListField)', () => {
319
+ it('CLASSIC PARITY PIN: column takes field.width and field.style (semantic path, Classic.js:3730-3769)', () => {
320
+ // the container-spliced CTA tagLists position "Add label" via style
321
+ // (marginRight 24%/10%) — Classic's semantic tag-list case honors both.
322
+ const field = {
323
+ id: 'title-tagList',
324
+ label: 'Add label',
325
+ offset: 3,
326
+ style: { marginRight: '10%', display: 'flex', justifyContent: 'end', width: '100%' },
327
+ };
328
+ const el = TagListField({ field, onEvent: jest.fn(), renderContext: {} });
329
+ expect(el.props.span).toBe(''); // no width -> '' exactly like Classic
330
+ expect(el.props.offset).toBe(3);
331
+ expect(el.props.style).toEqual(field.style);
332
+ expect(el.props.children.props.label).toBe('Add label');
333
+ });
334
+
335
+ it('bridges onTagSelect through onEvent', () => {
336
+ const onEvent = jest.fn();
337
+ const el = TagListField({ field: { id: 'title-tagList' }, onEvent, renderContext: {} });
338
+ el.props.children.props.onTagSelect({ definition: { value: 'x' } });
339
+ expect(onEvent).toHaveBeenCalledWith('onTagSelect', { definition: { value: 'x' } });
340
+ });
341
+ });
342
+
343
+ describe('MobilePushPreviewField', () => {
344
+ const field = {
345
+ id: 'mobile-push-preview',
346
+ content: {
347
+ title: 'message-title', message: 'message-editor', secondaryCta1: 'secondary-cta-0-label', secondaryCta2: 'secondary-cta-1-label', appName: 'My App',
348
+ },
349
+ };
350
+
351
+ it('builds both platform contents from the ACTIVE tab (Classic quirk) with CTA labels', () => {
352
+ const el = MobilePushPreviewField({
353
+ field,
354
+ renderContext: {
355
+ intl,
356
+ activeTabIndex: 0,
357
+ legacyFormData: {
358
+ 0: {
359
+ 'message-title': 'T', 'message-editor': 'B', image: 'img.png', 'secondary-cta-0-label': 'Buy', 'secondary-cta-1-label': 'Skip',
360
+ },
361
+ },
362
+ },
363
+ });
364
+ const [preview] = kids(el);
365
+ expect(preview.props.content.androidContent).toEqual(preview.props.content.iosContent);
366
+ expect(preview.props.content.androidContent.header).toBe('T');
367
+ expect(preview.props.content.androidContent.actions).toEqual([{ label: 'Buy' }, { label: 'Skip' }]);
368
+ expect(preview.props.showDeviceToggle).toBe(false);
369
+ });
370
+
371
+ it('returns null when the active tab is absent', () => {
372
+ expect(MobilePushPreviewField({ field, renderContext: { legacyFormData: {} } })).toBeNull();
373
+ });
374
+ });
375
+
376
+ describe('createMpushRegistry', () => {
377
+ it('resolves every MPUSH field type to a renderer', () => {
378
+ const registry = createMpushRegistry();
379
+ ['input', 'textarea', 'checkbox', 'radioGroup', 'select', 'upload', 'div', 'icon', 'mobile-push-preview']
380
+ .forEach((type) => expect(registry.resolve(type)).toBeTruthy());
381
+ });
382
+ });
@@ -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,112 @@
1
+ /**
2
+ * Unit tests for TabsContainer — pane/key binding, unsupported-pane skipping,
3
+ * locale-independent headers, 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
+ // it must also be ACTIVE, proving the pane key and activeKey shift together
58
+ expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(1);
59
+ expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
60
+ });
61
+
62
+ it('renders the tab-header sections for every locale (Classic has no locale gating)', () => {
63
+ const context = buildContext({ intl: { locale: 'ja-JP', formatMessage: (d) => d.id } });
64
+ const { queryAllByTestId } = render(
65
+ <TabsContainer container={buildContainer()} renderContext={context} />,
66
+ );
67
+ expect(queryAllByTestId('stub').length).toBeGreaterThan(0);
68
+ });
69
+
70
+ it('falls back to container.tabContent.sections when a pane has no sectionsHeaders', () => {
71
+ const container = buildContainer({
72
+ panes: [{ sections: [] }, { sections: [] }],
73
+ tabContent: { sections: [headerSection('shared')] },
74
+ });
75
+ const { getAllByTestId } = render(
76
+ <TabsContainer container={container} renderContext={buildContext()} />,
77
+ );
78
+ expect(getAllByTestId('stub').length).toBeGreaterThan(0);
79
+ });
80
+
81
+ it('a tab-header click routes through onTabSwitch with the clicked tabKey', () => {
82
+ const context = buildContext();
83
+ const { container: dom } = render(
84
+ <TabsContainer container={buildContainer()} renderContext={context} />,
85
+ );
86
+ fireEvent.click(dom.querySelectorAll('.ant-tabs-tab')[1]);
87
+ expect(context.onTabSwitch).toHaveBeenCalledWith(expect.objectContaining({ id: 'pane' }), 'kI');
88
+ });
89
+
90
+ it('an out-of-range active index falls back to the first pane key', () => {
91
+ const context = buildContext({
92
+ activeTabIndex: 5,
93
+ state: { root: {}, tabs: [{ fields: {} }, { fields: {} }] },
94
+ });
95
+ const { container: dom } = render(
96
+ <TabsContainer container={buildContainer()} renderContext={context} />,
97
+ );
98
+ expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
99
+ });
100
+
101
+ it('key-less tabs fall back to the pane index for both pane keys and the active key', () => {
102
+ const context = buildContext({
103
+ state: { root: {}, tabs: [{ fields: {} }, { fields: {} }] },
104
+ });
105
+ const { container: dom } = render(
106
+ <TabsContainer container={buildContainer()} renderContext={context} />,
107
+ );
108
+ // both panes render and one is active — the fallback chains agree
109
+ expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(2);
110
+ expect(dom.querySelectorAll('.ant-tabs-tab-active').length).toBe(1);
111
+ });
112
+ });
@@ -67,6 +67,17 @@
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-tabs-full-width {
75
+ width: 100%;
76
+ }
77
+ .fb-upload-placeholder {
78
+ width: 100%;
79
+ text-align: center;
80
+ }
70
81
  .disabled {
71
82
  pointer-events: none;
72
83
  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 migrates SMS only, behind `ENABLE_NEW_FORMBUILDER_SMS`. The new path
10
- * is taken ONLY when (a) the org has that flag AND (b) this FormBuilder instance
11
- * is rendering the SMS channel. Every other channel and every org without the
12
- * flag keeps using Classic with byte-identical behavior. The flag defaults
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,33 @@
21
22
  import React, { useState } from 'react';
22
23
  import ClassicFormBuilder from './Classic';
23
24
  import FunctionalFormBuilder from './Functional';
24
- import { hasNewFormBuilderEnabledForSms } from '../../utils/common';
25
- import { SMS } from '../../v2Containers/CreativesContainer/constants';
25
+ import {
26
+ hasNewFormBuilderEnabledForSms,
27
+ hasNewFormBuilderEnabledForMpush,
28
+ } from '../../utils/common';
29
+ import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
30
+
31
+ // Guard each read separately so one channel's flag failure routes only that channel to Classic.
32
+ const readFlag = (reader) => {
33
+ try {
34
+ return Boolean(reader());
35
+ } catch (e) {
36
+ return false;
37
+ }
38
+ };
26
39
 
27
40
  const FormBuilder = (props) => {
28
- // The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
29
- const [isSmsNewBuilderOrg] = useState(() => {
30
- try {
31
- console.log('### ENABLE_NEW_FORMBUILDER_SMS enabled for org:', hasNewFormBuilderEnabledForSms());
32
- return Boolean(hasNewFormBuilderEnabledForSms());
33
- } catch (e) {
34
- return false;
35
- }
36
- });
41
+ // One-time org-flag read (API-provisioned via Auth.hasFeatureAccess); the try/catch
42
+ // guards early bootstrap where window.capAuth is not ready — any failure routes to Classic.
43
+ const [newBuilderFlags] = useState(() => ({
44
+ [SMS]: readFlag(hasNewFormBuilderEnabledForSms),
45
+ [MOBILE_PUSH]: readFlag(hasNewFormBuilderEnabledForMpush),
46
+ }));
37
47
 
48
+ // Derived every render: the schema (and therefore the channel) arrives asynchronously.
38
49
  const channel = (props?.channel || props.schema?.channel)?.toUpperCase();
39
50
 
40
- const useNewFormBuilder = isSmsNewBuilderOrg && channel === SMS;
41
- return useNewFormBuilder ? (
51
+ return newBuilderFlags[channel] ? (
42
52
  <FunctionalFormBuilder {...props} />
43
53
  ) : (
44
54
  <ClassicFormBuilder {...props} />