| | |
| | | /** |
| | | * Enhanced FormController - Manages forms with special fields, caching, and queue integration |
| | | * Works with DataStore for CRUD operations and standalone for front-end forms |
| | | */ |
| | | class FormController { |
| | | constructor() { |
| | | this.store = new window.jvbStore({ |
| | | name:'forms', |
| | | storeName: 'forms', |
| | | keyPath: 'formId', |
| | | indexes: [ |
| | | { name: 'status', keyPath: 'status' }, |
| | | { name: 'operationId', keyPath: 'operationId' }, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | | { name: 'formType', keyPath: 'type' } |
| | | ], |
| | | TTL: 604800000, //7 days |
| | | }); |
| | | constructor(config = {}) { |
| | | this.config = { |
| | | collectFormData: false, |
| | | ... config |
| | | } |
| | | this.isRestoring = false; |
| | | const store = window.jvbStore.register( |
| | | 'forms', |
| | | { |
| | | storeName: 'forms', |
| | | keyPath: 'formId', |
| | | indexes: [ |
| | | { name: 'status', keyPath: 'status' }, |
| | | { name: 'operationId', keyPath: 'operationId' }, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | | { name: 'formType', keyPath: 'type' } |
| | | ], |
| | | TTL: 7 * 24 * 60 * 1000, //7 days |
| | | validateData: true, |
| | | delayFetch: true |
| | | }); |
| | | this.store = store.forms; |
| | | |
| | | this.debouncer = window.debouncer; |
| | | |
| | |
| | | this.specialFields = new Map(); |
| | | this.dependencies = new Map(); |
| | | |
| | | // Validation (YOU ARE GREAT!) |
| | | this.validators = this.initValidators(); |
| | | this.touchedFields = new Set(); |
| | | |
| | | // Auto-save configuration |
| | | this.autoSaveDefaults = { |
| | | delay: 3000, // 3 seconds |
| | |
| | | reorder: 1000 |
| | | }; |
| | | |
| | | this.isTimeline = window.crudManager && window.crudManager.isTimeline; |
| | | |
| | | // 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); |
| | | //Processors |
| | | this.processRepeaterField = this.processRepeaterField.bind(this); |
| | | this.processGroupField = this.processGroupField.bind(this); |
| | | this.processLocationField = this.processLocationField.bind(this); |
| | | this.processRegularField = this.processRegularField.bind(this); |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | | async init() { |
| | | // Check for pending operations on page load |
| | | await this.checkPendingOperations(); |
| | | |
| | | this.store.subscribe(this.handleStoreEvent.bind(this)); |
| | | |
| | | // Set up global form handlers for standalone forms |
| | | this.initListeners(); |
| | | if (window.jvbQueue) { |
| | | window.jvbQueue.subscribe((event, data) => { |
| | | if (event === 'operation-completed' && data.type === 'form') { |
| | | this.handleOperationComplete(data); |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle operation completion - clear related form cache |
| | | */ |
| | | async handleOperationComplete(operation) { |
| | | // Clear the form data from store |
| | | if (operation.formId) { |
| | | try { |
| | | await this.store.delete(operation.formId); |
| | | } catch (error) { |
| | | console.warn('Failed to clear form cache:', error); |
| | | } |
| | | } |
| | | |
| | | // Clear any related form state |
| | | const form = this.forms.get(operation.formId); |
| | | if (form) { |
| | | form.isDirty = false; |
| | | form.lastSaved = Date.now(); |
| | | form.data = {}; |
| | | } |
| | | } |
| | | |
| | | handleStoreEvent(event, data) { |
| | | switch(event) { |
| | | case 'item-saved': |
| | | if (data.item.status === 'autosave') { |
| | | this.showFormStatus(data.item.formId, 'autosave'); |
| | | // this.showFormStatus(data.item.formId, 'autosave'); |
| | | } |
| | | break; |
| | | case 'data-loaded': |
| | | |
| | | this.checkPendingForms(); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check for pending operations from previous session |
| | | * Check for pending forms from current page |
| | | */ |
| | | async checkPendingOperations() { |
| | | const pendingForms = await this.store.query('status', 'pending'); |
| | | async checkPendingForms() { |
| | | const allForms = await this.store.getAll(); |
| | | const currentPath = window.location.pathname; |
| | | |
| | | if (pendingForms.length === 0) return; |
| | | const pendingForms = allForms.filter(form => { |
| | | if (form.status !== 'draft') return false; |
| | | |
| | | // Group by form type or page |
| | | const grouped = this.groupPendingForms(pendingForms); |
| | | // Check if form is from current page |
| | | const formPath = form.data?._wp_http_referer; |
| | | return formPath === currentPath; |
| | | }); |
| | | |
| | | // Show consolidated notification |
| | | this.showPendingNotification(grouped); |
| | | pendingForms.forEach(item => { |
| | | const formElement = this.findFormElement(item); |
| | | if (!formElement) return; |
| | | |
| | | // Register form if not already registered |
| | | let formConfig = this.forms.get(item.formId); |
| | | if (!formElement.dataset.formId) { |
| | | formConfig = this.registerForm(formElement); |
| | | } |
| | | |
| | | // Set flag to prevent event handlers from firing |
| | | this.isRestoring = true; |
| | | // Auto-populate the form |
| | | new this.populateForm(formElement, item.data); |
| | | |
| | | // Reset flag after a tick (gives DOM time to settle) |
| | | setTimeout(() => { |
| | | this.isRestoring = false; |
| | | }, 0); |
| | | |
| | | // Show restore status |
| | | this.showFormStatus(item.formId, 'restored'); |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Your previous entry has been restored'); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Find form element that matches the cached data |
| | | */ |
| | | findFormElement(formData) { |
| | | // Try by form_id first (hidden field) |
| | | if (formData.data?.form_id) { |
| | | const form = document.querySelector(`[name="form_id"][value="${formData.data.form_id}"]`)?.closest('form'); |
| | | if (form) return form; |
| | | } |
| | | |
| | | // Try by form_type |
| | | if (formData.data?.form_type) { |
| | | const form = document.querySelector(`[name="form_type"][value="${formData.data.form_type}"]`)?.closest('form'); |
| | | if (form) return form; |
| | | } |
| | | |
| | | // Fallback: try by formId (if it was already registered) |
| | | return document.querySelector(`[data-form-id="${formData.formId}"]`); |
| | | } |
| | | |
| | | /** |
| | | * Show notification for pending changes |
| | | */ |
| | | showPendingNotification(pendingData) { |
| | | const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`); |
| | | /** |
| | | * Show notification for pending changes |
| | | */ |
| | | showPendingNotification(formId, formData) { |
| | | const formElement = document.querySelector(`[data-form-id="${formId}"]`); |
| | | if (!formElement) return; |
| | | |
| | | const notification = document.createElement('div'); |
| | | notification.className = 'pending-changes-notification'; |
| | | notification.innerHTML = ` |
| | | <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> |
| | | `; |
| | | <p>We noticed unsaved changes from last time. Would you like to restore them?</p> |
| | | <button class="restore-changes" data-form-id="${formId}">Restore</button> |
| | | <button class="discard-changes" data-form-id="${formId}">Discard</button> |
| | | `; |
| | | |
| | | formElement.insertBefore(notification, formElement.firstChild); |
| | | |
| | | // Add handlers |
| | | notification.querySelector('.restore-changes').addEventListener('click', () => { |
| | | this.restorePendingForm(pendingData); |
| | | notification.querySelector('.restore-changes').addEventListener('click', async () => { |
| | | await this.restorePendingForm(formId, formData); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('.discard-changes').addEventListener('click', () => { |
| | | this.discardPendingForm(pendingData.formId); |
| | | notification.querySelector('.discard-changes').addEventListener('click', async () => { |
| | | await this.discardPendingForm(formId); |
| | | notification.remove(); |
| | | }); |
| | | } |
| | |
| | | /** |
| | | * Restore pending form data |
| | | */ |
| | | restorePendingForm(pendingData) { |
| | | const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`); |
| | | async restorePendingForm(formId, formData) { |
| | | const form = document.querySelector(`[data-form-id="${formId}"]`); |
| | | if (!form) return; |
| | | |
| | | // Populate form with cached data |
| | | new this.populateForm(form, pendingData.formData); |
| | | new this.populateForm(form, formData); |
| | | |
| | | // Mark as restored |
| | | pendingData.status = 'restored'; |
| | | this.pendingForms.set(pendingData.formId, pendingData); |
| | | // Update status in store (mark as restored, not draft) |
| | | await this.store.save({ |
| | | formId: formId, |
| | | data: formData, |
| | | status: 'restored', |
| | | timestamp: Date.now() |
| | | }); |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Previous changes restored'); |
| | |
| | | * Discard pending form data |
| | | */ |
| | | async discardPendingForm(formId) { |
| | | this.store.delete(formId); |
| | | try { |
| | | await this.store.delete(formId); |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Previous changes discarded'); |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Previous changes discarded'); |
| | | } |
| | | } catch (error) { |
| | | console.error('Failed to discard pending form:', error); |
| | | } |
| | | } |
| | | |
| | |
| | | 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); |
| | | document.addEventListener('input', this.inputHandler); |
| | | this.globalHandlersAdded = true; |
| | | } |
| | | } |
| | |
| | | * Register a standalone form (for front-end forms) |
| | | */ |
| | | registerForm(formElement, options = {}) { |
| | | if (!formElement) return; |
| | | const formId = formElement.dataset.formId || `form_${Date.now()}`; |
| | | formElement.dataset.formId = formId; |
| | | |
| | | formElement.addEventListener('submit', this.submitHandler); |
| | | |
| | | const formConfig = { |
| | | element: formElement, |
| | | id: formId, |
| | | status: '', |
| | | options: { |
| | | autoSave: true, |
| | | autosave: 'autosave' in formElement.dataset, |
| | | autoUpload: true, |
| | | saveDelay: this.autoSaveDefaults.delay, |
| | | endpoint: formElement.dataset.save, |
| | | endpoint: formElement.dataset.save ?? '', |
| | | formStatus: true, |
| | | cache: true, |
| | | ...options |
| | | }, |
| | | dependencies: new Map(), |
| | | data: this.collectFormData(formElement), |
| | | isDirty: false |
| | | data: this.collectFormData(formElement, true), |
| | | }; |
| | | |
| | | // Initialize special fields |
| | | this.initializeFormFields(formElement, formConfig); |
| | | |
| | | // Store form config |
| | | this.forms.set(formId, formConfig); |
| | | |
| | | // Check for pending data |
| | | // Check for pending data - FIXED |
| | | if (this.store && formConfig.options.cache) { |
| | | const cached = this.store.getForm(formId); |
| | | if (cached && cached.formData) { |
| | | this.showPendingNotification(cached); |
| | | const cached = this.store.get(formId); |
| | | if (cached && cached.data) { |
| | | this.showPendingNotification(formId, cached.data); |
| | | } |
| | | } |
| | | |
| | |
| | | // Initialize repeater fields |
| | | this.initRepeaterFields(form, formConfig); |
| | | |
| | | this.initTagListFields(form, formConfig); |
| | | |
| | | // Initialize conditional fields |
| | | if (formConfig) { |
| | | this.initConditionalFields(form, formConfig); |
| | |
| | | this.initCharacterLimits(form); |
| | | |
| | | // Initialize image upload fields |
| | | this.initImageUploadFields(form); |
| | | this.initImageUploadFields(form, formConfig); |
| | | |
| | | // Initialize tabs if present |
| | | if (window.jvbTabs && form.querySelector('nav.tabs')) { |
| | | new window.jvbTabs(form); |
| | | formConfig.tabs = new window.jvbTabs(form); |
| | | this.forms.set(formConfig.formId, formConfig); |
| | | this.initSteppedForm(formConfig.formId); |
| | | } |
| | | |
| | | // Scan for existing selector fields |
| | | if (window.jvbSelector) { |
| | | window.jvbSelector.scanExistingFields(); |
| | | window.jvbSelector.scanExistingFields(form); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize stepped form functionality |
| | | */ |
| | | initSteppedForm(formId) { |
| | | const formConfig = this.forms.get(formId); |
| | | const form = formConfig.element; |
| | | const tabsInstance = formConfig.tabs; |
| | | |
| | | const sections = form.querySelectorAll('.tab-content'); |
| | | const totalSteps = sections.length; |
| | | const progressBar = form.querySelector('.form-progress .fill'); |
| | | const stepText = form.querySelector('.step-text .current'); |
| | | const tabButtons = form.querySelectorAll('nav.tabs button'); |
| | | |
| | | // Update progress display |
| | | const updateProgress = (currentStep) => { |
| | | const progress = (currentStep / totalSteps) * 100; |
| | | if (progressBar) { |
| | | progressBar.style.width = progress + '%'; |
| | | } |
| | | if (stepText) { |
| | | stepText.textContent = currentStep; |
| | | } |
| | | |
| | | // Update tab states |
| | | tabButtons.forEach((btn, idx) => { |
| | | const stepNum = idx + 1; |
| | | btn.classList.remove('current', 'completed', 'pending'); |
| | | |
| | | if (stepNum < currentStep) { |
| | | btn.classList.add('completed'); |
| | | } else if (stepNum === currentStep) { |
| | | btn.classList.add('current'); |
| | | } else { |
| | | btn.classList.add('pending'); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | // Next/Previous button handling |
| | | form.addEventListener('click', (e) => { |
| | | const nextBtn = e.target.closest('[data-action="next-step"]'); |
| | | const prevBtn = e.target.closest('[data-action="prev-step"]'); |
| | | |
| | | if (nextBtn) { |
| | | e.preventDefault(); |
| | | const currentSection = nextBtn.closest('.tab-content'); |
| | | const currentStep = parseInt(currentSection.dataset.step); |
| | | const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`); |
| | | |
| | | if (nextSection && this.validateStep(currentSection)) { |
| | | const nextTab = nextSection.dataset.tab; |
| | | tabsInstance.switchTab(nextTab, true); |
| | | updateProgress(currentStep + 1); |
| | | |
| | | // Scroll to top of form |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | } |
| | | |
| | | if (prevBtn) { |
| | | e.preventDefault(); |
| | | const currentSection = prevBtn.closest('.tab-content'); |
| | | const currentStep = parseInt(currentSection.dataset.step); |
| | | const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`); |
| | | |
| | | if (prevSection) { |
| | | const prevTab = prevSection.dataset.tab; |
| | | tabsInstance.switchTab(prevTab, true); |
| | | updateProgress(currentStep - 1); |
| | | |
| | | // Scroll to top of form |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | // Update progress when tabs are clicked directly |
| | | const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance); |
| | | tabsInstance.switchTab = (tab, updateHistory) => { |
| | | originalSwitchTab(tab, updateHistory); |
| | | const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`); |
| | | if (activeSection) { |
| | | const step = parseInt(activeSection.dataset.step); |
| | | updateProgress(step); |
| | | } |
| | | }; |
| | | |
| | | // Initialize progress |
| | | updateProgress(1); |
| | | } |
| | | |
| | | /** |
| | | * Validate current step before allowing progression |
| | | * Can be enhanced with custom validation rules |
| | | */ |
| | | validateStep(section) { |
| | | const fields = section.querySelectorAll('.field'); |
| | | let allValid = true; |
| | | |
| | | fields.forEach(fieldWrapper => { |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input && !input.closest('[hidden]')) { |
| | | const isValid = this.validateField(input, fieldWrapper); |
| | | if (!isValid) { |
| | | allValid = false; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | return allValid; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Initialize Quill editors |
| | | */ |
| | | initQuillEditors(form) { |
| | |
| | | |
| | | container.appendChild(row); |
| | | |
| | | // Schedule save if auto-save enabled |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | if (formConfig) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'add', |
| | |
| | | this.updateRepeaterOrder(repeater, formConfig); |
| | | |
| | | // Schedule save |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | if (formConfig) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'remove', |
| | |
| | | }); |
| | | |
| | | // Schedule save |
| | | if (formConfig && formConfig.options.autoSave) { |
| | | if (formConfig) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'repeater', |
| | | action: 'reorder', |
| | |
| | | } |
| | | |
| | | /** |
| | | * Initialize tag list fields |
| | | */ |
| | | initTagListFields(form, formConfig) { |
| | | form.querySelectorAll('.field.tag-list').forEach(field => { |
| | | const inputRow = field.querySelector('.tag-input-row'); |
| | | const addButton = field.querySelector('.add-tag-item'); |
| | | const tagsContainer = field.querySelector('.tag-items'); |
| | | const template = field.querySelector('.tag-template'); |
| | | const fieldName = field.dataset.field; |
| | | const tagFormat = field.dataset.tagFormat || 'first_field'; |
| | | |
| | | if (!inputRow || !addButton || !tagsContainer || !template) return; |
| | | |
| | | // Get all input fields in the input row (excluding the button) |
| | | const getInputFields = () => { |
| | | return Array.from(inputRow.querySelectorAll('input, select, textarea')) |
| | | .filter(input => !input.closest('button')); |
| | | }; |
| | | |
| | | // Add tag handler |
| | | const addTag = () => { |
| | | const inputs = getInputFields(); |
| | | const data = {}; |
| | | let hasValue = false; |
| | | |
| | | // Collect values from inputs |
| | | inputs.forEach(input => { |
| | | const fieldName = input.name.replace('new_', ''); |
| | | const value = this.getFieldValue(input); |
| | | |
| | | if (value) hasValue = true; |
| | | data[fieldName] = value; |
| | | }); |
| | | |
| | | if (!hasValue) { |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Please fill in at least one field', 'error'); |
| | | } |
| | | inputs[0].focus(); |
| | | return; |
| | | } |
| | | |
| | | // Validate required fields using data-required attribute |
| | | const invalidField = inputs.find(input => { |
| | | const isRequired = ('required' in input.dataset && input.dataset.required === '1'); |
| | | const value = this.getFieldValue(input); |
| | | return isRequired && !value; |
| | | }); |
| | | |
| | | if (invalidField) { |
| | | const fieldWrapper = invalidField.closest('.field'); |
| | | const fieldLabel = fieldWrapper?.querySelector('label')?.textContent || 'This field'; |
| | | this.showError(fieldWrapper, `${fieldLabel} is required.`); |
| | | |
| | | invalidField.focus(); |
| | | return; |
| | | } |
| | | |
| | | for (let input of inputs) { |
| | | let wrapper = field.closest('.field'); |
| | | if (!this.validateField(input, wrapper)){ |
| | | input.focus(); |
| | | return; |
| | | } |
| | | } |
| | | |
| | | // Clone template and populate |
| | | const index = tagsContainer.children.length; |
| | | const newTag = template.content.cloneNode(true).firstElementChild; |
| | | newTag.dataset.index = index; |
| | | |
| | | // Update tag label |
| | | const tagLabel = newTag.querySelector('.tag-label'); |
| | | if (tagLabel) { |
| | | tagLabel.textContent = this.getTagDisplayText(data, tagFormat); |
| | | } |
| | | |
| | | // Update hidden inputs |
| | | newTag.querySelectorAll('input[type="hidden"]').forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${fieldName}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | tagsContainer.appendChild(newTag); |
| | | |
| | | // Clear inputs |
| | | inputs.forEach(input => { |
| | | if (input.type === 'checkbox' || input.type === 'radio') { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | let field = input.closest('.field'); |
| | | this.clearValidation(field); |
| | | }); |
| | | |
| | | // Focus first input |
| | | if (inputs.length > 0) { |
| | | inputs[0].focus(); |
| | | } |
| | | |
| | | |
| | | // Schedule save |
| | | if (formConfig) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'tag_list', |
| | | action: 'add', |
| | | fieldName: fieldName, |
| | | delay: this.autoSaveDefaults.delay |
| | | }); |
| | | } |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Item added'); |
| | | } |
| | | }; |
| | | |
| | | // Add button click |
| | | addButton.addEventListener('click', addTag); |
| | | |
| | | // Enter key support on last input |
| | | const inputs = getInputFields(); |
| | | if (inputs.length > 0) { |
| | | // Tab through inputs, Enter on last one adds the tag |
| | | inputs[inputs.length - 1].addEventListener('keypress', (e) => { |
| | | if (e.key === 'Enter') { |
| | | e.preventDefault(); |
| | | addTag(); |
| | | } |
| | | }); |
| | | |
| | | // Enter on other inputs moves to next field |
| | | inputs.slice(0, -1).forEach((input, i) => { |
| | | input.addEventListener('keypress', (e) => { |
| | | if (e.key === 'Enter') { |
| | | e.preventDefault(); |
| | | inputs[i + 1].focus(); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | // Remove tag handler |
| | | tagsContainer.addEventListener('click', (e) => { |
| | | if (e.target.closest('.remove-tag')) { |
| | | const tag = e.target.closest('.tag-item'); |
| | | const tagText = tag.querySelector('.tag-label')?.textContent || 'Item'; |
| | | |
| | | tag.remove(); |
| | | |
| | | // Reindex remaining tags |
| | | this.reindexTagList(tagsContainer, fieldName); |
| | | |
| | | // Schedule save |
| | | if (formConfig) { |
| | | this.scheduleSave(formConfig, { |
| | | type: 'tag_list', |
| | | action: 'remove', |
| | | fieldName: fieldName, |
| | | delay: this.autoSaveDefaults.delay |
| | | }); |
| | | } |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce(`${tagText} removed`); |
| | | } |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Reindex tag list items |
| | | */ |
| | | reindexTagList(container, baseFieldName) { |
| | | Array.from(container.children).forEach((tag, index) => { |
| | | tag.dataset.index = index; |
| | | |
| | | tag.querySelectorAll('input[type="hidden"]').forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${baseFieldName}:${index}:${fieldKey}`; |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Get display text for tag based on format |
| | | */ |
| | | getTagDisplayText(data, format) { |
| | | const values = Object.values(data).filter(v => v); |
| | | |
| | | if (values.length === 0) return 'New Item'; |
| | | |
| | | switch (format) { |
| | | case 'first_field': |
| | | return values[0]; |
| | | |
| | | case 'all_fields': |
| | | return values.join(', '); |
| | | |
| | | default: |
| | | // Template format like "{name} ({email})" |
| | | if (format.includes('{')) { |
| | | let text = format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | text = text.replace(`{${key}}`, value); |
| | | } |
| | | return text; |
| | | } |
| | | // Use specific field |
| | | return data[format] || values[0]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * HTML escape helper |
| | | */ |
| | | escapeHtml(text) { |
| | | const div = document.createElement('div'); |
| | | div.textContent = text; |
| | | return div.innerHTML; |
| | | } |
| | | |
| | | /** |
| | | * Initialize conditional fields |
| | | */ |
| | | initConditionalFields(form, formConfig) { |
| | |
| | | const requiredStr = String(requiredValue || ''); |
| | | |
| | | switch (operator) { |
| | | case '==': return fieldStr == requiredStr; |
| | | case '!=': return fieldStr != requiredStr; |
| | | 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 'contains': return fieldStr.includes(requiredStr); |
| | | case 'empty': return fieldStr === ''; |
| | | case 'not_empty': return fieldStr !== ''; |
| | | default: return fieldStr == requiredStr; |
| | | default: return fieldStr === requiredStr; |
| | | } |
| | | } |
| | | |
| | |
| | | /** |
| | | * Initialize image upload fields |
| | | */ |
| | | initImageUploadFields(form) { |
| | | window.jvbUploads.scanFields(form); |
| | | initImageUploadFields(form, config) { |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload); |
| | | } |
| | | |
| | | /* ========== Event Handlers ========== */ |
| | | |
| | | handleSubmit(event) { |
| | | //TODO: submit data, if successful, delete from store |
| | | if (this.subscribers.size > 0 ){ |
| | | const form = event.target; |
| | | if (!form.dataset.formId) return; |
| | | this.store.delete(form.dataset.formId); |
| | | async handleSubmit(event) { |
| | | const form = event.target; |
| | | |
| | | if (!form.dataset.formId) return; |
| | | const formConfig = this.forms.get(form.dataset.formId); |
| | | |
| | | // Handle subscriber-based forms |
| | | if (this.subscribers.size > 0) { |
| | | event.preventDefault(); |
| | | |
| | | const formConfig = this.forms.get(form.dataset.formId); |
| | | if (!formConfig) return; |
| | | |
| | | const formData = this.collectFormData(form); |
| | | |
| | | event.preventDefault(); |
| | | // Notify subscribers (they'll handle actual submission) |
| | | this.notify('form-submit', { |
| | | formId: formConfig.id, |
| | | data: formData, |
| | | formId: form.dataset.formId, |
| | | fullData: formData, |
| | | config: formConfig |
| | | }); |
| | | } |
| | | } |
| | | |
| | | handleFormSuccess(form, data) { |
| | | // Clear previous errors |
| | | form.querySelectorAll('.error-message').forEach(el => el.remove()); |
| | | form.querySelectorAll('.field-error').forEach(el => |
| | | el.classList.remove('field-error') |
| | | ); |
| | | |
| | | // Add success class to form |
| | | form.classList.add('form-success'); |
| | | |
| | | // Show success message if provided |
| | | if (data.message) { |
| | | const success = document.createElement('div'); |
| | | success.className = 'form-success-message success-message'; |
| | | success.textContent = data.message; |
| | | form.insertBefore(success, form.firstChild); |
| | | |
| | | const icon = window.getIcon?.('check-circle'); |
| | | if (icon) { |
| | | icon.classList.add('success-icon'); |
| | | success.prepend(icon); |
| | | } |
| | | } |
| | | |
| | | // If there's a title/description (for registration success) |
| | | if (data.title || data.description) { |
| | | const successBox = document.createElement('div'); |
| | | successBox.className = 'success-box'; |
| | | |
| | | if (data.title) { |
| | | const title = document.createElement('h3'); |
| | | title.textContent = data.title; |
| | | successBox.appendChild(title); |
| | | } |
| | | |
| | | if (data.description) { |
| | | const descriptions = Array.isArray(data.description) |
| | | ? data.description |
| | | : [data.description]; |
| | | |
| | | descriptions.forEach(desc => { |
| | | const p = document.createElement('p'); |
| | | p.textContent = desc; |
| | | successBox.appendChild(p); |
| | | }); |
| | | } |
| | | |
| | | form.insertBefore(successBox, form.firstChild); |
| | | } |
| | | |
| | | // DELETE CACHED FORM DATA ON SUCCESS |
| | | if (form.dataset.formId) { |
| | | this.store.delete(form.dataset.formId).catch(err => { |
| | | console.warn('Failed to clear form cache:', err); |
| | | }); |
| | | |
| | | // Clear form config dirty state |
| | | const formConfig = this.forms.get(form.dataset.formId); |
| | | if (formConfig) { |
| | | formConfig.isDirty = false; |
| | | formConfig.lastSaved = Date.now(); |
| | | formConfig.data = {}; // Clear cached data |
| | | } |
| | | } |
| | | |
| | | // Announce success for accessibility |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce(data.message || 'Form submitted successfully'); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-success', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | handleFormError(form, data) { |
| | | // Clear all previous errors |
| | | form.querySelectorAll('.error-message').forEach(el => el.remove()); |
| | | form.querySelectorAll('.field-error, .has-error').forEach(el => { |
| | | el.classList.remove('field-error', 'has-error'); |
| | | }); |
| | | |
| | | // Clear validation states using existing method |
| | | form.querySelectorAll('.field').forEach(fieldWrapper => { |
| | | this.clearValidation(fieldWrapper); |
| | | }); |
| | | |
| | | // Handle field-specific errors |
| | | if (data.field) { |
| | | const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); |
| | | if (fieldWrapper) { |
| | | // Use existing showError method for consistency |
| | | this.showError(fieldWrapper, data.message); |
| | | |
| | | // Mark as touched so validation persists |
| | | this.touchedFields.add(data.field); |
| | | |
| | | // Scroll to error |
| | | fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | |
| | | // Focus the input for better UX |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input) { |
| | | input.focus(); |
| | | } |
| | | } |
| | | } else { |
| | | // General form error (not field-specific) |
| | | const error = document.createElement('div'); |
| | | error.className = 'form-error error-message'; |
| | | error.textContent = data.message; |
| | | |
| | | // Add icon for consistency |
| | | const icon = window.getIcon?.('close-circle'); |
| | | if (icon) { |
| | | icon.classList.add('error-icon'); |
| | | error.prepend(icon); |
| | | } |
| | | |
| | | form.insertBefore(error, form.firstChild); |
| | | |
| | | // Scroll to top to show the error |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | |
| | | // Announce error for accessibility |
| | | if (window.jvbA11y) { |
| | | const announcement = data.field |
| | | ? `Error in ${data.field}: ${data.message}` |
| | | : `Form error: ${data.message}`; |
| | | window.jvbA11y.announce(announcement); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-error', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (window.targetCheck(e, 'div.quantity')) { |
| | | let container = window.targetCheck(e, 'div.quantity'); |
| | | this.handleNumberClick(e, container.querySelector('input')); |
| | | } else if (window.targetCheck(e, '[data-action]')) { |
| | | let actionEl = window.targetCheck(e, '[data-action]'); |
| | | let action = actionEl.dataset.action; |
| | | let form = actionEl.closest('form'); |
| | | |
| | | switch (action) { |
| | | case 'clear-form': |
| | | if (form?.dataset.formId) { |
| | | this.store.delete(form.dataset.formId); |
| | | form.reset(); |
| | | // Hide the status message |
| | | form.querySelector('.fstatus').hidden = true; |
| | | } |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Form cleared, starting fresh'); |
| | | } |
| | | break; |
| | | |
| | | case 'dismiss-restore': |
| | | form.querySelector('.fstatus').hidden = true; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | handleNumberClick(e, input) { |
| | | let change = 0; |
| | | |
| | |
| | | } |
| | | |
| | | handleChange(event) { |
| | | if (this.subscribers.size > 0) { |
| | | const target = event.target; |
| | | const form = target.form || target.closest('form'); |
| | | if (event.target.closest('[data-ignore]') || this.isRestoring) { |
| | | return; |
| | | } |
| | | const target = event.target; |
| | | const form = target.form || target.closest('form');if (!form) return; |
| | | |
| | | if (!form) return; |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (!formConfig) return; |
| | | |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (!formConfig) return; |
| | | |
| | | if (formConfig.options.autosave || this.subscribers.size > 0) { |
| | | // Check conditional fields |
| | | const dependencies = formConfig.dependencies.get(target.name); |
| | | if (dependencies) { |
| | |
| | | } |
| | | |
| | | // Schedule auto-save if enabled |
| | | if (formConfig.options.autoSave && !form.dataset.noautosave) { |
| | | const delay = this.getDelayForField(target); |
| | | this.scheduleSave(formConfig, delay); |
| | | } |
| | | const delay = this.getDelayForField(target); |
| | | this.scheduleSave(formConfig, delay); |
| | | } |
| | | } |
| | | |
| | | handleFocus(event) { |
| | | const target = event.target; |
| | | if (target.matches('input, textarea, select')) { |
| | | // Track focus for better UX |
| | | this.currentFocus = target; |
| | | handleBlur(e) { |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) { |
| | | return; |
| | | } |
| | | } |
| | | |
| | | handleBlur(event) { |
| | | const target = event.target; |
| | | const target = e.target; |
| | | const form = target.form || target.closest('form'); |
| | | |
| | | if (!form) return; |
| | | |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) { |
| | | // Shorter delay on blur |
| | | this.scheduleSave(formConfig, { |
| | | type: 'blur', |
| | | fieldName: target.name, |
| | | delay: 1500 |
| | | }); |
| | | |
| | | const input = e.target.closest('input, textarea, select'); |
| | | if (input) { |
| | | const fieldWrapper = this.findFieldWrapper(input); |
| | | if (fieldWrapper) { |
| | | // Mark as touched and validate |
| | | const fieldName = fieldWrapper.dataset.field; |
| | | if (fieldName) { |
| | | if (this.shouldDebounce(input)) { |
| | | window.debouncer.cancel(`validate_${fieldName}`); |
| | | } |
| | | this.touchedFields.add(fieldName); |
| | | } |
| | | this.validateField(input, fieldWrapper); |
| | | } |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (formConfig) { |
| | | // Shorter delay on blur |
| | | this.scheduleSave(formConfig, { |
| | | type: 'blur', |
| | | fieldName: target.name, |
| | | delay: 1500 |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleInput(e) { |
| | | if (e.target.closest('[data-ignore]') || ! e.target.closest('form') || this.isRestoring) { |
| | | return; |
| | | } |
| | | const input = e.target.closest('input, textarea, select'); |
| | | if (!input) return; |
| | | |
| | | let form = input.closest('form'); |
| | | this.showFormStatus(form.dataset.formId, 'pending'); |
| | | |
| | | const fieldWrapper = this.findFieldWrapper(input); |
| | | if (!fieldWrapper) return; |
| | | |
| | | const fieldName = fieldWrapper.dataset.field; |
| | | if (fieldName) { |
| | | this.touchedFields.add(fieldName); |
| | | } |
| | | |
| | | if (this.shouldDebounce(input)){ |
| | | window.debouncer.schedule( |
| | | `validate_${fieldName}`, |
| | | () => this.validateField.bind(this), |
| | | 500 |
| | | ) |
| | | } |
| | | } |
| | | |
| | | /*************************************************************** |
| | | FORM VALIDATION |
| | | ***************************************************************/ |
| | | /** |
| | | * Initialize validation rules |
| | | */ |
| | | initValidators() { |
| | | return { |
| | | email: { |
| | | pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, |
| | | message: 'Please enter a valid email address' |
| | | }, |
| | | url: { |
| | | pattern: /^https?:\/\/.+\..+/, |
| | | message: 'Please enter a valid URL starting with https://' |
| | | }, |
| | | phone: { |
| | | pattern: /^[\d\s\-+().]+$/, |
| | | message: 'Please enter a valid phone number' |
| | | }, |
| | | number: { |
| | | test: (value, fieldWrapper) => { |
| | | const num = parseFloat(value); |
| | | if (isNaN(num)) return 'Please enter a valid number'; |
| | | |
| | | const min = fieldWrapper.dataset.min; |
| | | const max = fieldWrapper.dataset.max; |
| | | |
| | | if (min !== undefined && num < parseFloat(min)) { |
| | | return `Value must be at least ${min}`; |
| | | } |
| | | if (max !== undefined && num > parseFloat(max)) { |
| | | return `Value must be at most ${max}`; |
| | | } |
| | | return true; |
| | | } |
| | | }, |
| | | text: { |
| | | test: (value, fieldWrapper) => { |
| | | const minLength = fieldWrapper.dataset.minlength; |
| | | const maxLength = fieldWrapper.dataset.maxlength; |
| | | |
| | | if (minLength && value.length < parseInt(minLength)) { |
| | | return `Must be at least ${minLength} characters`; |
| | | } |
| | | if (maxLength && value.length > parseInt(maxLength)) { |
| | | return `Must be no more than ${maxLength} characters`; |
| | | } |
| | | return true; |
| | | } |
| | | } |
| | | }; |
| | | } |
| | | /** |
| | | * Find the field wrapper (handles both simple and complex fields) |
| | | */ |
| | | findFieldWrapper(input) { |
| | | // Try to find the closest .field wrapper |
| | | let wrapper = input.closest('.field'); |
| | | |
| | | // If we're in a repeater row, make sure we get the right field wrapper |
| | | if (!wrapper) { |
| | | wrapper = input.closest('[data-field]'); |
| | | } |
| | | |
| | | return wrapper; |
| | | } |
| | | |
| | | /** |
| | | * Check if input should be debounced |
| | | */ |
| | | shouldDebounce(input) { |
| | | const debounceTypes = ['text', 'email', 'url', 'tel', 'search']; |
| | | return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA'; |
| | | } |
| | | |
| | | /** |
| | | * Validate a single field |
| | | */ |
| | | validateField(input, fieldWrapper) { |
| | | const value = this.getFieldValue(input); |
| | | const fieldName = fieldWrapper.dataset.field; |
| | | |
| | | // Skip validation if field hasn't been touched yet (unless it's required) |
| | | if (!this.touchedFields.has(fieldName) && !input.required) { |
| | | return true; |
| | | } |
| | | |
| | | // Skip validation if field is empty and not required |
| | | if (!value && !input.required) { |
| | | this.clearValidation(fieldWrapper); |
| | | return true; |
| | | } |
| | | |
| | | // Check required |
| | | if (input.required && !value) { |
| | | this.showError(fieldWrapper, 'This field is required'); |
| | | return false; |
| | | } |
| | | |
| | | // Check HTML5 validity first |
| | | if (input.checkValidity && !input.checkValidity()) { |
| | | this.showError(fieldWrapper, input.validationMessage); |
| | | return false; |
| | | } |
| | | |
| | | // Custom pattern validation from data attribute |
| | | const pattern = fieldWrapper.dataset.pattern; |
| | | if (pattern && value) { |
| | | const regex = new RegExp(pattern); |
| | | if (!regex.test(value)) { |
| | | const message = fieldWrapper.dataset.validationMessage || 'Invalid format'; |
| | | this.showError(fieldWrapper, message); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // Type-specific validation |
| | | const validateType = fieldWrapper.dataset.validate || input.type; |
| | | if (validateType && this.validators[validateType]) { |
| | | const validator = this.validators[validateType]; |
| | | |
| | | if (validator.pattern && !validator.pattern.test(value)) { |
| | | this.showError(fieldWrapper, validator.message); |
| | | return false; |
| | | } |
| | | |
| | | if (validator.test) { |
| | | const result = validator.test(value, fieldWrapper); |
| | | if (result !== true) { |
| | | this.showError(fieldWrapper, result); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // All validations passed |
| | | this.showSuccess(fieldWrapper); |
| | | this.notify('field-validated', input); |
| | | return true; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Show success state (green checkmark) |
| | | */ |
| | | showSuccess(fieldWrapper, textMessage = '') { |
| | | if (!fieldWrapper) return; |
| | | |
| | | // Find validation elements (they might be in field-input-wrapper or field-content) |
| | | const success = fieldWrapper.querySelector('.validation-icon.success'); |
| | | const error = fieldWrapper.querySelector('.validation-icon.error'); |
| | | const message = fieldWrapper.querySelector('.validation-message'); |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | |
| | | // Remove error state |
| | | fieldWrapper.classList.remove('has-error'); |
| | | input?.classList.remove('error'); |
| | | |
| | | // Add success state |
| | | fieldWrapper.classList.add('has-success'); |
| | | |
| | | // Show checkmark (if element exists) |
| | | if (success) { |
| | | success.hidden = false; |
| | | } |
| | | if (error) { |
| | | error.hidden = true; |
| | | } |
| | | |
| | | // Hide error message |
| | | if (message) { |
| | | if (textMessage === '') { |
| | | message.hidden = true; |
| | | message.textContent = ''; |
| | | } else { |
| | | message.hidden = false; |
| | | message.textContent = textMessage; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show error state (red message below field) |
| | | */ |
| | | showError(fieldWrapper, errorMessage) { |
| | | if (!fieldWrapper) return; |
| | | |
| | | const success = fieldWrapper.querySelector('.validation-icon.success'); |
| | | const error = fieldWrapper.querySelector('.validation-icon.error'); |
| | | const message = fieldWrapper.querySelector('.validation-message'); |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | |
| | | // Remove success state |
| | | fieldWrapper.classList.remove('has-success'); |
| | | |
| | | // Add error state |
| | | fieldWrapper.classList.add('has-error'); |
| | | input?.classList.add('error'); |
| | | |
| | | // Hide checkmark (if element exists) |
| | | if (success) { |
| | | success.hidden = true; |
| | | } |
| | | //show x |
| | | if (error) { |
| | | error.hidden = false; |
| | | } |
| | | |
| | | // Show error message |
| | | if (message) { |
| | | message.hidden = false; |
| | | message.textContent = errorMessage; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clear validation state |
| | | */ |
| | | clearValidation(fieldWrapper) { |
| | | if (!fieldWrapper) return; |
| | | |
| | | const icon = fieldWrapper.querySelector('.validation-icon'); |
| | | const message = fieldWrapper.querySelector('.validation-message'); |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | |
| | | fieldWrapper.classList.remove('has-error', 'has-success'); |
| | | input?.classList.remove('error'); |
| | | |
| | | if (icon) { |
| | | icon.hidden = true; |
| | | } |
| | | |
| | | if (message) { |
| | | message.hidden = true; |
| | | message.textContent = ''; |
| | | } |
| | | } |
| | | |
| | |
| | | return this.autoSaveDefaults.delay; |
| | | } |
| | | scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) { |
| | | document.addEventListener('input', this.handleInput, {passive: true}); |
| | | if (!formConfig.options.autosave) { |
| | | return; |
| | | } |
| | | document.addEventListener('input', this.saveCheck, {passive: true}); |
| | | const saveKey = `autosave_${formConfig.id}`; |
| | | |
| | | this.debouncer.schedule( |
| | |
| | | } |
| | | |
| | | //Extend delay if user is currently typing |
| | | handleInput(e) { |
| | | saveCheck(e) { |
| | | let form = e.target.closest('form[data-id]'); |
| | | if (!form) { |
| | | return; |
| | | } |
| | | |
| | | this.scheduleSave(this.forms.get(form.dataset.id)); |
| | | } |
| | | |
| | | async autosave(formConfig) { |
| | | const formData = this.collectFormData(formConfig.element); |
| | | |
| | | this.showFormStatus(formConfig.id, 'saving'); |
| | | |
| | | // DataStore will now automatically: |
| | | // - Convert Sets/Maps to Arrays/Objects |
| | | // - Strip DOM references |
| | | // - Validate serializability |
| | | await this.store.save({ |
| | | formId: formConfig.id, |
| | | data: formData, |
| | | status: 'draft', |
| | | timestamp: Date.now() |
| | | }).then(() => { |
| | | this.showFormStatus(formConfig.id, 'autosaved'); |
| | | }).catch(error => { |
| | | console.error('Autosave failed:', error); |
| | | this.showFormStatus(formConfig.id, 'error', 'Failed to save changes'); |
| | | }); |
| | | this.showFormStatus(formConfig.id, 'saved'); |
| | | |
| | | // Get only changed fields |
| | | const changes = this.getChangedFields(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 |
| | | for (let [key, value] of Object.entries(formData)) { |
| | | // Complex fields need full data |
| | | if (typeof value === 'object') { |
| | | changes[key] = value; |
| | | } |
| | | } |
| | | // Notify instead of callback |
| | | |
| | | // Notify |
| | | this.notify('form-autosave', { |
| | | formId: formConfig.id, |
| | | changes: changes, |
| | |
| | | |
| | | // Check if current data differs from snapshot |
| | | const currentData = this.collectFormData(formConfig.element); |
| | | const changes = this.getChangedFields(formConfig.lastSnapshot, currentData); |
| | | const changes = this.getChangedFields(formConfig.data, currentData); |
| | | |
| | | return Object.keys(changes).length > 0; |
| | | } |
| | | |
| | | showFormStatus(form, status) { |
| | | // Remove existing status |
| | | const existingStatus = form.querySelector('.form-status'); |
| | | if (existingStatus) { |
| | | existingStatus.remove(); |
| | | showFormStatus(formID, status, message='') { |
| | | let form = this.forms.get(formID); |
| | | if (!form?.options.formStatus) { |
| | | return; |
| | | } |
| | | |
| | | // Add new status |
| | | const statusElement = document.createElement('div'); |
| | | statusElement.className = `form-status status-${status}`; |
| | | if (form.status === status){ |
| | | return; |
| | | } |
| | | |
| | | form.status = status; |
| | | |
| | | const statusWrap = form.element.querySelector('.fstatus'); |
| | | statusWrap.hidden = false; |
| | | const statusElement = statusWrap.querySelector('.message'); |
| | | statusElement.textContent = ''; |
| | | statusWrap.querySelector('.icon')?.remove(); |
| | | statusWrap.querySelector('.actions')?.remove(); // Clear old actions |
| | | |
| | | const messages = { |
| | | 'saving': 'Saving changes...', |
| | | 'saved': 'Changes saved', |
| | | 'error': 'Failed to save 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' |
| | | }; |
| | | |
| | | statusElement.textContent = messages[status] || status; |
| | | form.insertBefore(statusElement, form.firstChild); |
| | | const icons = { |
| | | 'autosaved': 'check-circle', |
| | | 'submitted': 'check-circle', |
| | | 'restored': 'history', |
| | | 'error': 'close-circle', |
| | | 'offline': 'cloud-slash', |
| | | 'pending': 'exclamation-mark' |
| | | } |
| | | |
| | | let icon = window.getIcon(icons[status]); |
| | | if (icon) { |
| | | statusWrap.prepend(icon); |
| | | } |
| | | |
| | | if (message === '') { |
| | | message = messages[status] || status; |
| | | } |
| | | statusElement.textContent = message; |
| | | statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status)); |
| | | |
| | | // Add action buttons for certain statuses |
| | | if (status === 'restored') { |
| | | const actions = document.createElement('div'); |
| | | actions.className = 'actions'; |
| | | actions.innerHTML = ` |
| | | <button type="button" class="button button-small" data-action="dismiss-restore">Got it</button> |
| | | <button type="button" class="button button-small button-link" data-action="clear-form">Start over</button> |
| | | `; |
| | | statusWrap.appendChild(actions); |
| | | |
| | | // Auto-dismiss after 10 seconds |
| | | setTimeout(() => statusWrap.hidden = true, 10000); |
| | | } |
| | | |
| | | // Auto-hide success messages |
| | | if (status === 'saved') { |
| | | setTimeout(() => statusElement.remove(), 3000); |
| | | if (status === 'submitted') { |
| | | setTimeout(() => statusWrap.hidden = true, 3000); |
| | | } |
| | | } |
| | | |
| | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form) { |
| | | if (Object.hasOwn(form.dataset, 'timeline')) { |
| | | return this.collectTimeline(form); |
| | | } |
| | | //Table forms are handled separately |
| | | if (form.classList.contains('table') && form.tagName === 'FORM') { |
| | | return {}; |
| | | } |
| | | |
| | | const formData = new FormData(form); |
| | | let data = {}; |
| | | const repeaterData = {}; |
| | |
| | | const processor = this.getFieldProcessor(key); |
| | | processor(key, value, data, repeaterData, postData, form); |
| | | } |
| | | if (!window.isEmptyObject(postData)) { |
| | | if (Object.keys(postData).length !== 0) { |
| | | data = this.mergeRepeaterData(data, repeaterData); |
| | | return this.mergePostData(data, postData); |
| | | } |
| | | return this.mergeRepeaterData(data, repeaterData); |
| | | } |
| | | |
| | | collectTimeline(form) { |
| | | let data = {}; |
| | | let posts = {}; // Temporary object keyed by post ID |
| | | let postOrder = []; // Track order as encountered (preserves DOM/drag order) |
| | | let formData = new FormData(form); |
| | | |
| | | for (const [key, value] of formData.entries()) { |
| | | if (this.ignore.includes(key) || key.endsWith('_temp')) { |
| | | continue; |
| | | } |
| | | const match = key.match(/^\[(\d+)](.+)$/); |
| | | if (match) { |
| | | // Timeline-specific field: [postId]fieldName |
| | | const [, postId, fieldName] = match; |
| | | if (!posts[postId]) { |
| | | posts[postId] = { |
| | | id: parseInt(postId), |
| | | }; |
| | | postOrder.push(postId); // Track first occurrence |
| | | } |
| | | if (fieldName === 'post_thumbnail') { |
| | | posts[postId]['post_thumbnail'] = parseInt(form.querySelector(`[name="${key}"]`).closest('.item')?.dataset.id); |
| | | } else { |
| | | const processor = this.getFieldProcessor(fieldName); |
| | | processor(fieldName, value, posts[postId], {}, {}, form); |
| | | } |
| | | |
| | | } else { |
| | | // Shared field (post_title, taxonomies, etc.) |
| | | const processor = this.getFieldProcessor(key); |
| | | processor(key, value, data, {}, {}, form); |
| | | } |
| | | } |
| | | |
| | | // Convert to array in DOM order (matches menu_order) |
| | | data.timeline = postOrder.map(id => posts[id]); |
| | | |
| | | delete data['form-id']; |
| | | delete data['sendAll']; |
| | | delete data['timeline_temp']; |
| | | delete data['']; // Empty key |
| | | |
| | | return data; |
| | | } |
| | | |
| | | 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; |
| | | if (/\[[^\]]+]/.test(key)) return this.processLocationField; |
| | | return this.processRegularField; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | mergePostData(data, postData) { |
| | | for (let [postId, postData] in Object.entries(postData)) { |
| | | data[postId] = postData; |
| | | for (let [postId, fields] of Object.entries(postData)) { |
| | | data[postId] = fields; |
| | | } |
| | | 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(':'); |
| | | |
| | |
| | | |
| | | processRegularField(key, value, data, repeaterData, postData, form) { |
| | | //handle array values (like checkboxes/selects) |
| | | key = key.replace('[]',''); |
| | | if (data[key]) { |
| | | if (!Array.isArray(data[key])) { |
| | | data[key] = [data[key]]; |
| | |
| | | } |
| | | } |
| | | |
| | | getFieldValue(field) { |
| | | if (!field) return ''; |
| | | /** |
| | | * Get field value (handles different input types) |
| | | */ |
| | | getFieldValue(input) { |
| | | if (!input) return ''; |
| | | |
| | | if (field.type === 'checkbox') { |
| | | return field.checked ? field.value || '1' : ''; |
| | | } else if (field.type === 'radio') { |
| | | const checked = field.form.querySelector(`[name="${field.name}"]:checked`); |
| | | if (input.type === 'checkbox') { |
| | | return input.checked ? input.value || '1' : ''; |
| | | } else if (input.type === 'radio') { |
| | | const checked = input.form?.querySelector(`[name="${input.name}"]:checked`); |
| | | return checked ? checked.value : ''; |
| | | } else if (field.type === 'select-multiple') { |
| | | return Array.from(field.selectedOptions).map(o => o.value); |
| | | } else { |
| | | return field.value; |
| | | } else if (input.type === 'select-multiple') { |
| | | return Array.from(input.selectedOptions).map(o => o.value); |
| | | } |
| | | |
| | | return input.value?.trim() || ''; |
| | | } |
| | | |
| | | getChangedFields(original, current) { |
| | | return window.getDifferences?.map(original, current) || {}; |
| | | } |
| | | |
| | | /******************************************************* |
| | | Field Summary |
| | | *******************************************************/ |
| | | /** |
| | | * Show a comprehensive summary of form submission |
| | | */ |
| | | showSummary(formId, clear = 'form') { |
| | | const formConfig = this.forms.get(formId); |
| | | if (!formConfig) return; |
| | | |
| | | const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`); |
| | | const summary = window.getTemplate('formSummary'); |
| | | if (!summary) return; |
| | | const wrapper = summary.querySelector('.result'); |
| | | |
| | | // Fields to skip in summary |
| | | const skipFields = ['sendAll', ...this.ignore]; |
| | | |
| | | // Process each field in the form data |
| | | for (const [key, value] of Object.entries(formConfig.data)) { |
| | | // Skip ignored fields and empty values |
| | | if (skipFields.includes(key) || this.isEmptyValue(value)) { |
| | | continue; |
| | | } |
| | | |
| | | // Get field info from form |
| | | const fieldInfo = this.getFieldInfo(form, key); |
| | | |
| | | if (!fieldInfo.label) continue; // Skip if no label found |
| | | |
| | | let field = wrapper.cloneNode(true); |
| | | let title = field.querySelector('h3'); |
| | | let p = field.querySelector('p'); |
| | | |
| | | title.textContent = fieldInfo.label; |
| | | |
| | | |
| | | let formatted = this.formatFieldValue(value, fieldInfo.type, form); |
| | | if (this.isHtmlContent(formatted)) { |
| | | p.innerHTML = formatted; |
| | | } else { |
| | | p.textContent = formatted; |
| | | } |
| | | |
| | | summary.append(field); |
| | | } |
| | | let uploads = form.querySelectorAll('[data-upload-field]'); |
| | | if (uploads) { |
| | | uploads.forEach(upload => { |
| | | let label = upload.querySelector('h2').textContent; |
| | | |
| | | let imgs = upload.querySelectorAll('.item-grid.preview img'); |
| | | if (imgs) { |
| | | let field = wrapper.cloneNode(true); |
| | | let title = field.querySelector('h3'); |
| | | let p = field.querySelector('p'); |
| | | p.remove(); |
| | | |
| | | title.textContent = label; |
| | | imgs.forEach(img => { |
| | | img = img.cloneNode(true); |
| | | field.append(img); |
| | | }); |
| | | summary.append(field); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Remove template |
| | | wrapper.remove(); |
| | | |
| | | // Insert summary and hide form |
| | | clear = (clear !== 'form') ? form.closest(clear)??form : form; |
| | | |
| | | clear.after(summary); |
| | | window.fade(clear, false); |
| | | } |
| | | |
| | | /** |
| | | * Check if a value is empty (null, undefined, empty string, empty array, empty object) |
| | | */ |
| | | isEmptyValue(value) { |
| | | if (value === null || value === undefined || value === '') { |
| | | return true; |
| | | } |
| | | if (Array.isArray(value) && value.length === 0) { |
| | | return true; |
| | | } |
| | | return typeof value === 'object' && Object.keys(value).length === 0; |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Get field information (label, type, etc.) from the form |
| | | * Handles special field name patterns ([], ::, :, etc.) |
| | | */ |
| | | getFieldInfo(form, fieldName) { |
| | | // Try to find label by 'for' attribute (exact match) |
| | | let label = form.querySelector(`label[for="${fieldName}"]`); |
| | | let input = form.querySelector(`[name=${fieldName}]`); |
| | | // Try to find the input field - check multiple patterns |
| | | if (!input) { |
| | | // Try exact match first |
| | | input = form.querySelector(`[name="${fieldName}"]`); |
| | | } |
| | | |
| | | if (!input) { |
| | | // Try with [] suffix (for checkboxes, multi-selects) |
| | | input = form.querySelector(`[name="${fieldName}[]"]`); |
| | | } |
| | | |
| | | if (!input) { |
| | | // Try as fieldset legend (for checkbox/radio groups) |
| | | const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`); |
| | | if (fieldset) { |
| | | label = fieldset.querySelector('legend'); |
| | | input = fieldset.querySelector('input, select, textarea'); |
| | | } |
| | | } |
| | | |
| | | // Get label from input if not found yet |
| | | if (!label && input) { |
| | | // Try closest field wrapper first |
| | | const field = input.closest('.field, fieldset'); |
| | | if (field) { |
| | | label = field.querySelector('label, legend, h2'); |
| | | } |
| | | } |
| | | |
| | | // Get field wrapper - always use base name (no special characters) |
| | | let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`); |
| | | |
| | | // Determine field type |
| | | let fieldType = 'text'; |
| | | if (fieldWrapper?.dataset.type) { |
| | | fieldType = fieldWrapper.dataset.type; |
| | | } else if (input) { |
| | | // Infer from input type |
| | | if (input.type === 'checkbox' && input.name.endsWith('[]')) { |
| | | fieldType = 'checkbox'; // checkbox group |
| | | } else if (input.type === 'checkbox') { |
| | | fieldType = 'true_false'; // single checkbox |
| | | } else if (input.tagName === 'SELECT' && input.multiple) { |
| | | fieldType = 'select'; // multi-select |
| | | } else { |
| | | fieldType = input.type || 'text'; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | label: label?.textContent.replace('*', '').trim() || null, |
| | | type: fieldType, |
| | | wrapper: fieldWrapper, |
| | | input: input |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Check if content should be treated as HTML |
| | | */ |
| | | isHtmlContent(content) { |
| | | return typeof content === 'string' && ( |
| | | content.includes('<br>') || |
| | | content.includes('<p>') || |
| | | content.includes('<ul>') || |
| | | content.includes('<ol>') || |
| | | content.includes('<a ') || |
| | | content.includes('<strong>') || |
| | | content.includes('<em>') || |
| | | content.includes('<div') |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Format field value based on type |
| | | */ |
| | | formatFieldValue(value, type, form) { |
| | | switch (type) { |
| | | case 'textarea': |
| | | case 'wysiwyg': |
| | | // Handle rich text - check if it's actual HTML content from Quill |
| | | return this.formatTextareaValue(value, type); |
| | | |
| | | case 'true_false': |
| | | return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No'; |
| | | case 'checkbox': |
| | | // Handle both single checkbox and checkbox groups |
| | | if (Array.isArray(value)) { |
| | | return this.formatArrayValue(value); |
| | | } |
| | | return (value === '1' || value === 1 || value === true) ? 'Yes' : value; |
| | | |
| | | case 'select': |
| | | // Handle both single and multi-select |
| | | if (Array.isArray(value)) { |
| | | return this.formatArrayValue(value); |
| | | } |
| | | // Get label from select option |
| | | return this.getSelectLabel(value, form, type); |
| | | case 'date': |
| | | case 'datetime': |
| | | case 'time': |
| | | return window.formatDate ? window.formatDate(value) : value; |
| | | |
| | | case 'radio': |
| | | // Get label from select option or radio label |
| | | return this.getSelectLabel(value, form, type); |
| | | |
| | | case 'repeater': |
| | | return this.formatRepeaterValue(value); |
| | | |
| | | case 'group': |
| | | return this.formatGroupValue(value); |
| | | |
| | | case 'location': |
| | | return this.formatLocationValue(value); |
| | | |
| | | case 'upload': |
| | | return this.formatFileValue(value); |
| | | |
| | | case 'number': |
| | | return this.formatNumber(value); |
| | | |
| | | case 'email': |
| | | return `<a href="mailto:${value}">${value}</a>`; |
| | | |
| | | case 'url': |
| | | return `<a href="${value}" target="_blank" rel="noopener">${value}</a>`; |
| | | |
| | | case 'phone': |
| | | return `<a href="tel:${value.replace(/\D/g, '')}">${value}</a>`; |
| | | |
| | | default: |
| | | // Handle arrays (multi-select, checkbox group) |
| | | if (Array.isArray(value)) { |
| | | return this.formatArrayValue(value); |
| | | } |
| | | return value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format repeater field value |
| | | */ |
| | | formatRepeaterValue(rows) { |
| | | if (!Array.isArray(rows) || rows.length === 0) { |
| | | return '<em>No entries</em>'; |
| | | } |
| | | |
| | | let html = '<div class="repeater-summary">'; |
| | | rows.forEach((row, index) => { |
| | | html += `<div class="repeater-row">`; |
| | | html += `<strong>Entry ${index + 1}:</strong><ul>`; |
| | | for (const [key, value] of Object.entries(row)) { |
| | | if (!this.isEmptyValue(value)) { |
| | | const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); |
| | | html += `<li><strong>${label}:</strong> ${value}</li>`; |
| | | } |
| | | } |
| | | html += `</ul></div>`; |
| | | }); |
| | | html += '</div>'; |
| | | return html; |
| | | } |
| | | |
| | | /** |
| | | * Format group field value |
| | | */ |
| | | formatGroupValue(groupData) { |
| | | if (typeof groupData !== 'object' || Object.keys(groupData).length === 0) { |
| | | return '<em>No data</em>'; |
| | | } |
| | | |
| | | let html = '<div class="group-summary"><ul>'; |
| | | for (const [key, value] of Object.entries(groupData)) { |
| | | if (!this.isEmptyValue(value)) { |
| | | const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); |
| | | // Handle nested groups |
| | | if (typeof value === 'object' && !Array.isArray(value)) { |
| | | html += `<li><strong>${label}:</strong> ${this.formatGroupValue(value)}</li>`; |
| | | } else { |
| | | html += `<li><strong>${label}:</strong> ${value}</li>`; |
| | | } |
| | | } |
| | | } |
| | | html += '</ul></div>'; |
| | | return html; |
| | | } |
| | | |
| | | /** |
| | | * Format location field value |
| | | */ |
| | | formatLocationValue(location) { |
| | | if (typeof location !== 'object') return location; |
| | | |
| | | const parts = []; |
| | | const fields = ['address', 'city', 'state', 'zip', 'country']; |
| | | |
| | | fields.forEach(field => { |
| | | if (location[field]) { |
| | | parts.push(location[field]); |
| | | } |
| | | }); |
| | | |
| | | return parts.join(', '); |
| | | } |
| | | |
| | | /** |
| | | * Format file/image value |
| | | */ |
| | | formatFileValue(value) { |
| | | if (typeof value === 'string') { |
| | | // Single file - could be URL or filename |
| | | if (value.startsWith('http')) { |
| | | return `<a href="${value}" target="_blank">View file</a>`; |
| | | } |
| | | return value; |
| | | } |
| | | |
| | | if (Array.isArray(value)) { |
| | | return value.map(file => { |
| | | if (typeof file === 'string') { |
| | | return `<a href="${file}" target="_blank">View file</a>`; |
| | | } |
| | | return file.name || 'File'; |
| | | }).join(', '); |
| | | } |
| | | |
| | | return 'File uploaded'; |
| | | } |
| | | |
| | | /** |
| | | * Format number with proper locale formatting |
| | | */ |
| | | formatNumber(value) { |
| | | const num = parseFloat(value); |
| | | if (isNaN(num)) return value; |
| | | |
| | | // Check if it's likely currency (has 2 decimal places) |
| | | if (value.toString().includes('.') && value.toString().split('.')[1].length === 2) { |
| | | return new Intl.NumberFormat('en-CA', { |
| | | style: 'currency', |
| | | currency: 'USD' |
| | | }).format(num); |
| | | } |
| | | |
| | | return new Intl.NumberFormat('en-CA').format(num); |
| | | } |
| | | |
| | | /** |
| | | * Format array values (checkboxes, multi-select) |
| | | */ |
| | | /** |
| | | * Format array values (checkboxes, multi-select) |
| | | */ |
| | | formatArrayValue(arr, form = null, fieldInfo = null) { |
| | | if (arr.length === 0) return '<em>None selected</em>'; |
| | | |
| | | // If we have field info, try to get proper labels |
| | | if (form && fieldInfo && fieldInfo.input) { |
| | | const labeled = arr.map(val => { |
| | | return this.getSelectLabel(val, form, fieldInfo.type); |
| | | }); |
| | | return '<ul><li>' + labeled.join('</li><li>') + '</li></ul>'; |
| | | } |
| | | |
| | | // Fallback to raw values |
| | | return '<ul><li>' + arr.join('</li><li>') + '</li></ul>'; |
| | | } |
| | | |
| | | /** |
| | | * Get label for select/radio option |
| | | */ |
| | | /** |
| | | * Get label for select/radio/checkbox option |
| | | */ |
| | | getSelectLabel(value, form, type) { |
| | | if (type === 'select') { |
| | | const option = form.querySelector(`option[value="${value}"]`); |
| | | return option?.textContent || value; |
| | | } |
| | | |
| | | if (type === 'radio') { |
| | | const radio = form.querySelector(`input[type="radio"][value="${value}"]`); |
| | | const label = radio?.nextElementSibling; |
| | | return label?.textContent || value; |
| | | } |
| | | |
| | | if (type === 'checkbox') { |
| | | // Try to find the checkbox with this value |
| | | const checkbox = form.querySelector(`input[type="checkbox"][value="${value}"]`); |
| | | if (checkbox) { |
| | | // Look for associated label |
| | | const label = form.querySelector(`label[for="${checkbox.id}"]`); |
| | | if (label) { |
| | | return label.textContent.trim(); |
| | | } |
| | | // Try next sibling |
| | | const nextLabel = checkbox.nextElementSibling; |
| | | if (nextLabel?.tagName === 'LABEL') { |
| | | return nextLabel.textContent.trim(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * Format textarea value - handles both rich text and plain text |
| | | */ |
| | | formatTextareaValue(value, type) { |
| | | if (!value) return '<em>Empty</em>'; |
| | | |
| | | // If it's explicitly a wysiwyg type or contains HTML tags, use as-is |
| | | if (type === 'wysiwyg' || this.containsHtml(value)) { |
| | | // Quill content already has proper HTML structure |
| | | return value; |
| | | } |
| | | |
| | | // Plain textarea - preserve formatting |
| | | return this.formatPlainText(value); |
| | | } |
| | | |
| | | /** |
| | | * Check if string contains HTML content (more reliable than just checking for '<') |
| | | */ |
| | | containsHtml(str) { |
| | | // Check for common HTML tags that Quill uses |
| | | const htmlPattern = /<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i; |
| | | return htmlPattern.test(str); |
| | | } |
| | | |
| | | /** |
| | | * Format plain text content - preserves whitespace and converts newlines |
| | | */ |
| | | formatPlainText(text) { |
| | | if (!text) return ''; |
| | | |
| | | // First, escape any HTML entities that might be in the text |
| | | text = text |
| | | .replace(/&/g, '&') |
| | | .replace(/</g, '<') |
| | | .replace(/>/g, '>'); |
| | | |
| | | // Convert double newlines to paragraphs for better readability |
| | | const paragraphs = text.split(/\n\n+/); |
| | | |
| | | if (paragraphs.length > 1) { |
| | | // Multiple paragraphs |
| | | return paragraphs |
| | | .map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`) |
| | | .join(''); |
| | | } |
| | | |
| | | // Single paragraph - just convert newlines to breaks |
| | | return text.replace(/\n/g, '<br>'); |
| | | } |
| | | |
| | | /** |
| | | * Event system |
| | | */ |
| | |
| | | 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); |
| | | document.removeEventListener('input', this.inputHandler, true); |
| | | } |
| | | this.forms.forEach((formConfig) => { |
| | | let element = formConfig.element; |
| | | if (element) { |
| | | element.removeEventListener('submit', this.submitHandler); |
| | | } |
| | | }); |
| | | |
| | | // Clear maps |
| | | this.specialFields.clear(); |
| | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbForm = FormController; |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe(event => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbForm = FormController; |
| | | } |
| | | }); |
| | | |
| | | }); |