@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.
Files changed (38) hide show
  1. package/constants/unified.js +3 -2
  2. package/package.json +1 -1
  3. package/utils/common.js +8 -0
  4. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +334 -46
  5. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
  6. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +74 -0
  7. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
  8. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
  9. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
  10. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
  11. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
  12. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
  13. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
  14. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
  15. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +250 -0
  16. package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
  17. package/v2Components/FormBuilder/Functional/constants.js +43 -8
  18. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
  19. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
  20. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
  21. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
  22. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
  23. package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
  24. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +89 -0
  25. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +447 -0
  26. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
  27. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
  28. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +313 -0
  29. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +141 -0
  30. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
  31. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +164 -0
  32. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +368 -0
  33. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
  34. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +110 -0
  35. package/v2Components/FormBuilder/_formBuilder.scss +8 -0
  36. package/v2Components/FormBuilder/index.js +34 -12
  37. package/v2Components/FormBuilder/tests/entryGate.test.js +94 -7
  38. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
@@ -0,0 +1,447 @@
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 { 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
+ // Image types the upload accepts (Classic.js:2746 wrong-file check).
38
+ const ALLOWED_IMAGE_EXTENSIONS = /(\.bmp|\.jpeg|\.png|\.gif|\.avif|\.jpg)$/i;
39
+
40
+ const dataFieldPropTypes = {
41
+ field: PropTypes.object.isRequired,
42
+ value: PropTypes.any,
43
+ error: PropTypes.any,
44
+ onChange: PropTypes.func.isRequired,
45
+ renderContext: PropTypes.object.isRequired,
46
+ };
47
+ const dataFieldDefaultProps = {
48
+ value: undefined,
49
+ error: undefined,
50
+ };
51
+
52
+ const formatSafe = (intl, descriptor) => (intl?.formatMessage ? intl.formatMessage(descriptor) : '');
53
+
54
+ // Classic gates every field-error display on checkValidation (pre-save silence).
55
+ const shouldShowError = (renderContext, error) => Boolean(renderContext?.checkValidation && error);
56
+
57
+ /** Classic input-case error message: personalization > brace > schema errorMessage. */
58
+ const resolveInputMessage = (field, value, error, renderContext) => {
59
+ const { intl, restrictPersonalization } = renderContext || {};
60
+ if (restrictPersonalization && value && hasPersonalizationTags(String(value))) {
61
+ return formatSafe(intl, formMessages.personalizationTagsErrorMessage);
62
+ }
63
+ if (error === ERROR_VALUE.BRACKET) {
64
+ return formatSafe(intl, globalMessages.unbalanacedCurlyBraces);
65
+ }
66
+ return field.errorMessage || '';
67
+ };
68
+
69
+ export const MpushInputField = ({
70
+ field, value, error, onChange, onBlur, renderContext,
71
+ }) => {
72
+ const showError = shouldShowError(renderContext, error);
73
+ const message = showError ? resolveInputMessage(field, value, error, renderContext) : '';
74
+ return (
75
+ <CapColumn key={field.id} span={field.width} offset={field.offset} style={field.style || {}}>
76
+ <CapInput
77
+ id={field.id}
78
+ label={field.label}
79
+ placeholder={field.placeholder}
80
+ errorMessage={message}
81
+ className={`input-primary chart-name-input${showError ? ' error' : ''}`}
82
+ value={value || ''}
83
+ onChange={(e) => onChange(e.target.value)}
84
+ onBlur={onBlur}
85
+ disabled={field.disabled}
86
+ size={field.size || 'default'}
87
+ />
88
+ </CapColumn>
89
+ );
90
+ };
91
+
92
+ MpushInputField.propTypes = { ...dataFieldPropTypes, onBlur: PropTypes.func };
93
+ MpushInputField.defaultProps = { ...dataFieldDefaultProps, onBlur: undefined };
94
+
95
+ export const MpushTextAreaField = ({
96
+ field, value, error, onChange, renderContext,
97
+ }) => {
98
+ let aiDisabled = true;
99
+ try { aiDisabled = isAiContentBotDisabled(); } catch (e) { aiDisabled = true; }
100
+ // Classic-observable behavior: errorData is only ever computed under
101
+ // startValidation (Classic.js:334-345), so NO editor error — empty or tag —
102
+ // is visible before a save attempt. checkValidation is true exactly in the
103
+ // states where Classic can hold a non-false errorType, so gating display on it
104
+ // reproduces Classic's typing silence and also keeps the initial-hydrate
105
+ // errorData (computed for the parent validity emission) from leaking into the UI.
106
+ const showError = shouldShowError(renderContext, error);
107
+ // MPUSH keeps the inline message even though the channel is liquid-supported
108
+ // (Classic.js:2908-2915). Personalization gets its own message (2877-2879).
109
+ const inlineMessage = showError ? resolveInputMessage(field, value, error, renderContext) : '';
110
+ return (
111
+ <CapColumn key={field.id} span={field.width} offset={field.offset}>
112
+ <TextArea
113
+ id={field.id}
114
+ label={field.label}
115
+ placeholder={field.placeholder || ''}
116
+ className={`${showError ? 'error-form-builder' : ''}`}
117
+ errorMessage={inlineMessage}
118
+ autosize={field.autosize ? field.autosizeParams : false}
119
+ value={value || ''}
120
+ onChange={(e) => onChange(e.target.value)}
121
+ style={field.style || {}}
122
+ disabled={field.disabled}
123
+ />
124
+ {!aiDisabled && (
125
+ // AskAira content bot is enabled for MOBILEPUSH textareas (Classic.js:2927).
126
+ <CapAskAira.ContentGenerationBot
127
+ text={value || ''}
128
+ setText={(x) => onChange(x)}
129
+ iconPlacement="float-br"
130
+ rootStyle={{ bottom: 'calc(1rem + 0.2rem)', right: '0.2rem' }}
131
+ />
132
+ )}
133
+ </CapColumn>
134
+ );
135
+ };
136
+
137
+ MpushTextAreaField.propTypes = { ...dataFieldPropTypes };
138
+ MpushTextAreaField.defaultProps = { ...dataFieldDefaultProps };
139
+
140
+ export const MpushCheckboxField = ({
141
+ field, value, error, onChange, onEvent, renderContext,
142
+ }) => {
143
+ const showError = shouldShowError(renderContext, error);
144
+ const checkbox = (
145
+ <CapCheckbox
146
+ key={field.id}
147
+ className={`${showError ? 'error' : ''}`}
148
+ errorMessage={field.errorMessage && showError ? field.errorMessage : ''}
149
+ onChange={(e) => {
150
+ const { checked } = e.target;
151
+ onChange(checked);
152
+ // CTA toggles route through the injected submit-action with the
153
+ // performFormDataUpdate commit signature (Classic.js:3567 -> 228).
154
+ if (field.submitAction && onEvent) onEvent(field.submitAction, checked);
155
+ }}
156
+ checked={Boolean(value)}
157
+ style={field.style || {}}
158
+ disabled={field.disabled}
159
+ inductiveText={field.inductiveText}
160
+ >
161
+ {field.label}
162
+ </CapCheckbox>
163
+ );
164
+ return (
165
+ <CapColumn key={field.id} span={field.width} offset={field.offset}>
166
+ {field.disabled && field.hoverText
167
+ ? <CapTooltip title={field.hoverText}>{checkbox}</CapTooltip>
168
+ : checkbox}
169
+ </CapColumn>
170
+ );
171
+ };
172
+
173
+ MpushCheckboxField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func };
174
+ MpushCheckboxField.defaultProps = { ...dataFieldDefaultProps, onEvent: undefined };
175
+
176
+ // Direct renderer (needs the full legacy formData for the value-resolution chain).
177
+ export const RadioGroupField = ({
178
+ field, error, onChange, onEvent, renderContext,
179
+ }) => {
180
+ const showError = shouldShowError(renderContext, error);
181
+ // Classic handleSetRadioValue (2729-2736): tab value -> root value -> schema default.
182
+ const legacy = renderContext?.legacyFormData || {};
183
+ const tab = legacy[(renderContext?.paneTabIndex ?? renderContext?.activeTabIndex) || 0] || {};
184
+ const resolvedValue = tab[field.id] !== undefined && tab[field.id] !== ''
185
+ ? tab[field.id]
186
+ : (legacy[field.id] !== undefined && legacy[field.id] !== '' ? legacy[field.id] : field.value);
187
+ return (
188
+ <CapColumn key={`input-${field.id}`} span={field.width} offset={field.offset}>
189
+ <CapRadioGroup
190
+ key={`${field.id}-radio-group`}
191
+ className={`form-builder-radio-group ${showError ? 'error' : ''}`}
192
+ errorMessage={field.errorMessage && showError ? field.errorMessage : ''}
193
+ onChange={(e) => {
194
+ const nextValue = e.target.value;
195
+ onChange(nextValue);
196
+ onEvent('onChange', nextValue); // -> injected onLinkTypeChange (commit signature)
197
+ }}
198
+ style={field.style || {}}
199
+ value={resolvedValue}
200
+ name={field.name || `${field.id}-name`}
201
+ disabled={field.disabled}
202
+ >
203
+ {(field.options || []).map((option, index) => (
204
+ <CapRadio
205
+ key={`${field.id}-radio-${index}`}
206
+ value={option}
207
+ inductiveText={field.inductiveText && field.inductiveText[index]}
208
+ >
209
+ {option}
210
+ </CapRadio>
211
+ ))}
212
+ </CapRadioGroup>
213
+ </CapColumn>
214
+ );
215
+ };
216
+
217
+ RadioGroupField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
218
+ RadioGroupField.defaultProps = { ...dataFieldDefaultProps };
219
+
220
+ // Direct renderer (options may be overridden through `${id}-options` in the form data).
221
+ export const SelectField = ({
222
+ field, value, error, onChange, onEvent, renderContext,
223
+ }) => {
224
+ const showError = shouldShowError(renderContext, error);
225
+ const options = renderContext?.legacyFormData?.[`${field.id}-options`] || field.options;
226
+ return (
227
+ <CapColumn key={`select-${field.id}`} style={field.colStyle || {}} span={field.width} offset={field.offset}>
228
+ <CapSelect
229
+ id={field.id}
230
+ options={options}
231
+ placeholder={field.placeholder || ''}
232
+ style={field.style || {}}
233
+ onSelect={(data) => {
234
+ onChange(data);
235
+ onEvent('onSelect', data); // -> injected showCtaKeys / onTemplateChange (commit signature)
236
+ }}
237
+ value={value}
238
+ disabled={field.disabled}
239
+ label={field.label}
240
+ />
241
+ {showError && field.errorMessage && <span className="error">{field.errorMessage}</span>}
242
+ </CapColumn>
243
+ );
244
+ };
245
+
246
+ SelectField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
247
+ SelectField.defaultProps = { ...dataFieldDefaultProps };
248
+
249
+ // Image upload (imageSchema only), Classic.js:3657-3690 + 2737-2764: the 5 MB check
250
+ // and dimension read happen here; the upload + formData write stay container-owned.
251
+ export const UploadField = ({
252
+ field, value, error, onEvent, renderContext,
253
+ }) => {
254
+ // The only field whose error display also honors startValidation (Classic.js:3666).
255
+ const showError = Boolean(
256
+ (renderContext?.checkValidation || renderContext?.startValidation) && error,
257
+ );
258
+ const isImage = Boolean(value);
259
+
260
+ const openFileDialog = (e) => {
261
+ if (e) e.preventDefault();
262
+ const fileInput = document.querySelector(`#${field.id} #fileName`);
263
+ if (fileInput) fileInput.click();
264
+ };
265
+
266
+ const onFileChange = (e) => {
267
+ const files = e?.target?.files;
268
+ const file = files && files[0];
269
+ if (!file) return;
270
+ if (field.supportedExtensions && !ALLOWED_IMAGE_EXTENSIONS.exec(file.name)) {
271
+ // Classic fires the wrong-file event and CONTINUES (no early return) — preserved.
272
+ onEvent(field.submitAction, { file, type: 'wrong file' });
273
+ }
274
+ const objectUrl = (window.URL || window.webkitURL).createObjectURL(file);
275
+ const img = new Image();
276
+ img.src = objectUrl;
277
+ img.onload = () => {
278
+ const fileParams = {
279
+ width: img.width,
280
+ height: img.height,
281
+ error: Boolean(file && (file.size / 1e6 > 5)), // > 5 MB (Classic.js:2753)
282
+ };
283
+ onEvent(field.submitAction, { file, type: 'image', fileParams });
284
+ };
285
+ e.target.value = null;
286
+ };
287
+
288
+ const ImageComponent = (imageProps) => (
289
+ <div key={`${field.id}-preview`}>
290
+ <div className={`image-container ${imageProps.ifError ? 'error' : ''}`}>
291
+ {isImage
292
+ ? <CapImage src={value} alt={imageProps.alt} style={imageProps.style} />
293
+ : (
294
+ <div className="fb-upload-placeholder">
295
+ <span className="image-placeholder">{imageProps.placeholder}</span>
296
+ </div>
297
+ )}
298
+ </div>
299
+ </div>
300
+ );
301
+ const WithLabel = LabelHOC(ImageComponent);
302
+ const { errorMessage, ...previewRest } = field.previewProps || {};
303
+
304
+ return (
305
+ <CapColumn span={field.width} offset={field.offset} style={field.style}>
306
+ {field.showPreview && (
307
+ <WithLabel
308
+ key={`${field.id}-with-label`}
309
+ {...previewRest}
310
+ errorMessage={showError && errorMessage}
311
+ ifError={showError}
312
+ />
313
+ )}
314
+ <form encType="multipart/form-data" id={field.id}>
315
+ {/* Native file input on purpose — openFileDialog clicks it via #fileName;
316
+ CapInput wraps antd's text Input (affix wrapper + suffix), which cannot
317
+ host type="file". Hidden: the visible control is the CapButton below. */}
318
+ <input
319
+ key={field.id}
320
+ className="fb-upload-file-input"
321
+ id="fileName"
322
+ type="file"
323
+ onChange={onFileChange}
324
+ accept={field.supportedExtensions ? field.supportedExtensions : 'image/*'}
325
+ />
326
+ <CapButton
327
+ disabled={field.disabled || false}
328
+ type="link"
329
+ prefix={<CapIcon size="s" type="add-photo" />}
330
+ onClick={openFileDialog}
331
+ // color is state-dependent and must beat CapButton's own runtime styling —
332
+ // kept inline (Classic.js:3399 verbatim).
333
+ style={{ float: 'right', color: `${field.disabled ? FONT_COLOR_04 : FONT_COLOR_05}` }}
334
+ >
335
+ {field.label}
336
+ </CapButton>
337
+ </form>
338
+ </CapColumn>
339
+ );
340
+ };
341
+
342
+ UploadField.propTypes = { ...dataFieldPropTypes, onEvent: PropTypes.func.isRequired };
343
+ UploadField.defaultProps = { ...dataFieldDefaultProps };
344
+
345
+ // Primitive div (tab headers, copy links, key labels), Classic.js:3304-3328:
346
+ // value resolves from the ACTIVE tab; DOM id gets the legacy pane suffix (`${id}2`).
347
+ export const MpushDivField = ({ field, onEvent, renderContext }) => {
348
+ const paneTabIndex = renderContext?.paneTabIndex ?? renderContext?.activeTabIndex ?? 0;
349
+ const activeTab = renderContext?.legacyFormData?.[renderContext?.activeTabIndex ?? 0] || {};
350
+ const children = activeTab[field.id] || field.value || '';
351
+ const domId = `${field.id}${paneTabIndex > 0 ? paneTabIndex + 1 : ''}`;
352
+ return (
353
+ <div
354
+ className={field.className || ''}
355
+ id={domId}
356
+ onClick={(data) => field.submitAction && onEvent(field.submitAction, data)}
357
+ style={field.style || {}}
358
+ // Classic fires the injected 'onChange' via callChildEvent's TAIL signature
359
+ // (data, field.id) — the shell's commit-signature branch is type-guarded to
360
+ // checkbox/radioGroup/select, so a div's onChange takes the tail path.
361
+ onInput={(e) => { e.stopPropagation(); onEvent('onChange', e.target.textContent); }}
362
+ >
363
+ {children}
364
+ </div>
365
+ );
366
+ };
367
+
368
+ MpushDivField.propTypes = {
369
+ field: PropTypes.object.isRequired,
370
+ onEvent: PropTypes.func.isRequired,
371
+ renderContext: PropTypes.object.isRequired,
372
+ };
373
+
374
+ // Direct renderer — CTA delete icons.
375
+ export const IconField = ({ field, onEvent }) => (
376
+ <CapColumn
377
+ key={`icon-${field.id}`}
378
+ id={field.id}
379
+ offset={field.offset}
380
+ width={field.width || 1}
381
+ onClick={(data) => field.submitAction && onEvent(field.submitAction, data)}
382
+ className={field.className || ''}
383
+ style={field.colStyle || {}}
384
+ >
385
+ <i style={field.style || {}} className="material-icons">{field.value || ''}</i>
386
+ </CapColumn>
387
+ );
388
+
389
+ IconField.propTypes = {
390
+ field: PropTypes.object.isRequired,
391
+ onEvent: PropTypes.func.isRequired,
392
+ };
393
+
394
+ // Device preview, Classic.js:3897-3943 quirks preserved: both panes show the ACTIVE
395
+ // tab's content and the frame is hardcoded to ANDROID with the toggle hidden.
396
+ export const MobilePushPreviewField = ({ field, renderContext }) => {
397
+ const activeTab = renderContext?.legacyFormData?.[renderContext?.activeTabIndex ?? 0];
398
+ if (!activeTab) return null;
399
+ const header = activeTab[field.content?.title] || '';
400
+ const bodyText = activeTab[field.content?.message] || '';
401
+ const bodyImage = activeTab.image || '';
402
+ const actions = [];
403
+ if (activeTab[field.content?.secondaryCta1]) actions.push({ label: activeTab[field.content.secondaryCta1] });
404
+ if (activeTab[field.content?.secondaryCta2]) actions.push({ label: activeTab[field.content.secondaryCta2] });
405
+ const platformContent = {
406
+ header, bodyText, bodyImage, actions, appName: field.content?.appName || '',
407
+ };
408
+ const mobilePushContent = { androidContent: platformContent, iosContent: platformContent };
409
+ return (
410
+ <CapColumn key="input" span={23} offset={1}>
411
+ <UnifiedPreview
412
+ key={field.id}
413
+ style={field.customStyling || {}}
414
+ channel={field.channel || MOBILE_PUSH}
415
+ content={mobilePushContent}
416
+ device={ANDROID}
417
+ showDeviceToggle={false}
418
+ showHeader={false}
419
+ formatMessage={renderContext?.intl?.formatMessage}
420
+ />
421
+ </CapColumn>
422
+ );
423
+ };
424
+
425
+ MobilePushPreviewField.propTypes = {
426
+ field: PropTypes.object.isRequired,
427
+ renderContext: PropTypes.object.isRequired,
428
+ };
429
+
430
+ /** Build a registry pre-loaded with the MOBILEPUSH field renderers. */
431
+ export const createMpushRegistry = () => createFieldRegistry({
432
+ [FIELD_TYPE.INPUT]: MpushInputField,
433
+ [FIELD_TYPE.TEXTAREA]: MpushTextAreaField,
434
+ [FIELD_TYPE.CHECKBOX]: MpushCheckboxField,
435
+ [FIELD_TYPE.BUTTON]: ButtonField,
436
+ // TagListField mirrors Classic's SEMANTIC tag-list path (Classic.js:3730-3769):
437
+ // span = field.width || '', field.style applied — the CTA splices rely on it.
438
+ [FIELD_TYPE.TAG_LIST]: TagListField,
439
+ [FIELD_TYPE.RADIO_GROUP]: RadioGroupField,
440
+ [FIELD_TYPE.SELECT]: SelectField,
441
+ [FIELD_TYPE.UPLOAD]: UploadField,
442
+ [FIELD_TYPE.DIV]: MpushDivField,
443
+ [FIELD_TYPE.ICON]: IconField,
444
+ [FIELD_TYPE.MOBILE_PUSH_PREVIEW]: MobilePushPreviewField,
445
+ });
446
+
447
+ 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
  });