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
This commit is contained in:
674
resources/js/modules/form-autosave.js
Normal file
674
resources/js/modules/form-autosave.js
Normal file
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user