Files
michaelschiemer/resources/js/modules/form-autosave.js
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

674 lines
19 KiB
JavaScript

/**
* 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;