Jake Vanderwerf
2026-02-04 2127b1bdd73ecd2423e443992da4b442f5a3c1a3
assets/js/concise/FormController.js
@@ -25,6 +25,7 @@
   }
   init() {
      this.templates = window.jvbTemplates;
      this.defineSummaryTemplate();
      this.initElements();
      this.initListeners();
      this.initStore();
@@ -72,7 +73,7 @@
         },
         tagList: {
            tagList: '.field.tag-list',         //querySelectorAll
            input: '.tag-input-row',
            input: '.row',
            add: '.add-tag',
            remove: '.remove-tag',
            label: '.tag-label',
@@ -137,7 +138,7 @@
      this.store = store.forms;
      this.store.subscribe((event, data)=> {
         if (event === 'data-loaded') {
         if (event === 'data-ready') {
            let stored = this.store.getFiltered();
            let pending = stored.filter(form=> form.src ===  window.location.pathname);
@@ -146,7 +147,7 @@
            }
         } else if (event === 'operation-status' && data.status === 'completed') {
            if (data.config) {
               this.store.remove(data.config.id);
               this.store.delete(data.config.id);
            }
         }
      });
@@ -164,15 +165,16 @@
         notification.className = 'pendingChanges';
         notification.innerHTML = `
         <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
        <button class="restore" data-form-id="${formId}">Restore</button>
        <button class="discard" data-form-id="${formId}">Discard</button>`;
        <button class="restore" type="button" data-form-id="${formId}">Restore</button>
        <button class="discard" type="button" data-form-id="${formId}">Discard</button>`;
         element.insertBefore(notification, form.ui.status.status);
         notification.querySelector('.restore').addEventListener('click', async () => {
            this.isRestoring = true;
            new this.populate(element, changes);
            let theChanges = {['fields']: changes};
            this.populate.populate(element, theChanges);
            this.a11y.announce('Previous changes restored');
            this.isRestoring = false;
@@ -180,8 +182,8 @@
         });
         notification.querySelector('.discard').addEventListener('click', async () => {
            await this.store.remove(formId);
            this.a11y.announce('Previous changes discared');
            await this.store.delete(formId);
            this.a11y.announce('Previous changes discarded');
            notification.remove();
         });
@@ -240,14 +242,26 @@
   }
   performValidation(input) {
      const field = input.closest('.field');
      const value = this.getFieldValue(input);
      const value = this.getFieldCheckedValue(input);
      if (!value && !input.required) {
         return { isValid: true, message: '' };
      }
      if (input.required && !value) {
         return { isValid: false, message: 'This field is required' };
      if (input.required) {
         if (input.type === 'checkbox') {
            if (!input.checked) {
               return { isValid: false, message: 'This field is required' };
            }
         } else if (input.type === 'radio') {
            const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`);
            const anyChecked = Array.from(radioGroup).some(r => r.checked);
            if (!anyChecked) {
               return { isValid: false, message: 'Please select an option' };
            }
         } else if (!value) {
            return { isValid: false, message: 'This field is required' };
         }
      }
      if(input.checkValidity && !input.checkValidity()){
@@ -264,11 +278,11 @@
      if (Object.hasOwn(field.dataset, 'validate') || input.type) {
         const validator = this.validators[field.dataset.validate||input.type];
         if (validator.pattern && !validator.pattern.test(value)) {
         if (validator && validator.pattern && !validator.pattern.test(value)) {
            return {isValid: false, message: validator.message};
         }
         if (validator.test) {
         if (validator && validator.test) {
            const result = validator.test(value, field);
            if (result !== true) {
               return {isValid: false, message: result};
@@ -311,6 +325,7 @@
      if (e.target.closest('[data-ignore]') || this.isRestoring) return;
      let field = this.getField(e.target);
      //Dependencies
      if (this.dependencies.has(field.dataset.field)) {
         let dependency = this.dependencies.get(field.dataset.field);
@@ -319,6 +334,11 @@
         });
      }
      if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
         this.updateCollectionField(field);
         return;
      }
      let form = this.getForm(e.target);
      this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
   }
@@ -338,15 +358,21 @@
   handleInput(e){
      let form = this.getForm(e.target);
      if (!form || !form.options.cache) return;
      if (!form) return;
      let field = this.getField(e.target);
      if (!field) return;
      this.showFormStatus(form, 'pending');
      const input = e.target;  // Capture reference
      const fieldName = field.dataset.field;
      // Show pending status regardless of cache
      this.showFormStatus(form.id, 'pending');
      // Debounce validation
      window.debouncer.schedule(
         `form:${form.id}:validate:${field.dataset.field}`,
         () => this.validateField.bind(this),
         `form:${form.id}:validate:${fieldName}`,
         () => this.validateField(input),
         500
      );
   }
@@ -357,9 +383,12 @@
      if (this.subscribers.size > 0) {
         e.preventDefault();
         const storedData = await this.store.get(form.id);
         if (form.options.cache) {
            this.cancelBackup();
            await this.backup();
            const storedData = await this.store.get(form.id);
            this.notify('form-submit', {
               config: form,
               data: storedData.changes
@@ -375,10 +404,7 @@
      if (form.options.showSummary) {
         const storedData = await this.store.get(form.id);
         this.showSummary(form.id, {
            config: form,
            data: storedData?.changes || {}
         });
         this.showSummary({config: form, changes: storedData?.changes});
      }
   }
@@ -405,21 +431,50 @@
      }
   }
   scheduleBackup()  {
   scheduleBackup() {
      window.debouncer.schedule(
         `form_changes`,
         async () => {
            if (this.changes.size > 0) {
               await this.store.saveMany(this.changes);
               for(let formId of this.changes.keys()) {
                  this.showFormStatus(formId, 'autosaved');
               }
               this.changes.clear();
               await this.backup();
            }
         },
         2000
      );
   }
   cancelBackup() {
      window.debouncer.cancel('form_changes');
   }
   async backup() {
      // Merge with existing stored data
      const toSave = new Map();
      for (let [formId, newData] of this.changes.entries()) {
         const stored = await this.store.get(formId);
         if (stored) {
            // Merge changes
            toSave.set(formId, {
               ...stored,
               ...newData,
               changes: {
                  ...stored.changes,
                  ...newData.changes
               },
               timestamp: Date.now()
            });
         } else {
            toSave.set(formId, newData);
         }
      }
      await this.store.saveMany(toSave);
      for (let formId of this.changes.keys()) {
         this.showFormStatus(formId, 'autosaved');
      }
      this.changes.clear();
   }
   saveCache(formId) {
      if (!this.changes.has(formId)) return;
@@ -451,20 +506,18 @@
         id: formId,
         status: '',
         options: {
            autoUpload: false,
            autoUpload: options.autoUpload??false,
            imageMeta: options.imageMeta??true,
            delay: options.delay??1500,
            endpoint: options.save??form.dataset.save??'',
            formStatus: options.showStatus??true,
            showSummary: false,
            showStatus: options.showStatus??true,
            showSummary: options.showSummary??false,
            cache: options.cache??true,
            ignore: options.ignore??[]
         },
         ui: window.uiFromSelectors(this.selectors.forms, form)
      };
      if (config.showSummary && !this.summaryTemplate) {
         this.defineSummaryTemplate();
      }
      this.initializeFields(form, config);
      this.forms.set(formId, config);
@@ -558,8 +611,11 @@
                        this.removeQuantityListeners(item.element);
                        break;
                  }
                  if (check.has(item.id)) {
                     check.delete(item.id);
                  }
               });
               check.delete(item.id);
            }
         }
@@ -581,12 +637,12 @@
                  p: 'p',
               },
               setup({ el, refs, manyRefs, data }) {
                  const skipFields = ['sendAll', ...form.ignore];
                  const skipFields = ['sendAll', ...data.config.options.ignore??[]];
                  for (let [key, value] of Object.entries(data.changes)) {
                     if (skipFields.includes(key) || this.isEmptyValue(value)) continue;
                     if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
                     let input = Array.from(this.inputs.values())
                     let input = Array.from(form.inputs.values())
                        .find(temp => temp.field?.dataset.field === key);
                     if (!input) continue;
@@ -594,15 +650,21 @@
                     let title = entry.querySelector('h3');
                     let p = entry.querySelector('p');
                     title.textContent = input.label.textContent;
                     // Get field label - prioritize legend for fieldsets, then label
                     const legend = input.field?.querySelector('legend');
                     title.textContent = legend
                        ? legend.textContent.replace('*', '').trim()
                        : input.ui.label?.textContent.replace('*', '').trim();
                     if (typeof value === 'string') {
                        p.textContent = value;
                     } else if (Array.isArray(value)) {
                        //Repeater or Tag Item
                     } else if (typeof value === 'object') {
                        //Location item
                        p.textContent = `${value.address}`;
                     const formattedValue = form.formatValueForSummary(value, input);
                     if (formattedValue instanceof HTMLElement) {
                        // If it's an HTML element (repeater, tag-list, etc.), replace <p>
                        p.replaceWith(formattedValue);
                     } else {
                        // If it's a string, set text content
                        p.textContent = formattedValue;
                     }
                     el.append(entry);
@@ -612,6 +674,7 @@
                     uploads.forEach(upload => {
                        let label = upload.querySelector('h2')?.textContent??'Upload:';
                        let imgs = upload.querySelectorAll('.item-grid.preview img');
                        let field = refs.result.cloneNode(true);
                        if (imgs) {
                           let entry = refs.result.cloneNode(true);
                           let title = field.querySelector('h3');
@@ -634,6 +697,8 @@
            }
         );
      }
      initializeFields(container, config = null) {
         const fieldHandlers = {
            '[data-editor]': () => this.checkForQuill(container,config),
@@ -642,7 +707,7 @@
            '.field.tag-list': () => this.checkForTagLists(container),
            '[data-depends-on]': () => this.checkForConditionalFields(container),
            '[data-limit]': () => this.checkForCharacterLimits(container),
            '[data-uploader]': () => this.checkForImageUploads(container, config),
            '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
            'nav.tabs': () => this.checkForTabs(container, config),
            '[data-type="selector"]': () => this.checkForSelectors(container)
         };
@@ -731,6 +796,7 @@
               }
            }
         checkForRepeaters(form) {
            if (!form.querySelector(this.selectors.repeater.repeater)) return;
            form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
@@ -743,7 +809,7 @@
                  sortable: false,
               };
               if (!config.ui.addButton) return;
               if (!config.ui.add) return;
               let template = repeater.querySelector('template');
               this.templates.define(
@@ -755,8 +821,10 @@
                     setup({el, refs, manyRefs, data}) {
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
                           window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
                        });
                     }
                  },
@@ -785,13 +853,18 @@
            }
            handleRepeaterClick(e) {
               if (e.target.matches(this.selectors.repeater.add)) {
                  console.log('Add Repeater Row');
                  this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
               } else if (e.target.matches(this.selectors.repeater.remove)) {
                  this.removeRepeaterRow(e.target);
                  console.log('Remove Repeater Row');
                  this.removeRepeaterRow(e.target.closest('[data-index]'));
               }
            }
            addRepeaterRow(repeater) {
               repeater.append(this.templates.create(repeater.dataset.repeaterId));
               let data = {};
               data.repeater = repeater;
               repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
               this.initializeFields(repeater, this.getField(repeater).config??{});
               this.a11y.announce('Row added');
            }
            removeRepeaterRow(row) {
@@ -827,7 +900,8 @@
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
                           let wrapper = window.closest('.tag-item');
                           window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
                        });
                        if (refs.label) {
@@ -921,6 +995,8 @@
                  config.ui.items.append(newItem);
                  config.ui.inputs[0]?.focus();
                  this.updateCollectionField(tagList);
                  this.a11y.announce('Item added');
               }
               removeTagListItem(tag) {
@@ -979,7 +1055,7 @@
               const controlField = this.dependencies.get(controlFieldName);
               if (!controlField) return;
               const controlValue = this.getFieldValue(controlField.element);
               const controlValue = this.getFieldCheckedValue(controlField.element);
               const shouldShow = this.evaluateCondition(
                  controlValue,
                  dependentField.requiredValue,
@@ -1061,7 +1137,7 @@
               }
            }
         checkForImageUploads(form, config) {
            window.jvbUploads.scanFields(form, config.autoUpload);
            window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
         }
         checkForTabs(form, config) {
@@ -1123,19 +1199,51 @@
    * @param {HTMLElement} container
    */
   reindexList(container) {
      const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
      Array.from(container.children).forEach((item, index) => {
         item.dataset.index = `${index}`;
         Array.from(item.children).forEach(child => {
            if (child.type === 'hidden') {
               window.prefixInput(
                  child,
                  `${container.dataset.field}:${index}:${child.dataset.field}`
               );
            }
         // Find ALL inputs within this item, not just direct children
         const inputs = item.querySelectorAll('input, select, textarea');
         inputs.forEach(input => {
            // Skip inputs that shouldn't be re-indexed (like file inputs)
            if (input.type === 'file') return;
            // Get the field name from the input's data-field or name
            const inputField = input.dataset.field || input.name.split(':').pop();
            // Re-prefix with the new index, passing item as wrapper
            window.prefixInput(
               input,
               `${fieldName}:${index}:`,
               item  // Pass the item as wrapper for label lookup
            );
         });
      });
      //schedule save
      this.updateCollectionField(container);
   }
   /**
    * Update the entire repeater/tagList field data
    * Call this whenever rows are added, removed, or reordered
    */
   updateCollectionField(element) {
      const field = element.closest('[data-field]');
      if (!field) return;
      const fieldType = field.dataset.fieldType;
      if (!['repeater', 'tag-list'].includes(fieldType)) return;
      const form = this.getForm(element);
      if (!form) return;
      // Get all current data for the collection
      const value = this.getFieldValue(field.querySelector('input, select, textarea'));
      this.updateItem(field.dataset.field, value, form);
   }
   /**********************************************************************
    VALIDATION
@@ -1269,11 +1377,6 @@
      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) {
@@ -1348,6 +1451,8 @@
      if (!form || !form.options.showStatus || !form.ui?.status?.status) return;
      if (form.status === status) return;
      form.status = status;
      form.ui.status.status.hidden = false;
      form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status));
@@ -1386,7 +1491,9 @@
    SUMMARY
   **********************************************************************/
   showSummary(data) {
      this.templates.create('formSummary', data);
      let summary = this.templates.create('formSummary', data);
      data.config.element.after(summary);
      window.fade(data.config.element, false);
   }
   /**********************************************************************
    UTILITY
@@ -1433,11 +1540,53 @@
         case 'true-false':
            return element.value === '1'||element.value === 'on'||element.value ==='true';
         case 'checkbox':
            // Handle multi-checkbox (name ends with [])
            if (element.name.endsWith('[]')) {
               return this.getCheckboxGroupValue(element, conf);
            }
            return element.checked ? element.value : '';
         default:
            return element.value;
      }
   }
   /**
    * Get all checked values for a checkbox group
    */
   getCheckboxGroupValue(element, conf) {
      if (!conf.checkboxGroup) {
         conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`);
         this.saveItem(conf);
      }
      return Array.from(conf.checkboxGroup)
         .filter(cb => cb.checked)
         .map(cb => cb.value);
   }
   /**
    * Get the actual user-facing value (for validation and submission)
    */
   getFieldCheckedValue(element) {
      // Handle checkboxes and radios based on checked state
      if (element.type === 'checkbox') {
         const type = this.getFieldType(element);
         if (type === 'true-false') {
            return element.checked;
         }
         return element.checked ? element.value : '';
      }
      if (element.type === 'radio') {
         const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`);
         const checked = Array.from(radioGroup).find(r => r.checked);
         return checked ? checked.value : '';
      }
      // For everything else, use existing logic
      return this.getFieldValue(element);
   }
   isEmptyValue(value) {
      if (value === null || value === undefined || value === '') return true;
      if (Array.isArray(value) && value.length === 0) return true;
@@ -1493,6 +1642,235 @@
         return conf.value.value;
      }
   /**
    * Format field value for display in summary
    * @param {*} value - The field value
    * @param {Object} input - The input config
    * @returns {HTMLElement|string} - Formatted display element or string
    */
   formatValueForSummary(value, input) {
      const fieldType = this.getFieldType(input.element);
      // Handle empty values
      if (this.isEmptyValue(value)) {
         return '';
      }
      // Handle different field types
      switch (fieldType) {
         case 'repeater':
            return this.formatRepeaterForSummary(value, input);
         case 'tag-list':
            return this.formatTagListForSummary(value, input);
         case 'location':
            return this.formatLocationForSummary(value);
         case 'true-false':
            return value ? 'Yes' : 'No';
         case 'checkbox':
            // Handle multi-checkbox arrays
            if (Array.isArray(value)) {
               return this.formatCheckboxGroupForSummary(value, input);
            }
            // Single checkbox - get display label
            return this.getDisplayLabel(input, value);
         case 'selector':
         case 'upload':
            // These might need special handling depending on your needs
            return this.formatHiddenFieldForSummary(value, input, fieldType);
         default:
            // For radio/checkbox, get the display label
            if (typeof value === 'string') {
               return this.getDisplayLabel(input, value);
            }
            // For textarea or any multi-line text, convert line breaks
            if (typeof value === 'string' && value.includes('\n')) {
               return this.convertLineBreaks(value);
            }
            return value;
      }
   }
   /**
    * Format checkbox group values with labels
    */
   formatCheckboxGroupForSummary(values, input) {
      const labels = values.map(value => this.getDisplayLabel(input, value));
      return labels.join(', ');
   }
   /**
    * Convert \n line breaks to HTML
    */
   convertLineBreaks(text) {
      const container = document.createElement('span');
      container.innerHTML = text.split('\n').join('<br>');
      return container;
   }
   /**
    * Format repeater data as a list
    */
   formatRepeaterForSummary(rows, input) {
      const container = document.createElement('div');
      container.className = 'summary-repeater';
      rows.forEach((row, index) => {
         const rowDiv = document.createElement('div');
         rowDiv.className = 'summary-repeater-row';
         const rowTitle = document.createElement('strong');
         rowTitle.textContent = `Entry ${index + 1}:`;
         rowDiv.appendChild(rowTitle);
         const fieldsList = document.createElement('ul');
         fieldsList.className = 'summary-repeater-fields';
         for (const [fieldName, fieldValue] of Object.entries(row)) {
            if (this.isEmptyValue(fieldValue)) continue;
            const li = document.createElement('li');
            // Try to find the label for this subfield
            const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`);
            const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName;
            li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`;
            fieldsList.appendChild(li);
         }
         rowDiv.appendChild(fieldsList);
         container.appendChild(rowDiv);
      });
      return container;
   }
   /**
    * Format tag-list data
    */
   formatTagListForSummary(tags, input) {
      const container = document.createElement('div');
      container.className = 'summary-taglist';
      const tagsList = document.createElement('ul');
      tagsList.className = 'summary-tags';
      tags.forEach(tag => {
         const li = document.createElement('li');
         li.className = 'summary-tag';
         // Get the primary display value (first non-empty field)
         const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || '';
         // If there are multiple fields, show them all
         const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v));
         if (fields.length > 1) {
            li.textContent = fields.map(([k, v]) => v).join(', ');
         } else {
            li.textContent = displayValue;
         }
         tagsList.appendChild(li);
      });
      container.appendChild(tagsList);
      return container;
   }
   /**
    * Format location data
    */
   formatLocationForSummary(location) {
      const parts = [];
      if (location.street) parts.push(location.street);
      if (location.city) parts.push(location.city);
      if (location.province) parts.push(location.province);
      if (location.postal_code) parts.push(location.postal_code);
      if (location.country) parts.push(location.country);
      return parts.length > 0 ? parts.join(', ') : location.address || '';
   }
   /**
    * Format hidden field types (upload, selector)
    */
   formatHiddenFieldForSummary(value, input, fieldType) {
      if (fieldType === 'upload') {
         // Get upload preview images if available
         const uploadField = input.field?.querySelector('[data-upload-field]');
         if (uploadField) {
            const previews = uploadField.querySelectorAll('.item-grid.preview img');
            if (previews.length > 0) {
               const container = document.createElement('div');
               container.className = 'summary-uploads';
               previews.forEach(img => {
                  const clone = img.cloneNode(true);
                  clone.style.maxWidth = '100px';
                  clone.style.maxHeight = '100px';
                  container.appendChild(clone);
               });
               return container;
            }
         }
         return `${value.split(',').length} file(s) uploaded`;
      }
      if (fieldType === 'selector') {
         // Could enhance this to show selected item names if available
         return value;
      }
      return value;
   }
   /**
    * Get the display label for an input value (especially for radio/checkbox)
    * @param {Object} input - The input config from this.inputs
    * @param {*} value - The field value
    * @returns {string} - The display label or original value
    */
   getDisplayLabel(input, value) {
      if (!input.element) return value;
      const inputType = input.element.type;
      // Handle radio buttons
      if (inputType === 'radio') {
         const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`);
         const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value);
         if (selectedRadio) {
            const label = selectedRadio.closest('label') ||
               input.field.querySelector(`label[for="${selectedRadio.id}"]`);
            if (label) {
               return label.textContent.replace('*', '').trim();
            }
         }
      }
      // Handle checkboxes (including groups)
      if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') {
         // Find checkbox with this value in the field
         const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`);
         if (checkbox) {
            const label = checkbox.closest('label') ||
               input.field.querySelector(`label[for="${checkbox.id}"]`);
            if (label) {
               // Get just the span content to avoid getting nested elements
               const span = label.querySelector('span');
               return span ? span.textContent.trim() : label.textContent.replace('*', '').trim();
            }
         }
      }
      return value;
   }
   getItem(element, formId = null) {
      const hasID = Object.hasOwn(element.dataset, 'ref');
      let id = (hasID) ? element.dataset.ref : window.generateID('input');