| | |
| | | * 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; |
| | | |
| | |
| | | const formConfig = { |
| | | element: formElement, |
| | | id: formId, |
| | | status: '', |
| | | options: { |
| | | autoSave: true, |
| | | autosave: 'autosave' in formElement.dataset, |
| | | 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 |
| | | }; |
| | | |
| | | // Initialize special fields |
| | |
| | | |
| | | // Scan for existing selector fields |
| | | if (window.jvbSelector) { |
| | | window.jvbSelector.scanExistingFields(); |
| | | window.jvbSelector.scanExistingFields(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', |
| | |
| | | |
| | | /* ========== 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; |
| | | 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); |
| | | this.notify('form-submit', { |
| | | formId: formConfig.id, |
| | |
| | | } |
| | | } |
| | | |
| | | handleFormSuccess(form, data) { |
| | | // Clear previous errors |
| | | form.querySelectorAll('.error-message').forEach(el => el.remove()); |
| | | form.querySelectorAll('.field-error').forEach(el => |
| | | el.classList.remove('field-error') |
| | | ); |
| | | |
| | | // Add success class to form |
| | | form.classList.add('form-success'); |
| | | |
| | | // Show success message if provided |
| | | if (data.message) { |
| | | const success = document.createElement('div'); |
| | | success.className = 'form-success-message success-message'; |
| | | success.textContent = data.message; |
| | | form.insertBefore(success, form.firstChild); |
| | | |
| | | // Optionally add icon |
| | | const icon = window.getIcon?.('check-circle'); |
| | | if (icon) { |
| | | icon.classList.add('success-icon'); |
| | | success.prepend(icon); |
| | | } |
| | | } |
| | | |
| | | // If there's a title/description (for registration success) |
| | | if (data.title || data.description) { |
| | | const successBox = document.createElement('div'); |
| | | successBox.className = 'success-box'; |
| | | |
| | | if (data.title) { |
| | | const title = document.createElement('h3'); |
| | | title.textContent = data.title; |
| | | successBox.appendChild(title); |
| | | } |
| | | |
| | | if (data.description) { |
| | | // Handle both string and array descriptions |
| | | const descriptions = Array.isArray(data.description) |
| | | ? data.description |
| | | : [data.description]; |
| | | |
| | | descriptions.forEach(desc => { |
| | | const p = document.createElement('p'); |
| | | p.textContent = desc; |
| | | successBox.appendChild(p); |
| | | }); |
| | | } |
| | | |
| | | form.insertBefore(successBox, form.firstChild); |
| | | } |
| | | |
| | | // Announce success for accessibility |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce(data.message || 'Form submitted successfully'); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-success', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | handleFormError(form, data) { |
| | | // Clear all previous errors |
| | | form.querySelectorAll('.error-message').forEach(el => el.remove()); |
| | | form.querySelectorAll('.field-error, .has-error').forEach(el => { |
| | | el.classList.remove('field-error', 'has-error'); |
| | | }); |
| | | |
| | | // Clear validation states using existing method |
| | | form.querySelectorAll('.field').forEach(fieldWrapper => { |
| | | this.clearValidation(fieldWrapper); |
| | | }); |
| | | |
| | | // Handle field-specific errors |
| | | if (data.field) { |
| | | const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); |
| | | if (fieldWrapper) { |
| | | // Use existing showError method for consistency |
| | | this.showError(fieldWrapper, data.message); |
| | | |
| | | // Mark as touched so validation persists |
| | | this.touchedFields.add(data.field); |
| | | |
| | | // Scroll to error |
| | | fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | |
| | | // Focus the input for better UX |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input) { |
| | | input.focus(); |
| | | } |
| | | } |
| | | } else { |
| | | // General form error (not field-specific) |
| | | const error = document.createElement('div'); |
| | | error.className = 'form-error error-message'; |
| | | error.textContent = data.message; |
| | | |
| | | // Add icon for consistency |
| | | const icon = window.getIcon?.('close-circle'); |
| | | if (icon) { |
| | | icon.classList.add('error-icon'); |
| | | error.prepend(icon); |
| | | } |
| | | |
| | | form.insertBefore(error, form.firstChild); |
| | | |
| | | // Scroll to top to show the error |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | |
| | | // Announce error for accessibility |
| | | if (window.jvbA11y) { |
| | | const announcement = data.field |
| | | ? `Error in ${data.field}: ${data.message}` |
| | | : `Form error: ${data.message}`; |
| | | window.jvbA11y.announce(announcement); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-error', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (window.targetCheck(e, 'div.quantity')) { |
| | | let container = window.targetCheck(e, 'div.quantity'); |
| | |
| | | } |
| | | |
| | | handleChange(event) { |
| | | if (this.subscribers.size > 0) { |
| | | const target = event.target; |
| | | const form = target.form || target.closest('form'); |
| | | if (event.target.closest('[data-ignore]')) { |
| | | 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; |
| | | console.log(formConfig.options); |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | handleBlur(e) { |
| | | if (e.target.closest('[data-ignore]')) { |
| | | return; |
| | | } |
| | | const target = e.target; |
| | | const form = target.form || target.closest('form'); |
| | | |
| | |
| | | this.validateField(input, fieldWrapper); |
| | | } |
| | | const formConfig = this.forms?.get(form.dataset.formId); |
| | | if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) { |
| | | if (formConfig) { |
| | | // Shorter delay on blur |
| | | this.scheduleSave(formConfig, { |
| | | type: 'blur', |
| | |
| | | } |
| | | |
| | | handleInput(e) { |
| | | if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) { |
| | | return; |
| | | } |
| | | const input = e.target.closest('input, textarea, select'); |
| | | if (!input) return; |
| | | |
| | |
| | | |
| | | // All validations passed |
| | | this.showSuccess(fieldWrapper); |
| | | this.notify('field-validated', input); |
| | | return true; |
| | | } |
| | | |
| | |
| | | /** |
| | | * Show success state (green checkmark) |
| | | */ |
| | | showSuccess(fieldWrapper) { |
| | | showSuccess(fieldWrapper, textMessage = '') { |
| | | if (!fieldWrapper) return; |
| | | |
| | | // Find validation elements (they might be in field-input-wrapper or field-content) |
| | |
| | | |
| | | // Hide error message |
| | | if (message) { |
| | | message.hidden = true; |
| | | message.textContent = ''; |
| | | if (textMessage === '') { |
| | | message.hidden = true; |
| | | message.textContent = ''; |
| | | } else { |
| | | message.hidden = false; |
| | | message.textContent = textMessage; |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | return this.autoSaveDefaults.delay; |
| | | } |
| | | scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) { |
| | | if (!formConfig.options.autosave) { |
| | | return; |
| | | } |
| | | document.addEventListener('input', this.saveCheck, {passive: true}); |
| | | const saveKey = `autosave_${formConfig.id}`; |
| | | |
| | |
| | | |
| | | // Get only changed fields |
| | | const changes = this.getChangedFields(formConfig.data, formData); |
| | | console.log('Changes:', changes); |
| | | if (Object.keys(changes).length === 0) return; |
| | | console.log('Continuing on:'); |
| | | |
| | | // Update stored data |
| | | formConfig.data = formData; |
| | |
| | | |
| | | // 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(formID, status) { |
| | | showFormStatus(formID, status, message='') { |
| | | // Remove existing status |
| | | let form = this.forms.get(formID); |
| | | if (!form.options.formStatus) { |
| | | return; |
| | | } |
| | | |
| | | if (form.status === status){ |
| | | return; |
| | | } |
| | | |
| | | form.status = status; |
| | | |
| | | console.log('Setting status: ', status); |
| | | |
| | |
| | | 'offline': 'Changes will be saved when online' |
| | | }; |
| | | const icons = { |
| | | 'autosaved': 'check', |
| | | 'submitted': 'check', |
| | | 'error': 'close', |
| | | 'autosaved': 'check-circle', |
| | | 'submitted': 'check-circle', |
| | | 'error': 'close-circle', |
| | | 'offline': 'cloud-slash', |
| | | 'pending': 'exclamation-mark' |
| | | } |
| | |
| | | if (icon) { |
| | | statusWrap.prepend(icon); |
| | | } |
| | | console.log(status, messages[status]); |
| | | console.log(status, icons[status]); |
| | | statusElement.textContent = messages[status] || status; |
| | | if (message === '') { |
| | | message = messages[status] || status; |
| | | } |
| | | statusElement.textContent = message; |
| | | statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status)); |
| | | |
| | | // Auto-hide success messages |
| | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form) { |
| | | if (Object.hasOwn(form.dataset, 'timeline')) { |
| | | return this.collectTimeline(form); |
| | | } |
| | | const formData = new FormData(form); |
| | | let data = {}; |
| | | const repeaterData = {}; |
| | |
| | | return this.mergeRepeaterData(data, repeaterData); |
| | | } |
| | | |
| | | collectTimeline(form) { |
| | | console.log('Collecting Timeline data:'); |
| | | let data = {}; |
| | | let posts = {}; // Temporary object keyed by post ID |
| | | let postOrder = []; // Track order as encountered (preserves DOM/drag order) |
| | | let formData = new FormData(form); |
| | | |
| | | for (const [key, value] of formData.entries()) { |
| | | if (this.ignore.includes(key) || key.endsWith('_temp')) { |
| | | continue; |
| | | } |
| | | const match = key.match(/^\[(\d+)\](.+)$/); |
| | | if (match) { |
| | | // Timeline-specific field: [postId]fieldName |
| | | const [, postId, fieldName] = match; |
| | | if (!posts[postId]) { |
| | | posts[postId] = { id: parseInt(postId) }; |
| | | postOrder.push(postId); // Track first occurrence |
| | | } |
| | | const processor = this.getFieldProcessor(fieldName); |
| | | processor(fieldName, value, posts[postId], {}, {}, form); |
| | | } else { |
| | | // Shared field (post_title, taxonomies, etc.) |
| | | const processor = this.getFieldProcessor(key); |
| | | processor(key, value, data, {}, {}, form); |
| | | } |
| | | } |
| | | |
| | | // Convert to array in DOM order (matches menu_order) |
| | | data.timeline = postOrder.map(id => posts[id]); |
| | | |
| | | delete data['form-id']; |
| | | delete data['sendAll']; |
| | | delete data['timeline_temp']; |
| | | delete data['']; // Empty key |
| | | |
| | | console.log('Data: ', data); |
| | | return data; |
| | | } |
| | | |
| | | getFieldProcessor(key) { |
| | | if (key.includes('|')) return this.processTableField; |
| | | if (key.includes('::')) return this.processGroupField; |