@capillarytech/creatives-library 9.0.57-alpha.4 → 9.0.57

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.57-alpha.4",
4
+ "version": "9.0.57",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -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
- // Field error MESSAGES are hidden until the user triggers validation (clicks
82
- // Save / "Done"), matching the class component's `checkValidation` gate. errorData
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
- // Container-driven confirmation modal (MOBILEPUSH single-platform save). The
87
- // container owns the content (setModalContent -> showModal/modal props); the
88
- // shell owns visibility after a click, mirroring Classic's local showModal state.
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
- // Footer state (mirrors Classic's `liquidErrorMessage`). Holds the last
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-eligible body message for `legacy`, or [] when none. Full mode shows
157
- // errors inline instead, so the footer is suppressed there; library mode keeps
158
- // the old flow (message goes to the footer). The extended ctx (errorData,
159
- // currentTab, schema, checkValidation) feeds the MPUSH footer echo — the SMS
160
- // descriptor derives everything from `legacy` and ignores the extras.
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's validateForm tail always emits onFormValidityChange (Classic.js:1361)
237
- // and runs on every schema change (542-559), deep-unequal parent push (516-537)
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 the POST-dispatch state (a stateRef snapshot can
244
- // be stale mid container-setState-cascade): hydration-family effects only ARM this
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
- // No dependency array on purpose: the parent may mutate the same formData object
266
- // in place (MOBILEPUSH copy-content, tag insertion), which a [parentFormData]-keyed
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 when the tag catalog changes (Classic.js:569-578) ----
304
- // The containers rebuild the tags array every render (Create/index.js:1928), so
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 a form that passed sync validation (mirrors Classic onSubmitWrapper).
328
- // Channels with their own pipeline (MOBILEPUSH: validateMobilePushContent) run it
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 (clean) sync errorData here: the footer carries 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 parity (Classic.js:1180-1204): the single-platform confirmation
390
- // takes over errorData is stored, the container builds the modal, and
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 (below-the-box) message map. The only change vs the old flow: in full mode
408
- // the brace error shows inline instead of the footer. Library mode and all other
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
- // Re-validate the high-frequency inputs (template-name/subject) on blur,
451
- // unconditionally mirroring Classic's handleFieldBlur. Flush the debounced
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: invoke container-injected handlers (Stage A compat) ----
461
- // Signatures mirror Classic exactly:
462
- // - onTagSelect -> (data, currentTab, field) [Classic.js:2588-2591]
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 its own state on discard (callChildEvent 2617-2625); the
473
- // create-flow container then pushes EMPTY formData, which rehydrate ignores —
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 (from TabsContainer): state dispatch + container bridge ----
500
- // Replaces Classic's DOM scan (Classic.js:2526-2551) with the same observable
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 handed to every field renderer (the `renderContext` prop the
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
- // Classic's upload case is the only renderer that ALSO honors startValidation
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
- // Loading overlay — mirrors Classic: spin while liquid-tag extraction is in
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
- // Confirmation modal (Classic getModal 'confirm' variant, Classic.js:597-641).
579
- // Only channels that declare modal semantics render it (MOBILEPUSH); the footer's
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 isTabKey = (key) => /^\d+$/.test(`${key}`);
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
- {!hideHeaders && headerSections.map((section, index) => (
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
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', flagValue);
245
+ mockMpushFlag = flagValue === 'true';
239
246
  });
240
247
  afterEach(() => {
241
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
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
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
132
+ mockMpushFlag = false;
126
133
  });
127
134
 
128
135
  it('Functional path (flag on) populates the fields', () => {
129
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
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
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'false');
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
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', flagValue);
91
+ mockMpushFlag = flagValue === 'true';
85
92
  jest.useFakeTimers();
86
93
  });
87
94
  afterEach(() => {
88
95
  jest.useRealTimers();
89
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
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
- * header hiding for ja-JP, and the tab-switch bridge.
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('hides the tab-header sections for the ja-JP locale', () => {
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).toBe(0);
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', () => {
@@ -71,6 +71,9 @@
71
71
  .fb-upload-file-input {
72
72
  display: none;
73
73
  }
74
+ .fb-tabs-full-width {
75
+ width: 100%;
76
+ }
74
77
  .fb-upload-placeholder {
75
78
  width: 100%;
76
79
  text-align: center;
@@ -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
- // The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
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
- const flags = {
50
- [SMS]: readChannelFlag(hasNewFormBuilderEnabledForSms, 'ENABLE_NEW_FORMBUILDER_SMS'),
51
- [MOBILE_PUSH]: readChannelFlag(hasNewFormBuilderEnabledForMpush, 'ENABLE_NEW_FORMBUILDER_MPUSH'),
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('localStorage session override (QA enabler / kill switch)', () => {
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("'true' override routes the channel to Functional even with the org flag OFF", () => {
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-functional')).toBeInTheDocument();
156
+ expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
158
157
  });
159
158
 
160
- it("'false' override (kill switch) forces Classic even with the org flag ON", () => {
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-classic')).toBeInTheDocument();
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