@capillarytech/creatives-library 9.0.59-alpha.2 → 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/services/api.js +1 -3
- 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 +13 -32
- package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +10 -36
- package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +83 -0
- package/v2Containers/CommunicationFlow/hooks/Tests/useFetchCommDefinition.test.js +118 -0
- package/v2Containers/CommunicationFlow/hooks/useFetchCommDefinition.js +49 -0
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +1 -5
- package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +28 -0
- package/v2Containers/CommunicationFlow/utils/Tests/mapCommDefinitionToStepData.test.js +29 -0
- package/v2Containers/CommunicationFlow/utils/mapCommDefinitionToStepData.js +6 -13
- 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
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MOBILEPUSH validate — unit tests for the line-faithful port of Classic's
|
|
3
|
-
* validateForm MPUSH block (Classic.js:935-1218), including the quirks the
|
|
4
|
-
* design doc pins (FormBuilder-V3-MPUSH-Technical-Design.md §6.1/§6.2).
|
|
5
|
-
*/
|
|
6
|
-
import validateMobilePush, { isValidExternalLink } from '../validate';
|
|
7
|
-
import { ERROR_VALUE } from '../config';
|
|
8
|
-
|
|
9
|
-
const baseOptions = { tags: [], isFullMode: true, isEmbedded: false };
|
|
10
|
-
|
|
11
|
-
const bothPlatforms = (overrides = {}) => ({
|
|
12
|
-
'template-name': 'my push',
|
|
13
|
-
"0": {
|
|
14
|
-
'message-title': 'hello', 'message-editor': 'world', "base": true, "tabKey": 'k0',
|
|
15
|
-
},
|
|
16
|
-
"1": { 'message-title2': 'bonjour', 'message-editor2': 'monde', "tabKey": 'k1' },
|
|
17
|
-
...overrides,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe('channels/mobilepush/validate', () => {
|
|
21
|
-
it('valid when both platforms carry title + message and template-name is set', () => {
|
|
22
|
-
const result = validateMobilePush(bothPlatforms(), baseOptions);
|
|
23
|
-
expect(result.isValid).toBe(true);
|
|
24
|
-
expect(result.errorData[0]['message-editor']).toBe(false);
|
|
25
|
-
expect(result.errorData[1]['message-editor2']).toBe(false);
|
|
26
|
-
expect(result.errorData['template-name']).toBe(false);
|
|
27
|
-
expect(result.platform).toEqual({
|
|
28
|
-
androidValid: true, iosValid: true, isOnlyAndroid: false, isOnlyIos: false,
|
|
29
|
-
});
|
|
30
|
-
expect(result.singlePlatformPrompt).toBeNull();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('QUIRK: absent tab 0 skips ALL validation (template-name included) and stays valid', () => {
|
|
34
|
-
const result = validateMobilePush({ 'template-name': '' }, baseOptions);
|
|
35
|
-
expect(result.isValid).toBe(true);
|
|
36
|
-
expect(result.errorData).toEqual({});
|
|
37
|
-
expect(result.singlePlatformPrompt).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('missing message => error true; missing title clears the tab flag, missing message does NOT (asymmetry)', () => {
|
|
41
|
-
// Android has a title but no message: isValid false, but androidValid STAYS true.
|
|
42
|
-
const onlyTitle = validateMobilePush(bothPlatforms({
|
|
43
|
-
0: { 'message-title': 'hello', "base": true, "tabKey": 'k0' },
|
|
44
|
-
}), baseOptions);
|
|
45
|
-
expect(onlyTitle.isValid).toBe(false);
|
|
46
|
-
expect(onlyTitle.errorData[0]['message-editor']).toBe(true);
|
|
47
|
-
expect(onlyTitle.platform.androidValid).toBe(true); // Classic parity: not cleared
|
|
48
|
-
|
|
49
|
-
// Android has a message but no title: androidValid IS cleared.
|
|
50
|
-
const onlyMessage = validateMobilePush(bothPlatforms({
|
|
51
|
-
0: { 'message-editor': 'world', "base": true, "tabKey": 'k0' },
|
|
52
|
-
}), baseOptions);
|
|
53
|
-
expect(onlyMessage.isValid).toBe(false);
|
|
54
|
-
expect(onlyMessage.errorData[0]['message-title']).toBe(true);
|
|
55
|
-
expect(onlyMessage.platform.androidValid).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('brace mismatch writes the STRING error value + bracket-error, and does NOT clear the tab flag', () => {
|
|
59
|
-
const result = validateMobilePush(bothPlatforms({
|
|
60
|
-
0: {
|
|
61
|
-
'message-title': 'hello', 'message-editor': 'abc {{full_name', "base": true, "tabKey": 'k0',
|
|
62
|
-
},
|
|
63
|
-
}), baseOptions);
|
|
64
|
-
expect(result.isValid).toBe(false);
|
|
65
|
-
expect(result.errorData[0]['message-editor']).toBe(ERROR_VALUE.BRACKET);
|
|
66
|
-
expect(result.errorData[0]['bracket-error']).toBe(ERROR_VALUE.BRACKET);
|
|
67
|
-
// The single-platform modal stays reachable on tag errors (Classic parity).
|
|
68
|
-
expect(result.platform.androidValid).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('template-name: required in full mode (and suppresses the platform flags); skipped when embedded', () => {
|
|
72
|
-
const full = validateMobilePush(bothPlatforms({ 'template-name': '' }), baseOptions);
|
|
73
|
-
expect(full.isValid).toBe(false);
|
|
74
|
-
expect(full.errorData['template-name']).toBe(true);
|
|
75
|
-
expect(full.platform.androidValid).toBe(false);
|
|
76
|
-
expect(full.platform.iosValid).toBe(false);
|
|
77
|
-
expect(full.singlePlatformPrompt).toBeNull(); // both flags false => no prompt
|
|
78
|
-
|
|
79
|
-
const embedded = validateMobilePush(
|
|
80
|
-
bothPlatforms({ 'template-name': '' }),
|
|
81
|
-
{ ...baseOptions, isEmbedded: true },
|
|
82
|
-
);
|
|
83
|
-
expect(embedded.errorData['template-name']).toBe(false);
|
|
84
|
-
expect(embedded.isValid).toBe(true);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('single-platform prompt: iOS empty + saving from the Android tab => "android"', () => {
|
|
88
|
-
const result = validateMobilePush(bothPlatforms({
|
|
89
|
-
1: { tabKey: 'k1' },
|
|
90
|
-
}), {
|
|
91
|
-
...baseOptions, isSave: true, currentTab: 1,
|
|
92
|
-
});
|
|
93
|
-
expect(result.singlePlatformPrompt).toBe('android');
|
|
94
|
-
expect(result.platform.isOnlyAndroid).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('single-platform prompt: Android empty + saving from the iOS tab => "ios"', () => {
|
|
98
|
-
const result = validateMobilePush(bothPlatforms({
|
|
99
|
-
0: { base: true, tabKey: 'k0' },
|
|
100
|
-
}), {
|
|
101
|
-
...baseOptions, isSave: true, currentTab: 2,
|
|
102
|
-
});
|
|
103
|
-
expect(result.singlePlatformPrompt).toBe('ios');
|
|
104
|
-
expect(result.platform.isOnlyIos).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('no prompt when the empty platform does not match the current tab (Classic co-condition)', () => {
|
|
108
|
-
// iOS empty but the user is ON the iOS tab (currentTab 2): no prompt.
|
|
109
|
-
const result = validateMobilePush(bothPlatforms({
|
|
110
|
-
1: { tabKey: 'k1' },
|
|
111
|
-
}), {
|
|
112
|
-
...baseOptions, isSave: true, currentTab: 2,
|
|
113
|
-
});
|
|
114
|
-
expect(result.singlePlatformPrompt).toBeNull();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('no prompt without isSave', () => {
|
|
118
|
-
const result = validateMobilePush(bothPlatforms({ 1: { tabKey: 'k1' } }), {
|
|
119
|
-
...baseOptions, isSave: false, currentTab: 1,
|
|
120
|
-
});
|
|
121
|
-
expect(result.singlePlatformPrompt).toBeNull();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('embedded mode skips validating the tab that is not open (empty other platform)', () => {
|
|
125
|
-
const result = validateMobilePush(bothPlatforms({
|
|
126
|
-
"1": { tabKey: 'k1' }, 'template-name': '',
|
|
127
|
-
}), {
|
|
128
|
-
...baseOptions, isEmbedded: true,
|
|
129
|
-
});
|
|
130
|
-
// The empty iOS tab (isOnlyAndroid) is skipped entirely: no message-editor2 error.
|
|
131
|
-
expect(result.errorData[1]).toBeUndefined();
|
|
132
|
-
expect(result.isValid).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('CTA rules: empty-string select fails, ABSENT key passes; labels required when present-empty', () => {
|
|
136
|
-
const failing = validateMobilePush(bothPlatforms({
|
|
137
|
-
0: {
|
|
138
|
-
'message-title': 'a',
|
|
139
|
-
'message-editor': 'b',
|
|
140
|
-
'cta-deeplink-select': '',
|
|
141
|
-
'secondary-cta-0-label': '',
|
|
142
|
-
"base": true,
|
|
143
|
-
"tabKey": 'k0',
|
|
144
|
-
},
|
|
145
|
-
}), baseOptions);
|
|
146
|
-
expect(failing.isValid).toBe(false);
|
|
147
|
-
expect(failing.errorData[0]['cta-deeplink-select']).toBe(true);
|
|
148
|
-
expect(failing.errorData[0]['secondary-cta-0-label']).toBe(true);
|
|
149
|
-
expect(failing.platform.androidValid).toBe(false);
|
|
150
|
-
|
|
151
|
-
const absent = validateMobilePush(bothPlatforms(), baseOptions);
|
|
152
|
-
expect(absent.errorData[0]['cta-deeplink-select']).toBe(false); // absent key => rule passes
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('external-link rules use isValidExternalLink semantics (scheme required)', () => {
|
|
156
|
-
const badUrl = validateMobilePush(bothPlatforms({
|
|
157
|
-
0: {
|
|
158
|
-
'message-title': 'a', 'message-editor': 'b', 'cta-deeplink-text': 'www.no-scheme.com', "base": true, "tabKey": 'k0',
|
|
159
|
-
},
|
|
160
|
-
}), baseOptions);
|
|
161
|
-
expect(badUrl.errorData[0]['cta-deeplink-text']).toBe(true);
|
|
162
|
-
expect(badUrl.isValid).toBe(false);
|
|
163
|
-
|
|
164
|
-
const goodUrl = validateMobilePush(bothPlatforms({
|
|
165
|
-
0: {
|
|
166
|
-
'message-title': 'a', 'message-editor': 'b', 'cta-deeplink-text': 'https://capillarytech.com/x', "base": true, "tabKey": 'k0',
|
|
167
|
-
},
|
|
168
|
-
}), baseOptions);
|
|
169
|
-
expect(goodUrl.errorData[0]['cta-deeplink-text']).toBe(false);
|
|
170
|
-
expect(goodUrl.isValid).toBe(true);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('image: required only when the key exists (imageSchema); custom-value-* keys required when present', () => {
|
|
174
|
-
const result = validateMobilePush(bothPlatforms({
|
|
175
|
-
0: {
|
|
176
|
-
'message-title': 'a',
|
|
177
|
-
'message-editor': 'b',
|
|
178
|
-
"image": '',
|
|
179
|
-
'custom-value-userId-cta-deeplink-select': '',
|
|
180
|
-
"base": true,
|
|
181
|
-
"tabKey": 'k0',
|
|
182
|
-
},
|
|
183
|
-
}), baseOptions);
|
|
184
|
-
expect(result.errorData[0].image).toBe(true);
|
|
185
|
-
expect(result.errorData[0]['custom-value-userId-cta-deeplink-select']).toBe(true);
|
|
186
|
-
expect(result.isValid).toBe(false);
|
|
187
|
-
|
|
188
|
-
const textSchema = validateMobilePush(bothPlatforms(), baseOptions);
|
|
189
|
-
expect(textSchema.errorData[0].image).toBe(false); // always written, false when absent
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('personalization tokens are rejected when restrictPersonalization is on', () => {
|
|
193
|
-
const result = validateMobilePush(bothPlatforms({
|
|
194
|
-
0: {
|
|
195
|
-
'message-title': 'hi [eventAttr]', 'message-editor': 'b', "base": true, "tabKey": 'k0',
|
|
196
|
-
},
|
|
197
|
-
}), { ...baseOptions, restrictPersonalization: true });
|
|
198
|
-
expect(result.errorData[0]['message-title']).toBe(true);
|
|
199
|
-
expect(result.platform.androidValid).toBe(false);
|
|
200
|
-
expect(result.isValid).toBe(false);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('personalization tokens in the EDITOR are also rejected (clears the tab flag)', () => {
|
|
204
|
-
const result = validateMobilePush(bothPlatforms({
|
|
205
|
-
0: {
|
|
206
|
-
'message-title': 'hi', 'message-editor': 'go [eventAttr]', "base": true, "tabKey": 'k0',
|
|
207
|
-
},
|
|
208
|
-
}), { ...baseOptions, restrictPersonalization: true });
|
|
209
|
-
expect(result.errorData[0]['message-editor']).toBe(true);
|
|
210
|
-
expect(result.platform.androidValid).toBe(false);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('whitespace-only editor/title trim to empty and fail (both clear the tab flag)', () => {
|
|
214
|
-
const result = validateMobilePush(bothPlatforms({
|
|
215
|
-
0: {
|
|
216
|
-
'message-title': ' ', 'message-editor': ' ', "base": true, "tabKey": 'k0',
|
|
217
|
-
},
|
|
218
|
-
}), baseOptions);
|
|
219
|
-
expect(result.errorData[0]['message-editor']).toBe(true);
|
|
220
|
-
expect(result.errorData[0]['message-title']).toBe(true);
|
|
221
|
-
expect(result.platform.androidValid).toBe(false);
|
|
222
|
-
expect(result.isValid).toBe(false);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('defaults: no arguments at all => valid (the tab-0 gate short-circuits everything)', () => {
|
|
226
|
-
const result = validateMobilePush();
|
|
227
|
-
expect(result.isValid).toBe(true);
|
|
228
|
-
expect(result.errorData).toEqual({});
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('seedTabErrors skips tabKey/base/selectedLanguages from the tab-0 key seed', () => {
|
|
232
|
-
const result = validateMobilePush(bothPlatforms({
|
|
233
|
-
0: {
|
|
234
|
-
'message-title': 'hello', 'message-editor': 'world', 'selectedLanguages': ['en'], "base": true, "tabKey": 'k0',
|
|
235
|
-
},
|
|
236
|
-
}), baseOptions);
|
|
237
|
-
expect(result.errorData[1].tabKey).toBeUndefined();
|
|
238
|
-
expect(result.errorData[1].selectedLanguages).toBeUndefined();
|
|
239
|
-
expect(result.errorData[1].base).toBeUndefined();
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('embedded with BOTH platforms empty skips nothing (isOnlyAndroid === isOnlyIos)', () => {
|
|
243
|
-
const result = validateMobilePush({
|
|
244
|
-
'template-name': 'p',
|
|
245
|
-
'0': { base: true, tabKey: 'k0' },
|
|
246
|
-
'1': { tabKey: 'k1' },
|
|
247
|
-
}, { ...baseOptions, isEmbedded: true });
|
|
248
|
-
// both tabs validated: both flagged for the missing editor/title
|
|
249
|
-
expect(result.errorData[0]['message-editor']).toBe(true);
|
|
250
|
-
expect(result.errorData[1]['message-editor2']).toBe(true);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('brace mismatch in the TITLE writes the string error value and keeps the tab flag (Classic asymmetry)', () => {
|
|
254
|
-
const result = validateMobilePush(bothPlatforms({
|
|
255
|
-
0: {
|
|
256
|
-
'message-title': 'hi {{name', 'message-editor': 'body', "base": true, "tabKey": 'k0',
|
|
257
|
-
},
|
|
258
|
-
}), baseOptions);
|
|
259
|
-
expect(result.errorData[0]['message-title']).toBe(ERROR_VALUE.BRACKET);
|
|
260
|
-
expect(result.errorData[0]['bracket-error']).toBe(ERROR_VALUE.BRACKET);
|
|
261
|
-
expect(result.platform.androidValid).toBe(true); // NOT cleared, like the editor
|
|
262
|
-
expect(result.isValid).toBe(false);
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
describe('isValidExternalLink', () => {
|
|
267
|
-
const tab = { present: 'https://a.co/x', empty: '', invalid: 'not a url' };
|
|
268
|
-
it('absent key => valid; present-but-empty or invalid => invalid; valid url => valid', () => {
|
|
269
|
-
expect(isValidExternalLink(tab, 'missing-key')).toBe(true);
|
|
270
|
-
expect(isValidExternalLink(tab, 'empty')).toBe(false);
|
|
271
|
-
expect(isValidExternalLink(tab, 'invalid')).toBe(false);
|
|
272
|
-
expect(isValidExternalLink(tab, 'present')).toBe(true);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MOBILEPUSH validation — line-faithful port of Classic's validateForm MPUSH block
|
|
3
|
-
* (Classic.js:935-1218) over the legacy formData; emitted errorData is byte-identical.
|
|
4
|
-
* Every oddity below is a deliberate Classic quirk pinned by the characterization
|
|
5
|
-
* suite — see FormBuilder-V3-MPUSH-Technical-Design.md §6.1 before "fixing" any.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { validateTagsCore } from '../../../../../utils/tagValidations';
|
|
9
|
-
import { hasPersonalizationTags } from '../../../../../utils/commonUtils';
|
|
10
|
-
import { isUrl } from '../../../../../v2Containers/Line/Container/Wrapper/utils';
|
|
11
|
-
import { DEFAULT as DEFAULT_MODULE, ANDROID, IOS } from '../../../../../constants/unified';
|
|
12
|
-
import { TEMPLATE_NAME_FIELD } from '../../constants';
|
|
13
|
-
import { ERROR_VALUE, TABS, CTA_RULES } from './config';
|
|
14
|
-
|
|
15
|
-
const TAB_KEY_REGEX = /^\d+$/;
|
|
16
|
-
const isTabKey = (key) => TAB_KEY_REGEX.test(`${key}`);
|
|
17
|
-
|
|
18
|
-
// Tab metadata keys skipped when seeding a tab's error map (Classic.js:1627-1656).
|
|
19
|
-
const ERROR_SEED_SKIP_KEYS = ['tabKey', 'base', 'selectedLanguages'];
|
|
20
|
-
|
|
21
|
-
/** Truthy-value projection of a tab, excluding the given metadata keys. */
|
|
22
|
-
const pickTruthy = (tab, excluded) => {
|
|
23
|
-
const out = {};
|
|
24
|
-
Object.keys(tab || {}).forEach((key) => {
|
|
25
|
-
if (tab[key] && !excluded.includes(key)) out[key] = tab[key];
|
|
26
|
-
});
|
|
27
|
-
return out;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const isEmptyObject = (obj) => !obj || Object.keys(obj).length === 0;
|
|
31
|
-
|
|
32
|
-
// Classic.js:752-758 — key absent => valid; present but empty/invalid URL => invalid.
|
|
33
|
-
export const isValidExternalLink = (tab, key) => {
|
|
34
|
-
if (key in (tab || {})) {
|
|
35
|
-
const url = tab[key];
|
|
36
|
-
return Boolean(url && url !== '' && isUrl(url));
|
|
37
|
-
}
|
|
38
|
-
return true;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// Classic's this.validateTags(content, tags, false /* isEmail */, isFullMode):
|
|
42
|
-
// MPUSH consumes only .valid and .isBraceError from the core result.
|
|
43
|
-
const runTagValidation = (content, { tags, currentModule, isFullMode }) => {
|
|
44
|
-
const result = validateTagsCore({
|
|
45
|
-
contentForBraceCheck: content,
|
|
46
|
-
contentForUnsubscribeScan: content,
|
|
47
|
-
tags,
|
|
48
|
-
currentModule,
|
|
49
|
-
isFullMode,
|
|
50
|
-
initialMissingTags: [], // not email — no unsubscribe enforcement
|
|
51
|
-
includeIsContentEmpty: true,
|
|
52
|
-
});
|
|
53
|
-
return result && typeof result === 'object'
|
|
54
|
-
? result
|
|
55
|
-
: { valid: false, isBraceError: false };
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// Classic seeds EVERY tab's error map from tab 0's keys (initializeEditErrorData).
|
|
59
|
-
const seedTabErrors = (tab0) => {
|
|
60
|
-
const seeded = {};
|
|
61
|
-
Object.keys(tab0 || {}).forEach((key) => {
|
|
62
|
-
if (!ERROR_SEED_SKIP_KEYS.includes(key)) seeded[key] = false;
|
|
63
|
-
});
|
|
64
|
-
return seeded;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @param {object} formData legacy formData ({ 'template-name', 0: {...}, 1: {...}, base })
|
|
69
|
-
* @param {object} options { tags, isFullMode, isEmbedded, currentModule,
|
|
70
|
-
* restrictPersonalization, isSave, currentTab (1-based) }
|
|
71
|
-
* @returns {{ isValid, errorData, platform: { androidValid, iosValid, isOnlyAndroid,
|
|
72
|
-
* isOnlyIos }, singlePlatformPrompt: 'android'|'ios'|null }}
|
|
73
|
-
*/
|
|
74
|
-
export default function validateMobilePush(formData = {}, options = {}) {
|
|
75
|
-
const {
|
|
76
|
-
tags = [],
|
|
77
|
-
isFullMode,
|
|
78
|
-
isEmbedded = false,
|
|
79
|
-
currentModule = DEFAULT_MODULE,
|
|
80
|
-
restrictPersonalization = false,
|
|
81
|
-
isSave = false,
|
|
82
|
-
currentTab = 1,
|
|
83
|
-
} = options;
|
|
84
|
-
|
|
85
|
-
let isValid = true;
|
|
86
|
-
let androidValid = true;
|
|
87
|
-
let iosValid = true;
|
|
88
|
-
let isOnlyAndroid = false;
|
|
89
|
-
let isOnlyIos = false;
|
|
90
|
-
const errorData = {};
|
|
91
|
-
let singlePlatformPrompt = null;
|
|
92
|
-
|
|
93
|
-
// Classic.js:941 — no Android tab => the WHOLE block (template-name included)
|
|
94
|
-
// is skipped and validity stays true. Quirk, preserved.
|
|
95
|
-
if (formData && formData[TABS.ANDROID]) {
|
|
96
|
-
if (isEmptyObject(pickTruthy(formData[TABS.ANDROID], ['tabKey', 'base']))) isOnlyIos = true;
|
|
97
|
-
if (isEmptyObject(pickTruthy(formData[TABS.IOS], ['tabKey', 'base']))) isOnlyAndroid = true;
|
|
98
|
-
|
|
99
|
-
Object.keys(formData).forEach((index) => {
|
|
100
|
-
const tab = formData[index];
|
|
101
|
-
let isCurrentTabValid = true;
|
|
102
|
-
// Embedded mode skips the tab that is not open (Classic.js:961-965).
|
|
103
|
-
const skipValidation = isEmbedded
|
|
104
|
-
&& ((isOnlyAndroid && parseInt(index, 10) === 1) || (isOnlyIos && parseInt(index, 10) === 0))
|
|
105
|
-
&& isOnlyAndroid !== isOnlyIos;
|
|
106
|
-
|
|
107
|
-
if (isTabKey(index) && tab && !skipValidation) {
|
|
108
|
-
const tabIdx = parseInt(index, 10);
|
|
109
|
-
const suffix = TABS.suffixFor(tabIdx);
|
|
110
|
-
errorData[tabIdx] = errorData[tabIdx] || seedTabErrors(formData[TABS.ANDROID]);
|
|
111
|
-
const tabErrors = errorData[tabIdx];
|
|
112
|
-
|
|
113
|
-
// --- message-editor{suffix} (Classic.js:971-998) ---
|
|
114
|
-
let message = tab[`message-editor${suffix}`];
|
|
115
|
-
if (message) {
|
|
116
|
-
message = message.trim();
|
|
117
|
-
if (message === '') {
|
|
118
|
-
tabErrors[`message-editor${suffix}`] = true;
|
|
119
|
-
isValid = false;
|
|
120
|
-
isCurrentTabValid = false;
|
|
121
|
-
} else if (restrictPersonalization && hasPersonalizationTags(message)) {
|
|
122
|
-
tabErrors[`message-editor${suffix}`] = true;
|
|
123
|
-
isValid = false;
|
|
124
|
-
isCurrentTabValid = false;
|
|
125
|
-
} else {
|
|
126
|
-
tabErrors[`message-editor${suffix}`] = false;
|
|
127
|
-
const tagValidation = runTagValidation(message, { tags, currentModule, isFullMode });
|
|
128
|
-
if (tagValidation.valid) {
|
|
129
|
-
tabErrors[`message-editor${suffix}`] = false;
|
|
130
|
-
} else {
|
|
131
|
-
const { isBraceError } = tagValidation;
|
|
132
|
-
tabErrors[`message-editor${suffix}`] = isBraceError ? ERROR_VALUE.BRACKET : true;
|
|
133
|
-
tabErrors['bracket-error'] = isBraceError && ERROR_VALUE.BRACKET;
|
|
134
|
-
isValid = false; // NOTE: isCurrentTabValid deliberately NOT cleared (Classic parity)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
tabErrors[`message-editor${suffix}`] = true;
|
|
139
|
-
isValid = false; // NOTE: isCurrentTabValid deliberately NOT cleared (Classic parity)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// --- message-title{suffix} (Classic.js:1002-1030) ---
|
|
143
|
-
let title = tab[`message-title${suffix}`];
|
|
144
|
-
if (title) {
|
|
145
|
-
title = title.trim();
|
|
146
|
-
if (title === '') {
|
|
147
|
-
tabErrors[`message-title${suffix}`] = true;
|
|
148
|
-
isValid = false;
|
|
149
|
-
isCurrentTabValid = false;
|
|
150
|
-
} else if (restrictPersonalization && hasPersonalizationTags(title)) {
|
|
151
|
-
tabErrors[`message-title${suffix}`] = true;
|
|
152
|
-
isValid = false;
|
|
153
|
-
isCurrentTabValid = false;
|
|
154
|
-
} else {
|
|
155
|
-
tabErrors[`message-title${suffix}`] = false;
|
|
156
|
-
const tagValidation = runTagValidation(title, { tags, currentModule, isFullMode });
|
|
157
|
-
if (tagValidation.valid) {
|
|
158
|
-
tabErrors[`message-title${suffix}`] = false;
|
|
159
|
-
} else {
|
|
160
|
-
const { isBraceError } = tagValidation;
|
|
161
|
-
tabErrors[`message-title${suffix}`] = isBraceError ? ERROR_VALUE.BRACKET : true;
|
|
162
|
-
tabErrors['bracket-error'] = isBraceError && ERROR_VALUE.BRACKET;
|
|
163
|
-
isValid = false; // isCurrentTabValid NOT cleared (Classic parity)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
} else {
|
|
167
|
-
// Unlike the editor, a missing title DOES clear the tab flag (Classic.js:1026-1029).
|
|
168
|
-
tabErrors[`message-title${suffix}`] = true;
|
|
169
|
-
isValid = false;
|
|
170
|
-
isCurrentTabValid = false;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// --- fixed CTA/deeplink rules (Classic.js:1037-1135), unsuffixed by design ---
|
|
174
|
-
CTA_RULES.forEach(({ fieldId, kind }) => {
|
|
175
|
-
const failed = kind === 'url'
|
|
176
|
-
? !isValidExternalLink(tab, fieldId)
|
|
177
|
-
: tab[fieldId] === '';
|
|
178
|
-
if (failed) {
|
|
179
|
-
tabErrors[fieldId] = true;
|
|
180
|
-
isValid = false;
|
|
181
|
-
isCurrentTabValid = false;
|
|
182
|
-
} else {
|
|
183
|
-
tabErrors[fieldId] = false;
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// --- image: required only when the key exists (imageSchema) (Classic.js:1137-1143) ---
|
|
188
|
-
if (typeof tab.image !== 'undefined' && tab.image === '') {
|
|
189
|
-
tabErrors.image = true;
|
|
190
|
-
isValid = false;
|
|
191
|
-
isCurrentTabValid = false;
|
|
192
|
-
} else {
|
|
193
|
-
tabErrors.image = false;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// --- dynamic deeplink custom-value keys (Classic.js:1144-1155) ---
|
|
197
|
-
Object.keys(tab).forEach((key) => {
|
|
198
|
-
if (key.indexOf('custom-value') > -1) {
|
|
199
|
-
if (tab[key] === '') {
|
|
200
|
-
tabErrors[key] = true;
|
|
201
|
-
isValid = false;
|
|
202
|
-
isCurrentTabValid = false;
|
|
203
|
-
} else {
|
|
204
|
-
tabErrors[key] = false;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// index is a string key ('0'/'1'/metadata) — Number() keeps Classic's `==` semantics.
|
|
211
|
-
if (Number(index) === TABS.ANDROID) androidValid = isCurrentTabValid;
|
|
212
|
-
else if (Number(index) === TABS.IOS) iosValid = isCurrentTabValid;
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// --- template-name: root, skipped in embedded mode; failure also suppresses
|
|
216
|
-
// the single-platform modal by clearing both platform flags (Classic.js:1163-1170) ---
|
|
217
|
-
if (!isEmbedded && (!formData[TEMPLATE_NAME_FIELD] || formData[TEMPLATE_NAME_FIELD] === '')) {
|
|
218
|
-
errorData[TEMPLATE_NAME_FIELD] = true;
|
|
219
|
-
isValid = false;
|
|
220
|
-
androidValid = false;
|
|
221
|
-
iosValid = false;
|
|
222
|
-
} else {
|
|
223
|
-
errorData[TEMPLATE_NAME_FIELD] = false;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// --- single-platform save prompt (Classic.js:1171-1204) ---
|
|
227
|
-
if (isSave && (androidValid || iosValid)) {
|
|
228
|
-
const androidData = pickTruthy(formData[TABS.ANDROID], ['tabKey', 'base']);
|
|
229
|
-
if (isEmptyObject(androidData) && Number(currentTab) === 2) {
|
|
230
|
-
singlePlatformPrompt = IOS;
|
|
231
|
-
} else {
|
|
232
|
-
// Classic's inner rebuild excludes only tabKey — NOT base (line 1191). Preserved.
|
|
233
|
-
const iosData = pickTruthy(formData[TABS.IOS], ['tabKey']);
|
|
234
|
-
if (isEmptyObject(iosData) && Number(currentTab) === 1) {
|
|
235
|
-
singlePlatformPrompt = ANDROID;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
// Classic.js:1209-1215 — the three roll-up branches are `isValid = isValid && true`
|
|
240
|
-
// no-ops; the accumulated isValid stands. isLiquidValid = isValid for MPUSH.
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
isValid,
|
|
245
|
-
errorData,
|
|
246
|
-
platform: {
|
|
247
|
-
androidValid, iosValid, isOnlyAndroid, isOnlyIos,
|
|
248
|
-
},
|
|
249
|
-
singlePlatformPrompt,
|
|
250
|
-
};
|
|
251
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
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;
|