class FormController { constructor(config = {}) { this.config = { collectFormData: false, ... config } this.isRestoring = false; const store = window.jvbStore.register( 'forms', { storeName: 'forms', keyPath: 'formId', indexes: [ { name: 'status', keyPath: 'status' }, { name: 'operationId', keyPath: 'operationId' }, { name: 'timestamp', keyPath: 'timestamp' }, { name: 'formType', keyPath: 'type' } ], TTL: 7 * 24 * 60 * 1000, //7 days validateData: true, delayFetch: true }); this.store = store.forms; 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.blurHandler = this.handleBlur.bind(this); //Processors this.processRepeaterField = this.processRepeaterField.bind(this); this.processGroupField = this.processGroupField.bind(this); this.processLocationField = this.processLocationField.bind(this); this.processRegularField = this.processRegularField.bind(this); this.init(); } async init() { this.store.subscribe(this.handleStoreEvent.bind(this)); // Set up global form handlers for standalone forms this.initListeners(); if (window.jvbQueue) { window.jvbQueue.subscribe((event, data) => { if (event === 'operation-completed' && data.type === 'form') { this.handleOperationComplete(data); } }); } } /** * Handle operation completion - clear related form cache */ async handleOperationComplete(operation) { // Clear the form data from store if (operation.formId) { try { await this.store.delete(operation.formId); } catch (error) { console.warn('Failed to clear form cache:', error); } } // Clear any related form state const form = this.forms.get(operation.formId); if (form) { form.isDirty = false; form.lastSaved = Date.now(); form.data = {}; } } 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; } } /** * Check for pending forms from current page */ async checkPendingForms() { const allForms = await this.store.getAll(); const currentPath = window.location.pathname; const pendingForms = allForms.filter(form => { if (form.status !== 'draft') return false; // Check if form is from current page const formPath = form.data?._wp_http_referer; return formPath === currentPath; }); pendingForms.forEach(item => { const formElement = this.findFormElement(item); if (!formElement) return; // Register form if not already registered let formConfig = this.forms.get(item.formId); if (!formElement.dataset.formId) { formConfig = this.registerForm(formElement); } // Set flag to prevent event handlers from firing this.isRestoring = true; // Auto-populate the form new this.populateForm(formElement, item.data); // Reset flag after a tick (gives DOM time to settle) setTimeout(() => { this.isRestoring = false; }, 0); // Show restore status this.showFormStatus(item.formId, 'restored'); if (window.jvbA11y) { window.jvbA11y.announce('Your previous entry has been restored'); } }); } /** * Find form element that matches the cached data */ findFormElement(formData) { // Try by form_id first (hidden field) if (formData.data?.form_id) { const form = document.querySelector(`[name="form_id"][value="${formData.data.form_id}"]`)?.closest('form'); if (form) return form; } // Try by form_type if (formData.data?.form_type) { const form = document.querySelector(`[name="form_type"][value="${formData.data.form_type}"]`)?.closest('form'); if (form) return form; } // Fallback: try by formId (if it was already registered) return document.querySelector(`[data-form-id="${formData.formId}"]`); } /** * Show notification for pending changes */ /** * Show notification for pending changes */ showPendingNotification(formId, formData) { const formElement = document.querySelector(`[data-form-id="${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', async () => { await this.restorePendingForm(formId, formData); notification.remove(); }); notification.querySelector('.discard-changes').addEventListener('click', async () => { await this.discardPendingForm(formId); notification.remove(); }); } /** * Restore pending form data */ async restorePendingForm(formId, formData) { const form = document.querySelector(`[data-form-id="${formId}"]`); if (!form) return; // Populate form with cached data new this.populateForm(form, formData); // Update status in store (mark as restored, not draft) await this.store.save({ formId: formId, data: formData, status: 'restored', timestamp: Date.now() }); if (window.jvbA11y) { window.jvbA11y.announce('Previous changes restored'); } } /** * Discard pending form data */ async discardPendingForm(formId) { try { await this.store.delete(formId); if (window.jvbA11y) { window.jvbA11y.announce('Previous changes discarded'); } } catch (error) { console.error('Failed to discard pending form:', error); } } /** * 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('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, autoUpload: true, saveDelay: this.autoSaveDefaults.delay, endpoint: formElement.dataset.save ?? '', formStatus: true, cache: true, ...options }, dependencies: new Map(), data: this.collectFormData(formElement, true), }; this.initializeFormFields(formElement, formConfig); this.forms.set(formId, formConfig); // Check for pending data if (this.store && formConfig.options.cache) { const cached = this.store.get(formId); if (cached && cached.data) { this.showPendingNotification(formId, cached.data); } } return formConfig; } /** * Initialize all special fields in a form */ initializeFormFields(form, formConfig = null) { if (formConfig?.options?.isTable) { if (window.jvbSelector) { window.jvbSelector.scanExistingFields(form); } // Initialize character limits this.initCharacterLimits(form); // Initialize image upload fields this.initImageUploadFields(form, formConfig); return; } // Initialize Quill editors this.initQuillEditors(form); // Initialize repeater fields this.initRepeaterFields(form, formConfig); this.initTagListFields(form, formConfig); // Initialize conditional fields if (formConfig) { this.initConditionalFields(form, formConfig); } // Initialize character limits this.initCharacterLimits(form); // Initialize image upload fields this.initImageUploadFields(form, formConfig); // 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}`; window.prefixInput(field, `${fieldName}-${index}-`); }); 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}`; window.prefixInput(field,`${fieldName}-${index}-`); } }); }); // Schedule save if (formConfig) { this.scheduleSave(formConfig, { type: 'repeater', action: 'reorder', fieldName: fieldName, delay: this.repeaterDelays.reorder }); } } /** * Initialize tag list fields */ initTagListFields(form, formConfig) { form.querySelectorAll('.field.tag-list').forEach(field => { const inputRow = field.querySelector('.tag-input-row'); const addButton = field.querySelector('.add-tag-item'); const tagsContainer = field.querySelector('.tag-items'); const template = field.querySelector('.tag-template'); const fieldName = field.dataset.field; const tagFormat = field.dataset.tagFormat || 'first_field'; if (!inputRow || !addButton || !tagsContainer || !template) return; // Get all input fields in the input row (excluding the button) const getInputFields = () => { return Array.from(inputRow.querySelectorAll('input, select, textarea')) .filter(input => !input.closest('button')); }; // Add tag handler const addTag = () => { const inputs = getInputFields(); const data = {}; let hasValue = false; // Collect values from inputs inputs.forEach(input => { const fieldName = input.name.replace('new_', ''); const value = this.getFieldValue(input); if (value) hasValue = true; data[fieldName] = value; }); if (!hasValue) { if (window.jvbA11y) { window.jvbA11y.announce('Please fill in at least one field', 'error'); } inputs[0].focus(); return; } // Validate required fields using data-required attribute const invalidField = inputs.find(input => { const isRequired = ('required' in input.dataset && input.dataset.required === '1'); const value = this.getFieldValue(input); return isRequired && !value; }); if (invalidField) { const fieldWrapper = invalidField.closest('.field'); const fieldLabel = fieldWrapper?.querySelector('label')?.textContent || 'This field'; this.showError(fieldWrapper, `${fieldLabel} is required.`); invalidField.focus(); return; } for (let input of inputs) { let wrapper = field.closest('.field'); if (!this.validateField(input, wrapper)){ input.focus(); return; } } // Clone template and populate const index = tagsContainer.children.length; const newTag = template.content.cloneNode(true).firstElementChild; newTag.dataset.index = index; // Update tag label const tagLabel = newTag.querySelector('.tag-label'); if (tagLabel) { tagLabel.textContent = this.getTagDisplayText(data, tagFormat); } // Update hidden inputs newTag.querySelectorAll('input[type="hidden"]').forEach(input => { const fieldKey = input.dataset.field; input.name = `${fieldName}:${index}:${fieldKey}`; input.value = data[fieldKey] || ''; }); tagsContainer.appendChild(newTag); // Clear inputs inputs.forEach(input => { if (input.type === 'checkbox' || input.type === 'radio') { input.checked = false; } else { input.value = ''; } let field = input.closest('.field'); this.clearValidation(field); }); // Focus first input if (inputs.length > 0) { inputs[0].focus(); } // Schedule save if (formConfig) { this.scheduleSave(formConfig, { type: 'tag_list', action: 'add', fieldName: fieldName, delay: this.autoSaveDefaults.delay }); } if (window.jvbA11y) { window.jvbA11y.announce('Item added'); } }; // Add button click addButton.addEventListener('click', addTag); // Enter key support on last input const inputs = getInputFields(); if (inputs.length > 0) { // Tab through inputs, Enter on last one adds the tag inputs[inputs.length - 1].addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); // Enter on other inputs moves to next field inputs.slice(0, -1).forEach((input, i) => { input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); inputs[i + 1].focus(); } }); }); } // Remove tag handler tagsContainer.addEventListener('click', (e) => { if (e.target.closest('.remove-tag')) { const tag = e.target.closest('.tag-item'); const tagText = tag.querySelector('.tag-label')?.textContent || 'Item'; tag.remove(); // Reindex remaining tags this.reindexTagList(tagsContainer, fieldName); // Schedule save if (formConfig) { this.scheduleSave(formConfig, { type: 'tag_list', action: 'remove', fieldName: fieldName, delay: this.autoSaveDefaults.delay }); } if (window.jvbA11y) { window.jvbA11y.announce(`${tagText} removed`); } } }); }); } /** * Reindex tag list items */ reindexTagList(container, baseFieldName) { Array.from(container.children).forEach((tag, index) => { tag.dataset.index = index; tag.querySelectorAll('input[type="hidden"]').forEach(input => { const fieldKey = input.dataset.field; input.name = `${baseFieldName}:${index}:${fieldKey}`; }); }); } /** * Get display text for tag based on format */ getTagDisplayText(data, format) { const values = Object.values(data).filter(v => v); if (values.length === 0) return 'New Item'; switch (format) { case 'first_field': return values[0]; case 'all_fields': return values.join(', '); default: // Template format like "{name} ({email})" if (format.includes('{')) { let text = format; for (const [key, value] of Object.entries(data)) { text = text.replace(`{${key}}`, value); } return text; } // Use specific field return data[format] || values[0]; } } /** * HTML escape helper */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * 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, config) { window.jvbUploads.scanFields(form, config.options.autoUpload); } /* ========== 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); // Notify subscribers (they'll handle actual submission) this.notify('form-submit', { formId: form.dataset.formId, fullData: 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); 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) { 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); } // DELETE CACHED FORM DATA ON SUCCESS if (form.dataset.formId) { this.store.delete(form.dataset.formId).catch(err => { console.warn('Failed to clear form cache:', err); }); // Clear form config dirty state const formConfig = this.forms.get(form.dataset.formId); if (formConfig) { formConfig.isDirty = false; formConfig.lastSaved = Date.now(); formConfig.data = {}; // Clear cached data } } // 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 actionEl = window.targetCheck(e, '[data-action]'); let action = actionEl.dataset.action; let form = actionEl.closest('form'); switch (action) { case 'clear-form': if (form?.dataset.formId) { this.store.delete(form.dataset.formId); form.reset(); // Hide the status message form.querySelector('.fstatus').hidden = true; } if (window.jvbA11y) { window.jvbA11y.announce('Form cleared, starting fresh'); } break; case 'dismiss-restore': form.querySelector('.fstatus').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) { const form = event.target.form || event.target.closest('form');if (!form) return; if (!form?.dataset.formId) return; // Not our form if (!this.forms.has(form.dataset.formId)) return; if (event.target.closest('[data-ignore]') || this.isRestoring) { return; } const target = event.target; const formConfig = this.forms?.get(form.dataset.formId); if (!formConfig) return; 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); } } handleBlur(e) { if (e.target.closest('[data-ignore]') || this.isRestoring) { 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 && formConfig.autosave) { // 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') || this.isRestoring) { 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}`, () => 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 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; } /** * 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 = ''; } } /* ========== 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) { if (!formConfig.autosave) return; const formData = this.collectFormData(formConfig.element); this.showFormStatus(formConfig.id, 'saving'); // DataStore will now automatically: // - Convert Sets/Maps to Arrays/Objects // - Strip DOM references // - Validate serializability await this.store.save({ formId: formConfig.id, data: formData, status: 'draft', timestamp: Date.now() }).then(() => { this.showFormStatus(formConfig.id, 'autosaved'); }).catch(error => { console.error('Autosave failed:', error); this.showFormStatus(formConfig.id, 'error', 'Failed to save changes'); }); // 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)) { // Complex fields need full data if (typeof value === 'object') { changes[key] = value; } } // Notify 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='') { let form = this.forms.get(formID); if (!form?.options.formStatus) { return; } if (form.status === status){ return; } form.status = status; const statusWrap = form.element.querySelector('.fstatus'); statusWrap.hidden = false; const statusElement = statusWrap.querySelector('.message'); statusElement.textContent = ''; statusWrap.querySelector('.icon')?.remove(); statusWrap.querySelector('.actions')?.remove(); // Clear old actions 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', 'restored': 'Welcome back! We\'ve restored your previous entry.', 'error': 'Failed to save changes. Refresh and try again?', 'offline': 'Changes will be saved when online' }; const icons = { 'autosaved': 'check-circle', 'submitted': 'check-circle', 'restored': 'history', '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)); // Add action buttons for certain statuses if (status === 'restored') { const actions = document.createElement('div'); actions.className = 'actions'; actions.innerHTML = ` `; statusWrap.appendChild(actions); // Auto-dismiss after 10 seconds setTimeout(() => statusWrap.hidden = true, 10000); } // 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) { //Table forms are handled separately if (form.classList.contains('table') && form.tagName === 'FORM') { return {}; } 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 (Object.keys(postData).length !== 0) { data = this.mergeRepeaterData(data, repeaterData); return this.mergePostData(data, postData); } return this.mergeRepeaterData(data, repeaterData); } getFieldProcessor(key) { 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, fields] of Object.entries(postData)) { data[postId] = fields; } return data; } 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; } } /** * 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() || ''; } 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'); if (!summary) return; const wrapper = 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 let field = wrapper.cloneNode(true); let title = field.querySelector('h3'); let p = field.querySelector('p'); title.textContent = fieldInfo.label; let formatted = this.formatFieldValue(value, fieldInfo.type, form); if (this.isHtmlContent(formatted)) { p.innerHTML = formatted; } else { p.textContent = formatted; } summary.append(field); } let uploads = form.querySelectorAll('[data-upload-field]'); if (uploads) { uploads.forEach(upload => { let label = upload.querySelector('h2').textContent; let imgs = upload.querySelectorAll('.item-grid.preview img'); if (imgs) { let field = wrapper.cloneNode(true); let title = field.querySelector('h3'); let p = field.querySelector('p'); p.remove(); title.textContent = label; imgs.forEach(img => { img = img.cloneNode(true); field.append(img); }); summary.append(field); } }); } // Remove template wrapper.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; } return typeof value === 'object' && Object.keys(value).length === 0; } /** * 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 = form.querySelector(`[name=${fieldName}]`); // 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, h2'); } } // Get field wrapper - always use base name (no special characters) let 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 }; } /** * Check if content should be treated as HTML */ isHtmlContent(content) { return typeof content === 'string' && ( content.includes('') || content.includes('
${p.replace(/\n/g, '
')}