@prosdevlab/experience-sdk-plugins 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateField, validateForm } from './form-validation';
3
+ import type { FormConfig, FormField } from './types';
4
+
5
+ describe('validateField', () => {
6
+ describe('required validation', () => {
7
+ it('should pass when required field has value', () => {
8
+ const field: FormField = {
9
+ name: 'email',
10
+ type: 'email',
11
+ required: true,
12
+ };
13
+
14
+ const result = validateField(field, 'user@example.com');
15
+
16
+ expect(result.valid).toBe(true);
17
+ expect(result.errors).toBeUndefined();
18
+ });
19
+
20
+ it('should fail when required field is empty', () => {
21
+ const field: FormField = {
22
+ name: 'email',
23
+ type: 'email',
24
+ required: true,
25
+ };
26
+
27
+ const result = validateField(field, '');
28
+
29
+ expect(result.valid).toBe(false);
30
+ expect(result.errors).toEqual({ email: 'email is required' });
31
+ });
32
+
33
+ it('should fail when required field is whitespace', () => {
34
+ const field: FormField = {
35
+ name: 'name',
36
+ type: 'text',
37
+ label: 'Name',
38
+ required: true,
39
+ };
40
+
41
+ const result = validateField(field, ' ');
42
+
43
+ expect(result.valid).toBe(false);
44
+ expect(result.errors).toEqual({ name: 'Name is required' });
45
+ });
46
+
47
+ it('should use custom error message for required field', () => {
48
+ const field: FormField = {
49
+ name: 'email',
50
+ type: 'email',
51
+ required: true,
52
+ errorMessage: 'Email address is required',
53
+ };
54
+
55
+ const result = validateField(field, '');
56
+
57
+ expect(result.valid).toBe(false);
58
+ expect(result.errors).toEqual({ email: 'Email address is required' });
59
+ });
60
+
61
+ it('should pass when optional field is empty', () => {
62
+ const field: FormField = {
63
+ name: 'phone',
64
+ type: 'tel',
65
+ required: false,
66
+ };
67
+
68
+ const result = validateField(field, '');
69
+
70
+ expect(result.valid).toBe(true);
71
+ expect(result.errors).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('email validation', () => {
76
+ const field: FormField = {
77
+ name: 'email',
78
+ type: 'email',
79
+ required: false,
80
+ };
81
+
82
+ it('should pass with valid email', () => {
83
+ const result = validateField(field, 'user@example.com');
84
+ expect(result.valid).toBe(true);
85
+ });
86
+
87
+ it('should pass with subdomain email', () => {
88
+ const result = validateField(field, 'user@mail.example.com');
89
+ expect(result.valid).toBe(true);
90
+ });
91
+
92
+ it('should fail with invalid email (no @)', () => {
93
+ const result = validateField(field, 'userexample.com');
94
+ expect(result.valid).toBe(false);
95
+ expect(result.errors).toEqual({ email: 'Please enter a valid email address' });
96
+ });
97
+
98
+ it('should fail with invalid email (no domain)', () => {
99
+ const result = validateField(field, 'user@');
100
+ expect(result.valid).toBe(false);
101
+ });
102
+
103
+ it('should fail with invalid email (no TLD)', () => {
104
+ const result = validateField(field, 'user@example');
105
+ expect(result.valid).toBe(false);
106
+ });
107
+
108
+ it('should use custom error message for invalid email', () => {
109
+ const customField: FormField = {
110
+ ...field,
111
+ errorMessage: 'Invalid email format',
112
+ };
113
+
114
+ const result = validateField(customField, 'invalid');
115
+ expect(result.errors).toEqual({ email: 'Invalid email format' });
116
+ });
117
+ });
118
+
119
+ describe('url validation', () => {
120
+ const field: FormField = {
121
+ name: 'website',
122
+ type: 'url',
123
+ required: false,
124
+ };
125
+
126
+ it('should pass with valid HTTP URL', () => {
127
+ const result = validateField(field, 'http://example.com');
128
+ expect(result.valid).toBe(true);
129
+ });
130
+
131
+ it('should pass with valid HTTPS URL', () => {
132
+ const result = validateField(field, 'https://example.com/path?query=1');
133
+ expect(result.valid).toBe(true);
134
+ });
135
+
136
+ it('should fail with invalid URL (no protocol)', () => {
137
+ const result = validateField(field, 'example.com');
138
+ expect(result.valid).toBe(false);
139
+ expect(result.errors).toEqual({ website: 'Please enter a valid URL' });
140
+ });
141
+
142
+ it('should fail with invalid URL format', () => {
143
+ const result = validateField(field, 'not a url');
144
+ expect(result.valid).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe('tel validation', () => {
149
+ const field: FormField = {
150
+ name: 'phone',
151
+ type: 'tel',
152
+ required: false,
153
+ };
154
+
155
+ it('should pass with digits only', () => {
156
+ const result = validateField(field, '5551234567');
157
+ expect(result.valid).toBe(true);
158
+ });
159
+
160
+ it('should pass with dashes', () => {
161
+ const result = validateField(field, '555-123-4567');
162
+ expect(result.valid).toBe(true);
163
+ });
164
+
165
+ it('should pass with parentheses and spaces', () => {
166
+ const result = validateField(field, '(555) 123-4567');
167
+ expect(result.valid).toBe(true);
168
+ });
169
+
170
+ it('should pass with plus and country code', () => {
171
+ const result = validateField(field, '+1 555-123-4567');
172
+ expect(result.valid).toBe(true);
173
+ });
174
+
175
+ it('should fail with letters', () => {
176
+ const result = validateField(field, '555-CALL-NOW');
177
+ expect(result.valid).toBe(false);
178
+ expect(result.errors).toEqual({ phone: 'Please enter a valid phone number' });
179
+ });
180
+ });
181
+
182
+ describe('number validation', () => {
183
+ const field: FormField = {
184
+ name: 'age',
185
+ type: 'number',
186
+ required: false,
187
+ };
188
+
189
+ it('should pass with integer', () => {
190
+ const result = validateField(field, '25');
191
+ expect(result.valid).toBe(true);
192
+ });
193
+
194
+ it('should pass with decimal', () => {
195
+ const result = validateField(field, '25.5');
196
+ expect(result.valid).toBe(true);
197
+ });
198
+
199
+ it('should pass with negative number', () => {
200
+ const result = validateField(field, '-10');
201
+ expect(result.valid).toBe(true);
202
+ });
203
+
204
+ it('should fail with non-numeric value', () => {
205
+ const result = validateField(field, 'twenty');
206
+ expect(result.valid).toBe(false);
207
+ expect(result.errors).toEqual({ age: 'Please enter a valid number' });
208
+ });
209
+ });
210
+
211
+ describe('pattern validation', () => {
212
+ it('should pass when value matches pattern', () => {
213
+ const field: FormField = {
214
+ name: 'zipcode',
215
+ type: 'text',
216
+ pattern: '^\\d{5}$',
217
+ required: false,
218
+ };
219
+
220
+ const result = validateField(field, '12345');
221
+ expect(result.valid).toBe(true);
222
+ });
223
+
224
+ it('should fail when value does not match pattern', () => {
225
+ const field: FormField = {
226
+ name: 'zipcode',
227
+ type: 'text',
228
+ label: 'ZIP Code',
229
+ pattern: '^\\d{5}$',
230
+ required: false,
231
+ };
232
+
233
+ const result = validateField(field, '1234');
234
+ expect(result.valid).toBe(false);
235
+ expect(result.errors).toEqual({ zipcode: 'Invalid format for ZIP Code' });
236
+ });
237
+
238
+ it('should use custom error message for pattern mismatch', () => {
239
+ const field: FormField = {
240
+ name: 'zipcode',
241
+ type: 'text',
242
+ pattern: '^\\d{5}$',
243
+ errorMessage: 'ZIP code must be 5 digits',
244
+ required: false,
245
+ };
246
+
247
+ const result = validateField(field, 'ABCDE');
248
+ expect(result.errors).toEqual({ zipcode: 'ZIP code must be 5 digits' });
249
+ });
250
+
251
+ it('should handle invalid regex pattern gracefully', () => {
252
+ const field: FormField = {
253
+ name: 'test',
254
+ type: 'text',
255
+ pattern: '[invalid(regex',
256
+ required: false,
257
+ };
258
+
259
+ // Should not throw, just pass validation
260
+ const result = validateField(field, 'anything');
261
+ expect(result.valid).toBe(true);
262
+ });
263
+ });
264
+ });
265
+
266
+ describe('validateForm', () => {
267
+ it('should pass when all fields are valid', () => {
268
+ const config: FormConfig = {
269
+ fields: [
270
+ { name: 'email', type: 'email', required: true },
271
+ { name: 'name', type: 'text', required: true },
272
+ ],
273
+ submitButton: { text: 'Submit', action: 'submit' },
274
+ };
275
+
276
+ const data = {
277
+ email: 'user@example.com',
278
+ name: 'John Doe',
279
+ };
280
+
281
+ const result = validateForm(config, data);
282
+ expect(result.valid).toBe(true);
283
+ expect(result.errors).toBeUndefined();
284
+ });
285
+
286
+ it('should fail when any field is invalid', () => {
287
+ const config: FormConfig = {
288
+ fields: [
289
+ { name: 'email', type: 'email', required: true },
290
+ { name: 'name', type: 'text', required: true },
291
+ ],
292
+ submitButton: { text: 'Submit', action: 'submit' },
293
+ };
294
+
295
+ const data = {
296
+ email: 'invalid-email',
297
+ name: 'John Doe',
298
+ };
299
+
300
+ const result = validateForm(config, data);
301
+ expect(result.valid).toBe(false);
302
+ expect(result.errors).toHaveProperty('email');
303
+ });
304
+
305
+ it('should collect errors from multiple fields', () => {
306
+ const config: FormConfig = {
307
+ fields: [
308
+ { name: 'email', type: 'email', required: true },
309
+ { name: 'name', type: 'text', required: true },
310
+ { name: 'phone', type: 'tel', required: true },
311
+ ],
312
+ submitButton: { text: 'Submit', action: 'submit' },
313
+ };
314
+
315
+ const data = {
316
+ email: '',
317
+ name: '',
318
+ phone: '',
319
+ };
320
+
321
+ const result = validateForm(config, data);
322
+ expect(result.valid).toBe(false);
323
+ expect(Object.keys(result.errors || {})).toHaveLength(3);
324
+ expect(result.errors).toHaveProperty('email');
325
+ expect(result.errors).toHaveProperty('name');
326
+ expect(result.errors).toHaveProperty('phone');
327
+ });
328
+
329
+ it('should run custom validation function', () => {
330
+ const config: FormConfig = {
331
+ fields: [{ name: 'email', type: 'email', required: true }],
332
+ submitButton: { text: 'Submit', action: 'submit' },
333
+ validate: (data) => {
334
+ if (data.email.endsWith('@competitor.com')) {
335
+ return {
336
+ valid: false,
337
+ errors: { email: 'Competitor emails not allowed' },
338
+ };
339
+ }
340
+ return { valid: true };
341
+ },
342
+ };
343
+
344
+ const data = { email: 'user@competitor.com' };
345
+
346
+ const result = validateForm(config, data);
347
+ expect(result.valid).toBe(false);
348
+ expect(result.errors).toEqual({ email: 'Competitor emails not allowed' });
349
+ });
350
+
351
+ it('should merge custom validation errors with field errors', () => {
352
+ const config: FormConfig = {
353
+ fields: [
354
+ { name: 'email', type: 'email', required: true },
355
+ { name: 'password', type: 'text', required: true },
356
+ ],
357
+ submitButton: { text: 'Submit', action: 'submit' },
358
+ validate: (data) => {
359
+ if (data.password.length < 8) {
360
+ return {
361
+ valid: false,
362
+ errors: { password: 'Password must be at least 8 characters' },
363
+ };
364
+ }
365
+ return { valid: true };
366
+ },
367
+ };
368
+
369
+ const data = {
370
+ email: 'invalid',
371
+ password: '123',
372
+ };
373
+
374
+ const result = validateForm(config, data);
375
+ expect(result.valid).toBe(false);
376
+ expect(result.errors).toHaveProperty('email'); // Field validation error
377
+ expect(result.errors).toHaveProperty('password'); // Custom validation error
378
+ });
379
+
380
+ it('should handle custom validation function errors gracefully', () => {
381
+ const config: FormConfig = {
382
+ fields: [{ name: 'email', type: 'email', required: true }],
383
+ submitButton: { text: 'Submit', action: 'submit' },
384
+ validate: () => {
385
+ throw new Error('Custom validation broke!');
386
+ },
387
+ };
388
+
389
+ const data = { email: 'user@example.com' };
390
+
391
+ // Should not throw, should pass validation
392
+ const result = validateForm(config, data);
393
+ expect(result.valid).toBe(true);
394
+ });
395
+
396
+ it('should pass when optional fields are empty', () => {
397
+ const config: FormConfig = {
398
+ fields: [
399
+ { name: 'email', type: 'email', required: true },
400
+ { name: 'phone', type: 'tel', required: false },
401
+ ],
402
+ submitButton: { text: 'Submit', action: 'submit' },
403
+ };
404
+
405
+ const data = {
406
+ email: 'user@example.com',
407
+ phone: '',
408
+ };
409
+
410
+ const result = validateForm(config, data);
411
+ expect(result.valid).toBe(true);
412
+ });
413
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pure validation functions for form fields
3
+ *
4
+ * These functions are intentionally pure (no side effects) to make them:
5
+ * - Easy to test
6
+ * - Easy to extract into a separate form plugin later
7
+ * - Reusable across different contexts
8
+ */
9
+
10
+ import type { FormConfig, FormField, ValidationResult } from './types';
11
+
12
+ /**
13
+ * Validate a single form field
14
+ *
15
+ * @param field - Field configuration
16
+ * @param value - Current field value
17
+ * @returns Validation result with errors if invalid
18
+ */
19
+ export function validateField(field: FormField, value: string): ValidationResult {
20
+ const errors: Record<string, string> = {};
21
+
22
+ // Required field validation
23
+ if (field.required && (!value || value.trim() === '')) {
24
+ errors[field.name] = field.errorMessage || `${field.label || field.name} is required`;
25
+ return { valid: false, errors };
26
+ }
27
+
28
+ // Skip further validation if field is empty and not required
29
+ if (!value || value.trim() === '') {
30
+ return { valid: true };
31
+ }
32
+
33
+ // Type-specific validation
34
+ switch (field.type) {
35
+ case 'email': {
36
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
37
+ if (!emailRegex.test(value)) {
38
+ errors[field.name] = field.errorMessage || 'Please enter a valid email address';
39
+ }
40
+ break;
41
+ }
42
+
43
+ case 'url': {
44
+ try {
45
+ new URL(value);
46
+ } catch {
47
+ errors[field.name] = field.errorMessage || 'Please enter a valid URL';
48
+ }
49
+ break;
50
+ }
51
+
52
+ case 'tel': {
53
+ // Basic phone validation (allows digits, spaces, dashes, parentheses, plus)
54
+ const phoneRegex = /^[\d\s\-()+]+$/;
55
+ if (!phoneRegex.test(value)) {
56
+ errors[field.name] = field.errorMessage || 'Please enter a valid phone number';
57
+ }
58
+ break;
59
+ }
60
+
61
+ case 'number': {
62
+ if (Number.isNaN(Number(value))) {
63
+ errors[field.name] = field.errorMessage || 'Please enter a valid number';
64
+ }
65
+ break;
66
+ }
67
+ }
68
+
69
+ // Custom pattern validation (regex)
70
+ if (field.pattern && value) {
71
+ try {
72
+ const regex = new RegExp(field.pattern);
73
+ if (!regex.test(value)) {
74
+ errors[field.name] =
75
+ field.errorMessage || `Invalid format for ${field.label || field.name}`;
76
+ }
77
+ } catch (_error) {
78
+ // Invalid regex pattern - log warning but don't break validation
79
+ console.warn(`Invalid regex pattern for field ${field.name}:`, field.pattern);
80
+ }
81
+ }
82
+
83
+ return {
84
+ valid: Object.keys(errors).length === 0,
85
+ errors: Object.keys(errors).length > 0 ? errors : undefined,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Validate entire form
91
+ *
92
+ * @param config - Form configuration
93
+ * @param data - Current form data
94
+ * @returns Validation result with all field errors if invalid
95
+ */
96
+ export function validateForm(config: FormConfig, data: Record<string, string>): ValidationResult {
97
+ const errors: Record<string, string> = {};
98
+
99
+ // Validate each field
100
+ config.fields.forEach((field) => {
101
+ const value = data[field.name] || '';
102
+ const result = validateField(field, value);
103
+
104
+ if (!result.valid && result.errors) {
105
+ Object.assign(errors, result.errors);
106
+ }
107
+ });
108
+
109
+ // Custom validation function
110
+ if (config.validate) {
111
+ try {
112
+ const customResult = config.validate(data);
113
+ if (!customResult.valid && customResult.errors) {
114
+ Object.assign(errors, customResult.errors);
115
+ }
116
+ } catch (error) {
117
+ console.error('Custom validation function threw an error:', error);
118
+ // Don't prevent submission if custom validation has a bug
119
+ }
120
+ }
121
+
122
+ return {
123
+ valid: Object.keys(errors).length === 0,
124
+ errors: Object.keys(errors).length > 0 ? errors : undefined,
125
+ };
126
+ }
@@ -0,0 +1,3 @@
1
+ export { modalPlugin } from './modal';
2
+ export * from './types';
3
+ export * from './types';