@capillarytech/creatives-library 9.0.56-betaa.0 → 9.0.56
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 +9 -4
- package/package.json +1 -1
- package/services/api.js +25 -4
- package/utils/common.js +0 -12
- package/utils/templateVarUtils.js +16 -0
- package/utils/tests/templateVarUtils.test.js +44 -0
- package/v2Components/CapActionButton/index.js +73 -60
- package/v2Components/CapActionButton/index.scss +44 -28
- package/v2Components/CapActionButton/messages.js +7 -3
- package/v2Components/CapActionButton/tests/index.test.js +17 -1
- package/v2Components/CapWhatsappCTA/messages.js +4 -0
- package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
- package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
- package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +13 -0
- package/v2Components/CommonTestAndPreview/actions.js +13 -0
- package/v2Components/CommonTestAndPreview/constants.js +8 -0
- package/v2Components/CommonTestAndPreview/index.js +55 -3
- package/v2Components/CommonTestAndPreview/reducer.js +20 -0
- package/v2Components/CommonTestAndPreview/sagas.js +28 -0
- package/v2Components/CommonTestAndPreview/selectors.js +6 -0
- package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +106 -0
- package/v2Components/CommonTestAndPreview/tests/actions.test.js +15 -0
- package/v2Components/CommonTestAndPreview/tests/constants.test.js +9 -1
- package/v2Components/CommonTestAndPreview/tests/index.test.js +101 -0
- package/v2Components/CommonTestAndPreview/tests/reducer.test.js +41 -0
- package/v2Components/CommonTestAndPreview/tests/sagas.test.js +84 -0
- package/v2Components/CommonTestAndPreview/tests/selectors.test.js +12 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +46 -320
- package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
- package/v2Components/FormBuilder/Functional/constants.js +8 -43
- 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/index.js +12 -34
- package/v2Components/FormBuilder/tests/entryGate.test.js +7 -94
- package/v2Components/TestAndPreviewSlidebox/index.js +9 -0
- package/v2Containers/CommunicationFlow/CommunicationFlow.js +140 -75
- package/v2Containers/CommunicationFlow/CommunicationFlow.scss +18 -2
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +67 -28
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +503 -51
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +98 -4
- package/v2Containers/CommunicationFlow/Tests/constants.test.js +85 -0
- package/v2Containers/CommunicationFlow/constants.js +65 -4
- package/v2Containers/CommunicationFlow/index.js +16 -23
- package/v2Containers/CommunicationFlow/messages.js +11 -3
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +27 -11
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +4 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +176 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +22 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +14 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +51 -0
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +11 -4
- package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +19 -5
- package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
- package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
- package/v2Containers/CreativesContainer/index.js +29 -3
- package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
- package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +18 -5
- package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
- package/v2Containers/MobilePush/Create/index.js +18 -1
- package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
- package/v2Containers/MobilePush/Edit/index.js +18 -1
- package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
- package/v2Containers/MobilePush/commonMethods.js +49 -1
- package/v2Containers/MobilePushNew/index.js +29 -4
- package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
- package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
- package/v2Containers/MobilePushNew/utils.js +34 -1
- package/v2Containers/MobilepushWrapper/index.js +3 -1
- package/v2Containers/Rcs/index.js +34 -0
- package/v2Containers/Rcs/index.scss +15 -0
- package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +115 -19
- package/v2Containers/Rcs/tests/index.test.js +67 -0
- package/v2Containers/Sms/Create/index.js +3 -1
- package/v2Containers/Sms/Edit/index.js +25 -0
- package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
- package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +27 -3
- package/v2Containers/Viber/index.js +24 -1
- package/v2Containers/Viber/tests/index.test.js +103 -0
- package/v2Containers/WebPush/Create/index.js +19 -0
- package/v2Containers/WebPush/Create/preview/WebPushPreview.js +17 -12
- package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +5 -0
- package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
- package/v2Containers/Whatsapp/index.js +25 -4
- package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +306 -34
- package/v2Containers/Whatsapp/tests/index.test.js +115 -0
- package/v2Containers/Zalo/index.js +28 -1
- package/v2Containers/Zalo/tests/index.test.js +99 -0
- package/v2Containers/mockdata.js +25 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -82
- 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 -19
- 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 -89
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -440
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -282
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -141
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -151
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -342
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
|
@@ -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
|
});
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* monolith, frozen) and the new functional implementation (./Functional)
|
|
7
7
|
* based on the per-channel, per-org feature flag.
|
|
8
8
|
*
|
|
9
|
-
* Phase 1
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* flag — keeps using Classic with byte-identical behavior. The flags default
|
|
9
|
+
* Phase 1 migrates SMS only, behind `ENABLE_NEW_FORMBUILDER_SMS`. The new path
|
|
10
|
+
* is taken ONLY when (a) the org has that flag AND (b) this FormBuilder instance
|
|
11
|
+
* is rendering the SMS channel. Every other channel — and every org without the
|
|
12
|
+
* flag — keeps using Classic with byte-identical behavior. The flag defaults
|
|
14
13
|
* OFF, so until rollout this gate always renders Classic.
|
|
15
14
|
*
|
|
16
15
|
* This component is a pure pass-through: no defaultProps, no prop mapping, no
|
|
@@ -22,45 +21,24 @@
|
|
|
22
21
|
import React, { useState } from 'react';
|
|
23
22
|
import ClassicFormBuilder from './Classic';
|
|
24
23
|
import FunctionalFormBuilder from './Functional';
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
hasNewFormBuilderEnabledForMpush,
|
|
28
|
-
} from '../../utils/common';
|
|
29
|
-
import { SMS, MOBILE_PUSH } from '../../v2Containers/CreativesContainer/constants';
|
|
30
|
-
|
|
31
|
-
// Session-level QA override / kill switch (see "Feature Flag Rollout Strategy" in
|
|
32
|
-
// the design docs): a localStorage key named after the flag ('true'/'false')
|
|
33
|
-
// beats the org flag for THIS browser session only. Lets QA exercise the new
|
|
34
|
-
// path before org provisioning, and support disable it for a single affected
|
|
35
|
-
// user without a config change. Any storage failure falls back to the org flag.
|
|
36
|
-
const readChannelFlag = (orgFlagReader, flagName) => {
|
|
37
|
-
try {
|
|
38
|
-
const override = window.localStorage.getItem(flagName);
|
|
39
|
-
if (override === 'true') return true;
|
|
40
|
-
if (override === 'false') return false;
|
|
41
|
-
} catch (e) { /* storage unavailable -> org flag */ }
|
|
42
|
-
return Boolean(orgFlagReader());
|
|
43
|
-
};
|
|
24
|
+
import { hasNewFormBuilderEnabledForSms } from '../../utils/common';
|
|
25
|
+
import { SMS } from '../../v2Containers/CreativesContainer/constants';
|
|
44
26
|
|
|
45
27
|
const FormBuilder = (props) => {
|
|
46
28
|
// The try/catch guards the early bootstrap / test case where window.capAuth is not yet initialized — any failure routes safely to Classic.
|
|
47
|
-
const [
|
|
29
|
+
const [isSmsNewBuilderOrg] = useState(() => {
|
|
48
30
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
[MOBILE_PUSH]: readChannelFlag(hasNewFormBuilderEnabledForMpush, 'ENABLE_NEW_FORMBUILDER_MPUSH'),
|
|
52
|
-
};
|
|
53
|
-
console.log('### FormBuilder V3 per-channel flags for org:', flags);
|
|
54
|
-
return flags;
|
|
31
|
+
console.log('### ENABLE_NEW_FORMBUILDER_SMS enabled for org:', hasNewFormBuilderEnabledForSms());
|
|
32
|
+
return Boolean(hasNewFormBuilderEnabledForSms());
|
|
55
33
|
} catch (e) {
|
|
56
|
-
return
|
|
34
|
+
return false;
|
|
57
35
|
}
|
|
58
36
|
});
|
|
59
37
|
|
|
60
|
-
// Derived every render: the schema (and therefore the channel) arrives asynchronously.
|
|
61
38
|
const channel = (props?.channel || props.schema?.channel)?.toUpperCase();
|
|
62
39
|
|
|
63
|
-
|
|
40
|
+
const useNewFormBuilder = isSmsNewBuilderOrg && channel === SMS;
|
|
41
|
+
return useNewFormBuilder ? (
|
|
64
42
|
<FunctionalFormBuilder {...props} />
|
|
65
43
|
) : (
|
|
66
44
|
<ClassicFormBuilder {...props} />
|