Jake Vanderwerf
2026-01-01 0e4b986e81f8132a44e61fa8df18860301cc3468
assets/js/concise/FormController.js
@@ -1,7 +1,3 @@
/**
 * 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 = {
@@ -289,6 +285,7 @@
         status: '',
         options: {
            autosave: 'autosave' in formElement.dataset,
            autoUpload: true,
            saveDelay: this.autoSaveDefaults.delay,
            endpoint: formElement.dataset.save ?? '',
            formStatus: true,
@@ -334,7 +331,7 @@
      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')) {
@@ -958,8 +955,8 @@
   /**
    * Initialize image upload fields
    */
   initImageUploadFields(form) {
      window.jvbUploads.scanFields(form);
   initImageUploadFields(form, config) {
      window.jvbUploads.scanFields(form, config.options.autoUpload);
   }
   /* ========== Event Handlers ========== */
@@ -981,14 +978,7 @@
            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) {
@@ -1320,7 +1310,7 @@
            message: 'Please enter a valid URL starting with https://'
         },
         phone: {
            pattern: /^[\d\s\-\+\(\)\.]+$/,
            pattern: /^[\d\s\-+().]+$/,
            message: 'Please enter a valid phone number'
         },
         number: {
@@ -1543,145 +1533,6 @@
      }
   }
   /**
    * 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
@@ -1875,7 +1726,7 @@
   /* ========== Form Data Methods ========== */
   collectFormData(form, isInit = false) {
   collectFormData(form) {
      if (Object.hasOwn(form.dataset, 'timeline')) {
         return this.collectTimeline(form);
      }
@@ -1912,7 +1763,7 @@
         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;
@@ -1950,7 +1801,7 @@
   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;
   }
@@ -2122,7 +1973,9 @@
         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 {
@@ -2131,6 +1984,27 @@
         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();
@@ -2152,10 +2026,8 @@
      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;
   }
   /**
@@ -2166,8 +2038,6 @@
      // 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
@@ -2193,12 +2063,12 @@
         // 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';
@@ -2258,7 +2128,7 @@
            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
@@ -2285,8 +2155,7 @@
         case 'location':
            return this.formatLocationValue(value);
         case 'file':
         case 'image':
         case 'upload':
            return this.formatFileValue(value);
         case 'number':
@@ -2529,13 +2398,6 @@
   }
   /**
    * Convert newlines to <br> tags (kept for backwards compatibility)
    */
   nl2br(text) {
      return this.formatPlainText(text);
   }
   /**
    * Event system
    */
   subscribe(callback) {
@@ -2594,6 +2456,11 @@
   }
}
document.addEventListener('DOMContentLoaded', () => {
   window.jvbForm = FormController;
document.addEventListener('DOMContentLoaded', async function () {
   window.auth.subscribe(event => {
      if (event === 'auth-loaded') {
         window.jvbForm = FormController;
      }
   });
});