/** * COMPOSABLE: useFormValidation * * Provides reusable form validation logic with real-time validation, * error tracking, and field-level validation rules. * * Usage: * ```js * import { useFormValidation } from '@/composables/useFormValidation' * * const validation = useFormValidation() * * // Validate a field * validation.validateField('email', form.email, { * required: true, * pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ * }) * * // Check if field has error * validation.hasError('email') // true/false * * // Get error message * validation.getError('email') // "E-Mail ist erforderlich" * ``` */ import { ref, computed } from 'vue' export function useFormValidation() { // State const errors = ref({}) const touched = ref({}) const validating = ref({}) /** * Validates a single field with given rules * @param {string} fieldName - Name of the field * @param {any} value - Current field value * @param {object} rules - Validation rules * @returns {boolean} - True if valid */ const validateField = (fieldName, value, rules = {}) => { validating.value[fieldName] = true const fieldErrors = [] // Required validation if (rules.required) { if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { fieldErrors.push('Dieses Feld ist erforderlich') } } // If field is empty and not required, skip other validations if (!value && !rules.required) { errors.value[fieldName] = [] validating.value[fieldName] = false return true } // Min length validation if (rules.minLength && value && value.length < rules.minLength) { fieldErrors.push(`Mindestens ${rules.minLength} Zeichen erforderlich`) } // Max length validation if (rules.maxLength && value && value.length > rules.maxLength) { fieldErrors.push(`Maximal ${rules.maxLength} Zeichen erlaubt`) } // Pattern validation if (rules.pattern && value && !rules.pattern.test(value)) { fieldErrors.push(rules.patternMessage || 'Ungültiges Format') } // Min value validation (for numbers) if (rules.min !== undefined && value < rules.min) { fieldErrors.push(`Wert muss mindestens ${rules.min} sein`) } // Max value validation (for numbers) if (rules.max !== undefined && value > rules.max) { fieldErrors.push(`Wert darf maximal ${rules.max} sein`) } // Email validation if (rules.email && value) { const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailPattern.test(value)) { fieldErrors.push('Ungültige E-Mail-Adresse') } } // URL validation if (rules.url && value) { try { new URL(value) } catch { fieldErrors.push('Ungültige URL') } } // Date validation if (rules.date && value) { const date = new Date(value) if (isNaN(date.getTime())) { fieldErrors.push('Ungültiges Datum') } } // Custom validation function if (rules.custom && typeof rules.custom === 'function') { const customError = rules.custom(value) if (customError) { fieldErrors.push(customError) } } // Async validation if (rules.asyncValidator && typeof rules.asyncValidator === 'function') { rules.asyncValidator(value).then(error => { if (error) { errors.value[fieldName] = [error] } validating.value[fieldName] = false }) } else { validating.value[fieldName] = false } // Update errors errors.value[fieldName] = fieldErrors return fieldErrors.length === 0 } /** * Validates multiple fields at once * @param {object} formData - Object with field names and values * @param {object} rulesMap - Object with field names and their rules * @returns {boolean} - True if all fields are valid */ const validateForm = (formData, rulesMap) => { let isValid = true Object.keys(rulesMap).forEach(fieldName => { const value = getNestedValue(formData, fieldName) const rules = rulesMap[fieldName] const fieldValid = validateField(fieldName, value, rules) if (!fieldValid) { isValid = false touchField(fieldName) } }) return isValid } /** * Marks a field as touched * @param {string} fieldName - Name of the field */ const touchField = (fieldName) => { touched.value[fieldName] = true } /** * Marks multiple fields as touched * @param {string[]} fieldNames - Array of field names */ const touchFields = (fieldNames) => { fieldNames.forEach(fieldName => { touched.value[fieldName] = true }) } /** * Marks all fields as touched */ const touchAll = () => { Object.keys(errors.value).forEach(fieldName => { touched.value[fieldName] = true }) } /** * Checks if a field has an error and has been touched * @param {string} fieldName - Name of the field * @returns {boolean} */ const hasError = (fieldName) => { return touched.value[fieldName] && errors.value[fieldName] && errors.value[fieldName].length > 0 } /** * Gets the first error message for a field * @param {string} fieldName - Name of the field * @returns {string|null} */ const getError = (fieldName) => { if (!hasError(fieldName)) return null return errors.value[fieldName][0] } /** * Gets all error messages for a field * @param {string} fieldName - Name of the field * @returns {string[]} */ const getErrors = (fieldName) => { if (!hasError(fieldName)) return [] return errors.value[fieldName] } /** * Clears errors for a specific field * @param {string} fieldName - Name of the field */ const clearFieldError = (fieldName) => { errors.value[fieldName] = [] touched.value[fieldName] = false } /** * Clears all errors */ const clearErrors = () => { errors.value = {} touched.value = {} } /** * Resets validation state */ const reset = () => { errors.value = {} touched.value = {} validating.value = {} } /** * Checks if the entire form is valid * @returns {boolean} */ const isValid = computed(() => { return Object.keys(errors.value).every( fieldName => !errors.value[fieldName] || errors.value[fieldName].length === 0 ) }) /** * Checks if any field is currently being validated * @returns {boolean} */ const isValidating = computed(() => { return Object.values(validating.value).some(v => v === true) }) /** * Gets nested value from object using dot notation * @param {object} obj - Object to get value from * @param {string} path - Path using dot notation (e.g., 'items.0.description') * @returns {any} */ const getNestedValue = (obj, path) => { return path.split('.').reduce((acc, part) => { return acc && acc[part] !== undefined ? acc[part] : undefined }, obj) } /** * Sets a custom error for a field * @param {string} fieldName - Name of the field * @param {string|string[]} error - Error message(s) */ const setError = (fieldName, error) => { errors.value[fieldName] = Array.isArray(error) ? error : [error] touched.value[fieldName] = true } /** * Sets multiple errors at once * @param {object} errorsMap - Object with field names and error messages */ const setErrors = (errorsMap) => { Object.entries(errorsMap).forEach(([fieldName, error]) => { setError(fieldName, error) }) } return { // State errors, touched, validating, // Methods validateField, validateForm, touchField, touchFields, touchAll, hasError, getError, getErrors, clearFieldError, clearErrors, reset, setError, setErrors, // Computed isValid, isValidating } } /** * Common validation rules presets */ export const validationRules = { required: { required: true }, email: { required: true, email: true }, optionalEmail: { email: true }, phone: { pattern: /^[\d\s()+\-/.]+$/, patternMessage: 'Ungültige Telefonnummer' }, url: { url: true }, invoiceNumber: { required: true, minLength: 3, pattern: /^[A-Z0-9-]+$/i, patternMessage: 'Nur Buchstaben, Zahlen und Bindestriche erlaubt' }, postalCode: { pattern: /^\d{5}$/, patternMessage: 'Postleitzahl muss 5 Zahlen enthalten' }, iban: { pattern: /^[A-Z]{2}\d{2}[A-Z0-9]+$/, patternMessage: 'Ungültiges IBAN-Format' }, taxId: { pattern: /^DE\d{9}$/, patternMessage: 'Ungültige Steuernummer (Format: DE123456789)' }, currency: { min: 0, custom: (value) => { if (value && isNaN(parseFloat(value))) { return 'Bitte geben Sie einen gültigen Betrag ein' } return null } }, percentage: { min: 0, max: 100, custom: (value) => { if (value && (isNaN(parseFloat(value)) || value < 0 || value > 100)) { return 'Prozentsatz muss zwischen 0 und 100 liegen' } return null } }, quantity: { required: true, min: 0.01, custom: (value) => { if (value && (isNaN(parseFloat(value)) || value <= 0)) { return 'Menge muss größer als 0 sein' } return null } } }