/** * Form Auto-Save System * * Automatically saves form data to localStorage and restores it when the user * returns to the page. Helps prevent data loss from browser crashes, accidental * navigation, network issues, or other interruptions. * * Features: * - Automatic saving every 30 seconds * - Smart field change detection * - Secure storage (excludes passwords and sensitive data) * - Configurable retention period (default 24 hours) * - Visual indicators when data is saved/restored * - Privacy-conscious (excludes honeypot fields) * - Multiple form support * - Graceful cleanup of expired drafts * * Usage: * import { FormAutoSave } from './modules/form-autosave.js'; * * // Initialize for specific form * const autosave = new FormAutoSave({ * formSelector: '#contact-form', * storageKey: 'contact_form_draft' * }); * * // Or auto-initialize all forms * FormAutoSave.initializeAll(); */ export class FormAutoSave { /** * Default configuration */ static DEFAULT_CONFIG = { formSelector: 'form[data-autosave]', saveInterval: 30000, // 30 seconds retentionPeriod: 24 * 60 * 60 * 1000, // 24 hours storagePrefix: 'form_draft_', enableVisualFeedback: true, enableConsoleLogging: true, // Security settings excludeFields: [ 'input[type="password"]', 'input[type="hidden"][name="_token"]', 'input[type="hidden"][name="_form_id"]', 'input[name*="password"]', 'input[name*="confirm"]', 'input[name*="honeypot"]', 'input[name="email_confirm"]', 'input[name="website_url"]', 'input[name="user_name"]', 'input[name="company_name"]' ], // Fields that trigger immediate save (important fields) immediateFields: [ 'textarea', 'input[type="email"]', 'input[type="text"]', 'select' ], // Cleanup settings maxDraftAge: 7 * 24 * 60 * 60 * 1000, // 7 days max storage cleanupOnInit: true }; /** * @param {Object} config Configuration options */ constructor(config = {}) { this.config = { ...FormAutoSave.DEFAULT_CONFIG, ...config }; this.form = null; this.storageKey = null; this.saveTimer = null; this.lastSaveTime = null; this.hasChanges = false; this.isInitialized = false; this.fieldValues = new Map(); this.log('FormAutoSave initializing', this.config); // Initialize the form this.initialize(); } /** * Initialize the autosave system for the form */ initialize() { // Find the form this.form = document.querySelector(this.config.formSelector); if (!this.form) { this.log('Form not found with selector:', this.config.formSelector); return; } // Generate storage key based on form or use provided key this.storageKey = this.config.storageKey || this.config.storagePrefix + this.generateFormId(); this.log('Form found, storage key:', this.storageKey); // Cleanup old drafts if enabled if (this.config.cleanupOnInit) { this.cleanupExpiredDrafts(); } // Setup event listeners this.setupEventListeners(); // Store initial field values this.storeInitialValues(); // Restore saved data this.restoreDraft(); // Start periodic saving this.startAutoSave(); this.isInitialized = true; this.log('FormAutoSave initialized successfully'); // Show status if enabled if (this.config.enableVisualFeedback) { this.showStatus('Auto-save enabled', 'info', 3000); } } /** * Generate a unique form identifier */ generateFormId() { // Try to get form ID from various sources 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, '_'); } /** * Setup form event listeners */ setupEventListeners() { // Listen for all input changes this.form.addEventListener('input', (e) => { this.onFieldChange(e); }); this.form.addEventListener('change', (e) => { this.onFieldChange(e); }); // Listen for form submission to clear draft this.form.addEventListener('submit', () => { this.onFormSubmit(); }); // Save on page unload window.addEventListener('beforeunload', () => { if (this.hasChanges) { this.saveDraft(); } }); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden && this.hasChanges) { this.saveDraft(); } }); } /** * Store initial field values to detect changes */ storeInitialValues() { const fields = this.getFormFields(); fields.forEach(field => { const key = this.getFieldKey(field); const value = this.getFieldValue(field); this.fieldValues.set(key, value); }); this.log(`Stored initial values for ${fields.length} fields`); } /** * Handle field value changes */ onFieldChange(event) { const field = event.target; // Skip excluded fields if (this.isFieldExcluded(field)) { return; } const key = this.getFieldKey(field); const newValue = this.getFieldValue(field); const oldValue = this.fieldValues.get(key); // Check if value actually changed if (newValue !== oldValue) { this.fieldValues.set(key, newValue); this.hasChanges = true; this.log(`Field changed: ${key} = "${newValue}"`); // Save immediately for important fields if (this.isImmediateField(field)) { this.saveDraft(); } } } /** * Handle form submission */ onFormSubmit() { this.log('Form submitted, clearing draft'); this.clearDraft(); this.stopAutoSave(); if (this.config.enableVisualFeedback) { this.showStatus('Form submitted, draft cleared', 'success', 2000); } } /** * Start periodic auto-save */ startAutoSave() { if (this.saveTimer) { return; } this.saveTimer = setInterval(() => { if (this.hasChanges) { this.saveDraft(); } }, this.config.saveInterval); this.log(`Auto-save started, interval: ${this.config.saveInterval}ms`); } /** * Stop periodic auto-save */ stopAutoSave() { if (this.saveTimer) { clearInterval(this.saveTimer); this.saveTimer = null; this.log('Auto-save stopped'); } } /** * Save current form data as draft */ saveDraft() { if (!this.form || !this.hasChanges) { return; } try { const formData = this.extractFormData(); const draft = { data: formData, timestamp: Date.now(), formId: this.generateFormId(), url: window.location.href, version: '1.0' }; localStorage.setItem(this.storageKey, JSON.stringify(draft)); this.lastSaveTime = new Date(); this.hasChanges = false; this.log('Draft saved', { fields: Object.keys(formData).length, size: JSON.stringify(draft).length + ' chars' }); if (this.config.enableVisualFeedback) { this.showStatus('Draft saved', 'success', 1500); } } catch (error) { console.error('Failed to save form draft:', error); if (this.config.enableVisualFeedback) { this.showStatus('Failed to save draft', 'error', 3000); } } } /** * Restore draft data to form */ restoreDraft() { try { const draftJson = localStorage.getItem(this.storageKey); if (!draftJson) { this.log('No draft found'); return; } const draft = JSON.parse(draftJson); // Check if draft is expired if (this.isDraftExpired(draft)) { this.log('Draft expired, removing'); localStorage.removeItem(this.storageKey); return; } // Check if draft is from same form/URL (optional) if (draft.url && draft.url !== window.location.href) { this.log('Draft from different URL, skipping restore'); return; } // Restore form data this.restoreFormData(draft.data); const age = this.formatDuration(Date.now() - draft.timestamp); this.log(`Draft restored (age: ${age})`, draft.data); if (this.config.enableVisualFeedback) { this.showStatus(`Draft restored from ${age} ago`, 'info', 4000); } } catch (error) { console.error('Failed to restore draft:', error); // Remove corrupted draft localStorage.removeItem(this.storageKey); } } /** * Extract form data (excluding sensitive fields) */ extractFormData() { const fields = this.getFormFields(); const data = {}; fields.forEach(field => { if (!this.isFieldExcluded(field)) { const key = this.getFieldKey(field); const value = this.getFieldValue(field); if (value !== null && value !== undefined && value !== '') { data[key] = value; } } }); return data; } /** * Restore data to form fields */ restoreFormData(data) { let restoredCount = 0; Object.entries(data).forEach(([key, value]) => { const field = this.findFieldByKey(key); if (field && !this.isFieldExcluded(field)) { this.setFieldValue(field, value); this.fieldValues.set(key, value); restoredCount++; } }); this.log(`Restored ${restoredCount} field values`); // Trigger change events for any listeners const fields = this.getFormFields(); fields.forEach(field => { if (!this.isFieldExcluded(field)) { field.dispatchEvent(new Event('change', { bubbles: true })); } }); return restoredCount; } /** * Get all form fields */ getFormFields() { return Array.from(this.form.querySelectorAll( 'input:not([type="submit"]):not([type="button"]):not([type="reset"]), ' + 'textarea, select' )); } /** * Check if field should be excluded from saving */ isFieldExcluded(field) { return this.config.excludeFields.some(selector => field.matches(selector) ); } /** * Check if field should trigger immediate save */ isImmediateField(field) { return this.config.immediateFields.some(selector => field.matches(selector) ); } /** * Generate unique key for field */ getFieldKey(field) { return field.name || field.id || `field_${field.type}_${Date.now()}`; } /** * Get field value based on field type */ getFieldValue(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 based on field type */ setFieldValue(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; } } /** * Find field by key */ findFieldByKey(key) { return this.form.querySelector(`[name="${key}"], #${key}`); } /** * Check if draft has expired */ isDraftExpired(draft) { const age = Date.now() - draft.timestamp; return age > this.config.retentionPeriod; } /** * Clear the current draft */ clearDraft() { localStorage.removeItem(this.storageKey); this.hasChanges = false; this.log('Draft cleared'); } /** * Cleanup all expired drafts */ cleanupExpiredDrafts() { let cleaned = 0; const prefix = this.config.storagePrefix; for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith(prefix)) { try { const draft = JSON.parse(localStorage.getItem(key)); const age = Date.now() - draft.timestamp; if (age > this.config.maxDraftAge) { localStorage.removeItem(key); cleaned++; } } catch (error) { // Remove corrupted entries localStorage.removeItem(key); cleaned++; } } } if (cleaned > 0) { this.log(`Cleaned up ${cleaned} expired drafts`); } } /** * 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 visual status message */ showStatus(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; statusEl.className = `autosave-status-${type}`; 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); } /** * Log messages if console logging is enabled */ log(message, data = null) { if (this.config.enableConsoleLogging) { const timestamp = new Date().toLocaleTimeString(); const prefix = `[${timestamp}] FormAutoSave`; if (data) { console.log(`${prefix}: ${message}`, data); } else { console.log(`${prefix}: ${message}`); } } } /** * Get current status */ getStatus() { return { isInitialized: this.isInitialized, formId: this.generateFormId(), storageKey: this.storageKey, hasChanges: this.hasChanges, lastSaveTime: this.lastSaveTime, fieldCount: this.fieldValues.size, draftExists: !!localStorage.getItem(this.storageKey) }; } /** * Manual save (for debugging/testing) */ forceSave() { this.hasChanges = true; this.saveDraft(); } /** * Destroy the autosave instance */ destroy() { this.stopAutoSave(); this.isInitialized = false; this.log('FormAutoSave destroyed'); } /** * Static method to initialize all forms with autosave */ static initializeAll() { const forms = document.querySelectorAll('form[data-autosave], form:has(input[name="_token"])'); const instances = []; forms.forEach((form, index) => { const formId = form.id || form.getAttribute('data-form-id') || form.querySelector('input[name="_form_id"]')?.value || `form_${index}`; const instance = new FormAutoSave({ formSelector: `#${form.id || 'form_' + index}`, storageKey: `form_draft_${formId}` }); if (!form.id) { form.id = `form_${index}`; } instances.push(instance); }); console.log(`FormAutoSave: Initialized for ${instances.length} forms`); return instances; } } // Export for manual usage export default FormAutoSave;