@capillarytech/creatives-library 9.0.57-alpha.4 → 9.0.57-alpha.6
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/package.json +1 -1
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +37 -74
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +2 -1
- package/v2Components/FormBuilder/Functional/constants.js +3 -0
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +3 -7
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +2 -4
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +10 -3
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +3 -3
- package/v2Components/FormBuilder/_formBuilder.scss +3 -0
- package/v2Components/FormBuilder/index.js +5 -20
- package/v2Components/FormBuilder/tests/entryGate.test.js +5 -12
package/package.json
CHANGED
|
@@ -78,14 +78,10 @@ const FormBuilderShell = (props) => {
|
|
|
78
78
|
|
|
79
79
|
const [state, dispatch] = useReducer(formReducer, channel, createInitialState);
|
|
80
80
|
const [errorData, setErrorData] = useState({});
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// is still computed + emitted via onFormValidityChange on mount; only the inline
|
|
84
|
-
// 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.
|
|
85
83
|
const [internalCheckValidation, setInternalCheckValidation] = useState(false);
|
|
86
|
-
//
|
|
87
|
-
// container owns the content (setModalContent -> showModal/modal props); the
|
|
88
|
-
// shell owns visibility after a click, mirroring Classic's local showModal state.
|
|
84
|
+
// Single-platform save modal: container owns content, shell owns visibility (Classic parity).
|
|
89
85
|
const [modalVisible, setModalVisible] = useState(false);
|
|
90
86
|
|
|
91
87
|
// refs to latest values for use inside stable/debounced callbacks
|
|
@@ -135,9 +131,7 @@ const FormBuilderShell = (props) => {
|
|
|
135
131
|
...extra,
|
|
136
132
|
});
|
|
137
133
|
|
|
138
|
-
//
|
|
139
|
-
// {STANDARD_ERROR_MSG, LIQUID_ERROR_MSG} so standard- and liquid-error pushes don't
|
|
140
|
-
// clobber each other before forwarding to the container's showLiquidErrorInFooter.
|
|
134
|
+
// Classic's liquidErrorMessage: keeps STANDARD/LIQUID footer errors from clobbering each other.
|
|
141
135
|
const footerRef = useRef({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
|
|
142
136
|
const liquidEnabled = Boolean(adapter.config?.features?.liquid);
|
|
143
137
|
// Classic passes null as the footer's tab argument for SMS and the live 1-based
|
|
@@ -153,11 +147,8 @@ const FormBuilderShell = (props) => {
|
|
|
153
147
|
}
|
|
154
148
|
};
|
|
155
149
|
|
|
156
|
-
// Footer
|
|
157
|
-
//
|
|
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.
|
|
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.
|
|
161
152
|
const standardFooterMsg = (legacy, result) => {
|
|
162
153
|
if (isFullMode || !adapter.getEditorErrorDescriptor || !intl) return [];
|
|
163
154
|
const descriptor = adapter.getEditorErrorDescriptor(legacy, {
|
|
@@ -233,16 +224,12 @@ const FormBuilderShell = (props) => {
|
|
|
233
224
|
}
|
|
234
225
|
}, [schemaReady]);
|
|
235
226
|
|
|
236
|
-
// Classic
|
|
237
|
-
//
|
|
238
|
-
// and tags change (569-578) — display stays gated. The MPUSH containers gate
|
|
239
|
-
// their save flows on that maintained validity (Create/index.js:101), so these
|
|
240
|
-
// background emissions are load-bearing. SMS keeps its shipped behavior.
|
|
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.
|
|
241
229
|
const validateOnExternalChange = Boolean(adapter.config?.features?.validateOnExternalChange);
|
|
242
230
|
|
|
243
|
-
// Background emissions must read
|
|
244
|
-
//
|
|
245
|
-
// flag; the [state]-keyed effect below emits once the new state has rendered.
|
|
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.
|
|
246
233
|
const pendingBackgroundValidateRef = useRef(false);
|
|
247
234
|
const pendingEmitChangeRef = useRef(false);
|
|
248
235
|
|
|
@@ -262,9 +249,8 @@ const FormBuilderShell = (props) => {
|
|
|
262
249
|
}, [schema]);
|
|
263
250
|
|
|
264
251
|
// ---- re-hydrate on a GENUINE external parent push (edit load / discard / copy) ----
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// effect would never see. The deep-compare guard keeps this a no-op on quiet renders.
|
|
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).
|
|
268
254
|
useEffect(() => {
|
|
269
255
|
if (!didInitRef.current || !isNonEmpty(parentFormData)) return;
|
|
270
256
|
if (isEqual(parentFormData, lastSeenParentRef.current)) return; // nothing new from the parent
|
|
@@ -278,13 +264,12 @@ const FormBuilderShell = (props) => {
|
|
|
278
264
|
if (propsRef.current.isEdit) {
|
|
279
265
|
emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
|
|
280
266
|
}
|
|
281
|
-
// Classic validates on every deep-unequal parent push (Classic.js:516-537)
|
|
282
|
-
// this is what turns the container's isFormValid true before Create/Done.
|
|
267
|
+
// Classic validates on every deep-unequal parent push (Classic.js:516-537).
|
|
283
268
|
if (validateOnExternalChange) {
|
|
284
269
|
pendingBackgroundValidateRef.current = true;
|
|
285
270
|
}
|
|
286
271
|
}
|
|
287
|
-
});
|
|
272
|
+
}, [parentFormData]);
|
|
288
273
|
|
|
289
274
|
// ---- background validity / formData emission from the SETTLED state (see flags above) ----
|
|
290
275
|
useEffect(() => {
|
|
@@ -300,9 +285,8 @@ const FormBuilderShell = (props) => {
|
|
|
300
285
|
}
|
|
301
286
|
}, [state]);
|
|
302
287
|
|
|
303
|
-
// ---- re-validate
|
|
304
|
-
//
|
|
305
|
-
// compare by content, not identity. Emits validity only; display stays gated.
|
|
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 ----
|
|
306
290
|
useEffect(() => {
|
|
307
291
|
if (!didInitRef.current || !validateOnExternalChange) return;
|
|
308
292
|
const nextTags = propsRef.current.tags;
|
|
@@ -324,11 +308,8 @@ const FormBuilderShell = (props) => {
|
|
|
324
308
|
setModalVisible(Boolean(showModal));
|
|
325
309
|
}, [showModal, modal]);
|
|
326
310
|
|
|
327
|
-
// Submit
|
|
328
|
-
//
|
|
329
|
-
// through adapter.runSubmitPipeline; otherwise the default liquid flow applies:
|
|
330
|
-
// full mode submits directly; outside it, liquid-supported channels run liquid-tag
|
|
331
|
-
// validation first — a clean result submits, an error goes to the footer and blocks.
|
|
311
|
+
// Submit after sync validation (Classic onSubmitWrapper): adapter.runSubmitPipeline
|
|
312
|
+
// when the channel has one, else the default liquid flow (validate outside full mode).
|
|
332
313
|
const submitValidForm = (legacy, validErrorData, { singleTab = null } = {}) => {
|
|
333
314
|
const currentProps = propsRef.current;
|
|
334
315
|
|
|
@@ -367,8 +348,7 @@ const FormBuilderShell = (props) => {
|
|
|
367
348
|
onError: ({ standardErrors, liquidErrors }) => {
|
|
368
349
|
pushFooter({ STANDARD_ERROR_MSG: standardErrors, LIQUID_ERROR_MSG: liquidErrors });
|
|
369
350
|
if (currentProps.stopValidation) currentProps.stopValidation();
|
|
370
|
-
// Classic passes the
|
|
371
|
-
// liquid error, not a per-field error.
|
|
351
|
+
// Classic passes the clean sync errorData: the footer carries the liquid error.
|
|
372
352
|
if (currentProps.onFormValidityChange) currentProps.onFormValidityChange(false, validErrorData);
|
|
373
353
|
},
|
|
374
354
|
onSuccess: () => {
|
|
@@ -386,10 +366,8 @@ const FormBuilderShell = (props) => {
|
|
|
386
366
|
const result = runValidate(legacy, { isSave: true });
|
|
387
367
|
if (result.platform) platformRef.current = result.platform;
|
|
388
368
|
if (result.singlePlatformPrompt) {
|
|
389
|
-
// Classic
|
|
390
|
-
//
|
|
391
|
-
// NOTHING else runs: no validity emission, no footer push, no
|
|
392
|
-
// stopValidation, no submit. The modal is the only continuation.
|
|
369
|
+
// Classic.js:1180-1204: the single-platform modal takes over — store errorData
|
|
370
|
+
// and STOP (no validity emission, footer push, stopValidation, or submit).
|
|
393
371
|
setErrorData(result.errorData);
|
|
394
372
|
if (propsRef.current.setModalContent) propsRef.current.setModalContent(result.singlePlatformPrompt);
|
|
395
373
|
} else {
|
|
@@ -404,9 +382,8 @@ const FormBuilderShell = (props) => {
|
|
|
404
382
|
prevStartValidationRef.current = startValidation;
|
|
405
383
|
}, [startValidation]);
|
|
406
384
|
|
|
407
|
-
// Inline
|
|
408
|
-
//
|
|
409
|
-
// 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.
|
|
410
387
|
const resolvedFieldErrors = useMemo(() => {
|
|
411
388
|
if (!isFullMode) return {}; // library mode: unchanged old flow (footer only)
|
|
412
389
|
const editorId = adapter.config?.fieldIds?.editor;
|
|
@@ -447,9 +424,8 @@ const FormBuilderShell = (props) => {
|
|
|
447
424
|
}
|
|
448
425
|
};
|
|
449
426
|
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// 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.
|
|
453
429
|
const onFieldBlur = (field) => {
|
|
454
430
|
if (!field || !HIGH_FREQ_FIELDS.includes(field.id)) return;
|
|
455
431
|
debouncedEmit.flush();
|
|
@@ -457,21 +433,16 @@ const FormBuilderShell = (props) => {
|
|
|
457
433
|
emitValidity(runValidate(legacy), legacy);
|
|
458
434
|
};
|
|
459
435
|
|
|
460
|
-
// ---- parent bridge:
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// - data-commit events -> (true, updatedLegacyFormData, field) [performFormDataUpdate]
|
|
464
|
-
// (checkbox submit-actions, radio onChange, select onSelect — MOBILEPUSH only,
|
|
465
|
-
// gated on config.features.bridgeFieldEvents so SMS behavior is untouched)
|
|
466
|
-
// - everything else -> (data, field.id) [callChildEvent tail]
|
|
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).
|
|
467
439
|
const onEvent = (field, eventName, data) => {
|
|
468
440
|
const injected = field.injectedEvents?.[eventName];
|
|
469
441
|
if (typeof injected !== 'function') return;
|
|
470
442
|
const parent = propsRef.current.parent;
|
|
471
443
|
if (eventName === 'discardValues') {
|
|
472
|
-
// Classic resets
|
|
473
|
-
//
|
|
474
|
-
// so the local re-init must happen here.
|
|
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.
|
|
475
446
|
injected.call(parent, data, field.id);
|
|
476
447
|
const fresh = initializeFormState(propsRef.current.schema || {});
|
|
477
448
|
lastEmittedRef.current = null;
|
|
@@ -496,9 +467,8 @@ const FormBuilderShell = (props) => {
|
|
|
496
467
|
}
|
|
497
468
|
};
|
|
498
469
|
|
|
499
|
-
// ---- tab switch (
|
|
500
|
-
//
|
|
501
|
-
// contract: the container's injected onTabChange receives the 1-based index.
|
|
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 ----
|
|
502
472
|
const onTabSwitch = (container, key) => {
|
|
503
473
|
const tabs = stateRef.current?.tabs || [];
|
|
504
474
|
let index = tabs.findIndex((tab) => `${tab?.tabKey}` === `${key}`);
|
|
@@ -535,15 +505,13 @@ const FormBuilderShell = (props) => {
|
|
|
535
505
|
// Point the stable wrappers at the latest handler bodies (read at call time).
|
|
536
506
|
handlersRef.current = { onFieldChange, onFieldBlur, onEvent };
|
|
537
507
|
|
|
538
|
-
// Render context
|
|
539
|
-
// renderers / Section / FieldSlot consume).
|
|
508
|
+
// Render context consumed by every field renderer / Section / FieldSlot.
|
|
540
509
|
const renderContext = {
|
|
541
510
|
state,
|
|
542
511
|
errorData,
|
|
543
512
|
registry,
|
|
544
513
|
checkValidation: internalCheckValidation || Boolean(checkValidation),
|
|
545
|
-
//
|
|
546
|
-
// for error display (Classic.js:3663) — exposed separately for it.
|
|
514
|
+
// Upload is the one renderer that also honors startValidation for display (Classic.js:3663).
|
|
547
515
|
startValidation: Boolean(startValidation),
|
|
548
516
|
activeTabIndex,
|
|
549
517
|
currentTab,
|
|
@@ -569,17 +537,12 @@ const FormBuilderShell = (props) => {
|
|
|
569
537
|
refs,
|
|
570
538
|
};
|
|
571
539
|
|
|
572
|
-
//
|
|
573
|
-
// progress (only for liquid-supported channels) or while the layout metadata
|
|
574
|
-
// is still being fetched.
|
|
540
|
+
// Classic's overlay: spin during liquid-tag extraction or while layout metadata loads.
|
|
575
541
|
const spinning = Boolean((liquidEnabled && liquidExtractionInProgress) || metaDataStatus === REQUEST);
|
|
576
542
|
const spinTip = intl?.formatMessage ? intl.formatMessage(formMessages.liquidSpinText) : '';
|
|
577
543
|
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// primary button carries the modal id — that id IS the handleOk dispatch key.
|
|
581
|
-
// width 520 = the antd Modal default Classic renders (Classic.js:601);
|
|
582
|
-
// CapModal's own default (324) wraps the single-platform prompt to two lines.
|
|
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).
|
|
583
546
|
const confirmModal = adapter.modals && modal && (
|
|
584
547
|
<CapModal
|
|
585
548
|
open={modalVisible}
|
|
@@ -12,7 +12,8 @@ import { DEFAULT as DEFAULT_MODULE, ANDROID, IOS } from '../../../../../constant
|
|
|
12
12
|
import { TEMPLATE_NAME_FIELD } from '../../constants';
|
|
13
13
|
import { ERROR_VALUE, TABS, CTA_RULES } from './config';
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const TAB_KEY_REGEX = /^\d+$/;
|
|
16
|
+
const isTabKey = (key) => TAB_KEY_REGEX.test(`${key}`);
|
|
16
17
|
|
|
17
18
|
// Tab metadata keys skipped when seeding a tab's error map (Classic.js:1627-1656).
|
|
18
19
|
const ERROR_SEED_SKIP_KEYS = ['tabKey', 'base', 'selectedLanguages'];
|
|
@@ -49,6 +49,9 @@ export const MEMOIZED_TYPES = new Set([
|
|
|
49
49
|
|
|
50
50
|
// Fallback active tab for single-tab channels (SMS). Tabbed channels keep the live
|
|
51
51
|
// value in reducer state (meta.activeTabIndex).
|
|
52
|
+
// Image types the MPUSH upload accepts (Classic.js:2746 wrong-file check).
|
|
53
|
+
export const ALLOWED_IMAGE_EXTENSIONS = /(\.bmp|\.jpeg|\.png|\.gif|\.avif|\.jpg)$/i;
|
|
54
|
+
|
|
52
55
|
export const ACTIVE_TAB_INDEX = 0;
|
|
53
56
|
|
|
54
57
|
// Injected events called with Classic's performFormDataUpdate signature
|
|
@@ -14,8 +14,6 @@ import PropTypes from 'prop-types';
|
|
|
14
14
|
import CapTab from '@capillarytech/cap-ui-library/CapTab';
|
|
15
15
|
import Section from './Section';
|
|
16
16
|
|
|
17
|
-
const JAPANESE_LOCALE = 'ja-JP';
|
|
18
|
-
|
|
19
17
|
const hasTabKey = (key) => key !== undefined && key !== '';
|
|
20
18
|
|
|
21
19
|
const TabsContainer = ({ container, renderContext }) => {
|
|
@@ -23,7 +21,6 @@ const TabsContainer = ({ container, renderContext }) => {
|
|
|
23
21
|
|
|
24
22
|
const tabs = renderContext?.state?.tabs || [];
|
|
25
23
|
const activeTabIndex = renderContext?.activeTabIndex ?? 0;
|
|
26
|
-
const hideHeaders = renderContext?.intl?.locale === JAPANESE_LOCALE;
|
|
27
24
|
|
|
28
25
|
const renderedPanes = [];
|
|
29
26
|
let supportedOrdinal = 0; // class parity: only supported panes advance the index
|
|
@@ -44,9 +41,10 @@ const TabsContainer = ({ container, renderContext }) => {
|
|
|
44
41
|
// stays cheap through the FieldSlot memo boundaries. Lazy activation is a
|
|
45
42
|
// deliberate post-rollout follow-up (see the MPUSH design doc §9).
|
|
46
43
|
forceRender: true,
|
|
44
|
+
// The div wrapper + onInput stop are Classic verbatim (Classic.js:4278).
|
|
47
45
|
tab: (
|
|
48
46
|
<div className="form-tab-header" onInput={(e) => e.stopPropagation()}>
|
|
49
|
-
{
|
|
47
|
+
{headerSections.map((section, index) => (
|
|
50
48
|
<Section key={`hdr-${index}`} section={section} renderContext={paneContext} />
|
|
51
49
|
))}
|
|
52
50
|
</div>
|
|
@@ -67,11 +65,9 @@ const TabsContainer = ({ container, renderContext }) => {
|
|
|
67
65
|
|
|
68
66
|
return (
|
|
69
67
|
<CapTab
|
|
70
|
-
className={`cap-tabs-${container.id}`}
|
|
68
|
+
className={`cap-tabs-${container.id} fb-tabs-full-width`}
|
|
71
69
|
activeKey={resolvedActiveKey}
|
|
72
|
-
defaultActiveKey={resolvedActiveKey}
|
|
73
70
|
onChange={(key) => renderContext?.onTabSwitch?.(container, key)}
|
|
74
|
-
style={{ width: '100%' }}
|
|
75
71
|
panes={renderedPanes}
|
|
76
72
|
/>
|
|
77
73
|
);
|
|
@@ -28,14 +28,12 @@ import formMessages from '../../messages';
|
|
|
28
28
|
import { isAiContentBotDisabled } from '../../../../utils/common';
|
|
29
29
|
import { hasPersonalizationTags } from '../../../../utils/commonUtils';
|
|
30
30
|
import { createFieldRegistry } from '../core/schema/fieldRegistry';
|
|
31
|
-
import { FIELD_TYPE } from '../constants';
|
|
31
|
+
import { ALLOWED_IMAGE_EXTENSIONS, FIELD_TYPE } from '../constants';
|
|
32
32
|
import { ButtonField, TagListField } from './smsRenderers';
|
|
33
33
|
import { ERROR_VALUE } from '../channels/mobilepush/config';
|
|
34
34
|
|
|
35
35
|
const { TextArea } = CapInput;
|
|
36
36
|
|
|
37
|
-
// Image types the upload accepts (Classic.js:2746 wrong-file check).
|
|
38
|
-
const ALLOWED_IMAGE_EXTENSIONS = /(\.bmp|\.jpeg|\.png|\.gif|\.avif|\.jpg)$/i;
|
|
39
37
|
|
|
40
38
|
const dataFieldPropTypes = {
|
|
41
39
|
field: PropTypes.object.isRequired,
|
|
@@ -96,7 +94,7 @@ export const MpushTextAreaField = ({
|
|
|
96
94
|
field, value, error, onChange, renderContext,
|
|
97
95
|
}) => {
|
|
98
96
|
let aiDisabled = true;
|
|
99
|
-
try { aiDisabled = isAiContentBotDisabled(); } catch (e) { aiDisabled = true; }
|
|
97
|
+
try { aiDisabled = isAiContentBotDisabled(); } catch (e) { console.error(e); aiDisabled = true; }
|
|
100
98
|
// Classic-observable behavior: errorData is only ever computed under
|
|
101
99
|
// startValidation (Classic.js:334-345), so NO editor error — empty or tag —
|
|
102
100
|
// is visible before a save attempt. checkValidation is true exactly in the
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MANUAL perf probe (skipped by default): per-keystroke JS cost, Classic vs
|
|
3
|
+
* Functional, over the real embedded Create container. jsdom has no paint, so
|
|
4
|
+
* this measures the render+effects side of typing lag as an A/B comparison.
|
|
5
|
+
* Run: RUN_PERF=1 TZ=UTC npx jest --config internals/testing/jest.unit.config.js manual.typingPerf
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import '@testing-library/jest-dom';
|
|
9
|
+
import _ from 'lodash';
|
|
10
|
+
import { Router } from 'react-router-dom';
|
|
11
|
+
import { render, act, fireEvent } from '../../../../utils/test-utils';
|
|
12
|
+
import history from '../../../../utils/history';
|
|
13
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
14
|
+
import { Create } from '../../../../v2Containers/MobilePush/Create';
|
|
15
|
+
|
|
16
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
17
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('../../../../services/api', () => ({
|
|
20
|
+
...jest.requireActual('../../../../services/api'),
|
|
21
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
let mockMpushFlag = false;
|
|
25
|
+
jest.mock('../../../../utils/common', () => ({
|
|
26
|
+
...jest.requireActual('../../../../utils/common'),
|
|
27
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
31
|
+
const intl = { formatMessage: (d) => (d && (d.defaultMessage || d.id)) || '', locale: 'en' };
|
|
32
|
+
const DEEPLINKS = JSON.stringify([{ name: 'Home Page', link: 'app://home', keys: ['user_id'] }]);
|
|
33
|
+
const selectedAccount = {
|
|
34
|
+
id: 'acc-1', name: 'Acc', sourceTypeName: 'SOME_SDK', sourceAccountIdentifier: 'lic-1',
|
|
35
|
+
configs: { android: '1', ios: '1', deeplink: DEEPLINKS },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildProps = () => ({
|
|
39
|
+
intl,
|
|
40
|
+
params: { mode: 'text' },
|
|
41
|
+
route: { name: 'create' },
|
|
42
|
+
location: { pathname: '/mobilepush/create/text', query: { module: 'default', type: 'embedded' } },
|
|
43
|
+
router: { push: jest.fn() },
|
|
44
|
+
isFullMode: false,
|
|
45
|
+
isGetFormData: false,
|
|
46
|
+
isLoadingMetaEntities: false,
|
|
47
|
+
metaEntities: {},
|
|
48
|
+
Create: { createTemplateInProgress: false },
|
|
49
|
+
Edit: {},
|
|
50
|
+
Templates: { selectedWeChatAccount: selectedAccount },
|
|
51
|
+
actions: new Proxy({}, { get: (t, n) => { if (!t[n]) t[n] = jest.fn(); return t[n]; } }), // eslint-disable-line no-param-reassign
|
|
52
|
+
globalActions: { fetchSchemaForEntity: jest.fn(), addMessageToQueue: jest.fn(), setInjectedTags: jest.fn() },
|
|
53
|
+
getFormLibraryData: jest.fn(),
|
|
54
|
+
onValidationFail: jest.fn(),
|
|
55
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
56
|
+
onPersonalizationTokensChange: jest.fn(),
|
|
57
|
+
onContentValidityChange: jest.fn(),
|
|
58
|
+
getLiquidTags: jest.fn(),
|
|
59
|
+
injectedTags: {},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const setNativeValue = (el, value) => {
|
|
63
|
+
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set.call(el, value);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const measureFlow = (flag, { withCta = false, longContent = 0 } = {}) => {
|
|
67
|
+
mockMpushFlag = flag;
|
|
68
|
+
const props = buildProps();
|
|
69
|
+
const ui = (p) => (
|
|
70
|
+
<Router history={history}>
|
|
71
|
+
<Create {...p} />
|
|
72
|
+
</Router>
|
|
73
|
+
);
|
|
74
|
+
const utils = render(ui(props));
|
|
75
|
+
const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
|
|
76
|
+
act(() => { utils.rerender(ui({ ...props, metaEntities: meta })); });
|
|
77
|
+
|
|
78
|
+
if (withCta) {
|
|
79
|
+
const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
|
|
80
|
+
const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
|
|
81
|
+
const cb = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
|
|
82
|
+
act(() => { fireEvent.click(cb); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const editor = document.getElementById('message-editor');
|
|
86
|
+
let text = 'The quick brown fox jumped over the lazy dog and kept typing more content ';
|
|
87
|
+
if (longContent) {
|
|
88
|
+
// long-message scenario incl. personalization tags — catches superlinear
|
|
89
|
+
// per-keystroke validation/render costs that short content hides.
|
|
90
|
+
text = 'Hello {{first_name}}, your offer {{offer_id}} is waiting. '.repeat(Math.ceil(longContent / 58));
|
|
91
|
+
}
|
|
92
|
+
// warm-up keystrokes (JIT/styles)
|
|
93
|
+
for (let i = 0; i < 5; i++) {
|
|
94
|
+
text += 'w';
|
|
95
|
+
setNativeValue(editor, text);
|
|
96
|
+
act(() => { fireEvent.change(editor, { target: { value: text } }); });
|
|
97
|
+
}
|
|
98
|
+
const N = 30;
|
|
99
|
+
const t0 = performance.now();
|
|
100
|
+
for (let i = 0; i < N; i++) {
|
|
101
|
+
text += String.fromCharCode(97 + (i % 26));
|
|
102
|
+
setNativeValue(editor, text);
|
|
103
|
+
act(() => { fireEvent.change(editor, { target: { value: text } }); });
|
|
104
|
+
}
|
|
105
|
+
const perKey = (performance.now() - t0) / N;
|
|
106
|
+
utils.unmount();
|
|
107
|
+
mockMpushFlag = false;
|
|
108
|
+
return perKey;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const describePerf = process.env.RUN_PERF ? describe : describe.skip;
|
|
112
|
+
describePerf('MANUAL typing perf (RUN_PERF=1 to enable)', () => {
|
|
113
|
+
it('per-keystroke JS cost, Classic vs Functional', () => {
|
|
114
|
+
const classicPlain = measureFlow(false);
|
|
115
|
+
const functionalPlain = measureFlow(true);
|
|
116
|
+
const classicCta = measureFlow(false, { withCta: true });
|
|
117
|
+
const functionalCta = measureFlow(true, { withCta: true });
|
|
118
|
+
const classicLong5k = measureFlow(false, { longContent: 5000 });
|
|
119
|
+
const functionalLong5k = measureFlow(true, { longContent: 5000 });
|
|
120
|
+
const classicLong20k = measureFlow(false, { longContent: 20000 });
|
|
121
|
+
const functionalLong20k = measureFlow(true, { longContent: 20000 });
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.log(JSON.stringify({
|
|
124
|
+
classicPlain: `${classicPlain.toFixed(1)} ms/key`,
|
|
125
|
+
functionalPlain: `${functionalPlain.toFixed(1)} ms/key`,
|
|
126
|
+
classicWithCta: `${classicCta.toFixed(1)} ms/key`,
|
|
127
|
+
functionalWithCta: `${functionalCta.toFixed(1)} ms/key`,
|
|
128
|
+
classicLong5k: `${classicLong5k.toFixed(1)} ms/key`,
|
|
129
|
+
functionalLong5k: `${functionalLong5k.toFixed(1)} ms/key`,
|
|
130
|
+
classicLong20k: `${classicLong20k.toFixed(1)} ms/key`,
|
|
131
|
+
functionalLong20k: `${functionalLong20k.toFixed(1)} ms/key`,
|
|
132
|
+
}, null, 1));
|
|
133
|
+
expect(true).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -37,6 +37,13 @@ jest.mock('../../../../services/api', () => ({
|
|
|
37
37
|
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
38
38
|
}));
|
|
39
39
|
|
|
40
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
41
|
+
let mockMpushFlag = false;
|
|
42
|
+
jest.mock('../../../../utils/common', () => ({
|
|
43
|
+
...jest.requireActual('../../../../utils/common'),
|
|
44
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
45
|
+
}));
|
|
46
|
+
|
|
40
47
|
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
41
48
|
|
|
42
49
|
const intl = {
|
|
@@ -235,10 +242,10 @@ describe.each([
|
|
|
235
242
|
['Functional', 'true'],
|
|
236
243
|
])('CTA flows parity — %s', (flow, flagValue) => {
|
|
237
244
|
beforeEach(() => {
|
|
238
|
-
|
|
245
|
+
mockMpushFlag = flagValue === 'true';
|
|
239
246
|
});
|
|
240
247
|
afterEach(() => {
|
|
241
|
-
|
|
248
|
+
mockMpushFlag = false;
|
|
242
249
|
});
|
|
243
250
|
|
|
244
251
|
it('deeplink option WITHOUT params: no extra input, save proceeds', () => {
|
|
@@ -30,6 +30,13 @@ jest.mock('../../../../services/api', () => ({
|
|
|
30
30
|
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
34
|
+
let mockMpushFlag = false;
|
|
35
|
+
jest.mock('../../../../utils/common', () => ({
|
|
36
|
+
...jest.requireActual('../../../../utils/common'),
|
|
37
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
38
|
+
}));
|
|
39
|
+
|
|
33
40
|
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
34
41
|
|
|
35
42
|
const intl = {
|
|
@@ -122,18 +129,18 @@ const runEditContainer = () => {
|
|
|
122
129
|
|
|
123
130
|
describe('SCRATCH v3: REAL Edit container over the gated FormBuilder', () => {
|
|
124
131
|
afterEach(() => {
|
|
125
|
-
|
|
132
|
+
mockMpushFlag = false;
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
it('Functional path (flag on) populates the fields', () => {
|
|
129
|
-
|
|
136
|
+
mockMpushFlag = true;
|
|
130
137
|
const r = runEditContainer();
|
|
131
138
|
expect(r.titleValue).toBe('Saved A title');
|
|
132
139
|
expect(r.editorValue).toBe('Saved A body');
|
|
133
140
|
});
|
|
134
141
|
|
|
135
142
|
it('Classic path (flag off) populates the fields', () => {
|
|
136
|
-
|
|
143
|
+
mockMpushFlag = false;
|
|
137
144
|
const r = runEditContainer();
|
|
138
145
|
expect(r.titleValue).toBe('Saved A title');
|
|
139
146
|
expect(r.editorValue).toBe('Saved A body');
|
|
@@ -21,6 +21,13 @@ jest.mock('../../../../services/api', () => ({
|
|
|
21
21
|
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
22
22
|
}));
|
|
23
23
|
|
|
24
|
+
// The gate is purely org-flag controlled — drive the flow via the flag reader.
|
|
25
|
+
let mockMpushFlag = false;
|
|
26
|
+
jest.mock('../../../../utils/common', () => ({
|
|
27
|
+
...jest.requireActual('../../../../utils/common'),
|
|
28
|
+
hasNewFormBuilderEnabledForMpush: () => mockMpushFlag,
|
|
29
|
+
}));
|
|
30
|
+
|
|
24
31
|
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
25
32
|
|
|
26
33
|
const intl = {
|
|
@@ -81,12 +88,12 @@ describe.each([
|
|
|
81
88
|
['Functional', 'true'],
|
|
82
89
|
])('library-mode validity parity — %s', (flow, flagValue) => {
|
|
83
90
|
beforeEach(() => {
|
|
84
|
-
|
|
91
|
+
mockMpushFlag = flagValue === 'true';
|
|
85
92
|
jest.useFakeTimers();
|
|
86
93
|
});
|
|
87
94
|
afterEach(() => {
|
|
88
95
|
jest.useRealTimers();
|
|
89
|
-
|
|
96
|
+
mockMpushFlag = false;
|
|
90
97
|
});
|
|
91
98
|
|
|
92
99
|
it('isFormValid flips true after typing valid content (Done gate) and content-emptiness reports live', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for TabsContainer — pane/key binding, unsupported-pane skipping,
|
|
3
|
-
*
|
|
3
|
+
* locale-independent headers, and the tab-switch bridge.
|
|
4
4
|
*/
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { render, fireEvent } from '@testing-library/react';
|
|
@@ -57,12 +57,12 @@ describe('TabsContainer', () => {
|
|
|
57
57
|
expect(dom.querySelectorAll('.ant-tabs-tab').length).toBe(1);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('
|
|
60
|
+
it('renders the tab-header sections for every locale (Classic has no locale gating)', () => {
|
|
61
61
|
const context = buildContext({ intl: { locale: 'ja-JP', formatMessage: (d) => d.id } });
|
|
62
62
|
const { queryAllByTestId } = render(
|
|
63
63
|
<TabsContainer container={buildContainer()} renderContext={context} />,
|
|
64
64
|
);
|
|
65
|
-
expect(queryAllByTestId('stub').length).
|
|
65
|
+
expect(queryAllByTestId('stub').length).toBeGreaterThan(0);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it('falls back to container.tabContent.sections when a pane has no sectionsHeaders', () => {
|
|
@@ -28,30 +28,15 @@ import {
|
|
|
28
28
|
} from '../../utils/common';
|
|
29
29
|
import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
|
|
30
30
|
|
|
31
|
-
// Session-level QA override / kill switch (see "Feature Flag Rollout Strategy" in
|
|
32
|
-
// the design docs): a localStorage key named after the flag ('true'/'false')
|
|
33
|
-
// beats the org flag for THIS browser session only. Lets QA exercise the new
|
|
34
|
-
// path before org provisioning, and support disable it for a single affected
|
|
35
|
-
// user without a config change. Any storage failure falls back to the org flag.
|
|
36
|
-
const readChannelFlag = (orgFlagReader, flagName) => {
|
|
37
|
-
try {
|
|
38
|
-
const override = window.localStorage.getItem(flagName);
|
|
39
|
-
if (override === 'true') return true;
|
|
40
|
-
if (override === 'false') return false;
|
|
41
|
-
} catch (e) { /* storage unavailable -> org flag */ }
|
|
42
|
-
return Boolean(orgFlagReader());
|
|
43
|
-
};
|
|
44
|
-
|
|
45
31
|
const FormBuilder = (props) => {
|
|
46
|
-
//
|
|
32
|
+
// One-time org-flag read (API-provisioned via Auth.hasFeatureAccess); the try/catch
|
|
33
|
+
// guards early bootstrap where window.capAuth is not ready — any failure routes to Classic.
|
|
47
34
|
const [newBuilderFlags] = useState(() => {
|
|
48
35
|
try {
|
|
49
|
-
|
|
50
|
-
[SMS]:
|
|
51
|
-
[MOBILE_PUSH]:
|
|
36
|
+
return {
|
|
37
|
+
[SMS]: Boolean(hasNewFormBuilderEnabledForSms()),
|
|
38
|
+
[MOBILE_PUSH]: Boolean(hasNewFormBuilderEnabledForMpush()),
|
|
52
39
|
};
|
|
53
|
-
console.log('### FormBuilder V3 per-channel flags for org:', flags);
|
|
54
|
-
return flags;
|
|
55
40
|
} catch (e) {
|
|
56
41
|
return {};
|
|
57
42
|
}
|
|
@@ -145,29 +145,22 @@ describe('FormBuilder entry gate — routing', () => {
|
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
describe('
|
|
148
|
+
describe('the gate is purely org-flag controlled (no localStorage override)', () => {
|
|
149
149
|
afterEach(() => {
|
|
150
150
|
window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
|
|
151
|
-
window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_SMS');
|
|
152
151
|
});
|
|
153
152
|
|
|
154
|
-
it(
|
|
153
|
+
it('a localStorage key does NOT enable the new path when the org flag is OFF', () => {
|
|
155
154
|
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
|
|
156
155
|
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
157
|
-
expect(screen.getByTestId('impl-
|
|
156
|
+
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
158
157
|
});
|
|
159
158
|
|
|
160
|
-
it(
|
|
159
|
+
it('a localStorage key does NOT disable the new path when the org flag is ON', () => {
|
|
161
160
|
hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
|
|
162
161
|
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'false');
|
|
163
162
|
renderGate({ schema: { channel: 'MOBILEPUSH' } });
|
|
164
|
-
expect(screen.getByTestId('impl-
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('an override on one channel does not affect the other', () => {
|
|
168
|
-
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
|
|
169
|
-
renderGate({ schema: { channel: 'SMS' } });
|
|
170
|
-
expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
|
|
163
|
+
expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
|
|
171
164
|
});
|
|
172
165
|
});
|
|
173
166
|
|