Jake Vanderwerf
2025-11-25 2a2303d1dccc120dd7aa5f6b6ade0f89e0064850
assets/js/concise/FormController.js
@@ -3,19 +3,27 @@
 * Works with DataStore for CRUD operations and standalone for front-end forms
 */
class FormController {
   constructor() {
      this.store = new window.jvbStore({
         name:'forms',
         storeName: 'forms',
         keyPath: 'formId',
         indexes: [
            { name: 'status', keyPath: 'status' },
            { name: 'operationId', keyPath: 'operationId' },
            { name: 'timestamp', keyPath: 'timestamp' },
            { name: 'formType', keyPath: 'type' }
         ],
         TTL: 604800000, //7 days
      });
   constructor(config = {}) {
      this.config = {
         collectFormData: false,
         ... config
      }
      const store = window.jvbStore.register(
         'forms',
         {
            storeName: 'forms',
            keyPath: 'formId',
            indexes: [
               { name: 'status', keyPath: 'status' },
               { name: 'operationId', keyPath: 'operationId' },
               { name: 'timestamp', keyPath: 'timestamp' },
               { name: 'formType', keyPath: 'type' }
            ],
            TTL: 7 * 24 * 60 * 1000, //7 days
            validateData: true,
            delayFetch: true
         });
      this.store = store.forms;
      this.debouncer = window.debouncer;
@@ -49,6 +57,10 @@
         remove: 800,
         reorder: 1000
      };
      this.isTimeline = false;
      if (window.crudManager && window.crudManager.isTimeline) {
         this.isTimeline = true;
      }
      // Bind handlers
      this.clickHandler = this.handleClick.bind(this);
@@ -57,18 +69,49 @@
      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);
      this.processGroupField = this.processGroupField.bind(this);
      this.processLocationField = this.processLocationField.bind(this);
      this.processRegularField = this.processRegularField.bind(this);
      this.init();
   }
   async init() {
      // Check for pending operations on page load
      await this.checkPendingOperations();
      this.store.subscribe(this.handleStoreEvent.bind(this));
      // Set up global form handlers for standalone forms
      this.initListeners();
      if (window.jvbQueue) {
         window.jvbQueue.subscribe((event, data) => {
            if (event === 'operation-completed' && data.type === 'form') {
               this.handleOperationComplete(data);
            }
         });
      }
   }
   /**
    * Handle operation completion - clear related form cache
    */
   async handleOperationComplete(operation) {
      // Clear the form data from store
      if (operation.formId) {
         try {
            await this.store.delete(operation.formId);
         } catch (error) {
            console.warn('Failed to clear form cache:', error);
         }
      }
      // Clear any related form state
      const form = this.forms.get(operation.formId);
      if (form) {
         form.isDirty = false;
         form.lastSaved = Date.now();
         form.data = {};
      }
   }
   handleStoreEvent(event, data) {
@@ -84,16 +127,21 @@
      }
   }
   async checkPendingForms() {
      let items = await this.store.query('status', 'draft');
      items.forEach(item => {
         let form = this.forms.get(item.formId);
         if (form && form.element) {
            form.element.querySelector('.restore-form').hidden = false;
   checkPendingForms() {
      // No async needed - data is already loaded in memory
      const allForms = this.store.getAll();
      const pendingForms = allForms.filter(form => form.status === 'draft');
      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);
         }
      });
   }
   /**
    * Check for pending operations from previous session
@@ -113,28 +161,31 @@
   /**
    * Show notification for pending changes
    */
   showPendingNotification(pendingData) {
      const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
   /**
    * Show notification for pending changes
    */
   showPendingNotification(formId, formData) {
      const formElement = document.querySelector(`[data-form-id="${formId}"]`);
      if (!formElement) return;
      const notification = document.createElement('div');
      notification.className = 'pending-changes-notification';
      notification.innerHTML = `
         <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
         <button class="restore-changes" data-form-id="${pendingData.formId}">Restore</button>
         <button class="discard-changes" data-form-id="${pendingData.formId}">Discard</button>
      `;
        <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
        <button class="restore-changes" data-form-id="${formId}">Restore</button>
        <button class="discard-changes" data-form-id="${formId}">Discard</button>
    `;
      formElement.insertBefore(notification, formElement.firstChild);
      // Add handlers
      notification.querySelector('.restore-changes').addEventListener('click', () => {
         this.restorePendingForm(pendingData);
      notification.querySelector('.restore-changes').addEventListener('click', async () => {
         await this.restorePendingForm(formId, formData);
         notification.remove();
      });
      notification.querySelector('.discard-changes').addEventListener('click', () => {
         this.discardPendingForm(pendingData.formId);
      notification.querySelector('.discard-changes').addEventListener('click', async () => {
         await this.discardPendingForm(formId);
         notification.remove();
      });
   }
@@ -142,16 +193,20 @@
   /**
    * Restore pending form data
    */
   restorePendingForm(pendingData) {
      const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
   async restorePendingForm(formId, formData) {
      const form = document.querySelector(`[data-form-id="${formId}"]`);
      if (!form) return;
      // Populate form with cached data
      new this.populateForm(form, pendingData.formData);
      new this.populateForm(form, formData);
      // Mark as restored
      pendingData.status = 'restored';
      this.pendingForms.set(pendingData.formId, pendingData);
      // Update status in store (mark as restored, not draft)
      await this.store.save({
         formId: formId,
         data: formData,
         status: 'restored',
         timestamp: Date.now()
      });
      if (window.jvbA11y) {
         window.jvbA11y.announce('Previous changes restored');
@@ -162,10 +217,14 @@
    * Discard pending form data
    */
   async discardPendingForm(formId) {
      this.store.delete(formId);
      try {
         await this.store.delete(formId);
      if (window.jvbA11y) {
         window.jvbA11y.announce('Previous changes discarded');
         if (window.jvbA11y) {
            window.jvbA11y.announce('Previous changes discarded');
         }
      } catch (error) {
         console.error('Failed to discard pending form:', error);
      }
   }
@@ -188,6 +247,7 @@
    * Register a standalone form (for front-end forms)
    */
   registerForm(formElement, options = {}) {
      if (!formElement) return;
      const formId = formElement.dataset.formId || `form_${Date.now()}`;
      formElement.dataset.formId = formId;
@@ -196,16 +256,17 @@
      const formConfig = {
         element: formElement,
         id: formId,
         status: '',
         options: {
            autoSave: true,
            autosave: 'autosave' in formElement.dataset,
            saveDelay: this.autoSaveDefaults.delay,
            endpoint: formElement.dataset.save,
            endpoint: formElement.dataset.save??'',
            formStatus: true,
            cache: true,
            ...options
         },
         dependencies: new Map(),
         data: this.collectFormData(formElement),
         isDirty: false
         data: this.collectFormData(formElement, true),
      };
      // Initialize special fields
@@ -255,7 +316,7 @@
      // Scan for existing selector fields
      if (window.jvbSelector) {
         window.jvbSelector.scanExistingFields();
         window.jvbSelector.scanExistingFields(form);
      }
   }
@@ -444,8 +505,7 @@
      container.appendChild(row);
      // Schedule save if auto-save enabled
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'add',
@@ -472,7 +532,7 @@
      this.updateRepeaterOrder(repeater, formConfig);
      // Schedule save
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'remove',
@@ -515,7 +575,7 @@
      });
      // Schedule save
      if (formConfig && formConfig.options.autoSave) {
      if (formConfig) {
         this.scheduleSave(formConfig, {
            type: 'repeater',
            action: 'reorder',
@@ -650,23 +710,171 @@
   /* ========== Event Handlers ========== */
   handleSubmit(event) {
      //TODO: submit data, if successful, delete from store
      if (this.subscribers.size > 0 ){
         const form = event.target;
         if (!form.dataset.formId) return;
   async handleSubmit(event) {
      const form = event.target;
      if (!form.dataset.formId) return;
      const formConfig = this.forms.get(form.dataset.formId);
      // Handle subscriber-based forms
      if (this.subscribers.size > 0) {
         event.preventDefault();
         const formConfig = this.forms.get(form.dataset.formId);
         if (!formConfig) return;
         const formData = this.collectFormData(form);
         // Notify subscribers (they'll handle actual submission)
         this.notify('form-submit', {
            formId: formConfig.id,
            data: formData,
            formId: form.dataset.formId,
            fullData: formData,
            config: formConfig
         });
         // Don't delete yet - wait for success/error from subscriber
         return;
      }
      // For forms that submit normally (not prevented)
      // We can clean up the cache on successful submission
      // This would typically be called from handleFormSuccess
   }
   handleFormSuccess(form, data) {
      // Clear previous errors
      form.querySelectorAll('.error-message').forEach(el => el.remove());
      form.querySelectorAll('.field-error').forEach(el =>
         el.classList.remove('field-error')
      );
      // Add success class to form
      form.classList.add('form-success');
      // Show success message if provided
      if (data.message) {
         const success = document.createElement('div');
         success.className = 'form-success-message success-message';
         success.textContent = data.message;
         form.insertBefore(success, form.firstChild);
         const icon = window.getIcon?.('check-circle');
         if (icon) {
            icon.classList.add('success-icon');
            success.prepend(icon);
         }
      }
      // If there's a title/description (for registration success)
      if (data.title || data.description) {
         const successBox = document.createElement('div');
         successBox.className = 'success-box';
         if (data.title) {
            const title = document.createElement('h3');
            title.textContent = data.title;
            successBox.appendChild(title);
         }
         if (data.description) {
            const descriptions = Array.isArray(data.description)
               ? data.description
               : [data.description];
            descriptions.forEach(desc => {
               const p = document.createElement('p');
               p.textContent = desc;
               successBox.appendChild(p);
            });
         }
         form.insertBefore(successBox, form.firstChild);
      }
      // âœ… 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);
         });
         // Clear form config dirty state
         const formConfig = this.forms.get(form.dataset.formId);
         if (formConfig) {
            formConfig.isDirty = false;
            formConfig.lastSaved = Date.now();
            formConfig.data = {}; // Clear cached data
         }
      }
      // Announce success for accessibility
      if (window.jvbA11y) {
         window.jvbA11y.announce(data.message || 'Form submitted successfully');
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-success', {
         detail: data
      }));
   }
   handleFormError(form, data) {
      // Clear all previous errors
      form.querySelectorAll('.error-message').forEach(el => el.remove());
      form.querySelectorAll('.field-error, .has-error').forEach(el => {
         el.classList.remove('field-error', 'has-error');
      });
      // Clear validation states using existing method
      form.querySelectorAll('.field').forEach(fieldWrapper => {
         this.clearValidation(fieldWrapper);
      });
      // Handle field-specific errors
      if (data.field) {
         const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
         if (fieldWrapper) {
            // Use existing showError method for consistency
            this.showError(fieldWrapper, data.message);
            // Mark as touched so validation persists
            this.touchedFields.add(data.field);
            // Scroll to error
            fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
            // Focus the input for better UX
            const input = fieldWrapper.querySelector('input, textarea, select');
            if (input) {
               input.focus();
            }
         }
      } else {
         // General form error (not field-specific)
         const error = document.createElement('div');
         error.className = 'form-error error-message';
         error.textContent = data.message;
         // Add icon for consistency
         const icon = window.getIcon?.('close-circle');
         if (icon) {
            icon.classList.add('error-icon');
            error.prepend(icon);
         }
         form.insertBefore(error, form.firstChild);
         // Scroll to top to show the error
         form.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }
      // Announce error for accessibility
      if (window.jvbA11y) {
         const announcement = data.field
            ? `Error in ${data.field}: ${data.message}`
            : `Form error: ${data.message}`;
         window.jvbA11y.announce(announcement);
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-error', {
         detail: data
      }));
   }
   handleClick(e) {
@@ -746,15 +954,16 @@
   }
   handleChange(event) {
      if (this.subscribers.size > 0) {
         const target = event.target;
         const form = target.form || target.closest('form');
      if (event.target.closest('[data-ignore]')) {
         return;
      }
      const target = event.target;
      const form = target.form || target.closest('form');if (!form) return;
         if (!form) return;
      const formConfig = this.forms?.get(form.dataset.formId);
      if (!formConfig) return;
         const formConfig = this.forms?.get(form.dataset.formId);
         if (!formConfig) return;
      if (formConfig.options.autosave || this.subscribers.size > 0) {
         // Check conditional fields
         const dependencies = formConfig.dependencies.get(target.name);
         if (dependencies) {
@@ -764,10 +973,8 @@
         }
         // Schedule auto-save if enabled
         if (formConfig.options.autoSave && !form.dataset.noautosave) {
            const delay = this.getDelayForField(target);
            this.scheduleSave(formConfig, delay);
         }
         const delay = this.getDelayForField(target);
         this.scheduleSave(formConfig, delay);
      }
   }
@@ -780,6 +987,9 @@
   }
   handleBlur(e) {
      if (e.target.closest('[data-ignore]')) {
         return;
      }
      const target = e.target;
      const form = target.form || target.closest('form');
@@ -801,7 +1011,7 @@
            this.validateField(input, fieldWrapper);
         }
         const formConfig = this.forms?.get(form.dataset.formId);
         if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
         if (formConfig) {
            // Shorter delay on blur
            this.scheduleSave(formConfig, {
               type: 'blur',
@@ -813,6 +1023,9 @@
   }
   handleInput(e) {
      if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
         return;
      }
      const input = e.target.closest('input, textarea, select');
      if (!input) return;
@@ -974,6 +1187,7 @@
      // All validations passed
      this.showSuccess(fieldWrapper);
      this.notify('field-validated', input);
      return true;
   }
@@ -998,7 +1212,7 @@
   /**
    * Show success state (green checkmark)
    */
   showSuccess(fieldWrapper) {
   showSuccess(fieldWrapper, textMessage = '') {
      if (!fieldWrapper) return;
      // Find validation elements (they might be in field-input-wrapper or field-content)
@@ -1024,8 +1238,13 @@
      // Hide error message
      if (message) {
         message.hidden = true;
         message.textContent = '';
         if (textMessage === '') {
            message.hidden = true;
            message.textContent = '';
         } else {
            message.hidden = false;
            message.textContent = textMessage;
         }
      }
   }
@@ -1244,6 +1463,9 @@
      return this.autoSaveDefaults.delay;
   }
   scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
      if (!formConfig.options.autosave) {
         return;
      }
      document.addEventListener('input', this.saveCheck, {passive: true});
      const saveKey = `autosave_${formConfig.id}`;
@@ -1268,13 +1490,21 @@
      const formData = this.collectFormData(formConfig.element);
      this.showFormStatus(formConfig.id, 'saving');
      // DataStore will now automatically:
      // - Convert Sets/Maps to Arrays/Objects
      // - Strip DOM references
      // - Validate serializability
      await this.store.save({
         formId: formConfig.id,
         data: formData,
         status: 'draft',
         timestamp: Date.now()
      }).then(()=> {
      }).then(() => {
         this.showFormStatus(formConfig.id, 'autosaved');
      }).catch(error => {
         console.error('Autosave failed:', error);
         this.showFormStatus(formConfig.id, 'error', 'Failed to save changes');
      });
      // Get only changed fields
@@ -1286,13 +1516,14 @@
      this.forms.set(formConfig.id, formConfig);
      document.removeEventListener('input', this.handleInput);
      for (let [key,  value] of Object.entries(formData)) {
         //We want all data for complex fields, like group, repeater, or location
      for (let [key, value] of Object.entries(formData)) {
         // Complex fields need full data
         if (typeof value === 'object') {
            changes[key] = value;
         }
      }
      // Notify instead of callback
      // Notify
      this.notify('form-autosave', {
         formId: formConfig.id,
         changes: changes,
@@ -1313,16 +1544,23 @@
      // Check if current data differs from snapshot
      const currentData = this.collectFormData(formConfig.element);
      const changes = this.getChangedFields(formConfig.lastSnapshot, currentData);
      const changes = this.getChangedFields(formConfig.data, currentData);
      return Object.keys(changes).length > 0;
   }
   showFormStatus(formID, status) {
   showFormStatus(formID, status, message='') {
      // Remove existing status
      let form = this.forms.get(formID);
      if (!form.options.formStatus) {
         return;
      }
      console.log('Setting status: ', status);
      if (form.status === status){
         return;
      }
      form.status = status;
      // Add new status
      const statusWrap = form.element.querySelector('.fstatus');
@@ -1341,9 +1579,9 @@
         'offline': 'Changes will be saved when online'
      };
      const icons = {
         'autosaved': 'check',
         'submitted': 'check',
         'error': 'close',
         'autosaved': 'check-circle',
         'submitted': 'check-circle',
         'error': 'close-circle',
         'offline': 'cloud-slash',
         'pending': 'exclamation-mark'
      }
@@ -1352,9 +1590,10 @@
      if (icon) {
         statusWrap.prepend(icon);
      }
      console.log(status, messages[status]);
      console.log(status, icons[status]);
      statusElement.textContent = messages[status] || status;
      if (message === '') {
         message = messages[status] || status;
      }
      statusElement.textContent = message;
      statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
      // Auto-hide success messages
@@ -1381,7 +1620,15 @@
   /* ========== Form Data Methods ========== */
   collectFormData(form) {
   collectFormData(form, isInit = false) {
      if (Object.hasOwn(form.dataset, 'timeline')) {
         return this.collectTimeline(form);
      }
      //Table forms are handled separately
      if (form.classList.contains('table') && form.tagName === 'FORM') {
         return {};
      }
      const formData = new FormData(form);
      let data = {};
      const repeaterData = {};
@@ -1400,8 +1647,52 @@
      return this.mergeRepeaterData(data, repeaterData);
   }
   collectTimeline(form) {
      let data = {};
      let posts = {}; // Temporary object keyed by post ID
      let postOrder = []; // Track order as encountered (preserves DOM/drag order)
      let formData = new FormData(form);
      for (const [key, value] of formData.entries()) {
         if (this.ignore.includes(key) || key.endsWith('_temp')) {
            continue;
         }
         const match = key.match(/^\[(\d+)\](.+)$/);
         if (match) {
            // Timeline-specific field: [postId]fieldName
            const [, postId, fieldName] = match;
            if (!posts[postId]) {
               posts[postId] = {
                  id: parseInt(postId),
               };
               postOrder.push(postId); // Track first occurrence
            }
            if (fieldName === 'post_thumbnail') {
               posts[postId]['post_thumbnail'] = parseInt(form.querySelector(`[name="${key}"]`).closest('.item')?.dataset.id);
            } else {
               const processor = this.getFieldProcessor(fieldName);
               processor(fieldName, value, posts[postId], {}, {}, form);
            }
         } else {
            // Shared field (post_title, taxonomies, etc.)
            const processor = this.getFieldProcessor(key);
            processor(key, value, data, {}, {}, form);
         }
      }
      // Convert to array in DOM order (matches menu_order)
      data.timeline = postOrder.map(id => posts[id]);
      delete data['form-id'];
      delete data['sendAll'];
      delete data['timeline_temp'];
      delete data['']; // Empty key
      return data;
   }
   getFieldProcessor(key) {
      if (key.includes('|')) return this.processTableField;
      if (key.includes('::')) return this.processGroupField;
      if (key.includes(':')) return this.processRepeaterField;
      if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
@@ -1426,42 +1717,12 @@
   }
   mergePostData(data, postData) {
      for (let [postId, postData] in Object.entries(postData)) {
         data[postId] = postData;
      for (let [postId, fields] of Object.entries(postData)) {
         data[postId] = fields;
      }
      return data;
   }
   processTableField(key, value, data, repeaterData, postData, form) {
      /***
       * Table forms are a huge form containing multiple posts and their data
       * Field names are prepended with  `${postID}|`
       * Goal:
       * 1) Separate out the post id from the field name
       * 2) store the original data in a temporary 'original' variable
       * 3) Process the field as normal
       * 4) return the original data, as PostID: {$field data}
       * Final format:
       * {
       *     id1: {
       *         field1: "A title",
       *         field3: 32
       *     },
       *     id2: {
       *         field1: "Another title",
       *         field2: "122,21,32"
       *     }
       * }
       **/
      let [post, fieldKey] = key.split('|');
      if (!post in postData) {
         postData[post] = {};
      }
      const processor = this.getFieldProcessor(fieldKey);
      processor(fieldKey, value, postData, repeaterData, postData, form);
   }
   processRepeaterField(key, value, data, repeaterData, postData, form) {
      let [fieldName, index, subField] = key.split(':');