/** * 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() { this.store = new window.jvbStore({ name:'forms', storeName: 'forms', keyPath: 'formId', indexes: [ { name: 'status', keyPath: 'status' }, { name: 'operationId', keyPath: 'operationId' }, { name: 'timestamp', keyPath: 'timestamp' }, { name: 'formType', keyPath: 'type' } ], TTL: 604800000, //7 days }); 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(); // Validation (YOU ARE GREAT!) this.validators = this.initValidators(); this.touchedFields = new Set(); // 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(); this.store.subscribe(this.handleStoreEvent.bind(this)); // Set up global form handlers for standalone forms this.initListeners(); } handleStoreEvent(event, data) { switch(event) { case 'item-saved': if (data.item.status === 'autosave') { // this.showFormStatus(data.item.formId, 'autosave'); } break; case 'data-loaded': this.checkPendingForms(); break; } } async checkPendingForms() { let items = await this.store.query('status', 'draft'); items.forEach(item => { let form = this.forms.get(item.formId); if (form && form.element) { form.element.querySelector('.restore-form').hidden = false; new this.populateForm(form.element, item.data); } }); } /** * Check for pending operations from previous session */ async checkPendingOperations() { const pendingForms = await this.store.query('status', 'pending'); if (pendingForms.length === 0) return; // Group by form type or page const grouped = this.groupPendingForms(pendingForms); // Show consolidated notification this.showPendingNotification(grouped); } /** * 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.delete(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('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('focus', this.focusHandler, true); document.addEventListener('blur', this.blurHandler, true); document.addEventListener('input', this.inputHandler); this.globalHandlersAdded = true; } } /** * Register a standalone form (for front-end forms) */ registerForm(formElement, options = {}) { if (!formElement) return; const formId = formElement.dataset.formId || `form_${Date.now()}`; formElement.dataset.formId = formId; formElement.addEventListener('submit', this.submitHandler); const formConfig = { element: formElement, id: formId, status: '', options: { autosave: 'autosave' in formElement.dataset, saveDelay: this.autoSaveDefaults.delay, endpoint: formElement.dataset.save??'', formStatus: true, cache: true, ...options }, dependencies: new Map(), data: this.collectFormData(formElement), }; // 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.get(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')) { formConfig.tabs = new window.jvbTabs(form); this.forms.set(formConfig.formId, formConfig); this.initSteppedForm(formConfig.formId); } // Scan for existing selector fields if (window.jvbSelector) { window.jvbSelector.scanExistingFields(form); } } /** * Initialize stepped form functionality */ initSteppedForm(formId) { const formConfig = this.forms.get(formId); const form = formConfig.element; const tabsInstance = formConfig.tabs; const sections = form.querySelectorAll('.tab-content'); const totalSteps = sections.length; const progressBar = form.querySelector('.form-progress .fill'); const stepText = form.querySelector('.step-text .current'); const tabButtons = form.querySelectorAll('nav.tabs button'); // Update progress display const updateProgress = (currentStep) => { const progress = (currentStep / totalSteps) * 100; if (progressBar) { progressBar.style.width = progress + '%'; } if (stepText) { stepText.textContent = currentStep; } // Update tab states tabButtons.forEach((btn, idx) => { const stepNum = idx + 1; btn.classList.remove('current', 'completed', 'pending'); if (stepNum < currentStep) { btn.classList.add('completed'); } else if (stepNum === currentStep) { btn.classList.add('current'); } else { btn.classList.add('pending'); } }); }; // Next/Previous button handling form.addEventListener('click', (e) => { const nextBtn = e.target.closest('[data-action="next-step"]'); const prevBtn = e.target.closest('[data-action="prev-step"]'); if (nextBtn) { e.preventDefault(); const currentSection = nextBtn.closest('.tab-content'); const currentStep = parseInt(currentSection.dataset.step); const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`); if (nextSection && this.validateStep(currentSection)) { const nextTab = nextSection.dataset.tab; tabsInstance.switchTab(nextTab, true); updateProgress(currentStep + 1); // Scroll to top of form form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } if (prevBtn) { e.preventDefault(); const currentSection = prevBtn.closest('.tab-content'); const currentStep = parseInt(currentSection.dataset.step); const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`); if (prevSection) { const prevTab = prevSection.dataset.tab; tabsInstance.switchTab(prevTab, true); updateProgress(currentStep - 1); // Scroll to top of form form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }); // Update progress when tabs are clicked directly const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance); tabsInstance.switchTab = (tab, updateHistory) => { originalSwitchTab(tab, updateHistory); const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`); if (activeSection) { const step = parseInt(activeSection.dataset.step); updateProgress(step); } }; // Initialize progress updateProgress(1); } /** * Validate current step before allowing progression * Can be enhanced with custom validation rules */ validateStep(section) { const fields = section.querySelectorAll('.field'); let allValid = true; fields.forEach(fieldWrapper => { const input = fieldWrapper.querySelector('input, textarea, select'); if (input && !input.closest('[hidden]')) { const isValid = this.validateField(input, fieldWrapper); if (!isValid) { allValid = false; } } }); return allValid; } /** * 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); if (formConfig) { 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) { 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) { 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(form) { window.jvbUploads.scanFields(form); } /* ========== Event Handlers ========== */ async handleSubmit(event) { const form = event.target; if (!form.dataset.formId) return; const formConfig = this.forms.get(form.dataset.formId); // Handle subscriber-based forms if (this.subscribers.size > 0) { event.preventDefault(); const formData = this.collectFormData(form); this.notify('form-submit', { formId: formConfig.id, data: formData, config: formConfig }); } } handleFormSuccess(form, data) { // Clear previous errors form.querySelectorAll('.error-message').forEach(el => el.remove()); form.querySelectorAll('.field-error').forEach(el => el.classList.remove('field-error') ); // Add success class to form form.classList.add('form-success'); // Show success message if provided if (data.message) { const success = document.createElement('div'); success.className = 'form-success-message success-message'; success.textContent = data.message; form.insertBefore(success, form.firstChild); // Optionally add icon const icon = window.getIcon?.('check-circle'); if (icon) { icon.classList.add('success-icon'); success.prepend(icon); } } // If there's a title/description (for registration success) if (data.title || data.description) { const successBox = document.createElement('div'); successBox.className = 'success-box'; if (data.title) { const title = document.createElement('h3'); title.textContent = data.title; successBox.appendChild(title); } if (data.description) { // Handle both string and array descriptions const descriptions = Array.isArray(data.description) ? data.description : [data.description]; descriptions.forEach(desc => { const p = document.createElement('p'); p.textContent = desc; successBox.appendChild(p); }); } form.insertBefore(successBox, form.firstChild); } // Announce success for accessibility if (window.jvbA11y) { window.jvbA11y.announce(data.message || 'Form submitted successfully'); } // Trigger custom event form.dispatchEvent(new CustomEvent('jvb-form-success', { detail: data })); } handleFormError(form, data) { // Clear all previous errors form.querySelectorAll('.error-message').forEach(el => el.remove()); form.querySelectorAll('.field-error, .has-error').forEach(el => { el.classList.remove('field-error', 'has-error'); }); // Clear validation states using existing method form.querySelectorAll('.field').forEach(fieldWrapper => { this.clearValidation(fieldWrapper); }); // Handle field-specific errors if (data.field) { const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); if (fieldWrapper) { // Use existing showError method for consistency this.showError(fieldWrapper, data.message); // Mark as touched so validation persists this.touchedFields.add(data.field); // Scroll to error fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Focus the input for better UX const input = fieldWrapper.querySelector('input, textarea, select'); if (input) { input.focus(); } } } else { // General form error (not field-specific) const error = document.createElement('div'); error.className = 'form-error error-message'; error.textContent = data.message; // Add icon for consistency const icon = window.getIcon?.('close-circle'); if (icon) { icon.classList.add('error-icon'); error.prepend(icon); } form.insertBefore(error, form.firstChild); // Scroll to top to show the error form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // Announce error for accessibility if (window.jvbA11y) { const announcement = data.field ? `Error in ${data.field}: ${data.message}` : `Form error: ${data.message}`; window.jvbA11y.announce(announcement); } // Trigger custom event form.dispatchEvent(new CustomEvent('jvb-form-error', { detail: data })); } handleClick(e) { if (window.targetCheck(e, 'div.quantity')) { let container = window.targetCheck(e, 'div.quantity'); this.handleNumberClick(e, container.querySelector('input')); } else if (window.targetCheck(e, '[data-action]')) { let action = window.targetCheck(e, '[data-action]'); action = action.dataset.action; switch (action) { case 'clear-form': let form = e.target.closest('form'); this.store.delete(form.dataset.formId); form?.reset(); e.target.closest('.restore-form').hidden = true; break; case 'dismiss-restore': e.target.closest('.restore-form').hidden = true; break; } } } 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 (event.target.closest('[data-ignore]')) { return; } 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; console.log(formConfig.options); if (formConfig.options.autosave || this.subscribers.size > 0) { // 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 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(e) { if (e.target.closest('[data-ignore]')) { return; } const target = e.target; const form = target.form || target.closest('form'); if (!form) return; const input = e.target.closest('input, textarea, select'); if (input) { const fieldWrapper = this.findFieldWrapper(input); if (fieldWrapper) { // Mark as touched and validate const fieldName = fieldWrapper.dataset.field; if (fieldName) { if (this.shouldDebounce(input)) { window.debouncer.cancel(`validate_${fieldName}`); } this.touchedFields.add(fieldName); } this.validateField(input, fieldWrapper); } const formConfig = this.forms?.get(form.dataset.formId); if (formConfig) { // Shorter delay on blur this.scheduleSave(formConfig, { type: 'blur', fieldName: target.name, delay: 1500 }); } } } handleInput(e) { if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) { return; } const input = e.target.closest('input, textarea, select'); if (!input) return; let form = input.closest('form'); this.showFormStatus(form.dataset.formId, 'pending'); const fieldWrapper = this.findFieldWrapper(input); if (!fieldWrapper) return; const fieldName = fieldWrapper.dataset.field; if (fieldName) { this.touchedFields.add(fieldName); } if (this.shouldDebounce(input)){ window.debouncer.schedule( `validate_${fieldName}`, (input, fieldWrapper) => this.validateField.bind(this), 500 ) } } /*************************************************************** FORM VALIDATION ***************************************************************/ /** * Initialize validation rules */ initValidators() { return { email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Please enter a valid email address' }, url: { pattern: /^https?:\/\/.+\..+/, message: 'Please enter a valid URL starting with http:// or https://' }, phone: { pattern: /^[\d\s\-\+\(\)\.]+$/, message: 'Please enter a valid phone number' }, number: { test: (value, fieldWrapper) => { const num = parseFloat(value); if (isNaN(num)) return 'Please enter a valid number'; const min = fieldWrapper.dataset.min; const max = fieldWrapper.dataset.max; if (min !== undefined && num < parseFloat(min)) { return `Value must be at least ${min}`; } if (max !== undefined && num > parseFloat(max)) { return `Value must be at most ${max}`; } return true; } }, text: { test: (value, fieldWrapper) => { const minLength = fieldWrapper.dataset.minlength; const maxLength = fieldWrapper.dataset.maxlength; if (minLength && value.length < parseInt(minLength)) { return `Must be at least ${minLength} characters`; } if (maxLength && value.length > parseInt(maxLength)) { return `Must be no more than ${maxLength} characters`; } return true; } } }; } /** * Find the field wrapper (handles both simple and complex fields) */ findFieldWrapper(input) { // Try to find the closest .field wrapper let wrapper = input.closest('.field'); // If we're in a repeater row, make sure we get the right field wrapper if (!wrapper) { wrapper = input.closest('[data-field]'); } return wrapper; } /** * Check if input should be debounced */ shouldDebounce(input) { const debounceTypes = ['text', 'email', 'url', 'tel', 'search']; return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA'; } /** * Validate a single field */ validateField(input, fieldWrapper) { const value = this.getFieldValue(input); const fieldName = fieldWrapper.dataset.field; // Skip validation if field hasn't been touched yet (unless it's required) if (!this.touchedFields.has(fieldName) && !input.required) { return true; } // Skip validation if field is empty and not required if (!value && !input.required) { this.clearValidation(fieldWrapper); return true; } // Check required if (input.required && !value) { this.showError(fieldWrapper, 'This field is required'); return false; } // Check HTML5 validity first if (input.checkValidity && !input.checkValidity()) { this.showError(fieldWrapper, input.validationMessage); return false; } // Custom pattern validation from data attribute const pattern = fieldWrapper.dataset.pattern; if (pattern && value) { const regex = new RegExp(pattern); if (!regex.test(value)) { const message = fieldWrapper.dataset.validationMessage || 'Invalid format'; this.showError(fieldWrapper, message); return false; } } // Type-specific validation const validateType = fieldWrapper.dataset.validate || input.type; if (validateType && this.validators[validateType]) { const validator = this.validators[validateType]; if (validator.pattern && !validator.pattern.test(value)) { this.showError(fieldWrapper, validator.message); return false; } if (validator.test) { const result = validator.test(value, fieldWrapper); if (result !== true) { this.showError(fieldWrapper, result); return false; } } } // All validations passed this.showSuccess(fieldWrapper); this.notify('field-validated', input); return true; } /** * Get field value (handles different input types) */ getFieldValue(input) { if (!input) return ''; if (input.type === 'checkbox') { return input.checked ? input.value || '1' : ''; } else if (input.type === 'radio') { const checked = input.form?.querySelector(`[name="${input.name}"]:checked`); return checked ? checked.value : ''; } else if (input.type === 'select-multiple') { return Array.from(input.selectedOptions).map(o => o.value); } return input.value?.trim() || ''; } /** * Show success state (green checkmark) */ showSuccess(fieldWrapper, textMessage = '') { if (!fieldWrapper) return; // Find validation elements (they might be in field-input-wrapper or field-content) const success = fieldWrapper.querySelector('.validation-icon.success'); const error = fieldWrapper.querySelector('.validation-icon.error'); const message = fieldWrapper.querySelector('.validation-message'); const input = fieldWrapper.querySelector('input, textarea, select'); // Remove error state fieldWrapper.classList.remove('has-error'); input?.classList.remove('error'); // Add success state fieldWrapper.classList.add('has-success'); // Show checkmark (if element exists) if (success) { success.hidden = false; } if (error) { error.hidden = true; } // Hide error message if (message) { if (textMessage === '') { message.hidden = true; message.textContent = ''; } else { message.hidden = false; message.textContent = textMessage; } } } /** * Show error state (red message below field) */ showError(fieldWrapper, errorMessage) { if (!fieldWrapper) return; const success = fieldWrapper.querySelector('.validation-icon.success'); const error = fieldWrapper.querySelector('.validation-icon.error'); const message = fieldWrapper.querySelector('.validation-message'); const input = fieldWrapper.querySelector('input, textarea, select'); // Remove success state fieldWrapper.classList.remove('has-success'); // Add error state fieldWrapper.classList.add('has-error'); input?.classList.add('error'); // Hide checkmark (if element exists) if (success) { success.hidden = true; } //show x if (error) { error.hidden = false; } // Show error message if (message) { message.hidden = false; message.textContent = errorMessage; } } /** * Clear validation state */ clearValidation(fieldWrapper) { if (!fieldWrapper) return; const icon = fieldWrapper.querySelector('.validation-icon'); const message = fieldWrapper.querySelector('.validation-message'); const input = fieldWrapper.querySelector('input, textarea, select'); fieldWrapper.classList.remove('has-error', 'has-success'); input?.classList.remove('error'); if (icon) { icon.hidden = true; } if (message) { message.hidden = true; message.textContent = ''; } } /** * Validate all fields in a container (useful for step validation) */ validateAllFields(container) { if (!container) return true; const fields = container.querySelectorAll('.field:not([hidden])'); let allValid = true; fields.forEach(fieldWrapper => { // Skip complex parent wrappers (repeater, group) - validate their children if (this.isComplexFieldWrapper(fieldWrapper)) { return; } const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select'); if (input && !input.closest('[hidden]')) { // Mark as touched so validation will run const fieldName = fieldWrapper.dataset.field; if (fieldName) { this.touchedFields.add(fieldName); } const isValid = this.validateField(input, fieldWrapper); if (!isValid) { allValid = false; // Scroll to first error if (allValid === false) { input.scrollIntoView({ behavior: 'smooth', block: 'center' }); input.focus(); } } } }); return allValid; } /** * Check if field wrapper is a complex type (repeater, group, etc.) */ isComplexFieldWrapper(fieldWrapper) { return fieldWrapper.classList.contains('repeater') || fieldWrapper.classList.contains('group') || fieldWrapper.classList.contains('upload'); } /** * Special validation for repeater fields */ attachRepeaterValidation(form) { // When a repeater row is added, attach validation to its fields form.addEventListener('click', (e) => { if (e.target.closest('.add-repeater-row')) { // Wait for the DOM to update setTimeout(() => { const repeaterRows = form.querySelectorAll('.repeater-row'); repeaterRows.forEach(row => { const inputs = row.querySelectorAll('input, textarea, select'); inputs.forEach(input => { const fieldWrapper = this.findFieldWrapper(input); if (fieldWrapper) { // Validation listeners are already attached via event delegation // Just clear any existing validation state for new rows this.clearValidation(fieldWrapper); } }); }); }, 100); } }); } /** * Special validation for group fields */ attachGroupValidation(form) { // Group fields might have conditional fields // Validate when conditions change form.addEventListener('change', (e) => { const changedInput = e.target.closest('input, select'); if (!changedInput) return; // Check if this change affects conditional fields const fieldName = changedInput.name; if (!fieldName) return; // Find any conditional fields that depend on this field const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`); conditionalFields.forEach(conditionalField => { // Clear validation for hidden fields if (conditionalField.hidden) { this.clearValidation(conditionalField); } }); }); } /** * Reset validation state for a form */ resetForm(form) { if (!form) return; // Clear all touched fields this.touchedFields.clear(); // Clear all validation states const fields = form.querySelectorAll('.field'); fields.forEach(fieldWrapper => { this.clearValidation(fieldWrapper); }); } /** * Get validation errors for a form */ getFormErrors(form) { const errors = {}; const fields = form.querySelectorAll('.field.has-error'); fields.forEach(fieldWrapper => { const fieldName = fieldWrapper.dataset.field; const message = fieldWrapper.querySelector('.validation-message'); if (fieldName && message) { errors[fieldName] = message.textContent; } }); return errors; } /** * Add custom validator */ addValidator(name, validator) { this.validators[name] = validator; } /* ========== Auto-save functionality ========== */ /** * Get appropriate delay based on field type and context */ getDelayForField(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) { if (!formConfig.options.autosave) { return; } document.addEventListener('input', this.saveCheck, {passive: true}); const saveKey = `autosave_${formConfig.id}`; this.debouncer.schedule( saveKey, () => this.autosave(formConfig), delay ); } //Extend delay if user is currently typing saveCheck(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.showFormStatus(formConfig.id, 'saving'); await this.store.save({ formId: formConfig.id, data: formData, status: 'draft', timestamp: Date.now() }).then(()=> { this.showFormStatus(formConfig.id, 'autosaved'); }); // Get only changed fields const changes = this.getChangedFields(formConfig.data, formData); console.log('Changes:', changes); if (Object.keys(changes).length === 0) return; console.log('Continuing on:'); // 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 }); } /** * 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.data, currentData); return Object.keys(changes).length > 0; } showFormStatus(formID, status, message='') { // Remove existing status let form = this.forms.get(formID); if (!form.options.formStatus) { return; } if (form.status === status){ return; } form.status = status; console.log('Setting status: ', status); // Add new status const statusWrap = form.element.querySelector('.fstatus'); statusWrap.hidden = false; const statusElement = statusWrap.querySelector('.message'); statusElement.textContent = ''; statusWrap.querySelector('.icon')?.remove(); const messages = { 'saving': 'Saving changes...', 'autosaved': 'Changes saved locally. Submit form to send to server.', 'uploading': 'Uploading your form to server', 'submitted': 'Successfully sent to server', 'pending': 'Unsaved changes', 'error': 'Failed to save changes. Refresh and try again?', 'offline': 'Changes will be saved when online' }; const icons = { 'autosaved': 'check-circle', 'submitted': 'check-circle', 'error': 'close-circle', 'offline': 'cloud-slash', 'pending': 'exclamation-mark' } let icon = window.getIcon(icons[status]); if (icon) { statusWrap.prepend(icon); } if (message === '') { message = messages[status] || status; } statusElement.textContent = message; statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status)); // Auto-hide success messages if (status === 'submitted') { setTimeout(() => statusWrap.hidden = true, 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) { if (Object.hasOwn(form.dataset, 'timeline')) { return this.collectTimeline(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); } collectTimeline(form) { console.log('Collecting Timeline data:'); let data = {}; let posts = {}; // Temporary object keyed by post ID let postOrder = []; // Track order as encountered (preserves DOM/drag order) let formData = new FormData(form); for (const [key, value] of formData.entries()) { if (this.ignore.includes(key) || key.endsWith('_temp')) { continue; } const match = key.match(/^\[(\d+)\](.+)$/); if (match) { // Timeline-specific field: [postId]fieldName const [, postId, fieldName] = match; if (!posts[postId]) { posts[postId] = { id: parseInt(postId) }; postOrder.push(postId); // Track first occurrence } const processor = this.getFieldProcessor(fieldName); processor(fieldName, value, posts[postId], {}, {}, form); } else { // Shared field (post_title, taxonomies, etc.) const processor = this.getFieldProcessor(key); processor(key, value, data, {}, {}, form); } } // Convert to array in DOM order (matches menu_order) data.timeline = postOrder.map(id => posts[id]); delete data['form-id']; delete data['sendAll']; delete data['timeline_temp']; delete data['']; // Empty key console.log('Data: ', data); return data; } getFieldProcessor(key) { if (key.includes('|')) return this.processTableField; if (key.includes('::')) return this.processGroupField; if (key.includes(':')) return this.processRepeaterField; if (/\[[^\]]+\]/.test(key)) 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) key = key.replace('[]',''); 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) || {}; } /******************************************************* Field Summary *******************************************************/ /** * Show a comprehensive summary of form submission */ showSummary(formId, clear = 'form') { const formConfig = this.forms.get(formId); if (!formConfig) return; const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`); const summary = window.getTemplate('formSummary'); const [ title, resultWrapper, resultTemplate ] = [ summary.querySelector('h2'), summary.querySelector('.summary'), summary.querySelector('.result') ]; // Fields to skip in summary const skipFields = ['sendAll', ...this.ignore]; // Process each field in the form data for (const [key, value] of Object.entries(formConfig.data)) { // Skip ignored fields and empty values if (skipFields.includes(key) || this.isEmptyValue(value)) { continue; } // Get field info from form const fieldInfo = this.getFieldInfo(form, key); if (!fieldInfo.label) continue; // Skip if no label found // Create result element const resultEl = this.createResultElement( resultTemplate, fieldInfo, value, form ); if (resultEl) { resultWrapper.appendChild(resultEl); } } // Remove template resultTemplate.remove(); // Insert summary and hide form clear = (clear !== 'form') ? form.closest(clear)??form : form; clear.after(summary); window.fade(clear, false); } /** * Check if a value is empty (null, undefined, empty string, empty array, empty object) */ isEmptyValue(value) { if (value === null || value === undefined || value === '') { return true; } if (Array.isArray(value) && value.length === 0) { return true; } if (typeof value === 'object' && Object.keys(value).length === 0) { return true; } return false; } /** * Get field information (label, type, etc.) from the form * Handles special field name patterns ([], ::, :, etc.) */ getFieldInfo(form, fieldName) { // Try to find label by 'for' attribute (exact match) let label = form.querySelector(`label[for="${fieldName}"]`); let input = null; let fieldWrapper = null; // Try to find the input field - check multiple patterns if (!input) { // Try exact match first input = form.querySelector(`[name="${fieldName}"]`); } if (!input) { // Try with [] suffix (for checkboxes, multi-selects) input = form.querySelector(`[name="${fieldName}[]"]`); } if (!input) { // Try as fieldset legend (for checkbox/radio groups) const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`); if (fieldset) { label = fieldset.querySelector('legend'); input = fieldset.querySelector('input, select, textarea'); } } // Get label from input if not found yet if (!label && input) { // Try closest field wrapper first const field = input.closest('.field, fieldset'); if (field) { label = field.querySelector('label, legend'); } } // Get field wrapper - always use base name (no special characters) fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`); // Determine field type let fieldType = 'text'; if (fieldWrapper?.dataset.type) { fieldType = fieldWrapper.dataset.type; } else if (input) { // Infer from input type if (input.type === 'checkbox' && input.name.endsWith('[]')) { fieldType = 'checkbox'; // checkbox group } else if (input.type === 'checkbox') { fieldType = 'true_false'; // single checkbox } else if (input.tagName === 'SELECT' && input.multiple) { fieldType = 'select'; // multi-select } else { fieldType = input.type || 'text'; } } return { label: label?.textContent.replace('*', '').trim() || null, type: fieldType, wrapper: fieldWrapper, input: input }; } /** * Create a result element for a field */ createResultElement(template, fieldInfo, value, form) { const resultEl = template.cloneNode(true); const titleEl = resultEl.querySelector('h4'); const valueEl = resultEl.querySelector('p'); // Set label titleEl.textContent = fieldInfo.label; // Format value based on field type const formattedValue = this.formatFieldValue(value, fieldInfo.type, form); // Determine how to set the value if (this.isHtmlContent(formattedValue)) { // HTML content - use innerHTML valueEl.innerHTML = formattedValue; } else { // Plain text - use textContent for safety valueEl.textContent = formattedValue; } return resultEl; } /** * Check if content should be treated as HTML */ isHtmlContent(content) { return typeof content === 'string' && ( content.includes('
') || content.includes('

') || content.includes('