Jake Vanderwerf
2025-11-10 e9967fa22781d922ba4eb8fb44fe72d200ac4b14
assets/js/concise/FormController.js
@@ -188,6 +188,7 @@
    * Register a standalone form (for front-end forms)
    */
   registerForm(formElement, options = {}) {
      if (!formElement) return;
      const formId = formElement.dataset.formId || `form_${Date.now()}`;
      formElement.dataset.formId = formId;
@@ -196,16 +197,17 @@
      const formConfig = {
         element: formElement,
         id: formId,
         status: '',
         options: {
            autoSave: true,
            autosave: 'autosave' in formElement.dataset,
            saveDelay: this.autoSaveDefaults.delay,
            endpoint: formElement.dataset.save,
            endpoint: formElement.dataset.save??'',
            formStatus: true,
            cache: true,
            ...options
         },
         dependencies: new Map(),
         data: this.collectFormData(formElement),
         isDirty: false
      };
      // Initialize special fields
@@ -255,7 +257,7 @@
      // Scan for existing selector fields
      if (window.jvbSelector) {
         window.jvbSelector.scanExistingFields();
         window.jvbSelector.scanExistingFields(form);
      }
   }
@@ -444,8 +446,7 @@
      container.appendChild(row);
      // Schedule save if auto-save enabled
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'add',
@@ -472,7 +473,7 @@
      this.updateRepeaterOrder(repeater, formConfig);
      // Schedule save
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'remove',
@@ -515,7 +516,7 @@
      });
      // Schedule save
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'reorder',
@@ -650,16 +651,15 @@
   /* ========== Event Handlers ========== */
   handleSubmit(event) {
      //TODO: submit data, if successful, delete from store
      if (this.subscribers.size > 0 ){
         const form = event.target;
         if (!form.dataset.formId) return;
   async handleSubmit(event) {
      const form = event.target;
      if (!form.dataset.formId) return;
      const formConfig = this.forms.get(form.dataset.formId);
      // Handle subscriber-based forms
      if (this.subscribers.size > 0) {
         event.preventDefault();
         const formConfig = this.forms.get(form.dataset.formId);
         if (!formConfig) return;
         const formData = this.collectFormData(form);
         this.notify('form-submit', {
            formId: formConfig.id,
@@ -669,6 +669,133 @@
      }
   }
   handleFormSuccess(form, data) {
      // Clear previous errors
      form.querySelectorAll('.error-message').forEach(el => el.remove());
      form.querySelectorAll('.field-error').forEach(el =>
         el.classList.remove('field-error')
      );
      // Add success class to form
      form.classList.add('form-success');
      // Show success message if provided
      if (data.message) {
         const success = document.createElement('div');
         success.className = 'form-success-message success-message';
         success.textContent = data.message;
         form.insertBefore(success, form.firstChild);
         // Optionally add icon
         const icon = window.getIcon?.('check-circle');
         if (icon) {
            icon.classList.add('success-icon');
            success.prepend(icon);
         }
      }
      // If there's a title/description (for registration success)
      if (data.title || data.description) {
         const successBox = document.createElement('div');
         successBox.className = 'success-box';
         if (data.title) {
            const title = document.createElement('h3');
            title.textContent = data.title;
            successBox.appendChild(title);
         }
         if (data.description) {
            // Handle both string and array descriptions
            const descriptions = Array.isArray(data.description)
               ? data.description
               : [data.description];
            descriptions.forEach(desc => {
               const p = document.createElement('p');
               p.textContent = desc;
               successBox.appendChild(p);
            });
         }
         form.insertBefore(successBox, form.firstChild);
      }
      // Announce success for accessibility
      if (window.jvbA11y) {
         window.jvbA11y.announce(data.message || 'Form submitted successfully');
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-success', {
         detail: data
      }));
   }
   handleFormError(form, data) {
      // Clear all previous errors
      form.querySelectorAll('.error-message').forEach(el => el.remove());
      form.querySelectorAll('.field-error, .has-error').forEach(el => {
         el.classList.remove('field-error', 'has-error');
      });
      // Clear validation states using existing method
      form.querySelectorAll('.field').forEach(fieldWrapper => {
         this.clearValidation(fieldWrapper);
      });
      // Handle field-specific errors
      if (data.field) {
         const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
         if (fieldWrapper) {
            // Use existing showError method for consistency
            this.showError(fieldWrapper, data.message);
            // Mark as touched so validation persists
            this.touchedFields.add(data.field);
            // Scroll to error
            fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
            // Focus the input for better UX
            const input = fieldWrapper.querySelector('input, textarea, select');
            if (input) {
               input.focus();
            }
         }
      } else {
         // General form error (not field-specific)
         const error = document.createElement('div');
         error.className = 'form-error error-message';
         error.textContent = data.message;
         // Add icon for consistency
         const icon = window.getIcon?.('close-circle');
         if (icon) {
            icon.classList.add('error-icon');
            error.prepend(icon);
         }
         form.insertBefore(error, form.firstChild);
         // Scroll to top to show the error
         form.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }
      // Announce error for accessibility
      if (window.jvbA11y) {
         const announcement = data.field
            ? `Error in ${data.field}: ${data.message}`
            : `Form error: ${data.message}`;
         window.jvbA11y.announce(announcement);
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-error', {
         detail: data
      }));
   }
   handleClick(e) {
      if (window.targetCheck(e, 'div.quantity')) {
         let container = window.targetCheck(e, 'div.quantity');
@@ -746,15 +873,16 @@
   }
   handleChange(event) {
      if (this.subscribers.size > 0) {
         const target = event.target;
         const form = target.form || target.closest('form');
      if (event.target.closest('[data-ignore]')) {
         return;
      }
      const target = event.target;
      const form = target.form || target.closest('form');if (!form) return;
         if (!form) return;
         const formConfig = this.forms?.get(form.dataset.formId);
         if (!formConfig) return;
      const formConfig = this.forms?.get(form.dataset.formId);
      if (!formConfig) return;
      console.log(formConfig.options);
      if (formConfig.options.autosave || this.subscribers.size > 0) {
         // Check conditional fields
         const dependencies = formConfig.dependencies.get(target.name);
         if (dependencies) {
@@ -764,10 +892,8 @@
         }
         // Schedule auto-save if enabled
         if (formConfig.options.autoSave && !form.dataset.noautosave) {
            const delay = this.getDelayForField(target);
            this.scheduleSave(formConfig, delay);
         }
         const delay = this.getDelayForField(target);
         this.scheduleSave(formConfig, delay);
      }
   }
@@ -780,6 +906,9 @@
   }
   handleBlur(e) {
      if (e.target.closest('[data-ignore]')) {
         return;
      }
      const target = e.target;
      const form = target.form || target.closest('form');
@@ -801,7 +930,7 @@
            this.validateField(input, fieldWrapper);
         }
         const formConfig = this.forms?.get(form.dataset.formId);
         if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
         if (formConfig) {
            // Shorter delay on blur
            this.scheduleSave(formConfig, {
               type: 'blur',
@@ -813,6 +942,9 @@
   }
   handleInput(e) {
      if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
         return;
      }
      const input = e.target.closest('input, textarea, select');
      if (!input) return;
@@ -974,6 +1106,7 @@
      // All validations passed
      this.showSuccess(fieldWrapper);
      this.notify('field-validated', input);
      return true;
   }
@@ -998,7 +1131,7 @@
   /**
    * Show success state (green checkmark)
    */
   showSuccess(fieldWrapper) {
   showSuccess(fieldWrapper, textMessage = '') {
      if (!fieldWrapper) return;
      // Find validation elements (they might be in field-input-wrapper or field-content)
@@ -1024,8 +1157,13 @@
      // Hide error message
      if (message) {
         message.hidden = true;
         message.textContent = '';
         if (textMessage === '') {
            message.hidden = true;
            message.textContent = '';
         } else {
            message.hidden = false;
            message.textContent = textMessage;
         }
      }
   }
@@ -1244,6 +1382,9 @@
      return this.autoSaveDefaults.delay;
   }
   scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
      if (!formConfig.options.autosave) {
         return;
      }
      document.addEventListener('input', this.saveCheck, {passive: true});
      const saveKey = `autosave_${formConfig.id}`;
@@ -1279,7 +1420,9 @@
      // Get only changed fields
      const changes = this.getChangedFields(formConfig.data, formData);
      console.log('Changes:', changes);
      if (Object.keys(changes).length === 0) return;
      console.log('Continuing on:');
      // Update stored data
      formConfig.data = formData;
@@ -1313,14 +1456,23 @@
      // Check if current data differs from snapshot
      const currentData = this.collectFormData(formConfig.element);
      const changes = this.getChangedFields(formConfig.lastSnapshot, currentData);
      const changes = this.getChangedFields(formConfig.data, currentData);
      return Object.keys(changes).length > 0;
   }
   showFormStatus(formID, status) {
   showFormStatus(formID, status, message='') {
      // Remove existing status
      let form = this.forms.get(formID);
      if (!form.options.formStatus) {
         return;
      }
      if (form.status === status){
         return;
      }
      form.status = status;
      console.log('Setting status: ', status);
@@ -1341,9 +1493,9 @@
         'offline': 'Changes will be saved when online'
      };
      const icons = {
         'autosaved': 'check',
         'submitted': 'check',
         'error': 'close',
         'autosaved': 'check-circle',
         'submitted': 'check-circle',
         'error': 'close-circle',
         'offline': 'cloud-slash',
         'pending': 'exclamation-mark'
      }
@@ -1352,9 +1504,10 @@
      if (icon) {
         statusWrap.prepend(icon);
      }
      console.log(status, messages[status]);
      console.log(status, icons[status]);
      statusElement.textContent = messages[status] || status;
      if (message === '') {
         message = messages[status] || status;
      }
      statusElement.textContent = message;
      statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
      // Auto-hide success messages
@@ -1382,6 +1535,9 @@
   /* ========== Form Data Methods ========== */
   collectFormData(form) {
      if (Object.hasOwn(form.dataset, 'timeline')) {
         return this.collectTimeline(form);
      }
      const formData = new FormData(form);
      let data = {};
      const repeaterData = {};
@@ -1400,6 +1556,46 @@
      return this.mergeRepeaterData(data, repeaterData);
   }
   collectTimeline(form) {
      console.log('Collecting Timeline data:');
      let data = {};
      let posts = {}; // Temporary object keyed by post ID
      let postOrder = []; // Track order as encountered (preserves DOM/drag order)
      let formData = new FormData(form);
      for (const [key, value] of formData.entries()) {
         if (this.ignore.includes(key) || key.endsWith('_temp')) {
            continue;
         }
         const match = key.match(/^\[(\d+)\](.+)$/);
         if (match) {
            // Timeline-specific field: [postId]fieldName
            const [, postId, fieldName] = match;
            if (!posts[postId]) {
               posts[postId] = { id: parseInt(postId) };
               postOrder.push(postId); // Track first occurrence
            }
            const processor = this.getFieldProcessor(fieldName);
            processor(fieldName, value, posts[postId], {}, {}, form);
         } else {
            // Shared field (post_title, taxonomies, etc.)
            const processor = this.getFieldProcessor(key);
            processor(key, value, data, {}, {}, form);
         }
      }
      // Convert to array in DOM order (matches menu_order)
      data.timeline = postOrder.map(id => posts[id]);
      delete data['form-id'];
      delete data['sendAll'];
      delete data['timeline_temp'];
      delete data['']; // Empty key
      console.log('Data: ', data);
      return data;
   }
   getFieldProcessor(key) {
      if (key.includes('|')) return this.processTableField;
      if (key.includes('::')) return this.processGroupField;