class FormFields { constructor() { this.forms = new Map(); // Store form configurations this.initialized = false; this.stepMultiplier = 1; this.cache = window.jvbCache; this.queue = window.jvbQueue; this.debouncer = window.debouncer; this.activeOperations = new Map(); // operationId -> operation details this.pendingForms = new Map(); // formId -> pending operation data this.activeRepeaters = new Map(); this.repeaterDelays = { change: 6000, typing: 3000, blur: 1500, add: 500, remove: 800, reorder: 1000 }; this.ignore = []; // Bind methods for event delegation this.submitHandler = this.handleSubmit.bind(this); this.changeHandler = this.handleChange.bind(this); this.clickHandler = this.handleClick.bind(this); this.focusHandler = this.handleFocus.bind(this); this.keyHandler = this.handleKeys.bind(this); this.blurHandler = this.handleBlur.bind(this); this.init(); } /** * Initialize the form manager */ async init() { this.scanExistingForms(); this.initListeners(); await this.resumePendingOperations(); } /** * Scan page for existing forms and register them automatically */ scanExistingForms() { //look for forms with the save endpoint const managedForms = document.querySelectorAll('form[data-form-id]'); managedForms.forEach(form => { try { this.registerForm(form); } catch (error) { this.handleError('Failed to register form:', form, error); } }); } /** * Register a form with configuration from data attributes * @param {HTMLFormElement} formElement * @param {Object} options - Override options (optional) * @returns {Object} Form configuration */ registerForm(formElement, options = {}) { options = { ...this.parseFormDataAttributes(formElement), ...options }; // Use existing addForm method with parsed config return this.addForm(formElement, options); } /** * Parse form configuration from data attributes * @param {HTMLFormElement} formElement * @returns {Object} Configuration object */ parseFormDataAttributes(formElement) { const dataset = formElement.dataset; const config = {}; // Basic options if ('autoSave' in dataset) { config.autoSave = dataset.autoSave !== 'false'; } if ('noCache' in dataset) { config.noCache = true; } if ('saveDelay' in dataset) { config.saveDelay = parseInt(dataset.saveDelay) || 2000; } if ('save' in dataset) { config.api = dataset.save; } if ('itemId' in dataset) { config.itemID = dataset.itemId; } if ('content' in dataset) { config.content = dataset.content; } if ('title' in dataset) { config.title = dataset.title; } // Advanced options if ('noAutosave' in dataset) { config.autoSave = false; } if ('onChange' in dataset) { // Look for global callback function const callbackName = dataset.onChange; if (window[callbackName] && typeof window[callbackName] === 'function') { config.onChange = window[callbackName]; } } if ('onSave' in dataset) { const callbackName = dataset.onSave; if (window[callbackName] && typeof window[callbackName] === 'function') { config.onSave = window[callbackName]; } } if ('onSubmit' in dataset) { const callbackName = dataset.onSubmit; if (window[callbackName] && typeof window[callbackName] === 'function') { config.onSubmit = window[callbackName]; } } // Headers config.headers = { 'action_nonce': jvbSettings?.dash }; if ('headers' in dataset) { try { const additionalHeaders = JSON.parse(dataset.headers); config.headers = { ...config.headers, ...additionalHeaders }; } catch (e) { console.warn('Invalid headers JSON in data-headers:', dataset.headers); } } return config; } /** * Add a form to be managed by this instance * @param {HTMLFormElement} formElement * @param {Object} options * @returns {Object} Form configuration */ addForm(formElement, options = {}) { const formConfig = { element: formElement, id: formElement.dataset.formId, data: this.collectFormData(formElement), initialized: false, // Default options with overrides options: { onSave: false, onChange: null, onSubmit: null, itemID: null, content: null, saveDelay: 2000, autoSave: true, api: formElement.dataset.save || null, headers: { 'action_nonce': jvbSettings?.dash }, ...options }, // Form-specific state state: { saveTimeout: null, lastData: null, isDirty: false, operationId: null, cached: false }, dependencies: new Map(), }; // Store the configuration this.forms.set(formConfig.id, formConfig); // Initialize form-specific features this.initFormFeatures(formConfig); return formConfig; } initFormFeatures(formConfig) { if (formConfig.initialized) { return; } formConfig.initialized = true; const form = formConfig.element; // Only initialize what exists const featureChecks = [ { selector: '[data-tab]', init: () => this.initTabs(formConfig) }, { selector: '.repeater', init: () => this.initRepeaterFields(form) }, { selector: '[data-editor="true"]', init: () => this.initQuillEditor(formConfig) }, { selector: '.gallery', init: () => this.initGalleryFields(formConfig) }, { selector: '.image', init: () => this.initImageFields(formConfig) }, { selector: '[data-limit]', init: () => this.initCharacterLimits(formConfig) }, { selector: '[data-depends-on]', init: () => this.initConditionalFields(formConfig)} ]; requestAnimationFrame(() => { featureChecks.forEach(({selector, init} ) => { if (form.querySelector(selector)) { init(); } }); }); } initTabs(formConfig) { if (!window.jvbTabs) { console.warn('jvbTabs not available'); return; } let form = formConfig.element; // Check if form is within a tabs container const tabsContainer = form.closest('[data-tab]') || form.closest('.tabs-container') || form.querySelector('.tabs:not(.icon)'); if (tabsContainer) { // Initialize tabs with form-specific callbacks formConfig.tabs = new window.jvbTabs(form, { updateURL: false, // Disable URL updates for form tabs, }); this.addTabNavigationButtons(formConfig); } } addTabNavigationButtons(formConfig) { const form = formConfig.element; const sections = form.querySelectorAll('section[id]'); if (sections.length <= 1) { return; // No need for navigation if only one section } sections.forEach((section, index) => { let isLast = index === sections.length - 1; // Check if navigation buttons already exist if (section.querySelector('.tab-navigation')) { return; } let buttons = { previous: sections[index - 1]?.id ?? null, next: (isLast) ? null : sections[index + 1]?.id ?? null }; for (var [direction, id] of Object.entries(buttons)) { if (id){ let button = form.querySelector('button[type=submit]').cloneNode(true); button.type = 'button'; button.className = `tab-navigation ${direction}`; button.innerText = window.uppercaseFirst(direction); let icon = (direction === 'previous') ? 'left' : 'right'; button.prepend(window.getIcon(icon)); button.dataset.navigateTo = id; if (isLast) { section.querySelector('.form-actions').prepend(button); } else { section.append(button); } } } }); } /********************************************************* * * CACHE * *********************************************************/ /** * Cache form data with operation tracking */ async cacheFormData(formConfig) { if (!this.cache || !formConfig.options.cache) return false; try { const cacheData = { formId: formConfig.id, formData: this.collectFormData(formConfig.element), formConfig: { endpoint: formConfig.element.dataset.save, content: formConfig.options.content, itemID: formConfig.options.itemID, headers: formConfig.options.headers }, timestamp: Date.now(), status: 'pending', operationId: formConfig.state.operationId }; const cacheKey = `form_${formConfig.id}`; await this.cache.setItem(cacheKey, cacheData); return true; } catch (error) { this.handleError('Failed to cache form data:', error); return false; } } /** * Update cached form with operation ID */ async updateCachedFormOperation(formId, operationId) { if (!this.cache || this.forms.get(formId).options.noCache) return; try { const cacheKey = `form_${formId}`; const cachedData = await this.cache.getItem(cacheKey); if (cachedData) { cachedData.operationId = operationId; cachedData.status = 'queued'; await this.cache.setItem(cacheKey, cachedData); } } catch (error) { this.handleError('Failed to update cached form operation:', error); } } /** * Resume pending operations from cache */ async resumePendingOperations() { if (!this.cache) return; try { // Get all cached form operations for current page const pendingForms = await this.getCachedFormsForCurrentPage(); if (pendingForms.length > 0) { for (const cachedForm of pendingForms) { await this.resumeFormOperation(cachedForm); } } } catch (error) { this.handleError('Failed to resume pending operations:', error); } } /** * Get cached forms for current page */ async getCachedFormsForCurrentPage() { if (!this.cache) return []; try { const pendingForms = []; for (let [key, value] of this.forms) { if (!value.options.cache){ continue; } const cachedData = await this.cache.getItem(`form_${key}`); if (cachedData && cachedData.operationId && ['queued', 'pending', 'processing'].includes(cachedData.status)) { pendingForms.push(cachedData); } } return pendingForms; } catch (error) { this.handleError('Failed to get cached forms:', error); return []; } } /** * Resume a specific form operation */ async resumeFormOperation(cachedForm) { try { // Check if operation still exists in queue const operationStatus = await this.queue.getOperationStatus(cachedForm.operationId); if (operationStatus) { // Re-track the operation this.activeOperations.set(cachedForm.operationId, { formId: cachedForm.formId, status: operationStatus.status, startTime: cachedForm.timestamp, data: cachedForm.formConfig }); // Update form config if form is registered const formConfig = this.forms.get(cachedForm.formId); if (formConfig) { formConfig.state.operationId = cachedForm.operationId; formConfig.state.cached = true; // Show resumption notification this.showFormResumptionNotification(formConfig, operationStatus.status); } // Set up completion handler this.queue.onOperationComplete(cachedForm.operationId, (operation) => { this.handleOperationComplete(operation); }); } else { // Operation no longer exists, clean up cache await this.removeCachedForm(cachedForm.formId); } } catch (error) { this.handleError('Failed to resume form operation:', error); // Clean up on error await this.removeCachedForm(cachedForm.formId); } } /** * Show form resumption notification */ showFormResumptionNotification(formConfig, status) { const form = formConfig.element; const notification = document.createElement('div'); notification.className = 'form-resumption-notification'; notification.innerHTML = `
🔄 Resumed: ${this.getStatusMessage(status)}
`; // Insert notification form.insertBefore(notification, form.firstChild); // Auto-dismiss setTimeout(() => { notification.remove(); }, 5000); // Manual dismiss notification.querySelector('.dismiss').addEventListener('click', () => { notification.remove(); }); } /** * Remove cached form data */ async removeCachedForm(formId) { if (!this.cache || !this.forms.get(formId).options.cache) return; try { const cacheKey = `form_${formId}`; await this.cache.removeItem(cacheKey); } catch (error) { this.handleError('Failed to remove cached form:', error); } } /** * Get form cache status */ async getFormCacheStatus(formId) { if (!this.cache) return null; try { const cacheKey = `form_${formId}`; const cachedData = await this.cache.getItem(cacheKey); return cachedData ? { formId: cachedData.formId, status: cachedData.status, operationId: cachedData.operationId, timestamp: cachedData.timestamp, hasData: !!cachedData.formData } : null; } catch (error) { this.handleError('Failed to get form cache status:', error); return null; } } /** * Track form operation */ trackOperation(formId, operationId, operationData) { this.activeOperations.set(operationId, { formId: formId, status: 'queued', startTime: Date.now(), data: operationData }); // Update form config const formConfig = this.forms.get(formId); if (formConfig) { formConfig.state.operationId = operationId; formConfig.state.cached = true; } } /** * Handle operation updates from queue */ handleOperationUpdate(operation) { const activeOp = this.activeOperations.get(operation.id); if (!activeOp) return; // Update operation status activeOp.status = operation.status; this.activeOperations.set(operation.id, activeOp); // Update form UI if needed const formConfig = this.forms.get(activeOp.formId); if (formConfig) { // Show progress or status updates in form UI this.updateFormStatus(formConfig, operation.status); } } /** * Handle operation completion */ async handleOperationComplete(operation) { const activeOp = this.activeOperations.get(operation.id); if (!activeOp) return; const formId = activeOp.formId; const formConfig = this.forms.get(formId); if (formConfig) { // Clear operation tracking formConfig.state.operationId = null; formConfig.state.cached = false; formConfig.state.isDirty = false; // Update last saved data formConfig.data = this.collectFormData(formConfig.element); // Update form UI this.updateFormStatus(formConfig, 'completed'); } // Clean up this.activeOperations.delete(operation.id); await this.removeCachedForm(formId); } /** * Update form status in UI */ updateFormStatus(formConfig, status) { // Add status indicator to form or show notification const statusElement = formConfig.element.querySelector('.form-status'); if (statusElement) { statusElement.className = `form-status ${status}`; statusElement.textContent = this.getStatusMessage(status); } } /** * Get human-readable status message */ getStatusMessage(status) { const messages = { 'queued': 'Queued for saving...', 'pending': 'Waiting to save...', 'processing': 'Saving...', 'completed': 'Saved successfully', 'failed': 'Save failed' }; return messages[status] || status; } /********************************************************* * * EVENT HANDLERS * *********************************************************/ handleSubmit(event) { const form = event.target.closest('form'); if (!form || !this.forms.has(form.dataset.formId)) return; this.checkHiddenTabs(this.forms.get(form.dataset.formId)); event.preventDefault(); const formConfig = this.forms.get(form.dataset.formId); // Call form-specific submit callback if provided if (formConfig.options.onSubmit) { formConfig.options.onSubmit(event, this.collectFormData(formConfig.element)); } else { // Default submit behavior this.handleSave(this.collectFormData(formConfig.element), formConfig); } } checkHiddenTabs(formConfig) { if (!('tabs' in formConfig)) { return; } let sections = formConfig.element.querySelectorAll('section'); for (let section of sections) { let requiredInputs = section.querySelector('[required]'); if (requiredInputs) { formConfig.tabs.switchTab(section.id); return; } } } handleChange(event) { // Cache target checks const target = event.target; const form = target.form || target.closest('form'); console.log('[form]Handling change'); // Early return if not a managed form if (!form?.dataset.formId || !this.forms.has(form.dataset.formId)) return; console.log('[form]Managing this form'); // Image fields handled separately if (window.targetCheck(event, '.image.field')) { return; } console.log('[form] Not image field'); const formConfig = this.forms.get(form.dataset.formId); const fieldName = event.target.name; // Check conditionals immediately for all fields this.checkConditionals(fieldName, formConfig); console.log('[form]Conditionals checked'); // Handle special field types this.handleSpecialFields(event, formConfig); // Skip autosave if disabled if ('noautosave' in form.dataset) { console.log('No Autosave set'); return; } if (fieldName.includes('::')) { const groupName = fieldName.split('::')[0]; this.scheduleSave(formConfig, { type: 'field', fieldName: groupName, reason: event.type, delay: 3000 }); } else if (fieldName.includes(':')) { const row = target.closest('.repeater-row'); if (row) { const repeater = row.closest('.field.repeater'); const repeaterName = repeater.dataset.field; const delay = this.getRepeaterChangeDelay(event.target, event.type); this.scheduleSave(formConfig, { type: 'field', fieldName: repeaterName, reason: event.type, delay: delay }); } } else { // Schedule form-wide save this.scheduleSave(formConfig, { type: 'form', reason: 'change' }); } } handleClick(event) { const form = event.target.closest('form'); if (!form || !this.forms.has(form.dataset.formId)) return; const formConfig = this.forms.get(form.dataset.formId); if (window.targetCheck(event, '.add-repeater-row')) { const repeater = event.target.closest('.repeater'); this.addRepeaterRow(repeater, formConfig); // Schedule save after add this.scheduleSave(formConfig, { type: 'field', fieldName: repeater.dataset.field, reason: 'add', delay: this.repeaterDelays.add }); } else if (window.targetCheck(event, '.remove-row')) { const repeater = event.target.closest('.repeater'); this.removeRepeaterRow(event.target, formConfig); // Schedule save after remove this.scheduleSave(formConfig, { type: 'field', fieldName: repeater.dataset.field, reason: 'remove', delay: this.repeaterDelays.remove }); } else if (window.targetCheck(event, '.remove-image')) { this.handleImageRemove(event.target.closest('.field')); } else if (window.targetCheck(event, '.replace-image')) { let fileInput = event.closest('.image').querySelector('input[type="file"]'); fileInput.click(); } else if (window.targetCheck(event, 'div.quantity')) { let quantity = window.targetCheck(event, 'div.quantity'); this.handleNumberClick(event, quantity); } else if (window.targetCheck(event, '.tab-navigation')) { formConfig.tabs.switchTab(event.target.dataset.navigateTo); } } handleFocus(event) { const form = event.target.closest('form'); if (!form || !this.forms.has(form.dataset.formId)) return; const repeaterRow = event.target.closest('.repeater-row'); if (!repeaterRow) return; const formConfig = this.forms.get(form.dataset.formId); const rowId = repeaterRow.id || this.generateRowId(repeaterRow); // Store the currently active repeater field this.activeRepeaters.set(form.dataset.formId, { rowId: rowId, element: event.target, formConfig: formConfig }); } handleBlur(event) { const form = event.target.closest('form'); if (!form || !this.forms.has(form.dataset.formId)) return; // Handle repeater field blur with shorter delay const repeaterRow = event.target.closest('.repeater-row'); if (repeaterRow) { const repeater = repeaterRow.closest('.field.repeater'); this.scheduleSave(this.forms.get(form.dataset.formId), { type: 'field', fieldName: repeater.dataset.field, reason: 'blur', delay: this.repeaterDelays.blur }); } } handleKeys(e) { const MAX_MULTIPLIER = 10000; if (e.ctrlKey && e.shiftKey) { this.stepMultiplier = Math.min( Math.max(this.stepMultiplier * 100, 1000), MAX_MULTIPLIER ); } else if (e.shiftKey) { this.stepMultiplier = Math.min( Math.max(this.stepMultiplier * 10, 100), MAX_MULTIPLIER ); } else if (e.key === 'Escape') { this.stepMultiplier = 1; } } /** * Initialize global event listeners (single delegation point) */ initListeners() { if (this.initialized) return; // Use event delegation for all forms document.addEventListener('submit', this.submitHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('click', this.clickHandler); document.addEventListener('keydown', this.keyHandler); document.addEventListener('focusin', this.focusHandler); document.addEventListener('focusout', this.blurHandler); this.initialized = true; } removeChangeListener() { document.removeEventListener('change', this.changeHandler); } addChangeListener() { document.addEventListener('change', this.changeHandler); } /******************************************************************* * * Data Processing * *******************************************************************/ // collectFormData(form) { // const formData = new FormData(form); // let data = {}; // //push any fields that need all parts to a 'sendAll' property // let repeaterData = {}; // // for (let [key, value] of formData.entries()) { // if (this.ignore.includes(key) || key.endsWith('_temp')) { // continue; // } // // let post = null; // let original = null; // // //If we're on a table view, we need to organize this by post ID // if (key.includes('|')) { // [post, key] = key.split('|'); // //Temporarily store data to merge later // original = data; // data = original[post] ?? {}; // } // // // Check if this is a repeater field (3 parts: field:index:name) // const keyParts = key.split(':'); // if (keyParts.length === 3) { // // Handle repeater fields (field:index:name) // let [fieldName, index, rawSubField] = keyParts; // // // Handle array fields (remove [] brackets) // const isArrayField = rawSubField.endsWith('[]'); // const subField = rawSubField.replace('[]', ''); // // // Initialize repeater structure // if (!repeaterData[fieldName]) { // repeaterData[fieldName] = {}; // } // if (!repeaterData[fieldName][index]) { // repeaterData[fieldName][index] = {}; // } // // // Handle different field types for repeater // let fieldValue = value; // // // Only check radio buttons since FormData handles checkboxes correctly // const fieldElement = form.querySelector(`[name="${key}"]`); // if (fieldElement && fieldElement.type === 'radio' && !fieldElement.checked) { // continue; // Skip unchecked radios // } // // // Handle array fields (like checkbox groups) // if (isArrayField || repeaterData[fieldName][index][subField]) { // // Initialize as array if not already // if (!repeaterData[fieldName][index][subField]) { // repeaterData[fieldName][index][subField] = []; // } else if (!Array.isArray(repeaterData[fieldName][index][subField])) { // repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]]; // } // repeaterData[fieldName][index][subField].push(fieldValue); // } else { // // Single value field // repeaterData[fieldName][index][subField] = fieldValue; // } // // } else if (keyParts.length === 2) { // // Handle group fields (group:field) // let [groupName, fieldName] = keyParts; // // // Initialize group structure if needed // if (!data[groupName]) { // data[groupName] = {}; // } // // // Handle array values for group fields (checkboxes, etc.) // if (data[groupName][fieldName]) { // if (!Array.isArray(data[groupName][fieldName])) { // data[groupName][fieldName] = [data[groupName][fieldName]]; // } // data[groupName][fieldName].push(value); // } else { // data[groupName][fieldName] = value; // } // // } else if (key.includes('[')) { // let [fieldKey, v ] = key.split('['); // v = v.replace(']',''); // if (!Object.hasOwn(data, fieldKey)) { // data[fieldKey] = {}; // // if (!Object.hasOwn(data, 'sendAll')) { // data['sendAll'] = [fieldKey]; // } else if (!data['sendAll'].includes(fieldKey)) { // data['sendAll'].push(fieldKey); // } // } // data[fieldKey][v] = value; // // } else { // // Handle regular fields (no colons or single field name) // // Handle array values (multiple checkboxes/selects) // if (data[key]) { // if (!Array.isArray(data[key])) { // data[key] = [data[key]]; // } // data[key].push(value); // } else { // data[key] = value; // } // } // // if (post) { // //Merge back with original // original[post] = data; // data = original; // } // } // // return data; // } collectFormData(form) { const formData = new FormData(form); let data = {}; const repeaterData = {}; const postData = {}; for (let [key, value] of formData.entries()) { if (this.ignore.includes(key) || key.endsWith('_temp')) continue; const processor = this.getFieldProcessor(key); processor(key, value, data, repeaterData, postData, form); } if (!window.isEmptyObject(postData)) { data = this.mergeRepeaterData(data, repeaterData); return this.mergePostData(data, postData); } return this.mergeRepeaterData(data, repeaterData); } getFieldProcessor(key) { if (key.includes('|')) return this.processTableField; if (key.includes('::')) return this.processGroupField; if (key.includes(':')) return this.processRepeaterField; if (key.includes('[')) return this.processLocationField; return this.processRegularField; } mergeRepeaterData(data, repeaterData) { Object.keys(repeaterData).forEach(fieldName => { // Clean up empty rows and convert to array format const cleanedRows = {}; Object.keys(repeaterData[fieldName]).forEach(index => { const rowData = repeaterData[fieldName][index]; if (Object.keys(rowData).length > 0) { cleanedRows[index] = rowData; } }); // Convert to sequential array data[fieldName] = Object.values(cleanedRows); }); return data; } mergePostData(data, postData) { for (let [postId, postData] in Object.entries(postData)) { data[postId] = postData; } return data; } processTableField(key, value, data, repeaterData, postData, form) { /*** * Table forms are a huge form containing multiple posts and their data * Field names are prepended with `${postID}|` * Goal: * 1) Separate out the post id from the field name * 2) store the original data in a temporary 'original' variable * 3) Process the field as normal * 4) return the original data, as PostID: {$field data} * Final format: * { * id1: { * field1: "A title", * field3: 32 * }, * id2: { * field1: "Another title", * field2: "122,21,32" * } * } **/ let [post, fieldKey] = key.split('|'); if (!post in postData) { postData[post] = {}; } const processor = this.getFieldProcessor(fieldKey); processor(fieldKey, value, postData, repeaterData, postData, form); } processRepeaterField(key, value, data, repeaterData, postData, form) { let [fieldName, index, subField] = key.split(':'); const isArray = subField.endsWith('[]'); subField = subField.replace('[]', ''); //Ensure this repeater and row is in repeaterData if (!repeaterData[fieldName]) { repeaterData[fieldName] = {}; } if (!repeaterData[fieldName][index]) { repeaterData[fieldName][index] = {}; } if (isArray || repeaterData[fieldName][index][subField]) { // Initialize as array if not already if (!repeaterData[fieldName][index][subField]) { repeaterData[fieldName][index][subField] = []; } else if (!Array.isArray(repeaterData[fieldName][index][subField])) { repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]]; } repeaterData[fieldName][index][subField].push(value); } else { // Single value field repeaterData[fieldName][index][subField] = value; } } processGroupField(key, value, data, repeaterData, postData, form) { const keys = key.split('::'); const rootGroup = keys[0]; // Initialize root group if it doesn't exist if (!data[rootGroup]) { data[rootGroup] = {}; } // Build nested structure step by step let current = data[rootGroup]; for (let i = 1; i < keys.length - 1; i++) { const groupKey = keys[i]; if (!current[groupKey]) { current[groupKey] = {}; } current = current[groupKey]; } // Set the final field value const fieldKey = keys[keys.length - 1]; // Handle array values (checkboxes, multi-selects) if (current[fieldKey] !== undefined) { if (!Array.isArray(current[fieldKey])) { current[fieldKey] = [current[fieldKey]]; } current[fieldKey].push(value); } else { current[fieldKey] = value; } } processLocationField(key, value, data, repeaterData, postData, form) { let [fieldKey, v ] = key.split('['); v = v.replace(']',''); if (!Object.hasOwn(data, fieldKey)) { data[fieldKey] = {}; if (!Object.hasOwn(data, 'sendAll')) { data['sendAll'] = [fieldKey]; } else if (!data['sendAll'].includes(fieldKey)) { data['sendAll'].push(fieldKey); } } data[fieldKey][v] = value; } processRegularField(key, value, data, repeaterData, postData, form) { //handle array values (like checkboxes/selects) if (data[key]) { if (!Array.isArray(data[key])) { data[key] = [data[key]]; } data[key].push(value); } else { data[key] = value; } } /** * Get differences between old and new data */ getDataChanges(newData, oldData, deep = false) { return window.getDifferences?.map(oldData, newData) || {}; } async handleSave(changes, formConfig) { console.log(changes); if (changes.length === 0 || window.isEmptyObject(changes)) { return; } // Cache the current form state before operation await this.cacheFormData(formConfig); if (typeof formConfig.options.onSave === "function") { formConfig.options.onSave(changes, formConfig); } else if (formConfig.element.dataset.save) { let endpoint = formConfig.element.dataset.save; let title = formConfig.element.dataset.title ?? endpoint; const operation = { endpoint: endpoint, headers: { 'action_nonce': jvbSettings.dash }, title: `Saving ${title}`, popup: `Saving ${title}...`, data: changes, formId: formConfig.id, onComplete: (operation) => this.handleOperationComplete(operation), onUpdate: (operation) => this.handleOperationUpdate(operation) }; try { const operationId = await window.jvbQueue.addToQueue(operation); // Track the operation this.trackOperation(formConfig.id, operationId, operation); // Update cached data with operation ID await this.updateCachedFormOperation(formConfig.id, operationId); } catch (error) { this.handleError('Failed to queue form operation:', error); // Remove from cache if queueing failed await this.removeCachedForm(formConfig.id); } } } /** * Unified save scheduling for both forms and specific fields * @param {Object} formConfig - Form configuration * @param {Object} options - Save options */ scheduleSave(formConfig, options = {}) { console.log('Scheduling save...'); const opts = { type: 'form', // 'form' or 'field' fieldName: null, // Required for field saves reason: 'change', // 'change', 'blur', 'add', 'remove', 'reorder' delay: null, // Uses form saveDelay if null ...options }; // Generate timeout key const timeoutKey = opts.type === 'field' ? `${formConfig.id}-${opts.fieldName}-${opts.type}` : `${formConfig.id}-${opts.type}`; // Determine delay const delay = opts.delay ?? this.getSaveDelay(formConfig, opts); console.log(timeoutKey); console.log(delay); this.debouncer.schedule( timeoutKey, () => { this.processChanges(formConfig, opts)}, delay ); } /** * Unified change processing for both forms and fields * @param {Object} formConfig - Form configuration * @param {Object} options - What to process */ processChanges(formConfig, options = {}) { const opts = { type: 'form', fieldName: null, processSave: true, ... options }; let changes; if (opts.type === 'field' && opts.fieldName) { // Process specific field changes changes = this.collectFieldChanges(formConfig, opts.fieldName); } else { console.log('formConfig: ', formConfig); // Process entire form changes const newData = this.collectFormData(formConfig.element); changes = this.getDataChanges(newData, formConfig.data); //Check for any fields that are flagged to send all data (mainly location fields) if (newData.sendAll) { newData.sendAll.forEach(key => { changes[key] = newData[key]; }); } formConfig.data = newData; } if (Object.keys(changes).length > 0) { formConfig.state.isDirty = true; if (opts.processSave) { console.log('Handling Save....'); this.handleSave(changes, formConfig); } } } hasUploadsBlocking(formConfig) { return formConfig.state.uploadPending || this.hasActiveUploads(formConfig); } getSaveDelay(formConfig, options) { if (options.type === 'field') { // Use repeater delays for field-level saves switch (options.reason) { case 'typing': return this.repeaterDelays.typing; case 'blur': return this.repeaterDelays.blur; case 'add': return this.repeaterDelays.add; case 'remove': return this.repeaterDelays.remove; case 'reorder': return this.repeaterDelays.reorder; case 'change': default: return this.repeaterDelays.change; } } // Use form-level delay return formConfig.options.saveDelay; } /** * Collect changes for a specific field (used for repeaters) */ collectFieldChanges(formConfig, fieldName) { const currentData = this.collectFormData(formConfig.element); // Return only the specified field data const fieldChanges = { [fieldName]: currentData[fieldName] || [] }; // Update stored data for this field if (!formConfig.data) { formConfig.data = {}; } formConfig.data[fieldName] = currentData[fieldName]; return fieldChanges; } /************************************************************ * * CONDITIONAL LOGIC * ************************************************************/ initConditionalFields(formConfig) { const form = formConfig.element; form.querySelectorAll('[data-depends-on]').forEach(field => { const dependsOn = field.dataset.dependsOn; if (!formConfig.dependencies.has(dependsOn)) { formConfig.dependencies.set(dependsOn, []); } formConfig.dependencies.get(dependsOn).push(field); const triggerValue = this.getFieldValue(form, dependsOn); const requiredValue = field.dataset.dependsValue; const operator = field.dataset.dependsOperator || '=='; const shouldShow = this.evaluateCondition(triggerValue, requiredValue, operator); this.toggleFieldVisibility(field, shouldShow); }); } handleSpecialFields(event, formConfig) { // Handle repeater field changes if (event.target.closest('.repeater-row')) { this.updateRepeaterOrder(event.target.closest('.repeater')); } } checkConditionals(changed, formConfig) { const dependencies = formConfig.dependencies.get(changed); if (!dependencies) return; this.updateConditionalFields(changed, dependencies, formConfig); } updateConditionalFields(changed, dependencies, formConfig) { const triggerValue = this.getFieldValue(formConfig.element, changed); dependencies.forEach(field => { this.checkDependencies(field, triggerValue); }); } checkDependencies(field, value) { const requiredValue = field.dataset.dependsValue; const operator = field.dataset.dependsOperator || '=='; const shouldShow = this.evaluateCondition(value, requiredValue, operator); this.toggleFieldVisibility(field, shouldShow); } evaluateCondition(fieldValue, requiredValue, operator) { // Handle null/undefined values if (fieldValue === null || fieldValue === undefined) { fieldValue = ''; } if (requiredValue === null || requiredValue === undefined) { requiredValue = ''; } // Convert both to strings for consistent comparison (since form values are always strings) const fieldStr = String(fieldValue); const requiredStr = String(requiredValue); switch (operator) { case '==': return fieldStr == requiredStr; // Loose equality case '!=': return fieldStr != requiredStr; // Loose inequality case '===': return fieldStr === requiredStr; // Strict equality (if needed) case '!==': return fieldStr !== requiredStr; // Strict inequality (if needed) case '>': return Number(fieldValue) > Number(requiredValue); case '>=': return Number(fieldValue) >= Number(requiredValue); case '<': return Number(fieldValue) < Number(requiredValue); case '<=': return Number(fieldValue) <= Number(requiredValue); case 'contains': return fieldStr.toLowerCase().includes(requiredStr.toLowerCase()); case 'not_contains': return !fieldStr.toLowerCase().includes(requiredStr.toLowerCase()); case 'starts_with': return fieldStr.toLowerCase().startsWith(requiredStr.toLowerCase()); case 'ends_with': return fieldStr.toLowerCase().endsWith(requiredStr.toLowerCase()); case 'empty': return fieldStr.trim() === ''; case 'not_empty': return fieldStr.trim() !== ''; case 'in': // For array/comma-separated values: requiredValue = "option1,option2,option3" const options = requiredStr.split(',').map(opt => opt.trim()); return options.includes(fieldStr); case 'not_in': const notOptions = requiredStr.split(',').map(opt => opt.trim()); return !notOptions.includes(fieldStr); default: return false; } } getFieldValue(form, fieldName) { const field = form.querySelector(`[name="${fieldName}"]`); if (!field) return null; if (field.type === 'radio') { const checked = form.querySelector(`[name="${fieldName}"]:checked`); return checked ? checked.value : null; } else if (field.type === 'checkbox') { return field.checked ? (field.value || '1') : ''; } return field.value; } toggleFieldVisibility(field, show) { const wrapper = field.closest('.field, fieldset'); if (!wrapper) return; wrapper.hidden = !show; wrapper.querySelectorAll('input, select, textarea').forEach(control => { control.hidden = !show; control.disabled = !show; }); } /************************************************** * * REPEATER FUNCTIONALITY * **************************************************/ initRepeaterFields(form) { form.querySelectorAll('.repeater').forEach(repeater => { this.hasRepeaters = true; const addButton = repeater.querySelector('.add-repeater-row'); let temp = repeater.querySelector('template').className; let template = window.getTemplate(temp); const container = repeater.querySelector('.repeater-items'); if (!addButton || !template || !container) { console.warn('Missing required repeater elements:', {addButton, template, container}); return; } // Initialize Sortable new Sortable(container, { handle: '.repeater-row-header', animation: 150, onEnd: () => { this.updateRepeaterOrder(repeater); } }); }); } addRepeaterRow(repeater, formConfig) { let rows = repeater.querySelector('.repeater-items'); let index = rows.children.length; let row = window.getTemplate(repeater.querySelector('template').className); // Set a proper ID for the row row.id = `${formConfig.id}-${repeater.dataset.field}-row-${index}`; let base = repeater.dataset.field; row.querySelectorAll('input, select, textarea').forEach((field) => { let label = field.nextElementSibling; if (!label || label.tagName !== 'LABEL') { label = field.closest('.field')?.querySelector(`label`); } let id = `${base}:${index}:${field.name}-${field.value}`; let name = `${base}:${index}:${field.name}`; [field.name, field.id, label.htmlFor] = [name, id, id]; }); [row.dataset.index, row.querySelector('.row-number').textContent] = [index, `#${index + 1}`]; rows.append(row); // Focus the first input in the new row const firstInput = row.querySelector('input, select, textarea'); if (firstInput) { firstInput.focus(); } } removeRepeaterRow(removeButton, formConfig) { let repeater = removeButton.closest('.repeater'); removeButton.closest('.repeater-row').remove(); this.updateRepeaterOrder(repeater); } updateRepeaterOrder(repeater) { requestAnimationFrame(() => { let items = repeater.querySelector('.repeater-items'); let updates = []; let base = repeater.dataset.field; const form = repeater.closest('form'); const formConfig = this.forms.get(form.dataset.formId); // Update field names and row numbers items.querySelectorAll('.repeater-row').forEach((row, index) => { updates.push(() => { [ row.dataset.index, row.querySelector('.row-number').textContent ] = [ index, `#${index + 1}` ]; row.querySelectorAll('[name]').forEach(field => { let name = field.name.split(':').pop(); let newName = `${base}:${index}:${name}`; let newId = `${base}-${index}-${name}-${field.value}`; [ field.name, field.id ] = [ newName, newId ]; let label = field.closest('.field').querySelector('label'); if (label) { label.htmlFor = newId; } }); }); }); updates.forEach(update => update()); // Schedule save after reorder this.scheduleSave(formConfig, { type: 'field', fieldName: base, reason: 'reorder', delay: this.repeaterDelays.reorder }); }); } getRepeaterChangeDelay(field, eventType) { // Text inputs get longer delays to allow for typing if ((field.type === 'text' || field.type === 'textarea') && eventType === 'input') { return this.repeaterDelays.typing; } // Other input types get standard change delay if (eventType === 'change') { return this.repeaterDelays.change; } // Blur events get shorter delay if (eventType === 'blur') { return this.repeaterDelays.blur; } return this.repeaterDelays.change; } generateRowId(repeaterRow) { if (repeaterRow.id) return repeaterRow.id; // Generate ID based on form and row position const form = repeaterRow.closest('form'); const repeater = repeaterRow.closest('.repeater'); const index = Array.from(repeater.querySelectorAll('.repeater-row')).indexOf(repeaterRow); const repeaterId = repeater.dataset.field || 'repeater'; const generatedId = `${form.dataset.formId}-${repeaterId}-row-${index}`; repeaterRow.id = generatedId; return generatedId; } /***************************************************** * * FIELD POPULATION (used by CRUD edit/bulk-edit modals and table view) * *****************************************************/ /** * Populate form fields with data values * @param {HTMLFormElement} form - The form element * @param {Object} fieldsData - Field values from API * @param {Object} imagesData - Image metadata (optional) * @param {Object} options - Additional options */ populateFormFields(form, fieldsData, imagesData = {}, options = {}) { const formConfig = this.forms.get(form.dataset.formId); // Build field type cache once if (!formConfig.fieldTypeCache) { formConfig.fieldTypeCache = new Map(); form.querySelectorAll('.field').forEach(field => { const fieldName = field.dataset.field; if (fieldName) { formConfig.fieldTypeCache.set(fieldName, this.getFieldType(field)); } }); } // Use cached types for (let [fieldName, fieldValue] of Object.entries(fieldsData)) { let fieldWrapper = form.querySelector(`[data-field=${fieldName}]`); if (fieldWrapper) { this.populateFieldValue(fieldWrapper, fieldName, fieldValue, imagesData, options); } } } /** * Populate a single field with its value * @param {HTMLElement} fieldWrapper - The field wrapper element * @param {string} fieldName - Field name * @param {*} fieldValue - Field value * @param {Object} imagesData - Image metadata * @param {Object} options - Additional options */ populateFieldValue(fieldWrapper, fieldName, fieldValue, imagesData = {}, options = {}) { if (!fieldWrapper || fieldValue === undefined || fieldValue === null) { return; } // Determine field type from classes or data attributes const fieldType = this.getFieldType(fieldWrapper); switch (fieldType) { case 'image': this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData); break; case 'gallery': this.populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData); break; case 'repeater': this.populateRepeaterField(fieldWrapper, fieldName, fieldValue, options); break; case 'taxonomy': this.populateTaxonomyField(fieldWrapper, fieldName, fieldValue); break; case 'user': this.populateUserField(fieldWrapper, fieldName, fieldValue); break; case 'location': this.populateLocationField(fieldWrapper, fieldName, fieldValue); break; case 'set': case 'checkbox': this.populateSetField(fieldWrapper, fieldName, fieldValue); break; case 'select': case 'radio': this.populateSelectField(fieldWrapper, fieldName, fieldValue); break; case 'true_false': this.populateBooleanField(fieldWrapper, fieldName, fieldValue); break; case 'date': case 'time': case 'datetime': this.populateDateField(fieldWrapper, fieldName, fieldValue); break; case 'number': this.populateNumberField(fieldWrapper, fieldName, fieldValue); break; case 'textarea': if (fieldWrapper.querySelector('.editor-container')) { this.populateEditorField(fieldWrapper, fieldName, fieldValue); } else { this.populateTextareaField(fieldWrapper, fieldName, fieldValue); } break; case 'text': case 'email': case 'url': case 'tel': case 'phone': default: this.populateTextField(fieldWrapper, fieldName, fieldValue); break; } } /** * Determine field type from wrapper element * @param {HTMLElement} fieldWrapper - Field wrapper element * @returns {string} Field type */ getFieldType(fieldWrapper) { // Check for specific field classes const typeClasses = [ 'image', 'gallery', 'repeater', 'taxonomy', 'user', 'location', 'set', 'checkbox', 'select', 'radio', 'true_false', 'date', 'time', 'datetime', 'editor', 'number', 'text', 'textarea', 'email', 'url', 'tel', 'phone' ]; for (const type of typeClasses) { if (fieldWrapper.classList.contains(type)) { return type; } } // Check for data attribute if (fieldWrapper.dataset.type) { return fieldWrapper.dataset.type; } // Check input type const input = fieldWrapper.querySelector('input, select, textarea'); if (input) { if (input.tagName === 'TEXTAREA') { return input.dataset.editor === 'true' ? 'editor' : 'textarea'; } if (input.type) { return input.type === 'checkbox' && !fieldWrapper.classList.contains('true_false') ? 'set' : input.type; } } return 'text'; } /** * Populate text-based fields */ populateTextField(fieldWrapper, fieldName, fieldValue) { const input = fieldWrapper.querySelector(`[name="${fieldName}"], input, textarea`); if (input) { input.value = String(fieldValue || ''); // Update character counter if present if (input.dataset.limit) { const counter = fieldWrapper.querySelector('.char-count .current'); if (counter) { counter.textContent = input.value.length; } } } } /** * Populate textarea fields */ populateTextareaField(fieldWrapper, fieldName, fieldValue) { const textarea = fieldWrapper.querySelector(`textarea[name="${fieldName}"]`) || fieldWrapper.querySelector('textarea:not([data-editor="true"])'); if (textarea) { const oldValue = textarea.value; textarea.value = String(fieldValue || ''); // Trigger change event to update any dependencies textarea.dispatchEvent(new Event('change', { bubbles: true })); // Update character counter if present if (textarea.dataset.limit) { const counter = fieldWrapper.querySelector('.char-count .current'); if (counter) { counter.textContent = textarea.value.length; // Check if limit is reached const limit = parseInt(textarea.dataset.limit, 10); if (textarea.value.length >= limit) { fieldWrapper.classList.add('reached'); } else { fieldWrapper.classList.remove('reached'); } } } } else { console.warn(`No textarea found for field ${fieldName} in wrapper:`, fieldWrapper); } } /** * Populate number fields */ populateNumberField(fieldWrapper, fieldName, fieldValue) { const input = fieldWrapper.querySelector(`[name="${fieldName}"], input[type="number"]`); if (input) { input.value = Number(fieldValue) || 0; } } /** * Populate boolean/true_false fields */ populateBooleanField(fieldWrapper, fieldName, fieldValue) { const input = fieldWrapper.querySelector(`[name="${fieldName}"], input[type="checkbox"]`); if (input) { input.checked = Boolean(fieldValue); } } /** * Populate select/radio fields */ populateSelectField(fieldWrapper, fieldName, fieldValue) { const value = String(fieldValue || ''); // Try select first const select = fieldWrapper.querySelector(`select[name="${fieldName}"]`); if (select) { select.value = value; return; } // Try radio buttons const radio = fieldWrapper.querySelector(`input[type="radio"][name="${fieldName}"][value="${value}"]`); if (radio) { radio.checked = true; } } /** * Populate set/checkbox fields (multiple selections) */ populateSetField(fieldWrapper, fieldName, fieldValue) { // Parse value if it's a string let values = fieldValue; if (typeof fieldValue === 'string') { try { values = JSON.parse(fieldValue); } catch (e) { values = fieldValue.split(',').map(v => v.trim()); } } if (!Array.isArray(values)) { values = [String(values)]; } // Update checkboxes fieldWrapper.querySelectorAll(`input[type="checkbox"][name*="${fieldName}"]`).forEach(checkbox => { checkbox.checked = values.includes(checkbox.value); }); } /** * Populate date/time fields */ populateDateField(fieldWrapper, fieldName, fieldValue) { const input = fieldWrapper.querySelector(`[name="${fieldName}"], input`); if (input && fieldValue) { // Handle different date formats let dateValue = fieldValue; if (typeof fieldValue === 'object' && fieldValue.date) { dateValue = fieldValue.date; } // Convert to appropriate format for input type try { const date = new Date(dateValue); if (!isNaN(date.getTime())) { switch (input.type) { case 'date': input.value = date.toISOString().split('T')[0]; break; case 'time': input.value = date.toTimeString().slice(0, 5); break; case 'datetime-local': input.value = date.toISOString().slice(0, 16); break; default: input.value = dateValue; } } } catch (e) { input.value = dateValue; } } } /** * Populate editor fields (Quill) */ populateEditorField(fieldWrapper, fieldName, fieldValue) { const textarea = fieldWrapper.querySelector(`textarea[name="${fieldName}"]`) || fieldWrapper.querySelector('textarea[data-editor="true"]') || fieldWrapper.querySelector('textarea'); if (!textarea) { console.warn(`Editor field ${fieldName}: textarea not found`); return; } const content = String(fieldValue || ''); // Update the textarea value textarea.value = content; // Try to find and update Quill editor const editorContainer = fieldWrapper.querySelector('.editor'); if (editorContainer) { // Try different ways to access the Quill instance let quillInstance = null; // Method 1: Check if Quill is stored on the editor element if (editorContainer.__quill) { quillInstance = editorContainer.__quill; } // Method 2: Check if Quill is stored as quill property else if (editorContainer.quill) { quillInstance = editorContainer.quill; } // Method 3: Try to find Quill in the global registry (if you have one) else if (window.Quill && window.Quill.find) { quillInstance = window.Quill.find(editorContainer); } // Method 4: Check all Quill instances if available else if (window.Quill && window.Quill.instances) { // Some setups store instances in a registry for (let instance of window.Quill.instances) { if (instance.container === editorContainer) { quillInstance = instance; break; } } } if (quillInstance) { // Set the content in Quill quillInstance.root.innerHTML = content; // Store the instance reference for future use editorContainer.__quill = quillInstance; } else { console.warn(`Quill instance not found for ${fieldName}, setting HTML directly`); // Fallback: set HTML directly editorContainer.innerHTML = content; } } else { console.warn(`Editor container not found for ${fieldName}`); } // Trigger change event on textarea textarea.dispatchEvent(new Event('change', { bubbles: true })); } /** * Populate location fields */ populateLocationField(fieldWrapper, fieldName, fieldValue) { if (!fieldValue || typeof fieldValue !== 'object') { return; } // Location fields typically have sub-fields const subFields = ['address', 'lat', 'lng', 'street', 'city', 'province', 'postal_code', 'country']; subFields.forEach(subField => { if (fieldValue[subField] !== undefined) { const input = fieldWrapper.querySelector(`[name="${fieldName}_${subField}"], [name="${subField}"]`); if (input) { input.value = String(fieldValue[subField] || ''); } } }); } /** * Populate taxonomy fields */ populateTaxonomyField(fieldWrapper, fieldName, fieldValue) { // Handle different value formats let termIds = []; if (Array.isArray(fieldValue)) { termIds = fieldValue.map(v => String(v)); } else if (typeof fieldValue === 'string') { try { const parsed = JSON.parse(fieldValue); termIds = Array.isArray(parsed) ? parsed.map(v => String(v)) : [String(parsed)]; } catch (e) { termIds = fieldValue.split(',').map(v => v.trim()); } } else if (fieldValue) { termIds = [String(fieldValue)]; } if (termIds.length === 0) { return; } // Update hidden input const hiddenInput = fieldWrapper.querySelector(`input[type="hidden"][name="${fieldName}"]`); if (hiddenInput) { hiddenInput.value = termIds.join(','); } // Update taxonomy selector if present if (fieldWrapper.dataset.fieldId && window.jvbSelector) { window.jvbSelector.updateFieldFromInput(fieldWrapper.dataset.fieldId); } } /** * Populate user fields (similar to taxonomy) */ populateUserField(fieldWrapper, fieldName, fieldValue) { // Similar logic to taxonomy fields this.populateTaxonomyField(fieldWrapper, fieldName, fieldValue); } /** * Populate image fields */ populateImageField(fieldWrapper, fieldName, fieldValue, imagesData = {}) { if (!fieldValue) { return; } // Handle comma-separated IDs or single ID const imageIds = String(fieldValue).split(',').filter(id => parseInt(id.trim())); if (imageIds.length === 0) { return; } // Update hidden input const hiddenInput = fieldWrapper.querySelector(`input[type="hidden"][name="${fieldName}"]`); if (hiddenInput) { hiddenInput.value = imageIds.join(','); } // Update image display const grid = fieldWrapper.querySelector('.item-grid'); const uploadContainer = fieldWrapper.querySelector('.file-upload-container'); if (grid) { imageIds.forEach(imageId => { let image = window.getTemplate('uploadItem'); let img = image.querySelector('img'); let details = image.querySelector('details'); let meta = window.getTemplate('uploadMeta') details.append(meta); [ img.src, img.alt, image.querySelector('[name="image-title"]').value, image.querySelector('[name="image-alt-text"]').value, image.querySelector('[name="image-caption"]').value, ] = [ imagesData[imageId].medium, imagesData[imageId].alt, imagesData[imageId].title, imagesData[imageId].alt, imagesData[imageId].caption, ]; details.querySelector('.upload-meta > .hint')?.remove(); grid.append(image); }); if (imageIds.length > 0) { if (uploadContainer) { uploadContainer.hidden = true; } } } } /** * Populate gallery fields */ populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData = {}) { this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData); } /** * Populate repeater fields */ populateRepeaterField(fieldWrapper, fieldName, fieldValue, options = {}) { if (!fieldValue || !Array.isArray(fieldValue)) { return; } const container = fieldWrapper.querySelector('.repeater-items'); const template = fieldWrapper.querySelector('template'); if (!container || !template) { console.warn(`Repeater field ${fieldName}: missing container or template`); return; } // Clear existing rows window.removeChildren(container); // Create rows for each data item fieldValue.forEach((rowData, index) => { if (!rowData || typeof rowData !== 'object') { return; } const row = window.getTemplate(template.className); if (!row) { console.warn(`Repeater field ${fieldName}: template not found`); return; } // Set row ID and update row number row.id = `${fieldWrapper.closest('form').id}-${fieldName}-row-${index}`; row.dataset.index = index; const rowNumber = row.querySelector('.row-number'); if (rowNumber) { rowNumber.textContent = `#${index + 1}`; } // Update field names and populate values row.querySelectorAll('input, select, textarea').forEach(field => { const originalName = field.name; const newName = `${fieldName}:${index}:${originalName}`; const newId = `${fieldName}-${index}-${originalName}-${field.value}`; // Update field identifiers field.name = newName; field.id = newId; // Update label const label = field.nextElementSibling; if (label && label.tagName === 'LABEL') { label.htmlFor = newId; } // Populate field value if (rowData[originalName] !== undefined) { this.populateRepeaterFieldValue(field, originalName, rowData[originalName]); } }); container.appendChild(row); }); } /** * Populate individual repeater field value */ populateRepeaterFieldValue(field, fieldName, fieldValue) { switch (field.type) { case 'checkbox': field.checked = Boolean(fieldValue); break; case 'radio': field.checked = field.value === String(fieldValue); break; case 'select-one': case 'select-multiple': field.value = String(fieldValue || ''); break; default: field.value = String(fieldValue || ''); } } /***************************************************** * * QUILL * *****************************************************/ initQuillEditor(formConfig) { let form = formConfig.element; const textareas = form.querySelectorAll('textarea[data-editor=true]'); textareas.forEach(textarea => { let container, editor, toolbar; //create it if it doesn't exist if(!textarea.parentNode.querySelector('.editor-container')){ container = document.createElement('div'); container.className = 'editor-container'; editor = document.createElement('div'); editor.className = 'editor'; toolbar = document.createElement('div'); toolbar.className = 'toolbar'; const image = textarea.dataset.allowimage === true ? `` : ''; toolbar.id = `toolbar-${textarea.id}`; toolbar.innerHTML = ` ${image} `; container.appendChild(toolbar); container.appendChild(editor); textarea.parentNode.insertBefore(container, textarea); textarea.style.display = 'none'; editor.innerHTML = textarea.value; }else{ container = textarea.parentNode.querySelector('.editor-container'); editor = container.querySelector('.editor'); toolbar = container.querySelector('.toolbar'); } const quill = new Quill(editor, { theme: 'snow', modules: { toolbar: { container: toolbar, handlers: { p: function() { this.quill.format('header', false); }, h1: function() { this.quill.format('header', 1); }, h2: function() { this.quill.format('header', 2); }, h3: function() { this.quill.format('header', 3); }, jvb_bold: function() {this.quill.format('bold', true)}, jvb_italic: function() {this.quill.format('italic', true)}, jvb_strike: function() {this.quill.format('strike', true)}, jvb_underline: function() {this.quill.format('underline', true)}, 'jvb_align': function(value) { this.quill.format('align', value === this.quill.getFormat().list ? false : value); }, 'jvb_list': function(value) { this.quill.format('list', value === this.quill.getFormat().list ? false : value); }, 'jvb_link': function(value) { if (value) { const range = this.quill.getSelection(); if (range == null || range.length === 0) return; // Get the existing link if any const preview = this.quill.getText(range.index, range.length); const existingLink = this.quill.getFormat(range).link; // Create modal for link input const modal = document.createElement('dialog'); modal.className = 'quill-link-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.showModal(); const input = modal.querySelector('input'); input.focus(); // Handle save modal.querySelector('.save').addEventListener('click', () => { const url = input.value; if (url) { this.quill.format('link', url); } modal.remove(); }); // Handle remove if link exists const removeBtn = modal.querySelector('.remove'); if (removeBtn) { removeBtn.addEventListener('click', () => { this.quill.format('link', false); modal.remove(); }); } // Handle cancel modal.querySelector('.cancel').addEventListener('click', () => { modal.remove(); }); // Handle Enter key input.addEventListener('keyup', (e) => { if (e.key === 'Enter') { const url = input.value; if (url) { this.quill.format('link', url); } modal.remove(); } }); } }, 'jvb_image': function() { const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp'); input.style.display = 'none'; document.body.appendChild(input); input.onchange = async (e) => { const file = e.target.files?.[0]; if (!file) return; // Validate file const maxSize = 5242880; // 5MB if (file.size > maxSize) { this.quill.insertText(range.index, 'File too large. Maximum size is 5MB', { 'color': '#f00', 'italic': true }, true); input.remove(); return; } const range = this.quill.getSelection(true); const formData = new FormData(); formData.append('image', file); if (objectID) { formData.append('post_id', objectID); } // Show loading state if (window.jvbLoading) { window.jvbLoading.showLoading('Uploading image...', 'Processing Upload'); } try { const response = await fetch( `${jvbSettings.api}uploads/`, { method: 'POST', headers: { 'X-WP-Nonce': jvbSettings.nonce }, body: formData } ); if (!response.ok) { throw new Error('Upload failed'); } const result = await response.json(); // Insert the image at cursor position this.quill.insertEmbed(range.index, 'image', result.url); } catch (error) { this.handleError('Upload error:', error); this.quill.insertText(range.index, 'Failed to upload image. Please try again.', { 'color': '#f00', 'italic': true }, true); } finally { if (window.jvbLoading) { window.jvbLoading.hide(); } input.remove(); } }; input.click(); } } }, history: { delay: 2000, maxStack: 500 }, clipboard: { matchVisual: false } } }); quill.on('selection-change', function(range) { const alignmentTools = toolbar.querySelector('.ql-align'); if (alignmentTools) { if (range && range.length === 0) { // Get the focused element const [leaf] = this.quill.getLeaf(range.index); if (leaf && leaf.domNode && leaf.domNode.tagName === 'IMG') { alignmentTools.style.display = 'inline-block'; return; } } alignmentTools.style.display = 'none'; } }); // Update hidden textarea and trigger form change quill.on('text-change', () => { textarea.value = quill.root.innerHTML; textarea.dispatchEvent(new Event('change', { bubbles: true })); }); }); } /***************************************************** * * CHARACTER LIMITS * *****************************************************/ initCharacterLimits(formConfig) { formConfig.element.querySelectorAll('input[data-limit], textarea[data-limit]').forEach(input => { const counter = input.closest('.field').querySelector('.char-count .current'); if (counter) { const updateCount = () => { const limit = parseInt(input.dataset.limit, 10); // Update the counter counter.textContent = input.value.length; // If length exceeds limit, truncate the input value if (input.value.length > limit) { input.closest('.field').classList.add('reached'); input.value = input.value.substring(0, limit); counter.textContent = limit; // Update counter after truncation }else { input.closest('.field').classList.remove('reached'); } }; input.addEventListener('input', updateCount); updateCount(); // Initial count } }) } /***************************************************** * * NUMBER FIELDS * *****************************************************/ handleNumberClick(e, container) { let change = 0; if (e.target.closest('.increase')) { change += 1; } else if (e.target.closest('.decrease')) { change -=1; } if (change !== 0) { let [ step, input ] = [ parseInt(container.dataset.step), container.querySelector('input'), ]; let value = (input.value === '') ? 0 : parseInt(input.value); input.value = (value + (step * change * this.stepMultiplier)); this.handleNumberLimits(container); } } handleNumberLimits(container) { let [ min, max, input, increase, decrease ] = [ container.dataset.min, container.dataset.max, container.querySelector('input'), container.querySelector('.increase'), container.querySelector('.decrease') ]; let value = parseInt(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; } } /***************************************************** * * GALLERY * TODO: Does the uploader handle this on its own now? * *****************************************************/ initImageFields(formConfig) { formConfig.element.querySelectorAll('.image').forEach(field => { const fieldName = field.querySelector('input[type="hidden"]').name; const fileInput = field.querySelector('input[type="file"]'); if (!fileInput) return; // Register with centralized upload manager const fieldId = window.jvbUploadManager.registerUploader(field, { content: formConfig.options.content, postId: formConfig.options.itemID, mode: 'direct', uploadType: 'image_upload', fieldName: fieldName, maxFiles: 1, autoSubmit: true, // Custom callbacks for this field type onUploadStart: () => { formConfig.state.uploadPending = true; }, onUploadComplete: (result) => { formConfig.state.uploadPending = false; this.handleImageUploadSuccess(result, field); }, onUploadError: (error) => { formConfig.state.uploadPending = false; this.handleImageUploadError(error, field); } }); // Store field reference for cleanup if (!formConfig.uploadFields) { formConfig.uploadFields = new Set(); } formConfig.uploadFields.add(fieldId); }); } handleImageRemove(field) { const imageDisplay = field.querySelector('.image-display'); const img = imageDisplay.querySelector('img'); const hiddenInput = field.querySelector('input[type="hidden"]'); const uploadContainer = field.querySelector('.file-upload-container'); // Clear the hidden input hiddenInput.value = ''; // Reset UI img.src = ''; imageDisplay.classList.remove('has-image'); uploadContainer.hidden = false; // Show notification this.showNotification('Image removed'); } handleImageUploadSuccess(result, field) { if (!result.data || !result.data.length) return; const imageDisplay = field.querySelector('.image-display'); removeChildren(imageDisplay); imageDisplay.classList.add('has-image'); let ids = []; result.data.forEach(file => { let img = new Image(); img.src = file.url; ids.push(file.attachment_id); imageDisplay.appendChild(img); }); const hiddenInput = field.querySelector('input[type="hidden"]'); hiddenInput.value = ids.join(','); const uploadContainer = field.querySelector('.file-upload-container'); uploadContainer.hidden = true; // Trigger form change event hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); this.showNotification('Image updated successfully'); } handleImageUploadError(error, field) { this.handleError('Upload error:', error); // Clear upload pending state const form = field.closest('form'); if (form && form.dataset.formId) { const formConfig = this.forms.get(form.dataset.formId); if (formConfig) { formConfig.state.uploadPending = false; } } this.showNotification('Failed to upload image', 'error'); // Reset field if needed const uploadContainer = field.querySelector('.file-upload-container'); uploadContainer.hidden = false; // Clear any error states const errorElement = field.querySelector('.file-error'); if (errorElement) { errorElement.textContent = ''; } } initGalleryFields(formConfig) { formConfig.element.querySelectorAll('.gallery').forEach(field => { const fieldName = field.querySelector('input[type="hidden"]').name; const previewGrid = field.querySelector('.gallery-preview'); // Pre-populate existing images if (field.dataset.images) { const urls = field.dataset.images.split(','); urls.forEach(url => { this.addToGalleryPreview(url, previewGrid); }); } // Register with centralized upload manager const fieldId = window.jvbUploadManager.registerUploader(field, { content: formConfig.options.content, postId: formConfig.options.itemID, mode: 'gallery', uploadType: 'image_upload', fieldName: fieldName, maxFiles: 20, allowMultiple: true, groupable: true, onUploadComplete: (result) => { this.handleGalleryUploadSuccess(result, field); }, }); // Store field reference if (!formConfig.uploadFields) { formConfig.uploadFields = new Set(); } formConfig.uploadFields.add(fieldId); }); } handleGalleryUploadSuccess(result, field) { if (!result.data || !result.data.length) return; const hiddenInput = field.querySelector('input[type="hidden"]'); const previewGrid = field.querySelector('.gallery-preview'); const currentIds = hiddenInput.value ? hiddenInput.value.split(',') : []; result.data.forEach(file => { currentIds.push(file.attachment_id); this.addToGalleryPreview(file.url, previewGrid); }); hiddenInput.value = currentIds.join(','); hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); this.showNotification(`Added ${result.data.length} image(s) to gallery`); } addToGalleryPreview(url, grid) { let preview = window.getTemplate('galleryPreview'); let img = preview.querySelector('img'); img.src = url; grid.appendChild(preview); return preview; } /***************************************************** * * UPLOAD INTEGRATION * TODO: This may not be necessary. I believe the uploads handles any form changes in the backend * *****************************************************/ hasActiveUploads(formConfig) { if (!formConfig.uploadFields || !window.jvbUploadManager) { return false; } // Check each upload field for active uploads for (const fieldId of formConfig.uploadFields) { const status = window.jvbUploadManager.getFieldStatus(fieldId); if (status && (status.uploading > 0 || status.ready > 0)) { return true; } } return false; } /*************************************************** * * * ***************************************************/ showNotification(msg, type){ window.jvbNotifications.showToast(msg, type); } getForm(formId) { return this.forms.get(formId); } processForm(formId) { const formConfig = this.forms.get(formId); if (formConfig) { this.processChanges(formConfig); } } isFormReadyToSave(formId) { const formConfig = this.forms.get(formId); if (!formConfig) return true; return !formConfig.state.uploadPending && !this.hasActiveUploads(formConfig); } getFormUploadProgress(formId) { const formConfig = this.forms.get(formId); if (!formConfig || !formConfig.uploadFields) return null; let totalUploads = 0; let completedUploads = 0; let failedUploads = 0; formConfig.uploadFields.forEach(fieldId => { const status = window.jvbUploadManager.getFieldStatus(fieldId); if (status) { totalUploads += status.uploadCount; completedUploads += status.completed; failedUploads += status.failed; } }); return { total: totalUploads, completed: completedUploads, failed: failedUploads, pending: totalUploads - completedUploads - failedUploads, progress: totalUploads > 0 ? (completedUploads / totalUploads) * 100 : 0 }; } // Remove form from management removeForm(formId) { const formConfig = this.forms.get(formId); if (!formConfig) return; // Clean up regular timeouts if (formConfig.state.saveTimeout) { clearTimeout(formConfig.state.saveTimeout); } // Clean up upload fields if (formConfig.uploadFields && window.jvbUploadManager) { formConfig.uploadFields.forEach(fieldId => { console.log(`Cleaning up upload field: ${fieldId}`); }); } // Clean up cached data this.removeCachedForm(formId); // Clean up active operations if (formConfig.state.operationId) { this.activeOperations.delete(formConfig.state.operationId); } this.forms.delete(formId); } cleanup() { // Clean up debouncer if (this.debouncer) { this.debouncer.cleanup(); } // Clear all maps this.activeRepeaters.clear(); this.activeOperations.clear(); this.pendingForms.clear(); // Clean up all forms for (let [formId, formConfig] of this.forms) { this.removeForm(formId); } // Remove event listeners if (this.initialized) { document.removeEventListener('submit', this.submitHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('click', this.clickHandler); document.removeEventListener('keydown', this.keyHandler); document.removeEventListener('focusin', this.focusHandler); document.removeEventListener('focusout', this.blurHandler); this.initialized = false; } } handleError(error, context) { // In production, send to error tracking service if (window.jvbError) { window.jvbError.log(error, context); } // In development only if (jvbSettings.debug) { console.error(context, error); } } } // Initialize singleton with auto-scanning document.addEventListener('DOMContentLoaded', () => { if (!window.jvbForm) { window.jvbForm = new FormFields(); } }); window.addEventListener('beforeunload', () => window.jvbForm?.cleanup());