@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.
- package/constants/unified.js +8 -2
- package/package.json +1 -1
- package/services/api.js +5 -0
- package/services/tests/api.test.js +44 -0
- package/utils/common.js +8 -0
- package/utils/downloadFile.js +57 -0
- package/utils/tests/downloadFile.test.js +219 -0
- package/v2Components/FormBuilder/Classic.js +43 -9
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +46 -8
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
- package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
- package/v2Components/FormBuilder/_formBuilder.scss +11 -0
- package/v2Components/FormBuilder/index.js +27 -17
- package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
- package/v2Containers/BeeEditor/index.js +8 -0
- package/v2Containers/Email/index.js +118 -16
- package/v2Containers/Email/initialSchema.js +25 -3
- package/v2Containers/Email/messages.js +16 -0
- package/v2Containers/Email/tests/index.test.js +597 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
- package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
- package/v2Containers/Templates/index.js +53 -0
- package/v2Containers/Templates/messages.js +8 -0
- package/v2Containers/Templates/tests/index.test.js +170 -0
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* initializeFormState — pure walk
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|