Jake Vanderwerf
2026-07-05 fff721dd185f5b97f7ae7a6e64189e55887ff590
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);
         });
      }
@@ -457,9 +482,14 @@
         } else {
            this.notify('form-submit', {
               config: form,
               data: this.changes.get(form.id)?.changes??{},
               data: this.changes.get(form.id)?.changes ?? {},
            });
         }
      }
      if (form.options.endpoint && !form.options.handled) {
         e.preventDefault();
         console.log('Submitting form...', this.changes.get(form.id)?.changes ?? {});
         await this.handleServerSave(form, this.changes.get(form.id)?.changes ?? {});
      }
@@ -496,11 +526,78 @@
         changes.changes[name] = value;
      }
      if (form.tabs) {
         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,89 @@
      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 (form.useQueue && window.jvbQueue) {
         changes.user = window.auth.getUser();
         window.jvbQueue.addToQueue({
            endpoint: form.options.endpoint,
            data: changes,
            headers: form.options.headers??{},
         });
      } else {
         try {
            let response = await window.auth.fetch(jvbSettings.api+form.options.endpoint, {
               body: changes,
               headers: form.options.headers??{},
               method: 'POST',
            });
            if (!response.ok) {
               let errorDetails;
               try {
                  // Attempt to parse the server's error payload as JSON
                  errorDetails = await response.json();
               } catch {
                  // Fallback if the error response isn't JSON (e.g., HTML error page)
                  errorDetails = { message: response.statusText || 'Unknown HTTP error' };
               }
               this.notify('form-error', {
                  config: form,
                  data: errorDetails
               });
               return;
            }
            let result = await response.json();
            this.notify('form-success', {
               config: form,
               result: result
            });
         } catch (error) {
            if (error.status) {
               this.notify('form-error', {
                  config: form,
                  data: error
               });
               // This is an HTTP error thrown manually (e.g., 404, 500)
            } else if (error instanceof SyntaxError) {
               this.notify('form-error', {
                  config: form,
                  data: {
                     status: 'error',
                     success: false,
                     message: 'Something went wrong'
                  }
               });
               // This happens if response.json() fails on a successful 200 OK response
               console.error('Data Error: Server returned invalid JSON format.');
            } else {
               this.notify('form-error', {
                  config: form,
                  data: {
                     status: 'network_error',
                     message: 'Network error: could not connect to the server'
                  }
               });
            }
         }
      }
   }
   /**
    * Register a form for handling
@@ -566,19 +746,25 @@
         autoUpload: false,
         imageMeta: true,
         delay: 1500,
         useQueue: true,
         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;
      if (!Object.hasOwn(form.dataset, 'formId')) {
         form.dataset.formId = window.generateID('form_');
      }
      if (Object.hasOwn(form.dataset, 'action')) {
         options.headers = window.auth.getHeader(form.dataset.action);
      }
      const formId = form.dataset.formId;
      this.addFormListeners(form);
@@ -591,9 +777,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 +807,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);
         }
      });
@@ -710,7 +900,7 @@
               p: 'p',
            },
            setup({ el, refs, manyRefs, data }) {
               const skipFields = ['sendAll', ...data.config.options.ignore??[]];
               const skipFields = ['sendAll', ...data?.config?.options?.ignore??[]];
               for (let [key, value] of Object.entries(data.changes)) {
                  if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
@@ -764,8 +954,6 @@
               }
               refs.result?.remove();
               data.config.element.after(el);
               window.fade(data.config.element, false);
            }
         }
      );
@@ -870,9 +1058,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 +1091,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 +1138,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 +1342,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 +1471,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 +1490,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 +1505,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 +1532,7 @@
         .filter(item =>
            item &&
            item.form === formId &&
            item.section === section.dataset.tab &&
            item.section === currentSection.dataset.tab &&
            !item.element.closest('[hidden]')
         );
@@ -1330,11 +1546,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 +1569,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 +1607,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
    **********************************************************************/
@@ -1414,6 +1664,12 @@
      field.classList.remove('has-success');
      field.classList.add('has-error');
      field.scrollIntoView({
         behavior: "smooth",
         block: "center",
         inline: "nearest"
      });
      field.querySelector(this.inputSelectors)?.focus();
      if (item.ui.message) {
         item.ui.message.hidden = false;
@@ -1720,6 +1976,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);
@@ -2100,6 +2360,9 @@
   window.auth.subscribe(event => {
      if (event === 'auth-loaded') {
         window.jvbForm = new FormController();
         document.querySelectorAll('form[data-auto]').forEach(form => {
            window.jvbForm.registerForm(form, {});
         });
      }
   });
});