import { Logger } from '../../core/logger.js'; import { FormValidator } from './FormValidator.js'; import { FormState } from './FormState.js'; export class FormHandler { constructor(form, options = {}) { this.form = form; this.options = { validateOnSubmit: true, validateOnBlur: false, validateOnInput: false, showInlineErrors: true, preventSubmitOnError: true, submitMethod: 'POST', ajaxSubmit: true, // Autosave options enableAutosave: options.enableAutosave ?? form.hasAttribute('data-autosave'), autosaveInterval: options.autosaveInterval || 30000, // 30 seconds autosaveRetentionPeriod: options.autosaveRetentionPeriod || 24 * 60 * 60 * 1000, // 24 hours autosaveStorageKey: options.autosaveStorageKey || null, autosaveStoragePrefix: options.autosaveStoragePrefix || 'form_draft_', autosaveVisualFeedback: options.autosaveVisualFeedback ?? true, autosaveExcludeFields: options.autosaveExcludeFields || [ 'input[type="password"]', 'input[type="hidden"][name="_token"]', 'input[type="hidden"][name="_form_id"]', 'input[name*="password"]', 'input[name*="confirm"]', 'input[name*="honeypot"]' ], autosaveImmediateFields: options.autosaveImmediateFields || [ 'textarea', 'input[type="email"]', 'input[type="text"]', 'select' ], ...options }; this.validator = FormValidator.create(form); this.state = FormState.create(form); this.isSubmitting = false; // Autosave state this.autosaveTimer = null; this.lastAutosaveTime = null; this.autosaveStorageKey = null; this.init(); } static create(form, options = {}) { return new FormHandler(form, options); } init() { this.bindEvents(); this.setupErrorDisplay(); // Initialize autosave if enabled if (this.options.enableAutosave) { this.initAutosave(); } // Mark form as enhanced this.form.setAttribute('data-enhanced', 'true'); Logger.info(`[FormHandler] Initialized for form: ${this.form.id || 'unnamed'}`); } bindEvents() { // Submit event this.form.addEventListener('submit', (e) => this.handleSubmit(e)); // Field validation events if (this.options.validateOnBlur || this.options.validateOnInput) { const fields = this.form.querySelectorAll('input, textarea, select'); fields.forEach(field => { if (this.options.validateOnBlur) { field.addEventListener('blur', () => this.validateSingleField(field)); } if (this.options.validateOnInput) { field.addEventListener('input', () => this.validateSingleField(field)); } }); } // Autosave events if (this.options.enableAutosave) { this.form.addEventListener('input', (e) => this.onAutosaveFieldChange(e)); this.form.addEventListener('change', (e) => this.onAutosaveFieldChange(e)); // Save on page unload window.addEventListener('beforeunload', () => { if (this.state.isDirty()) { this.saveAutosaveDraft(); } }); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden && this.state.isDirty()) { this.saveAutosaveDraft(); } }); } } async handleSubmit(event) { if (this.isSubmitting) { event.preventDefault(); return; } // Validate if enabled if (this.options.validateOnSubmit) { const isValid = this.validator.validate(); if (!isValid) { event.preventDefault(); this.displayErrors(); return; } } // AJAX submission if (this.options.ajaxSubmit) { event.preventDefault(); await this.submitViaAjax(); } // If not AJAX, let the form submit naturally } async submitViaAjax() { try { this.setSubmitState(true); this.clearErrors(); const formData = new FormData(this.form); const url = this.form.action || window.location.href; const method = this.form.method || this.options.submitMethod; const response = await fetch(url, { method: method.toUpperCase(), body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await this.parseResponse(response); if (response.ok) { this.handleSuccess(data); } else { this.handleError(data); } } catch (error) { Logger.error('[FormHandler] Submit error:', error); this.handleError({ message: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', errors: {} }); } finally { this.setSubmitState(false); } } async parseResponse(response) { const contentType = response.headers.get('content-type'); if (contentType?.includes('application/json')) { return await response.json(); } const text = await response.text(); // Try to parse as JSON, fallback to text try { return JSON.parse(text); } catch { return { message: text }; } } handleSuccess(data) { Logger.info('[FormHandler] Form submitted successfully'); // Clear autosave draft on successful submission if (this.options.enableAutosave) { this.clearAutosaveDraft(); } // Clear form if configured if (data.clearForm !== false) { this.form.reset(); this.state.reset(); } this.showMessage(data.message || 'Formular erfolgreich gesendet!', 'success'); // Call success callback this.triggerEvent('form:success', { data }); } handleError(data) { Logger.warn('[FormHandler] Form submission error:', data); // Handle field-specific errors if (data.errors && typeof data.errors === 'object') { for (const [fieldName, errorMessage] of Object.entries(data.errors)) { this.validator.errors.set(fieldName, errorMessage); } this.displayErrors(); } // Show general error message this.showMessage(data.message || 'Ein Fehler ist aufgetreten.', 'error'); // Call error callback this.triggerEvent('form:error', { data }); } validateSingleField(field) { this.validator.errors.delete(field.name); this.validator.validateField(field); this.displayFieldError(field); } displayErrors() { if (!this.options.showInlineErrors) return; const errors = this.validator.getErrors(); for (const [fieldName, message] of Object.entries(errors)) { const field = this.form.querySelector(`[name="${fieldName}"]`); if (field) { this.displayFieldError(field, message); } } } displayFieldError(field, message = null) { const errorMessage = message || this.validator.getFieldError(field.name); const errorElement = this.getOrCreateErrorElement(field); if (errorMessage) { errorElement.textContent = errorMessage; errorElement.style.display = 'block'; field.classList.add('error'); field.setAttribute('aria-invalid', 'true'); field.setAttribute('aria-describedby', errorElement.id); } else { errorElement.textContent = ''; errorElement.style.display = 'none'; field.classList.remove('error'); field.removeAttribute('aria-invalid'); field.removeAttribute('aria-describedby'); } } getOrCreateErrorElement(field) { const errorId = `error-${field.name}`; let errorElement = document.getElementById(errorId); if (!errorElement) { errorElement = document.createElement('div'); errorElement.id = errorId; errorElement.className = 'form-error'; errorElement.setAttribute('role', 'alert'); errorElement.style.display = 'none'; // Insert after field or field container const container = field.closest('.form-group') || field.parentElement; container.appendChild(errorElement); } return errorElement; } setupErrorDisplay() { // Add CSS for error states if not present if (!document.getElementById('form-handler-styles')) { const styles = document.createElement('style'); styles.id = 'form-handler-styles'; styles.textContent = ` .form-error { color: #dc2626; font-size: 0.875rem; margin-top: 0.25rem; } input.error, textarea.error, select.error { border-color: #dc2626; box-shadow: 0 0 0 1px #dc2626; } .form-message { padding: 0.75rem; border-radius: 0.375rem; margin: 1rem 0; } .form-message.success { background-color: #dcfce7; color: #166534; border: 1px solid #bbf7d0; } .form-message.error { background-color: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } `; document.head.appendChild(styles); } } showMessage(message, type = 'info') { let messageContainer = this.form.querySelector('.form-messages'); if (!messageContainer) { messageContainer = document.createElement('div'); messageContainer.className = 'form-messages'; this.form.prepend(messageContainer); } const messageElement = document.createElement('div'); messageElement.className = `form-message ${type}`; messageElement.textContent = message; messageElement.setAttribute('role', type === 'error' ? 'alert' : 'status'); messageContainer.innerHTML = ''; messageContainer.appendChild(messageElement); // Auto-hide success messages after 5 seconds if (type === 'success') { setTimeout(() => { if (messageElement.parentElement) { messageElement.remove(); } }, 5000); } } clearErrors() { this.validator.clearErrors(); // Clear visual error states const errorFields = this.form.querySelectorAll('.error'); errorFields.forEach(field => { field.classList.remove('error'); field.removeAttribute('aria-invalid'); field.removeAttribute('aria-describedby'); }); // Hide error messages const errorElements = this.form.querySelectorAll('.form-error'); errorElements.forEach(element => { element.style.display = 'none'; element.textContent = ''; }); // Clear general messages const messageContainer = this.form.querySelector('.form-messages'); if (messageContainer) { messageContainer.innerHTML = ''; } } setSubmitState(isSubmitting) { this.isSubmitting = isSubmitting; const submitButtons = this.form.querySelectorAll('button[type="submit"], input[type="submit"]'); submitButtons.forEach(button => { button.disabled = isSubmitting; if (isSubmitting) { button.setAttribute('data-original-text', button.textContent); button.textContent = 'Wird gesendet...'; } else { const originalText = button.getAttribute('data-original-text'); if (originalText) { button.textContent = originalText; button.removeAttribute('data-original-text'); } } }); } triggerEvent(eventName, detail = {}) { const event = new CustomEvent(eventName, { detail, bubbles: true, cancelable: true }); this.form.dispatchEvent(event); } /** * Initialize autosave functionality */ initAutosave() { // Generate storage key this.autosaveStorageKey = this.options.autosaveStorageKey || this.options.autosaveStoragePrefix + this.generateFormId(); // Restore draft on init this.restoreAutosaveDraft(); // Start periodic autosave this.startAutosave(); Logger.info('[FormHandler] Autosave initialized', { storageKey: this.autosaveStorageKey }); } /** * Generate unique form identifier */ generateFormId() { const formId = this.form.id || this.form.getAttribute('data-form-id') || this.form.querySelector('input[name="_form_id"]')?.value || 'form_' + Date.now(); return formId.replace(/[^a-zA-Z0-9_-]/g, '_'); } /** * Handle field change for autosave */ onAutosaveFieldChange(event) { const field = event.target; // Skip excluded fields if (this.isAutosaveFieldExcluded(field)) { return; } // Save immediately for important fields if (this.isAutosaveImmediateField(field)) { this.saveAutosaveDraft(); } } /** * Check if field should be excluded from autosave */ isAutosaveFieldExcluded(field) { return this.options.autosaveExcludeFields.some(selector => field.matches(selector) ); } /** * Check if field should trigger immediate save */ isAutosaveImmediateField(field) { return this.options.autosaveImmediateFields.some(selector => field.matches(selector) ); } /** * Start periodic autosave */ startAutosave() { if (this.autosaveTimer) { return; } this.autosaveTimer = setInterval(() => { if (this.state.isDirty()) { this.saveAutosaveDraft(); } }, this.options.autosaveInterval); Logger.debug('[FormHandler] Autosave started', { interval: this.options.autosaveInterval }); } /** * Stop periodic autosave */ stopAutosave() { if (this.autosaveTimer) { clearInterval(this.autosaveTimer); this.autosaveTimer = null; } } /** * Save form data as draft */ saveAutosaveDraft() { if (!this.form || !this.state.isDirty()) { return; } try { const formData = this.extractAutosaveFormData(); const draft = { data: formData, timestamp: Date.now(), formId: this.generateFormId(), url: window.location.href, version: '1.0' }; localStorage.setItem(this.autosaveStorageKey, JSON.stringify(draft)); this.lastAutosaveTime = new Date(); Logger.debug('[FormHandler] Draft saved', { fields: Object.keys(formData).length, storageKey: this.autosaveStorageKey }); if (this.options.autosaveVisualFeedback) { this.showAutosaveStatus('Draft saved', 'success', 1500); } // Trigger event this.triggerEvent('form:autosave', { draft }); } catch (error) { Logger.error('[FormHandler] Failed to save draft', error); if (this.options.autosaveVisualFeedback) { this.showAutosaveStatus('Failed to save draft', 'error', 3000); } } } /** * Restore draft data to form */ restoreAutosaveDraft() { try { const draftJson = localStorage.getItem(this.autosaveStorageKey); if (!draftJson) { return; } const draft = JSON.parse(draftJson); // Check if draft is expired const age = Date.now() - draft.timestamp; if (age > this.options.autosaveRetentionPeriod) { localStorage.removeItem(this.autosaveStorageKey); return; } // Restore form data this.restoreAutosaveFormData(draft.data); Logger.info('[FormHandler] Draft restored', { age: Math.floor(age / 1000) + 's', fields: Object.keys(draft.data).length }); if (this.options.autosaveVisualFeedback) { const ageText = this.formatDuration(age); this.showAutosaveStatus(`Draft restored from ${ageText} ago`, 'info', 4000); } // Trigger event this.triggerEvent('form:autosave-restored', { draft }); } catch (error) { Logger.error('[FormHandler] Failed to restore draft', error); localStorage.removeItem(this.autosaveStorageKey); } } /** * Extract form data for autosave (excluding sensitive fields) */ extractAutosaveFormData() { const fields = this.getFormFields(); const data = {}; fields.forEach(field => { if (!this.isAutosaveFieldExcluded(field)) { const key = field.name || field.id; const value = this.getAutosaveFieldValue(field); if (key && value !== null && value !== undefined && value !== '') { data[key] = value; } } }); return data; } /** * Restore data to form fields */ restoreAutosaveFormData(data) { let restoredCount = 0; Object.entries(data).forEach(([key, value]) => { const field = this.form.querySelector(`[name="${key}"], #${key}`); if (field && !this.isAutosaveFieldExcluded(field)) { this.setAutosaveFieldValue(field, value); restoredCount++; } }); // Update form state this.state.captureInitialValues(); return restoredCount; } /** * Get form fields */ getFormFields() { return Array.from(this.form.querySelectorAll( 'input:not([type="submit"]):not([type="button"]):not([type="reset"]), ' + 'textarea, select' )); } /** * Get field value for autosave */ getAutosaveFieldValue(field) { switch (field.type) { case 'checkbox': return field.checked; case 'radio': return field.checked ? field.value : null; case 'file': return null; // Don't save file inputs default: return field.value; } } /** * Set field value for autosave */ setAutosaveFieldValue(field, value) { switch (field.type) { case 'checkbox': field.checked = Boolean(value); break; case 'radio': field.checked = (field.value === value); break; case 'file': // Can't restore file inputs break; default: field.value = value; break; } } /** * Clear autosave draft */ clearAutosaveDraft() { if (this.autosaveStorageKey) { localStorage.removeItem(this.autosaveStorageKey); } Logger.debug('[FormHandler] Draft cleared'); } /** * Format duration for human reading */ formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { return `${minutes}m`; } else { return `${seconds}s`; } } /** * Show autosave status message */ showAutosaveStatus(message, type = 'info', duration = 3000) { // Create or update status element let statusEl = document.getElementById('form-autosave-status'); if (!statusEl) { statusEl = document.createElement('div'); statusEl.id = 'form-autosave-status'; statusEl.style.cssText = ` position: fixed; bottom: 20px; right: 20px; padding: 8px 12px; border-radius: 4px; font-size: 12px; z-index: 9999; max-width: 250px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: opacity 0.3s ease; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; document.body.appendChild(statusEl); } statusEl.textContent = message; const styles = { info: 'background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb;', success: 'background: #e8f5e8; color: #2e7d32; border: 1px solid #c8e6c9;', error: 'background: #ffebee; color: #c62828; border: 1px solid #ffcdd2;' }; statusEl.style.cssText += styles[type] || styles.info; statusEl.style.opacity = '1'; setTimeout(() => { if (statusEl) { statusEl.style.opacity = '0'; setTimeout(() => { if (statusEl && statusEl.parentNode) { statusEl.parentNode.removeChild(statusEl); } }, 300); } }, duration); } destroy() { // Stop autosave if (this.options.enableAutosave) { this.stopAutosave(); } // Remove event listeners and clean up this.form.removeAttribute('data-enhanced'); Logger.info('[FormHandler] Destroyed'); } }