@capillarytech/creatives-library 9.0.61 → 9.0.63-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 (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
@@ -1,7 +1,8 @@
1
1
  /**
2
- * FormBuilderShell — the functional FormBuilder (Phase 1: SMS). Owns form state in a
3
- * useReducer, bridges to the legacy formData contract via the codec, and validates
4
- * through the channel adapter. Stage A keeps the channel containers unchanged.
2
+ * FormBuilderShell — the functional FormBuilder (Phase 1: SMS; Phase 2: MOBILEPUSH).
3
+ * Owns form state in a useReducer, bridges to the legacy formData contract via the
4
+ * codec, and validates/submits through the channel adapter. Stage A keeps the
5
+ * channel containers unchanged.
5
6
  */
6
7
 
7
8
  import React, {
@@ -10,14 +11,19 @@ import React, {
10
11
  import PropTypes from 'prop-types';
11
12
  import debounce from 'lodash/debounce';
12
13
  import isEqual from 'lodash/isEqual';
14
+ import cloneDeep from 'lodash/cloneDeep';
13
15
  import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
16
+ import CapModal from '@capillarytech/cap-ui-library/CapModal';
17
+ import CapButton from '@capillarytech/cap-ui-library/CapButton';
14
18
  import formReducer, {
15
19
  createInitialState,
16
20
  hydrate,
17
21
  fieldChanged,
22
+ setActiveTab,
23
+ reconcileSchema,
18
24
  } from './core/store/formReducer';
19
25
  import { toLegacy, fromLegacy } from './core/store/toLegacyFormData';
20
- import initializeFormState from './core/schema/initializeFormState';
26
+ import initializeFormState, { reconcileFormState } from './core/schema/initializeFormState';
21
27
  import { getChannelAdapter } from './channels/registry';
22
28
  import { createSmsRegistry } from './renderers/smsRenderers';
23
29
  import { validateLiquidTemplateContent } from '../../../utils/commonUtils';
@@ -26,7 +32,9 @@ import { SMS } from '../../../v2Containers/CreativesContainer/constants';
26
32
  import { DEFAULT as DEFAULT_MODULE, EMBEDDED } from '../../../constants/unified';
27
33
  import formMessages from '../messages';
28
34
  import SchemaForm from './layout/SchemaForm';
29
- import { ACTIVE_TAB_INDEX, EDITOR_ERROR_KIND, HIGH_FREQ_FIELDS } from './constants';
35
+ import {
36
+ ACTIVE_TAB_INDEX, DATA_COMMIT_EVENTS, DATA_COMMIT_FIELD_TYPES, EDITOR_ERROR_KIND, HIGH_FREQ_FIELDS,
37
+ } from './constants';
30
38
 
31
39
  const isNonEmpty = (obj) => obj && Object.keys(obj).length > 0;
32
40
 
@@ -34,9 +42,6 @@ const buildInitialState = (schema, parentFormData, channel) => (isNonEmpty(paren
34
42
  ? fromLegacy(parentFormData, { channel })
35
43
  : initializeFormState(schema || {}));
36
44
 
37
- // Phase 1: only SMS has renderers. Later channels return their own registry.
38
- const registryFor = () => createSmsRegistry();
39
-
40
45
  const FormBuilderShell = (props) => {
41
46
  const {
42
47
  schema,
@@ -58,19 +63,26 @@ const FormBuilderShell = (props) => {
58
63
  waitEventContextTags,
59
64
  restrictPersonalization,
60
65
  refs,
66
+ currentTab: currentTabProp,
67
+ showModal,
68
+ modal,
61
69
  } = props;
62
70
 
63
71
  const channel = (channelProp || schema?.channel || SMS).toUpperCase();
64
72
  const adapter = getChannelAdapter(channel);
65
- const registry = useMemo(() => registryFor(), [channel]);
73
+ // Channels ship their own registry via the adapter; SMS predates the hook and stays on its original factory.
74
+ const registry = useMemo(
75
+ () => (adapter?.createRegistry ? adapter.createRegistry() : createSmsRegistry()),
76
+ [channel],
77
+ );
66
78
 
67
79
  const [state, dispatch] = useReducer(formReducer, channel, createInitialState);
68
80
  const [errorData, setErrorData] = useState({});
69
- // Field error MESSAGES are hidden until the user triggers validation (clicks
70
- // Save / "Done"), matching the class component's `checkValidation` gate. errorData
71
- // is still computed + emitted via onFormValidityChange on mount; only the inline
72
- // display is gated. Can also be driven by the container's checkValidation prop.
81
+ // Error DISPLAY is gated until a save attempt (Classic's checkValidation);
82
+ // errorData itself is still computed and emitted in the background.
73
83
  const [internalCheckValidation, setInternalCheckValidation] = useState(false);
84
+ // Single-platform save modal: container owns content, shell owns visibility (Classic parity).
85
+ const [modalVisible, setModalVisible] = useState(false);
74
86
 
75
87
  // refs to latest values for use inside stable/debounced callbacks
76
88
  const stateRef = useRef(state);
@@ -80,6 +92,18 @@ const FormBuilderShell = (props) => {
80
92
  const lastEmittedRef = useRef(null);
81
93
  const didInitRef = useRef(false);
82
94
  const prevStartValidationRef = useRef(startValidation);
95
+ const prevSchemaRef = useRef(null);
96
+ // Deep snapshot of the last processed parent formData — containers mutate it in
97
+ // place (copy-content, tag insertion), so pushes are deep-compared, not ref-keyed.
98
+ const lastSeenParentRef = useRef(null);
99
+ // android/ios validity from the last save-validation (single-platform modal contract).
100
+ const platformRef = useRef(null);
101
+ // Content snapshot of the tags prop (containers rebuild the array each render).
102
+ const lastTagsRef = useRef(null);
103
+ const lastNextStateRef = useRef(null);
104
+ // GOLDEN parity: Classic emits tabCount starting at 1, mirroring the prop only
105
+ // on a parent formData sync — the create flow emits 1 though the containers pass 2.
106
+ const emittedTabCountRef = useRef(1);
83
107
 
84
108
  // Stable handler identities for the memoized FieldSlot: these wrappers delegate to
85
109
  // the latest handler bodies (via a ref), so React.memo can bail out on sibling changes.
@@ -91,57 +115,82 @@ const FormBuilderShell = (props) => {
91
115
  const isEmbedded = location?.query?.type === EMBEDDED;
92
116
  const currentModule = tagModule || location?.query?.module || DEFAULT_MODULE;
93
117
 
94
- const runValidate = (legacy) => adapter.validate(legacy, {
118
+ const activeTabIndex = state?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
119
+ const currentTab = activeTabIndex + 1; // legacy 1-based (MOBILEPUSH: 1=Android, 2=iOS)
120
+ const tabCount = state?.tabs?.length || 1;
121
+ const hasTabs = Boolean(adapter?.config?.features?.tabs);
122
+
123
+ const runValidate = (legacy, extra = {}) => adapter.validate(legacy, {
95
124
  tags: propsRef.current.tags,
96
125
  isFullMode,
97
126
  isEmbedded,
98
127
  currentModule,
128
+ restrictPersonalization: propsRef.current.restrictPersonalization,
129
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
130
+ schema: propsRef.current.schema,
131
+ ...extra,
99
132
  });
100
133
 
101
- // Footer state (mirrors Classic's `liquidErrorMessage`). Holds the last
102
- // {STANDARD_ERROR_MSG, LIQUID_ERROR_MSG} so standard- and liquid-error pushes don't
103
- // clobber each other before forwarding to the container's showLiquidErrorInFooter.
134
+ // Classic's liquidErrorMessage: keeps STANDARD/LIQUID footer errors from clobbering each other.
104
135
  const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
105
136
  const liquidEnabled = Boolean(adapter.config?.features?.liquid);
137
+ // Classic passes null as the footer's tab argument for SMS and the live 1-based
138
+ // currentTab for every other channel (Classic.js:1352, 1487).
139
+ const footerTabArg = () => (adapter.config?.footerTabArg === 'currentTab'
140
+ ? (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1
141
+ : null);
106
142
 
107
143
  const pushFooter = (next) => {
108
144
  footerRef.current = { ...footerRef.current, ...next };
109
145
  if (propsRef.current.showLiquidErrorInFooter) {
110
- propsRef.current.showLiquidErrorInFooter(
111
- footerRef.current,
112
- channel === SMS ? null : propsRef.current.currentTab,
113
- );
146
+ propsRef.current.showLiquidErrorInFooter(footerRef.current, footerTabArg());
114
147
  }
115
148
  };
116
149
 
117
- // Footer-eligible body message (brace/missing-tag) for `legacy`, or [] when none.
118
- // Full mode shows the brace error inline instead, so the footer is suppressed there;
119
- // library mode keeps the old flow (message goes to the footer).
120
- const standardFooterMsg = (legacy) => {
150
+ // Footer body message for `legacy` ([] when none). Full mode shows errors inline,
151
+ // so the footer is suppressed there; library mode keeps the old footer flow.
152
+ const standardFooterMsg = (legacy, result) => {
121
153
  if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
122
154
  const descriptor = adapter.getEditorErrorDescriptor(legacy, {
123
155
  tags: propsRef.current.tags,
124
156
  isFullMode,
125
157
  currentModule,
158
+ errorData: result?.errorData,
159
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
160
+ schema: propsRef.current.schema,
161
+ checkValidation: internalCheckValidation
162
+ || Boolean(propsRef.current.checkValidation)
163
+ || Boolean(propsRef.current.startValidation),
164
+ restrictPersonalization: propsRef.current.restrictPersonalization,
126
165
  });
127
- return descriptor ? [intl.formatMessage(descriptor.message, descriptor.values)] : [];
166
+ if (!descriptor) return [];
167
+ if (descriptor.text) return [descriptor.text];
168
+ return [intl.formatMessage(descriptor.message, descriptor.values)];
128
169
  };
129
170
 
130
171
  const emitValidity = (result, legacy) => {
131
172
  setErrorData(result.errorData);
173
+ if (result.platform) platformRef.current = result.platform;
132
174
  if (propsRef.current.onFormValidityChange) {
133
175
  propsRef.current.onFormValidityChange(result.isValid, result.errorData);
134
176
  }
135
177
  // Footer: clear standard errors when the form is valid; otherwise surface the
136
- // brace/missing-tag message (empty/generic => none). Liquid errors are preserved.
178
+ // body-error message (empty/generic gated per channel). Liquid errors are preserved.
137
179
  if (liquidEnabled) {
138
- pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy) });
180
+ pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy, result) });
139
181
  }
140
182
  };
141
183
 
142
184
  const emitChange = (legacy, field) => {
143
185
  lastEmittedRef.current = legacy;
144
- if (propsRef.current.onChange) propsRef.current.onChange(legacy, 1, 1, field);
186
+ if (propsRef.current.onChange) {
187
+ propsRef.current.onChange(
188
+ legacy,
189
+ emittedTabCountRef.current,
190
+ (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
191
+ field,
192
+ );
193
+ }
145
194
  };
146
195
 
147
196
  const debouncedEmit = useMemo(
@@ -150,31 +199,139 @@ const FormBuilderShell = (props) => {
150
199
  );
151
200
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
152
201
 
202
+ // Classic pushes formData up on init and on every schema re-init (Classic.js:1906-1911 ->
203
+ // 740-748); the MPUSH copy-content handlers read that copy. MPUSH-only, SMS unchanged.
204
+ const emitFormDataOnSchemaSync = Boolean(adapter.config?.features?.emitFormDataOnSchemaSync);
205
+
153
206
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
154
- const schemaReady = Boolean(schema?.standalone);
207
+ const schemaReady = Boolean(schema?.standalone || schema?.containers?.length);
155
208
  useEffect(() => {
156
209
  if (schemaReady && !didInitRef.current) {
157
210
  didInitRef.current = true;
211
+ prevSchemaRef.current = schema;
158
212
  const init = buildInitialState(schema, propsRef.current.formData, channel);
213
+ if (isNonEmpty(propsRef.current.formData)) {
214
+ lastSeenParentRef.current = cloneDeep(propsRef.current.formData);
215
+ // Classic mirrors the tabCount prop only alongside a parent formData sync.
216
+ emittedTabCountRef.current = propsRef.current.tabCount || init.tabs?.length || 1;
217
+ }
159
218
  dispatch(hydrate(init));
219
+ lastTagsRef.current = cloneDeep(propsRef.current.tags || null);
160
220
  const legacy = toLegacy(init);
161
221
  emitValidity(runValidate(legacy), legacy);
222
+ if (emitFormDataOnSchemaSync) emitChange(legacy); // seed the container's formData copy
223
+
162
224
  }
163
225
  }, [schemaReady]);
164
226
 
165
- // ---- re-hydrate on a GENUINE external parent push (edit load / discard) ----
227
+ // Classic validates + emits on every schema/parent/tags change (Classic.js:516-578, 1361);
228
+ // the MPUSH containers gate their save flows on that validity. SMS keeps shipped behavior.
229
+ const validateOnExternalChange = Boolean(adapter.config?.features?.validateOnExternalChange);
230
+
231
+ // Background emissions must read POST-dispatch state (stateRef can be stale mid
232
+ // container-setState-cascade): effects ARM these; the [state] effect below emits.
233
+ const pendingBackgroundValidateRef = useRef(false);
234
+ const pendingEmitChangeRef = useRef(false);
235
+
236
+ // ---- reconcile on a genuine schema change (CTA splices, text<->image swap) ----
237
+ // Runs inside the reducer (reconcileSchema) — see the action's note on stale snapshots.
238
+ useEffect(() => {
239
+ if (!didInitRef.current || !schema || prevSchemaRef.current === schema) return;
240
+ prevSchemaRef.current = schema;
241
+ dispatch(reconcileSchema(schema, reconcileFormState));
242
+ // Classic re-validates on EVERY schema change (Classic.js:542-559).
243
+ const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
244
+ if (validationActive || validateOnExternalChange) {
245
+ pendingBackgroundValidateRef.current = true;
246
+ }
247
+ // Classic re-emits onChange after a schema-change re-init (Classic.js:1908-1909).
248
+ if (emitFormDataOnSchemaSync) pendingEmitChangeRef.current = true;
249
+ }, [schema]);
250
+
251
+ // ---- re-hydrate on a GENUINE external parent push (edit load / discard / copy) ----
252
+ // Contract: parent pushes must arrive as NEW references (all containers comply);
253
+ // the deep-compare guards below are the real gate, mirroring Classic's CWRP (Classic.js:516).
166
254
  useEffect(() => {
167
255
  if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
256
+ if (isEqual(parentFormData, lastSeenParentRef.current)) return; // nothing new from the parent
257
+ lastSeenParentRef.current = cloneDeep(parentFormData);
168
258
  const current = toLegacy(stateRef.current);
169
259
  const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
170
- if (!isEcho) dispatch(hydrate(fromLegacy(parentFormData, { channel })));
260
+ if (!isEcho) {
261
+ dispatch(hydrate(fromLegacy(parentFormData, { channel })));
262
+ // Classic mirrors the tabCount prop on a data sync only in EDIT mode (Classic.js:446-447);
263
+ // adopting the create flow's hardcoded 2 makes the emptiness check scan the empty iOS tab.
264
+ if (propsRef.current.isEdit) {
265
+ emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
266
+ }
267
+ // Classic validates on every deep-unequal parent push (Classic.js:516-537).
268
+ if (validateOnExternalChange) {
269
+ pendingBackgroundValidateRef.current = true;
270
+ }
271
+ }
171
272
  }, [parentFormData]);
172
273
 
173
- // Submit a form that passed sync validation (mirrors Classic onSubmitWrapper).
174
- // Full mode submits directly; outside it, liquid-supported channels run liquid-tag
175
- // validation first a clean result submits, an error goes to the footer and blocks.
176
- const submitValidForm = (legacy, validErrorData) => {
274
+ // ---- background validity / formData emission from the SETTLED state (see flags above) ----
275
+ useEffect(() => {
276
+ if (!didInitRef.current || (!pendingBackgroundValidateRef.current && !pendingEmitChangeRef.current)) return;
277
+ const legacy = toLegacy(state);
278
+ if (pendingBackgroundValidateRef.current) {
279
+ pendingBackgroundValidateRef.current = false;
280
+ emitValidity(runValidate(legacy), legacy);
281
+ }
282
+ if (pendingEmitChangeRef.current) {
283
+ pendingEmitChangeRef.current = false;
284
+ emitChange(legacy);
285
+ }
286
+ }, [state]);
287
+
288
+ // ---- re-validate on a tags-catalog change (Classic.js:569-578) — containers rebuild
289
+ // the array every render, so compare by content; emits validity only, display stays gated ----
290
+ useEffect(() => {
291
+ if (!didInitRef.current || !validateOnExternalChange) return;
292
+ const nextTags = propsRef.current.tags;
293
+ if (!Array.isArray(nextTags) || !nextTags.length) return;
294
+ if (isEqual(nextTags, lastTagsRef.current)) return;
295
+ lastTagsRef.current = cloneDeep(nextTags);
296
+ const legacy = toLegacy(stateRef.current);
297
+ emitValidity(runValidate(legacy), legacy);
298
+ });
299
+
300
+ // ---- container-driven tab position (MOBILEPUSH: Create forces iOS when Android unsupported) ----
301
+ useEffect(() => {
302
+ if (!hasTabs || typeof currentTabProp !== 'number') return;
303
+ dispatch(setActiveTab(currentTabProp - 1));
304
+ }, [currentTabProp, hasTabs]);
305
+
306
+ // ---- container-driven modal visibility (mirrors Classic's props->state modal sync) ----
307
+ useEffect(() => {
308
+ setModalVisible(Boolean(showModal));
309
+ }, [showModal, modal]);
310
+
311
+ // Submit after sync validation (Classic onSubmitWrapper): adapter.runSubmitPipeline
312
+ // when the channel has one, else the default liquid flow (validate outside full mode).
313
+ const submitValidForm = (legacy, validErrorData, { singleTab = null } = {}) => {
177
314
  const currentProps = propsRef.current;
315
+
316
+ if (typeof adapter.runSubmitPipeline === 'function') {
317
+ adapter.runSubmitPipeline(legacy, {
318
+ isFullMode,
319
+ singleTab,
320
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
321
+ baseLanguage: currentProps.baseLanguage,
322
+ getLiquidTags: currentProps.actions?.getLiquidTags,
323
+ formatMessage: currentProps.intl?.formatMessage,
324
+ messages: formMessages,
325
+ }, {
326
+ onSubmit: (payload) => currentProps.onSubmit && currentProps.onSubmit(payload),
327
+ pushFooter,
328
+ stopValidation: () => currentProps.stopValidation && currentProps.stopValidation(),
329
+ onFormValidityChange: (isValid, nextErrorData) => currentProps.onFormValidityChange
330
+ && currentProps.onFormValidityChange(isValid, nextErrorData !== undefined ? nextErrorData : validErrorData),
331
+ });
332
+ return;
333
+ }
334
+
178
335
  const runLiquid = liquidEnabled && !isFullMode;
179
336
  const getLiquidTags = currentProps.actions?.getLiquidTags;
180
337
  if (!runLiquid || typeof getLiquidTags !== 'function') {
@@ -191,8 +348,7 @@ const FormBuilderShell = (props) => {
191
348
  onError: ({ standardErrors, liquidErrors }) => {
192
349
  pushFooter({ STANDARD_ERROR_MSG: standardErrors, LIQUID_ERROR_MSG: liquidErrors });
193
350
  if (currentProps.stopValidation) currentProps.stopValidation();
194
- // Classic passes the (clean) sync errorData here: the footer carries the
195
- // liquid error, not a per-field error.
351
+ // Classic passes the clean sync errorData: the footer carries the liquid error.
196
352
  if (currentProps.onFormValidityChange) currentProps.onFormValidityChange(false, validErrorData);
197
353
  },
198
354
  onSuccess: () => {
@@ -201,41 +357,50 @@ const FormBuilderShell = (props) => {
201
357
  });
202
358
  };
203
359
 
204
- // ---- startValidation rising edge -> validate, then save or stop ----
360
+ // ---- startValidation rising edge -> validate, then save / prompt / stop ----
205
361
  useEffect(() => {
206
362
  if (startValidation && !prevStartValidationRef.current) {
207
363
  setInternalCheckValidation(true); // reveal field error messages from now on
208
364
  debouncedEmit.flush();
209
365
  const legacy = toLegacy(stateRef.current);
210
- const result = runValidate(legacy);
211
- emitValidity(result, legacy);
212
- if (result.isValid) {
213
- submitValidForm(legacy, result.errorData);
214
- } else if (propsRef.current.stopValidation) {
215
- propsRef.current.stopValidation();
366
+ const result = runValidate(legacy, { isSave: true });
367
+ if (result.platform) platformRef.current = result.platform;
368
+ if (result.singlePlatformPrompt) {
369
+ // Classic.js:1180-1204: the single-platform modal takes over — store errorData
370
+ // and STOP (no validity emission, footer push, stopValidation, or submit).
371
+ setErrorData(result.errorData);
372
+ if (propsRef.current.setModalContent) propsRef.current.setModalContent(result.singlePlatformPrompt);
373
+ } else {
374
+ emitValidity(result, legacy);
375
+ if (result.isValid) {
376
+ submitValidForm(legacy, result.errorData);
377
+ } else if (propsRef.current.stopValidation) {
378
+ propsRef.current.stopValidation();
379
+ }
216
380
  }
217
381
  }
218
382
  prevStartValidationRef.current = startValidation;
219
383
  }, [startValidation]);
220
384
 
221
- // Inline (below-the-box) message map. The only change vs the old flow: in full mode
222
- // the brace error shows inline instead of the footer. Library mode and all other
223
- // body errors keep the old placement.
385
+ // Inline message map: in full mode the brace error shows inline instead of the
386
+ // footer; library mode and all other body errors keep the old placement.
224
387
  const resolvedFieldErrors = useMemo(() => {
225
388
  if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
226
389
  const editorId = adapter.config?.fieldIds?.editor;
227
- const editorError = editorId && errorData?.[ACTIVE_TAB_INDEX]?.[editorId];
390
+ const editorError = editorId && errorData?.[activeTabIndex]?.[editorId];
228
391
  if (!editorError || !adapter.getEditorErrorDescriptor || !intl) return {};
229
392
  const descriptor = adapter.getEditorErrorDescriptor(toLegacy(stateRef.current), { tags, isFullMode, currentModule });
230
393
  if (descriptor?.kind !== EDITOR_ERROR_KIND.BRACE) return {};
231
394
  return { [editorId]: intl.formatMessage(descriptor.message, descriptor.values) };
232
- }, [errorData, intl, isFullMode, currentModule]);
395
+ }, [errorData, intl, isFullMode, currentModule, activeTabIndex]);
233
396
 
234
397
  // ---- field change ----
235
398
  const onFieldChange = (field, value) => {
236
- const tabIndex = field.standalone ? null : ACTIVE_TAB_INDEX;
399
+ const liveActiveTab = stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
400
+ const tabIndex = field.standalone ? null : liveActiveTab;
237
401
  const action = fieldChanged({ fieldId: field.id, value, tabIndex });
238
402
  const nextState = formReducer(stateRef.current, action);
403
+ lastNextStateRef.current = nextState;
239
404
  dispatch(action); // immediate UI update on next render
240
405
  const legacy = toLegacy(nextState);
241
406
 
@@ -245,19 +410,22 @@ const FormBuilderShell = (props) => {
245
410
  emitChange(legacy, field);
246
411
  }
247
412
 
248
- // Re-validate on change while errors are being shown, so a revealed error clears
249
- // as soon as the field becomes valid. Focus-safe because renderers don't toggle
250
- // CapInput's antd suffix (see InputField), so the <input> never remounts.
413
+ // Re-validate on change while errors are shown, so a revealed error clears as
414
+ // soon as the field becomes valid.
251
415
  const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
252
- const validateLive = liquidEnabled && !isFullMode;
253
- if (validationActive || validateLive) {
416
+ // Classic re-validates pre-save typing only under startValidation (Classic.js:334-345);
417
+ // MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
418
+ const validateLive = liquidEnabled && !isFullMode
419
+ && adapter.config?.features?.liveValidation !== false;
420
+ // MPUSH: Classic re-validates every change via the container round-trip (Classic.js:516-537)
421
+ // — keeps the container's isFormValid current for the library Done gate; display stays gated.
422
+ if (validationActive || validateLive || validateOnExternalChange) {
254
423
  emitValidity(runValidate(legacy), legacy);
255
424
  }
256
425
  };
257
426
 
258
- // Re-validate the high-frequency inputs (template-name/subject) on blur,
259
- // unconditionally mirroring Classic's handleFieldBlur. Flush the debounced
260
- // onChange first so the parent has the latest typed value before we validate.
427
+ // Blur on high-freq inputs re-validates unconditionally (Classic handleFieldBlur);
428
+ // flush the debounced onChange first so the parent holds the latest value.
261
429
  const onFieldBlur = (field) => {
262
430
  if (!field || !HIGH_FREQ_FIELDS.includes(field.id)) return;
263
431
  debouncedEmit.flush();
@@ -265,32 +433,93 @@ const FormBuilderShell = (props) => {
265
433
  emitValidity(runValidate(legacy), legacy);
266
434
  };
267
435
 
268
- // ---- parent bridge: invoke container-injected handlers (Stage A compat) ----
436
+ // ---- parent bridge: container-injected handlers, Classic-exact signatures ----
437
+ // onTagSelect (data, tab, field); data-commit events (true, formData, field) MPUSH-only;
438
+ // everything else takes callChildEvent's tail (data, field.id).
269
439
  const onEvent = (field, eventName, data) => {
270
440
  const injected = field.injectedEvents?.[eventName];
271
441
  if (typeof injected !== 'function') return;
442
+ const parent = propsRef.current.parent;
443
+ if (eventName === 'discardValues') {
444
+ // Classic self-resets on discard (2617-2625); the container's empty push is
445
+ // ignored by rehydrate, so the local re-init happens here.
446
+ injected.call(parent, data, field.id);
447
+ const fresh = initializeFormState(propsRef.current.schema || {});
448
+ lastEmittedRef.current = null;
449
+ lastSeenParentRef.current = null;
450
+ dispatch(hydrate(fresh));
451
+ setErrorData({});
452
+ setInternalCheckValidation(false);
453
+ const legacy = toLegacy(fresh);
454
+ emitValidity(runValidate(legacy), legacy);
455
+ return;
456
+ }
272
457
  if (eventName === 'onTagSelect') {
273
- injected.call(propsRef.current.parent, data, 1, field); // Classic signature
458
+ injected.call(parent, data, (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1, field);
459
+ } else if (
460
+ DATA_COMMIT_EVENTS.has(eventName)
461
+ && DATA_COMMIT_FIELD_TYPES.has(field.type)
462
+ && adapter.config?.features?.bridgeFieldEvents
463
+ ) {
464
+ injected.call(parent, true, toLegacy(lastNextStateRef.current || stateRef.current), field);
274
465
  } else {
275
- injected.call(propsRef.current.parent, data, field.id); // saveFormData / onClick
466
+ injected.call(parent, data, field.id); // saveFormData / onClick / onUpload / div onInput / …
467
+ }
468
+ };
469
+
470
+ // ---- tab switch: replaces Classic's DOM scan (2526-2551) with the same contract —
471
+ // the container's injected onTabChange receives the 1-based index ----
472
+ const onTabSwitch = (container, key) => {
473
+ const tabs = stateRef.current?.tabs || [];
474
+ let index = tabs.findIndex((tab) => `${tab?.tabKey}` === `${key}`);
475
+ if (index === -1) {
476
+ const numeric = Number(key);
477
+ index = Number.isNaN(numeric) ? 0 : numeric;
478
+ }
479
+ dispatch(setActiveTab(index));
480
+ const injected = container?.injectedEvents?.onTabChange;
481
+ if (typeof injected === 'function') injected.call(propsRef.current.parent, index + 1);
482
+ };
483
+
484
+ // ---- confirmation modal (container-owned content, adapter-owned OK semantics) ----
485
+ const handleModalOk = (ev) => {
486
+ // antd v6 wraps button text in a <span>; use currentTarget so the button id resolves.
487
+ const id = (ev?.currentTarget && ev.currentTarget.id) || ev?.target?.id;
488
+ setModalVisible(false);
489
+ if (adapter.modals?.handleOk) {
490
+ adapter.modals.handleOk(id, {
491
+ platform: platformRef.current,
492
+ submitSingleTab: (singleTab) => {
493
+ const legacy = toLegacy(stateRef.current);
494
+ submitValidForm(legacy, errorData, { singleTab });
495
+ },
496
+ });
276
497
  }
277
498
  };
278
499
 
500
+ const handleModalCancel = () => {
501
+ setModalVisible(false);
502
+ if (propsRef.current.handleCancelModal) propsRef.current.handleCancelModal();
503
+ };
504
+
279
505
  // Point the stable wrappers at the latest handler bodies (read at call time).
280
506
  handlersRef.current = { onFieldChange, onFieldBlur, onEvent };
281
507
 
282
- // Render context handed to every field renderer (the `renderContext` prop the
283
- // renderers / Section / FieldSlot consume).
508
+ // Render context consumed by every field renderer / Section / FieldSlot.
284
509
  const renderContext = {
285
510
  state,
286
511
  errorData,
287
512
  registry,
288
513
  checkValidation: internalCheckValidation || Boolean(checkValidation),
289
- activeTabIndex: ACTIVE_TAB_INDEX,
290
- currentTab: 1,
514
+ // Upload is the one renderer that also honors startValidation for display (Classic.js:3663).
515
+ startValidation: Boolean(startValidation),
516
+ activeTabIndex,
517
+ currentTab,
518
+ tabCount,
291
519
  onFieldChange: stableOnFieldChange,
292
520
  onFieldBlur: stableOnFieldBlur,
293
521
  onEvent: stableOnEvent,
522
+ onTabSwitch,
294
523
  resolvedFieldErrors,
295
524
  legacyFormData: toLegacy(state),
296
525
  channel,
@@ -308,15 +537,33 @@ const FormBuilderShell = (props) => {
308
537
  refs,
309
538
  };
310
539
 
311
- // Loading overlay — mirrors Classic: spin while liquid-tag extraction is in
312
- // progress (only for liquid-supported channels) or while the layout metadata
313
- // is still being fetched.
540
+ // Classic's overlay: spin during liquid-tag extraction or while layout metadata loads.
314
541
  const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
315
542
  const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
316
543
 
544
+ // Classic's 'confirm' modal (597-641), MOBILEPUSH only; the primary button's id is the
545
+ // handleOk dispatch key. width 520 matches Classic's antd default (CapModal's 324 wraps).
546
+ const confirmModal = adapter.modals && modal && (
547
+ <CapModal
548
+ open={modalVisible}
549
+ width={520}
550
+ title={modal.title || ''}
551
+ onCancel={handleModalCancel}
552
+ footer={[
553
+ <CapButton key="back" onClick={handleModalCancel}>{intl.formatMessage(formMessages.cancel)}</CapButton>,
554
+ <CapButton key="submit" type="primary" id={modal.id} onClick={handleModalOk}>
555
+ {intl.formatMessage(formMessages.yes)}
556
+ </CapButton>,
557
+ ]}
558
+ >
559
+ {modal.body || ''}
560
+ </CapModal>
561
+ );
562
+
317
563
  return (
318
564
  <CapSpin spinning={spinning} tip={spinTip}>
319
565
  <SchemaForm schema={schema} renderContext={renderContext} />
566
+ {confirmModal}
320
567
  </CapSpin>
321
568
  );
322
569
  };
@@ -340,6 +587,7 @@ FormBuilderShell.propTypes = {
340
587
  tagModule: PropTypes.string,
341
588
  tags: PropTypes.array,
342
589
  checkValidation: PropTypes.bool,
590
+ isEdit: PropTypes.bool,
343
591
  injectedTags: PropTypes.object,
344
592
  onContextChange: PropTypes.func,
345
593
  // Shapes match TagList's own contract (array of offer objects / keyed-by-blockId map).
@@ -348,6 +596,10 @@ FormBuilderShell.propTypes = {
348
596
  waitEventContextTags: PropTypes.object,
349
597
  restrictPersonalization: PropTypes.bool,
350
598
  refs: PropTypes.object,
599
+ // Tabbed channels (MOBILEPUSH): container-pushed tab position + confirmation modal.
600
+ currentTab: PropTypes.number,
601
+ showModal: PropTypes.bool,
602
+ modal: PropTypes.object,
351
603
  };
352
604
 
353
605
  FormBuilderShell.defaultProps = {
@@ -361,6 +613,9 @@ FormBuilderShell.defaultProps = {
361
613
  waitEventContextTags: {},
362
614
  restrictPersonalization: false,
363
615
  refs: undefined,
616
+ currentTab: undefined,
617
+ showModal: false,
618
+ modal: null,
364
619
  };
365
620
 
366
621
  export default FormBuilderShell;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * MOBILEPUSH submit payload — identity, exactly like Classic: the container
3
+ * receives the full legacy formData object and builds the API payload itself
4
+ * (getTransformedData). Note that getChannelData has NO MOBILEPUSH branch —
5
+ * Classic short-circuits before reaching it (Classic.js:1402-1405) — so this
6
+ * adapter must never call it.
7
+ */
8
+ const buildSubmitPayload = (legacyFormData) => legacyFormData;
9
+
10
+ export default buildSubmitPayload;