| | |
| | | /** |
| | | * Enhanced FormController - Manages forms with special fields, caching, and queue integration |
| | | * Works with DataStore for CRUD operations and standalone for front-end forms |
| | | */ |
| | | class FormController { |
| | | constructor(store = null) { |
| | | this.store = store; // Optional - for CRUD operations |
| | | if (!store) { |
| | | this.store = new window.jvbStore({name:'forms', TTL: 604800}); |
| | | } |
| | | this.debouncer = window.debouncer; |
| | | constructor() { |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | this.queue = window.jvbQueue; |
| | | this.populate = window.jvbPopulate; |
| | | |
| | | this.ignore = []; |
| | | |
| | | this.populateForm = window.jvbPopulate; |
| | | |
| | | this.subscribers = new Set(); |
| | | this.changes = new Map(); |
| | | this.forms = new Map(); |
| | | this.specialFields = new Map(); |
| | | this.inputs = new Map(); |
| | | this.repeaters = new Map(); |
| | | this.tagLists = new Map(); |
| | | this.charLimits = new Map(); |
| | | this.quantityFields = new Map(); |
| | | this.quillInstances = new Map(); // formId -> Set of quill instances |
| | | this.dependencies = new Map(); |
| | | |
| | | // Auto-save configuration |
| | | this.autoSaveDefaults = { |
| | | delay: 3000, // 3 seconds |
| | | typingDelay: 1500, // 1.5 seconds for text fields |
| | | enabled: true |
| | | }; |
| | | this.subscribers = new Set(); |
| | | |
| | | // 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.isRestoring = false; |
| | | this.hasListeners = false; |
| | | this.summaryTemplate = false; |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | | async init() { |
| | | // Check for pending operations on page load |
| | | await this.checkPendingOperations(); |
| | | |
| | | // Set up global form handlers for standalone forms |
| | | init() { |
| | | this.templates = window.jvbTemplates; |
| | | this.defineSummaryTemplate(); |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initStore(); |
| | | this.initValidators(); |
| | | } |
| | | |
| | | /** |
| | | * Check for pending operations from previous session |
| | | */ |
| | | async checkPendingOperations() { |
| | | if (!this.store) return; |
| | | try { |
| | | let pending = this.store.getAllForms(); |
| | | |
| | | } catch (error) { |
| | | console.error('Failed to load pending forms:', error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show notification for pending changes |
| | | */ |
| | | showPendingNotification(pendingData) { |
| | | const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`); |
| | | if (!formElement) return; |
| | | |
| | | const notification = document.createElement('div'); |
| | | notification.className = 'pending-changes-notification'; |
| | | notification.innerHTML = ` |
| | | <p>We noticed unsaved changes from last time. Would you like to restore them?</p> |
| | | <button class="restore-changes" data-form-id="${pendingData.formId}">Restore</button> |
| | | <button class="discard-changes" data-form-id="${pendingData.formId}">Discard</button> |
| | | `; |
| | | |
| | | formElement.insertBefore(notification, formElement.firstChild); |
| | | |
| | | // Add handlers |
| | | notification.querySelector('.restore-changes').addEventListener('click', () => { |
| | | this.restorePendingForm(pendingData); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('.discard-changes').addEventListener('click', () => { |
| | | this.discardPendingForm(pendingData.formId); |
| | | notification.remove(); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Restore pending form data |
| | | */ |
| | | restorePendingForm(pendingData) { |
| | | const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`); |
| | | if (!form) return; |
| | | |
| | | // Populate form with cached data |
| | | new this.populateForm(form, pendingData.formData); |
| | | |
| | | // Mark as restored |
| | | pendingData.status = 'restored'; |
| | | this.pendingForms.set(pendingData.formId, pendingData); |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Previous changes restored'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Discard pending form data |
| | | */ |
| | | async discardPendingForm(formId) { |
| | | this.store.clearForm(formId); |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Previous changes discarded'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Setup global handlers for standalone forms |
| | | */ |
| | | initListeners() { |
| | | // Only add if not already added |
| | | if (!this.globalHandlersAdded) { |
| | | document.addEventListener('submit', this.submitHandler); |
| | | document.addEventListener('click', this.clickHandler); |
| | | document.addEventListener('change', this.changeHandler); |
| | | document.addEventListener('focus', this.focusHandler, true); |
| | | document.addEventListener('blur', this.blurHandler, true); |
| | | this.globalHandlersAdded = true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register a standalone form (for front-end forms) |
| | | */ |
| | | registerForm(formElement, options = {}) { |
| | | const formId = formElement.dataset.formId || `form_${Date.now()}`; |
| | | formElement.dataset.formId = formId; |
| | | |
| | | const formConfig = { |
| | | element: formElement, |
| | | id: formId, |
| | | options: { |
| | | autoSave: true, |
| | | saveDelay: this.autoSaveDefaults.delay, |
| | | endpoint: formElement.dataset.save, |
| | | cache: true, |
| | | ...options |
| | | initElements() { |
| | | this.inputSelectors = 'input, textarea, select'; |
| | | this.selectors = { |
| | | tabs: { |
| | | nav: 'nav.tabs', |
| | | sections: '.tab.content', //querySelectorAll |
| | | progress: { |
| | | progress: '.progress', |
| | | fill: '.progress .fill', |
| | | details: '.progress .details', |
| | | icon: '.progress .icon' |
| | | }, |
| | | buttons: 'nav.tabs button', |
| | | }, |
| | | dependencies: new Map(), |
| | | data: this.collectFormData(formElement), |
| | | isDirty: false |
| | | dependsOn: '[data-depends-on]', |
| | | forms: { |
| | | status: { |
| | | status: '.fstatus', |
| | | message: '.fstatus .message', |
| | | icon: '.fstatus .icon', |
| | | actions: '.fstatus .actions' |
| | | } |
| | | }, |
| | | inputs: this.inputSelectors, //querySelectorAll |
| | | fields: { |
| | | field: '.field', //querySelectorAll |
| | | label: 'label', |
| | | success: '.success', |
| | | error: '.error', |
| | | message: '.validation-message', |
| | | }, |
| | | repeater: { |
| | | repeater: '.repeater', //querySelectorAll |
| | | header: '.repeater-row-header', |
| | | remove: '.remove-row', |
| | | add: '.add-repeater-row', |
| | | template: 'template', |
| | | items: '.repeater-items', |
| | | inputs: this.inputSelectors //querySelectorAll |
| | | }, |
| | | tagList: { |
| | | tagList: '.field.tag-list', //querySelectorAll |
| | | input: '.row', |
| | | add: '.add-tag', |
| | | remove: '.remove-tag', |
| | | label: '.tag-label', |
| | | items: '.tag-items', |
| | | item: '.tag-item', |
| | | inputs: this.inputSelectors, //querySelectorAll |
| | | value: 'input[type="hidden"]' //querySelectorAll |
| | | }, |
| | | tag: { |
| | | label: '.tag-label' |
| | | }, |
| | | number: { |
| | | number: '.field div.quantity', |
| | | increase: 'button.increase', |
| | | decrease: 'button.decrease', |
| | | input: 'input[type="number"]' |
| | | }, |
| | | limits: { |
| | | hasLimit: '[data-maxlength]', |
| | | limit: '.limit', |
| | | current: '.current', |
| | | } |
| | | }; |
| | | } |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | this.blurHandler = this.handleBlur.bind(this); |
| | | this.inputHandler = this.handleInput.bind(this); |
| | | this.submitHandler = this.handleSubmit.bind(this); |
| | | this.quantityClick = this.handleQuantityClick.bind(this); |
| | | this.repeaterClick = this.handleRepeaterClick.bind(this); |
| | | this.tagListClick = this.handleTagListClick.bind(this); |
| | | this.tagListInput = this.handleTagListInput.bind(this); |
| | | } |
| | | addFormListeners(form) { |
| | | form.addEventListener('click', this.clickHandler); |
| | | form.addEventListener('change', this.changeHandler); |
| | | form.addEventListener('input', this.inputHandler); |
| | | form.addEventListener('blur', this.blurHandler); |
| | | form.addEventListener('submit', this.submitHandler); |
| | | } |
| | | removeFormListeners(form) { |
| | | form.removeEventListener('click', this.clickHandler); |
| | | form.removeEventListener('change', this.changeHandler); |
| | | form.removeEventListener('input', this.inputHandler); |
| | | form.removeEventListener('blur', this.blurHandler); |
| | | form.removeEventListener('submit', this.submitHandler); |
| | | } |
| | | initStore() { |
| | | const store = window.jvbStore.register( |
| | | 'forms', |
| | | { |
| | | storeName: 'forms', |
| | | keyPath: 'id', |
| | | indexes: [ |
| | | { name: 'src', keyPath: 'src'}, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | | { name: 'formType', keyPath: 'type' } |
| | | ], |
| | | TTL: 7 * 24 * 60 * 1000, //7 days |
| | | }); |
| | | this.store = store.forms; |
| | | |
| | | // Initialize special fields |
| | | this.initializeFormFields(formElement, formConfig); |
| | | this.store.subscribe((event, data)=> { |
| | | if (event === 'data-ready') { |
| | | let stored = this.store.getFiltered(); |
| | | |
| | | // Store form config |
| | | this.forms.set(formId, formConfig); |
| | | |
| | | // Check for pending data |
| | | if (this.store && formConfig.options.cache) { |
| | | const cached = this.store.getForm(formId); |
| | | if (cached && cached.formData) { |
| | | this.showPendingNotification(cached); |
| | | let pending = stored.filter(form=> form.src === window.location.pathname); |
| | | for (let form of pending) { |
| | | this.showPendingNotification(form.id, form.changes); |
| | | } |
| | | } else if (event === 'operation-status' && data.status === 'completed') { |
| | | if (data.config) { |
| | | this.store.delete(data.config.id); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return formConfig; |
| | | }); |
| | | } |
| | | showPendingNotification(formId, changes) { |
| | | let form = this.forms.get(formId); |
| | | if (!form) return; |
| | | let element = form.element; |
| | | if (!element) { |
| | | console.warn(`Form element not found for: ${formId}`); |
| | | return; |
| | | } |
| | | |
| | | /** |
| | | * Initialize all special fields in a form |
| | | */ |
| | | initializeFormFields(form, formConfig = null) { |
| | | // Initialize Quill editors |
| | | this.initQuillEditors(form); |
| | | const notification = document.createElement('div'); |
| | | notification.className = 'pendingChanges'; |
| | | notification.innerHTML = ` |
| | | <p>We noticed unsaved changes from last time. Would you like to restore them?</p> |
| | | <button class="restore" type="button" data-form-id="${formId}">Restore</button> |
| | | <button class="discard" type="button" data-form-id="${formId}">Discard</button>`; |
| | | |
| | | // Initialize repeater fields |
| | | this.initRepeaterFields(form, formConfig); |
| | | element.insertBefore(notification, form.ui.status.status); |
| | | |
| | | // Initialize conditional fields |
| | | if (formConfig) { |
| | | this.initConditionalFields(form, formConfig); |
| | | notification.querySelector('.restore').addEventListener('click', async () => { |
| | | this.isRestoring = true; |
| | | |
| | | let theChanges = {['fields']: changes}; |
| | | this.populate.populate(element, theChanges); |
| | | this.a11y.announce('Previous changes restored'); |
| | | |
| | | this.isRestoring = false; |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('.discard').addEventListener('click', async () => { |
| | | await this.store.delete(formId); |
| | | this.a11y.announce('Previous changes discarded'); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | } |
| | | initValidators() { |
| | | this.validators = { |
| | | 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'; |
| | | |
| | | // Initialize character limits |
| | | this.initCharacterLimits(form); |
| | | const min = fieldWrapper.dataset.min; |
| | | const max = fieldWrapper.dataset.max; |
| | | |
| | | // Initialize image upload fields |
| | | this.initImageUploadFields(form); |
| | | |
| | | // Initialize tabs if present |
| | | if (window.jvbTabs && form.querySelector('nav.tabs')) { |
| | | new window.jvbTabs(form); |
| | | } |
| | | |
| | | // Scan for existing selector fields |
| | | if (window.jvbSelector) { |
| | | window.jvbSelector.scanExistingFields(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize Quill editors |
| | | */ |
| | | initQuillEditors(form) { |
| | | window.jvbQuill(form); |
| | | } |
| | | |
| | | /** |
| | | * Initialize repeater fields |
| | | */ |
| | | initRepeaterFields(form, formConfig) { |
| | | form.querySelectorAll('.repeater').forEach(repeater => { |
| | | const addButton = repeater.querySelector('.add-repeater-row'); |
| | | const container = repeater.querySelector('.repeater-items'); |
| | | const template = repeater.querySelector('template'); |
| | | |
| | | if (!addButton || !template || !container) return; |
| | | |
| | | // Initialize Sortable for drag-and-drop |
| | | if (window.Sortable) { |
| | | new Sortable(container, { |
| | | handle: '.repeater-row-header', |
| | | animation: 150, |
| | | onEnd: () => { |
| | | this.updateRepeaterOrder(repeater, formConfig); |
| | | if (min !== undefined && num < parseFloat(min)) { |
| | | return `Value must be at least ${min}`; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Add row handler |
| | | addButton.addEventListener('click', () => { |
| | | this.addRepeaterRow(repeater, formConfig); |
| | | }); |
| | | |
| | | // Remove row handlers |
| | | container.addEventListener('click', (e) => { |
| | | if (e.target.closest('.remove-row')) { |
| | | this.removeRepeaterRow(e.target.closest('.repeater-row'), formConfig); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Add repeater row |
| | | */ |
| | | addRepeaterRow(repeater, formConfig) { |
| | | const container = repeater.querySelector('.repeater-items'); |
| | | const template = repeater.querySelector('template'); |
| | | const index = container.children.length; |
| | | const fieldName = repeater.dataset.field; |
| | | |
| | | // Clone template |
| | | const row = template.content.cloneNode(true).firstElementChild; |
| | | row.dataset.index = index; |
| | | |
| | | // Update field names |
| | | row.querySelectorAll('input, select, textarea').forEach(field => { |
| | | const originalName = field.name; |
| | | field.name = `${fieldName}:${index}:${originalName}`; |
| | | field.id = `${fieldName}-${index}-${originalName}`; |
| | | |
| | | // Update label if exists |
| | | const label = field.nextElementSibling; |
| | | if (label && label.tagName === 'LABEL') { |
| | | label.htmlFor = field.id; |
| | | } |
| | | }); |
| | | |
| | | container.appendChild(row); |
| | | |
| | | // Schedule save if auto-save enabled |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'add', |
| | | fieldName: fieldName, |
| | | delay: this.repeaterDelays.add |
| | | }); |
| | | } |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Row added'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Remove repeater row |
| | | */ |
| | | removeRepeaterRow(row, formConfig) { |
| | | const repeater = row.closest('.repeater'); |
| | | const fieldName = repeater.dataset.field; |
| | | |
| | | row.remove(); |
| | | |
| | | // Reindex remaining rows |
| | | this.updateRepeaterOrder(repeater, formConfig); |
| | | |
| | | // Schedule save |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'remove', |
| | | fieldName: fieldName, |
| | | delay: this.repeaterDelays.remove |
| | | }); |
| | | } |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Row removed'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update repeater order after sorting |
| | | */ |
| | | updateRepeaterOrder(repeater, formConfig) { |
| | | const container = repeater.querySelector('.repeater-items'); |
| | | const fieldName = repeater.dataset.field; |
| | | |
| | | // Reindex all rows |
| | | Array.from(container.children).forEach((row, index) => { |
| | | row.dataset.index = index; |
| | | |
| | | // Update field names |
| | | row.querySelectorAll('input, select, textarea').forEach(field => { |
| | | const parts = field.name.split(':'); |
| | | if (parts.length === 3) { |
| | | const originalName = parts[2]; |
| | | field.name = `${fieldName}:${index}:${originalName}`; |
| | | field.id = `${fieldName}-${index}-${originalName}`; |
| | | |
| | | // Update label |
| | | const label = field.nextElementSibling; |
| | | if (label && label.tagName === 'LABEL') { |
| | | label.htmlFor = field.id; |
| | | 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; |
| | | |
| | | // Schedule save |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'reorder', |
| | | fieldName: fieldName, |
| | | delay: this.repeaterDelays.reorder |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize conditional fields |
| | | */ |
| | | initConditionalFields(form, formConfig) { |
| | | form.querySelectorAll('[data-depends-on]').forEach(field => { |
| | | const dependsOn = field.dataset.dependsOn; |
| | | const requiredValue = field.dataset.dependsValue; |
| | | const operator = field.dataset.dependsOperator || '=='; |
| | | |
| | | // Store dependency |
| | | if (!formConfig.dependencies.has(dependsOn)) { |
| | | formConfig.dependencies.set(dependsOn, []); |
| | | } |
| | | formConfig.dependencies.get(dependsOn).push({ |
| | | field: field, |
| | | requiredValue: requiredValue, |
| | | operator: operator |
| | | }); |
| | | |
| | | // Check initial state |
| | | this.checkFieldDependency(form, field, dependsOn, requiredValue, operator); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Check field dependency |
| | | */ |
| | | checkFieldDependency(form, field, dependsOn, requiredValue, operator) { |
| | | const triggerField = form.querySelector(`[name="${dependsOn}"]`); |
| | | if (!triggerField) return; |
| | | |
| | | const value = this.getFieldValue(triggerField); |
| | | const shouldShow = this.evaluateCondition(value, requiredValue, operator); |
| | | |
| | | this.toggleFieldVisibility(field, shouldShow); |
| | | } |
| | | |
| | | /** |
| | | * Evaluate conditional operator |
| | | */ |
| | | evaluateCondition(value, requiredValue, operator) { |
| | | const fieldStr = String(value || ''); |
| | | const requiredStr = String(requiredValue || ''); |
| | | |
| | | switch (operator) { |
| | | case '==': return fieldStr == requiredStr; |
| | | case '!=': return fieldStr != requiredStr; |
| | | case '>': return parseFloat(fieldStr) > parseFloat(requiredStr); |
| | | case '<': return parseFloat(fieldStr) < parseFloat(requiredStr); |
| | | case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr); |
| | | case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr); |
| | | case 'contains': return fieldStr.includes(requiredStr); |
| | | case 'empty': return fieldStr === ''; |
| | | case 'not_empty': return fieldStr !== ''; |
| | | default: return fieldStr == requiredStr; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Toggle field visibility |
| | | */ |
| | | toggleFieldVisibility(field, show) { |
| | | const wrapper = field.closest('.field, fieldset'); |
| | | if (!wrapper) return; |
| | | |
| | | wrapper.hidden = !show; |
| | | wrapper.querySelectorAll('input, select, textarea').forEach(control => { |
| | | control.disabled = !show; |
| | | if (!show && control.hasAttribute('required')) { |
| | | control.dataset.wasRequired = 'true'; |
| | | control.removeAttribute('required'); |
| | | } else if (show && control.dataset.wasRequired === 'true') { |
| | | control.setAttribute('required', ''); |
| | | delete control.dataset.wasRequired; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Initialize character limits |
| | | */ |
| | | initCharacterLimits(form) { |
| | | form.querySelectorAll('[data-limit]').forEach(input => { |
| | | const limit = parseInt(input.dataset.limit, 10); |
| | | const field = input.closest('.field'); |
| | | |
| | | // Create counter if it doesn't exist |
| | | let counter = field?.querySelector('.char-count'); |
| | | if (!counter && field) { |
| | | counter = document.createElement('div'); |
| | | counter.className = 'char-count'; |
| | | counter.innerHTML = `<span class="current">0</span> / <span class="limit">${limit}</span>`; |
| | | 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; |
| | | 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; |
| | | } |
| | | }; |
| | | |
| | | input.addEventListener('input', updateCount); |
| | | updateCount(); // Initial count |
| | | }); |
| | | } |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Initialize image upload fields |
| | | */ |
| | | initImageUploadFields() { |
| | | window.jvbUploads.scanFields(); |
| | | validateField(input) { |
| | | const result = this.performValidation(input); |
| | | this.updateValidationUI(input, result); |
| | | return result.isValid; |
| | | } |
| | | performValidation(input) { |
| | | const field = input.closest('.field'); |
| | | const value = this.getFieldCheckedValue(input); |
| | | |
| | | /* ========== Event Handlers ========== */ |
| | | if (!value && !input.required) { |
| | | return { isValid: true, message: '' }; |
| | | } |
| | | |
| | | handleSubmit(event) { |
| | | if (this.subscribers.size > 0 ){ |
| | | const form = event.target; |
| | | if (!form.dataset.formId) return; |
| | | if (input.required) { |
| | | if (input.type === 'checkbox') { |
| | | if (!input.checked) { |
| | | return { isValid: false, message: 'This field is required' }; |
| | | } |
| | | } else if (input.type === 'radio') { |
| | | const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); |
| | | const anyChecked = Array.from(radioGroup).some(r => r.checked); |
| | | if (!anyChecked) { |
| | | return { isValid: false, message: 'Please select an option' }; |
| | | } |
| | | } else if (!value) { |
| | | return { isValid: false, message: 'This field is required' }; |
| | | } |
| | | } |
| | | |
| | | event.preventDefault(); |
| | | if(input.checkValidity && !input.checkValidity()){ |
| | | return {isValid: false, message: input.validationMessage}; |
| | | } |
| | | |
| | | const formConfig = this.forms.get(form.dataset.formId); |
| | | if (!formConfig) return; |
| | | if (value && Object.hasOwn(field.dataset, 'pattern')) { |
| | | const regex = new RegExp(field.dataset.pattern); |
| | | if (!regex.test(value)) { |
| | | return {isValid: false, message: field.dataset.validationMessage || 'Invalid format'}; |
| | | } |
| | | } |
| | | |
| | | const formData = this.collectFormData(form); |
| | | if (Object.hasOwn(field.dataset, 'validate') || input.type) { |
| | | const validator = this.validators[field.dataset.validate||input.type]; |
| | | |
| | | event.preventDefault(); |
| | | this.notify('form-submit', { |
| | | formId: formConfig.id, |
| | | data: formData, |
| | | config: formConfig |
| | | }); |
| | | if (validator && validator.pattern && !validator.pattern.test(value)) { |
| | | return {isValid: false, message: validator.message}; |
| | | } |
| | | |
| | | if (validator && validator.test) { |
| | | const result = validator.test(value, field); |
| | | if (result !== true) { |
| | | return {isValid: false, message: result}; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return {isValid: true, message: ''}; |
| | | } |
| | | updateValidationUI(input, result) { |
| | | if (result.isValid) { |
| | | this.showSuccess(input, result.message); |
| | | } else { |
| | | this.showError(input, result.message); |
| | | } |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (window.targetCheck(e, 'div.quantity')) { |
| | | let container = window.targetCheck(e, 'div.quantity'); |
| | | this.handleNumberClick(e, container.querySelector('input')); |
| | | } |
| | | } |
| | | |
| | | handleNumberClick(e, input) { |
| | | let change = 0; |
| | | |
| | | if (e.target.closest('.increase')) { |
| | | change += 1; |
| | | } else if (e.target.closest('.decrease')) { |
| | | change -=1; |
| | | } |
| | | if (change !== 0) { |
| | | let step = parseFloat(input.step); |
| | | //Allow for cents, but default to increasing by 1 |
| | | step = Math.max(step, 1); |
| | | |
| | | if(e.ctrlKey && e.shiftKey) { |
| | | step = step * 50; |
| | | } else if (e.ctrlKey) { |
| | | step = step * 5; |
| | | } else if (e.shiftKey) { |
| | | step = step * 10; |
| | | } |
| | | |
| | | let value = (input.value === '') ? 0 : parseFloat(input.value); |
| | | |
| | | input.value = (value + (step * change)); |
| | | this.handleNumberLimits(input); |
| | | } |
| | | } |
| | | |
| | | handleNumberLimits(input) { |
| | | let [ |
| | | min, |
| | | max, |
| | | increase, |
| | | decrease |
| | | ] = [ |
| | | input.min, |
| | | input.max, |
| | | input.closest('.quantity')?.querySelector('.increase'), |
| | | input.closest('.quantity')?.querySelector('.decrease') |
| | | ]; |
| | | let value = parseFloat(input.value); |
| | | if (value < min) { |
| | | input.value = min; |
| | | decrease.disabled = true; |
| | | } else if (value > max) { |
| | | input.value = max; |
| | | increase.disabled = false; |
| | | } else if (increase.disabled) { |
| | | increase.disabled = false; |
| | | } else if (decrease.disabled) { |
| | | decrease.disabled = false; |
| | | } |
| | | } |
| | | |
| | | handleChange(event) { |
| | | if (this.subscribers.size > 0) { |
| | | const target = event.target; |
| | | const form = target.form || target.closest('form'); |
| | | |
| | | if (!form) return; |
| | | |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (!formConfig) return; |
| | | |
| | | // Check conditional fields |
| | | const dependencies = formConfig.dependencies.get(target.name); |
| | | if (dependencies) { |
| | | dependencies.forEach(dep => { |
| | | this.checkFieldDependency(form, dep.field, target.name, dep.requiredValue, dep.operator); |
| | | }); |
| | | } |
| | | |
| | | // Schedule auto-save if enabled |
| | | if (formConfig.options.autoSave && !form.dataset.noautosave) { |
| | | const delay = this.getDelayForField(target); |
| | | this.scheduleSave(formConfig, delay); |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleFocus(event) { |
| | | const target = event.target; |
| | | if (target.matches('input, textarea, select')) { |
| | | // Track focus for better UX |
| | | this.currentFocus = target; |
| | | } |
| | | } |
| | | |
| | | handleBlur(event) { |
| | | const target = event.target; |
| | | const form = target.form || target.closest('form'); |
| | | |
| | | let form = this.getForm(e.target); |
| | | if (!form) return; |
| | | |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) { |
| | | // Shorter delay on blur |
| | | this.scheduleSave(formConfig, { |
| | | type: 'blur', |
| | | fieldName: target.name, |
| | | delay: 1500 |
| | | const itemAction = window.targetCheck(e, '[data-action]'); |
| | | if (itemAction) { |
| | | let action = itemAction.dataset.action; |
| | | switch (action) { |
| | | case 'clear-form': |
| | | this.store.delete(form.id); |
| | | form.element.reset(); |
| | | form.ui.status.status.hidden = true; |
| | | this.a11y.announce('Form cleared, starting fresh'); |
| | | break; |
| | | case 'dismiss-restore': |
| | | form.ui.status.status.hidden = true; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleChange(e) { |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | |
| | | // Check if this input lives inside a collection field |
| | | const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]'); |
| | | if (collectionField) { |
| | | // Dependencies still need checking |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | | dependency.items.forEach(item => { |
| | | this.checkFieldDependency(item, field.dataset.field); |
| | | }); |
| | | } |
| | | const collectionName = collectionField.dataset.field; |
| | | window.debouncer.schedule( |
| | | `collection:${collectionName}`, |
| | | () => this.updateCollectionField(collectionField), |
| | | 150 |
| | | ); |
| | | return; |
| | | } |
| | | |
| | | //Dependencies |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | | dependency.items.forEach(item => { |
| | | this.checkFieldDependency(item, field.dataset.field); |
| | | }); |
| | | } |
| | | |
| | | let form = this.getForm(e.target); |
| | | this.updateItem(field.dataset.field, this.getFieldValue(e.target), form); |
| | | } |
| | | |
| | | /* ========== Auto-save functionality ========== */ |
| | | /** |
| | | * Get appropriate delay based on field type and context |
| | | */ |
| | | getDelayForField(field) { |
| | | console.log('Get Delay for Field', field); |
| | | // Text fields get longer delay for typing |
| | | if (field.type === 'text' || field.type === 'textarea') { |
| | | return this.autoSaveDefaults.typingDelay; |
| | | handleBlur(e) { |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) return; |
| | | let form = this.getForm(e.target); |
| | | if (!form) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | let fieldName = field.dataset.field; |
| | | window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`); |
| | | this.validateField(e.target); |
| | | |
| | | // If inside a collection, update the whole collection instead |
| | | const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]'); |
| | | if (collectionField) { |
| | | this.updateCollectionField(collectionField); |
| | | return; |
| | | } |
| | | |
| | | // Checkboxes, radios, selects get shorter delay |
| | | if (['checkbox', 'radio', 'select-one', 'select-multiple'].includes(field.type)) { |
| | | return 1000; |
| | | } |
| | | |
| | | // Default delay |
| | | return this.autoSaveDefaults.delay; |
| | | this.updateItem(fieldName, this.getFieldValue(e.target), form); |
| | | } |
| | | scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) { |
| | | document.addEventListener('input', this.handleInput, {passive: true}); |
| | | const saveKey = `autosave_${formConfig.id}`; |
| | | |
| | | this.debouncer.schedule( |
| | | saveKey, |
| | | () => this.autosave(formConfig), |
| | | delay |
| | | handleInput(e){ |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) return; |
| | | let form = this.getForm(e.target); |
| | | if (!form) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | if (!field) return; |
| | | |
| | | const input = e.target; // Capture reference |
| | | const fieldName = field.dataset.field; |
| | | |
| | | // Show pending status regardless of cache |
| | | this.showFormStatus(form.id, 'pending'); |
| | | |
| | | // Debounce validation |
| | | window.debouncer.schedule( |
| | | `form:${form.id}:validate:${fieldName}`, |
| | | () => this.validateField(input), |
| | | 500 |
| | | ); |
| | | } |
| | | |
| | | //Extend delay if user is currently typing |
| | | handleInput(e) { |
| | | let form = e.target.closest('form[data-id]'); |
| | | if (!form) { |
| | | return; |
| | | } |
| | | this.scheduleSave(this.forms.get(form.dataset.id)); |
| | | } |
| | | async handleSubmit(e) { |
| | | let form = this.getForm(e.target); |
| | | if (!form) return; |
| | | |
| | | async autosave(formConfig) { |
| | | const formData = this.collectFormData(formConfig.element); |
| | | this.cacheFormData(formConfig, formData); |
| | | if (this.subscribers.size > 0) { |
| | | e.preventDefault(); |
| | | |
| | | // Get only changed fields |
| | | const changes = this.getChangedFields(formConfig.data, formData); |
| | | if (Object.keys(changes).length === 0) return; |
| | | if (form.options.cache) { |
| | | this.cancelBackup(); |
| | | await this.backup(); |
| | | const storedData = await this.store.get(form.id); |
| | | |
| | | // 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; |
| | | this.notify('form-submit', { |
| | | config: form, |
| | | data: storedData.changes |
| | | }); |
| | | } else { |
| | | this.notify('form-submit', { |
| | | config: form, |
| | | data: this.changes.get(form.id)?.changes??{}, |
| | | }); |
| | | } |
| | | } |
| | | // Notify instead of callback |
| | | this.notify('form-autosave', { |
| | | formId: formConfig.id, |
| | | changes: changes, |
| | | fullData: formData, |
| | | config: formConfig |
| | | }); |
| | | } |
| | | |
| | | cacheFormData(formConfig, formData) { |
| | | try { |
| | | this.store.storeForm(formConfig.id, { |
| | | formId: formConfig.id, |
| | | formData: formData, |
| | | timestamp: Date.now(), |
| | | status: 'pending', |
| | | operationId: null |
| | | }); |
| | | } catch (error) { |
| | | console.error('Failed to cache form data:', error); |
| | | } |
| | | |
| | | if (form.options.showSummary) { |
| | | const storedData = await this.store.get(form.id); |
| | | this.showSummary({config: form, changes: storedData?.changes}); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if form has unsaved changes |
| | | * Updates the item, schedules caching if |
| | | * @param name |
| | | * @param value |
| | | * @param form |
| | | */ |
| | | hasUnsavedChanges(formId) { |
| | | const formConfig = this.forms.get(formId); |
| | | if (!formConfig) return false; |
| | | |
| | | // Check if there are pending operations |
| | | if (formConfig.operations?.size > 0) return true; |
| | | |
| | | // Check if current data differs from snapshot |
| | | const currentData = this.collectFormData(formConfig.element); |
| | | const changes = this.getChangedFields(formConfig.lastSnapshot, currentData); |
| | | |
| | | return Object.keys(changes).length > 0; |
| | | updateItem(name, value, form) { |
| | | if (!this.changes.has(form.id)) { |
| | | this.changes.set(form.id, { |
| | | id: form.id, |
| | | timestamp: Date.now(), |
| | | src: window.location.pathname, |
| | | changes: {}, |
| | | }); |
| | | } |
| | | let changes = this.changes.get(form.id); |
| | | changes.changes[name] = value; |
| | | this.changes.set(form.id, changes); |
| | | if (form.options.cache) { |
| | | this.scheduleBackup(); |
| | | } |
| | | } |
| | | |
| | | showFormStatus(form, status) { |
| | | // Remove existing status |
| | | const existingStatus = form.querySelector('.form-status'); |
| | | if (existingStatus) { |
| | | existingStatus.remove(); |
| | | scheduleBackup() { |
| | | window.debouncer.schedule( |
| | | `form_changes`, |
| | | async () => { |
| | | if (this.changes.size > 0) { |
| | | await this.backup(); |
| | | } |
| | | }, |
| | | 2000 |
| | | ); |
| | | } |
| | | cancelBackup() { |
| | | window.debouncer.cancel('form_changes'); |
| | | } |
| | | async backup() { |
| | | // Merge with existing stored data |
| | | const toSave = new Map(); |
| | | |
| | | for (let [formId, newData] of this.changes.entries()) { |
| | | const stored = await this.store.get(formId); |
| | | |
| | | if (stored) { |
| | | // Merge changes |
| | | toSave.set(formId, { |
| | | ...stored, |
| | | ...newData, |
| | | changes: { |
| | | ...stored.changes, |
| | | ...newData.changes |
| | | }, |
| | | timestamp: Date.now() |
| | | }); |
| | | } else { |
| | | toSave.set(formId, newData); |
| | | } |
| | | } |
| | | |
| | | // Add new status |
| | | const statusElement = document.createElement('div'); |
| | | statusElement.className = `form-status status-${status}`; |
| | | await this.store.saveMany(toSave); |
| | | |
| | | const messages = { |
| | | 'saving': 'Saving changes...', |
| | | 'saved': 'Changes saved', |
| | | 'error': 'Failed to save changes', |
| | | 'offline': 'Changes will be saved when online' |
| | | for (let formId of this.changes.keys()) { |
| | | this.showFormStatus(formId, 'autosaved'); |
| | | } |
| | | this.changes.clear(); |
| | | } |
| | | |
| | | saveCache(formId) { |
| | | if (!this.changes.has(formId)) return; |
| | | let changes = this.changes.get(formId); |
| | | if (changes.size === 0) return; |
| | | this.store.save(changes).then(()=>{}); |
| | | this.changes.delete(formId); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Register a form for handling |
| | | * @param {HTMLElement} form |
| | | * @param {object} options |
| | | */ |
| | | registerForm(form, options) { |
| | | //Bail if form already registered |
| | | if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return; |
| | | |
| | | if (!Object.hasOwn(form.dataset, 'formId')) { |
| | | form.dataset.formId = window.generateID('form_'); |
| | | } |
| | | const formId = form.dataset.formId; |
| | | |
| | | this.addFormListeners(form); |
| | | |
| | | const config = { |
| | | element: form, |
| | | id: formId, |
| | | status: '', |
| | | options: { |
| | | autoUpload: options.autoUpload??false, |
| | | imageMeta: options.imageMeta??true, |
| | | delay: options.delay??1500, |
| | | endpoint: options.save??form.dataset.save??'', |
| | | showStatus: options.showStatus??true, |
| | | showSummary: options.showSummary??false, |
| | | cache: options.cache??true, |
| | | ignore: options.ignore??[] |
| | | }, |
| | | ui: window.uiFromSelectors(this.selectors.forms, form) |
| | | }; |
| | | |
| | | statusElement.textContent = messages[status] || status; |
| | | form.insertBefore(statusElement, form.firstChild); |
| | | this.initializeFields(form, config); |
| | | this.forms.set(formId, config); |
| | | |
| | | // Auto-hide success messages |
| | | if (status === 'saved') { |
| | | setTimeout(() => statusElement.remove(), 3000); |
| | | } |
| | | return config; |
| | | } |
| | | clearForm(formId) { |
| | | const config = this.forms.get(formId); |
| | | if (!config) return; |
| | | |
| | | 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(); |
| | | if (config.unsubscribeTabs) { |
| | | config.unsubscribeTabs(); |
| | | } |
| | | if(config.tabs) { |
| | | window.jvbTabs.removeTab(config.element); |
| | | } |
| | | |
| | | if (config.cache && this.changes.has(formId)) this.saveCache(formId); |
| | | |
| | | // Cleanup items |
| | | for (let [id, input] of this.inputs.entries()) { |
| | | if (input.form === formId) { |
| | | this.inputs.delete(id); |
| | | } |
| | | } |
| | | }); |
| | | // Clean up dependencies for this form |
| | | this.dependencies.forEach((dependency, fieldName) => { |
| | | dependency.items = dependency.items.filter(item => item.form !== formId); |
| | | |
| | | this.uploader?.destroy(); |
| | | |
| | | this.specialFields.clear(); |
| | | } |
| | | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form) { |
| | | const formData = new FormData(form); |
| | | let data = {}; |
| | | const repeaterData = {}; |
| | | const postData = {}; |
| | | |
| | | for (let [key, value] of formData.entries()) { |
| | | if (this.ignore.includes(key) || key.endsWith('_temp')) continue; |
| | | |
| | | const processor = this.getFieldProcessor(key); |
| | | processor(key, value, data, repeaterData, postData, form); |
| | | } |
| | | if (!window.isEmptyObject(postData)) { |
| | | data = this.mergeRepeaterData(data, repeaterData); |
| | | return this.mergePostData(data, postData); |
| | | } |
| | | return this.mergeRepeaterData(data, repeaterData); |
| | | } |
| | | |
| | | getFieldProcessor(key) { |
| | | if (key.includes('|')) return this.processTableField; |
| | | if (key.includes('::')) return this.processGroupField; |
| | | if (key.includes(':')) return this.processRepeaterField; |
| | | if (key.includes('[')) return this.processLocationField; |
| | | return this.processRegularField; |
| | | } |
| | | |
| | | mergeRepeaterData(data, repeaterData) { |
| | | Object.keys(repeaterData).forEach(fieldName => { |
| | | // Clean up empty rows and convert to array format |
| | | const cleanedRows = {}; |
| | | Object.keys(repeaterData[fieldName]).forEach(index => { |
| | | const rowData = repeaterData[fieldName][index]; |
| | | if (Object.keys(rowData).length > 0) { |
| | | cleanedRows[index] = rowData; |
| | | // Remove the dependency entry entirely if no items left |
| | | if (dependency.items.length === 0) { |
| | | this.dependencies.delete(fieldName); |
| | | } |
| | | }); |
| | | |
| | | // Convert to sequential array |
| | | data[fieldName] = Object.values(cleanedRows); |
| | | if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) { |
| | | const instances = this.quillInstances.get(formId); |
| | | instances.forEach(quillInstance => { |
| | | // Disable the editor |
| | | quillInstance.disable(); |
| | | |
| | | // Remove all event listeners |
| | | quillInstance.off('text-change'); |
| | | quillInstance.off('selection-change'); |
| | | |
| | | // Get the container elements |
| | | const container = quillInstance.container.parentElement; |
| | | const toolbar = container?.querySelector('.ql-toolbar'); |
| | | |
| | | // Remove toolbar |
| | | if (toolbar) { |
| | | toolbar.remove(); |
| | | } |
| | | |
| | | // Clear the editor content |
| | | quillInstance.setText(''); |
| | | |
| | | // Remove container |
| | | if (container && container.classList.contains('editor-container')) { |
| | | const textarea = container.nextElementSibling; |
| | | if (textarea?.tagName === 'TEXTAREA') { |
| | | textarea.style.display = ''; |
| | | } |
| | | container.remove(); |
| | | } |
| | | }); |
| | | |
| | | this.quillInstances.delete(formId); |
| | | } |
| | | let checks = { |
| | | repeater: this.repeaters, |
| | | tagList: this.tagLists, |
| | | charLimit: this.charLimits, |
| | | quantity: this.quantityFields |
| | | }; |
| | | for (let [type, check] of Object.entries(checks)) { |
| | | if (check.size === 0) continue; |
| | | let hasAny = Array.from(check.values()).filter(item => item.form === formId); |
| | | if (hasAny.length > 0) { |
| | | hasAny.forEach(item => { |
| | | switch (type) { |
| | | case 'repeater': |
| | | this.removeRepeaterListeners(item.element); |
| | | break; |
| | | case 'tagList': |
| | | this.removeTagListListeners(item.element); |
| | | break; |
| | | case 'charLimit': |
| | | this.removeCharacterLimitListeners(item.element); |
| | | break; |
| | | case 'quantity': |
| | | this.removeQuantityListeners(item.element); |
| | | break; |
| | | } |
| | | |
| | | if (check.has(item.id)) { |
| | | check.delete(item.id); |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | |
| | | this.removeFormListeners(config.element); |
| | | this.forms.delete(formId); |
| | | |
| | | window.debouncer.cancel(`form_changes`); |
| | | } |
| | | defineSummaryTemplate() { |
| | | this.summaryTemplate = true; |
| | | let form = this; |
| | | this.templates.define( |
| | | 'formSummary', |
| | | { |
| | | refs: { |
| | | result: '.result', |
| | | h3: 'h3', |
| | | p: 'p', |
| | | }, |
| | | setup({ el, refs, manyRefs, data }) { |
| | | const skipFields = ['sendAll', ...data.config.options.ignore??[]]; |
| | | |
| | | for (let [key, value] of Object.entries(data.changes)) { |
| | | if (skipFields.includes(key) || form.isEmptyValue(value)) continue; |
| | | |
| | | let input = Array.from(form.inputs.values()) |
| | | .find(temp => temp.field?.dataset.field === key); |
| | | if (!input) continue; |
| | | |
| | | let entry = refs.result.cloneNode(true); |
| | | let title = entry.querySelector('h3'); |
| | | let p = entry.querySelector('p'); |
| | | |
| | | // Get field label - prioritize legend for fieldsets, then label |
| | | const legend = input.field?.querySelector('legend'); |
| | | title.textContent = legend |
| | | ? legend.textContent.replace('*', '').trim() |
| | | : input.ui.label?.textContent.replace('*', '').trim(); |
| | | |
| | | |
| | | const formattedValue = form.formatValueForSummary(value, input); |
| | | |
| | | if (formattedValue instanceof HTMLElement) { |
| | | // If it's an HTML element (repeater, tag-list, etc.), replace <p> |
| | | p.replaceWith(formattedValue); |
| | | } else { |
| | | // If it's a string, set text content |
| | | p.textContent = formattedValue; |
| | | } |
| | | |
| | | el.append(entry); |
| | | } |
| | | let uploads = data.config?.element?.querySelectorAll('[data-upload-field]'); |
| | | if (uploads) { |
| | | uploads.forEach(upload => { |
| | | let label = upload.querySelector('h2')?.textContent??'Upload:'; |
| | | let imgs = upload.querySelectorAll('.item-grid.preview img'); |
| | | let field = refs.result.cloneNode(true); |
| | | if (imgs) { |
| | | let entry = refs.result.cloneNode(true); |
| | | let title = field.querySelector('h3'); |
| | | let p = field.querySelector('p'); |
| | | p?.remove(); |
| | | if (title) title.textContent = label; |
| | | imgs.forEach(img => { |
| | | img = img.cloneNode(true); |
| | | entry.append(img); |
| | | }); |
| | | el.append(entry); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | refs.result?.remove(); |
| | | data.config.element.after(el); |
| | | window.fade(data.config.element, false); |
| | | } |
| | | } |
| | | ); |
| | | } |
| | | |
| | | |
| | | initializeFields(container, config = null) { |
| | | const fieldHandlers = { |
| | | '[data-editor]': () => this.checkForQuill(container,config), |
| | | 'div.quantity': () => this.checkForQuantity(container), |
| | | '.repeater': () => this.checkForRepeaters(container, config), |
| | | '.field.tag-list': () => this.checkForTagLists(container), |
| | | '[data-depends-on]': () => this.checkForConditionalFields(container), |
| | | '[data-limit]': () => this.checkForCharacterLimits(container), |
| | | '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config), |
| | | 'nav.tabs': () => this.checkForTabs(container, config), |
| | | '[data-type="selector"]': () => this.checkForSelectors(container) |
| | | }; |
| | | |
| | | for (const [selector, handler] of Object.entries(fieldHandlers)) { |
| | | if (container.querySelector(selector)) { |
| | | handler(); |
| | | } |
| | | } |
| | | |
| | | let inputs = Array.from(container.querySelectorAll(this.inputSelectors)) |
| | | .filter(input => !input.closest('.ql-clipboard')); |
| | | inputs.map(input => { |
| | | this.getItem(input, config?.id); |
| | | }); |
| | | } |
| | | checkForQuill(form, config) { |
| | | if (!form.querySelector('[data-editor]')) return; |
| | | if (config && !Object.hasOwn(config, 'hasQuill')){ |
| | | config.hasQuill = true; |
| | | this.forms.set(config.id, config); |
| | | } |
| | | |
| | | if (!this.quillInstances.has(config.id)) { |
| | | this.quillInstances.set(config.id, new Set()); |
| | | } |
| | | |
| | | const instances = window.jvbQuill(form); |
| | | instances.forEach(instance => { |
| | | this.quillInstances.get(config.id).add(instance); |
| | | }); |
| | | } |
| | | checkForQuantity(form) { |
| | | if (!form.querySelector(this.selectors.number.number)) return; |
| | | form.querySelectorAll(this.selectors.number.number).forEach(num => { |
| | | let config = { |
| | | id: window.generateID('quant'), |
| | | form: form.dataset.formId, |
| | | ui: window.uiFromSelectors(this.selectors.number, num), |
| | | element: num |
| | | }; |
| | | num.dataset.numId = config.id; |
| | | this.quantityFields.set(config.id, config); |
| | | this.addQuantityListeners(num); |
| | | }); |
| | | } |
| | | addQuantityListeners(el) { |
| | | el.addEventListener('click', this.quantityClick); |
| | | } |
| | | removeQuantityListeners(el) { |
| | | el.removeEventListener('click', this.quantityClick); |
| | | } |
| | | handleQuantityClick(e) { |
| | | let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId); |
| | | if(!conf) return; |
| | | let change = 0; |
| | | if (conf.ui.increase.contains(e.target)) { |
| | | change++; |
| | | } else if (conf.ui.decrease.contains(e.target)) { |
| | | change--; |
| | | } |
| | | if (change === 0) return; |
| | | let field = this.getField(e.target); |
| | | let step = conf.ui.input.step; |
| | | 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 = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value); |
| | | conf.ui.input.value = (value + (step * change)); |
| | | |
| | | value = parseFloat(conf.ui.input.value); |
| | | |
| | | if (conf.ui.input.min && value < conf.ui.input.min) { |
| | | conf.ui.input.value = conf.ui.input.min; |
| | | conf.ui.decrease.disabled = true; |
| | | } else if (conf.ui.input.max && value > conf.ui.input.max) { |
| | | conf.ui.input.value = conf.ui.input.max; |
| | | conf.ui.increase.disabled = true; |
| | | } else { |
| | | if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false; |
| | | if (conf.ui.increase.disabled) conf.ui.increase.disabled = false; |
| | | } |
| | | } |
| | | checkForRepeaters(form) { |
| | | |
| | | if (!form.querySelector(this.selectors.repeater.repeater)) return; |
| | | |
| | | form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => { |
| | | let config = { |
| | | id: repeater.querySelector('template').className??window.generateID('repeater'), |
| | | ui: window.uiFromSelectors(this.selectors.repeater, repeater), |
| | | form: form.dataset.formId, |
| | | element: repeater, |
| | | field: this.getField(repeater), |
| | | sortable: false, |
| | | rows: [] |
| | | }; |
| | | |
| | | if (!config.ui.add) return; |
| | | |
| | | let template = repeater.querySelector('template'); |
| | | this.templates.define( |
| | | template.className, |
| | | { |
| | | manyRefs: { |
| | | inputs: this.inputSelectors, |
| | | }, |
| | | setup({el, refs, manyRefs, data}) { |
| | | let index = config.ui.items?.children?.length??0; |
| | | el.dataset.index = index; |
| | | |
| | | manyRefs.inputs?.forEach(input => { |
| | | window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el, false, true); |
| | | }); |
| | | } |
| | | }, |
| | | ); |
| | | |
| | | if (window.Sortable) { |
| | | config.sortable = new Sortable(repeater, { |
| | | handle: this.selectors.repeater.header, |
| | | animation: 150, |
| | | onEnd: () => { |
| | | this.reindexList(repeater); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | repeater.dataset.repeaterId = config.id; |
| | | this.addRepeaterListeners(repeater); |
| | | this.repeaters.set(config.id, config); |
| | | }); |
| | | |
| | | } |
| | | addRepeaterListeners(el) { |
| | | el.addEventListener('click', this.repeaterClick); |
| | | } |
| | | removeRepeaterListeners(el) { |
| | | el.removeEventListener('click', this.repeaterClick); |
| | | } |
| | | handleRepeaterClick(e) { |
| | | if (e.target.matches(this.selectors.repeater.add)) { |
| | | this.addRepeaterRow(e.target.closest('[data-repeater-id]')); |
| | | } else if (e.target.matches(this.selectors.repeater.remove)) { |
| | | this.removeRepeaterRow(e.target.closest('[data-index]')); |
| | | } |
| | | } |
| | | addRepeaterRow(repeater) { |
| | | let data = {}; |
| | | data.repeater = repeater; |
| | | let config = this.repeaters.get(repeater.dataset.repeaterId); |
| | | |
| | | let row = this.templates.create(repeater.dataset.repeaterId, data); |
| | | config.rows.push({ |
| | | element: row, |
| | | fields: Array.from(row.querySelectorAll('[data-field]')) |
| | | }); |
| | | this.repeaters.set(config.id, config); |
| | | config.ui.items.append(row); |
| | | |
| | | let form = this.getForm(repeater); |
| | | this.initializeFields(repeater, form); |
| | | this.a11y.announce('Row added'); |
| | | } |
| | | removeRepeaterRow(row) { |
| | | let repeater = row.closest('[data-repeater-id]'); |
| | | row.remove(); |
| | | this.reindexList(repeater); |
| | | this.a11y.announce('Row removed'); |
| | | } |
| | | checkForTagLists(form) { |
| | | form.querySelectorAll(this.selectors.tagList.tagList)?.forEach(field=> { |
| | | let config = { |
| | | id: field.querySelector('template').className??window.generateID('tagList'), |
| | | ui: window.uiFromSelectors(this.selectors.tagList, field), |
| | | element: field, |
| | | form: form.dataset.formId, |
| | | format: field.dataset.tagFormat??'first_field' |
| | | }; |
| | | if (!config.ui.input || !config.ui.add || !config.ui.items) return; |
| | | |
| | | field.dataset.tagListId = config.id; |
| | | config.fieldName = field.dataset.field; |
| | | |
| | | let template = field.querySelector('template'); |
| | | this.templates.define( |
| | | template.className, |
| | | { |
| | | refs: { |
| | | label: this.selectors.tagList.label, |
| | | }, |
| | | manyRefs: { |
| | | inputs: this.inputSelectors, |
| | | }, |
| | | setup({el, refs, manyRefs, data}) { |
| | | let index = config.ui.items?.children?.length??0; |
| | | el.dataset.index = index; |
| | | manyRefs.inputs?.forEach(input => { |
| | | let wrapper = input.closest('.tag-item'); |
| | | window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true) |
| | | }); |
| | | |
| | | if (refs.label) { |
| | | refs.label.textContent = data.label; |
| | | } |
| | | } |
| | | }, |
| | | ); |
| | | config.ui.inputs = Array.from(field.querySelectorAll(this.selectors.tagList.inputs)); |
| | | config.ui.value = Array.from(field.querySelectorAll(this.selectors.tagList.value)); |
| | | this.tagLists.set(config.id, config); |
| | | this.addTagListListeners(field); |
| | | }); |
| | | |
| | | } |
| | | addTagListListeners(el) { |
| | | el.addEventListener('click', this.tagListClick); |
| | | el.addEventListener('keypress', this.tagListInput); |
| | | } |
| | | removeTagListListeners(el) { |
| | | el.removeEventListener('click', this.tagListClick); |
| | | el.removeEventListener('keypress', this.tagListInput); |
| | | } |
| | | |
| | | handleTagListClick(e) { |
| | | if (window.targetCheck(e,this.selectors.tagList.add)) { |
| | | this.addTagListItem(e.target.closest('[data-tag-list-id]')); |
| | | } else if (window.targetCheck(e, this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.item)); |
| | | } |
| | | } |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let isValid = true; |
| | | |
| | | // First pass: validate all inputs |
| | | for (let input of config.ui.inputs) { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | const value = this.getFieldValue(input); |
| | | |
| | | if (value) hasValue = true; |
| | | |
| | | // Validate and check for errors |
| | | const valid = this.validateField(input); |
| | | |
| | | if (isRequired && !value) { |
| | | this.showError(input, 'This field is required'); |
| | | isValid = false; |
| | | } else if (!valid) { |
| | | isValid = false; |
| | | } |
| | | |
| | | const fieldName = input.name.replace('new_',''); |
| | | data[fieldName] = value; |
| | | } |
| | | |
| | | // Stop if validation failed |
| | | if (!isValid) { |
| | | this.a11y.announce('Please correct the errors before adding'); |
| | | const firstInvalid = config.ui.inputs.find(input => { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | return (isRequired && !this.getFieldValue(input)); |
| | | }); |
| | | if (firstInvalid) firstInvalid.focus(); |
| | | return; |
| | | } |
| | | |
| | | if (!hasValue) { |
| | | this.a11y.announce('Please fill in at least one field'); |
| | | config.ui.inputs[0].focus(); |
| | | return; |
| | | } |
| | | |
| | | // Build label |
| | | let label; |
| | | switch (config.format) { |
| | | case 'first_field': |
| | | label = Object.values(data)[0]; |
| | | break; |
| | | case 'all_fields': |
| | | label = Object.values(data).join(', '); |
| | | break; |
| | | default: |
| | | if (config.format.includes('{')) { |
| | | label = config.format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label, |
| | | fieldName: config.fieldName |
| | | }); |
| | | |
| | | const index = config.ui.items?.children?.length ?? 0; |
| | | newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.id = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | config.ui.items.append(newItem); |
| | | |
| | | // Clear inputs AFTER success |
| | | for (let input of config.ui.inputs) { |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | this.clearValidation(input); |
| | | } |
| | | |
| | | config.ui.inputs[0]?.focus(); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item added'); |
| | | } |
| | | removeTagListItem(item) { |
| | | let tagList = item.closest('[data-tag-list-id]'); |
| | | if (!tagList) return; |
| | | item.remove(); |
| | | this.reindexList(tagList); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | handleTagListInput(e) { |
| | | let target = e.target; |
| | | let field = target.closest('[data-tag-list-id]'); |
| | | if (!field) return; |
| | | let config = this.tagLists.get(field.dataset.tagListId); |
| | | if (!config) return; |
| | | |
| | | if (e.key === 'Enter') { |
| | | if (target === config.ui.inputs[config.ui.inputs.length - 1]) { |
| | | e.preventDefault(); |
| | | this.addTagListItem(target.closest('[data-tag-list-id]')); |
| | | } else { |
| | | e.preventDefault(); |
| | | let index = config.ui.inputs.indexOf(target); |
| | | config.ui.inputs[index+1].focus(); |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| | | checkForConditionalFields(form) { |
| | | form.querySelectorAll(this.selectors.dependsOn).forEach( field => { |
| | | const dependsOn = field.dataset.dependsOn; |
| | | const requiredValue = field.dataset.dependsValue; |
| | | const operator = field.dataset.dependsOperatior??'=='; |
| | | |
| | | if (!this.dependencies.has(dependsOn)) { |
| | | let element = document.querySelector(`[field="${dependsOn}"]`); |
| | | if (element) { |
| | | this.dependencies.set(dependsOn, { |
| | | element: element, |
| | | items: [] |
| | | }); |
| | | } |
| | | } |
| | | let dependency = this.dependencies.get(dependsOn); |
| | | dependency.items.push({ |
| | | field: field, |
| | | form: form.dataset.formId, |
| | | requiredValue: requiredValue, |
| | | operator: operator |
| | | }); |
| | | this.dependencies.set(dependsOn, dependency); |
| | | this.checkFieldDependency(dependency, dependsOn); |
| | | }); |
| | | } |
| | | checkFieldDependency(dependentField, controlFieldName) { |
| | | const controlField = this.dependencies.get(controlFieldName); |
| | | if (!controlField) return; |
| | | |
| | | const controlValue = this.getFieldCheckedValue(controlField.element); |
| | | const shouldShow = this.evaluateCondition( |
| | | controlValue, |
| | | dependentField.requiredValue, |
| | | dependentField.operator |
| | | ); |
| | | |
| | | this.toggleFieldVisibility(dependentField.field, shouldShow); |
| | | } |
| | | 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; |
| | | } |
| | | } |
| | | 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; |
| | | } |
| | | }); |
| | | } |
| | | checkForCharacterLimits(form) { |
| | | if (!form.querySelector(this.selectors.limits.hasLimit)) return; |
| | | this.countUpdaters = this.updateCount.bind(this); |
| | | |
| | | form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => { |
| | | const input = this.getFieldInput(field); |
| | | if (!input) return; |
| | | |
| | | let id = window.generateID('limit'); |
| | | input.dataset.charLimitId = id; |
| | | input.dataset.limit = field.dataset.maxlength; |
| | | |
| | | let config = { |
| | | element: input, |
| | | form: form.dataset.formId, |
| | | ui: window.uiFromSelectors(this.selectors.limits, field) |
| | | }; |
| | | |
| | | if (config.ui.limit) { |
| | | config.ui.limit.textContent = field.dataset.maxlength; |
| | | } |
| | | |
| | | this.charLimits.set(id, config); |
| | | this.addCharacterLimitListeners(input); |
| | | }); |
| | | 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]]; |
| | | addCharacterLimitListeners(input) { |
| | | input.addEventListener('input', this.countUpdaters, {passive: true}); |
| | | } |
| | | removeCharacterLimitListeners(input) { |
| | | input.removeEventListener('input', this.countUpdaters, {passive: true}); |
| | | } |
| | | updateCount(e) { |
| | | let target = e.target; |
| | | let config = this.charLimits.get(target.dataset.charLimitId); |
| | | if (!config) return; |
| | | let length = target.value.length; |
| | | let limit = target.dataset.limit; |
| | | if (config.ui.current) { |
| | | config.ui.current.textContent = length; |
| | | config.ui.current.classList.toggle('exceeded', length >= limit); |
| | | } |
| | | if (length > limit) { |
| | | target.value = target.value.slice(0, limit); |
| | | } |
| | | } |
| | | checkForImageUploads(form, config) { |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta); |
| | | } |
| | | 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] = {}; |
| | | } |
| | | checkForTabs(form, config) { |
| | | if (window.jvbTabs && form.querySelector('nav.tabs')) { |
| | | config.tabs = window.jvbTabs.registerTab(form, { |
| | | preCheck: (section, tabConfig) => { |
| | | return this.validateStep(section, config); |
| | | } |
| | | }); |
| | | config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form); |
| | | config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections)); |
| | | config.ui.tabs.inputs = {}; |
| | | config.ui.tabs.sections.forEach(section => { |
| | | config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs)); |
| | | }); |
| | | config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons)); |
| | | |
| | | // 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] = {}; |
| | | config.unsubscribeTabs = window.jvbTabs.subscribe((event, data) => { |
| | | if (event === 'tab-switched') { |
| | | if (config.ui.tabs.progress) { |
| | | const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false; |
| | | if (!section) return; |
| | | const step = section.dataset.step; |
| | | const total = config.ui.sections.length; |
| | | |
| | | window.showProgress( |
| | | config.ui.tabs.progress, |
| | | step, |
| | | total |
| | | ); |
| | | } |
| | | } |
| | | }); |
| | | this.forms.set(config.id, config); |
| | | } |
| | | } |
| | | current = current[groupKey]; |
| | | } |
| | | validateStep(section, config) { |
| | | const formId = section.closest('[data-form-id]')?.dataset.formId; |
| | | if (!formId) return true; |
| | | |
| | | // Set the final field value |
| | | const fieldKey = keys[keys.length - 1]; |
| | | const form = this.forms.get(formId); |
| | | if (!form) return true; |
| | | |
| | | // Handle array values (checkboxes, multi-selects) |
| | | if (current[fieldKey] !== undefined) { |
| | | if (!Array.isArray(current[fieldKey])) { |
| | | current[fieldKey] = [current[fieldKey]]; |
| | | const inputs = Array.from(this.inputs.values()) |
| | | .filter(item => |
| | | item && |
| | | item.form === formId && |
| | | item.section === section.dataset.tab && |
| | | !item.element.closest('[hidden]') |
| | | ); |
| | | |
| | | return inputs.every(item => this.validateField(item.element) === true); |
| | | } |
| | | 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); |
| | | checkForSelectors(form) { |
| | | if (window.jvbSelector) window.jvbSelector.scanExistingFields(form); |
| | | } |
| | | } |
| | | data[fieldKey][v] = value; |
| | | } |
| | | /** |
| | | * Mainly for repeaters or taglist |
| | | * @param {HTMLElement} container |
| | | */ |
| | | reindexList(container) { |
| | | const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId; |
| | | |
| | | processRegularField(key, value, data, repeaterData, postData, form) { |
| | | //handle array values (like checkboxes/selects) |
| | | if (data[key]) { |
| | | if (!Array.isArray(data[key])) { |
| | | data[key] = [data[key]]; |
| | | } |
| | | data[key].push(value); |
| | | } else { |
| | | data[key] = value; |
| | | } |
| | | } |
| | | Array.from(container.children).forEach((item, index) => { |
| | | item.dataset.index = `${index}`; |
| | | |
| | | getFieldValue(field) { |
| | | if (!field) return ''; |
| | | // Find ALL inputs within this item, not just direct children |
| | | const inputs = item.querySelectorAll('input, select, textarea'); |
| | | |
| | | 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; |
| | | } |
| | | } |
| | | inputs.forEach(input => { |
| | | // Skip inputs that shouldn't be re-indexed (like file inputs) |
| | | if (input.type === 'file') return; |
| | | |
| | | getChangedFields(original, current) { |
| | | return window.getDifferences?.map(original, current) || {}; |
| | | // Get the field name from the input's data-field or name |
| | | const inputField = input.dataset.field || input.name.split(':').pop(); |
| | | |
| | | // Re-prefix with the new index, passing item as wrapper |
| | | window.prefixInput( |
| | | input, |
| | | `${fieldName}:${index}:`, |
| | | item, |
| | | false, |
| | | true |
| | | ); |
| | | }); |
| | | }); |
| | | |
| | | |
| | | this.updateCollectionField(container); |
| | | } |
| | | |
| | | /** |
| | | * Event system |
| | | * Update the entire repeater/tagList field data |
| | | * Call this whenever rows are added, removed, or reordered |
| | | */ |
| | | updateCollectionField(element) { |
| | | const field = element.closest('[data-field]'); |
| | | if (!field) return; |
| | | |
| | | const fieldType = field.dataset.fieldType; |
| | | if (!['repeater', 'tag-list'].includes(fieldType)) return; |
| | | |
| | | const form = this.getForm(element); |
| | | if (!form) return; |
| | | |
| | | // Get all current data for the collection |
| | | const value = this.getFieldValue(field); |
| | | this.updateItem(field.dataset.field, value, form); |
| | | } |
| | | /********************************************************************** |
| | | VALIDATION |
| | | **********************************************************************/ |
| | | //text, email, url, tel, date, time, datetime, number |
| | | //select, checkbox, radio, true_false |
| | | //textarea |
| | | //repeater: subfields validation; no submission until all required are entered |
| | | //tag fields: similar to repeater; each separate field is its own hidden field |
| | | //upload: comma separated ints |
| | | //selector: comma separated ints |
| | | //location: hidden inputs for address, lat, lng, street, city, province, postal_code, country |
| | | |
| | | clearValidation(input) { |
| | | let field = this.getField(input); |
| | | if (!field) return; |
| | | let item = this.getItem(input); |
| | | if (!item) return; |
| | | |
| | | field.classList.remove('has-error', 'has-success'); |
| | | |
| | | if (item.ui.success) item.ui.success.hidden = true; |
| | | if (item.ui.error) item.ui.error.hidden = true; |
| | | if (item.ui.message) { |
| | | item.ui.message.hidden = true; |
| | | item.ui.message.textContent = ''; |
| | | } |
| | | } |
| | | |
| | | showError(input, message = 'Invalid field') { |
| | | let field = this.getField(input); |
| | | if (!field) return; |
| | | let item = this.getItem(input); |
| | | if (!item) return; |
| | | |
| | | field.classList.remove('has-success'); |
| | | field.classList.add('has-error'); |
| | | |
| | | if (item.ui.success) item.ui.success.hidden = true; |
| | | if (item.ui.error) item.ui.error.hidden = true; |
| | | if (item.ui.message) { |
| | | item.ui.message.hidden = false; |
| | | item.ui.message.textContent = message; |
| | | } |
| | | } |
| | | |
| | | showSuccess(input, message = '') { |
| | | let field = this.getField(input); |
| | | if (!field) return; |
| | | let item = this.getItem(input); |
| | | if (!item) return; |
| | | |
| | | field.classList.remove('has-error'); |
| | | field.classList.add('has-success'); |
| | | |
| | | if (item.ui.success) item.ui.success.hidden = false; |
| | | if (item.ui.error) item.ui.error.hidden = true; |
| | | |
| | | if (item.ui.message) { |
| | | item.ui.message.hidden = message=== ''; |
| | | item.ui.message.textContent = message; |
| | | } |
| | | } |
| | | |
| | | 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'); |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | | this.showError(fieldWrapper, data.message); |
| | | |
| | | fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input) { |
| | | input.focus(); |
| | | } |
| | | } |
| | | } else { |
| | | const error = document.createElement('div'); |
| | | error.className = 'form-error error-message'; |
| | | error.textContent = data.message; |
| | | |
| | | const icon = window.getIcon?.('close-circle'); |
| | | if (icon) { |
| | | icon.classList.add('error-icon'); |
| | | error.prepend(icon); |
| | | } |
| | | |
| | | form.insertBefore(error, form.firstChild); |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | |
| | | if (window.jvbA11y) { |
| | | const announcement = data.field |
| | | ? `Error in ${data.field}: ${data.message}` |
| | | : `Form error: ${data.message}`; |
| | | window.jvbA11y.announce(announcement); |
| | | } |
| | | |
| | | form.dispatchEvent(new CustomEvent('jvb-form-error', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | /********************************************************************** |
| | | STATUS |
| | | **********************************************************************/ |
| | | showFormStatus(formId, status, message ='') { |
| | | let form = this.forms.get(formId); |
| | | if (!form || !form.options.showStatus || !form.ui?.status?.status) return; |
| | | if (form.status === status) return; |
| | | |
| | | |
| | | form.status = status; |
| | | form.ui.status.status.hidden = false; |
| | | form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status)); |
| | | |
| | | form.ui.status.message.textContent = message === '' ? this.getDefaultMessage(status) : message; |
| | | |
| | | form.ui.status.icon.className = 'icon icon-'+this.getDefaultIcon(status); |
| | | setTimeout(()=> form.ui.status.status.hidden = true, (status === 'submitted') ? 3000 : 10000); |
| | | } |
| | | getDefaultMessage(status) { |
| | | 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' |
| | | }; |
| | | return messages[status]??status; |
| | | } |
| | | getDefaultIcon(status) { |
| | | const icons = { |
| | | 'autosaved': 'check-circle', |
| | | 'submitted': 'check-circle', |
| | | 'restored': 'history', |
| | | 'error': 'close-circle', |
| | | 'offline': 'cloud-slash', |
| | | 'pending': 'exclamation-mark' |
| | | } |
| | | return icons[status]??''; |
| | | } |
| | | |
| | | |
| | | /********************************************************************** |
| | | SUMMARY |
| | | **********************************************************************/ |
| | | showSummary(data) { |
| | | let summary = this.templates.create('formSummary', data); |
| | | data.config.element.after(summary); |
| | | window.fade(data.config.element, false); |
| | | } |
| | | /********************************************************************** |
| | | UTILITY |
| | | **********************************************************************/ |
| | | getForm(element) { |
| | | let form = element.closest('[data-form-id]'); |
| | | if (!form) return false; |
| | | let id = form.dataset.formId; |
| | | if (!id) return false; |
| | | let config = this.forms.get(id); |
| | | if (!config) return false; |
| | | return config; |
| | | } |
| | | getField(element) { |
| | | return element.closest('[data-field]'); |
| | | } |
| | | getFieldType(element) { |
| | | let field = this.getField(element); |
| | | if (!field) return; |
| | | return field.dataset.fieldType; |
| | | } |
| | | getFieldValue(element) { |
| | | let type = this.getFieldType(element); |
| | | let conf = this.getItem(element); |
| | | let fieldName = conf.field?.dataset.field??false; |
| | | if (!fieldName) return false; |
| | | |
| | | switch (type) { |
| | | case 'repeater': |
| | | return this.getRepeaterValue(element, conf); |
| | | |
| | | case 'tag-list': |
| | | return this.getTagListValue(element, conf); |
| | | |
| | | case 'group': |
| | | //Do we actually need anything here? I think each subfield just |
| | | break; |
| | | |
| | | case 'location': |
| | | return this.getLocationValue(element, conf); |
| | | |
| | | case 'selector': |
| | | case 'upload': |
| | | case 'gallery': |
| | | case 'image': |
| | | return this.getHiddenInputValue(element, conf, fieldName); |
| | | |
| | | case 'true-false': |
| | | case 'toggle-text': |
| | | return element.checked; |
| | | case 'checkbox': |
| | | // Handle multi-checkbox (name ends with []) |
| | | if (element.name.endsWith('[]')) { |
| | | return this.getCheckboxGroupValue(element, conf); |
| | | } |
| | | return element.checked ? element.value : ''; |
| | | default: |
| | | return element.value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get all checked values for a checkbox group |
| | | */ |
| | | getCheckboxGroupValue(element, conf) { |
| | | if (!conf.checkboxGroup) { |
| | | conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`); |
| | | this.saveItem(conf); |
| | | } |
| | | |
| | | return Array.from(conf.checkboxGroup) |
| | | .filter(cb => cb.checked) |
| | | .map(cb => cb.value); |
| | | } |
| | | /** |
| | | * Get the actual user-facing value (for validation and submission) |
| | | */ |
| | | getFieldCheckedValue(element) { |
| | | // Handle checkboxes and radios based on checked state |
| | | if (element.type === 'checkbox') { |
| | | const type = this.getFieldType(element); |
| | | if (type === 'true-false') { |
| | | return element.checked; |
| | | } |
| | | return element.checked ? element.value : ''; |
| | | } |
| | | |
| | | if (element.type === 'radio') { |
| | | const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`); |
| | | const checked = Array.from(radioGroup).find(r => r.checked); |
| | | return checked ? checked.value : ''; |
| | | } |
| | | |
| | | // For everything else, use existing logic |
| | | return this.getFieldValue(element); |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | getRepeaterValue(element, conf) { |
| | | const items = element.querySelector('.repeater-items'); |
| | | if (!items) return []; |
| | | let ignore = ['image_data','image-title','image-caption','image-description','image-alt-text'] |
| | | let value = []; |
| | | Array.from(items.children).forEach(row => { |
| | | let rowData = {}; |
| | | row.querySelectorAll('[data-field]').forEach(field => { |
| | | if (!ignore.includes(field.dataset.field)) { |
| | | const input = this.getFieldInput(field); |
| | | if (input) { |
| | | rowData[field.dataset.field] = this.getFieldValue(input); |
| | | } |
| | | } |
| | | }); |
| | | value.push(rowData); |
| | | }); |
| | | return value; |
| | | } |
| | | getFieldInput(field) { |
| | | // For quill fields, target the specific editor textarea |
| | | const quillTextarea = field.querySelector('textarea[data-editor]'); |
| | | if (quillTextarea) return quillTextarea; |
| | | |
| | | return field.querySelector(this.inputSelectors); |
| | | } |
| | | getTagListValue(element, conf) { |
| | | if (!conf.container) { |
| | | conf.container = conf.field?.querySelector('.tag-items'); |
| | | this.saveItem(conf); |
| | | } |
| | | let value = []; |
| | | Array.from(conf.container.children).forEach(item => { |
| | | let inputs = item.querySelectorAll('input[type="hidden"]'); |
| | | let fieldData = {}; |
| | | inputs.forEach(input => { |
| | | fieldData[input.dataset.field] = input.value; |
| | | }); |
| | | value.push(fieldData); |
| | | }); |
| | | return value; |
| | | } |
| | | getLocationValue(element, conf) { |
| | | if(!conf.values){ |
| | | conf.values = Array.from(conf.field?.querySelectorAll('[data-location-field]')); |
| | | this.saveItem(conf); |
| | | } |
| | | let value = {}; |
| | | conf.values.forEach(input => { |
| | | value[input.dataset.locationField] = input.value; |
| | | }); |
| | | return value; |
| | | } |
| | | getHiddenInputValue(element, conf, fieldName) { |
| | | if (!conf.value) { |
| | | conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`) |
| | | || conf.field?.querySelector(`input[type=hidden]`); |
| | | this.saveItem(conf); |
| | | } |
| | | return conf.value?.value ?? ''; |
| | | } |
| | | |
| | | /** |
| | | * Format field value for display in summary |
| | | * @param {*} value - The field value |
| | | * @param {Object} input - The input config |
| | | * @returns {HTMLElement|string} - Formatted display element or string |
| | | */ |
| | | formatValueForSummary(value, input) { |
| | | const fieldType = this.getFieldType(input.element); |
| | | |
| | | // Handle empty values |
| | | if (this.isEmptyValue(value)) { |
| | | return ''; |
| | | } |
| | | |
| | | // Handle different field types |
| | | switch (fieldType) { |
| | | case 'repeater': |
| | | return this.formatRepeaterForSummary(value, input); |
| | | |
| | | case 'tag-list': |
| | | return this.formatTagListForSummary(value, input); |
| | | |
| | | case 'location': |
| | | return this.formatLocationForSummary(value); |
| | | |
| | | case 'true-false': |
| | | return value ? 'Yes' : 'No'; |
| | | |
| | | case 'checkbox': |
| | | // Handle multi-checkbox arrays |
| | | if (Array.isArray(value)) { |
| | | return this.formatCheckboxGroupForSummary(value, input); |
| | | } |
| | | // Single checkbox - get display label |
| | | return this.getDisplayLabel(input, value); |
| | | |
| | | case 'selector': |
| | | case 'upload': |
| | | case 'image': //legacy, shouldn't be needed |
| | | case 'gallery': //legacy, shouldn't be needed |
| | | // These might need special handling depending on your needs |
| | | return this.formatHiddenFieldForSummary(value, input, fieldType); |
| | | |
| | | default: |
| | | // For radio/checkbox, get the display label |
| | | if (typeof value === 'string') { |
| | | return this.getDisplayLabel(input, value); |
| | | } |
| | | // For textarea or any multi-line text, convert line breaks |
| | | if (typeof value === 'string' && value.includes('\n')) { |
| | | return this.convertLineBreaks(value); |
| | | } |
| | | return value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format checkbox group values with labels |
| | | */ |
| | | formatCheckboxGroupForSummary(values, input) { |
| | | const labels = values.map(value => this.getDisplayLabel(input, value)); |
| | | return labels.join(', '); |
| | | } |
| | | |
| | | /** |
| | | * Convert \n line breaks to HTML |
| | | */ |
| | | convertLineBreaks(text) { |
| | | const container = document.createElement('span'); |
| | | container.innerHTML = text.split('\n').join('<br>'); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format repeater data as a list |
| | | */ |
| | | formatRepeaterForSummary(rows, input) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-repeater'; |
| | | |
| | | rows.forEach((row, index) => { |
| | | const rowDiv = document.createElement('div'); |
| | | rowDiv.className = 'summary-repeater-row'; |
| | | |
| | | const rowTitle = document.createElement('strong'); |
| | | rowTitle.textContent = `Entry ${index + 1}:`; |
| | | rowDiv.appendChild(rowTitle); |
| | | |
| | | const fieldsList = document.createElement('ul'); |
| | | fieldsList.className = 'summary-repeater-fields'; |
| | | |
| | | for (const [fieldName, fieldValue] of Object.entries(row)) { |
| | | if (this.isEmptyValue(fieldValue)) continue; |
| | | |
| | | const li = document.createElement('li'); |
| | | |
| | | // Try to find the label for this subfield |
| | | const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`); |
| | | const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName; |
| | | |
| | | li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`; |
| | | fieldsList.appendChild(li); |
| | | } |
| | | |
| | | rowDiv.appendChild(fieldsList); |
| | | container.appendChild(rowDiv); |
| | | }); |
| | | |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format tag-list data |
| | | */ |
| | | formatTagListForSummary(tags, input) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-taglist'; |
| | | |
| | | const tagsList = document.createElement('ul'); |
| | | tagsList.className = 'summary-tags'; |
| | | |
| | | tags.forEach(tag => { |
| | | const li = document.createElement('li'); |
| | | li.className = 'summary-tag'; |
| | | |
| | | // Get the primary display value (first non-empty field) |
| | | const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || ''; |
| | | |
| | | // If there are multiple fields, show them all |
| | | const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v)); |
| | | if (fields.length > 1) { |
| | | li.textContent = fields.map(([k, v]) => v).join(', '); |
| | | } else { |
| | | li.textContent = displayValue; |
| | | } |
| | | |
| | | tagsList.appendChild(li); |
| | | }); |
| | | |
| | | container.appendChild(tagsList); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format location data |
| | | */ |
| | | formatLocationForSummary(location) { |
| | | const parts = []; |
| | | |
| | | if (location.street) parts.push(location.street); |
| | | if (location.city) parts.push(location.city); |
| | | if (location.province) parts.push(location.province); |
| | | if (location.postal_code) parts.push(location.postal_code); |
| | | if (location.country) parts.push(location.country); |
| | | |
| | | return parts.length > 0 ? parts.join(', ') : location.address || ''; |
| | | } |
| | | |
| | | /** |
| | | * Format hidden field types (upload, selector) |
| | | */ |
| | | formatHiddenFieldForSummary(value, input, fieldType) { |
| | | if (['upload', 'gallery', 'image'].includes(fieldType)) { |
| | | // Get upload preview images if available |
| | | const uploadField = input.field?.querySelector('[data-upload-field]'); |
| | | if (uploadField) { |
| | | const previews = uploadField.querySelectorAll('.item-grid.preview img'); |
| | | if (previews.length > 0) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-uploads'; |
| | | previews.forEach(img => { |
| | | const clone = img.cloneNode(true); |
| | | clone.style.maxWidth = '100px'; |
| | | clone.style.maxHeight = '100px'; |
| | | container.appendChild(clone); |
| | | }); |
| | | return container; |
| | | } |
| | | } |
| | | return `${value.split(',').length} file(s) uploaded`; |
| | | } |
| | | |
| | | if (fieldType === 'selector') { |
| | | // Could enhance this to show selected item names if available |
| | | return value; |
| | | } |
| | | |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * Get the display label for an input value (especially for radio/checkbox) |
| | | * @param {Object} input - The input config from this.inputs |
| | | * @param {*} value - The field value |
| | | * @returns {string} - The display label or original value |
| | | */ |
| | | getDisplayLabel(input, value) { |
| | | if (!input.element) return value; |
| | | |
| | | const inputType = input.element.type; |
| | | |
| | | // Handle radio buttons |
| | | if (inputType === 'radio') { |
| | | const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`); |
| | | const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value); |
| | | if (selectedRadio) { |
| | | const label = selectedRadio.closest('label') || |
| | | input.field.querySelector(`label[for="${selectedRadio.id}"]`); |
| | | if (label) { |
| | | return label.textContent.replace('*', '').trim(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle checkboxes (including groups) |
| | | if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') { |
| | | // Find checkbox with this value in the field |
| | | const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`); |
| | | if (checkbox) { |
| | | const label = checkbox.closest('label') || |
| | | input.field.querySelector(`label[for="${checkbox.id}"]`); |
| | | if (label) { |
| | | // Get just the span content to avoid getting nested elements |
| | | const span = label.querySelector('span'); |
| | | return span ? span.textContent.trim() : label.textContent.replace('*', '').trim(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return value; |
| | | } |
| | | getItem(element, formId = null) { |
| | | const hasID = Object.hasOwn(element.dataset, 'ref'); |
| | | let id = (hasID) ? element.dataset.ref : window.generateID('input'); |
| | | if (!hasID) element.dataset.ref = id; |
| | | |
| | | //check if we have it already |
| | | if (!this.inputs.has(id)) { |
| | | if (!formId) { |
| | | formId = element.closest('[data-form-id]')?.dataset.formId??false; |
| | | } |
| | | let field = this.getField(element); |
| | | |
| | | this.inputs.set(id, { |
| | | id: id, |
| | | element: element, |
| | | form: formId, |
| | | field: field, |
| | | section: element.closest('[data-tab]')?.dataset.tab ?? false, |
| | | ui: window.uiFromSelectors(this.selectors.fields, field) |
| | | }); |
| | | } |
| | | |
| | | return this.inputs.get(id); |
| | | } |
| | | saveItem(config) { |
| | | this.inputs.set(config.id, config); |
| | | } |
| | | /********************************************************************** |
| | | Subscription |
| | | **********************************************************************/ |
| | | subscribe(callback) { |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | | |
| | | notify(event, data) { |
| | | this.subscribers.forEach(cb => cb(event, data)); |
| | | this.subscribers.forEach(cb => { |
| | | try { |
| | | cb(event, data); |
| | | } catch (e) { |
| | | console.error('HandleSelection subscriber error:', e); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Cleanup when form is closed/destroyed |
| | | */ |
| | | cleanupForm(formId) { |
| | | const formConfig = this.forms.get(formId); |
| | | if (!formConfig) return; |
| | | console.log('Cleaning up form', formConfig); |
| | | |
| | | // Check for unsaved changes |
| | | if (this.hasUnsavedChanges(formId)) { |
| | | this.autosave(formConfig); |
| | | } |
| | | |
| | | // Clean up special fields |
| | | this.cleanupSpecialFields(); |
| | | |
| | | // Remove form config |
| | | this.forms.delete(formId); |
| | | } |
| | | |
| | | /** |
| | | * Cleanup |
| | | */ |
| | | /********************************************************************** |
| | | Cleanup |
| | | **********************************************************************/ |
| | | destroy() { |
| | | // Remove global handlers |
| | | if (this.globalHandlersAdded) { |
| | | document.removeEventListener('submit', this.submitHandler); |
| | | document.removeEventListener('change', this.changeHandler); |
| | | document.removeEventListener('focus', this.focusHandler, true); |
| | | document.removeEventListener('blur', this.blurHandler, true); |
| | | } |
| | | |
| | | // Clear maps |
| | | this.specialFields.clear(); |
| | | this.forms.clear(); |
| | | this.activeRepeaters.clear(); |
| | | |
| | | if (this.forms) { |
| | | if (this.forms.size > 0) { |
| | | Array.from(this.forms.values()).forEach(form => { |
| | | this.removeFormListeners(form); |
| | | }); |
| | | this.forms.clear(); |
| | | } |
| | | if (this.repeaters.size > 0) { |
| | | Array.from(this.repeaters.values()).forEach(repeater => { |
| | | this.removeRepeaterListeners(repeater.element); |
| | | repeater.sortable?.destroy(); |
| | | }); |
| | | this.repeaters.clear(); |
| | | } |
| | | if (this.quantityFields.size > 0) { |
| | | Array.from(this.quantityFields.values()).forEach(num => { |
| | | this.removeQuantityListeners(num.element); |
| | | }); |
| | | this.quantityFields.clear(); |
| | | } |
| | | if (this.tagLists.size > 0) { |
| | | Array.from(this.tagLists.values()).forEach(tagList => { |
| | | this.removeTagListListeners(tagList.element); |
| | | }); |
| | | this.tagLists.clear(); |
| | | } |
| | | if (this.charLimits.size > 0) { |
| | | Array.from(this.charLimits.values()).forEach(charLimit => { |
| | | charLimit.element.removeEventListener('input', this.countUpdaters); |
| | | }); |
| | | } |
| | | this.inputs.clear(); |
| | | this.forms.clear(); |
| | | this.charLimits.clear(); |
| | | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbForm = FormController; |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe(event => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbForm = new FormController(); |
| | | } |
| | | }); |
| | | }); |