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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/constants/unified.js +8 -2
  2. package/package.json +1 -1
  3. package/services/api.js +5 -0
  4. package/services/tests/api.test.js +44 -0
  5. package/utils/common.js +8 -0
  6. package/utils/downloadFile.js +57 -0
  7. package/utils/tests/downloadFile.test.js +219 -0
  8. package/v2Components/FormBuilder/Classic.js +43 -9
  9. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
  10. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
  11. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
  12. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
  13. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
  14. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
  15. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
  16. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
  17. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
  18. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
  19. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
  20. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
  21. package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
  22. package/v2Components/FormBuilder/Functional/constants.js +46 -8
  23. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
  24. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
  25. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
  26. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
  27. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
  28. package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
  29. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
  30. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
  31. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
  32. package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
  33. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
  34. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
  35. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
  36. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
  37. package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
  38. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
  39. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
  40. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
  41. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
  42. package/v2Components/FormBuilder/_formBuilder.scss +11 -0
  43. package/v2Components/FormBuilder/index.js +27 -17
  44. package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
  45. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
  46. package/v2Containers/BeeEditor/index.js +8 -0
  47. package/v2Containers/Email/index.js +118 -16
  48. package/v2Containers/Email/initialSchema.js +25 -3
  49. package/v2Containers/Email/messages.js +16 -0
  50. package/v2Containers/Email/tests/index.test.js +597 -0
  51. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
  52. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
  53. package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
  54. package/v2Containers/Templates/index.js +53 -0
  55. package/v2Containers/Templates/messages.js +8 -0
  56. package/v2Containers/Templates/tests/index.test.js +170 -0
@@ -1,24 +1,22 @@
1
1
  /**
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).
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 ''.
5
8
  *
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.
9
+ * reconcileFormState (below) is the schema-CHANGE counterpart for the runtime
10
+ * schema mutations (CTA splices, text<->image swap).
15
11
  */
16
12
 
17
13
  import {
18
14
  createInitialState,
19
15
  emptyLiquidErrors,
20
16
  } from '../store/formReducer';
21
- import { SECTION_TYPE, FIELD_TYPE } from '../../constants';
17
+ import {
18
+ SECTION_TYPE, CONTAINER_TYPE, FIELD_TYPE, TEMPLATE_NAME_FIELD,
19
+ } from '../../constants';
22
20
 
23
21
  const defaultValueFor = (field) => {
24
22
  if (field.type === FIELD_TYPE.CHECKBOX) return false;
@@ -47,14 +45,85 @@ const visitSectionFields = (section, visit) => {
47
45
  }
48
46
  };
49
47
 
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
+
50
70
  let autoId = 0;
51
71
  const defaultGenerateId = () => {
52
72
  autoId += 1;
53
73
  return `fb-${autoId}`;
54
74
  };
55
75
 
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
+
56
125
  /**
57
- * @param {object} schema the layout `definition` (with `.channel`, `.standalone`, …)
126
+ * @param {object} schema the layout `definition` (with `.channel`, `.standalone`, `.containers`, …)
58
127
  * @param {object} [opts]
59
128
  * @param {() => string} [opts.generateId] tab-key generator (injectable for tests)
60
129
  * @returns normalized state with defaults seeded
@@ -63,23 +132,101 @@ export default function initializeFormState(schema = {}, opts = {}) {
63
132
  const generateId = opts.generateId || defaultGenerateId;
64
133
  const channel = schema.channel || null;
65
134
 
135
+ const tabsContainer = getTabsContainer(schema);
136
+ const tabCount = Math.max(tabsContainer?.panes?.length || 0, 1);
137
+
66
138
  const state = createInitialState(channel);
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: [{}] };
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(() => ({})) };
71
146
  state.validity = { isFormValid: false, liquidErrorMessage: emptyLiquidErrors() };
72
147
 
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));
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
+ });
83
152
 
84
153
  return state;
85
154
  }
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,13 +6,27 @@ 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',
9
11
  };
10
12
 
11
13
  export const emptyLiquidErrors = () => ({ STANDARD_ERROR_MSG: [], LIQUID_ERROR_MSG: [] });
12
14
 
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
+
13
25
  /** Build a clean initial normalized state for a channel (no fields seeded). */
14
26
  export const createInitialState = (channel = null) => ({
15
- meta: { channel, baseTabIndex: 0 },
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 },
16
30
  root: {},
17
31
  tabs: [],
18
32
  errors: { root: {}, tabs: [] },
@@ -29,7 +43,7 @@ export const hydrate = (state) => ({ type: ActionTypes.HYDRATE, state });
29
43
  * otherwise it targets tabs[tabIndex].fields.
30
44
  */
31
45
  export const fieldChanged = ({ fieldId, value, tabIndex = null }) => ({
32
- type: ActionTypes?.FIELD_CHANGED,
46
+ type: ActionTypes.FIELD_CHANGED,
33
47
  fieldId,
34
48
  value,
35
49
  tabIndex,
@@ -43,20 +57,75 @@ export const validationResult = ({ errors, isFormValid, liquidErrorMessage }) =>
43
57
  liquidErrorMessage,
44
58
  });
45
59
 
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
+
46
76
  // ---- reducer -----------------------------------------------------------------
47
77
 
48
78
  export default function formReducer(state, action) {
49
79
  switch (action?.type) {
50
- case ActionTypes.HYDRATE:
51
- return action?.state || state;
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
+ }
52
121
 
53
122
  case ActionTypes.FIELD_CHANGED: {
54
123
  const { fieldId, value, tabIndex } = action;
55
124
  if (tabIndex == null) {
56
- if (state?.root[fieldId] === value) return state; // no-op keeps identity
125
+ if (state?.root?.[fieldId] === value) return state; // no-op keeps identity
57
126
  return { ...state, root: { ...state.root, [fieldId]: value } };
58
127
  }
59
- const tab = state?.tabs[tabIndex];
128
+ const tab = state?.tabs?.[tabIndex];
60
129
  if (!tab) return state;
61
130
  if (tab?.fields?.[fieldId] === value) return state; // no-op keeps identity
62
131
  return {
@@ -1,24 +1,12 @@
1
1
  /**
2
- * toLegacyFormData — codec between the normalized V3 state and the legacy
3
- * `formData` dialect that the channel containers + `getChannelData` consume.
2
+ * toLegacyFormData — codec between the normalized V3 state and the legacy formData
3
+ * dialect the containers consume; it keeps the migration invisible to them.
4
4
  *
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)
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.
22
10
  */
23
11
 
24
12
  const isTabKey = (key) => /^\d+$/.test(key);
@@ -57,7 +45,9 @@ export const fromLegacy = (legacy = {}, { channel = null } = {}) => {
57
45
  const baseTabIndex = foundBase === -1 ? 0 : foundBase;
58
46
 
59
47
  return {
60
- meta: { channel, baseTabIndex },
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 },
61
51
  root,
62
52
  tabs,
63
53
  errors: { root: {}, tabs: tabs?.map(() => ({})) },
@@ -17,8 +17,12 @@ const FieldSlot = React.memo(({
17
17
  onFieldChange,
18
18
  onFieldBlur,
19
19
  onEvent,
20
+ intl,
21
+ restrictPersonalization,
20
22
  }) => {
21
- const renderContext = { checkValidation, channel };
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 };
22
26
  return (
23
27
  <Renderer
24
28
  field={field}
@@ -44,6 +48,8 @@ FieldSlot.propTypes = {
44
48
  onFieldChange: PropTypes.func.isRequired,
45
49
  onFieldBlur: PropTypes.func,
46
50
  onEvent: PropTypes.func,
51
+ intl: PropTypes.object,
52
+ restrictPersonalization: PropTypes.bool,
47
53
  };
48
54
 
49
55
  FieldSlot.defaultProps = {
@@ -54,6 +60,8 @@ FieldSlot.defaultProps = {
54
60
  channel: '',
55
61
  onFieldBlur: undefined,
56
62
  onEvent: undefined,
63
+ intl: undefined,
64
+ restrictPersonalization: false,
57
65
  };
58
66
 
59
67
  export default FieldSlot;
@@ -1,20 +1,37 @@
1
1
  /**
2
2
  * SchemaForm — top of the schema-driven render. Walks the standalone sections
3
- * (SMS shape). Container/tabs handling is added when MobilePush/Email migrate.
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`.
4
7
  */
5
8
 
6
9
  import React from 'react';
7
10
  import PropTypes from 'prop-types';
8
11
  import Section from './Section';
12
+ import TabsContainer from './TabsContainer';
13
+ import { CONTAINER_TYPE } from '../constants';
9
14
 
10
15
  const SchemaForm = ({ schema, renderContext }) => {
11
- if (!schema?.standalone) return null;
16
+ const containers = (schema?.containers || []).filter(
17
+ (container) => container && (container.isActive === undefined || container.isActive),
18
+ );
19
+ if (!schema?.standalone && !containers.length) return null;
12
20
  return (
13
21
  <>
14
- {schema.standalone?.sections?.map((section, index) => (
22
+ {schema?.standalone?.sections?.map((section, index) => (
15
23
  // The schema is a frozen, never-reordered layout descriptor, so the index is a stable key here.
16
24
  <Section key={index} section={section} renderContext={renderContext} />
17
25
  ))}
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
+ ))}
18
35
  </>
19
36
  );
20
37
  };
@@ -24,7 +24,10 @@ 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
- const tabIndex = field.standalone ? null : renderContext?.activeTabIndex;
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);
28
31
  const value = selectFieldValue(renderContext?.state, { fieldId: field?.id, tabIndex });
29
32
  const error = legacyError(renderContext?.errorData, field, tabIndex);
30
33
 
@@ -42,6 +45,8 @@ export const renderField = (field, renderContext) => {
42
45
  onFieldChange={renderContext?.onFieldChange}
43
46
  onFieldBlur={renderContext?.onFieldBlur}
44
47
  onEvent={renderContext?.onEvent}
48
+ intl={renderContext?.intl}
49
+ restrictPersonalization={renderContext?.restrictPersonalization}
45
50
  />
46
51
  );
47
52
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * TabsContainer — channel-agnostic renderer for a schema 'tabs' container
3
+ * (MOBILEPUSH: the Android/iOS platform panes). Replaces the class component's
4
+ * renderContainer 'tabs' case (Classic.js:4246-4328) with state-driven tab
5
+ * activation — no DOM scans, no setTimeout — while keeping the exact container
6
+ * contract: pane keys come from the bound tab's tabKey, unsupported panes are
7
+ * skipped WITHOUT reserving an index (pane→tab binding shifts, matching the
8
+ * class's paneIndex behavior), and the container's injected onTabChange still
9
+ * receives the 1-based active index (bridged by the shell via onTabSwitch).
10
+ */
11
+
12
+ import React from 'react';
13
+ import PropTypes from 'prop-types';
14
+ import CapTab from '@capillarytech/cap-ui-library/CapTab';
15
+ import Section from './Section';
16
+
17
+ const hasTabKey = (key) => key !== undefined && key !== '';
18
+
19
+ const TabsContainer = ({ container, renderContext }) => {
20
+ if (!container?.panes?.length) return null;
21
+
22
+ const tabs = renderContext?.state?.tabs || [];
23
+ const activeTabIndex = renderContext?.activeTabIndex ?? 0;
24
+
25
+ const renderedPanes = [];
26
+ let supportedOrdinal = 0; // class parity: only supported panes advance the index
27
+ container.panes.forEach((pane, paneIndex) => {
28
+ const isPaneSupported = typeof pane.isSupported === 'boolean' ? pane.isSupported : true;
29
+ if (!isPaneSupported) return;
30
+ const tabIndex = supportedOrdinal;
31
+ supportedOrdinal += 1;
32
+
33
+ // Fields inside this pane read/write their OWN tab; everything else in the
34
+ // context is shared. (paneTabIndex is consumed by Section.renderField.)
35
+ const paneContext = { ...renderContext, paneTabIndex: tabIndex };
36
+ const paneTabKey = tabs[tabIndex]?.tabKey;
37
+ const headerSections = pane.sectionsHeaders || container.tabContent?.sections || [];
38
+
39
+ renderedPanes.push({
40
+ // Class parity: panes render eagerly (forceRender). Hidden-pane reconciliation
41
+ // stays cheap through the FieldSlot memo boundaries. Lazy activation is a
42
+ // deliberate post-rollout follow-up (see the MPUSH design doc §9).
43
+ forceRender: true,
44
+ // The div wrapper + onInput stop are Classic verbatim (Classic.js:4278).
45
+ tab: (
46
+ <div className="form-tab-header" onInput={(e) => e.stopPropagation()}>
47
+ {headerSections.map((section, index) => (
48
+ <Section key={`hdr-${index}`} section={section} renderContext={paneContext} />
49
+ ))}
50
+ </div>
51
+ ),
52
+ key: hasTabKey(paneTabKey) ? `${paneTabKey}` : `${paneIndex}`,
53
+ content: (pane.sections || []).map((section, index) => (
54
+ <Section key={`sec-${index}`} section={section} renderContext={paneContext} />
55
+ )),
56
+ });
57
+ });
58
+
59
+ // Must resolve through the SAME fallback chain as the pane keys — a divergent
60
+ // fallback leaves no pane matching activeKey and CapTab renders nothing.
61
+ const activeKey = tabs[activeTabIndex]?.tabKey;
62
+ const resolvedActiveKey = hasTabKey(activeKey)
63
+ ? `${activeKey}`
64
+ : (renderedPanes[activeTabIndex]?.key ?? renderedPanes[0]?.key ?? '1');
65
+
66
+ return (
67
+ <CapTab
68
+ className={`cap-tabs-${container.id} fb-tabs-full-width`}
69
+ activeKey={resolvedActiveKey}
70
+ onChange={(key) => renderContext?.onTabSwitch?.(container, key)}
71
+ panes={renderedPanes}
72
+ />
73
+ );
74
+ };
75
+
76
+ TabsContainer.propTypes = {
77
+ container: PropTypes.object,
78
+ renderContext: PropTypes.object.isRequired,
79
+ };
80
+
81
+ TabsContainer.defaultProps = {
82
+ container: null,
83
+ };
84
+
85
+ export default TabsContainer;