@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.
- package/constants/unified.js +3 -2
- package/package.json +1 -1
- package/utils/common.js +8 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +334 -46
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +250 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +43 -8
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
- package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +89 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +447 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +313 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +141 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +164 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +368 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +110 -0
- package/v2Components/FormBuilder/_formBuilder.scss +8 -0
- package/v2Components/FormBuilder/index.js +34 -12
- package/v2Components/FormBuilder/tests/entryGate.test.js +94 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
package/constants/unified.js
CHANGED
|
@@ -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
|
|
49
|
-
// new functional FormBuilder; later channels get their own flags (…
|
|
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
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
|
|
3
|
-
* useReducer, bridges to the legacy formData contract via the
|
|
4
|
-
* through the channel adapter. Stage A keeps the
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
//
|
|
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)
|
|
171
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
212
|
-
if (result.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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?.[
|
|
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
|
|
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
|
|
249
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
290
|
-
|
|
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;
|