@capillarytech/creatives-library 9.0.59-alpha.2 → 9.0.60

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 (50) hide show
  1. package/constants/unified.js +2 -3
  2. package/package.json +1 -1
  3. package/services/api.js +1 -3
  4. package/utils/common.js +0 -8
  5. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
  6. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  7. package/v2Components/FormBuilder/Functional/constants.js +8 -46
  8. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  9. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  10. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  11. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  12. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  13. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  14. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  15. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  16. package/v2Components/FormBuilder/_formBuilder.scss +0 -11
  17. package/v2Components/FormBuilder/index.js +13 -20
  18. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
  19. package/v2Containers/CommunicationFlow/CommunicationFlow.js +13 -32
  20. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -36
  21. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -0
  22. package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
  23. package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
  24. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +1 -5
  25. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +28 -0
  26. package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +29 -0
  27. package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +6 -13
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  33. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  34. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  35. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  36. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  37. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  38. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  39. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  40. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  41. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  42. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  43. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  44. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  45. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  46. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  47. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  48. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  49. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  50. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -1,8 +1,7 @@
1
1
  /**
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.
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.
6
5
  */
7
6
 
8
7
  import React, {
@@ -11,19 +10,14 @@ import React, {
11
10
  import PropTypes from 'prop-types';
12
11
  import debounce from 'lodash/debounce';
13
12
  import isEqual from 'lodash/isEqual';
14
- import cloneDeep from 'lodash/cloneDeep';
15
13
  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';
18
14
  import formReducer, {
19
15
  createInitialState,
20
16
  hydrate,
21
17
  fieldChanged,
22
- setActiveTab,
23
- reconcileSchema,
24
18
  } from './core/store/formReducer';
25
19
  import { toLegacy, fromLegacy } from './core/store/toLegacyFormData';
26
- import initializeFormState, { reconcileFormState } from './core/schema/initializeFormState';
20
+ import initializeFormState from './core/schema/initializeFormState';
27
21
  import { getChannelAdapter } from './channels/registry';
28
22
  import { createSmsRegistry } from './renderers/smsRenderers';
29
23
  import { validateLiquidTemplateContent } from '../../../utils/commonUtils';
@@ -32,9 +26,7 @@ import { SMS } from '../../../v2Containers/CreativesContainer/constants';
32
26
  import { DEFAULT as DEFAULT_MODULE, EMBEDDED } from '../../../constants/unified';
33
27
  import formMessages from '../messages';
34
28
  import SchemaForm from './layout/SchemaForm';
35
- import {
36
- ACTIVE_TAB_INDEX, DATA_COMMIT_EVENTS, DATA_COMMIT_FIELD_TYPES, EDITOR_ERROR_KIND, HIGH_FREQ_FIELDS,
37
- } from './constants';
29
+ import { ACTIVE_TAB_INDEX, EDITOR_ERROR_KIND, HIGH_FREQ_FIELDS } from './constants';
38
30
 
39
31
  const isNonEmpty = (obj) => obj && Object.keys(obj).length > 0;
40
32
 
@@ -42,6 +34,9 @@ const buildInitialState = (schema, parentFormData, channel) => (isNonEmpty(paren
42
34
  ? fromLegacy(parentFormData, { channel })
43
35
  : initializeFormState(schema || {}));
44
36
 
37
+ // Phase 1: only SMS has renderers. Later channels return their own registry.
38
+ const registryFor = () => createSmsRegistry();
39
+
45
40
  const FormBuilderShell = (props) => {
46
41
  const {
47
42
  schema,
@@ -63,26 +58,19 @@ const FormBuilderShell = (props) => {
63
58
  waitEventContextTags,
64
59
  restrictPersonalization,
65
60
  refs,
66
- currentTab: currentTabProp,
67
- showModal,
68
- modal,
69
61
  } = props;
70
62
 
71
63
  const channel = (channelProp || schema?.channel || SMS).toUpperCase();
72
64
  const adapter = getChannelAdapter(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
- );
65
+ const registry = useMemo(() => registryFor(), [channel]);
78
66
 
79
67
  const [state, dispatch] = useReducer(formReducer, channel, createInitialState);
80
68
  const [errorData, setErrorData] = useState({});
81
- // Error DISPLAY is gated until a save attempt (Classic's checkValidation);
82
- // errorData itself is still computed and emitted in the background.
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.
83
73
  const [internalCheckValidation, setInternalCheckValidation] = useState(false);
84
- // Single-platform save modal: container owns content, shell owns visibility (Classic parity).
85
- const [modalVisible, setModalVisible] = useState(false);
86
74
 
87
75
  // refs to latest values for use inside stable/debounced callbacks
88
76
  const stateRef = useRef(state);
@@ -92,18 +80,6 @@ const FormBuilderShell = (props) => {
92
80
  const lastEmittedRef = useRef(null);
93
81
  const didInitRef = useRef(false);
94
82
  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);
107
83
 
108
84
  // Stable handler identities for the memoized FieldSlot: these wrappers delegate to
109
85
  // the latest handler bodies (via a ref), so React.memo can bail out on sibling changes.
@@ -115,82 +91,57 @@ const FormBuilderShell = (props) => {
115
91
  const isEmbedded = location?.query?.type === EMBEDDED;
116
92
  const currentModule = tagModule || location?.query?.module || DEFAULT_MODULE;
117
93
 
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, {
94
+ const runValidate = (legacy) => adapter.validate(legacy, {
124
95
  tags: propsRef.current.tags,
125
96
  isFullMode,
126
97
  isEmbedded,
127
98
  currentModule,
128
- restrictPersonalization: propsRef.current.restrictPersonalization,
129
- currentTab: (stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX) + 1,
130
- schema: propsRef.current.schema,
131
- ...extra,
132
99
  });
133
100
 
134
- // Classic's liquidErrorMessage: keeps STANDARD/LIQUID footer errors from clobbering each other.
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.
135
104
  const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
136
105
  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);
142
106
 
143
107
  const pushFooter = (next) => {
144
108
  footerRef.current = { ...footerRef.current, ...next };
145
109
  if (propsRef.current.showLiquidErrorInFooter) {
146
- propsRef.current.showLiquidErrorInFooter(footerRef.current, footerTabArg());
110
+ propsRef.current.showLiquidErrorInFooter(
111
+ footerRef.current,
112
+ channel === SMS ? null : propsRef.current.currentTab,
113
+ );
147
114
  }
148
115
  };
149
116
 
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) => {
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) => {
153
121
  if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
154
122
  const descriptor = adapter.getEditorErrorDescriptor(legacy, {
155
123
  tags: propsRef.current.tags,
156
124
  isFullMode,
157
125
  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,
165
126
  });
166
- if (!descriptor) return [];
167
- if (descriptor.text) return [descriptor.text];
168
- return [intl.formatMessage(descriptor.message, descriptor.values)];
127
+ return descriptor ? [intl.formatMessage(descriptor.message, descriptor.values)] : [];
169
128
  };
170
129
 
171
130
  const emitValidity = (result, legacy) => {
172
131
  setErrorData(result.errorData);
173
- if (result.platform) platformRef.current = result.platform;
174
132
  if (propsRef.current.onFormValidityChange) {
175
133
  propsRef.current.onFormValidityChange(result.isValid, result.errorData);
176
134
  }
177
135
  // Footer: clear standard errors when the form is valid; otherwise surface the
178
- // body-error message (empty/generic gated per channel). Liquid errors are preserved.
136
+ // brace/missing-tag message (empty/generic => none). Liquid errors are preserved.
179
137
  if (liquidEnabled) {
180
- pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy, result) });
138
+ pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy) });
181
139
  }
182
140
  };
183
141
 
184
142
  const emitChange = (legacy, field) => {
185
143
  lastEmittedRef.current = legacy;
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
- }
144
+ if (propsRef.current.onChange) propsRef.current.onChange(legacy, 1, 1, field);
194
145
  };
195
146
 
196
147
  const debouncedEmit = useMemo(
@@ -199,139 +150,31 @@ const FormBuilderShell = (props) => {
199
150
  );
200
151
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
201
152
 
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
-
206
153
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
207
- const schemaReady = Boolean(schema?.standalone || schema?.containers?.length);
154
+ const schemaReady = Boolean(schema?.standalone);
208
155
  useEffect(() => {
209
156
  if (schemaReady && !didInitRef.current) {
210
157
  didInitRef.current = true;
211
- prevSchemaRef.current = schema;
212
158
  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
- }
218
159
  dispatch(hydrate(init));
219
- lastTagsRef.current = cloneDeep(propsRef.current.tags || null);
220
160
  const legacy = toLegacy(init);
221
161
  emitValidity(runValidate(legacy), legacy);
222
- if (emitFormDataOnSchemaSync) emitChange(legacy); // seed the container's formData copy
223
-
224
162
  }
225
163
  }, [schemaReady]);
226
164
 
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).
165
+ // ---- re-hydrate on a GENUINE external parent push (edit load / discard) ----
254
166
  useEffect(() => {
255
167
  if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
256
- if (isEqual(parentFormData, lastSeenParentRef.current)) return; // nothing new from the parent
257
- lastSeenParentRef.current = cloneDeep(parentFormData);
258
168
  const current = toLegacy(stateRef.current);
259
169
  const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
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
- }
170
+ if (!isEcho) dispatch(hydrate(fromLegacy(parentFormData, { channel })));
272
171
  }, [parentFormData]);
273
172
 
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 } = {}) => {
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) => {
314
177
  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
-
335
178
  const runLiquid = liquidEnabled && !isFullMode;
336
179
  const getLiquidTags = currentProps.actions?.getLiquidTags;
337
180
  if (!runLiquid || typeof getLiquidTags !== 'function') {
@@ -348,7 +191,8 @@ const FormBuilderShell = (props) => {
348
191
  onError: ({ standardErrors, liquidErrors }) => {
349
192
  pushFooter({ STANDARD_ERROR_MSG: standardErrors, LIQUID_ERROR_MSG: liquidErrors });
350
193
  if (currentProps.stopValidation) currentProps.stopValidation();
351
- // Classic passes the clean sync errorData: the footer carries the liquid error.
194
+ // Classic passes the (clean) sync errorData here: the footer carries the
195
+ // liquid error, not a per-field error.
352
196
  if (currentProps.onFormValidityChange) currentProps.onFormValidityChange(false, validErrorData);
353
197
  },
354
198
  onSuccess: () => {
@@ -357,50 +201,41 @@ const FormBuilderShell = (props) => {
357
201
  });
358
202
  };
359
203
 
360
- // ---- startValidation rising edge -> validate, then save / prompt / stop ----
204
+ // ---- startValidation rising edge -> validate, then save or stop ----
361
205
  useEffect(() => {
362
206
  if (startValidation && !prevStartValidationRef.current) {
363
207
  setInternalCheckValidation(true); // reveal field error messages from now on
364
208
  debouncedEmit.flush();
365
209
  const legacy = toLegacy(stateRef.current);
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
- }
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();
380
216
  }
381
217
  }
382
218
  prevStartValidationRef.current = startValidation;
383
219
  }, [startValidation]);
384
220
 
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.
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.
387
224
  const resolvedFieldErrors = useMemo(() => {
388
225
  if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
389
226
  const editorId = adapter.config?.fieldIds?.editor;
390
- const editorError = editorId && errorData?.[activeTabIndex]?.[editorId];
227
+ const editorError = editorId && errorData?.[ACTIVE_TAB_INDEX]?.[editorId];
391
228
  if (!editorError || !adapter.getEditorErrorDescriptor || !intl) return {};
392
229
  const descriptor = adapter.getEditorErrorDescriptor(toLegacy(stateRef.current), { tags, isFullMode, currentModule });
393
230
  if (descriptor?.kind !== EDITOR_ERROR_KIND.BRACE) return {};
394
231
  return { [editorId]: intl.formatMessage(descriptor.message, descriptor.values) };
395
- }, [errorData, intl, isFullMode, currentModule, activeTabIndex]);
232
+ }, [errorData, intl, isFullMode, currentModule]);
396
233
 
397
234
  // ---- field change ----
398
235
  const onFieldChange = (field, value) => {
399
- const liveActiveTab = stateRef.current?.meta?.activeTabIndex ?? ACTIVE_TAB_INDEX;
400
- const tabIndex = field.standalone ? null : liveActiveTab;
236
+ const tabIndex = field.standalone ? null : ACTIVE_TAB_INDEX;
401
237
  const action = fieldChanged({ fieldId: field.id, value, tabIndex });
402
238
  const nextState = formReducer(stateRef.current, action);
403
- lastNextStateRef.current = nextState;
404
239
  dispatch(action); // immediate UI update on next render
405
240
  const legacy = toLegacy(nextState);
406
241
 
@@ -410,22 +245,19 @@ const FormBuilderShell = (props) => {
410
245
  emitChange(legacy, field);
411
246
  }
412
247
 
413
- // Re-validate on change while errors are shown, so a revealed error clears as
414
- // soon as the field becomes valid.
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.
415
251
  const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
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) {
252
+ const validateLive = liquidEnabled && !isFullMode;
253
+ if (validationActive || validateLive) {
423
254
  emitValidity(runValidate(legacy), legacy);
424
255
  }
425
256
  };
426
257
 
427
- // Blur on high-freq inputs re-validates unconditionally (Classic handleFieldBlur);
428
- // flush the debounced onChange first so the parent holds the latest value.
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.
429
261
  const onFieldBlur = (field) => {
430
262
  if (!field || !HIGH_FREQ_FIELDS.includes(field.id)) return;
431
263
  debouncedEmit.flush();
@@ -433,93 +265,32 @@ const FormBuilderShell = (props) => {
433
265
  emitValidity(runValidate(legacy), legacy);
434
266
  };
435
267
 
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).
268
+ // ---- parent bridge: invoke container-injected handlers (Stage A compat) ----
439
269
  const onEvent = (field, eventName, data) => {
440
270
  const injected = field.injectedEvents?.[eventName];
441
271
  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
- }
457
272
  if (eventName === 'onTagSelect') {
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);
273
+ injected.call(propsRef.current.parent, data, 1, field); // Classic signature
465
274
  } else {
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
- });
275
+ injected.call(propsRef.current.parent, data, field.id); // saveFormData / onClick
497
276
  }
498
277
  };
499
278
 
500
- const handleModalCancel = () => {
501
- setModalVisible(false);
502
- if (propsRef.current.handleCancelModal) propsRef.current.handleCancelModal();
503
- };
504
-
505
279
  // Point the stable wrappers at the latest handler bodies (read at call time).
506
280
  handlersRef.current = { onFieldChange, onFieldBlur, onEvent };
507
281
 
508
- // Render context consumed by every field renderer / Section / FieldSlot.
282
+ // Render context handed to every field renderer (the `renderContext` prop the
283
+ // renderers / Section / FieldSlot consume).
509
284
  const renderContext = {
510
285
  state,
511
286
  errorData,
512
287
  registry,
513
288
  checkValidation: internalCheckValidation || Boolean(checkValidation),
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,
289
+ activeTabIndex: ACTIVE_TAB_INDEX,
290
+ currentTab: 1,
519
291
  onFieldChange: stableOnFieldChange,
520
292
  onFieldBlur: stableOnFieldBlur,
521
293
  onEvent: stableOnEvent,
522
- onTabSwitch,
523
294
  resolvedFieldErrors,
524
295
  legacyFormData: toLegacy(state),
525
296
  channel,
@@ -537,33 +308,15 @@ const FormBuilderShell = (props) => {
537
308
  refs,
538
309
  };
539
310
 
540
- // Classic's overlay: spin during liquid-tag extraction or while layout metadata loads.
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.
541
314
  const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
542
315
  const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
543
316
 
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
-
563
317
  return (
564
318
  <CapSpin spinning={spinning} tip={spinTip}>
565
319
  <SchemaForm schema={schema} renderContext={renderContext} />
566
- {confirmModal}
567
320
  </CapSpin>
568
321
  );
569
322
  };
@@ -587,7 +340,6 @@ FormBuilderShell.propTypes = {
587
340
  tagModule: PropTypes.string,
588
341
  tags: PropTypes.array,
589
342
  checkValidation: PropTypes.bool,
590
- isEdit: PropTypes.bool,
591
343
  injectedTags: PropTypes.object,
592
344
  onContextChange: PropTypes.func,
593
345
  // Shapes match TagList's own contract (array of offer objects / keyed-by-blockId map).
@@ -596,10 +348,6 @@ FormBuilderShell.propTypes = {
596
348
  waitEventContextTags: PropTypes.object,
597
349
  restrictPersonalization: PropTypes.bool,
598
350
  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,
603
351
  };
604
352
 
605
353
  FormBuilderShell.defaultProps = {
@@ -613,9 +361,6 @@ FormBuilderShell.defaultProps = {
613
361
  waitEventContextTags: {},
614
362
  restrictPersonalization: false,
615
363
  refs: undefined,
616
- currentTab: undefined,
617
- showModal: false,
618
- modal: null,
619
364
  };
620
365
 
621
366
  export default FormBuilderShell;
@@ -4,11 +4,9 @@
4
4
  * is a new folder + one line here.
5
5
  */
6
6
  import smsAdapter from './sms';
7
- import mpushAdapter from './mobilepush';
8
7
 
9
8
  const adapters = {
10
9
  SMS: smsAdapter,
11
- MOBILEPUSH: mpushAdapter,
12
10
  };
13
11
 
14
12
  export const getChannelAdapter = (channel) => {