Jake Vanderwerf
2025-12-21 3aada9949d51024a92a8b5c6cb70d12f9c3cac16
assets/js/concise/FormController.js
@@ -8,6 +8,7 @@
         collectFormData: false,
         ... config
      }
      this.isRestoring = false;
      const store = window.jvbStore.register(
         'forms',
         {
@@ -57,17 +58,14 @@
         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);
@@ -127,35 +125,68 @@
      }
   }
   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}"]`);
   }
   /**
@@ -236,7 +267,6 @@
      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;
@@ -260,7 +290,7 @@
         options: {
            autosave: 'autosave' in formElement.dataset,
            saveDelay: this.autoSaveDefaults.delay,
            endpoint: formElement.dataset.save??'',
            endpoint: formElement.dataset.save ?? '',
            formStatus: true,
            cache: true,
            ...options
@@ -269,17 +299,14 @@
         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);
         }
      }
@@ -296,6 +323,8 @@
      // Initialize repeater fields
      this.initRepeaterFields(form, formConfig);
      this.initTagListFields(form, formConfig);
      // Initialize conditional fields
      if (formConfig) {
         this.initConditionalFields(form, formConfig);
@@ -586,6 +615,231 @@
   }
   /**
    * 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) {
@@ -630,8 +884,8 @@
      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);
@@ -639,7 +893,7 @@
         case 'contains': return fieldStr.includes(requiredStr);
         case 'empty': return fieldStr === '';
         case 'not_empty': return fieldStr !== '';
         default: return fieldStr == requiredStr;
         default: return fieldStr === requiredStr;
      }
   }
@@ -712,8 +966,8 @@
   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
@@ -787,7 +1041,7 @@
         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);
@@ -882,17 +1136,25 @@
         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;
         }
      }
@@ -954,7 +1216,7 @@
   }
   handleChange(event) {
      if (event.target.closest('[data-ignore]')) {
      if (event.target.closest('[data-ignore]') || this.isRestoring) {
         return;
      }
      const target = event.target;
@@ -978,16 +1240,8 @@
      }
   }
   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;
@@ -1023,7 +1277,7 @@
   }
   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');
@@ -1043,7 +1297,7 @@
      if (this.shouldDebounce(input)){
         window.debouncer.schedule(
            `validate_${fieldName}`,
            (input, fieldWrapper) => this.validateField.bind(this),
            () => this.validateField.bind(this),
            500
         )
      }
@@ -1063,7 +1317,7 @@
         },
         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\-\+\(\)\.]+$/,
@@ -1191,23 +1445,7 @@
      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)
@@ -1550,9 +1788,8 @@
   }
   showFormStatus(formID, status, message='') {
      // Remove existing status
      let form = this.forms.get(formID);
      if (!form.options.formStatus) {
      if (!form?.options.formStatus) {
         return;
      }
@@ -1562,12 +1799,12 @@
      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...',
@@ -1575,12 +1812,15 @@
         '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'
@@ -1590,12 +1830,27 @@
      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);
@@ -1640,7 +1895,7 @@
         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);
      }
@@ -1810,19 +2065,22 @@
      }
   }
   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) {
@@ -1841,16 +2099,8 @@
      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];
@@ -1864,23 +2114,26 @@
         // 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);
         if (this.isHtmlContent(formatted)) {
            p.innerHTML = formatted;
         } else {
            p.textContent = formatted;
         }
         summary.append(field);
      }
      // Remove template
      resultTemplate.remove();
      wrapper.remove();
      // Insert summary and hide form
      clear = (clear !== 'form') ? form.closest(clear)??form : form;
@@ -1912,8 +2165,8 @@
   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}]`);
      let fieldWrapper = input?.closest('.field');
      // Try to find the input field - check multiple patterns
      if (!input) {
@@ -1973,32 +2226,6 @@
   }
   /**
    * 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) {
@@ -2346,7 +2573,6 @@
      // 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);
      }