@capillarytech/creatives-library 9.0.59 → 9.0.61-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/constants/unified.js +2 -3
  2. package/package.json +1 -1
  3. package/utils/common.js +0 -8
  4. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
  5. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  6. package/v2Components/FormBuilder/Functional/constants.js +8 -46
  7. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  8. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  9. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  10. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  11. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  12. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  13. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  14. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  15. package/v2Components/FormBuilder/_formBuilder.scss +0 -11
  16. package/v2Components/FormBuilder/index.js +13 -20
  17. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
  18. package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -25
  19. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -28
  20. package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
  21. package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
  22. package/v2Containers/TagList/index.js +3 -3
  23. package/v2Containers/TagList/tests/TagList.test.js +16 -0
  24. package/v2Containers/Viber/constants.js +6 -0
  25. package/v2Containers/Viber/index.js +22 -16
  26. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  27. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
  28. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  32. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  33. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  34. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  35. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  36. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  37. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
  38. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
  39. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
  40. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  41. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
  42. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
  43. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  44. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
  45. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
  46. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
  47. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  48. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -9,13 +9,7 @@ export const SECTION_TYPE = {
9
9
  COL_LABEL: 'col-label',
10
10
  };
11
11
 
12
- // Container types in the layout schema (schema.containers[]). MOBILEPUSH uses a
13
- // single 'tabs' container carrying the Android/iOS panes.
14
- export const CONTAINER_TYPE = {
15
- TABS: 'tabs',
16
- };
17
-
18
- // Leaf field types the migrated channels use (registry keys + memoization decisions).
12
+ // Leaf field types the SMS schema uses (registry keys + memoization decisions).
19
13
  export const FIELD_TYPE = {
20
14
  INPUT: 'input',
21
15
  TEXTAREA: 'textarea',
@@ -24,12 +18,6 @@ export const FIELD_TYPE = {
24
18
  TAG_LIST: 'tag-list',
25
19
  SMS_PREVIEW: 'sms-preview',
26
20
  DIV: 'div',
27
- // MOBILEPUSH (Phase 2)
28
- RADIO_GROUP: 'radioGroup',
29
- SELECT: 'select',
30
- UPLOAD: 'upload',
31
- ICON: 'icon',
32
- MOBILE_PUSH_PREVIEW: 'mobile-push-preview',
33
21
  };
34
22
 
35
23
  // Editor-error descriptor kinds (getEditorErrorDescriptor): the brace error is shown
@@ -39,42 +27,16 @@ export const EDITOR_ERROR_KIND = {
39
27
  MISSING: 'missing',
40
28
  };
41
29
 
42
- // Types rendered through the memoized FieldSlot (depend only on own value/error).
43
- // radioGroup/select stay direct: they resolve value/options from the full legacy formData.
44
- export const MEMOIZED_TYPES = new Set([
45
- FIELD_TYPE.INPUT,
46
- FIELD_TYPE.TEXTAREA,
47
- FIELD_TYPE.CHECKBOX,
48
- ]);
49
-
50
- // Fallback active tab for single-tab channels (SMS). Tabbed channels keep the live
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;
30
+ // Field types whose render depends only on their own value/error (+ the
31
+ // checkValidation/channel primitives). These go through the memoized FieldSlot;
32
+ // derived/control fields (sms-preview, sms-count, tag-list, button) render directly.
33
+ export const MEMOIZED_TYPES = new Set([FIELD_TYPE.INPUT, FIELD_TYPE.TEXTAREA, FIELD_TYPE.CHECKBOX]);
54
34
 
35
+ // Phase 1 renders a single active tab (index 0); multi-tab / version machinery
36
+ // arrives with later channels.
55
37
  export const ACTIVE_TAB_INDEX = 0;
56
38
 
57
- // Injected events called with Classic's performFormDataUpdate signature
58
- // (true, updatedLegacyFormData, field); everything else takes (data, field.id).
59
- export const DATA_COMMIT_EVENTS = new Set([
60
- 'addPrimaryCta',
61
- 'addSecondaryCta',
62
- 'addSecondaryCtaIos',
63
- 'onChange',
64
- 'onSelect',
65
- ]);
66
-
67
- // Only these types reach Classic's commit path — a div's injected 'onChange'
68
- // must keep the tail signature, hence the type guard.
69
- export const DATA_COMMIT_FIELD_TYPES = new Set([
70
- FIELD_TYPE.CHECKBOX,
71
- FIELD_TYPE.RADIO_GROUP,
72
- FIELD_TYPE.SELECT,
73
- ]);
74
-
75
- export const TEMPLATE_NAME_FIELD = 'template-name';
76
-
77
- export const HIGH_FREQ_FIELDS = [TEMPLATE_NAME_FIELD, 'template-subject'];
39
+ export const HIGH_FREQ_FIELDS = ['template-name', 'template-subject'];
78
40
 
79
41
  export const ASCII = 'ASCII';
80
42
  export const UNICODE = 'Unicode';
@@ -1,22 +1,24 @@
1
1
  /**
2
- * initializeFormState — pure schema walk that seeds default field values into
3
- * the normalized state (Classic's initialize* family). `standalone` fields seed
4
- * into `root`; each tabs-container pane seeds its own tab (every pane gets a
5
- * tab, even `isSupported: false` — visibility is render-time, like Classic).
6
- * `onlyDisplay` fields are not seeded. Defaults: checkbox => false, else the
7
- * schema `value` or ''.
2
+ * initializeFormState — pure walk of a layout schema that seeds default field
3
+ * values into the normalized state (the V3 equivalent of the class component's
4
+ * initializeStandAloneSections / initializeMultiColSection family).
8
5
  *
9
- * reconcileFormState (below) is the schema-CHANGE counterpart for the runtime
10
- * schema mutations (CTA splices, text<->image swap).
6
+ * Scope (Phase 1): SMS a single-tab, standalone-only schema. Fields marked
7
+ * `standalone: true` seed into `root`; all other data fields seed into tab 0.
8
+ * `onlyDisplay` fields (buttons, preview, counter, tag-list) are NOT seeded —
9
+ * they are non-data UI controls. Defaults match the class component: checkbox
10
+ * => false; otherwise the schema's `value` if defined, else ''.
11
+ *
12
+ * NOTE: tab/version/language seeding is intentionally out of scope here — those
13
+ * arrive with MobilePush (tabs) and Email (versions/languages). Parity of the
14
+ * seeded shape against the monolith is asserted in PR3 against real fixtures.
11
15
  */
12
16
 
13
17
  import {
14
18
  createInitialState,
15
19
  emptyLiquidErrors,
16
20
  } from '../store/formReducer';
17
- import {
18
- SECTION_TYPE, CONTAINER_TYPE, FIELD_TYPE, TEMPLATE_NAME_FIELD,
19
- } from '../../constants';
21
+ import { SECTION_TYPE, FIELD_TYPE } from '../../constants';
20
22
 
21
23
  const defaultValueFor = (field) => {
22
24
  if (field.type === FIELD_TYPE.CHECKBOX) return false;
@@ -45,85 +47,14 @@ const visitSectionFields = (section, visit) => {
45
47
  }
46
48
  };
47
49
 
48
- /** The 'tabs' container of a schema, or null (single-tab channels). */
49
- const getTabsContainer = (schema) => (schema?.containers || []).find(
50
- (container) => container?.type === CONTAINER_TYPE.TABS
51
- && (container.isActive === undefined || container.isActive),
52
- ) || null;
53
-
54
- /** Visit every data field of a schema, reporting the tab it belongs to (null => root). */
55
- const visitSchemaFields = (schema, visit) => {
56
- const visitInto = (section, tabIndex) => visitSectionFields(section, (field) => {
57
- if (!isDataField(field)) return;
58
- visit(field, field.standalone ? null : tabIndex);
59
- });
60
-
61
- (schema?.standalone?.sections || []).forEach((section) => visitInto(section, 0));
62
-
63
- const tabsContainer = getTabsContainer(schema);
64
- (tabsContainer?.panes || []).forEach((pane, paneIndex) => {
65
- (pane?.sections || []).forEach((section) => visitInto(section, paneIndex));
66
- (pane?.sectionsHeaders || []).forEach((section) => visitInto(section, paneIndex));
67
- });
68
- };
69
-
70
50
  let autoId = 0;
71
51
  const defaultGenerateId = () => {
72
52
  autoId += 1;
73
53
  return `fb-${autoId}`;
74
54
  };
75
55
 
76
- // Classic's `this.formElements` KEEP set for the formData prune (Classic.js:1879-1898):
77
- // one flat set across standalone sections + every pane's `sections` (not headers).
78
- // Per-type quirks ported as-is: multicols collects inputFields cols only when NOT
79
- // onlyDisplay (skips tagList-); col-label collects inputFields even when onlyDisplay
80
- // (skips template-label-/tagList-); actionFields are never collected.
81
- const collectFormElements = (schema) => {
82
- const keep = new Set();
83
- const visitSection = (section) => {
84
- if (!section) return;
85
- switch (section.type) {
86
- case SECTION_TYPE.PARENT:
87
- (section.childSections || []).forEach(visitSection);
88
- break;
89
- case SECTION_TYPE.MULTICOLS:
90
- (section.inputFields || []).forEach((row) => (row?.cols || []).forEach((col) => {
91
- if (col?.id && !col.onlyDisplay && col.id.indexOf('tagList-') === -1) keep.add(col.id);
92
- }));
93
- break;
94
- case SECTION_TYPE.COL_LABEL:
95
- (section.inputFields || []).forEach((field) => {
96
- if (field?.id && field.id.indexOf('template-label-') === -1 && field.id.indexOf('tagList-') === -1) {
97
- keep.add(field.id);
98
- }
99
- });
100
- break;
101
- default:
102
- break;
103
- }
104
- };
105
- (schema?.standalone?.sections || []).forEach(visitSection);
106
- (getTabsContainer(schema)?.panes || []).forEach((pane) => {
107
- (pane?.sections || []).forEach(visitSection);
108
- });
109
- return keep;
110
- };
111
-
112
- // Tab keys Classic's prune never deletes (Classic.js:1893); tabKey/base live
113
- // outside `fields` in the normalized shape.
114
- const TAB_PRUNE_EXCEPTIONS = new Set(['name', 'imagePreview', 'image']);
115
-
116
- /** Copy of `source` keeping only formElements ids + the prune exceptions. */
117
- const pruneToFormElements = (source, formElements) => {
118
- const kept = {};
119
- Object.keys(source || {}).forEach((key) => {
120
- if (formElements.has(key) || TAB_PRUNE_EXCEPTIONS.has(key)) kept[key] = source[key];
121
- });
122
- return kept;
123
- };
124
-
125
56
  /**
126
- * @param {object} schema the layout `definition` (with `.channel`, `.standalone`, `.containers`, …)
57
+ * @param {object} schema the layout `definition` (with `.channel`, `.standalone`, …)
127
58
  * @param {object} [opts]
128
59
  * @param {() => string} [opts.generateId] tab-key generator (injectable for tests)
129
60
  * @returns normalized state with defaults seeded
@@ -132,101 +63,23 @@ export default function initializeFormState(schema = {}, opts = {}) {
132
63
  const generateId = opts.generateId || defaultGenerateId;
133
64
  const channel = schema.channel || null;
134
65
 
135
- const tabsContainer = getTabsContainer(schema);
136
- const tabCount = Math.max(tabsContainer?.panes?.length || 0, 1);
137
-
138
66
  const state = createInitialState(channel);
139
- // Tab 0 is always the base tab (formData[0].base === true in the legacy dialect).
140
- state.tabs = Array.from({ length: tabCount }, (unused, index) => ({
141
- fields: {},
142
- ...(index === 0 ? { base: true } : {}),
143
- tabKey: generateId(),
144
- }));
145
- state.errors = { root: {}, tabs: state.tabs.map(() => ({})) };
67
+ // SMS uses exactly one tab (index 0), marked base.
68
+ const tab0 = { fields: {}, base: true, tabKey: generateId() };
69
+ state.tabs = [tab0];
70
+ state.errors = { root: {}, tabs: [{}] };
146
71
  state.validity = { isFormValid: false, liquidErrorMessage: emptyLiquidErrors() };
147
72
 
148
- visitSchemaFields(schema, (field, tabIndex) => {
149
- const target = tabIndex == null ? state.root : state.tabs[tabIndex]?.fields;
150
- if (target && target[field.id] === undefined) target[field.id] = defaultValueFor(field);
151
- });
73
+ // Seed each leaf field's default in a single walk: standalone fields go to `root`,
74
+ // the rest to tab 0. onlyDisplay (non-data) fields are skipped.
75
+ const seedField = (field) => {
76
+ if (!isDataField(field)) return;
77
+ const target = field.standalone ? state.root : tab0.fields;
78
+ if (target[field.id] === undefined) target[field.id] = defaultValueFor(field);
79
+ };
80
+
81
+ const sections = schema.standalone?.sections;
82
+ if (sections) sections.forEach((section) => visitSectionFields(section, seedField));
152
83
 
153
84
  return state;
154
85
  }
155
-
156
- /**
157
- * reconcileFormState — apply a CHANGED schema to an existing state without losing
158
- * user input (Classic's `initialiseForm(schema, false, resetTabKeys=false)`).
159
- *
160
- * - Seeds newly-appeared fields; keeps every existing value, tabKey, active tab.
161
- * - PRUNES fields whose schema rows departed, exactly like Classic (1889-1897):
162
- * required for the CTA flows — a Deeplink <-> External Link radio switch
163
- * replaces the select row with a text row, and the orphaned seeded-'' key
164
- * would otherwise fail validation ("Validation failed for android").
165
- * - Root: non-object keys not in the walk are pruned; `template-name` restored
166
- * (Classic.js:1900-1904). Errors follow the field lifecycle (pruned fields
167
- * lose flags, new fields seed `false` — validation needs pre-existing maps).
168
- *
169
- * Pure: returns a new state; dispatch it via hydrate()/reconcileSchema().
170
- */
171
- export const reconcileFormState = (state, schema = {}, opts = {}) => {
172
- const generateId = opts.generateId || defaultGenerateId;
173
- if (!state) return initializeFormState(schema, opts);
174
-
175
- const tabsContainer = getTabsContainer(schema);
176
- const tabCount = Math.max(tabsContainer?.panes?.length || 0, state.tabs?.length || 0, 1);
177
-
178
- // Collect the schema's current data-field ids per slot.
179
- const rootIds = new Set();
180
- const tabIds = Array.from({ length: tabCount }, () => new Map());
181
- visitSchemaFields(schema, (field, tabIndex) => {
182
- if (tabIndex == null) rootIds.add(field.id);
183
- else if (tabIds[tabIndex]) tabIds[tabIndex].set(field.id, field);
184
- });
185
-
186
- // Root: keep schema-known + object-valued keys, seed new ones, restore template-name.
187
- const nextRoot = {};
188
- Object.keys(state.root || {}).forEach((key) => {
189
- const value = state.root[key];
190
- const isObjectValue = value !== null && typeof value === 'object';
191
- if (rootIds.has(key) || isObjectValue || key === TEMPLATE_NAME_FIELD) nextRoot[key] = value;
192
- });
193
- visitSchemaFields(schema, (field, tabIndex) => {
194
- if (tabIndex == null && nextRoot[field.id] === undefined) nextRoot[field.id] = defaultValueFor(field);
195
- });
196
-
197
- // Tabs: prune schema-departed fields, seed new ones, extend to new panes.
198
- // Seeds are ALSO filtered by the keep-set: Classic seeds then prunes, so an id
199
- // excluded from formElements (tagList-*, template-label-*) never survives a re-init.
200
- const formElements = collectFormElements(schema);
201
- const nextTabs = Array.from({ length: tabCount }, (unused, index) => {
202
- const existing = state.tabs?.[index];
203
- const fields = pruneToFormElements(existing?.fields, formElements);
204
- tabIds[index]?.forEach((field, fieldId) => {
205
- if (fields[fieldId] === undefined && formElements.has(fieldId)) fields[fieldId] = defaultValueFor(field);
206
- });
207
- return {
208
- fields,
209
- ...(existing?.base || index === 0 ? { base: true } : {}),
210
- tabKey: existing?.tabKey !== undefined ? existing.tabKey : generateId(),
211
- ...(existing?.name !== undefined ? { name: existing.name } : {}),
212
- };
213
- });
214
-
215
- // Errors mirror the field lifecycle: pruned fields lose their flags, new ones seed false.
216
- const prevTabErrors = state.errors?.tabs || [];
217
- const nextTabErrors = Array.from({ length: tabCount }, (unused, index) => {
218
- const seeded = pruneToFormElements(prevTabErrors[index], formElements);
219
- tabIds[index]?.forEach((unusedField, fieldId) => {
220
- if (seeded[fieldId] === undefined) seeded[fieldId] = false;
221
- });
222
- return seeded;
223
- });
224
-
225
- return {
226
- ...state,
227
- meta: { ...state.meta, channel: schema.channel || state.meta?.channel || null },
228
- root: nextRoot,
229
- tabs: nextTabs,
230
- errors: { root: { ...(state.errors?.root || {}) }, tabs: nextTabErrors },
231
- };
232
- };
@@ -6,27 +6,13 @@ export const ActionTypes = {
6
6
  HYDRATE: 'fb/HYDRATE',
7
7
  FIELD_CHANGED: 'fb/FIELD_CHANGED',
8
8
  VALIDATION_RESULT: 'fb/VALIDATION_RESULT',
9
- SET_ACTIVE_TAB: 'fb/SET_ACTIVE_TAB',
10
- RECONCILE_SCHEMA: 'fb/RECONCILE_SCHEMA',
11
9
  };
12
10
 
13
11
  export const emptyLiquidErrors = () => ({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
14
12
 
15
- // Fresh tab keys for hydrated states that arrive without them (Classic parity:
16
- // resetTabKeys assigns _.uniqueId() keys on every edit/parent push, Classic.js:453-461).
17
- let hydrateTabKeySeq = 0;
18
- const nextHydrateTabKey = () => {
19
- hydrateTabKeySeq += 1;
20
- return `fb-tab-${hydrateTabKeySeq}`;
21
- };
22
-
23
- const hasTabKey = (key) => key !== undefined && key !== '';
24
-
25
13
  /** Build a clean initial normalized state for a channel (no fields seeded). */
26
14
  export const createInitialState = (channel = null) => ({
27
- // activeTabIndex is 0-based (Android=0, iOS=1 for MOBILEPUSH). The legacy
28
- // `currentTab` the containers see is 1-based — converted at the shell boundary.
29
- meta: { channel, baseTabIndex: 0, activeTabIndex: 0 },
15
+ meta: { channel, baseTabIndex: 0 },
30
16
  root: {},
31
17
  tabs: [],
32
18
  errors: { root: {}, tabs: [] },
@@ -43,7 +29,7 @@ export const hydrate = (state) => ({ type: ActionTypes.HYDRATE, state });
43
29
  * otherwise it targets tabs[tabIndex].fields.
44
30
  */
45
31
  export const fieldChanged = ({ fieldId, value, tabIndex = null }) => ({
46
- type: ActionTypes.FIELD_CHANGED,
32
+ type: ActionTypes?.FIELD_CHANGED,
47
33
  fieldId,
48
34
  value,
49
35
  tabIndex,
@@ -57,75 +43,20 @@ export const validationResult = ({ errors, isFormValid, liquidErrorMessage }) =>
57
43
  liquidErrorMessage,
58
44
  });
59
45
 
60
- /** Switch the active tab (0-based). No-op (identity preserved) when unchanged or out of range. */
61
- export const setActiveTab = (tabIndex) => ({ type: ActionTypes.SET_ACTIVE_TAB, tabIndex });
62
-
63
- /**
64
- * Reconcile the current state onto a mutated schema (CTA splices, text<->image
65
- * swap). Runs INSIDE the reducer: a shell-side `stateRef` snapshot can be stale
66
- * when a container setState cascade commits before a prior dispatch renders
67
- * (it clobbered edit-template hydration). The reconciler travels in the action
68
- * because the schema module imports from this file (circular otherwise).
69
- */
70
- export const reconcileSchema = (schema, reconciler) => ({
71
- type: ActionTypes.RECONCILE_SCHEMA,
72
- schema,
73
- reconciler,
74
- });
75
-
76
46
  // ---- reducer -----------------------------------------------------------------
77
47
 
78
48
  export default function formReducer(state, action) {
79
49
  switch (action?.type) {
80
- case ActionTypes.HYDRATE: {
81
- const next = action?.state;
82
- if (!next) return state;
83
- // CapTab panes are keyed by tabKey — a key-less hydrate (Edit's setEditState
84
- // pushes tabs without keys) would leave no pane active. Preserve the existing
85
- // key at the same position, else assign a fresh one (Classic: resetTabKeys).
86
- let tabs = next?.tabs;
87
- if (Array.isArray(tabs) && tabs.some((tab) => tab && !hasTabKey(tab.tabKey))) {
88
- tabs = tabs.map((tab, index) => {
89
- if (!tab || hasTabKey(tab.tabKey)) return tab;
90
- const prevKey = state?.tabs?.[index]?.tabKey;
91
- return { ...tab, tabKey: hasTabKey(prevKey) ? prevKey : nextHydrateTabKey() };
92
- });
93
- }
94
- const hydrated = tabs === next?.tabs ? next : { ...next, tabs };
95
- // A hydrate replaces the data tree but must not lose UI position: an edit-load
96
- // or parent push while the user sits on the iOS tab keeps them there. States
97
- // built by fromLegacy/initializeFormState carry activeTabIndex 0 by default.
98
- const prevActive = state?.meta?.activeTabIndex ?? 0;
99
- const nextActive = hydrated?.meta?.activeTabIndex ?? 0;
100
- if (prevActive !== nextActive && hydrated?.tabs?.[prevActive] !== undefined) {
101
- return { ...hydrated, meta: { ...hydrated.meta, activeTabIndex: prevActive } };
102
- }
103
- return hydrated;
104
- }
105
-
106
- case ActionTypes.RECONCILE_SCHEMA: {
107
- if (!action?.schema || typeof action?.reconciler !== 'function') return state;
108
- return action.reconciler(state, action.schema);
109
- }
110
-
111
- case ActionTypes.SET_ACTIVE_TAB: {
112
- const { tabIndex } = action;
113
- if (
114
- typeof tabIndex !== 'number'
115
- || tabIndex === state?.meta?.activeTabIndex
116
- || tabIndex < 0
117
- || (state?.tabs?.length ? tabIndex >= state.tabs.length : tabIndex > 0)
118
- ) return state;
119
- return { ...state, meta: { ...state.meta, activeTabIndex: tabIndex } };
120
- }
50
+ case ActionTypes.HYDRATE:
51
+ return action?.state || state;
121
52
 
122
53
  case ActionTypes.FIELD_CHANGED: {
123
54
  const { fieldId, value, tabIndex } = action;
124
55
  if (tabIndex == null) {
125
- if (state?.root?.[fieldId] === value) return state; // no-op keeps identity
56
+ if (state?.root[fieldId] === value) return state; // no-op keeps identity
126
57
  return { ...state, root: { ...state.root, [fieldId]: value } };
127
58
  }
128
- const tab = state?.tabs?.[tabIndex];
59
+ const tab = state?.tabs[tabIndex];
129
60
  if (!tab) return state;
130
61
  if (tab?.fields?.[fieldId] === value) return state; // no-op keeps identity
131
62
  return {
@@ -1,12 +1,24 @@
1
1
  /**
2
- * toLegacyFormData — codec between the normalized V3 state and the legacy formData
3
- * dialect the containers consume; it keeps the migration invisible to them.
2
+ * toLegacyFormData — codec between the normalized V3 state and the legacy
3
+ * `formData` dialect that the channel containers + `getChannelData` consume.
4
4
  *
5
- * Dialect: root fields are top-level non-numeric keys; tabs[i] is numeric key i
6
- * (base/tabKey/name are metadata, the rest are fields); `base` is the SAME object
7
- * reference as the base tab, never a copy. Example:
8
- * { 'template-name': 'My SMS', 0: { 'sms-editor': 'hi', base: true, tabKey: '…' }, base: <ref to [0]> }
9
- * Invariant (unit-tested): toLegacy(fromLegacy(x)) deep-equals x.
5
+ * This is the compatibility contract that keeps the migration invisible to the
6
+ * containers and the backend: the functional engine works in the normalized
7
+ * shape, but emits `onChange(legacyFormData, ...)` and builds the save payload
8
+ * from the exact same shape the class component produced.
9
+ *
10
+ * LEGACY DIALECT (SMS example):
11
+ * {
12
+ * 'template-name': 'My SMS', // root (standalone) fields
13
+ * 0: { 'sms-editor': 'hi', 'unicode-validity': false, // tab fields
14
+ * base: true, tabKey: '...' }, // tab metadata
15
+ * base: <SAME REFERENCE as formData[0]>, // alias of the base tab
16
+ * }
17
+ *
18
+ * INVARIANT (unit-tested): toLegacy(fromLegacy(x)) deep-equals x.
19
+ * - root fields <-> top-level non-numeric keys (excluding `base`)
20
+ * - tabs[i] <-> numeric key i; base/tabKey/name are metadata, the rest are fields
21
+ * - `base` <-> the SAME object reference as the base tab (never a copy)
10
22
  */
11
23
 
12
24
  const isTabKey = (key) => /^\d+$/.test(key);
@@ -45,9 +57,7 @@ export const fromLegacy = (legacy = {}, { channel = null } = {}) => {
45
57
  const baseTabIndex = foundBase === -1 ? 0 : foundBase;
46
58
 
47
59
  return {
48
- // activeTabIndex is UI position, not data — parsed states start at 0; the
49
- // reducer's HYDRATE preserves the user's current tab when replacing state.
50
- meta: { channel, baseTabIndex, activeTabIndex: 0 },
60
+ meta: { channel, baseTabIndex },
51
61
  root,
52
62
  tabs,
53
63
  errors: { root: {}, tabs: tabs?.map(() => ({})) },
@@ -17,12 +17,8 @@ const FieldSlot = React.memo(({
17
17
  onFieldChange,
18
18
  onFieldBlur,
19
19
  onEvent,
20
- intl,
21
- restrictPersonalization,
22
20
  }) => {
23
- // intl is identity-stable across renders (locale changes remount the app), and
24
- // restrictPersonalization is a primitive — both memo-safe.
25
- const renderContext = { checkValidation, channel, intl, restrictPersonalization };
21
+ const renderContext = { checkValidation, channel };
26
22
  return (
27
23
  <Renderer
28
24
  field={field}
@@ -48,8 +44,6 @@ FieldSlot.propTypes = {
48
44
  onFieldChange: PropTypes.func.isRequired,
49
45
  onFieldBlur: PropTypes.func,
50
46
  onEvent: PropTypes.func,
51
- intl: PropTypes.object,
52
- restrictPersonalization: PropTypes.bool,
53
47
  };
54
48
 
55
49
  FieldSlot.defaultProps = {
@@ -60,8 +54,6 @@ FieldSlot.defaultProps = {
60
54
  channel: '',
61
55
  onFieldBlur: undefined,
62
56
  onEvent: undefined,
63
- intl: undefined,
64
- restrictPersonalization: false,
65
57
  };
66
58
 
67
59
  export default FieldSlot;
@@ -1,37 +1,20 @@
1
1
  /**
2
2
  * SchemaForm — top of the schema-driven render. Walks the standalone sections
3
- * and any active containers (MOBILEPUSH: one 'tabs' container with the
4
- * Android/iOS panes). Either part may be absent: hosts delete `standalone`
5
- * entirely in embedded/full mode (removeStandAlone), and single-tab channels
6
- * (SMS) have no `containers`.
3
+ * (SMS shape). Container/tabs handling is added when MobilePush/Email migrate.
7
4
  */
8
5
 
9
6
  import React from 'react';
10
7
  import PropTypes from 'prop-types';
11
8
  import Section from './Section';
12
- import TabsContainer from './TabsContainer';
13
- import { CONTAINER_TYPE } from '../constants';
14
9
 
15
10
  const SchemaForm = ({ schema, renderContext }) => {
16
- const containers = (schema?.containers || []).filter(
17
- (container) => container && (container.isActive === undefined || container.isActive),
18
- );
19
- if (!schema?.standalone && !containers.length) return null;
11
+ if (!schema?.standalone) return null;
20
12
  return (
21
13
  <>
22
- {schema?.standalone?.sections?.map((section, index) => (
14
+ {schema.standalone?.sections?.map((section, index) => (
23
15
  // The schema is a frozen, never-reordered layout descriptor, so the index is a stable key here.
24
16
  <Section key={index} section={section} renderContext={renderContext} />
25
17
  ))}
26
- {containers.map((container, index) => (
27
- container.type === CONTAINER_TYPE.TABS ? (
28
- <TabsContainer
29
- key={container.id || `container-${index}`}
30
- container={container}
31
- renderContext={renderContext}
32
- />
33
- ) : null
34
- ))}
35
18
  </>
36
19
  );
37
20
  };
@@ -24,10 +24,7 @@ export const renderField = (field, renderContext) => {
24
24
  const Renderer = renderContext?.registry?.resolve(field.type);
25
25
  if (!Renderer) return null; // unknown/unsupported type -> nothing (dead branches don't exist here)
26
26
 
27
- // Fields inside a tabs-container pane resolve against THEIR pane's tab
28
- // (paneTabIndex, set by TabsContainer); standalone-section fields against the
29
- // active tab; `standalone: true` fields live at the root.
30
- const tabIndex = field.standalone ? null : (renderContext?.paneTabIndex ?? renderContext?.activeTabIndex);
27
+ const tabIndex = field.standalone ? null : renderContext?.activeTabIndex;
31
28
  const value = selectFieldValue(renderContext?.state, { fieldId: field?.id, tabIndex });
32
29
  const error = legacyError(renderContext?.errorData, field, tabIndex);
33
30
 
@@ -45,8 +42,6 @@ export const renderField = (field, renderContext) => {
45
42
  onFieldChange={renderContext?.onFieldChange}
46
43
  onFieldBlur={renderContext?.onFieldBlur}
47
44
  onEvent={renderContext?.onEvent}
48
- intl={renderContext?.intl}
49
- restrictPersonalization={renderContext?.restrictPersonalization}
50
45
  />
51
46
  );
52
47
  }
@@ -54,7 +54,7 @@ describe('FieldSlot memoization', () => {
54
54
  expect(Renderer).toHaveBeenCalledTimes(2);
55
55
  });
56
56
 
57
- it('passes a minimal renderContext (checkValidation/channel + memo-safe intl/restrictPersonalization) and wires onChange to its field', () => {
57
+ it('passes a minimal renderContext ({ checkValidation, channel }) and wires onChange to its field', () => {
58
58
  const onFieldChange = jest.fn();
59
59
  let captured;
60
60
  const Renderer = jest.fn((p) => { captured = p; return <div>f</div>; });
@@ -71,14 +71,7 @@ describe('FieldSlot memoization', () => {
71
71
  onEvent={noop}
72
72
  />,
73
73
  );
74
- // intl + restrictPersonalization joined the mini-context in Phase 2 (MPUSH):
75
- // both are memo-safe (stable identity / primitive) and default-empty here.
76
- expect(captured.renderContext).toEqual({
77
- checkValidation: true,
78
- channel: 'SMS',
79
- intl: undefined,
80
- restrictPersonalization: false,
81
- });
74
+ expect(captured.renderContext).toEqual({ checkValidation: true, channel: 'SMS' });
82
75
  captured.onChange('hello');
83
76
  expect(onFieldChange).toHaveBeenCalledWith(field, 'hello');
84
77
  });
@@ -37,21 +37,4 @@ describe('SchemaForm', () => {
37
37
  const { getAllByTestId } = render(<SchemaForm schema={schema} renderContext={renderContext} />);
38
38
  expect(getAllByTestId('stub').length).toBe(2);
39
39
  });
40
-
41
- it('skips inactive and non-tabs containers; renders an active tabs container (id-less key fallback)', () => {
42
- const schema = {
43
- containers: [
44
- { id: 'off', type: 'tabs', isActive: false, panes: [{ sections: [] }] },
45
- { id: 'other', type: 'accordion' },
46
- { type: 'tabs', panes: [{ sections: [] }] }, // no id -> index key fallback
47
- ],
48
- };
49
- const { container } = render(
50
- <SchemaForm
51
- schema={schema}
52
- renderContext={{ ...renderContext, onTabSwitch: () => {}, intl: { locale: 'en' } }}
53
- />,
54
- );
55
- expect(container.querySelectorAll('.ant-tabs').length).toBe(1);
56
- });
57
40
  });
@@ -67,17 +67,6 @@
67
67
  margin: auto;
68
68
  }
69
69
  }
70
- // Functional MPUSH upload field (values identical to Classic's inline styles).
71
- .fb-upload-file-input {
72
- display: none;
73
- }
74
- .fb-tabs-full-width {
75
- width: 100%;
76
- }
77
- .fb-upload-placeholder {
78
- width: 100%;
79
- text-align: center;
80
- }
81
70
  .disabled {
82
71
  pointer-events: none;
83
72
  border-bottom: 1px solid #C2C2C2 !important;