| | |
| | | /** |
| | | * 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(config = {}) { |
| | | this.config = { |
| | | collectFormData: false, |
| | | ... config |
| | | } |
| | | this.isRestoring = false; |
| | | const store = window.jvbStore.register( |
| | | 'forms', |
| | | { |
| | |
| | | remove: 800, |
| | | reorder: 1000 |
| | | }; |
| | | this.isTimeline = false; |
| | | if (window.crudManager && window.crudManager.isTimeline) { |
| | | this.isTimeline = true; |
| | | } |
| | | |
| | | 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); |
| | |
| | | } |
| | | } |
| | | |
| | | checkPendingForms() { |
| | | // No async needed - data is already loaded in memory |
| | | const allForms = this.store.getAll(); |
| | | const pendingForms = allForms.filter(form => form.status === 'draft'); |
| | | /** |
| | | * Check for pending forms from current page |
| | | */ |
| | | async checkPendingForms() { |
| | | const allForms = await this.store.getAll(); |
| | | const currentPath = window.location.pathname; |
| | | |
| | | const pendingForms = allForms.filter(form => { |
| | | if (form.status !== 'draft') return false; |
| | | |
| | | // Check if form is from current page |
| | | const formPath = form.data?._wp_http_referer; |
| | | return formPath === currentPath; |
| | | }); |
| | | |
| | | pendingForms.forEach(item => { |
| | | const form = this.forms.get(item.formId); |
| | | if (form?.element) { |
| | | const restoreBtn = form.element.querySelector('.restore-form'); |
| | | if (restoreBtn) { |
| | | restoreBtn.hidden = false; |
| | | } |
| | | new this.populateForm(form.element, item.data); |
| | | 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'); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Check for pending operations from previous session |
| | | * Find form element that matches the cached data |
| | | */ |
| | | async checkPendingOperations() { |
| | | const pendingForms = await this.store.query('status', 'pending'); |
| | | 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; |
| | | } |
| | | |
| | | if (pendingForms.length === 0) return; |
| | | // 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; |
| | | } |
| | | |
| | | // Group by form type or page |
| | | const grouped = this.groupPendingForms(pendingForms); |
| | | |
| | | // Show consolidated notification |
| | | this.showPendingNotification(grouped); |
| | | // Fallback: try by formId (if it was already registered) |
| | | return document.querySelector(`[data-form-id="${formData.formId}"]`); |
| | | } |
| | | |
| | | /** |
| | |
| | | if (!this.globalHandlersAdded) { |
| | | 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; |
| | |
| | | status: '', |
| | | options: { |
| | | autosave: 'autosave' in formElement.dataset, |
| | | autoUpload: true, |
| | | saveDelay: this.autoSaveDefaults.delay, |
| | | endpoint: formElement.dataset.save??'', |
| | | endpoint: formElement.dataset.save ?? '', |
| | | formStatus: true, |
| | | cache: true, |
| | | ...options |
| | |
| | | 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.get(formId); |
| | | if (cached && cached.formData) { |
| | | this.showPendingNotification(cached); |
| | | 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')) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 ========== */ |
| | | |
| | | async handleSubmit(event) { |
| | | const form = event.target; |
| | | if (!form.dataset.formId) return; |
| | | |
| | | if (!form.dataset.formId) return; |
| | | const formConfig = this.forms.get(form.dataset.formId); |
| | | |
| | | // Handle subscriber-based forms |
| | |
| | | fullData: formData, |
| | | config: formConfig |
| | | }); |
| | | |
| | | // Don't delete yet - wait for success/error from subscriber |
| | | return; |
| | | } |
| | | |
| | | // For forms that submit normally (not prevented) |
| | | // We can clean up the cache on successful submission |
| | | // This would typically be called from handleFormSuccess |
| | | } |
| | | |
| | | handleFormSuccess(form, data) { |
| | |
| | | form.insertBefore(successBox, form.firstChild); |
| | | } |
| | | |
| | | // ✅ DELETE CACHED FORM DATA ON SUCCESS |
| | | // 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); |
| | |
| | | let container = window.targetCheck(e, 'div.quantity'); |
| | | this.handleNumberClick(e, container.querySelector('input')); |
| | | } else if (window.targetCheck(e, '[data-action]')) { |
| | | let action = window.targetCheck(e, '[data-action]'); |
| | | action = action.dataset.action; |
| | | let actionEl = window.targetCheck(e, '[data-action]'); |
| | | let action = actionEl.dataset.action; |
| | | let form = actionEl.closest('form'); |
| | | |
| | | switch (action) { |
| | | case 'clear-form': |
| | | let form = e.target.closest('form'); |
| | | this.store.delete(form.dataset.formId); |
| | | form?.reset(); |
| | | e.target.closest('.restore-form').hidden = true; |
| | | 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': |
| | | e.target.closest('.restore-form').hidden = true; |
| | | form.querySelector('.fstatus').hidden = true; |
| | | break; |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | handleChange(event) { |
| | | if (event.target.closest('[data-ignore]')) { |
| | | if (event.target.closest('[data-ignore]') || this.isRestoring) { |
| | | return; |
| | | } |
| | | const target = event.target; |
| | |
| | | } |
| | | } |
| | | |
| | | 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]')) { |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) { |
| | | return; |
| | | } |
| | | const target = e.target; |
| | |
| | | } |
| | | |
| | | handleInput(e) { |
| | | if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) { |
| | | if (e.target.closest('[data-ignore]') || ! e.target.closest('form') || this.isRestoring) { |
| | | return; |
| | | } |
| | | const input = e.target.closest('input, textarea, select'); |
| | |
| | | if (this.shouldDebounce(input)){ |
| | | window.debouncer.schedule( |
| | | `validate_${fieldName}`, |
| | | (input, fieldWrapper) => this.validateField.bind(this), |
| | | () => this.validateField.bind(this), |
| | | 500 |
| | | ) |
| | | } |
| | |
| | | }, |
| | | url: { |
| | | pattern: /^https?:\/\/.+\..+/, |
| | | message: 'Please enter a valid URL starting with http:// or https://' |
| | | message: 'Please enter a valid URL starting with https://' |
| | | }, |
| | | phone: { |
| | | pattern: /^[\d\s\-\+\(\)\.]+$/, |
| | | pattern: /^[\d\s\-+().]+$/, |
| | | message: 'Please enter a valid phone number' |
| | | }, |
| | | number: { |
| | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get field value (handles different input types) |
| | | */ |
| | | getFieldValue(input) { |
| | | if (!input) return ''; |
| | | |
| | | if (input.type === 'checkbox') { |
| | | return input.checked ? input.value || '1' : ''; |
| | | } else if (input.type === 'radio') { |
| | | const checked = input.form?.querySelector(`[name="${input.name}"]:checked`); |
| | | return checked ? checked.value : ''; |
| | | } else if (input.type === 'select-multiple') { |
| | | return Array.from(input.selectedOptions).map(o => o.value); |
| | | } |
| | | |
| | | return input.value?.trim() || ''; |
| | | } |
| | | |
| | | /** |
| | | * Show success state (green checkmark) |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate all fields in a container (useful for step validation) |
| | | */ |
| | | validateAllFields(container) { |
| | | if (!container) return true; |
| | | |
| | | const fields = container.querySelectorAll('.field:not([hidden])'); |
| | | let allValid = true; |
| | | |
| | | fields.forEach(fieldWrapper => { |
| | | // Skip complex parent wrappers (repeater, group) - validate their children |
| | | if (this.isComplexFieldWrapper(fieldWrapper)) { |
| | | return; |
| | | } |
| | | |
| | | const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select'); |
| | | if (input && !input.closest('[hidden]')) { |
| | | // Mark as touched so validation will run |
| | | const fieldName = fieldWrapper.dataset.field; |
| | | if (fieldName) { |
| | | this.touchedFields.add(fieldName); |
| | | } |
| | | |
| | | const isValid = this.validateField(input, fieldWrapper); |
| | | if (!isValid) { |
| | | allValid = false; |
| | | |
| | | // Scroll to first error |
| | | if (allValid === false) { |
| | | input.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | input.focus(); |
| | | } |
| | | } |
| | | } |
| | | }); |
| | | |
| | | return allValid; |
| | | } |
| | | |
| | | /** |
| | | * Check if field wrapper is a complex type (repeater, group, etc.) |
| | | */ |
| | | isComplexFieldWrapper(fieldWrapper) { |
| | | return fieldWrapper.classList.contains('repeater') || |
| | | fieldWrapper.classList.contains('group') || |
| | | fieldWrapper.classList.contains('upload'); |
| | | } |
| | | |
| | | /** |
| | | * Special validation for repeater fields |
| | | */ |
| | | attachRepeaterValidation(form) { |
| | | // When a repeater row is added, attach validation to its fields |
| | | form.addEventListener('click', (e) => { |
| | | if (e.target.closest('.add-repeater-row')) { |
| | | // Wait for the DOM to update |
| | | setTimeout(() => { |
| | | const repeaterRows = form.querySelectorAll('.repeater-row'); |
| | | repeaterRows.forEach(row => { |
| | | const inputs = row.querySelectorAll('input, textarea, select'); |
| | | inputs.forEach(input => { |
| | | const fieldWrapper = this.findFieldWrapper(input); |
| | | if (fieldWrapper) { |
| | | // Validation listeners are already attached via event delegation |
| | | // Just clear any existing validation state for new rows |
| | | this.clearValidation(fieldWrapper); |
| | | } |
| | | }); |
| | | }); |
| | | }, 100); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Special validation for group fields |
| | | */ |
| | | attachGroupValidation(form) { |
| | | // Group fields might have conditional fields |
| | | // Validate when conditions change |
| | | form.addEventListener('change', (e) => { |
| | | const changedInput = e.target.closest('input, select'); |
| | | if (!changedInput) return; |
| | | |
| | | // Check if this change affects conditional fields |
| | | const fieldName = changedInput.name; |
| | | if (!fieldName) return; |
| | | |
| | | // Find any conditional fields that depend on this field |
| | | const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`); |
| | | conditionalFields.forEach(conditionalField => { |
| | | // Clear validation for hidden fields |
| | | if (conditionalField.hidden) { |
| | | this.clearValidation(conditionalField); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Reset validation state for a form |
| | | */ |
| | | resetForm(form) { |
| | | if (!form) return; |
| | | |
| | | // Clear all touched fields |
| | | this.touchedFields.clear(); |
| | | |
| | | // Clear all validation states |
| | | const fields = form.querySelectorAll('.field'); |
| | | fields.forEach(fieldWrapper => { |
| | | this.clearValidation(fieldWrapper); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Get validation errors for a form |
| | | */ |
| | | getFormErrors(form) { |
| | | const errors = {}; |
| | | const fields = form.querySelectorAll('.field.has-error'); |
| | | |
| | | fields.forEach(fieldWrapper => { |
| | | const fieldName = fieldWrapper.dataset.field; |
| | | const message = fieldWrapper.querySelector('.validation-message'); |
| | | if (fieldName && message) { |
| | | errors[fieldName] = message.textContent; |
| | | } |
| | | }); |
| | | |
| | | return errors; |
| | | } |
| | | |
| | | /** |
| | | * Add custom validator |
| | | */ |
| | | addValidator(name, validator) { |
| | | this.validators[name] = validator; |
| | | } |
| | | /* ========== Auto-save functionality ========== */ |
| | | /** |
| | | * Get appropriate delay based on field type and context |
| | |
| | | } |
| | | |
| | | showFormStatus(formID, status, message='') { |
| | | // Remove existing status |
| | | let form = this.forms.get(formID); |
| | | if (!form.options.formStatus) { |
| | | if (!form?.options.formStatus) { |
| | | return; |
| | | } |
| | | |
| | |
| | | |
| | | form.status = status; |
| | | |
| | | // Add new 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...', |
| | |
| | | 'uploading': 'Uploading your form to server', |
| | | 'submitted': 'Successfully sent to server', |
| | | 'pending': 'Unsaved changes', |
| | | 'restored': 'Welcome back! We\'ve restored your previous entry.', |
| | | 'error': 'Failed to save changes. Refresh and try again?', |
| | | 'offline': 'Changes will be saved when online' |
| | | }; |
| | | |
| | | const icons = { |
| | | 'autosaved': 'check-circle', |
| | | 'submitted': 'check-circle', |
| | | 'restored': 'history', |
| | | 'error': 'close-circle', |
| | | 'offline': 'cloud-slash', |
| | | 'pending': 'exclamation-mark' |
| | |
| | | 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 === 'submitted') { |
| | | setTimeout(() => statusWrap.hidden = true, 3000); |
| | |
| | | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form, isInit = false) { |
| | | collectFormData(form) { |
| | | if (Object.hasOwn(form.dataset, 'timeline')) { |
| | | return this.collectTimeline(form); |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | if (this.ignore.includes(key) || key.endsWith('_temp')) { |
| | | continue; |
| | | } |
| | | const match = key.match(/^\[(\d+)\](.+)$/); |
| | | const match = key.match(/^\[(\d+)](.+)$/); |
| | | if (match) { |
| | | // Timeline-specific field: [postId]fieldName |
| | | const [, postId, fieldName] = match; |
| | |
| | | getFieldProcessor(key) { |
| | | if (key.includes('::')) return this.processGroupField; |
| | | if (key.includes(':')) return this.processRepeaterField; |
| | | if (/\[[^\]]+\]/.test(key)) return this.processLocationField; |
| | | if (/\[[^\]]+]/.test(key)) return this.processLocationField; |
| | | return this.processRegularField; |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | |
| | | |
| | | const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`); |
| | | const summary = window.getTemplate('formSummary'); |
| | | |
| | | const [ |
| | | title, |
| | | resultWrapper, |
| | | resultTemplate |
| | | ] = [ |
| | | summary.querySelector('h2'), |
| | | summary.querySelector('.summary'), |
| | | summary.querySelector('.result') |
| | | ]; |
| | | if (!summary) return; |
| | | const wrapper = summary.querySelector('.result'); |
| | | |
| | | // Fields to skip in summary |
| | | const skipFields = ['sendAll', ...this.ignore]; |
| | |
| | | |
| | | // Get field info from form |
| | | const fieldInfo = this.getFieldInfo(form, key); |
| | | |
| | | if (!fieldInfo.label) continue; // Skip if no label found |
| | | |
| | | // Create result element |
| | | const resultEl = this.createResultElement( |
| | | resultTemplate, |
| | | fieldInfo, |
| | | value, |
| | | form |
| | | ); |
| | | let field = wrapper.cloneNode(true); |
| | | let title = field.querySelector('h3'); |
| | | let p = field.querySelector('p'); |
| | | |
| | | if (resultEl) { |
| | | resultWrapper.appendChild(resultEl); |
| | | 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 |
| | | resultTemplate.remove(); |
| | | wrapper.remove(); |
| | | |
| | | // Insert summary and hide form |
| | | clear = (clear !== 'form') ? form.closest(clear)??form : form; |
| | |
| | | if (Array.isArray(value) && value.length === 0) { |
| | | return true; |
| | | } |
| | | if (typeof value === 'object' && Object.keys(value).length === 0) { |
| | | return true; |
| | | } |
| | | return false; |
| | | return typeof value === 'object' && Object.keys(value).length === 0; |
| | | |
| | | } |
| | | |
| | | /** |
| | |
| | | getFieldInfo(form, fieldName) { |
| | | // Try to find label by 'for' attribute (exact match) |
| | | let label = form.querySelector(`label[for="${fieldName}"]`); |
| | | let input = null; |
| | | let fieldWrapper = null; |
| | | |
| | | let input = form.querySelector(`[name=${fieldName}]`); |
| | | // Try to find the input field - check multiple patterns |
| | | if (!input) { |
| | | // Try exact match first |
| | |
| | | // Try closest field wrapper first |
| | | const field = input.closest('.field, fieldset'); |
| | | if (field) { |
| | | label = field.querySelector('label, legend'); |
| | | label = field.querySelector('label, legend, h2'); |
| | | } |
| | | } |
| | | |
| | | // Get field wrapper - always use base name (no special characters) |
| | | fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`); |
| | | let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`); |
| | | |
| | | // Determine field type |
| | | let fieldType = 'text'; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Create a result element for a field |
| | | */ |
| | | createResultElement(template, fieldInfo, value, form) { |
| | | const resultEl = template.cloneNode(true); |
| | | const titleEl = resultEl.querySelector('h4'); |
| | | const valueEl = resultEl.querySelector('p'); |
| | | |
| | | // Set label |
| | | titleEl.textContent = fieldInfo.label; |
| | | |
| | | // Format value based on field type |
| | | const formattedValue = this.formatFieldValue(value, fieldInfo.type, form); |
| | | |
| | | // Determine how to set the value |
| | | if (this.isHtmlContent(formattedValue)) { |
| | | // HTML content - use innerHTML |
| | | valueEl.innerHTML = formattedValue; |
| | | } else { |
| | | // Plain text - use textContent for safety |
| | | valueEl.textContent = formattedValue; |
| | | } |
| | | |
| | | return resultEl; |
| | | } |
| | | |
| | | /** |
| | | * Check if content should be treated as HTML |
| | | */ |
| | | isHtmlContent(content) { |
| | |
| | | if (Array.isArray(value)) { |
| | | return this.formatArrayValue(value); |
| | | } |
| | | return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No'; |
| | | return (value === '1' || value === 1 || value === true) ? 'Yes' : value; |
| | | |
| | | case 'select': |
| | | // Handle both single and multi-select |
| | |
| | | case 'location': |
| | | return this.formatLocationValue(value); |
| | | |
| | | case 'file': |
| | | case 'image': |
| | | case 'upload': |
| | | return this.formatFileValue(value); |
| | | |
| | | case 'number': |
| | |
| | | } |
| | | |
| | | /** |
| | | * Convert newlines to <br> tags (kept for backwards compatibility) |
| | | */ |
| | | nl2br(text) { |
| | | return this.formatPlainText(text); |
| | | } |
| | | |
| | | /** |
| | | * Event system |
| | | */ |
| | | subscribe(callback) { |
| | |
| | | // Remove global handlers |
| | | if (this.globalHandlersAdded) { |
| | | document.removeEventListener('change', this.changeHandler); |
| | | document.removeEventListener('focus', this.focusHandler, true); |
| | | document.removeEventListener('blur', this.blurHandler, true); |
| | | document.removeEventListener('input', this.inputHandler, true); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbForm = FormController; |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe(event => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbForm = FormController; |
| | | } |
| | | }); |
| | | |
| | | }); |