@capillarytech/creatives-library 9.0.56-alpha.7 → 9.0.56-betaa.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 (82) hide show
  1. package/constants/unified.js +4 -2
  2. package/package.json +1 -1
  3. package/services/api.js +0 -20
  4. package/utils/common.js +12 -0
  5. package/utils/templateVarUtils.js +0 -16
  6. package/utils/tests/templateVarUtils.test.js +0 -44
  7. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +0 -13
  8. package/v2Components/CommonTestAndPreview/actions.js +0 -13
  9. package/v2Components/CommonTestAndPreview/constants.js +0 -8
  10. package/v2Components/CommonTestAndPreview/index.js +3 -55
  11. package/v2Components/CommonTestAndPreview/reducer.js +0 -20
  12. package/v2Components/CommonTestAndPreview/sagas.js +0 -28
  13. package/v2Components/CommonTestAndPreview/selectors.js +0 -6
  14. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +0 -106
  15. package/v2Components/CommonTestAndPreview/tests/actions.test.js +0 -15
  16. package/v2Components/CommonTestAndPreview/tests/constants.test.js +1 -9
  17. package/v2Components/CommonTestAndPreview/tests/index.test.js +0 -101
  18. package/v2Components/CommonTestAndPreview/tests/reducer.test.js +0 -41
  19. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +0 -84
  20. package/v2Components/CommonTestAndPreview/tests/selectors.test.js +0 -12
  21. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +320 -46
  22. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
  23. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +82 -0
  24. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
  25. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
  26. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +19 -0
  27. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
  33. package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
  34. package/v2Components/FormBuilder/Functional/constants.js +43 -8
  35. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
  36. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
  37. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
  38. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
  39. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
  40. package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
  41. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +89 -0
  42. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +440 -0
  43. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
  44. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
  45. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +282 -0
  46. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +141 -0
  47. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
  48. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +151 -0
  49. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +342 -0
  50. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
  51. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +110 -0
  52. package/v2Components/FormBuilder/index.js +34 -12
  53. package/v2Components/FormBuilder/tests/entryGate.test.js +94 -7
  54. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
  55. package/v2Components/TestAndPreviewSlidebox/index.js +0 -9
  56. package/v2Containers/CommunicationFlow/CommunicationFlow.js +75 -140
  57. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +2 -18
  58. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +27 -58
  59. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +51 -503
  60. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +4 -40
  61. package/v2Containers/CommunicationFlow/constants.js +4 -65
  62. package/v2Containers/CommunicationFlow/index.js +23 -16
  63. package/v2Containers/CommunicationFlow/messages.js +3 -11
  64. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +11 -24
  65. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +0 -4
  66. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +4 -176
  67. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +0 -3
  68. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +0 -6
  69. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +0 -25
  70. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +4 -11
  71. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +5 -19
  72. package/v2Containers/CreativesContainer/index.js +3 -2
  73. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +5 -5
  74. package/v2Containers/CreativesContainer/tests/index.test.js +0 -12
  75. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +19 -115
  76. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +3 -27
  77. package/v2Containers/WebPush/Create/preview/WebPushPreview.js +12 -17
  78. package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +0 -5
  79. package/v2Containers/Whatsapp/index.js +3 -6
  80. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +34 -306
  81. package/v2Containers/mockdata.js +0 -25
  82. package/v2Containers/CommunicationFlow/Tests/constants.test.js +0 -85
@@ -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,19 @@ 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
74
+ // stays on its original factory.
75
+ const registry = useMemo(
76
+ () => (adapter?.createRegistry ? adapter.createRegistry() : createSmsRegistry()),
77
+ [channel],
78
+ );
66
79
 
67
80
  const [state, dispatch] = useReducer(formReducer, channel, createInitialState);
68
81
  const [errorData, setErrorData] = useState({});
@@ -71,6 +84,10 @@ const FormBuilderShell = (props) => {
71
84
  // is still computed + emitted via onFormValidityChange on mount; only the inline
72
85
  // display is gated. Can also be driven by the container's checkValidation prop.
73
86
  const [internalCheckValidation, setInternalCheckValidation] = useState(false);
87
+ // Container-driven confirmation modal (MOBILEPUSH single-platform save). The
88
+ // container owns the content (setModalContent -> showModal/modal props); the
89
+ // shell owns visibility after a click, mirroring Classic's local showModal state.
90
+ const [modalVisible, setModalVisible] = useState(false);
74
91
 
75
92
  // refs to latest values for use inside stable/debounced callbacks
76
93
  const stateRef = useRef(state);
@@ -80,6 +97,25 @@ const FormBuilderShell = (props) => {
80
97
  const lastEmittedRef = useRef(null);
81
98
  const didInitRef = useRef(false);
82
99
  const prevStartValidationRef = useRef(startValidation);
100
+ const prevSchemaRef = useRef(null);
101
+ // Deep snapshot of the last parent formData we processed. The MOBILEPUSH
102
+ // containers mutate their formData IN PLACE (copy-content, tag insertion), so a
103
+ // reference-keyed effect would miss genuine pushes — we deep-compare on every
104
+ // render instead, exactly like Classic's componentWillReceiveProps.
105
+ const lastSeenParentRef = useRef(null);
106
+ // Platform validity flags from the last save-validation (MOBILEPUSH android/ios
107
+ // modal contract): Classic keeps these in state only after a validate pass.
108
+ const platformRef = useRef(null);
109
+ // Content snapshot of the tags prop for the tags-change re-validation
110
+ // (Classic.js:569-578) — containers rebuild the array each render, so identity
111
+ // is useless. Seeded by the init effect (init already validates with those tags).
112
+ const lastTagsRef = useRef(null);
113
+ const lastNextStateRef = useRef(null);
114
+ // GOLDEN parity (pinned by mpush.characterization): Classic emits
115
+ // this.state.tabCount in onChange, which starts at 1 and is mirrored from the
116
+ // tabCount PROP only when hydrating from a parent formData push (edit sync) —
117
+ // so the MPUSH create flow emits tabCount 1 even though the containers pass 2.
118
+ const emittedTabCountRef = useRef(1);
83
119
 
84
120
  // Stable handler identities for the memoized FieldSlot: these wrappers delegate to
85
121
  // the latest handler bodies (via a ref), so React.memo can bail out on sibling changes.
@@ -91,11 +127,20 @@ const FormBuilderShell = (props) => {
91
127
  const isEmbedded = location?.query?.type === EMBEDDED;
92
128
  const currentModule = tagModule || location?.query?.module || DEFAULT_MODULE;
93
129
 
94
- const runValidate = (legacy) => adapter.validate(legacy, {
130
+ const activeTabIndex = state?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
131
+ const currentTab = activeTabIndex + 1; // legacy 1-based (MOBILEPUSH: 1=Android, 2=iOS)
132
+ const tabCount = state?.tabs?.length || 1;
133
+ const hasTabs = Boolean(adapter?.config?.features?.tabs);
134
+
135
+ const runValidate = (legacy, extra = {}) => adapter.validate(legacy, {
95
136
  tags: propsRef.current.tags,
96
137
  isFullMode,
97
138
  isEmbedded,
98
139
  currentModule,
140
+ restrictPersonalization: propsRef.current.restrictPersonalization,
141
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
142
+ schema: propsRef.current.schema,
143
+ ...extra,
99
144
  });
100
145
 
101
146
  // Footer state (mirrors Classic's `liquidErrorMessage`). Holds the last
@@ -103,45 +148,66 @@ const FormBuilderShell = (props) => {
103
148
  // clobber each other before forwarding to the container's showLiquidErrorInFooter.
104
149
  const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
105
150
  const liquidEnabled = Boolean(adapter.config?.features?.liquid);
151
+ // Classic passes null as the footer's tab argument for SMS and the live 1-based
152
+ // currentTab for every other channel (Classic.js:1352, 1487).
153
+ const footerTabArg = () => (adapter.config?.footerTabArg === 'currentTab'
154
+ ? (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1
155
+ : null);
106
156
 
107
157
  const pushFooter = (next) => {
108
158
  footerRef.current = { ...footerRef.current, ...next };
109
159
  if (propsRef.current.showLiquidErrorInFooter) {
110
- propsRef.current.showLiquidErrorInFooter(
111
- footerRef.current,
112
- channel === SMS ? null : propsRef.current.currentTab,
113
- );
160
+ propsRef.current.showLiquidErrorInFooter(footerRef.current, footerTabArg());
114
161
  }
115
162
  };
116
163
 
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) => {
164
+ // Footer-eligible body message for `legacy`, or [] when none. Full mode shows
165
+ // errors inline instead, so the footer is suppressed there; library mode keeps
166
+ // the old flow (message goes to the footer). The extended ctx (errorData,
167
+ // currentTab, schema, checkValidation) feeds the MPUSH footer echo — the SMS
168
+ // descriptor derives everything from `legacy` and ignores the extras.
169
+ const standardFooterMsg = (legacy, result) => {
121
170
  if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
122
171
  const descriptor = adapter.getEditorErrorDescriptor(legacy, {
123
172
  tags: propsRef.current.tags,
124
173
  isFullMode,
125
174
  currentModule,
175
+ errorData: result?.errorData,
176
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
177
+ schema: propsRef.current.schema,
178
+ checkValidation: internalCheckValidation
179
+ || Boolean(propsRef.current.checkValidation)
180
+ || Boolean(propsRef.current.startValidation),
181
+ restrictPersonalization: propsRef.current.restrictPersonalization,
126
182
  });
127
- return descriptor ? [intl.formatMessage(descriptor.message, descriptor.values)] : [];
183
+ if (!descriptor) return [];
184
+ if (descriptor.text) return [descriptor.text];
185
+ return [intl.formatMessage(descriptor.message, descriptor.values)];
128
186
  };
129
187
 
130
188
  const emitValidity = (result, legacy) => {
131
189
  setErrorData(result.errorData);
190
+ if (result.platform) platformRef.current = result.platform;
132
191
  if (propsRef.current.onFormValidityChange) {
133
192
  propsRef.current.onFormValidityChange(result.isValid, result.errorData);
134
193
  }
135
194
  // Footer: clear standard errors when the form is valid; otherwise surface the
136
- // brace/missing-tag message (empty/generic => none). Liquid errors are preserved.
195
+ // body-error message (empty/generic gated per channel). Liquid errors are preserved.
137
196
  if (liquidEnabled) {
138
- pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy) });
197
+ pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy, result) });
139
198
  }
140
199
  };
141
200
 
142
201
  const emitChange = (legacy, field) => {
143
202
  lastEmittedRef.current = legacy;
144
- if (propsRef.current.onChange) propsRef.current.onChange(legacy, 1, 1, field);
203
+ if (propsRef.current.onChange) {
204
+ propsRef.current.onChange(
205
+ legacy,
206
+ emittedTabCountRef.current,
207
+ (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
208
+ field,
209
+ );
210
+ }
145
211
  };
146
212
 
147
213
  const debouncedEmit = useMemo(
@@ -151,30 +217,130 @@ const FormBuilderShell = (props) => {
151
217
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
152
218
 
153
219
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
154
- const schemaReady = Boolean(schema?.standalone);
220
+ const schemaReady = Boolean(schema?.standalone || schema?.containers?.length);
155
221
  useEffect(() => {
156
222
  if (schemaReady && !didInitRef.current) {
157
223
  didInitRef.current = true;
224
+ prevSchemaRef.current = schema;
158
225
  const init = buildInitialState(schema, propsRef.current.formData, channel);
226
+ if (isNonEmpty(propsRef.current.formData)) {
227
+ lastSeenParentRef.current = cloneDeep(propsRef.current.formData);
228
+ // Classic mirrors the tabCount prop only alongside a parent formData sync.
229
+ emittedTabCountRef.current = propsRef.current.tabCount || init.tabs?.length || 1;
230
+ }
159
231
  dispatch(hydrate(init));
232
+ lastTagsRef.current = cloneDeep(propsRef.current.tags || null);
160
233
  const legacy = toLegacy(init);
161
234
  emitValidity(runValidate(legacy), legacy);
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
+
250
+ // ---- reconcile on a genuine schema change (CTA splices, text<->image swap) ----
251
+ // Runs inside the reducer (reconcileSchema) — see the action's note on stale snapshots.
252
+ useEffect(() => {
253
+ if (!didInitRef.current || !schema || prevSchemaRef.current === schema) return;
254
+ prevSchemaRef.current = schema;
255
+ dispatch(reconcileSchema(schema, reconcileFormState));
256
+ // Classic re-validates on EVERY schema change (Classic.js:542-559).
257
+ const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
258
+ if (validationActive || validateOnExternalChange) {
259
+ pendingBackgroundValidateRef.current = true;
260
+ }
261
+ }, [schema]);
262
+
263
+ // ---- re-hydrate on a GENUINE external parent push (edit load / discard / copy) ----
264
+ // No dependency array on purpose: the parent may mutate the same formData object
265
+ // in place (MOBILEPUSH copy-content, tag insertion), which a [parentFormData]-keyed
266
+ // effect would never see. The deep-compare guard keeps this a no-op on quiet renders.
166
267
  useEffect(() => {
167
268
  if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
269
+ if (isEqual(parentFormData, lastSeenParentRef.current)) return; // nothing new from the parent
270
+ lastSeenParentRef.current = cloneDeep(parentFormData);
168
271
  const current = toLegacy(stateRef.current);
169
272
  const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
170
- if (!isEcho) dispatch(hydrate(fromLegacy(parentFormData, { channel })));
171
- }, [parentFormData]);
273
+ if (!isEcho) {
274
+ dispatch(hydrate(fromLegacy(parentFormData, { channel })));
275
+ // Classic mirrors the tabCount prop only alongside a parent formData sync.
276
+ emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
277
+ // Classic validates on every deep-unequal parent push (Classic.js:516-537) —
278
+ // this is what turns the container's isFormValid true before Create/Done.
279
+ if (validateOnExternalChange) {
280
+ pendingBackgroundValidateRef.current = true;
281
+ }
282
+ }
283
+ });
284
+
285
+ // ---- background validity emission from the SETTLED state (see flag above) ----
286
+ useEffect(() => {
287
+ if (!didInitRef.current || !pendingBackgroundValidateRef.current) return;
288
+ pendingBackgroundValidateRef.current = false;
289
+ const legacy = toLegacy(state);
290
+ emitValidity(runValidate(legacy), legacy);
291
+ }, [state]);
292
+
293
+ // ---- re-validate when the tag catalog changes (Classic.js:569-578) ----
294
+ // The containers rebuild the tags array every render (Create/index.js:1928), so
295
+ // compare by content, not identity. Emits validity only; display stays gated.
296
+ useEffect(() => {
297
+ if (!didInitRef.current || !validateOnExternalChange) return;
298
+ const nextTags = propsRef.current.tags;
299
+ if (!Array.isArray(nextTags) || !nextTags.length) return;
300
+ if (isEqual(nextTags, lastTagsRef.current)) return;
301
+ lastTagsRef.current = cloneDeep(nextTags);
302
+ const legacy = toLegacy(stateRef.current);
303
+ emitValidity(runValidate(legacy), legacy);
304
+ });
305
+
306
+ // ---- container-driven tab position (MOBILEPUSH: Create forces iOS when Android unsupported) ----
307
+ useEffect(() => {
308
+ if (!hasTabs || typeof currentTabProp !== 'number') return;
309
+ dispatch(setActiveTab(currentTabProp - 1));
310
+ }, [currentTabProp, hasTabs]);
311
+
312
+ // ---- container-driven modal visibility (mirrors Classic's props->state modal sync) ----
313
+ useEffect(() => {
314
+ setModalVisible(Boolean(showModal));
315
+ }, [showModal, modal]);
172
316
 
173
317
  // Submit a form that passed sync validation (mirrors Classic onSubmitWrapper).
174
- // Full mode submits directly; outside it, liquid-supported channels run liquid-tag
318
+ // Channels with their own pipeline (MOBILEPUSH: validateMobilePushContent) run it
319
+ // through adapter.runSubmitPipeline; otherwise the default liquid flow applies:
320
+ // full mode submits directly; outside it, liquid-supported channels run liquid-tag
175
321
  // validation first — a clean result submits, an error goes to the footer and blocks.
176
- const submitValidForm = (legacy, validErrorData) => {
322
+ const submitValidForm = (legacy, validErrorData, { singleTab = null } = {}) => {
177
323
  const currentProps = propsRef.current;
324
+
325
+ if (typeof adapter.runSubmitPipeline === 'function') {
326
+ adapter.runSubmitPipeline(legacy, {
327
+ isFullMode,
328
+ singleTab,
329
+ currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
330
+ baseLanguage: currentProps.baseLanguage,
331
+ getLiquidTags: currentProps.actions?.getLiquidTags,
332
+ formatMessage: currentProps.intl?.formatMessage,
333
+ messages: formMessages,
334
+ }, {
335
+ onSubmit: (payload) => currentProps.onSubmit && currentProps.onSubmit(payload),
336
+ pushFooter,
337
+ stopValidation: () => currentProps.stopValidation && currentProps.stopValidation(),
338
+ onFormValidityChange: (isValid, nextErrorData) => currentProps.onFormValidityChange
339
+ && currentProps.onFormValidityChange(isValid, nextErrorData !== undefined ? nextErrorData : validErrorData),
340
+ });
341
+ return;
342
+ }
343
+
178
344
  const runLiquid = liquidEnabled && !isFullMode;
179
345
  const getLiquidTags = currentProps.actions?.getLiquidTags;
180
346
  if (!runLiquid || typeof getLiquidTags !== 'function') {
@@ -201,18 +367,28 @@ const FormBuilderShell = (props) => {
201
367
  });
202
368
  };
203
369
 
204
- // ---- startValidation rising edge -> validate, then save or stop ----
370
+ // ---- startValidation rising edge -> validate, then save / prompt / stop ----
205
371
  useEffect(() => {
206
372
  if (startValidation && !prevStartValidationRef.current) {
207
373
  setInternalCheckValidation(true); // reveal field error messages from now on
208
374
  debouncedEmit.flush();
209
375
  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();
376
+ const result = runValidate(legacy, { isSave: true });
377
+ if (result.platform) platformRef.current = result.platform;
378
+ if (result.singlePlatformPrompt) {
379
+ // Classic parity (Classic.js:1180-1204): the single-platform confirmation
380
+ // takes over — errorData is stored, the container builds the modal, and
381
+ // NOTHING else runs: no validity emission, no footer push, no
382
+ // stopValidation, no submit. The modal is the only continuation.
383
+ setErrorData(result.errorData);
384
+ if (propsRef.current.setModalContent) propsRef.current.setModalContent(result.singlePlatformPrompt);
385
+ } else {
386
+ emitValidity(result, legacy);
387
+ if (result.isValid) {
388
+ submitValidForm(legacy, result.errorData);
389
+ } else if (propsRef.current.stopValidation) {
390
+ propsRef.current.stopValidation();
391
+ }
216
392
  }
217
393
  }
218
394
  prevStartValidationRef.current = startValidation;
@@ -224,18 +400,20 @@ const FormBuilderShell = (props) => {
224
400
  const resolvedFieldErrors = useMemo(() => {
225
401
  if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
226
402
  const editorId = adapter.config?.fieldIds?.editor;
227
- const editorError = editorId && errorData?.[ACTIVE_TAB_INDEX]?.[editorId];
403
+ const editorError = editorId && errorData?.[activeTabIndex]?.[editorId];
228
404
  if (!editorError || !adapter.getEditorErrorDescriptor || !intl) return {};
229
405
  const descriptor = adapter.getEditorErrorDescriptor(toLegacy(stateRef.current), { tags, isFullMode, currentModule });
230
406
  if (descriptor?.kind !== EDITOR_ERROR_KIND.BRACE) return {};
231
407
  return { [editorId]: intl.formatMessage(descriptor.message, descriptor.values) };
232
- }, [errorData, intl, isFullMode, currentModule]);
408
+ }, [errorData, intl, isFullMode, currentModule, activeTabIndex]);
233
409
 
234
410
  // ---- field change ----
235
411
  const onFieldChange = (field, value) => {
236
- const tabIndex = field.standalone ? null : ACTIVE_TAB_INDEX;
412
+ const liveActiveTab = stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
413
+ const tabIndex = field.standalone ? null : liveActiveTab;
237
414
  const action = fieldChanged({ fieldId: field.id, value, tabIndex });
238
415
  const nextState = formReducer(stateRef.current, action);
416
+ lastNextStateRef.current = nextState;
239
417
  dispatch(action); // immediate UI update on next render
240
418
  const legacy = toLegacy(nextState);
241
419
 
@@ -245,11 +423,13 @@ const FormBuilderShell = (props) => {
245
423
  emitChange(legacy, field);
246
424
  }
247
425
 
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.
426
+ // Re-validate on change while errors are shown, so a revealed error clears as
427
+ // soon as the field becomes valid.
251
428
  const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
252
- const validateLive = liquidEnabled && !isFullMode;
429
+ // Classic re-validates pre-save typing only under startValidation (Classic.js:334-345);
430
+ // MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
431
+ const validateLive = liquidEnabled && !isFullMode
432
+ && adapter.config?.features?.liveValidation !== false;
253
433
  if (validationActive || validateLive) {
254
434
  emitValidity(runValidate(legacy), legacy);
255
435
  }
@@ -266,14 +446,78 @@ const FormBuilderShell = (props) => {
266
446
  };
267
447
 
268
448
  // ---- parent bridge: invoke container-injected handlers (Stage A compat) ----
449
+ // Signatures mirror Classic exactly:
450
+ // - onTagSelect -> (data, currentTab, field) [Classic.js:2588-2591]
451
+ // - data-commit events -> (true, updatedLegacyFormData, field) [performFormDataUpdate]
452
+ // (checkbox submit-actions, radio onChange, select onSelect — MOBILEPUSH only,
453
+ // gated on config.features.bridgeFieldEvents so SMS behavior is untouched)
454
+ // - everything else -> (data, field.id) [callChildEvent tail]
269
455
  const onEvent = (field, eventName, data) => {
270
456
  const injected = field.injectedEvents?.[eventName];
271
457
  if (typeof injected !== 'function') return;
458
+ const parent = propsRef.current.parent;
459
+ if (eventName === 'discardValues') {
460
+ // Classic resets its own state on discard (callChildEvent 2617-2625); the
461
+ // create-flow container then pushes EMPTY formData, which rehydrate ignores —
462
+ // so the local re-init must happen here.
463
+ injected.call(parent, data, field.id);
464
+ const fresh = initializeFormState(propsRef.current.schema || {});
465
+ lastEmittedRef.current = null;
466
+ lastSeenParentRef.current = null;
467
+ dispatch(hydrate(fresh));
468
+ setErrorData({});
469
+ setInternalCheckValidation(false);
470
+ const legacy = toLegacy(fresh);
471
+ emitValidity(runValidate(legacy), legacy);
472
+ return;
473
+ }
272
474
  if (eventName === 'onTagSelect') {
273
- injected.call(propsRef.current.parent, data, 1, field); // Classic signature
475
+ injected.call(parent, data, (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1, field);
476
+ } else if (
477
+ DATA_COMMIT_EVENTS.has(eventName)
478
+ && DATA_COMMIT_FIELD_TYPES.has(field.type)
479
+ && adapter.config?.features?.bridgeFieldEvents
480
+ ) {
481
+ injected.call(parent, true, toLegacy(lastNextStateRef.current || stateRef.current), field);
274
482
  } else {
275
- injected.call(propsRef.current.parent, data, field.id); // saveFormData / onClick
483
+ injected.call(parent, data, field.id); // saveFormData / onClick / onUpload / div onInput / …
484
+ }
485
+ };
486
+
487
+ // ---- tab switch (from TabsContainer): state dispatch + container bridge ----
488
+ // Replaces Classic's DOM scan (Classic.js:2526-2551) with the same observable
489
+ // contract: the container's injected onTabChange receives the 1-based index.
490
+ const onTabSwitch = (container, key) => {
491
+ const tabs = stateRef.current?.tabs || [];
492
+ let index = tabs.findIndex((tab) => `${tab?.tabKey}` === `${key}`);
493
+ if (index === -1) {
494
+ const numeric = Number(key);
495
+ index = Number.isNaN(numeric) ? 0 : numeric;
276
496
  }
497
+ dispatch(setActiveTab(index));
498
+ const injected = container?.injectedEvents?.onTabChange;
499
+ if (typeof injected === 'function') injected.call(propsRef.current.parent, index + 1);
500
+ };
501
+
502
+ // ---- confirmation modal (container-owned content, adapter-owned OK semantics) ----
503
+ const handleModalOk = (ev) => {
504
+ // antd v6 wraps button text in a <span>; use currentTarget so the button id resolves.
505
+ const id = (ev?.currentTarget && ev.currentTarget.id) || ev?.target?.id;
506
+ setModalVisible(false);
507
+ if (adapter.modals?.handleOk) {
508
+ adapter.modals.handleOk(id, {
509
+ platform: platformRef.current,
510
+ submitSingleTab: (singleTab) => {
511
+ const legacy = toLegacy(stateRef.current);
512
+ submitValidForm(legacy, errorData, { singleTab });
513
+ },
514
+ });
515
+ }
516
+ };
517
+
518
+ const handleModalCancel = () => {
519
+ setModalVisible(false);
520
+ if (propsRef.current.handleCancelModal) propsRef.current.handleCancelModal();
277
521
  };
278
522
 
279
523
  // Point the stable wrappers at the latest handler bodies (read at call time).
@@ -286,11 +530,16 @@ const FormBuilderShell = (props) => {
286
530
  errorData,
287
531
  registry,
288
532
  checkValidation: internalCheckValidation || Boolean(checkValidation),
289
- activeTabIndex: ACTIVE_TAB_INDEX,
290
- currentTab: 1,
533
+ // Classic's upload case is the only renderer that ALSO honors startValidation
534
+ // for error display (Classic.js:3663) — exposed separately for it.
535
+ startValidation: Boolean(startValidation),
536
+ activeTabIndex,
537
+ currentTab,
538
+ tabCount,
291
539
  onFieldChange: stableOnFieldChange,
292
540
  onFieldBlur: stableOnFieldBlur,
293
541
  onEvent: stableOnEvent,
542
+ onTabSwitch,
294
543
  resolvedFieldErrors,
295
544
  legacyFormData: toLegacy(state),
296
545
  channel,
@@ -314,9 +563,27 @@ const FormBuilderShell = (props) => {
314
563
  const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
315
564
  const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
316
565
 
566
+ // Confirmation modal (Classic getModal 'confirm' variant, Classic.js:597-641).
567
+ // Only channels that declare modal semantics render it (MOBILEPUSH); the footer's
568
+ // primary button carries the modal id — that id IS the handleOk dispatch key.
569
+ const confirmModal = adapter.modals && modal && (
570
+ <CapModal
571
+ visible={modalVisible}
572
+ title={modal.title || ''}
573
+ onCancel={handleModalCancel}
574
+ footer={[
575
+ <CapButton key="back" onClick={handleModalCancel}>Cancel</CapButton>,
576
+ <CapButton key="submit" type="primary" id={modal.id} onClick={handleModalOk}>Yes</CapButton>,
577
+ ]}
578
+ >
579
+ {modal.body || ''}
580
+ </CapModal>
581
+ );
582
+
317
583
  return (
318
584
  <CapSpin spinning={spinning} tip={spinTip}>
319
585
  <SchemaForm schema={schema} renderContext={renderContext} />
586
+ {confirmModal}
320
587
  </CapSpin>
321
588
  );
322
589
  };
@@ -348,6 +615,10 @@ FormBuilderShell.propTypes = {
348
615
  waitEventContextTags: PropTypes.object,
349
616
  restrictPersonalization: PropTypes.bool,
350
617
  refs: PropTypes.object,
618
+ // Tabbed channels (MOBILEPUSH): container-pushed tab position + confirmation modal.
619
+ currentTab: PropTypes.number,
620
+ showModal: PropTypes.bool,
621
+ modal: PropTypes.object,
351
622
  };
352
623
 
353
624
  FormBuilderShell.defaultProps = {
@@ -361,6 +632,9 @@ FormBuilderShell.defaultProps = {
361
632
  waitEventContextTags: {},
362
633
  restrictPersonalization: false,
363
634
  refs: undefined,
635
+ currentTab: undefined,
636
+ showModal: false,
637
+ modal: null,
364
638
  };
365
639
 
366
640
  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;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * MOBILEPUSH channel config — declarative capabilities + field ids for the MPUSH
3
+ * adapter. MOBILEPUSH is a two-tab channel (Android=tab 0, iOS=tab 1); iOS field
4
+ * ids carry the legacy `2` suffix (message-editor2). The containers mutate the
5
+ * schema at runtime (CTA row splices), so the engine's reconcile path is active.
6
+ */
7
+ import { MOBILE_PUSH } from '../../../../../v2Containers/CreativesContainer/constants';
8
+ import { TEMPLATE_NAME_FIELD } from '../../constants';
9
+
10
+ // The exact legacy error VALUE written for brace mismatches — containers pattern
11
+ // on it (string ≠ boolean true matters: Classic's cross-tab toast reacts only to
12
+ // boolean true). Mirrors errorMessageForTags.TAG_BRACKET_COUNT_MISMATCH_ERROR.
13
+ export const ERROR_VALUE = {
14
+ BRACKET: 'tagBracketCountMismatchError',
15
+ };
16
+
17
+ // Legacy tab semantics: 1-based currentTab, 1=Android, 2=iOS (Classic.js:483-486).
18
+ export const TABS = {
19
+ ANDROID: 0,
20
+ IOS: 1,
21
+ /** iOS field ids get suffix '2'; Android none (Classic.js:967). */
22
+ suffixFor: (tabIndex) => (tabIndex > 0 ? `${Number(tabIndex) + 1}` : ''),
23
+ };
24
+
25
+ export const fieldIds = {
26
+ name: TEMPLATE_NAME_FIELD, // root field
27
+ editor: 'message-editor', // per-tab, suffixed on iOS
28
+ title: 'message-title', // per-tab, suffixed on iOS
29
+ image: 'image', // per-tab (imageSchema only), same id on both panes
30
+ bracketError: 'bracket-error',
31
+ };
32
+
33
+ // The 13 fixed CTA/deeplink rules of Classic's validateForm MPUSH block
34
+ // (Classic.js:1037-1135), in source order. kind: 'empty' => `=== ""` fails
35
+ // (absent key passes); kind: 'url' => isValidExternalLink semantics.
36
+ export const CTA_RULES = [
37
+ { key: 'cta-deeplink-select', kind: 'empty' },
38
+ { key: 'cta-deeplink2-select', kind: 'empty' },
39
+ { key: 'cta-deeplink-text', kind: 'url' },
40
+ { key: 'cta-deeplink2-text', kind: 'url' },
41
+ { key: 'secondary-cta-0-link', kind: 'empty' },
42
+ { key: 'secondary-cta-0-label', kind: 'empty' },
43
+ { key: 'cta-deeplink-secondary-cta-0-text', kind: 'url' },
44
+ { key: 'cta-deeplink-secondary-cta-0-select', kind: 'empty' },
45
+ { key: 'secondary-cta-1-label', kind: 'empty' },
46
+ { key: 'cta-deeplink-secondary-cta-1-text', kind: 'url' },
47
+ { key: 'cta-deeplink-secondary-cta-1-select', kind: 'empty' },
48
+ { key: 'cta-deeplink-secondary-cta-1-text2', kind: 'url' },
49
+ { key: 'cta-deeplink-secondary-cta-1-select2', kind: 'empty' },
50
+ ];
51
+
52
+ export default {
53
+ channel: MOBILE_PUSH,
54
+ fieldIds,
55
+ // Classic passes the live 1-based currentTab as showLiquidErrorInFooter's second
56
+ // argument for MPUSH (Classic.js:1352, 1487); SMS is the only null channel.
57
+ footerTabArg: 'currentTab',
58
+ features: {
59
+ tabs: true,
60
+ versions: false,
61
+ languages: false,
62
+ unicode: false,
63
+ liquid: true, // LIQUID_SUPPORTED_CHANNELS member; pipeline runs only outside isFullMode
64
+ askAira: true, // textarea only (Classic.js:2927)
65
+ preview: true,
66
+ imageUpload: true,
67
+ cta: true,
68
+ // Bridge checkbox/radio/select commit events to the container-injected handlers
69
+ // with the performFormDataUpdate signature (true, formData, field). MPUSH-only.
70
+ bridgeFieldEvents: true,
71
+ // MPUSH keeps the inline textarea error even though liquid is supported
72
+ // (Classic.js:2908-2915) — encoded in the MPUSH renderers.
73
+ inlineErrorWithLiquid: true,
74
+ // Classic re-validates typing only under startValidation (Classic.js:334-345):
75
+ // no pre-save errorData, so no tag/footer message before a save attempt.
76
+ liveValidation: false,
77
+ // Validity is still maintained in the background (Classic validates + emits on
78
+ // parent pushes / schema changes / tags changes, Classic.js:516-578, 1361) —
79
+ // the containers gate their save flows on it (Create/index.js:101).
80
+ validateOnExternalChange: true,
81
+ },
82
+ };