@capillarytech/creatives-library 9.0.13-alpha.1 → 9.0.13-beta.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 -0
- package/package.json +1 -1
- package/utils/common.js +8 -0
- package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberPreviewContent.js +15 -108
- package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +1 -141
- package/v2Components/CommonTestAndPreview/index.js +26 -244
- package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/ViberPreviewContent.test.js +0 -364
- package/v2Components/FormBuilder/Classic.js +4487 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +369 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +17 -0
- package/v2Components/FormBuilder/Functional/channels/sms/buildSubmitPayload.js +9 -0
- package/v2Components/FormBuilder/Functional/channels/sms/config.js +30 -0
- package/v2Components/FormBuilder/Functional/channels/sms/getEditorErrorDescriptor.js +46 -0
- package/v2Components/FormBuilder/Functional/channels/sms/getLiquidContent.js +13 -0
- package/v2Components/FormBuilder/Functional/channels/sms/index.js +22 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/getEditorErrorDescriptor.test.js +52 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/getLiquidContent.test.js +25 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/validate.test.js +87 -0
- package/v2Components/FormBuilder/Functional/channels/sms/validate.js +89 -0
- package/v2Components/FormBuilder/Functional/constants.js +42 -0
- package/v2Components/FormBuilder/Functional/core/schema/fieldRegistry.js +38 -0
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +85 -0
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +81 -0
- package/v2Components/FormBuilder/Functional/core/store/selectors.js +30 -0
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +91 -0
- package/v2Components/FormBuilder/Functional/index.js +26 -0
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +59 -0
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +32 -0
- package/v2Components/FormBuilder/Functional/layout/Section.js +116 -0
- package/v2Components/FormBuilder/Functional/renderers/smsRenderers.js +258 -0
- package/v2Components/FormBuilder/Functional/tests/channelRegistry.test.js +21 -0
- package/v2Components/FormBuilder/Functional/tests/fieldRegistry.test.js +65 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +97 -0
- package/v2Components/FormBuilder/Functional/tests/fixtures/smsParityCases.js +192 -0
- package/v2Components/FormBuilder/Functional/tests/formReducer.test.js +129 -0
- package/v2Components/FormBuilder/Functional/tests/initializeFormState.test.js +132 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +40 -0
- package/v2Components/FormBuilder/Functional/tests/section.test.js +99 -0
- package/v2Components/FormBuilder/Functional/tests/selectors.test.js +67 -0
- package/v2Components/FormBuilder/Functional/tests/sms.crossFlowParity.test.js +155 -0
- package/v2Components/FormBuilder/Functional/tests/sms.liquid.test.js +172 -0
- package/v2Components/FormBuilder/Functional/tests/sms.rollout.test.js +122 -0
- package/v2Components/FormBuilder/Functional/tests/sms.shell.parity.test.js +329 -0
- package/v2Components/FormBuilder/Functional/tests/smsRenderers.test.js +160 -0
- package/v2Components/FormBuilder/Functional/tests/toLegacyFormData.test.js +95 -0
- package/v2Components/FormBuilder/_formBuilder.scss +5 -0
- package/v2Components/FormBuilder/index.js +41 -4479
- package/v2Components/FormBuilder/tests/__snapshots__/sms.characterization.test.js.snap +114 -0
- package/v2Components/FormBuilder/tests/entryGate.test.js +106 -0
- package/v2Components/FormBuilder/tests/sms.characterization.test.js +336 -0
- package/v2Containers/Templates/_templates.scss +0 -83
- package/v2Containers/Templates/index.js +10 -90
- package/v2Containers/Viber/constants.js +0 -21
- package/v2Containers/Viber/index.js +49 -719
- package/v2Containers/Viber/index.scss +0 -175
- package/v2Containers/Viber/messages.js +0 -121
- package/v2Containers/Viber/tests/index.test.js +0 -80
- package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberCarouselPreviewCards.js +0 -132
- package/v2Components/CommonTestAndPreview/UnifiedPreview/_viberCarouselPreviewCards.scss +0 -132
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FormBuilderShell — the functional FormBuilder (Phase 1: SMS). Owns form state in a
|
|
3
|
+
* useReducer, bridges to the legacy formData contract via the codec, and validates
|
|
4
|
+
* through the channel adapter. Stage A keeps the channel containers unchanged.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, {
|
|
8
|
+
useReducer, useRef, useEffect, useState, useMemo, useCallback,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import PropTypes from 'prop-types';
|
|
11
|
+
import debounce from 'lodash/debounce';
|
|
12
|
+
import isEqual from 'lodash/isEqual';
|
|
13
|
+
import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
|
|
14
|
+
import formReducer, {
|
|
15
|
+
createInitialState,
|
|
16
|
+
hydrate,
|
|
17
|
+
fieldChanged,
|
|
18
|
+
} from './core/store/formReducer';
|
|
19
|
+
import { toLegacy, fromLegacy } from './core/store/toLegacyFormData';
|
|
20
|
+
import initializeFormState from './core/schema/initializeFormState';
|
|
21
|
+
import { getChannelAdapter } from './channels/registry';
|
|
22
|
+
import { createSmsRegistry } from './renderers/smsRenderers';
|
|
23
|
+
import { validateLiquidTemplateContent } from '../../../utils/commonUtils';
|
|
24
|
+
import { REQUEST } from '../../../v2Containers/Cap/constants';
|
|
25
|
+
import { SMS } from '../../../v2Containers/CreativesContainer/constants';
|
|
26
|
+
import { DEFAULT as DEFAULT_MODULE, EMBEDDED } from '../../../constants/unified';
|
|
27
|
+
import formMessages from '../messages';
|
|
28
|
+
import SchemaForm from './layout/SchemaForm';
|
|
29
|
+
import { ACTIVE_TAB_INDEX, EDITOR_ERROR_KIND, HIGH_FREQ_FIELDS } from './constants';
|
|
30
|
+
|
|
31
|
+
const isNonEmpty = (obj) => obj && Object.keys(obj).length > 0;
|
|
32
|
+
|
|
33
|
+
const buildInitialState = (schema, parentFormData, channel) => (isNonEmpty(parentFormData)
|
|
34
|
+
? fromLegacy(parentFormData, { channel })
|
|
35
|
+
: initializeFormState(schema || {}));
|
|
36
|
+
|
|
37
|
+
// Phase 1: only SMS has renderers. Later channels return their own registry.
|
|
38
|
+
const registryFor = () => createSmsRegistry();
|
|
39
|
+
|
|
40
|
+
const FormBuilderShell = (props) => {
|
|
41
|
+
const {
|
|
42
|
+
schema,
|
|
43
|
+
formData: parentFormData,
|
|
44
|
+
isFullMode,
|
|
45
|
+
startValidation,
|
|
46
|
+
location,
|
|
47
|
+
channel: channelProp,
|
|
48
|
+
intl,
|
|
49
|
+
liquidExtractionInProgress,
|
|
50
|
+
metaDataStatus,
|
|
51
|
+
tagModule,
|
|
52
|
+
tags,
|
|
53
|
+
checkValidation,
|
|
54
|
+
injectedTags,
|
|
55
|
+
onContextChange,
|
|
56
|
+
selectedOfferDetails,
|
|
57
|
+
eventContextTags,
|
|
58
|
+
waitEventContextTags,
|
|
59
|
+
restrictPersonalization,
|
|
60
|
+
refs,
|
|
61
|
+
} = props;
|
|
62
|
+
|
|
63
|
+
const channel = (channelProp || schema?.channel || SMS).toUpperCase();
|
|
64
|
+
const adapter = getChannelAdapter(channel);
|
|
65
|
+
const registry = useMemo(() => registryFor(), [channel]);
|
|
66
|
+
|
|
67
|
+
const [state, dispatch] = useReducer(formReducer, channel, createInitialState);
|
|
68
|
+
const [errorData, setErrorData] = useState({});
|
|
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.
|
|
73
|
+
const [internalCheckValidation, setInternalCheckValidation] = useState(false);
|
|
74
|
+
|
|
75
|
+
// refs to latest values for use inside stable/debounced callbacks
|
|
76
|
+
const stateRef = useRef(state);
|
|
77
|
+
stateRef.current = state;
|
|
78
|
+
const propsRef = useRef(props);
|
|
79
|
+
propsRef.current = props;
|
|
80
|
+
const lastEmittedRef = useRef(null);
|
|
81
|
+
const didInitRef = useRef(false);
|
|
82
|
+
const prevStartValidationRef = useRef(startValidation);
|
|
83
|
+
|
|
84
|
+
// Stable handler identities for the memoized FieldSlot: these wrappers delegate to
|
|
85
|
+
// the latest handler bodies (via a ref), so React.memo can bail out on sibling changes.
|
|
86
|
+
const handlersRef = useRef({});
|
|
87
|
+
const stableOnFieldChange = useCallback((field, value) => handlersRef.current.onFieldChange(field, value), []);
|
|
88
|
+
const stableOnFieldBlur = useCallback((field) => handlersRef.current.onFieldBlur(field), []);
|
|
89
|
+
const stableOnEvent = useCallback((field, eventName, data) => handlersRef.current.onEvent(field, eventName, data), []);
|
|
90
|
+
|
|
91
|
+
const isEmbedded = location?.query?.type === EMBEDDED;
|
|
92
|
+
const currentModule = tagModule || location?.query?.module || DEFAULT_MODULE;
|
|
93
|
+
|
|
94
|
+
const runValidate = (legacy) => adapter.validate(legacy, {
|
|
95
|
+
tags: propsRef.current.tags,
|
|
96
|
+
isFullMode,
|
|
97
|
+
isEmbedded,
|
|
98
|
+
currentModule,
|
|
99
|
+
});
|
|
100
|
+
|
|
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.
|
|
104
|
+
const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
|
|
105
|
+
const liquidEnabled = Boolean(adapter.config?.features?.liquid);
|
|
106
|
+
|
|
107
|
+
const pushFooter = (next) => {
|
|
108
|
+
footerRef.current = { ...footerRef.current, ...next };
|
|
109
|
+
if (propsRef.current.showLiquidErrorInFooter) {
|
|
110
|
+
propsRef.current.showLiquidErrorInFooter(
|
|
111
|
+
footerRef.current,
|
|
112
|
+
channel === SMS ? null : propsRef.current.currentTab,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
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) => {
|
|
121
|
+
if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
|
|
122
|
+
const descriptor = adapter.getEditorErrorDescriptor(legacy, {
|
|
123
|
+
tags: propsRef.current.tags,
|
|
124
|
+
isFullMode,
|
|
125
|
+
currentModule,
|
|
126
|
+
});
|
|
127
|
+
return descriptor ? [intl.formatMessage(descriptor.message, descriptor.values)] : [];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const emitValidity = (result, legacy) => {
|
|
131
|
+
setErrorData(result.errorData);
|
|
132
|
+
if (propsRef.current.onFormValidityChange) {
|
|
133
|
+
propsRef.current.onFormValidityChange(result.isValid, result.errorData);
|
|
134
|
+
}
|
|
135
|
+
// Footer: clear standard errors when the form is valid; otherwise surface the
|
|
136
|
+
// brace/missing-tag message (empty/generic => none). Liquid errors are preserved.
|
|
137
|
+
if (liquidEnabled) {
|
|
138
|
+
pushFooter({ STANDARD_ERROR_MSG: result.isValid ? [] : standardFooterMsg(legacy) });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const emitChange = (legacy, field) => {
|
|
143
|
+
lastEmittedRef.current = legacy;
|
|
144
|
+
if (propsRef.current.onChange) propsRef.current.onChange(legacy, 1, 1, field);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const debouncedEmit = useMemo(
|
|
148
|
+
() => debounce((legacy, field) => emitChange(legacy, field), 300),
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
|
|
152
|
+
|
|
153
|
+
// ---- initialize once the (possibly async) schema is ready, then validate+emit ----
|
|
154
|
+
const schemaReady = Boolean(schema?.standalone);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (schemaReady && !didInitRef.current) {
|
|
157
|
+
didInitRef.current = true;
|
|
158
|
+
const init = buildInitialState(schema, propsRef.current.formData, channel);
|
|
159
|
+
dispatch(hydrate(init));
|
|
160
|
+
const legacy = toLegacy(init);
|
|
161
|
+
emitValidity(runValidate(legacy), legacy);
|
|
162
|
+
}
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
+
}, [schemaReady]);
|
|
165
|
+
|
|
166
|
+
// ---- re-hydrate on a GENUINE external parent push (edit load / discard) ----
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
|
|
169
|
+
const current = toLegacy(stateRef.current);
|
|
170
|
+
const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
|
|
171
|
+
if (!isEcho) dispatch(hydrate(fromLegacy(parentFormData, { channel })));
|
|
172
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
|
+
}, [parentFormData]);
|
|
174
|
+
|
|
175
|
+
// Submit a form that passed sync validation (mirrors Classic onSubmitWrapper).
|
|
176
|
+
// Full mode submits directly; outside it, liquid-supported channels run liquid-tag
|
|
177
|
+
// validation first — a clean result submits, an error goes to the footer and blocks.
|
|
178
|
+
const submitValidForm = (legacy, validErrorData) => {
|
|
179
|
+
const currentProps = propsRef.current;
|
|
180
|
+
const runLiquid = liquidEnabled && !isFullMode;
|
|
181
|
+
const getLiquidTags = currentProps.actions?.getLiquidTags;
|
|
182
|
+
if (!runLiquid || typeof getLiquidTags !== 'function') {
|
|
183
|
+
if (currentProps.onSubmit) currentProps.onSubmit(adapter.buildSubmitPayload(legacy));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const content = adapter.getLiquidContent
|
|
187
|
+
? adapter.getLiquidContent(legacy, { baseLanguage: currentProps.baseLanguage })
|
|
188
|
+
: adapter.buildSubmitPayload(legacy);
|
|
189
|
+
validateLiquidTemplateContent(content, {
|
|
190
|
+
getLiquidTags,
|
|
191
|
+
formatMessage: currentProps.intl.formatMessage,
|
|
192
|
+
messages: formMessages,
|
|
193
|
+
onError: ({ standardErrors, liquidErrors }) => {
|
|
194
|
+
pushFooter({ STANDARD_ERROR_MSG: standardErrors, LIQUID_ERROR_MSG: liquidErrors });
|
|
195
|
+
if (currentProps.stopValidation) currentProps.stopValidation();
|
|
196
|
+
// Classic passes the (clean) sync errorData here: the footer carries the
|
|
197
|
+
// liquid error, not a per-field error.
|
|
198
|
+
if (currentProps.onFormValidityChange) currentProps.onFormValidityChange(false, validErrorData);
|
|
199
|
+
},
|
|
200
|
+
onSuccess: () => {
|
|
201
|
+
if (currentProps.onSubmit) currentProps.onSubmit(adapter.buildSubmitPayload(legacy));
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// ---- startValidation rising edge -> validate, then save or stop ----
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (startValidation && !prevStartValidationRef.current) {
|
|
209
|
+
setInternalCheckValidation(true); // reveal field error messages from now on
|
|
210
|
+
debouncedEmit.flush();
|
|
211
|
+
const legacy = toLegacy(stateRef.current);
|
|
212
|
+
const result = runValidate(legacy);
|
|
213
|
+
emitValidity(result, legacy);
|
|
214
|
+
if (result.isValid) {
|
|
215
|
+
submitValidForm(legacy, result.errorData);
|
|
216
|
+
} else if (propsRef.current.stopValidation) {
|
|
217
|
+
propsRef.current.stopValidation();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
prevStartValidationRef.current = startValidation;
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [startValidation]);
|
|
223
|
+
|
|
224
|
+
// Inline (below-the-box) message map. The only change vs the old flow: in full mode
|
|
225
|
+
// the brace error shows inline instead of the footer. Library mode and all other
|
|
226
|
+
// body errors keep the old placement.
|
|
227
|
+
const resolvedFieldErrors = useMemo(() => {
|
|
228
|
+
if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
|
|
229
|
+
const editorId = adapter.config?.fieldIds?.editor;
|
|
230
|
+
const editorError = editorId && errorData?.[ACTIVE_TAB_INDEX]?.[editorId];
|
|
231
|
+
if (!editorError || !adapter.getEditorErrorDescriptor || !intl) return {};
|
|
232
|
+
const descriptor = adapter.getEditorErrorDescriptor(toLegacy(stateRef.current), { tags, isFullMode, currentModule });
|
|
233
|
+
if (descriptor?.kind !== EDITOR_ERROR_KIND.BRACE) return {};
|
|
234
|
+
return { [editorId]: intl.formatMessage(descriptor.message, descriptor.values) };
|
|
235
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
236
|
+
}, [errorData, intl, isFullMode, currentModule]);
|
|
237
|
+
|
|
238
|
+
// ---- field change ----
|
|
239
|
+
const onFieldChange = (field, value) => {
|
|
240
|
+
const tabIndex = field.standalone ? null : ACTIVE_TAB_INDEX;
|
|
241
|
+
const action = fieldChanged({ fieldId: field.id, value, tabIndex });
|
|
242
|
+
const nextState = formReducer(stateRef.current, action);
|
|
243
|
+
dispatch(action); // immediate UI update on next render
|
|
244
|
+
const legacy = toLegacy(nextState);
|
|
245
|
+
|
|
246
|
+
if (HIGH_FREQ_FIELDS.includes(field.id)) {
|
|
247
|
+
debouncedEmit(legacy, field); // debounce only the parent notification
|
|
248
|
+
} else {
|
|
249
|
+
emitChange(legacy, field);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Re-validate on change while errors are being shown, so a revealed error clears
|
|
253
|
+
// as soon as the field becomes valid. Focus-safe because renderers don't toggle
|
|
254
|
+
// CapInput's antd suffix (see InputField), so the <input> never remounts.
|
|
255
|
+
const validationActive = propsRef.current.startValidation || internalCheckValidation || Boolean(propsRef.current.checkValidation);
|
|
256
|
+
if (validationActive) {
|
|
257
|
+
emitValidity(runValidate(legacy), legacy);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Re-validate the high-frequency inputs (template-name/subject) on blur,
|
|
262
|
+
// unconditionally — mirroring Classic's handleFieldBlur. Flush the debounced
|
|
263
|
+
// onChange first so the parent has the latest typed value before we validate.
|
|
264
|
+
const onFieldBlur = (field) => {
|
|
265
|
+
if (!field || !HIGH_FREQ_FIELDS.includes(field.id)) return;
|
|
266
|
+
debouncedEmit.flush();
|
|
267
|
+
const legacy = toLegacy(stateRef.current);
|
|
268
|
+
emitValidity(runValidate(legacy), legacy);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ---- parent bridge: invoke container-injected handlers (Stage A compat) ----
|
|
272
|
+
const onEvent = (field, eventName, data) => {
|
|
273
|
+
const injected = field.injectedEvents?.[eventName];
|
|
274
|
+
if (typeof injected !== 'function') return;
|
|
275
|
+
if (eventName === 'onTagSelect') {
|
|
276
|
+
injected.call(propsRef.current.parent, data, 1, field); // Classic signature
|
|
277
|
+
} else {
|
|
278
|
+
injected.call(propsRef.current.parent, data, field.id); // saveFormData / onClick
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Point the stable wrappers at the latest handler bodies (read at call time).
|
|
283
|
+
handlersRef.current = { onFieldChange, onFieldBlur, onEvent };
|
|
284
|
+
|
|
285
|
+
// Render context handed to every field renderer (the `renderContext` prop the
|
|
286
|
+
// renderers / Section / FieldSlot consume).
|
|
287
|
+
const renderContext = {
|
|
288
|
+
state,
|
|
289
|
+
errorData,
|
|
290
|
+
registry,
|
|
291
|
+
checkValidation: internalCheckValidation || Boolean(checkValidation),
|
|
292
|
+
activeTabIndex: ACTIVE_TAB_INDEX,
|
|
293
|
+
currentTab: 1,
|
|
294
|
+
onFieldChange: stableOnFieldChange,
|
|
295
|
+
onFieldBlur: stableOnFieldBlur,
|
|
296
|
+
onEvent: stableOnEvent,
|
|
297
|
+
resolvedFieldErrors,
|
|
298
|
+
legacyFormData: toLegacy(state),
|
|
299
|
+
channel,
|
|
300
|
+
isFullMode,
|
|
301
|
+
tags,
|
|
302
|
+
injectedTags,
|
|
303
|
+
location,
|
|
304
|
+
intl,
|
|
305
|
+
onContextChange,
|
|
306
|
+
selectedOfferDetails,
|
|
307
|
+
eventContextTags,
|
|
308
|
+
waitEventContextTags,
|
|
309
|
+
restrictPersonalization,
|
|
310
|
+
userLocale: 'en',
|
|
311
|
+
refs,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Loading overlay — mirrors Classic: spin while liquid-tag extraction is in
|
|
315
|
+
// progress (only for liquid-supported channels) or while the layout metadata
|
|
316
|
+
// is still being fetched.
|
|
317
|
+
const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
|
|
318
|
+
const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<CapSpin spinning={spinning} tip={spinTip}>
|
|
322
|
+
<SchemaForm schema={schema} renderContext={renderContext} />
|
|
323
|
+
</CapSpin>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
FormBuilderShell.propTypes = {
|
|
328
|
+
schema: PropTypes.object,
|
|
329
|
+
formData: PropTypes.object,
|
|
330
|
+
isFullMode: PropTypes.bool,
|
|
331
|
+
startValidation: PropTypes.bool,
|
|
332
|
+
location: PropTypes.object,
|
|
333
|
+
channel: PropTypes.string,
|
|
334
|
+
// Redux-sourced (via the connected wrapper in ./index.js), used by the
|
|
335
|
+
// non-isFullMode liquid path and the loading overlay.
|
|
336
|
+
actions: PropTypes.object,
|
|
337
|
+
liquidExtractionInProgress: PropTypes.bool,
|
|
338
|
+
metaDataStatus: PropTypes.string,
|
|
339
|
+
showLiquidErrorInFooter: PropTypes.func,
|
|
340
|
+
baseLanguage: PropTypes.string,
|
|
341
|
+
intl: PropTypes.object,
|
|
342
|
+
// Forwarded to the render context / field renderers (tag-list, preview, etc.).
|
|
343
|
+
tagModule: PropTypes.string,
|
|
344
|
+
tags: PropTypes.array,
|
|
345
|
+
checkValidation: PropTypes.bool,
|
|
346
|
+
injectedTags: PropTypes.object,
|
|
347
|
+
onContextChange: PropTypes.func,
|
|
348
|
+
// Shapes match TagList's own contract (array of offer objects / keyed-by-blockId map).
|
|
349
|
+
selectedOfferDetails: PropTypes.array,
|
|
350
|
+
eventContextTags: PropTypes.array,
|
|
351
|
+
waitEventContextTags: PropTypes.object,
|
|
352
|
+
restrictPersonalization: PropTypes.bool,
|
|
353
|
+
refs: PropTypes.object,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
FormBuilderShell.defaultProps = {
|
|
357
|
+
tagModule: '',
|
|
358
|
+
tags: [],
|
|
359
|
+
checkValidation: false,
|
|
360
|
+
injectedTags: {},
|
|
361
|
+
onContextChange: undefined,
|
|
362
|
+
selectedOfferDetails: [],
|
|
363
|
+
eventContextTags: [],
|
|
364
|
+
waitEventContextTags: {},
|
|
365
|
+
restrictPersonalization: false,
|
|
366
|
+
refs: undefined,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export default FormBuilderShell;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/registry — channelKey -> adapter. The shell resolves the adapter by
|
|
3
|
+
* `schema.channel`; core/layout never import channels directly. Adding a channel
|
|
4
|
+
* is a new folder + one line here.
|
|
5
|
+
*/
|
|
6
|
+
import smsAdapter from './sms';
|
|
7
|
+
|
|
8
|
+
const adapters = {
|
|
9
|
+
SMS: smsAdapter,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const getChannelAdapter = (channel) => {
|
|
13
|
+
const key = typeof channel === 'string' ? channel.toUpperCase() : '';
|
|
14
|
+
return adapters[key] || null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default getChannelAdapter;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS submit payload — the class component calls props.onSubmit(this.state.formData)
|
|
3
|
+
* directly for SMS (Classic.js onSubmitWrapper, isFullMode path). So the payload IS
|
|
4
|
+
* the legacy formData; no transform. Kept as an explicit adapter hook so other
|
|
5
|
+
* channels (EMAIL's HTML preprocess, etc.) can diverge without touching the shell.
|
|
6
|
+
*/
|
|
7
|
+
export default function buildSubmitPayload(legacyFormData) {
|
|
8
|
+
return legacyFormData;
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS channel config — declarative capabilities + field ids for the SMS adapter.
|
|
3
|
+
* SMS is the simplest channel: a single template (no tabs/versions/languages).
|
|
4
|
+
*/
|
|
5
|
+
import { SMS } from '../../../../../v2Containers/CreativesContainer/constants';
|
|
6
|
+
|
|
7
|
+
// SMS is single-tab: its body and editor errors always live under tab index 0.
|
|
8
|
+
export const SMS_TAB = 0;
|
|
9
|
+
|
|
10
|
+
// Field ids the SMS schema uses — shared by validate / getEditorErrorDescriptor so
|
|
11
|
+
// the magic strings live in exactly one place.
|
|
12
|
+
export const fieldIds = {
|
|
13
|
+
name: 'template-name', // standalone (root) field
|
|
14
|
+
editor: 'sms-editor', // tab-0 field (the SMS body)
|
|
15
|
+
unicode: 'unicode-validity', // tab-0 checkbox
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
channel: SMS,
|
|
20
|
+
fieldIds,
|
|
21
|
+
features: {
|
|
22
|
+
versions: false,
|
|
23
|
+
languages: false,
|
|
24
|
+
tabs: false,
|
|
25
|
+
unicode: true,
|
|
26
|
+
liquid: true, // SMS is in LIQUID_SUPPORTED_CHANNELS (only runs outside isFullMode)
|
|
27
|
+
askAira: true,
|
|
28
|
+
preview: true,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps an SMS message-body error to an intl descriptor: brace (kind 'brace', shown
|
|
3
|
+
* inline + footer) or missing-tag (kind 'missing', footer only, library mode).
|
|
4
|
+
* Returns null for valid/empty/generic content — matching the old flow's placement.
|
|
5
|
+
*/
|
|
6
|
+
import { validateTagsCore } from '../../../../../utils/tagValidations';
|
|
7
|
+
import { DEFAULT as DEFAULT_MODULE } from '../../../../../constants/unified';
|
|
8
|
+
import globalMessages from '../../../../../v2Containers/Cap/messages';
|
|
9
|
+
import formMessages from '../../../messages';
|
|
10
|
+
import { EDITOR_ERROR_KIND } from '../../constants';
|
|
11
|
+
import { fieldIds, SMS_TAB } from './config';
|
|
12
|
+
|
|
13
|
+
export default function getEditorErrorDescriptor(legacyFormData = {}, options = {}) {
|
|
14
|
+
const tab = legacyFormData[SMS_TAB] || {};
|
|
15
|
+
const content = tab[fieldIds.editor];
|
|
16
|
+
|
|
17
|
+
// Empty body -> border only, no footer message (matches Classic).
|
|
18
|
+
if (!content) return null;
|
|
19
|
+
|
|
20
|
+
const { tags = [], isFullMode, currentModule = DEFAULT_MODULE } = options;
|
|
21
|
+
const tagValidation = validateTagsCore({
|
|
22
|
+
contentForBraceCheck: content,
|
|
23
|
+
contentForUnsubscribeScan: content,
|
|
24
|
+
tags,
|
|
25
|
+
currentModule,
|
|
26
|
+
isFullMode,
|
|
27
|
+
initialMissingTags: [],
|
|
28
|
+
includeIsContentEmpty: true,
|
|
29
|
+
});
|
|
30
|
+
if (tagValidation?.valid !== false) return null;
|
|
31
|
+
|
|
32
|
+
// `kind` lets the shell decide placement: the brace error is shown BELOW the
|
|
33
|
+
// message box (inline) in addition to the footer; missing-tag is footer-only.
|
|
34
|
+
if (tagValidation?.isBraceError) {
|
|
35
|
+
return { message: globalMessages.unbalanacedCurlyBraces, kind: EDITOR_ERROR_KIND.BRACE };
|
|
36
|
+
}
|
|
37
|
+
if (tagValidation?.missingTags?.length) {
|
|
38
|
+
return {
|
|
39
|
+
message: formMessages.missingTagsValidationError,
|
|
40
|
+
values: { missingTags: tagValidation.missingTags.join(', ') },
|
|
41
|
+
kind: EDITOR_ERROR_KIND.MISSING,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Generic tag failure -> border only, no footer message (matches Classic).
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS liquid content — the string handed to the liquid-tag extraction/validation
|
|
3
|
+
* API in non-isFullMode (library/embedded) mode. Mirrors Classic.onSubmitWrapper:
|
|
4
|
+
* content = getChannelData(channel, formData, baseLanguage)
|
|
5
|
+
* which for SMS is `${base['sms-editor']} ${template-name}`. Kept in the adapter
|
|
6
|
+
* so the shell never names a channel; getChannelData itself is the shared util.
|
|
7
|
+
*/
|
|
8
|
+
import { getChannelData } from '../../../../../utils/commonUtils';
|
|
9
|
+
import { SMS } from '../../../../../v2Containers/CreativesContainer/constants';
|
|
10
|
+
|
|
11
|
+
export default function getLiquidContent(legacyFormData, { baseLanguage } = {}) {
|
|
12
|
+
return getChannelData(SMS, legacyFormData, baseLanguage);
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS channel adapter — the isolation unit for SMS. Bundles config + the pure
|
|
3
|
+
* validate + payload builder. The shell looks this up via channels/registry by
|
|
4
|
+
* `schema.channel`; it imports nothing from other channels.
|
|
5
|
+
*/
|
|
6
|
+
import { SMS } from '../../../../../v2Containers/CreativesContainer/constants';
|
|
7
|
+
import config from './config';
|
|
8
|
+
import validate from './validate';
|
|
9
|
+
import buildSubmitPayload from './buildSubmitPayload';
|
|
10
|
+
import getLiquidContent from './getLiquidContent';
|
|
11
|
+
import getEditorErrorDescriptor from './getEditorErrorDescriptor';
|
|
12
|
+
|
|
13
|
+
const smsAdapter = {
|
|
14
|
+
channel: SMS,
|
|
15
|
+
config,
|
|
16
|
+
validate,
|
|
17
|
+
buildSubmitPayload,
|
|
18
|
+
getLiquidContent,
|
|
19
|
+
getEditorErrorDescriptor,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default smsAdapter;
|
package/v2Components/FormBuilder/Functional/channels/sms/tests/getEditorErrorDescriptor.test.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the SMS message-body error → message-descriptor mapping. These
|
|
3
|
+
* are the messages shown INLINE below the message box (the MPUSH-style placement).
|
|
4
|
+
*/
|
|
5
|
+
import getEditorErrorDescriptor from '../getEditorErrorDescriptor';
|
|
6
|
+
import globalMessages from '../../../../../../v2Containers/Cap/messages';
|
|
7
|
+
import formMessages from '../../../../messages';
|
|
8
|
+
import * as tagValidations from '../../../../../../utils/tagValidations';
|
|
9
|
+
|
|
10
|
+
const fd = (content) => ({ 'template-name': 'n', 0: { 'sms-editor': content, base: true } });
|
|
11
|
+
const ctx = { tags: [], isFullMode: true, currentModule: 'default' };
|
|
12
|
+
|
|
13
|
+
describe('getEditorErrorDescriptor (SMS)', () => {
|
|
14
|
+
it('unbalanced braces -> unbalanacedCurlyBraces descriptor tagged kind:brace (shown inline + footer)', () => {
|
|
15
|
+
const d = getEditorErrorDescriptor(fd('abc {{full_name'), ctx);
|
|
16
|
+
expect(d).toEqual({ message: globalMessages.unbalanacedCurlyBraces, kind: 'brace' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('valid content -> null (no error)', () => {
|
|
20
|
+
expect(getEditorErrorDescriptor(fd('hello world'), ctx)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('tolerates default args (no formData / no options)', () => {
|
|
24
|
+
expect(getEditorErrorDescriptor()).toBeNull(); // no tab 0 -> empty content -> null
|
|
25
|
+
expect(getEditorErrorDescriptor(fd('hello world'))).toBeNull(); // valid content, default options
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('empty content -> null (border only, no footer message — matches Classic)', () => {
|
|
29
|
+
expect(getEditorErrorDescriptor(fd(''), ctx)).toBeNull();
|
|
30
|
+
expect(getEditorErrorDescriptor({ 0: {} }, ctx)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// When the tag core reports missing tags, the descriptor maps them to the
|
|
34
|
+
// missing-tag message + kind. (SMS passes initialMissingTags:[] so this path is
|
|
35
|
+
// forward-looking; we stub the core to verify the mapping contract directly.)
|
|
36
|
+
it('maps a missing-tag core result -> missingTagsValidationError descriptor (kind:missing)', () => {
|
|
37
|
+
jest.spyOn(tagValidations, 'validateTagsCore').mockReturnValue({ valid: false, missingTags: ['unsubscribe', 'name'], isBraceError: false });
|
|
38
|
+
const d = getEditorErrorDescriptor(fd('hi'), { tags: [], isFullMode: false });
|
|
39
|
+
expect(d).toEqual({
|
|
40
|
+
message: formMessages.missingTagsValidationError,
|
|
41
|
+
values: { missingTags: 'unsubscribe, name' },
|
|
42
|
+
kind: 'missing',
|
|
43
|
+
});
|
|
44
|
+
tagValidations.validateTagsCore.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null for a non-brace, non-missing invalid result (generic body failure)', () => {
|
|
48
|
+
jest.spyOn(tagValidations, 'validateTagsCore').mockReturnValue({ valid: false, missingTags: [], isBraceError: false });
|
|
49
|
+
expect(getEditorErrorDescriptor(fd('hi'), { tags: [], isFullMode: false })).toBeNull();
|
|
50
|
+
tagValidations.validateTagsCore.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the SMS liquid-content builder. Covers both the with-options
|
|
3
|
+
* and the default-options (no second arg) call shapes.
|
|
4
|
+
*/
|
|
5
|
+
import getLiquidContent from '../getLiquidContent';
|
|
6
|
+
|
|
7
|
+
const fd = (content, name) => ({
|
|
8
|
+
'template-name': name,
|
|
9
|
+
0: { 'sms-editor': content, base: true },
|
|
10
|
+
base: { 'sms-editor': content, base: true },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('getLiquidContent (SMS)', () => {
|
|
14
|
+
it('builds the channel content string from the base tab + name', () => {
|
|
15
|
+
const out = getLiquidContent(fd('hello world', 'My SMS'), { baseLanguage: 'en' });
|
|
16
|
+
expect(typeof out).toBe('string');
|
|
17
|
+
expect(out).toContain('hello world');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('works without an options argument (default {})', () => {
|
|
21
|
+
const out = getLiquidContent(fd('hi there', 'N'));
|
|
22
|
+
expect(typeof out).toBe('string');
|
|
23
|
+
expect(out).toContain('hi there');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure SMS validator (mirrors the Classic SMS validateForm
|
|
3
|
+
* block). Operates on the legacy formData shape; returns legacy errorData.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import validateSms, { ERROR } from '../validate';
|
|
7
|
+
import * as tagValidations from '../../../../../../utils/tagValidations';
|
|
8
|
+
|
|
9
|
+
const fd = ({ name = 'My SMS', content = 'hello world', unicode = false } = {}) => ({
|
|
10
|
+
'template-name': name,
|
|
11
|
+
0: { 'sms-editor': content, 'unicode-validity': unicode, base: true, tabKey: 'k0' },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const baseCtx = { tags: [], isFullMode: true, isEmbedded: false, currentModule: 'default' };
|
|
15
|
+
|
|
16
|
+
describe('validateSms', () => {
|
|
17
|
+
it('valid: ascii content + name', () => {
|
|
18
|
+
const { isValid, errorData } = validateSms(fd(), baseCtx);
|
|
19
|
+
expect(isValid).toBe(true);
|
|
20
|
+
expect(errorData['template-name']).toBe(false);
|
|
21
|
+
expect(errorData[0]['sms-editor']).toBe(false);
|
|
22
|
+
expect(errorData[0]['unicode-validity']).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tolerates default args (no formData / no options)', () => {
|
|
26
|
+
// empty form, default options -> empty content + required name both flagged
|
|
27
|
+
const { isValid, errorData } = validateSms();
|
|
28
|
+
expect(isValid).toBe(false);
|
|
29
|
+
expect(errorData[0]['sms-editor']).toBe(ERROR.GENERIC);
|
|
30
|
+
expect(errorData['template-name']).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('invalid: empty content flags sms-editor with a generic error', () => {
|
|
34
|
+
const { isValid, errorData } = validateSms(fd({ content: '' }), baseCtx);
|
|
35
|
+
expect(isValid).toBe(false);
|
|
36
|
+
expect(errorData[0]['sms-editor']).toBe(ERROR.GENERIC);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('invalid: empty template-name (non-embedded) flags template-name at root', () => {
|
|
40
|
+
const { isValid, errorData } = validateSms(fd({ name: '' }), baseCtx);
|
|
41
|
+
expect(isValid).toBe(false);
|
|
42
|
+
expect(errorData['template-name']).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('invalid: unicode content with the checkbox OFF', () => {
|
|
46
|
+
const { isValid, errorData } = validateSms(fd({ content: 'नमस्ते', unicode: false }), baseCtx);
|
|
47
|
+
expect(isValid).toBe(false);
|
|
48
|
+
expect(errorData[0]['unicode-validity']).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('valid: unicode content with the checkbox ON', () => {
|
|
52
|
+
const { isValid, errorData } = validateSms(fd({ content: 'नमस्ते', unicode: true }), baseCtx);
|
|
53
|
+
expect(isValid).toBe(true);
|
|
54
|
+
expect(errorData[0]['unicode-validity']).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('embedded: template-name is not required', () => {
|
|
58
|
+
const { isValid, errorData } = validateSms(fd({ name: '' }), { ...baseCtx, isEmbedded: true });
|
|
59
|
+
expect(errorData['template-name']).toBe(false);
|
|
60
|
+
expect(isValid).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('invalid: unbalanced braces flag a bracket error on sms-editor', () => {
|
|
64
|
+
const { isValid, errorData } = validateSms(fd({ content: 'hi {{name}' }), baseCtx);
|
|
65
|
+
expect(isValid).toBe(false);
|
|
66
|
+
expect(errorData[0]['sms-editor']).toBe(ERROR.BRACKET);
|
|
67
|
+
expect(errorData[0]['bracket-error']).toBe(ERROR.BRACKET);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('maps a missing-tag core result -> MISSING_TAG editor error', () => {
|
|
71
|
+
// SMS passes initialMissingTags:[] so the core never reports missing tags in
|
|
72
|
+
// practice; stub it to verify validateSms maps a missing-tag result correctly.
|
|
73
|
+
jest.spyOn(tagValidations, 'validateTagsCore').mockReturnValue({ valid: false, missingTags: ['unsubscribe'], isBraceError: false });
|
|
74
|
+
const { isValid, errorData } = validateSms(fd({ content: 'hi' }), { ...baseCtx, isFullMode: false });
|
|
75
|
+
expect(isValid).toBe(false);
|
|
76
|
+
expect(errorData[0]['sms-editor']).toBe(ERROR.MISSING_TAG);
|
|
77
|
+
tagValidations.validateTagsCore.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to a generic error when the core returns a non-object', () => {
|
|
81
|
+
jest.spyOn(tagValidations, 'validateTagsCore').mockReturnValue(undefined);
|
|
82
|
+
const { isValid, errorData } = validateSms(fd({ content: 'hi' }), baseCtx);
|
|
83
|
+
expect(isValid).toBe(false);
|
|
84
|
+
expect(errorData[0]['sms-editor']).toBe(ERROR.GENERIC);
|
|
85
|
+
tagValidations.validateTagsCore.mockRestore();
|
|
86
|
+
});
|
|
87
|
+
});
|