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);
      }
   }
@@ -207,7 +266,7 @@
            ...options
         },
         dependencies: new Map(),
         data: this.collectFormData(formElement),
         data: this.collectFormData(formElement, true),
      };
      // Initialize special fields
@@ -661,12 +720,21 @@
      if (this.subscribers.size > 0) {
         event.preventDefault();
         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) {
@@ -686,7 +754,6 @@
         success.textContent = data.message;
         form.insertBefore(success, form.firstChild);
         // Optionally add icon
         const icon = window.getIcon?.('check-circle');
         if (icon) {
            icon.classList.add('success-icon');
@@ -706,7 +773,6 @@
         }
         if (data.description) {
            // Handle both string and array descriptions
            const descriptions = Array.isArray(data.description)
               ? data.description
               : [data.description];
@@ -721,6 +787,21 @@
         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');
@@ -881,7 +962,7 @@
      const formConfig = this.forms?.get(form.dataset.formId);
      if (!formConfig) return;
      console.log(formConfig.options);
      if (formConfig.options.autosave || this.subscribers.size > 0) {
         // Check conditional fields
         const dependencies = formConfig.dependencies.get(target.name);
@@ -1409,33 +1490,40 @@
      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
      const changes = this.getChangedFields(formConfig.data, formData);
      console.log('Changes:', changes);
      if (Object.keys(changes).length === 0) return;
      console.log('Continuing on:');
      // Update stored data
      formConfig.data = formData;
      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,
@@ -1474,8 +1562,6 @@
      form.status = status;
      console.log('Setting status: ', status);
      // Add new status
      const statusWrap = form.element.querySelector('.fstatus');
      statusWrap.hidden = false;
@@ -1534,10 +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 = {};
@@ -1557,7 +1648,6 @@
   }
   collectTimeline(form) {
      console.log('Collecting Timeline data:');
      let data = {};
      let posts = {}; // Temporary object keyed by post ID
      let postOrder = []; // Track order as encountered (preserves DOM/drag order)
@@ -1572,11 +1662,18 @@
            // Timeline-specific field: [postId]fieldName
            const [, postId, fieldName] = match;
            if (!posts[postId]) {
               posts[postId] = { id: parseInt(postId) };
               posts[postId] = {
                  id: parseInt(postId),
               };
               postOrder.push(postId); // Track first occurrence
            }
            const processor = this.getFieldProcessor(fieldName);
            processor(fieldName, value, posts[postId], {}, {}, form);
            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);
@@ -1592,12 +1689,10 @@
      delete data['timeline_temp'];
      delete data['']; // Empty key
      console.log('Data: ', data);
      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;
@@ -1622,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(':');