@capillarytech/creatives-library 9.0.59 → 9.0.60
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 +2 -3
- package/package.json +1 -1
- package/utils/common.js +0 -8
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +69 -324
- package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
- package/v2Components/FormBuilder/Functional/constants.js +8 -46
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
- package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
- package/v2Components/FormBuilder/_formBuilder.scss +0 -11
- package/v2Components/FormBuilder/index.js +13 -20
- package/v2Components/FormBuilder/tests/entryGate.test.js +7 -87
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +12 -25
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +9 -28
- package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
- package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -75
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -21
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -85
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -448
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +0 -135
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -320
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -148
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +0 -147
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -200
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -376
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
- 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
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
* the normalized state (
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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`,
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* dialect the containers
|
|
2
|
+
* toLegacyFormData — codec between the normalized V3 state and the legacy
|
|
3
|
+
* `formData` dialect that the channel containers + `getChannelData` consume.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|