| | |
| | | /** |
| | | * 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 = { |
| | |
| | | status: '', |
| | | options: { |
| | | autosave: 'autosave' in formElement.dataset, |
| | | autoUpload: true, |
| | | saveDelay: this.autoSaveDefaults.delay, |
| | | endpoint: formElement.dataset.save ?? '', |
| | | formStatus: true, |
| | |
| | | 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 image upload fields |
| | | */ |
| | | initImageUploadFields(form) { |
| | | window.jvbUploads.scanFields(form); |
| | | initImageUploadFields(form, config) { |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload); |
| | | } |
| | | |
| | | /* ========== Event Handlers ========== */ |
| | |
| | | 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) { |
| | |
| | | message: 'Please enter a valid URL starting with https://' |
| | | }, |
| | | phone: { |
| | | pattern: /^[\d\s\-\+\(\)\.]+$/, |
| | | pattern: /^[\d\s\-+().]+$/, |
| | | message: 'Please enter a valid phone number' |
| | | }, |
| | | number: { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | |
| | | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form, isInit = false) { |
| | | collectFormData(form) { |
| | | if (Object.hasOwn(form.dataset, 'timeline')) { |
| | | return this.collectTimeline(form); |
| | | } |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | let p = field.querySelector('p'); |
| | | |
| | | title.textContent = fieldInfo.label; |
| | | let formatted = this.formatFieldValue(value, fieldInfo.type); |
| | | |
| | | |
| | | let formatted = this.formatFieldValue(value, fieldInfo.type, form); |
| | | if (this.isHtmlContent(formatted)) { |
| | | p.innerHTML = formatted; |
| | | } else { |
| | |
| | | |
| | | 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(); |
| | |
| | | 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; |
| | | |
| | | } |
| | | |
| | | /** |
| | |
| | | // Try to find label by 'for' attribute (exact match) |
| | | let label = form.querySelector(`label[for="${fieldName}"]`); |
| | | let input = form.querySelector(`[name=${fieldName}]`); |
| | | let fieldWrapper = input?.closest('.field'); |
| | | |
| | | // 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'; |
| | |
| | | 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) { |
| | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbForm = FormController; |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe(event => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbForm = FormController; |
| | | } |
| | | }); |
| | | |
| | | }); |