@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
@@ -45,9 +45,10 @@ export const GIFT_CARDS = 'GIFT_CARDS';
45
45
  export const PROMO_ENGINE = 'PROMO_ENGINE';
46
46
  export const ENABLE_NEW_MPUSH = 'ENABLE_NEW_MPUSH';
47
47
  export const ENABLE_NEW_EDITOR_FLOW_INAPP = 'ENABLE_NEW_EDITOR_FLOW_INAPP';
48
- // Per-channel flag for the FormBuilder V3 migration. Routes SMS templates to the
49
- // new functional FormBuilder; later channels get their own flags (…_MPUSH, …_EMAIL).
48
+ // Per-channel flags for the FormBuilder V3 migration. Each routes its channel to
49
+ // the new functional FormBuilder; later channels get their own flags (…_EMAIL).
50
50
  export const ENABLE_NEW_FORMBUILDER_SMS = 'ENABLE_NEW_FORMBUILDER_SMS';
51
+ export const ENABLE_NEW_FORMBUILDER_MPUSH = 'ENABLE_NEW_FORMBUILDER_MPUSH';
51
52
  export const SUPPORT_CK_EDITOR = 'SUPPORT_CK_EDITOR';
52
53
  export const CUSTOM_TAG = 'CustomTagMessage';
53
54
  export const CUSTOMER_EXTENDED_FIELD = 'Customer extended fields';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.56",
4
+ "version": "9.0.57-alpha.0",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/utils/common.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  ENABLE_NEW_MPUSH,
28
28
  ENABLE_NEW_EDITOR_FLOW_INAPP,
29
29
  ENABLE_NEW_FORMBUILDER_SMS,
30
+ ENABLE_NEW_FORMBUILDER_MPUSH,
30
31
  SUPPORT_ENGAGEMENT_MODULE,
31
32
  ENABLE_CREATIVES_ARCHIVAL,
32
33
  } from '../constants/unified';
@@ -166,6 +167,13 @@ export const hasNewFormBuilderEnabledForSms = Auth.hasFeatureAccess.bind(
166
167
  ENABLE_NEW_FORMBUILDER_SMS,
167
168
  );
168
169
 
170
+ // FormBuilder V3 — per-org flag that routes the MOBILEPUSH channel to the new
171
+ // functional FormBuilder. Off => the legacy class implementation (Classic).
172
+ export const hasNewFormBuilderEnabledForMpush = Auth.hasFeatureAccess.bind(
173
+ null,
174
+ ENABLE_NEW_FORMBUILDER_MPUSH,
175
+ );
176
+
169
177
  //filtering tags based on scope
170
178
  export const filterTags = (tagsToFilter, tagsList) => tagsList?.filter(
171
179
  (tag) => !tagsToFilter?.includes(tag?.definition?.value)
@@ -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,11 +63,18 @@ 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({});
@@ -71,6 +83,10 @@ const FormBuilderShell = (props) => {
71
83
  // is still computed + emitted via onFormValidityChange on mount; only the inline
72
84
  // display is gated. Can also be driven by the container's checkValidation prop.
73
85
  const [internalCheckValidation, setInternalCheckValidation] = useState(false);
86
+ // Container-driven confirmation modal (MOBILEPUSH single-platform save). The
87
+ // container owns the content (setModalContent -> showModal/modal props); the
88
+ // shell owns visibility after a click, mirroring Classic's local showModal state.
89
+ const [modalVisible, setModalVisible] = useState(false);
74
90
 
75
91
  // refs to latest values for use inside stable/debounced callbacks
76
92
  const stateRef = useRef(state);
@@ -80,6 +96,18 @@ const FormBuilderShell = (props) => {
80
96
  const lastEmittedRef = useRef(null);
81
97
  const didInitRef = useRef(false);
82
98
  const prevStartValidationRef = useRef(startValidation);
99
+ const prevSchemaRef = useRef(null);
100
+ // Deep snapshot of the last processed parent formData — containers mutate it in
101
+ // place (copy-content, tag insertion), so pushes are deep-compared, not ref-keyed.
102
+ const lastSeenParentRef = useRef(null);
103
+ // android/ios validity from the last save-validation (single-platform modal contract).
104
+ const platformRef = useRef(null);
105
+ // Content snapshot of the tags prop (containers rebuild the array each render).
106
+ const lastTagsRef = useRef(null);
107
+ const lastNextStateRef = useRef(null);
108
+ // GOLDEN parity: Classic emits tabCount starting at 1, mirroring the prop only
109
+ // on a parent formData sync — the create flow emits 1 though the containers pass 2.
110
+ const emittedTabCountRef = useRef(1);
83
111
 
84
112
  // Stable handler identities for the memoized FieldSlot: these wrappers delegate to
85
113
  // the latest handler bodies (via a ref), so React.memo can bail out on sibling changes.
@@ -91,11 +119,20 @@ const FormBuilderShell = (props) => {
91
119
  const isEmbedded = location?.query?.type === EMBEDDED;
92
120
  const currentModule = tagModule || location?.query?.module || DEFAULT_MODULE;
93
121
 
94
- const runValidate = (legacy) => adapter.validate(legacy, {
122
+ const activeTabIndex = state?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
123
+ const currentTab = activeTabIndex + 1; // legacy 1-based (MOBILEPUSH: 1=Android, 2=iOS)
124
+ const tabCount = state?.tabs?.length || 1;
125
+ const hasTabs = Boolean(adapter?.config?.features?.tabs);
126
+
127
+ const runValidate = (legacy, extra = {}) => adapter.validate(legacy, {
95
128
  tags: propsRef.current.tags,
96
129
  isFullMode,
97
130
  isEmbedded,
98
131
  currentModule,
132
+ restrictPersonalization: propsRef.current.restrictPersonalization,
133
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
134
+ schema: propsRef.current.schema,
135
+ ...extra,
99
136
  });
100
137
 
101
138
  // Footer state (mirrors Classic's `liquidErrorMessage`). Holds the last
@@ -103,45 +140,66 @@ const FormBuilderShell = (props) => {
103
140
  // clobber each other before forwarding to the container's showLiquidErrorInFooter.
104
141
  const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
105
142
  const liquidEnabled = Boolean(adapter.config?.features?.liquid);
143
+ // Classic passes null as the footer's tab argument for SMS and the live 1-based
144
+ // currentTab for every other channel (Classic.js:1352, 1487).
145
+ const footerTabArg = () => (adapter.config?.footerTabArg === 'currentTab'
146
+ ? (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1
147
+ : null);
106
148
 
107
149
  const pushFooter = (next) => {
108
150
  footerRef.current = { ...footerRef.current, ...next };
109
151
  if (propsRef.current.showLiquidErrorInFooter) {
110
- propsRef.current.showLiquidErrorInFooter(
111
- footerRef.current,
112
- channel === SMS ? null : propsRef.current.currentTab,
113
- );
152
+ propsRef.current.showLiquidErrorInFooter(footerRef.current, footerTabArg());
114
153
  }
115
154
  };
116
155
 
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) => {
156
+ // Footer-eligible body message for `legacy`, or [] when none. Full mode shows
157
+ // errors inline instead, so the footer is suppressed there; library mode keeps
158
+ // the old flow (message goes to the footer). The extended ctx (errorData,
159
+ // currentTab, schema, checkValidation) feeds the MPUSH footer echo — the SMS
160
+ // descriptor derives everything from `legacy` and ignores the extras.
161
+ const standardFooterMsg = (legacy, result) => {
121
162
  if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
122
163
  const descriptor = adapter.getEditorErrorDescriptor(legacy, {
123
164
  tags: propsRef.current.tags,
124
165
  isFullMode,
125
166
  currentModule,
167
+ errorData: result?.errorData,
168
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
169
+ schema: propsRef.current.schema,
170
+ checkValidation: internalCheckValidation
171
+ || Boolean(propsRef.current.checkValidation)
172
+ || Boolean(propsRef.current.startValidation),
173
+ restrictPersonalization: propsRef.current.restrictPersonalization,
126
174
  });
127
- return descriptor ? [intl.formatMessage(descriptor.message, descriptor.values)] : [];
175
+ if (!descriptor) return [];
176
+ if (descriptor.text) return [descriptor.text];
177
+ return [intl.formatMessage(descriptor.message, descriptor.values)];
128
178
  };
129
179
 
130
180
  const emitValidity = (result, legacy) => {
131
181
  setErrorData(result.errorData);
182
+ if (result.platform) platformRef.current = result.platform;
132
183
  if (propsRef.current.onFormValidityChange) {
133
184
  propsRef.current.onFormValidityChange(result.isValid, result.errorData);
134
185
  }
135
186
  // Footer: clear standard errors when the form is valid; otherwise surface the
136
- // brace/missing-tag message (empty/generic => none). Liquid errors are preserved.
187
+ // body-error message (empty/generic gated per channel). Liquid errors are preserved.
137
188
  if (liquidEnabled) {
138
- pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy) });
189
+ pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy, result) });
139
190
  }
140
191
  };
141
192
 
142
193
  const emitChange = (legacy, field) => {
143
194
  lastEmittedRef.current = legacy;
144
- if (propsRef.current.onChange) propsRef.current.onChange(legacy, 1, 1, field);
195
+ if (propsRef.current.onChange) {
196
+ propsRef.current.onChange(
197
+ legacy,
198
+ emittedTabCountRef.current,
199
+ (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
200
+ field,
201
+ );
202
+ }
145
203
  };
146
204
 
147
205
  const debouncedEmit = useMemo(
@@ -150,31 +208,148 @@ const FormBuilderShell = (props) => {
150
208
  );
151
209
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
152
210
 
211
+ // Classic pushes formData up (props.onChange) on init AND on every schema change
212
+ // (initialiseForm -> resetTabKeys when isSchemaChanged, Classic.js:1906-1911,
213
+ // 740-748) — the MPUSH containers read that copy (copy-content handlers) before
214
+ // any user input. MPUSH-only; SMS keeps its shipped behavior.
215
+ const emitFormDataOnSchemaSync = Boolean(adapter.config?.features?.emitFormDataOnSchemaSync);
216
+
153
217
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
154
- const schemaReady = Boolean(schema?.standalone);
218
+ const schemaReady = Boolean(schema?.standalone || schema?.containers?.length);
155
219
  useEffect(() => {
156
220
  if (schemaReady && !didInitRef.current) {
157
221
  didInitRef.current = true;
222
+ prevSchemaRef.current = schema;
158
223
  const init = buildInitialState(schema, propsRef.current.formData, channel);
224
+ if (isNonEmpty(propsRef.current.formData)) {
225
+ lastSeenParentRef.current = cloneDeep(propsRef.current.formData);
226
+ // Classic mirrors the tabCount prop only alongside a parent formData sync.
227
+ emittedTabCountRef.current = propsRef.current.tabCount || init.tabs?.length || 1;
228
+ }
159
229
  dispatch(hydrate(init));
230
+ lastTagsRef.current = cloneDeep(propsRef.current.tags || null);
160
231
  const legacy = toLegacy(init);
161
232
  emitValidity(runValidate(legacy), legacy);
233
+ if (emitFormDataOnSchemaSync) emitChange(legacy); // seed the container's formData copy
234
+
162
235
  }
163
236
  }, [schemaReady]);
164
237
 
165
- // ---- re-hydrate on a GENUINE external parent push (edit load / discard) ----
238
+ // Classic's validateForm tail always emits onFormValidityChange (Classic.js:1361)
239
+ // and runs on every schema change (542-559), deep-unequal parent push (516-537)
240
+ // and tags change (569-578) — display stays gated. The MPUSH containers gate
241
+ // their save flows on that maintained validity (Create/index.js:101), so these
242
+ // background emissions are load-bearing. SMS keeps its shipped behavior.
243
+ const validateOnExternalChange = Boolean(adapter.config?.features?.validateOnExternalChange);
244
+
245
+ // Background emissions must read the POST-dispatch state (a stateRef snapshot can
246
+ // be stale mid container-setState-cascade): hydration-family effects only ARM this
247
+ // flag; the [state]-keyed effect below emits once the new state has rendered.
248
+ const pendingBackgroundValidateRef = useRef(false);
249
+ const pendingEmitChangeRef = useRef(false);
250
+
251
+ // ---- reconcile on a genuine schema change (CTA splices, text<->image swap) ----
252
+ // Runs inside the reducer (reconcileSchema) — see the action's note on stale snapshots.
253
+ useEffect(() => {
254
+ if (!didInitRef.current || !schema || prevSchemaRef.current === schema) return;
255
+ prevSchemaRef.current = schema;
256
+ dispatch(reconcileSchema(schema, reconcileFormState));
257
+ // Classic re-validates on EVERY schema change (Classic.js:542-559).
258
+ const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
259
+ if (validationActive || validateOnExternalChange) {
260
+ pendingBackgroundValidateRef.current = true;
261
+ }
262
+ // Classic re-emits onChange after a schema-change re-init (Classic.js:1908-1909).
263
+ if (emitFormDataOnSchemaSync) pendingEmitChangeRef.current = true;
264
+ }, [schema]);
265
+
266
+ // ---- re-hydrate on a GENUINE external parent push (edit load / discard / copy) ----
267
+ // No dependency array on purpose: the parent may mutate the same formData object
268
+ // in place (MOBILEPUSH copy-content, tag insertion), which a [parentFormData]-keyed
269
+ // effect would never see. The deep-compare guard keeps this a no-op on quiet renders.
166
270
  useEffect(() => {
167
271
  if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
272
+ if (isEqual(parentFormData, lastSeenParentRef.current)) return; // nothing new from the parent
273
+ lastSeenParentRef.current = cloneDeep(parentFormData);
168
274
  const current = toLegacy(stateRef.current);
169
275
  const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
170
- if (!isEcho) dispatch(hydrate(fromLegacy(parentFormData, { channel })));
171
- }, [parentFormData]);
276
+ if (!isEcho) {
277
+ dispatch(hydrate(fromLegacy(parentFormData, { channel })));
278
+ // Classic mirrors the tabCount prop only alongside a parent formData sync.
279
+ emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
280
+ // Classic validates on every deep-unequal parent push (Classic.js:516-537) —
281
+ // this is what turns the container's isFormValid true before Create/Done.
282
+ if (validateOnExternalChange) {
283
+ pendingBackgroundValidateRef.current = true;
284
+ }
285
+ }
286
+ });
287
+
288
+ // ---- background validity / formData emission from the SETTLED state (see flags above) ----
289
+ useEffect(() => {
290
+ if (!didInitRef.current || (!pendingBackgroundValidateRef.current && !pendingEmitChangeRef.current)) return;
291
+ const legacy = toLegacy(state);
292
+ if (pendingBackgroundValidateRef.current) {
293
+ pendingBackgroundValidateRef.current = false;
294
+ emitValidity(runValidate(legacy), legacy);
295
+ }
296
+ if (pendingEmitChangeRef.current) {
297
+ pendingEmitChangeRef.current = false;
298
+ emitChange(legacy);
299
+ }
300
+ }, [state]);
301
+
302
+ // ---- re-validate when the tag catalog changes (Classic.js:569-578) ----
303
+ // The containers rebuild the tags array every render (Create/index.js:1928), so
304
+ // compare by content, not identity. Emits validity only; display stays gated.
305
+ useEffect(() => {
306
+ if (!didInitRef.current || !validateOnExternalChange) return;
307
+ const nextTags = propsRef.current.tags;
308
+ if (!Array.isArray(nextTags) || !nextTags.length) return;
309
+ if (isEqual(nextTags, lastTagsRef.current)) return;
310
+ lastTagsRef.current = cloneDeep(nextTags);
311
+ const legacy = toLegacy(stateRef.current);
312
+ emitValidity(runValidate(legacy), legacy);
313
+ });
314
+
315
+ // ---- container-driven tab position (MOBILEPUSH: Create forces iOS when Android unsupported) ----
316
+ useEffect(() => {
317
+ if (!hasTabs || typeof currentTabProp !== 'number') return;
318
+ dispatch(setActiveTab(currentTabProp - 1));
319
+ }, [currentTabProp, hasTabs]);
320
+
321
+ // ---- container-driven modal visibility (mirrors Classic's props->state modal sync) ----
322
+ useEffect(() => {
323
+ setModalVisible(Boolean(showModal));
324
+ }, [showModal, modal]);
172
325
 
173
326
  // Submit a form that passed sync validation (mirrors Classic onSubmitWrapper).
174
- // Full mode submits directly; outside it, liquid-supported channels run liquid-tag
327
+ // Channels with their own pipeline (MOBILEPUSH: validateMobilePushContent) run it
328
+ // through adapter.runSubmitPipeline; otherwise the default liquid flow applies:
329
+ // full mode submits directly; outside it, liquid-supported channels run liquid-tag
175
330
  // validation first — a clean result submits, an error goes to the footer and blocks.
176
- const submitValidForm = (legacy, validErrorData) => {
331
+ const submitValidForm = (legacy, validErrorData, { singleTab = null } = {}) => {
177
332
  const currentProps = propsRef.current;
333
+
334
+ if (typeof adapter.runSubmitPipeline === 'function') {
335
+ adapter.runSubmitPipeline(legacy, {
336
+ isFullMode,
337
+ singleTab,
338
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
339
+ baseLanguage: currentProps.baseLanguage,
340
+ getLiquidTags: currentProps.actions?.getLiquidTags,
341
+ formatMessage: currentProps.intl?.formatMessage,
342
+ messages: formMessages,
343
+ }, {
344
+ onSubmit: (payload) => currentProps.onSubmit && currentProps.onSubmit(payload),
345
+ pushFooter,
346
+ stopValidation: () => currentProps.stopValidation && currentProps.stopValidation(),
347
+ onFormValidityChange: (isValid, nextErrorData) => currentProps.onFormValidityChange
348
+ && currentProps.onFormValidityChange(isValid, nextErrorData !== undefined ? nextErrorData : validErrorData),
349
+ });
350
+ return;
351
+ }
352
+
178
353
  const runLiquid = liquidEnabled && !isFullMode;
179
354
  const getLiquidTags = currentProps.actions?.getLiquidTags;
180
355
  if (!runLiquid || typeof getLiquidTags !== 'function') {
@@ -201,18 +376,28 @@ const FormBuilderShell = (props) => {
201
376
  });
202
377
  };
203
378
 
204
- // ---- startValidation rising edge -> validate, then save or stop ----
379
+ // ---- startValidation rising edge -> validate, then save / prompt / stop ----
205
380
  useEffect(() => {
206
381
  if (startValidation && !prevStartValidationRef.current) {
207
382
  setInternalCheckValidation(true); // reveal field error messages from now on
208
383
  debouncedEmit.flush();
209
384
  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();
385
+ const result = runValidate(legacy, { isSave: true });
386
+ if (result.platform) platformRef.current = result.platform;
387
+ if (result.singlePlatformPrompt) {
388
+ // Classic parity (Classic.js:1180-1204): the single-platform confirmation
389
+ // takes over errorData is stored, the container builds the modal, and
390
+ // NOTHING else runs: no validity emission, no footer push, no
391
+ // stopValidation, no submit. The modal is the only continuation.
392
+ setErrorData(result.errorData);
393
+ if (propsRef.current.setModalContent) propsRef.current.setModalContent(result.singlePlatformPrompt);
394
+ } else {
395
+ emitValidity(result, legacy);
396
+ if (result.isValid) {
397
+ submitValidForm(legacy, result.errorData);
398
+ } else if (propsRef.current.stopValidation) {
399
+ propsRef.current.stopValidation();
400
+ }
216
401
  }
217
402
  }
218
403
  prevStartValidationRef.current = startValidation;
@@ -224,18 +409,20 @@ const FormBuilderShell = (props) => {
224
409
  const resolvedFieldErrors = useMemo(() => {
225
410
  if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
226
411
  const editorId = adapter.config?.fieldIds?.editor;
227
- const editorError = editorId && errorData?.[ACTIVE_TAB_INDEX]?.[editorId];
412
+ const editorError = editorId && errorData?.[activeTabIndex]?.[editorId];
228
413
  if (!editorError || !adapter.getEditorErrorDescriptor || !intl) return {};
229
414
  const descriptor = adapter.getEditorErrorDescriptor(toLegacy(stateRef.current), { tags, isFullMode, currentModule });
230
415
  if (descriptor?.kind !== EDITOR_ERROR_KIND.BRACE) return {};
231
416
  return { [editorId]: intl.formatMessage(descriptor.message, descriptor.values) };
232
- }, [errorData, intl, isFullMode, currentModule]);
417
+ }, [errorData, intl, isFullMode, currentModule, activeTabIndex]);
233
418
 
234
419
  // ---- field change ----
235
420
  const onFieldChange = (field, value) => {
236
- const tabIndex = field.standalone ? null : ACTIVE_TAB_INDEX;
421
+ const liveActiveTab = stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
422
+ const tabIndex = field.standalone ? null : liveActiveTab;
237
423
  const action = fieldChanged({ fieldId: field.id, value, tabIndex });
238
424
  const nextState = formReducer(stateRef.current, action);
425
+ lastNextStateRef.current = nextState;
239
426
  dispatch(action); // immediate UI update on next render
240
427
  const legacy = toLegacy(nextState);
241
428
 
@@ -245,11 +432,13 @@ const FormBuilderShell = (props) => {
245
432
  emitChange(legacy, field);
246
433
  }
247
434
 
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.
435
+ // Re-validate on change while errors are shown, so a revealed error clears as
436
+ // soon as the field becomes valid.
251
437
  const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
252
- const validateLive = liquidEnabled && !isFullMode;
438
+ // Classic re-validates pre-save typing only under startValidation (Classic.js:334-345);
439
+ // MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
440
+ const validateLive = liquidEnabled && !isFullMode
441
+ && adapter.config?.features?.liveValidation !== false;
253
442
  if (validationActive || validateLive) {
254
443
  emitValidity(runValidate(legacy), legacy);
255
444
  }
@@ -266,14 +455,78 @@ const FormBuilderShell = (props) => {
266
455
  };
267
456
 
268
457
  // ---- parent bridge: invoke container-injected handlers (Stage A compat) ----
458
+ // Signatures mirror Classic exactly:
459
+ // - onTagSelect -> (data, currentTab, field) [Classic.js:2588-2591]
460
+ // - data-commit events -> (true, updatedLegacyFormData, field) [performFormDataUpdate]
461
+ // (checkbox submit-actions, radio onChange, select onSelect — MOBILEPUSH only,
462
+ // gated on config.features.bridgeFieldEvents so SMS behavior is untouched)
463
+ // - everything else -> (data, field.id) [callChildEvent tail]
269
464
  const onEvent = (field, eventName, data) => {
270
465
  const injected = field.injectedEvents?.[eventName];
271
466
  if (typeof injected !== 'function') return;
467
+ const parent = propsRef.current.parent;
468
+ if (eventName === 'discardValues') {
469
+ // Classic resets its own state on discard (callChildEvent 2617-2625); the
470
+ // create-flow container then pushes EMPTY formData, which rehydrate ignores —
471
+ // so the local re-init must happen here.
472
+ injected.call(parent, data, field.id);
473
+ const fresh = initializeFormState(propsRef.current.schema || {});
474
+ lastEmittedRef.current = null;
475
+ lastSeenParentRef.current = null;
476
+ dispatch(hydrate(fresh));
477
+ setErrorData({});
478
+ setInternalCheckValidation(false);
479
+ const legacy = toLegacy(fresh);
480
+ emitValidity(runValidate(legacy), legacy);
481
+ return;
482
+ }
272
483
  if (eventName === 'onTagSelect') {
273
- injected.call(propsRef.current.parent, data, 1, field); // Classic signature
484
+ injected.call(parent, data, (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1, field);
485
+ } else if (
486
+ DATA_COMMIT_EVENTS.has(eventName)
487
+ && DATA_COMMIT_FIELD_TYPES.has(field.type)
488
+ && adapter.config?.features?.bridgeFieldEvents
489
+ ) {
490
+ injected.call(parent, true, toLegacy(lastNextStateRef.current || stateRef.current), field);
274
491
  } else {
275
- injected.call(propsRef.current.parent, data, field.id); // saveFormData / onClick
492
+ injected.call(parent, data, field.id); // saveFormData / onClick / onUpload / div onInput / …
493
+ }
494
+ };
495
+
496
+ // ---- tab switch (from TabsContainer): state dispatch + container bridge ----
497
+ // Replaces Classic's DOM scan (Classic.js:2526-2551) with the same observable
498
+ // contract: the container's injected onTabChange receives the 1-based index.
499
+ const onTabSwitch = (container, key) => {
500
+ const tabs = stateRef.current?.tabs || [];
501
+ let index = tabs.findIndex((tab) => `${tab?.tabKey}` === `${key}`);
502
+ if (index === -1) {
503
+ const numeric = Number(key);
504
+ index = Number.isNaN(numeric) ? 0 : numeric;
276
505
  }
506
+ dispatch(setActiveTab(index));
507
+ const injected = container?.injectedEvents?.onTabChange;
508
+ if (typeof injected === 'function') injected.call(propsRef.current.parent, index + 1);
509
+ };
510
+
511
+ // ---- confirmation modal (container-owned content, adapter-owned OK semantics) ----
512
+ const handleModalOk = (ev) => {
513
+ // antd v6 wraps button text in a <span>; use currentTarget so the button id resolves.
514
+ const id = (ev?.currentTarget && ev.currentTarget.id) || ev?.target?.id;
515
+ setModalVisible(false);
516
+ if (adapter.modals?.handleOk) {
517
+ adapter.modals.handleOk(id, {
518
+ platform: platformRef.current,
519
+ submitSingleTab: (singleTab) => {
520
+ const legacy = toLegacy(stateRef.current);
521
+ submitValidForm(legacy, errorData, { singleTab });
522
+ },
523
+ });
524
+ }
525
+ };
526
+
527
+ const handleModalCancel = () => {
528
+ setModalVisible(false);
529
+ if (propsRef.current.handleCancelModal) propsRef.current.handleCancelModal();
277
530
  };
278
531
 
279
532
  // Point the stable wrappers at the latest handler bodies (read at call time).
@@ -286,11 +539,16 @@ const FormBuilderShell = (props) => {
286
539
  errorData,
287
540
  registry,
288
541
  checkValidation: internalCheckValidation || Boolean(checkValidation),
289
- activeTabIndex: ACTIVE_TAB_INDEX,
290
- currentTab: 1,
542
+ // Classic's upload case is the only renderer that ALSO honors startValidation
543
+ // for error display (Classic.js:3663) — exposed separately for it.
544
+ startValidation: Boolean(startValidation),
545
+ activeTabIndex,
546
+ currentTab,
547
+ tabCount,
291
548
  onFieldChange: stableOnFieldChange,
292
549
  onFieldBlur: stableOnFieldBlur,
293
550
  onEvent: stableOnEvent,
551
+ onTabSwitch,
294
552
  resolvedFieldErrors,
295
553
  legacyFormData: toLegacy(state),
296
554
  channel,
@@ -314,9 +572,32 @@ const FormBuilderShell = (props) => {
314
572
  const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
315
573
  const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
316
574
 
575
+ // Confirmation modal (Classic getModal 'confirm' variant, Classic.js:597-641).
576
+ // Only channels that declare modal semantics render it (MOBILEPUSH); the footer's
577
+ // primary button carries the modal id — that id IS the handleOk dispatch key.
578
+ // width 520 = the antd Modal default Classic renders (Classic.js:601);
579
+ // CapModal's own default (324) wraps the single-platform prompt to two lines.
580
+ const confirmModal = adapter.modals && modal && (
581
+ <CapModal
582
+ open={modalVisible}
583
+ width={520}
584
+ title={modal.title || ''}
585
+ onCancel={handleModalCancel}
586
+ footer={[
587
+ <CapButton key="back" onClick={handleModalCancel}>{intl.formatMessage(formMessages.cancel)}</CapButton>,
588
+ <CapButton key="submit" type="primary" id={modal.id} onClick={handleModalOk}>
589
+ {intl.formatMessage(formMessages.yes)}
590
+ </CapButton>,
591
+ ]}
592
+ >
593
+ {modal.body || ''}
594
+ </CapModal>
595
+ );
596
+
317
597
  return (
318
598
  <CapSpin spinning={spinning} tip={spinTip}>
319
599
  <SchemaForm schema={schema} renderContext={renderContext} />
600
+ {confirmModal}
320
601
  </CapSpin>
321
602
  );
322
603
  };
@@ -348,6 +629,10 @@ FormBuilderShell.propTypes = {
348
629
  waitEventContextTags: PropTypes.object,
349
630
  restrictPersonalization: PropTypes.bool,
350
631
  refs: PropTypes.object,
632
+ // Tabbed channels (MOBILEPUSH): container-pushed tab position + confirmation modal.
633
+ currentTab: PropTypes.number,
634
+ showModal: PropTypes.bool,
635
+ modal: PropTypes.object,
351
636
  };
352
637
 
353
638
  FormBuilderShell.defaultProps = {
@@ -361,6 +646,9 @@ FormBuilderShell.defaultProps = {
361
646
  waitEventContextTags: {},
362
647
  restrictPersonalization: false,
363
648
  refs: undefined,
649
+ currentTab: undefined,
650
+ showModal: false,
651
+ modal: null,
364
652
  };
365
653
 
366
654
  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;