- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
674 lines
19 KiB
JavaScript
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; |