Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
assets/js/concise/FormController.js
@@ -3,11 +3,20 @@
 * Works with DataStore for CRUD operations and standalone for front-end forms
 */
class FormController {
   constructor(store = null) {
      this.store = store; // Optional - for CRUD operations
      if (!store) {
         this.store = new window.jvbStore({name:'forms', TTL: 604800});
      }
   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
      });
      this.debouncer = window.debouncer;
      this.ignore = [];
@@ -19,6 +28,10 @@
      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
@@ -52,21 +65,49 @@
      // 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();
   }
   handleStoreEvent(event, data) {
      switch(event) {
         case 'item-saved':
            if (data.item.status === 'autosave') {
               // this.showFormStatus(data.item.formId, 'autosave');
            }
            break;
         case 'data-loaded':
            this.checkPendingForms();
            break;
      }
   }
   async checkPendingForms() {
      let items = await this.store.query('status', 'draft');
      items.forEach(item => {
         let form = this.forms.get(item.formId);
         if (form && form.element) {
            form.element.querySelector('.restore-form').hidden = false;
            new this.populateForm(form.element, item.data);
         }
      });
   }
   /**
    * Check for pending operations from previous session
    */
   async checkPendingOperations() {
      if (!this.store) return;
      try {
         let pending = this.store.getAllForms();
      const pendingForms = await this.store.query('status', 'pending');
      } catch (error) {
         console.error('Failed to load pending forms:', error);
      }
      if (pendingForms.length === 0) return;
      // Group by form type or page
      const grouped = this.groupPendingForms(pendingForms);
      // Show consolidated notification
      this.showPendingNotification(grouped);
   }
   /**
@@ -121,7 +162,7 @@
    * Discard pending form data
    */
   async discardPendingForm(formId) {
      this.store.clearForm(formId);
      this.store.delete(formId);
      if (window.jvbA11y) {
         window.jvbA11y.announce('Previous changes discarded');
@@ -134,11 +175,11 @@
   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;
      }
   }
@@ -150,13 +191,15 @@
      const formId = formElement.dataset.formId || `form_${Date.now()}`;
      formElement.dataset.formId = formId;
      formElement.addEventListener('submit', this.submitHandler);
      const formConfig = {
         element: formElement,
         id: formId,
         options: {
            autoSave: true,
            autoSave: 'autosave' in formElement.dataset,
            saveDelay: this.autoSaveDefaults.delay,
            endpoint: formElement.dataset.save,
            endpoint: formElement.dataset.save??'',
            cache: true,
            ...options
         },
@@ -173,7 +216,7 @@
      // Check for pending data
      if (this.store && formConfig.options.cache) {
         const cached = this.store.getForm(formId);
         const cached = this.store.get(formId);
         if (cached && cached.formData) {
            this.showPendingNotification(cached);
         }
@@ -205,16 +248,132 @@
      // 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) {
@@ -485,25 +644,23 @@
   /**
    * Initialize image upload fields
    */
   initImageUploadFields() {
      window.jvbUploads.scanFields();
   initImageUploadFields(form) {
      window.jvbUploads.scanFields(form);
   }
   /* ========== 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;
         event.preventDefault();
         const formConfig = this.forms.get(form.dataset.formId);
         if (!formConfig) return;
         const formData = this.collectFormData(form);
         event.preventDefault();
         this.notify('form-submit', {
            formId: formConfig.id,
            data: formData,
@@ -516,9 +673,24 @@
      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 action = window.targetCheck(e, '[data-action]');
         action = action.dataset.action;
         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;
               break;
            case 'dismiss-restore':
               e.target.closest('.restore-form').hidden = true;
               break;
         }
      }
   }
   handleNumberClick(e, input) {
      let change = 0;
@@ -574,6 +746,9 @@
   }
   handleChange(event) {
      if (event.target.closest('[data-ignore]')) {
         return;
      }
      if (this.subscribers.size > 0) {
         const target = event.target;
         const form = target.form || target.closest('form');
@@ -607,29 +782,463 @@
      }
   }
   handleBlur(event) {
      const target = event.target;
   handleBlur(e) {
      if (e.target.closest('[data-ignore]')) {
         return;
      }
      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 && formConfig.options.autoSave && !form.dataset.noautosave) {
            // 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')) {
         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}`,
            (input, fieldWrapper) => 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 http:// or 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);
      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)
    */
   showSuccess(fieldWrapper) {
      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) {
         message.hidden = true;
         message.textContent = '';
      }
   }
   /**
    * 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 = '';
      }
   }
   /**
    * 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
    */
   getDelayForField(field) {
      console.log('Get Delay for Field', field);
      // Text fields get longer delay for typing
      if (field.type === 'text' || field.type === 'textarea') {
         return this.autoSaveDefaults.typingDelay;
@@ -644,7 +1253,7 @@
      return this.autoSaveDefaults.delay;
   }
   scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
      document.addEventListener('input', this.handleInput, {passive: true});
      document.addEventListener('input', this.saveCheck, {passive: true});
      const saveKey = `autosave_${formConfig.id}`;
      this.debouncer.schedule(
@@ -655,17 +1264,27 @@
   }
   //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.cacheFormData(formConfig, formData);
      this.showFormStatus(formConfig.id, 'saving');
      await this.store.save({
         formId: formConfig.id,
         data: formData,
         status: 'draft',
         timestamp: Date.now()
      }).then(()=> {
         this.showFormStatus(formConfig.id, 'autosaved');
      });
      // Get only changed fields
      const changes = this.getChangedFields(formConfig.data, formData);
@@ -691,20 +1310,6 @@
      });
   }
   cacheFormData(formConfig, formData) {
      try {
         this.store.storeForm(formConfig.id, {
            formId: formConfig.id,
            formData: formData,
            timestamp: Date.now(),
            status: 'pending',
            operationId: null
         });
      } catch (error) {
         console.error('Failed to cache form data:', error);
      }
   }
   /**
    * Check if form has unsaved changes
    */
@@ -722,30 +1327,48 @@
      return Object.keys(changes).length > 0;
   }
   showFormStatus(form, status) {
   showFormStatus(formID, status) {
      // Remove existing status
      const existingStatus = form.querySelector('.form-status');
      if (existingStatus) {
         existingStatus.remove();
      }
      let form = this.forms.get(formID);
      console.log('Setting status: ', status);
      // Add new status
      const statusElement = document.createElement('div');
      statusElement.className = `form-status status-${status}`;
      const statusWrap = form.element.querySelector('.fstatus');
      statusWrap.hidden = false;
      const statusElement = statusWrap.querySelector('.message');
      statusElement.textContent = '';
      statusWrap.querySelector('.icon')?.remove();
      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',
         'error': 'Failed to save changes. Refresh and try again?',
         'offline': 'Changes will be saved when online'
      };
      const icons = {
         'autosaved': 'check',
         'submitted': 'check',
         'error': 'close',
         'offline': 'cloud-slash',
         'pending': 'exclamation-mark'
      }
      let icon = window.getIcon(icons[status]);
      if (icon) {
         statusWrap.prepend(icon);
      }
      console.log(status, messages[status]);
      console.log(status, icons[status]);
      statusElement.textContent = messages[status] || status;
      form.insertBefore(statusElement, form.firstChild);
      statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
      // Auto-hide success messages
      if (status === 'saved') {
         setTimeout(() => statusElement.remove(), 3000);
      if (status === 'submitted') {
         setTimeout(() => statusWrap.hidden = true, 3000);
      }
   }
@@ -790,7 +1413,7 @@
      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;
   }
@@ -924,6 +1547,7 @@
   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]];
@@ -953,6 +1577,485 @@
      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');
      const [
         title,
         resultWrapper,
         resultTemplate
      ] = [
         summary.querySelector('h2'),
         summary.querySelector('.summary'),
         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
         // Create result element
         const resultEl = this.createResultElement(
            resultTemplate,
            fieldInfo,
            value,
            form
         );
         if (resultEl) {
            resultWrapper.appendChild(resultEl);
         }
      }
      // Remove template
      resultTemplate.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;
      }
      if (typeof value === 'object' && Object.keys(value).length === 0) {
         return true;
      }
      return false;
   }
   /**
    * 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 = null;
      let fieldWrapper = null;
      // 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');
         }
      }
      // Get field wrapper - always use base name (no special characters)
      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
      };
   }
   /**
    * 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) {
      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' : 'No';
         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 'file':
         case 'image':
            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, '&amp;')
         .replace(/</g, '&lt;')
         .replace(/>/g, '&gt;');
      // 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>');
   }
   /**
    * Convert newlines to <br> tags (kept for backwards compatibility)
    */
   nl2br(text) {
      return this.formatPlainText(text);
   }
   /**
    * Event system
    */
@@ -971,7 +2074,6 @@
   cleanupForm(formId) {
      const formConfig = this.forms.get(formId);
      if (!formConfig) return;
      console.log('Cleaning up form', formConfig);
      // Check for unsaved changes
      if (this.hasUnsavedChanges(formId)) {
@@ -991,11 +2093,17 @@
   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();
@@ -1010,4 +2118,5 @@
document.addEventListener('DOMContentLoaded', () => {
   window.jvbForm = FormController;
   console.log('FormController in window');
});