Jake Vanderwerf
2026-06-22 715e26a9eb219808d5c899d418e1d596f9318f61
assets/js/concise/FormController.js
@@ -51,7 +51,7 @@
      this.selectors = {
         tabs: {
            nav: 'nav.tabs',
            sections: '.tab.content',        //querySelectorAll
            sections: '.tab-content',        //querySelectorAll
            progress: {
               progress: '.progress',
               fill: '.progress .fill',
@@ -84,6 +84,8 @@
         },
         repeater: {
            repeater: '.repeater',           //querySelectorAll
            row: '.repeater-row',
            handle: '.drag-handle',
            header: '.repeater-row-header',
            remove: '.remove-row',
            add: '.add-repeater-row',
@@ -356,6 +358,28 @@
            case 'dismiss-restore':
               form.ui.status.status.hidden = true;
               break;
            case 'next-step':
               e.preventDefault();
               form.tabs.ui.buttons.forEach((btn, index) => {
                  if (btn.dataset.tab === form.tabs.activeTab) {
                     let next = form.tabs.ui.buttons[index + 1]??false;
                     if (next) {
                        window.jvbTabs.switchTab(next.dataset.tab, window.jvbTabs.getConfig(next));
                     }
                  }
               });
               break;
            case 'prev-step':
               e.preventDefault();
               form.tabs.ui.buttons.forEach((btn, index) => {
                  if (btn.dataset.tab === form.tabs.activeTab) {
                     let prev = form.tabs.ui.buttons[index - 1]??false;
                     if (prev) {
                        window.jvbTabs.switchTab(prev.dataset.tab, window.jvbTabs.getConfig(prev));
                     }
                  }
               });
               break;
         }
      }
   }
@@ -371,11 +395,12 @@
         // Dependencies still need checking
         if (this.dependencies.has(field.dataset.field)) {
            let dependency = this.dependencies.get(field.dataset.field);
            dependency.items.forEach(item => {
            dependency.forEach(item => {
               this.checkFieldDependency(item, field.dataset.field);
            });
         }
         const collectionName = collectionField.dataset.field;
         this.maybeUpdateCollectionDisplay(field);
         window.debouncer.schedule(
            `collection:${collectionName}`,
            () => this.updateCollectionField(collectionField),
@@ -387,7 +412,7 @@
      //Dependencies
      if (this.dependencies.has(field.dataset.field)) {
         let dependency = this.dependencies.get(field.dataset.field);
         dependency.items.forEach(item => {
         dependency.forEach(item => {
            this.checkFieldDependency(item, field.dataset.field);
         });
      }
@@ -460,6 +485,10 @@
               data: this.changes.get(form.id)?.changes??{},
            });
         }
         if (form.options.endpoint && !form.options.handled) {
            console.log('Submitting form...');
            await this.handleServerSave(form, this.collectFormData(form.form));
         }
      }
@@ -496,11 +525,79 @@
         changes.changes[name] = value;
      }
      if (form.tabs) {
         console.log('Updating step progress on change');
         this.updateStepProgress(form);
      }
      this.changes.set(form.id, changes);
      if (form.options.cache) {
         this.scheduleBackup();
      }
      if (form.options.endpoint && !form.options.handled) {
         this.scheduleServerSave(form, changes);
      }
   }
      updateStepProgress(form) {
         console.log('First check in updateStepProgress');
         if (!form.tabs) return;
         let currentStep = this.getCurrentStep(form);
         console.log('Current step: ', currentStep);
         if (!currentStep) return;
         //The progress root will be 1 less than whatever the current step is
         currentStep = currentStep - 1;
         let section = this.getCurrentSection(form);
         console.log('Current section: ', section);
         if (!section) return;
         let total = 0;
         let filled = 0;
         section.childNodes.forEach(f => {
            console.log('Child node: ', f);
            if (f.classList.contains('field')) {
               total++;
               let value = this.getFieldValue(f.querySelector(this.inputSelectors));
               console.log('Value', value);
               if (value.length > 0) {
                  filled++;
               }
            }
         });
         //Shouldn't happen, but simplifies things if it does
         if (total === 0) return;
         let current = currentStep + (filled / total);
         let totalSteps = form.ui.tabs.sections.length;
         window.showProgress(
            form.ui.tabs.progress,
            current,
            totalSteps,
            `Step ${currentStep +1} of ${totalSteps}`
         );
      }
         getCurrentStep(form) {
            let step = null;
            form.tabs.ui.buttons.forEach(btn => {
               if (btn.dataset.tab === form.tabs.activeTab) {
                  step = btn.dataset.step;
               }
            });
            return step;
         }
         getCurrentSection(form) {
            console.log('Getting current section: ', form.tabs.ui.sections);
            let section = null;
            form.tabs.ui.sections.forEach(s => {
               if (s.dataset.tab === form.tabs.activeTab) {
                  section = s;
               }
            });
            return section;
         }
   scheduleBackup() {
      window.debouncer.schedule(
@@ -555,6 +652,37 @@
      this.changes.delete(formId);
   }
   scheduleServerSave(form, changes){
      window.debouncer.schedule(
         `form_${form.id}_server_save`,
         async () => {
            await this.handleServerSave(form, changes);
         },
         1500
      );
   }
   cancelServerSave(formId) {
      window.debouncer.cancel(`form_${formId}_server_save`);
   }
   async handleServerSave(form, changes) {
      this.cancelServerSave(form.id);
      if (window.jvbQueue) {
         changes.user = window.auth.getUser();
         window.jvbQueue.addToQueue({
            endpoint: form.options.endpoint,
            data: changes,
            headers: form.options.headers??{},
         });
      } else {
         await window.auth.fetch(jvbBase.api+form.options.endpoint, {
            body: changes,
            headers: form.options.headers??{},
         });
      }
   }
   /**
    * Register a form for handling
@@ -569,10 +697,12 @@
         endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '',
         showStatus: true,
         showSummary: false,
         handled: Object.hasOwn(form.dataset, 'handled'),
         cache: true,
         ignore: [],
         ... options
      };
      //Bail if form already registered
      if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return;
@@ -591,9 +721,13 @@
         ui: window.uiFromSelectors(this.selectors.forms, form)
      };
      config.ui.fields = {};
      form.querySelectorAll('[data-field]').forEach((field) => {
         config.ui.fields[field.dataset.field] = field;
      });
      this.initializeFields(form, config);
      this.forms.set(formId, config);
      return config;
   }
   clearForm(formId) {
@@ -617,10 +751,10 @@
      }
      // Clean up dependencies for this form
      this.dependencies.forEach((dependency, fieldName) => {
         dependency.items = dependency.items.filter(item => item.form !== formId);
         dependency = dependency.filter(item => item.form !== formId);
         // Remove the dependency entry entirely if no items left
         if (dependency.items.length === 0) {
         if (dependency.length === 0) {
            this.dependencies.delete(fieldName);
         }
      });
@@ -870,9 +1004,7 @@
      }
   }
   checkForRepeaters(form) {
      if (!form.querySelector(this.selectors.repeater.repeater)) return;
      form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
         let config = {
            id: repeater.querySelector('template').className??window.generateID('repeater'),
@@ -905,11 +1037,15 @@
         );
         if (window.Sortable) {
            config.sortable = new Sortable(repeater, {
               handle: this.selectors.repeater.header,
            config.sortable = new Sortable(config.ui.items, {
               animation: 150,
               draggable: this.selectors.repeater.row,
               handle: this.selectors.repeater.handle,
               ghostClass: 'repeater-row-ghost',
               chosenClass: 'repeater-row-chosen',
               dragClass: 'dragging',
               onEnd: () => {
                  this.reindexList(repeater);
                  this.reindexList(config.ui.items);
               }
            });
         }
@@ -948,12 +1084,17 @@
      let form = this.getForm(repeater);
      this.initializeFields(repeater, form);
      this.reindexList(config.ui.items);
      this.a11y.announce('Row added');
   }
   removeRepeaterRow(row) {
      let repeater = row.closest('[data-repeater-id]');
      row.remove();
      this.reindexList(repeater);
      let items = this.repeaters.get(repeater);
      if (items) {
         this.reindexList(items.ui.items);
      }
      this.a11y.announce('Row removed');
   }
   checkForTagLists(form) {
@@ -1147,38 +1288,41 @@
         const requiredValue = field.dataset.dependsValue;
         const operator = field.dataset.dependsOperatior??'==';
         let formData = this.forms.get(form.dataset.formId);
         if (!this.dependencies.has(dependsOn)) {
            let element = document.querySelector(`[field="${dependsOn}"]`);
            if (element) {
               this.dependencies.set(dependsOn, {
                  element: element,
                  items: []
               });
            if (Object.hasOwn(formData.ui.fields, dependsOn)) {
               this.dependencies.set(dependsOn, []);
            }
         }
         let dependency = this.dependencies.get(dependsOn);
         dependency.items.push({
            field: field,
            form: form.dataset.formId,
            requiredValue: requiredValue,
            operator: operator
         });
         this.dependencies.set(dependsOn, dependency);
         this.checkFieldDependency(dependency, dependsOn);
         if (dependency) {
            dependency.push({
               field:   field,
               form: form.dataset.formId,
               requiredValue: requiredValue,
               operator: operator
            });
            this.dependencies.set(dependsOn, dependency);
         }
         this.checkFieldDependency(field, dependsOn);
      });
   }
   checkFieldDependency(dependentField, controlFieldName) {
      const form = this.getForm(dependentField);
      const controlField = this.dependencies.get(controlFieldName);
      if (!controlField) return;
      const controlValue = this.getFieldCheckedValue(controlField.element);
      const controlValue = this.getFieldValue(form.ui.fields[controlFieldName]);
      const shouldShow = this.evaluateCondition(
         controlValue,
         dependentField.requiredValue,
         dependentField.operator
         dependentField.dataset.dependsValue,
         dependentField.dataset.dependsOperatior
      );
      this.toggleFieldVisibility(dependentField.field, shouldShow);
      this.toggleFieldVisibility(dependentField, shouldShow);
   }
   evaluateCondition(value, requiredValue, operator) {
      const fieldStr = String(value || '');
@@ -1273,16 +1417,17 @@
   checkForTabs(form, config) {
      if (window.jvbTabs && form.querySelector('nav.tabs')) {
         config.tabs = window.jvbTabs.registerTab(form, {
            preCheck: (section, tabConfig) => {
               return this.validateStep(section, config);
            preCheck: (activeSection, switchTo, tabConfig) => {
               return this.validateStep(activeSection, switchTo, tabConfig);
            }
         });
         config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form);
         config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections));
         config.ui.tabs.inputs = {};
         config.ui.tabs.sections.forEach(section => {
            config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs));
            config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputSelectors));
         });
         config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
@@ -1291,13 +1436,14 @@
               if (config.ui.tabs.progress) {
                  const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false;
                  if (!section) return;
                  const step = section.dataset.step;
                  const total = config.ui.sections.length;
                  const step = section.dataset.step - 1;
                  const total = config.ui.tabs.sections.length;
                  window.showProgress(
                     config.ui.tabs.progress,
                     step,
                     total
                     total,
                     `Step ${step + 1} of ${total}`
                  );
               }
            }
@@ -1305,8 +1451,24 @@
         this.forms.set(config.id, config);
      }
   }
   validateStep(section, config) {
      const formId = section.closest('[data-form-id]')?.dataset.formId;
   validateStep(currentSection, switchTo, config) {
      let doit = false;
      if (config.ui.sections) {
         config.ui.sections.forEach((section, index) => {
            if (section === currentSection) {
               let prev = index -1;
               if (prev >= 0) {
                  let prevSection = config.ui.sections[prev];
                  if (prevSection && prevSection.dataset.tab === switchTo) {
                     doit = true;
                  }
               }
            }
         });
      }
      if (doit) return true;
      const formId = currentSection.closest('[data-form-id]')?.dataset.formId;
      if (!formId) return true;
      const form = this.forms.get(formId);
@@ -1316,7 +1478,7 @@
         .filter(item =>
            item &&
            item.form === formId &&
            item.section === section.dataset.tab &&
            item.section === currentSection.dataset.tab &&
            !item.element.closest('[hidden]')
         );
@@ -1330,11 +1492,21 @@
    * @param {HTMLElement} container
    */
   reindexList(container) {
      const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
      let fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
      if (!fieldName) {
         let parent = container.closest('[data-field]');
         fieldName = parent.dataset.field || parent.dataset.repeaterId || parent.dataset.tagListId;
      }
      Array.from(container.children).forEach((item, index) => {
         item.dataset.index = `${index}`;
         let rowNum = item.querySelector('.row-number');
         if (rowNum) {
            rowNum.textContent = `#${index + 1}`;
         }
         let rowTitle = item.querySelector('.row-title');
         let rowLabel = rowTitle ? rowTitle.dataset.label : null;
         // Find ALL inputs within this item, not just direct children
         const inputs = item.querySelectorAll('input, select, textarea');
@@ -1343,14 +1515,17 @@
            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();
            const inputField = Object.hasOwn(input.dataset, 'field') ? input.dataset.field : input.name.split(':').pop();
            if (inputField === rowLabel) {
               rowTitle.textContent = window.escapeHtml(input.value);
            }
            // Re-prefix with the new index, passing item as wrapper
            window.prefixInput(
               input,
               `${fieldName}:${index}:`,
               `${fieldName}:${index}:${inputField}`,
               item,
               false,
               true,
               true
            );
         });
@@ -1378,6 +1553,27 @@
      const value = this.getFieldValue(field);
      this.updateItem(field.dataset.field, value, form);
   }
   //TODO: Store these selectors in the repeater object maybe?
   maybeUpdateCollectionDisplay(field) {
      let parent = field.closest('[data-repeater-id],[data-tag-list-id]');
      if (!parent) {
         return;
      }
      if (parent.dataset.fieldType === 'repeater') {
         let row = field.closest('.repeater-row');
         if (!row) return;
         let title = row.querySelector('.row-title');
         if (!title || !title.dataset.label) return;
         console.log(field.dataset.field.split(':').pop());
         console.log(title.dataset.label);
         if (field.dataset.field.split(':').pop() === title.dataset.label) {
            title.textContent = this.getFieldValue(field.querySelector(this.inputSelectors));
         }
      }
   }
   /**********************************************************************
    VALIDATION
    **********************************************************************/
@@ -1720,6 +1916,10 @@
      let value = [];
      Array.from(items.children).forEach(row => {
         let rowData = {};
         row.querySelectorAll('input[type="hidden"]').forEach(hidden => {
            rowData[hidden.name] = hidden.value;
         });
         row.querySelectorAll('[data-field]').forEach(field => {
            if (!ignore.includes(field.dataset.field)) {
               const input = this.getFieldInput(field);