/** * Validation Module * * Provides standalone validation system for fields, forms, and data. * Features: * - Schema-based validation * - Field-level validation * - Async validation * - Custom validation rules * - Integration with form-handling * - Integration with LiveComponents */ import { Logger } from '../../core/logger.js'; /** * Built-in validation rules */ const builtInRules = { required: (value, options = {}) => { if (value === null || value === undefined || value === '') { return options.message || 'This field is required'; } return true; }, email: (value, options = {}) => { if (!value) return true; // Optional if not required const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { return options.message || 'Invalid email address'; } return true; }, url: (value, options = {}) => { if (!value) return true; try { new URL(value); return true; } catch { return options.message || 'Invalid URL'; } }, min: (value, options = {}) => { if (!value && value !== 0) return true; const min = parseFloat(options.value); const numValue = typeof value === 'string' ? parseFloat(value) : value; if (isNaN(numValue) || numValue < min) { return options.message || `Value must be at least ${min}`; } return true; }, max: (value, options = {}) => { if (!value && value !== 0) return true; const max = parseFloat(options.value); const numValue = typeof value === 'string' ? parseFloat(value) : value; if (isNaN(numValue) || numValue > max) { return options.message || `Value must be at most ${max}`; } return true; }, minLength: (value, options = {}) => { if (!value) return true; const min = parseInt(options.value, 10); if (typeof value !== 'string' || value.length < min) { return options.message || `Must be at least ${min} characters`; } return true; }, maxLength: (value, options = {}) => { if (!value) return true; const max = parseInt(options.value, 10); if (typeof value !== 'string' || value.length > max) { return options.message || `Must be at most ${max} characters`; } return true; }, pattern: (value, options = {}) => { if (!value) return true; const regex = new RegExp(options.value); if (!regex.test(value)) { return options.message || 'Invalid format'; } return true; }, number: (value, options = {}) => { if (!value && value !== 0) return true; if (isNaN(parseFloat(value))) { return options.message || 'Must be a number'; } return true; }, integer: (value, options = {}) => { if (!value && value !== 0) return true; if (!Number.isInteger(parseFloat(value))) { return options.message || 'Must be an integer'; } return true; }, phone: (value, options = {}) => { if (!value) return true; const phoneRegex = /^[\d\s\-\+\(\)]+$/; if (!phoneRegex.test(value)) { return options.message || 'Invalid phone number'; } return true; }, postalCode: (value, options = {}) => { if (!value) return true; const country = options.country || 'DE'; const patterns = { DE: /^\d{5}$/, US: /^\d{5}(-\d{4})?$/, UK: /^[A-Z]{1,2}\d{1,2}[A-Z]?\s?\d[A-Z]{2}$/i, FR: /^\d{5}$/ }; const pattern = patterns[country] || patterns.DE; if (!pattern.test(value)) { return options.message || `Invalid postal code for ${country}`; } return true; }, custom: (value, options = {}) => { if (!options.validator || typeof options.validator !== 'function') { return 'Custom validator function required'; } return options.validator(value, options); } }; /** * Validator - Schema-based validation */ export class Validator { constructor(schema = {}) { this.schema = schema; this.customRules = new Map(); this.errors = {}; this.validatedFields = new Set(); } /** * Create a new Validator instance */ static create(schema = {}) { return new Validator(schema); } /** * Register a custom validation rule */ registerRule(name, rule) { if (typeof rule !== 'function') { throw new Error('Validation rule must be a function'); } this.customRules.set(name, rule); } /** * Validate a single field */ async validateField(fieldName, value, schema = null) { const fieldSchema = schema || this.schema[fieldName]; if (!fieldSchema) { return { valid: true, errors: [] }; } const errors = []; // Handle array of rules const rules = Array.isArray(fieldSchema) ? fieldSchema : [fieldSchema]; for (const ruleConfig of rules) { const result = await this.validateRule(value, ruleConfig); if (result !== true) { errors.push(result); } } // Store errors if (errors.length > 0) { this.errors[fieldName] = errors; } else { delete this.errors[fieldName]; } this.validatedFields.add(fieldName); return { valid: errors.length === 0, errors }; } /** * Validate a single rule */ async validateRule(value, ruleConfig) { if (typeof ruleConfig === 'function') { // Custom validator function const result = await ruleConfig(value); return result === true ? true : (result || 'Validation failed'); } if (typeof ruleConfig === 'string') { // Rule name only return this.executeRule(ruleConfig, value, {}); } if (typeof ruleConfig === 'object' && ruleConfig !== null) { // Rule with options const ruleName = ruleConfig.rule || ruleConfig.type || Object.keys(ruleConfig)[0]; const options = ruleConfig.options || ruleConfig[ruleName] || {}; // Check for async validation if (ruleConfig.async && typeof ruleConfig.validator === 'function') { const result = await ruleConfig.validator(value, options); return result === true ? true : (result || options.message || 'Validation failed'); } return this.executeRule(ruleName, value, options); } return true; } /** * Execute a validation rule */ executeRule(ruleName, value, options) { // Check custom rules first if (this.customRules.has(ruleName)) { const result = this.customRules.get(ruleName)(value, options); return result === true ? true : (result || options.message || 'Validation failed'); } // Check built-in rules if (builtInRules[ruleName]) { const result = builtInRules[ruleName](value, options); return result === true ? true : (result || options.message || 'Validation failed'); } Logger.warn(`[Validator] Unknown validation rule: ${ruleName}`); return true; // Unknown rules pass by default } /** * Validate entire schema */ async validate(data) { this.errors = {}; this.validatedFields.clear(); const results = {}; let isValid = true; for (const fieldName in this.schema) { const value = data[fieldName]; const result = await this.validateField(fieldName, value); results[fieldName] = result; if (!result.valid) { isValid = false; } } return { valid: isValid, errors: this.errors, results }; } /** * Validate specific fields */ async validateFields(data, fieldNames) { this.errors = {}; const results = {}; let isValid = true; for (const fieldName of fieldNames) { if (!(fieldName in this.schema)) { continue; } const value = data[fieldName]; const result = await this.validateField(fieldName, value); results[fieldName] = result; if (!result.valid) { isValid = false; } } return { valid: isValid, errors: this.errors, results }; } /** * Get errors for a specific field */ getFieldErrors(fieldName) { return this.errors[fieldName] || []; } /** * Get all errors */ getErrors() { return { ...this.errors }; } /** * Check if field is valid */ isFieldValid(fieldName) { return !this.errors[fieldName] || this.errors[fieldName].length === 0; } /** * Check if all fields are valid */ isValid() { return Object.keys(this.errors).length === 0; } /** * Clear errors */ clearErrors(fieldName = null) { if (fieldName) { delete this.errors[fieldName]; } else { this.errors = {}; } } /** * Reset validator */ reset() { this.errors = {}; this.validatedFields.clear(); } /** * Create validator from HTML form */ static fromForm(form) { const schema = {}; const fields = form.querySelectorAll('input, textarea, select'); fields.forEach(field => { if (!field.name) return; const rules = []; // Required if (field.hasAttribute('required')) { rules.push('required'); } // Type-based validation if (field.type === 'email') { rules.push('email'); } else if (field.type === 'url') { rules.push('url'); } else if (field.type === 'number') { rules.push('number'); } // Min/Max length if (field.hasAttribute('minlength')) { rules.push({ rule: 'minLength', options: { value: field.getAttribute('minlength') } }); } if (field.hasAttribute('maxlength')) { rules.push({ rule: 'maxLength', options: { value: field.getAttribute('maxlength') } }); } // Min/Max (for numbers) if (field.hasAttribute('min')) { rules.push({ rule: 'min', options: { value: field.getAttribute('min') } }); } if (field.hasAttribute('max')) { rules.push({ rule: 'max', options: { value: field.getAttribute('max') } }); } // Pattern if (field.hasAttribute('pattern')) { rules.push({ rule: 'pattern', options: { value: field.getAttribute('pattern'), message: field.getAttribute('data-error-pattern') || 'Invalid format' } }); } // Custom validation if (field.hasAttribute('data-validate')) { const validateAttr = field.getAttribute('data-validate'); try { const customRule = JSON.parse(validateAttr); rules.push(customRule); } catch { // Treat as rule name rules.push(validateAttr); } } if (rules.length > 0) { schema[field.name] = rules; } }); return new Validator(schema); } } /** * ValidationRule - Individual validation rule */ export class ValidationRule { constructor(name, validator, options = {}) { this.name = name; this.validator = validator; this.options = options; } async validate(value) { if (typeof this.validator === 'function') { const result = await this.validator(value, this.options); return result === true ? true : (result || this.options.message || 'Validation failed'); } return true; } }