@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,451 @@
1
+ /**
2
+ * MOBILEPUSH field renderers — presentational mirrors of Classic's render cases.
3
+ * Prop contract: { field, value, error, resolvedError, onChange, onBlur, onEvent, renderContext }.
4
+ * ButtonField/TagListField are reused from the SMS set; the rest are MPUSH variants
5
+ * where Classic's behavior differs by channel (each notes its Classic lines).
6
+ */
7
+
8
+ import React from 'react';
9
+ import PropTypes from 'prop-types';
10
+ import CapColumn from '@capillarytech/cap-ui-library/CapColumn';
11
+ import CapInput from '@capillarytech/cap-ui-library/CapInput';
12
+ import CapCheckbox from '@capillarytech/cap-ui-library/CapCheckbox';
13
+ import CapTooltip from '@capillarytech/cap-ui-library/CapTooltip';
14
+ import CapButton from '@capillarytech/cap-ui-library/CapButton';
15
+ import CapIcon from '@capillarytech/cap-ui-library/CapIcon';
16
+ import CapImage from '@capillarytech/cap-ui-library/CapImage';
17
+ import CapRadio from '@capillarytech/cap-ui-library/CapRadio';
18
+ import CapRadioGroup from '@capillarytech/cap-ui-library/CapRadioGroup';
19
+ import CapSelect from '@capillarytech/cap-ui-library/CapSelect';
20
+ import CapAskAira from '@capillarytech/cap-ui-library/CapAskAira';
21
+ import LabelHOC from '@capillarytech/cap-ui-library/assets/HOCs/ComponentWithLabelHOC';
22
+ import { FONT_COLOR_04, FONT_COLOR_05 } from '@capillarytech/cap-ui-library/styled/variables';
23
+ import UnifiedPreview from '../../../CommonTestAndPreview/UnifiedPreview';
24
+ import { ANDROID } from '../../../CommonTestAndPreview/constants';
25
+ import { MOBILE_PUSH } from '../../../../v2Containers/CreativesContainer/constants';
26
+ import globalMessages from '../../../../v2Containers/Cap/messages';
27
+ import formMessages from '../../messages';
28
+ import { isAiContentBotDisabled } from '../../../../utils/common';
29
+ import { hasPersonalizationTags } from '../../../../utils/commonUtils';
30
+ import { createFieldRegistry } from '../core/schema/fieldRegistry';
31
+ import { ALLOWED_IMAGE_EXTENSIONS, FIELD_TYPE } from '../constants';
32
+ import { ButtonField, TagListField } from './smsRenderers';
33
+ import { ERROR_VALUE } from '../channels/mobilepush/config';
34
+
35
+ const { TextArea } = CapInput;
36
+
37
+
38
+ const dataFieldPropTypes = {
39
+ field: PropTypes.object.isRequired,
40
+ value: PropTypes.any,
41
+ error: PropTypes.any,
42
+ onChange: PropTypes.func.isRequired,
43
+ renderContext: PropTypes.object.isRequired,
44
+ };
45
+ const dataFieldDefaultProps = {
46
+ value: undefined,
47
+ error: undefined,
48
+ };
49
+
50
+ const formatSafe = (intl, descriptor) => (intl?.formatMessage ? intl.formatMessage(descriptor) : '');
51
+
52
+ // Classic gates every field-error display on checkValidation (pre-save silence).
53
+ const shouldShowError = (renderContext, error) => Boolean(renderContext?.checkValidation && error);
54
+
55
+ /** Classic input-case error message: personalization > brace > schema errorMessage. */
56
+ const resolveInputMessage = (field, value, error, renderContext) => {
57
+ const { intl, restrictPersonalization } = renderContext || {};
58
+ if (restrictPersonalization && value && hasPersonalizationTags(String(value))) {
59
+ return formatSafe(intl, formMessages.personalizationTagsErrorMessage);
60
+ }
61
+ if (error === ERROR_VALUE.BRACKET) {
62
+ return formatSafe(intl, globalMessages.unbalanacedCurlyBraces);
63
+ }
64
+ return field.errorMessage || '';
65
+ };
66
+
67
+ export const MpushInputField = ({
68
+ field, value, error, onChange, onBlur, renderContext,
69
+ }) => {
70
+ const showError = shouldShowError(renderContext, error);
71
+ const message = showError ? resolveInputMessage(field, value, error, renderContext) : '';
72
+ return (
73
+ <CapColumn key={field.id} span={field.width} offset={field.offset} style={field.style || {}}>
74
+ <CapInput
75
+ id={field.id}
76
+ label={field.label}
77
+ placeholder={field.placeholder}
78
+ errorMessage={message}
79
+ className={`input-primary chart-name-input${showError ? ' error' : ''}`}
80
+ value={value || ''}
81
+ onChange={(e) => onChange(e.target.value)}
82
+ onBlur={onBlur}
83
+ disabled={field.disabled}
84
+ size={field.size || 'default'}
85
+ />
86
+ </CapColumn>
87
+ );
88
+ };
89
+
90
+ MpushInputField.propTypes = { ...dataFieldPropTypes, onBlur: PropTypes.func };
91
+ MpushInputField.defaultProps = { ...dataFieldDefaultProps, onBlur: undefined };
92
+
93
+ export const MpushTextAreaField = ({
94
+ field, value, error, onChange, renderContext,
95
+ }) => {
96
+ let aiDisabled = true;
97
+ try { aiDisabled = isAiContentBotDisabled(); } catch (e) { console.error(e); aiDisabled = true; }
98
+ // Classic-observable behavior: errorData is only ever computed under
99
+ // startValidation (Classic.js:334-345), so NO editor error — empty or tag —
100
+ // is visible before a save attempt. checkValidation is true exactly in the
101
+ // states where Classic can hold a non-false errorType, so gating display on it
102
+ // reproduces Classic's typing silence and also keeps the initial-hydrate
103
+ // errorData (computed for the parent validity emission) from leaking into the UI.
104
+ const showError = shouldShowError(renderContext, error);
105
+ // MPUSH keeps the inline message even though the channel is liquid-supported
106
+ // (Classic.js:2908-2915). Personalization gets its own message (2877-2879).
107
+ const inlineMessage = showError ? resolveInputMessage(field, value, error, renderContext) : '';
108
+ return (
109
+ <CapColumn key={field.id} span={field.width} offset={field.offset}>
110
+ <TextArea
111
+ id={field.id}
112
+ label={field.label}
113
+ placeholder={field.placeholder || ''}
114
+ className={`${showError ? 'error-form-builder' : ''}`}
115
+ errorMessage={inlineMessage}
116
+ autosize={field.autosize ? field.autosizeParams : false}
117
+ value={value || ''}
118
+ onChange={(e) => onChange(e.target.value)}
119
+ style={field.style || {}}
120
+ disabled={field.disabled}
121
+ />
122
+ {!aiDisabled && (
123
+ // AskAira content bot is enabled for MOBILEPUSH textareas (Classic.js:2927).
124
+ <CapAskAira.ContentGenerationBot
125
+ text={value || ''}
126
+ setText={(x) => onChange(x)}
127
+ iconPlacement="float-br"
128
+ rootStyle={{ bottom: 'calc(1rem + 0.2rem)', right: '0.2rem' }}
129
+ />
130
+ )}
131
+ </CapColumn>
132
+ );
133
+ };
134
+
135
+ MpushTextAreaField.propTypes = { ...dataFieldPropTypes };
136
+ MpushTextAreaField.defaultProps = { ...dataFieldDefaultProps };
137
+
138
+ export const MpushCheckboxField = ({
139
+ field, value, error, onChange, onEvent, renderContext,
140
+ }) => {
141
+ const showError = shouldShowError(renderContext, error);
142
+ const checkbox = (
143
+ <CapCheckbox
144
+ key={field.id}
145
+ className={`${showError ? 'error' : ''}`}
146
+ errorMessage={field.errorMessage && showError ? field.errorMessage : ''}
147
+ onChange={(e) => {
148
+ const { checked } = e.target;
149
+ onChange(checked);
150
+ // CTA toggles route through the injected submit-action with the
151
+ // performFormDataUpdate commit signature (Classic.js:3567 -> 228).
152
+ if (field.submitAction && onEvent) onEvent(field.submitAction, checked);
153
+ }}
154
+ checked={Boolean(value)}
155
+ style={field.style || {}}
156
+ disabled={field.disabled}
157
+ inductiveText={field.inductiveText}
158
+ >
159
+ {field.label}
160
+ </CapCheckbox>
161
+ );
162
+ return (
163
+ <CapColumn key={field.id} span={field.width} offset={field.offset}>
164
+ {field.disabled && field.hoverText
165
+ ? <CapTooltip title={field.hoverText}>{checkbox}</CapTooltip>
166
+ : checkbox}
167
+ </CapColumn>
168
+ );
169
+ };
170
+
171
+ MpushCheckboxField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func };
172
+ MpushCheckboxField.defaultProps = { ...dataFieldDefaultProps, onEvent: undefined };
173
+
174
+ // Direct renderer (needs the full legacy formData for the value-resolution chain).
175
+ export const RadioGroupField = ({
176
+ field, error, onChange, onEvent, renderContext,
177
+ }) => {
178
+ const showError = shouldShowError(renderContext, error);
179
+ // Classic handleSetRadioValue (2729-2736): tab value -> root value -> schema default.
180
+ const legacy = renderContext?.legacyFormData || {};
181
+ const tab = legacy[(renderContext?.paneTabIndex ?? renderContext?.activeTabIndex) || 0] || {};
182
+ const resolvedValue = tab[field.id] !== undefined && tab[field.id] !== ''
183
+ ? tab[field.id]
184
+ : (legacy[field.id] !== undefined && legacy[field.id] !== '' ? legacy[field.id] : field.value);
185
+ return (
186
+ <CapColumn key={`input-${field.id}`} span={field.width} offset={field.offset}>
187
+ <CapRadioGroup
188
+ key={`${field.id}-radio-group`}
189
+ className={`form-builder-radio-group ${showError ? 'error' : ''}`}
190
+ errorMessage={field.errorMessage && showError ? field.errorMessage : ''}
191
+ onChange={(e) => {
192
+ const nextValue = e.target.value;
193
+ onChange(nextValue);
194
+ onEvent('onChange', nextValue); // -> injected onLinkTypeChange (commit signature)
195
+ }}
196
+ style={field.style || {}}
197
+ value={resolvedValue}
198
+ name={field.name || `${field.id}-name`}
199
+ disabled={field.disabled}
200
+ >
201
+ {(field.options || []).map((option, index) => (
202
+ <CapRadio
203
+ key={`${field.id}-radio-${index}`}
204
+ value={option}
205
+ inductiveText={field.inductiveText && field.inductiveText[index]}
206
+ >
207
+ {option}
208
+ </CapRadio>
209
+ ))}
210
+ </CapRadioGroup>
211
+ </CapColumn>
212
+ );
213
+ };
214
+
215
+ RadioGroupField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
216
+ RadioGroupField.defaultProps = { ...dataFieldDefaultProps };
217
+
218
+ // Direct renderer (options may be overridden through `${id}-options` in the form data).
219
+ export const SelectField = ({
220
+ field, value, error, onChange, onEvent, renderContext,
221
+ }) => {
222
+ const showError = shouldShowError(renderContext, error);
223
+ const options = renderContext?.legacyFormData?.[`${field.id}-options`] || field.options;
224
+ return (
225
+ <CapColumn key={`select-${field.id}`} style={field.colStyle || {}} span={field.width} offset={field.offset}>
226
+ <CapSelect
227
+ id={field.id}
228
+ options={options}
229
+ placeholder={field.placeholder || ''}
230
+ style={field.style || {}}
231
+ onSelect={(data) => {
232
+ onChange(data);
233
+ onEvent('onSelect', data); // -> injected showCtaKeys / onTemplateChange (commit signature)
234
+ }}
235
+ value={value}
236
+ disabled={field.disabled}
237
+ label={field.label}
238
+ />
239
+ {showError && field.errorMessage && <span className="error">{field.errorMessage}</span>}
240
+ </CapColumn>
241
+ );
242
+ };
243
+
244
+ SelectField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
245
+ SelectField.defaultProps = { ...dataFieldDefaultProps };
246
+
247
+ // Image upload (imageSchema only), Classic.js:3657-3690 + 2737-2764: the 5 MB check
248
+ // and dimension read happen here; the upload + formData write stay container-owned.
249
+ export const UploadField = ({
250
+ field, value, error, onEvent, renderContext,
251
+ }) => {
252
+ // The only field whose error display also honors startValidation (Classic.js:3666).
253
+ const showError = Boolean(
254
+ (renderContext?.checkValidation || renderContext?.startValidation) && error,
255
+ );
256
+ const isImage = Boolean(value);
257
+
258
+ const openFileDialog = (e) => {
259
+ if (e) e.preventDefault();
260
+ const fileInput = document.querySelector(`#${field.id} #fileName`);
261
+ if (fileInput) fileInput.click();
262
+ };
263
+
264
+ const onFileChange = (e) => {
265
+ const files = e?.target?.files;
266
+ const file = files && files[0];
267
+ if (!file) return;
268
+ if (field.supportedExtensions && !ALLOWED_IMAGE_EXTENSIONS.exec(file.name)) {
269
+ // Reject unsupported extensions here: the mapped container handler reads
270
+ // fileParams (absent on the 'wrong file' payload) and would throw, and
271
+ // continuing would let a loadable wrong-extension image reach the upload path.
272
+ e.target.value = null;
273
+ return;
274
+ }
275
+ const urlApi = window.URL || window.webkitURL;
276
+ const objectUrl = urlApi.createObjectURL(file);
277
+ const img = new Image();
278
+ img.src = objectUrl;
279
+ img.onload = () => {
280
+ const fileParams = {
281
+ width: img?.width,
282
+ height: img?.height,
283
+ error: Boolean(file && (file.size / 1e6 > 5)), // > 5 MB (Classic.js:2753)
284
+ };
285
+ urlApi.revokeObjectURL(objectUrl); // dimension read done — free the blob URL
286
+ onEvent(field.submitAction, { file, type: 'image', fileParams });
287
+ };
288
+ // Classic dispatches nothing on a failed image load — only release the URL.
289
+ img.onerror = () => urlApi.revokeObjectURL(objectUrl);
290
+ e.target.value = null;
291
+ };
292
+
293
+ const ImageComponent = (imageProps) => (
294
+ <div key={`${field.id}-preview`}>
295
+ <div className={`image-container ${imageProps.ifError ? 'error' : ''}`}>
296
+ {isImage
297
+ ? <CapImage src={value} alt={imageProps.alt} style={imageProps.style} />
298
+ : (
299
+ <div className="fb-upload-placeholder">
300
+ <span className="image-placeholder">{imageProps.placeholder}</span>
301
+ </div>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ const WithLabel = LabelHOC(ImageComponent);
307
+ const { errorMessage, ...previewRest } = field.previewProps || {};
308
+
309
+ return (
310
+ <CapColumn span={field.width} offset={field.offset} style={field.style}>
311
+ {field.showPreview && (
312
+ <WithLabel
313
+ key={`${field.id}-with-label`}
314
+ {...previewRest}
315
+ errorMessage={showError && errorMessage}
316
+ ifError={showError}
317
+ />
318
+ )}
319
+ <form encType="multipart/form-data" id={field.id}>
320
+ {/* Native input on purpose: openFileDialog clicks it via #fileName, and CapInput
321
+ (antd text Input + affix wrapper) can't host type="file". */}
322
+ <input
323
+ key={field.id}
324
+ className="fb-upload-file-input"
325
+ id="fileName"
326
+ type="file"
327
+ onChange={onFileChange}
328
+ accept={field.supportedExtensions ? field.supportedExtensions : 'image/*'}
329
+ />
330
+ <CapButton
331
+ disabled={field.disabled || false}
332
+ type="link"
333
+ prefix={<CapIcon size="s" type="add-photo" />}
334
+ onClick={openFileDialog}
335
+ // color is state-dependent and must beat CapButton's own runtime styling —
336
+ // kept inline (Classic.js:3399 verbatim).
337
+ style={{ float: 'right', color: `${field.disabled ? FONT_COLOR_04 : FONT_COLOR_05}` }}
338
+ >
339
+ {field.label}
340
+ </CapButton>
341
+ </form>
342
+ </CapColumn>
343
+ );
344
+ };
345
+
346
+ UploadField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
347
+ UploadField.defaultProps = { ...dataFieldDefaultProps };
348
+
349
+ // Primitive div (tab headers, copy links, key labels), Classic.js:3304-3328:
350
+ // value resolves from the ACTIVE tab; DOM id gets the legacy pane suffix (`${id}2`).
351
+ export const MpushDivField = ({ field, onEvent, renderContext }) => {
352
+ const paneTabIndex = renderContext?.paneTabIndex ?? renderContext?.activeTabIndex ?? 0;
353
+ const activeTab = renderContext?.legacyFormData?.[renderContext?.activeTabIndex ?? 0] || {};
354
+ const children = activeTab[field.id] || field.value || '';
355
+ const domId = `${field.id}${paneTabIndex > 0 ? paneTabIndex + 1 : ''}`;
356
+ return (
357
+ <div
358
+ className={field.className || ''}
359
+ id={domId}
360
+ onClick={(data) => field.submitAction && onEvent(field.submitAction, data)}
361
+ style={field.style || {}}
362
+ // Classic fires the injected 'onChange' via callChildEvent's TAIL signature
363
+ // (data, field.id) — the shell's commit-signature branch is type-guarded to
364
+ // checkbox/radioGroup/select, so a div's onChange takes the tail path.
365
+ onInput={(e) => { e.stopPropagation(); onEvent('onChange', e.target.textContent); }}
366
+ >
367
+ {children}
368
+ </div>
369
+ );
370
+ };
371
+
372
+ MpushDivField.propTypes = {
373
+ field: PropTypes.object.isRequired,
374
+ onEvent: PropTypes.func.isRequired,
375
+ renderContext: PropTypes.object.isRequired,
376
+ };
377
+
378
+ // Direct renderer — CTA delete icons.
379
+ export const IconField = ({ field, onEvent }) => (
380
+ <CapColumn
381
+ key={`icon-${field.id}`}
382
+ id={field.id}
383
+ offset={field.offset}
384
+ width={field.width || 1}
385
+ onClick={(data) => field.submitAction && onEvent(field.submitAction, data)}
386
+ className={field.className || ''}
387
+ style={field.colStyle || {}}
388
+ >
389
+ <i style={field.style || {}} className="material-icons">{field.value || ''}</i>
390
+ </CapColumn>
391
+ );
392
+
393
+ IconField.propTypes = {
394
+ field: PropTypes.object.isRequired,
395
+ onEvent: PropTypes.func.isRequired,
396
+ };
397
+
398
+ // Device preview, Classic.js:3897-3943 quirks preserved: both panes show the ACTIVE
399
+ // tab's content and the frame is hardcoded to ANDROID with the toggle hidden.
400
+ export const MobilePushPreviewField = ({ field, renderContext }) => {
401
+ const activeTab = renderContext?.legacyFormData?.[renderContext?.activeTabIndex ?? 0];
402
+ if (!activeTab) return null;
403
+ const header = activeTab[field.content?.title] || '';
404
+ const bodyText = activeTab[field.content?.message] || '';
405
+ const bodyImage = activeTab.image || '';
406
+ const actions = [];
407
+ if (activeTab[field.content?.secondaryCta1]) actions.push({ label: activeTab[field.content.secondaryCta1] });
408
+ if (activeTab[field.content?.secondaryCta2]) actions.push({ label: activeTab[field.content.secondaryCta2] });
409
+ const platformContent = {
410
+ header, bodyText, bodyImage, actions, appName: field.content?.appName || '',
411
+ };
412
+ const mobilePushContent = { androidContent: platformContent, iosContent: platformContent };
413
+ return (
414
+ <CapColumn key="input" span={23} offset={1}>
415
+ <UnifiedPreview
416
+ key={field.id}
417
+ style={field.customStyling || {}}
418
+ channel={field.channel || MOBILE_PUSH}
419
+ content={mobilePushContent}
420
+ device={ANDROID}
421
+ showDeviceToggle={false}
422
+ showHeader={false}
423
+ formatMessage={renderContext?.intl?.formatMessage}
424
+ />
425
+ </CapColumn>
426
+ );
427
+ };
428
+
429
+ MobilePushPreviewField.propTypes = {
430
+ field: PropTypes.object.isRequired,
431
+ renderContext: PropTypes.object.isRequired,
432
+ };
433
+
434
+ /** Build a registry pre-loaded with the MOBILEPUSH field renderers. */
435
+ export const createMpushRegistry = () => createFieldRegistry({
436
+ [FIELD_TYPE.INPUT]: MpushInputField,
437
+ [FIELD_TYPE.TEXTAREA]: MpushTextAreaField,
438
+ [FIELD_TYPE.CHECKBOX]: MpushCheckboxField,
439
+ [FIELD_TYPE.BUTTON]: ButtonField,
440
+ // TagListField mirrors Classic's SEMANTIC tag-list path (Classic.js:3730-3769):
441
+ // span = field.width || '', field.style applied — the CTA splices rely on it.
442
+ [FIELD_TYPE.TAG_LIST]: TagListField,
443
+ [FIELD_TYPE.RADIO_GROUP]: RadioGroupField,
444
+ [FIELD_TYPE.SELECT]: SelectField,
445
+ [FIELD_TYPE.UPLOAD]: UploadField,
446
+ [FIELD_TYPE.DIV]: MpushDivField,
447
+ [FIELD_TYPE.ICON]: IconField,
448
+ [FIELD_TYPE.MOBILE_PUSH_PREVIEW]: MobilePushPreviewField,
449
+ });
450
+
451
+ export default createMpushRegistry;
@@ -54,7 +54,7 @@ describe('FieldSlot memoization', () => {
54
54
  expect(Renderer).toHaveBeenCalledTimes(2);
55
55
  });
56
56
 
57
- it('passes a minimal renderContext ({ checkValidation, channel }) and wires onChange to its field', () => {
57
+ it('passes a minimal renderContext (checkValidation/channel + memo-safe intl/restrictPersonalization) and wires onChange to its field', () => {
58
58
  const onFieldChange = jest.fn();
59
59
  let captured;
60
60
  const Renderer = jest.fn((p) => { captured = p; return <div>f</div>; });
@@ -71,7 +71,14 @@ describe('FieldSlot memoization', () => {
71
71
  onEvent={noop}
72
72
  />,
73
73
  );
74
- expect(captured.renderContext).toEqual({ checkValidation: true, channel: 'SMS' });
74
+ // intl + restrictPersonalization joined the mini-context in Phase 2 (MPUSH):
75
+ // both are memo-safe (stable identity / primitive) and default-empty here.
76
+ expect(captured.renderContext).toEqual({
77
+ checkValidation: true,
78
+ channel: 'SMS',
79
+ intl: undefined,
80
+ restrictPersonalization: false,
81
+ });
75
82
  captured.onChange('hello');
76
83
  expect(onFieldChange).toHaveBeenCalledWith(field, 'hello');
77
84
  });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * MANUAL perf probe (skipped by default): per-keystroke JS cost, Classic vs
3
+ * Functional, over the real embedded Create container. jsdom has no paint, so
4
+ * this measures the render+effects side of typing lag as an A/B comparison.
5
+ * Run: RUN_PERF=1 TZ=UTC npx jest --config internals/testing/jest.unit.config.js manual.typingPerf
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
+ let mockMpushFlag = false;
25
+ jest.mock('../../../../utils/common', () => ({
26
+ ...jest.requireActual('../../../../utils/common'),
27
+ hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
28
+ }));
29
+
30
+ const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
31
+ const intl = { formatMessage: (d) => (d && (d.defaultMessage || d.id)) || '', locale: 'en' };
32
+ const DEEPLINKS = JSON.stringify([{ name: 'Home Page', link: 'app://home', keys: ['user_id'] }]);
33
+ const selectedAccount = {
34
+ id: 'acc-1', name: 'Acc', sourceTypeName: 'SOME_SDK', sourceAccountIdentifier: 'lic-1',
35
+ configs: { android: '1', ios: '1', deeplink: DEEPLINKS },
36
+ };
37
+
38
+ const buildProps = () => ({
39
+ intl,
40
+ params: { mode: 'text' },
41
+ route: { name: 'create' },
42
+ location: { pathname: '/mobilepush/create/text', query: { module: 'default', type: 'embedded' } },
43
+ router: { push: jest.fn() },
44
+ isFullMode: false,
45
+ isGetFormData: false,
46
+ isLoadingMetaEntities: false,
47
+ metaEntities: {},
48
+ Create: { createTemplateInProgress: false },
49
+ Edit: {},
50
+ Templates: { selectedWeChatAccount: selectedAccount },
51
+ actions: new Proxy({}, { get: (t, n) => { if (!t[n]) t[n] = jest.fn(); return t[n]; } }), // eslint-disable-line no-param-reassign
52
+ globalActions: { fetchSchemaForEntity: jest.fn(), addMessageToQueue: jest.fn(), setInjectedTags: jest.fn() },
53
+ getFormLibraryData: jest.fn(),
54
+ onValidationFail: jest.fn(),
55
+ showLiquidErrorInFooter: jest.fn(),
56
+ onPersonalizationTokensChange: jest.fn(),
57
+ onContentValidityChange: jest.fn(),
58
+ getLiquidTags: jest.fn(),
59
+ injectedTags: {},
60
+ });
61
+
62
+ const setNativeValue = (el, value) => {
63
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set.call(el, value);
64
+ };
65
+
66
+ const measureFlow = (flag, { withCta = false, longContent = 0 } = {}) => {
67
+ mockMpushFlag = flag;
68
+ const props = buildProps();
69
+ const ui = (p) => (
70
+ <Router history={history}>
71
+ <Create {...p} />
72
+ </Router>
73
+ );
74
+ const utils = render(ui(props));
75
+ const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
76
+ act(() => { utils.rerender(ui({ ...props, metaEntities: meta })); });
77
+
78
+ if (withCta) {
79
+ const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
80
+ const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
81
+ const cb = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
82
+ act(() => { fireEvent.click(cb); });
83
+ }
84
+
85
+ const editor = document.getElementById('message-editor');
86
+ let text = 'The quick brown fox jumped over the lazy dog and kept typing more content ';
87
+ if (longContent) {
88
+ // long-message scenario incl. personalization tags — catches superlinear
89
+ // per-keystroke validation/render costs that short content hides.
90
+ text = 'Hello {{first_name}}, your offer {{offer_id}} is waiting. '.repeat(Math.ceil(longContent / 58));
91
+ }
92
+ // warm-up keystrokes (JIT/styles)
93
+ for (let i = 0; i < 5; i++) {
94
+ text += 'w';
95
+ setNativeValue(editor, text);
96
+ act(() => { fireEvent.change(editor, { target: { value: text } }); });
97
+ }
98
+ const N = 30;
99
+ const t0 = performance.now();
100
+ for (let i = 0; i < N; i++) {
101
+ text += String.fromCharCode(97 + (i % 26));
102
+ setNativeValue(editor, text);
103
+ act(() => { fireEvent.change(editor, { target: { value: text } }); });
104
+ }
105
+ const perKey = (performance.now() - t0) / N;
106
+ utils.unmount();
107
+ mockMpushFlag = false;
108
+ return perKey;
109
+ };
110
+
111
+ const describePerf = process.env.RUN_PERF ? describe : describe.skip;
112
+ describePerf('MANUAL typing perf (RUN_PERF=1 to enable)', () => {
113
+ it('per-keystroke JS cost, Classic vs Functional', () => {
114
+ const classicPlain = measureFlow(false);
115
+ const functionalPlain = measureFlow(true);
116
+ const classicCta = measureFlow(false, { withCta: true });
117
+ const functionalCta = measureFlow(true, { withCta: true });
118
+ const classicLong5k = measureFlow(false, { longContent: 5000 });
119
+ const functionalLong5k = measureFlow(true, { longContent: 5000 });
120
+ const classicLong20k = measureFlow(false, { longContent: 20000 });
121
+ const functionalLong20k = measureFlow(true, { longContent: 20000 });
122
+ // eslint-disable-next-line no-console
123
+ console.log(JSON.stringify({
124
+ classicPlain: `${classicPlain.toFixed(1)} ms/key`,
125
+ functionalPlain: `${functionalPlain.toFixed(1)} ms/key`,
126
+ classicWithCta: `${classicCta.toFixed(1)} ms/key`,
127
+ functionalWithCta: `${functionalCta.toFixed(1)} ms/key`,
128
+ classicLong5k: `${classicLong5k.toFixed(1)} ms/key`,
129
+ functionalLong5k: `${functionalLong5k.toFixed(1)} ms/key`,
130
+ classicLong20k: `${classicLong20k.toFixed(1)} ms/key`,
131
+ functionalLong20k: `${functionalLong20k.toFixed(1)} ms/key`,
132
+ }, null, 1));
133
+ expect(true).toBe(true);
134
+ });
135
+ });