/** * Enhanced FormController - Manages forms with special fields, caching, and queue integration * Works with DataStore for CRUD operations and standalone for front-end forms */ class FormController { constructor(store = null) { this.store = store; // Optional - for CRUD operations if (!store) { this.store = new window.jvbStore({name:'forms', TTL: 604800}); } this.debouncer = window.debouncer; this.ignore = []; this.populateForm = window.jvbPopulate; this.subscribers = new Set(); this.forms = new Map(); this.specialFields = new Map(); this.dependencies = new Map(); // Auto-save configuration this.autoSaveDefaults = { delay: 3000, // 3 seconds typingDelay: 1500, // 1.5 seconds for text fields enabled: true }; // Repeater field management this.activeRepeaters = new Map(); this.repeaterDelays = { change: 6000, typing: 3000, blur: 1500, add: 500, remove: 800, reorder: 1000 }; // Bind handlers this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.submitHandler = this.handleSubmit.bind(this); this.inputHandler = this.handleInput.bind(this); this.focusHandler = this.handleFocus.bind(this); this.blurHandler = this.handleBlur.bind(this); this.init(); } async init() { // Check for pending operations on page load await this.checkPendingOperations(); // Set up global form handlers for standalone forms this.initListeners(); } /** * Check for pending operations from previous session */ async checkPendingOperations() { if (!this.store) return; try { let pending = this.store.getAllForms(); } catch (error) { console.error('Failed to load pending forms:', error); } } /** * Show notification for pending changes */ showPendingNotification(pendingData) { const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`); if (!formElement) return; const notification = document.createElement('div'); notification.className = 'pending-changes-notification'; notification.innerHTML = `

We noticed unsaved changes from last time. Would you like to restore them?

`; formElement.insertBefore(notification, formElement.firstChild); // Add handlers notification.querySelector('.restore-changes').addEventListener('click', () => { this.restorePendingForm(pendingData); notification.remove(); }); notification.querySelector('.discard-changes').addEventListener('click', () => { this.discardPendingForm(pendingData.formId); notification.remove(); }); } /** * Restore pending form data */ restorePendingForm(pendingData) { const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`); if (!form) return; // Populate form with cached data new this.populateForm(form, pendingData.formData); // Mark as restored pendingData.status = 'restored'; this.pendingForms.set(pendingData.formId, pendingData); if (window.jvbA11y) { window.jvbA11y.announce('Previous changes restored'); } } /** * Discard pending form data */ async discardPendingForm(formId) { this.store.clearForm(formId); if (window.jvbA11y) { window.jvbA11y.announce('Previous changes discarded'); } } /** * Setup global handlers for standalone forms */ initListeners() { // Only add if not already added if (!this.globalHandlersAdded) { document.addEventListener('submit', this.submitHandler); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('focus', this.focusHandler, true); document.addEventListener('blur', this.blurHandler, true); this.globalHandlersAdded = true; } } /** * Register a standalone form (for front-end forms) */ registerForm(formElement, options = {}) { const formId = formElement.dataset.formId || `form_${Date.now()}`; formElement.dataset.formId = formId; const formConfig = { element: formElement, id: formId, options: { autoSave: true, saveDelay: this.autoSaveDefaults.delay, endpoint: formElement.dataset.save, cache: true, ...options }, dependencies: new Map(), data: this.collectFormData(formElement), isDirty: false }; // Initialize special fields this.initializeFormFields(formElement, formConfig); // Store form config this.forms.set(formId, formConfig); // Check for pending data if (this.store && formConfig.options.cache) { const cached = this.store.getForm(formId); if (cached && cached.formData) { this.showPendingNotification(cached); } } return formConfig; } /** * Initialize all special fields in a form */ initializeFormFields(form, formConfig = null) { // Initialize Quill editors this.initQuillEditors(form); // Initialize repeater fields this.initRepeaterFields(form, formConfig); // Initialize conditional fields if (formConfig) { this.initConditionalFields(form, formConfig); } // Initialize character limits this.initCharacterLimits(form); // Initialize image upload fields this.initImageUploadFields(form); // Initialize tabs if present if (window.jvbTabs && form.querySelector('nav.tabs')) { new window.jvbTabs(form); } // Scan for existing selector fields if (window.jvbSelector) { window.jvbSelector.scanExistingFields(); } } /** * Initialize Quill editors */ initQuillEditors(form) { window.jvbQuill(form); } /** * Initialize repeater fields */ initRepeaterFields(form, formConfig) { form.querySelectorAll('.repeater').forEach(repeater => { const addButton = repeater.querySelector('.add-repeater-row'); const container = repeater.querySelector('.repeater-items'); const template = repeater.querySelector('template'); if (!addButton || !template || !container) return; // Initialize Sortable for drag-and-drop if (window.Sortable) { new Sortable(container, { handle: '.repeater-row-header', animation: 150, onEnd: () => { this.updateRepeaterOrder(repeater, formConfig); } }); } // Add row handler addButton.addEventListener('click', () => { this.addRepeaterRow(repeater, formConfig); }); // Remove row handlers container.addEventListener('click', (e) => { if (e.target.closest('.remove-row')) { this.removeRepeaterRow(e.target.closest('.repeater-row'), formConfig); } }); }); } /** * Add repeater row */ addRepeaterRow(repeater, formConfig) { const container = repeater.querySelector('.repeater-items'); const template = repeater.querySelector('template'); const index = container.children.length; const fieldName = repeater.dataset.field; // Clone template const row = template.content.cloneNode(true).firstElementChild; row.dataset.index = index; // Update field names row.querySelectorAll('input, select, textarea').forEach(field => { const originalName = field.name; field.name = `${fieldName}:${index}:${originalName}`; field.id = `${fieldName}-${index}-${originalName}`; // Update label if exists const label = field.nextElementSibling; if (label && label.tagName === 'LABEL') { label.htmlFor = field.id; } }); container.appendChild(row); // Schedule save if auto-save enabled if (formConfig && formConfig.options.autoSave) { this.scheduleSave(formConfig, { type: 'repeater', action: 'add', fieldName: fieldName, delay: this.repeaterDelays.add }); } if (window.jvbA11y) { window.jvbA11y.announce('Row added'); } } /** * Remove repeater row */ removeRepeaterRow(row, formConfig) { const repeater = row.closest('.repeater'); const fieldName = repeater.dataset.field; row.remove(); // Reindex remaining rows this.updateRepeaterOrder(repeater, formConfig); // Schedule save if (formConfig && formConfig.options.autoSave) { this.scheduleSave(formConfig, { type: 'repeater', action: 'remove', fieldName: fieldName, delay: this.repeaterDelays.remove }); } if (window.jvbA11y) { window.jvbA11y.announce('Row removed'); } } /** * Update repeater order after sorting */ updateRepeaterOrder(repeater, formConfig) { const container = repeater.querySelector('.repeater-items'); const fieldName = repeater.dataset.field; // Reindex all rows Array.from(container.children).forEach((row, index) => { row.dataset.index = index; // Update field names row.querySelectorAll('input, select, textarea').forEach(field => { const parts = field.name.split(':'); if (parts.length === 3) { const originalName = parts[2]; field.name = `${fieldName}:${index}:${originalName}`; field.id = `${fieldName}-${index}-${originalName}`; // Update label const label = field.nextElementSibling; if (label && label.tagName === 'LABEL') { label.htmlFor = field.id; } } }); }); // Schedule save if (formConfig && formConfig.options.autoSave) { this.scheduleSave(formConfig, { type: 'repeater', action: 'reorder', fieldName: fieldName, delay: this.repeaterDelays.reorder }); } } /** * Initialize conditional fields */ initConditionalFields(form, formConfig) { form.querySelectorAll('[data-depends-on]').forEach(field => { const dependsOn = field.dataset.dependsOn; const requiredValue = field.dataset.dependsValue; const operator = field.dataset.dependsOperator || '=='; // Store dependency if (!formConfig.dependencies.has(dependsOn)) { formConfig.dependencies.set(dependsOn, []); } formConfig.dependencies.get(dependsOn).push({ field: field, requiredValue: requiredValue, operator: operator }); // Check initial state this.checkFieldDependency(form, field, dependsOn, requiredValue, operator); }); } /** * Check field dependency */ checkFieldDependency(form, field, dependsOn, requiredValue, operator) { const triggerField = form.querySelector(`[name="${dependsOn}"]`); if (!triggerField) return; const value = this.getFieldValue(triggerField); const shouldShow = this.evaluateCondition(value, requiredValue, operator); this.toggleFieldVisibility(field, shouldShow); } /** * Evaluate conditional operator */ evaluateCondition(value, requiredValue, operator) { const fieldStr = String(value || ''); const requiredStr = String(requiredValue || ''); switch (operator) { case '==': return fieldStr == requiredStr; case '!=': return fieldStr != requiredStr; case '>': return parseFloat(fieldStr) > parseFloat(requiredStr); case '<': return parseFloat(fieldStr) < parseFloat(requiredStr); case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr); case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr); case 'contains': return fieldStr.includes(requiredStr); case 'empty': return fieldStr === ''; case 'not_empty': return fieldStr !== ''; default: return fieldStr == requiredStr; } } /** * Toggle field visibility */ toggleFieldVisibility(field, show) { const wrapper = field.closest('.field, fieldset'); if (!wrapper) return; wrapper.hidden = !show; wrapper.querySelectorAll('input, select, textarea').forEach(control => { control.disabled = !show; if (!show && control.hasAttribute('required')) { control.dataset.wasRequired = 'true'; control.removeAttribute('required'); } else if (show && control.dataset.wasRequired === 'true') { control.setAttribute('required', ''); delete control.dataset.wasRequired; } }); } /** * Initialize character limits */ initCharacterLimits(form) { form.querySelectorAll('[data-limit]').forEach(input => { const limit = parseInt(input.dataset.limit, 10); const field = input.closest('.field'); // Create counter if it doesn't exist let counter = field?.querySelector('.char-count'); if (!counter && field) { counter = document.createElement('div'); counter.className = 'char-count'; counter.innerHTML = `0 / ${limit}`; field.appendChild(counter); } const updateCount = () => { const length = input.value.length; if (counter) { counter.querySelector('.current').textContent = length; counter.classList.toggle('exceeded', length > limit); } // Truncate if exceeds limit if (length > limit) { input.value = input.value.substring(0, limit); if (counter) { counter.querySelector('.current').textContent = limit; } } }; input.addEventListener('input', updateCount); updateCount(); // Initial count }); } /** * Initialize image upload fields */ initImageUploadFields() { window.jvbUploads.scanFields(); } /* ========== Event Handlers ========== */ handleSubmit(event) { if (this.subscribers.size > 0 ){ const form = event.target; if (!form.dataset.formId) return; event.preventDefault(); const formConfig = this.forms.get(form.dataset.formId); if (!formConfig) return; const formData = this.collectFormData(form); event.preventDefault(); this.notify('form-submit', { formId: formConfig.id, data: formData, config: formConfig }); } } handleClick(e) { if (window.targetCheck(e, 'div.quantity')) { let container = window.targetCheck(e, 'div.quantity'); this.handleNumberClick(e, container.querySelector('input')); } } handleNumberClick(e, input) { let change = 0; if (e.target.closest('.increase')) { change += 1; } else if (e.target.closest('.decrease')) { change -=1; } if (change !== 0) { let step = parseFloat(input.step); //Allow for cents, but default to increasing by 1 step = Math.max(step, 1); if(e.ctrlKey && e.shiftKey) { step = step * 50; } else if (e.ctrlKey) { step = step * 5; } else if (e.shiftKey) { step = step * 10; } let value = (input.value === '') ? 0 : parseFloat(input.value); input.value = (value + (step * change)); this.handleNumberLimits(input); } } handleNumberLimits(input) { let [ min, max, increase, decrease ] = [ input.min, input.max, input.closest('.quantity')?.querySelector('.increase'), input.closest('.quantity')?.querySelector('.decrease') ]; let value = parseFloat(input.value); if (value < min) { input.value = min; decrease.disabled = true; } else if (value > max) { input.value = max; increase.disabled = false; } else if (increase.disabled) { increase.disabled = false; } else if (decrease.disabled) { decrease.disabled = false; } } handleChange(event) { if (this.subscribers.size > 0) { const target = event.target; const form = target.form || target.closest('form'); if (!form) return; const formConfig = this.forms?.get(form.dataset.formId); if (!formConfig) return; // Check conditional fields const dependencies = formConfig.dependencies.get(target.name); if (dependencies) { dependencies.forEach(dep => { this.checkFieldDependency(form, dep.field, target.name, dep.requiredValue, dep.operator); }); } // Schedule auto-save if enabled if (formConfig.options.autoSave && !form.dataset.noautosave) { const delay = this.getDelayForField(target); this.scheduleSave(formConfig, delay); } } } handleFocus(event) { const target = event.target; if (target.matches('input, textarea, select')) { // Track focus for better UX this.currentFocus = target; } } handleBlur(event) { const target = event.target; const form = target.form || target.closest('form'); if (!form) return; const formConfig = this.forms?.get(form.dataset.formId); if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) { // Shorter delay on blur this.scheduleSave(formConfig, { type: 'blur', fieldName: target.name, delay: 1500 }); } } /* ========== Auto-save functionality ========== */ /** * Get appropriate delay based on field type and context */ getDelayForField(field) { console.log('Get Delay for Field', field); // Text fields get longer delay for typing if (field.type === 'text' || field.type === 'textarea') { return this.autoSaveDefaults.typingDelay; } // Checkboxes, radios, selects get shorter delay if (['checkbox', 'radio', 'select-one', 'select-multiple'].includes(field.type)) { return 1000; } // Default delay return this.autoSaveDefaults.delay; } scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) { document.addEventListener('input', this.handleInput, {passive: true}); const saveKey = `autosave_${formConfig.id}`; this.debouncer.schedule( saveKey, () => this.autosave(formConfig), delay ); } //Extend delay if user is currently typing handleInput(e) { let form = e.target.closest('form[data-id]'); if (!form) { return; } this.scheduleSave(this.forms.get(form.dataset.id)); } async autosave(formConfig) { const formData = this.collectFormData(formConfig.element); this.cacheFormData(formConfig, formData); // Get only changed fields const changes = this.getChangedFields(formConfig.data, formData); if (Object.keys(changes).length === 0) return; // Update stored data formConfig.data = formData; this.forms.set(formConfig.id, formConfig); document.removeEventListener('input', this.handleInput); for (let [key, value] of Object.entries(formData)) { //We want all data for complex fields, like group, repeater, or location if (typeof value === 'object') { changes[key] = value; } } // Notify instead of callback this.notify('form-autosave', { formId: formConfig.id, changes: changes, fullData: formData, config: formConfig }); } cacheFormData(formConfig, formData) { try { this.store.storeForm(formConfig.id, { formId: formConfig.id, formData: formData, timestamp: Date.now(), status: 'pending', operationId: null }); } catch (error) { console.error('Failed to cache form data:', error); } } /** * Check if form has unsaved changes */ hasUnsavedChanges(formId) { const formConfig = this.forms.get(formId); if (!formConfig) return false; // Check if there are pending operations if (formConfig.operations?.size > 0) return true; // Check if current data differs from snapshot const currentData = this.collectFormData(formConfig.element); const changes = this.getChangedFields(formConfig.lastSnapshot, currentData); return Object.keys(changes).length > 0; } showFormStatus(form, status) { // Remove existing status const existingStatus = form.querySelector('.form-status'); if (existingStatus) { existingStatus.remove(); } // Add new status const statusElement = document.createElement('div'); statusElement.className = `form-status status-${status}`; const messages = { 'saving': 'Saving changes...', 'saved': 'Changes saved', 'error': 'Failed to save changes', 'offline': 'Changes will be saved when online' }; statusElement.textContent = messages[status] || status; form.insertBefore(statusElement, form.firstChild); // Auto-hide success messages if (status === 'saved') { setTimeout(() => statusElement.remove(), 3000); } } cleanupSpecialFields() { this.specialFields.forEach(field => { if (field.type === 'quill' && field.instance) { // Remove Quill toolbar const toolbar = field.instance.container.previousSibling; if (toolbar?.classList.contains('ql-toolbar')) { toolbar.remove(); } } }); this.uploader?.destroy(); this.specialFields.clear(); } /* ========== Form Data Methods ========== */ collectFormData(form) { const formData = new FormData(form); let data = {}; const repeaterData = {}; const postData = {}; for (let [key, value] of formData.entries()) { if (this.ignore.includes(key) || key.endsWith('_temp')) continue; const processor = this.getFieldProcessor(key); processor(key, value, data, repeaterData, postData, form); } if (!window.isEmptyObject(postData)) { data = this.mergeRepeaterData(data, repeaterData); return this.mergePostData(data, postData); } return this.mergeRepeaterData(data, repeaterData); } getFieldProcessor(key) { if (key.includes('|')) return this.processTableField; if (key.includes('::')) return this.processGroupField; if (key.includes(':')) return this.processRepeaterField; if (key.includes('[')) return this.processLocationField; return this.processRegularField; } mergeRepeaterData(data, repeaterData) { Object.keys(repeaterData).forEach(fieldName => { // Clean up empty rows and convert to array format const cleanedRows = {}; Object.keys(repeaterData[fieldName]).forEach(index => { const rowData = repeaterData[fieldName][index]; if (Object.keys(rowData).length > 0) { cleanedRows[index] = rowData; } }); // Convert to sequential array data[fieldName] = Object.values(cleanedRows); }); return data; } mergePostData(data, postData) { for (let [postId, postData] in Object.entries(postData)) { data[postId] = postData; } return data; } processTableField(key, value, data, repeaterData, postData, form) { /*** * Table forms are a huge form containing multiple posts and their data * Field names are prepended with `${postID}|` * Goal: * 1) Separate out the post id from the field name * 2) store the original data in a temporary 'original' variable * 3) Process the field as normal * 4) return the original data, as PostID: {$field data} * Final format: * { * id1: { * field1: "A title", * field3: 32 * }, * id2: { * field1: "Another title", * field2: "122,21,32" * } * } **/ let [post, fieldKey] = key.split('|'); if (!post in postData) { postData[post] = {}; } const processor = this.getFieldProcessor(fieldKey); processor(fieldKey, value, postData, repeaterData, postData, form); } processRepeaterField(key, value, data, repeaterData, postData, form) { let [fieldName, index, subField] = key.split(':'); const isArray = subField.endsWith('[]'); subField = subField.replace('[]', ''); //Ensure this repeater and row is in repeaterData if (!repeaterData[fieldName]) { repeaterData[fieldName] = {}; } if (!repeaterData[fieldName][index]) { repeaterData[fieldName][index] = {}; } if (isArray || repeaterData[fieldName][index][subField]) { // Initialize as array if not already if (!repeaterData[fieldName][index][subField]) { repeaterData[fieldName][index][subField] = []; } else if (!Array.isArray(repeaterData[fieldName][index][subField])) { repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]]; } repeaterData[fieldName][index][subField].push(value); } else { // Single value field repeaterData[fieldName][index][subField] = value; } } processGroupField(key, value, data, repeaterData, postData, form) { const keys = key.split('::'); const rootGroup = keys[0]; // Initialize root group if it doesn't exist if (!data[rootGroup]) { data[rootGroup] = {}; } // Build nested structure step by step let current = data[rootGroup]; for (let i = 1; i < keys.length - 1; i++) { const groupKey = keys[i]; if (!current[groupKey]) { current[groupKey] = {}; } current = current[groupKey]; } // Set the final field value const fieldKey = keys[keys.length - 1]; // Handle array values (checkboxes, multi-selects) if (current[fieldKey] !== undefined) { if (!Array.isArray(current[fieldKey])) { current[fieldKey] = [current[fieldKey]]; } current[fieldKey].push(value); } else { current[fieldKey] = value; } } processLocationField(key, value, data, repeaterData, postData, form) { let [fieldKey, v ] = key.split('['); v = v.replace(']',''); if (!Object.hasOwn(data, fieldKey)) { data[fieldKey] = {}; if (!Object.hasOwn(data, 'sendAll')) { data['sendAll'] = [fieldKey]; } else if (!data['sendAll'].includes(fieldKey)) { data['sendAll'].push(fieldKey); } } data[fieldKey][v] = value; } processRegularField(key, value, data, repeaterData, postData, form) { //handle array values (like checkboxes/selects) if (data[key]) { if (!Array.isArray(data[key])) { data[key] = [data[key]]; } data[key].push(value); } else { data[key] = value; } } getFieldValue(field) { if (!field) return ''; if (field.type === 'checkbox') { return field.checked ? field.value || '1' : ''; } else if (field.type === 'radio') { const checked = field.form.querySelector(`[name="${field.name}"]:checked`); return checked ? checked.value : ''; } else if (field.type === 'select-multiple') { return Array.from(field.selectedOptions).map(o => o.value); } else { return field.value; } } getChangedFields(original, current) { return window.getDifferences?.map(original, current) || {}; } /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /** * Cleanup when form is closed/destroyed */ cleanupForm(formId) { const formConfig = this.forms.get(formId); if (!formConfig) return; console.log('Cleaning up form', formConfig); // Check for unsaved changes if (this.hasUnsavedChanges(formId)) { this.autosave(formConfig); } // Clean up special fields this.cleanupSpecialFields(); // Remove form config this.forms.delete(formId); } /** * Cleanup */ destroy() { // Remove global handlers if (this.globalHandlersAdded) { document.removeEventListener('submit', this.submitHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('focus', this.focusHandler, true); document.removeEventListener('blur', this.blurHandler, true); } // Clear maps this.specialFields.clear(); this.forms.clear(); this.activeRepeaters.clear(); if (this.forms) { this.forms.clear(); } } } document.addEventListener('DOMContentLoaded', () => { window.jvbForm = FormController; });