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