@capillarytech/creatives-library 9.0.61-alpha.3 → 9.0.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/constants/unified.js +8 -2
- package/package.json +1 -1
- package/services/api.js +5 -0
- package/services/tests/api.test.js +44 -0
- package/utils/common.js +8 -0
- package/utils/downloadFile.js +57 -0
- package/utils/tests/downloadFile.test.js +219 -0
- package/v2Components/FormBuilder/Classic.js +43 -9
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +324 -69
- package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +10 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +75 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +74 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +28 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +21 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +45 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +162 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +37 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +70 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +274 -0
- package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +251 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +2 -0
- package/v2Components/FormBuilder/Functional/constants.js +46 -8
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +175 -28
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +75 -6
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +10 -20
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +9 -1
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +20 -3
- package/v2Components/FormBuilder/Functional/layout/Section.js +6 -1
- package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +85 -0
- package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +451 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +9 -2
- package/v2Components/FormBuilder/Functional/tests/manual.typingPerf.test.js +135 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +430 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +320 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +148 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +306 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.libraryValidity.parity.test.js +152 -0
- package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +200 -0
- package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +382 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +17 -0
- package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +112 -0
- package/v2Components/FormBuilder/_formBuilder.scss +11 -0
- package/v2Components/FormBuilder/index.js +27 -17
- package/v2Components/FormBuilder/tests/entryGate.test.js +90 -7
- package/v2Components/FormBuilder/tests/mpush.characterization.test.js +459 -0
- package/v2Containers/BeeEditor/index.js +8 -0
- package/v2Containers/Email/index.js +118 -16
- package/v2Containers/Email/initialSchema.js +25 -3
- package/v2Containers/Email/messages.js +16 -0
- package/v2Containers/Email/tests/index.test.js +597 -0
- package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +55 -1
- package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +141 -0
- package/v2Containers/EmailWrapper/components/_emailHTMLEditor.scss +11 -0
- package/v2Containers/Templates/index.js +53 -0
- package/v2Containers/Templates/messages.js +8 -0
- package/v2Containers/Templates/tests/index.test.js +170 -0
- package/v2Containers/Viber/constants.js +0 -3
- package/v2Containers/Viber/sagas.js +3 -1
- package/v2Containers/Viber/tests/saga.test.js +21 -0
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
}
|
|
@@ -9,7 +9,13 @@ export const SECTION_TYPE = {
|
|
|
9
9
|
COL_LABEL: 'col-label',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
//
|
|
12
|
+
// Container types in the layout schema (schema.containers[]). MOBILEPUSH uses a
|
|
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).
|
|
13
19
|
export const FIELD_TYPE = {
|
|
14
20
|
INPUT: 'input',
|
|
15
21
|
TEXTAREA: 'textarea',
|
|
@@ -18,6 +24,12 @@ export const FIELD_TYPE = {
|
|
|
18
24
|
TAG_LIST: 'tag-list',
|
|
19
25
|
SMS_PREVIEW: 'sms-preview',
|
|
20
26
|
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',
|
|
21
33
|
};
|
|
22
34
|
|
|
23
35
|
// Editor-error descriptor kinds (getEditorErrorDescriptor): the brace error is shown
|
|
@@ -27,16 +39,42 @@ export const EDITOR_ERROR_KIND = {
|
|
|
27
39
|
MISSING: 'missing',
|
|
28
40
|
};
|
|
29
41
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
// Types rendered through the memoized FieldSlot (depend only on own value/error).
|
|
43
|
+
// radioGroup/select stay direct: they resolve value/options from the full legacy formData.
|
|
44
|
+
export const MEMOIZED_TYPES = new Set([
|
|
45
|
+
FIELD_TYPE.INPUT,
|
|
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;
|
|
34
54
|
|
|
35
|
-
// Phase 1 renders a single active tab (index 0); multi-tab / version machinery
|
|
36
|
-
// arrives with later channels.
|
|
37
55
|
export const ACTIVE_TAB_INDEX = 0;
|
|
38
56
|
|
|
39
|
-
|
|
57
|
+
// Injected events called with Classic's performFormDataUpdate signature
|
|
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'];
|
|
40
78
|
|
|
41
79
|
export const ASCII = 'ASCII';
|
|
42
80
|
export const UNICODE = 'Unicode';
|