Jake Vanderwerf
2026-05-12 457c329237f97069063e641b10f384a52d584f21
assets/js/concise/CRUD.js
@@ -10,15 +10,16 @@
      this.queue = window.jvbQueue;
      this.a11y = window.jvbA11y;
      this.error = window.jvbError;
      this.populate = window.jvbPopulate;
      this.cache = new window.jvbCache(this.content);
      this.activeItem = null;
      this.isTimeline = false;
      this.items = {
         list: new Map(),
         grid: new Map(),
         table: new Map()
      }; //DOM references
      this.isPopulating = false;
      //Track Changes
      this.changes = new Map();
      this.items = new Map();
      this.init();
   }
@@ -26,6 +27,7 @@
   init() {
      this.initElements();
      this.initListeners();
      this.defineTemplates();
      let cached = this.initSettings();
      this.initStore(cached);
      this.checkHideFilters();
@@ -34,6 +36,221 @@
      this.initModals();
   }
   defineTemplates() {
      const T = window.jvbTemplates;
      const crud = this;
      const baseSetup = (el, refs, data) => {
         el.dataset.itemId = data.id;
         let wrapper = refs.checkbox.closest('.preview');
         window.prefixInput(refs.checkbox, `select-${data.id}`, wrapper, true);
         refs.checkbox.value = data.id;
         refs.checkbox.checked = crud.selected.has(parseInt(data.id));
         if (refs.selectLabel) refs.selectLabel.htmlFor = `select-${data.id}`;
         if (refs.edit) refs.edit.dataset.id = data.id;
         if (refs.trash) refs.trash.dataset.id = data.id;
      };
      const imageSetup = function(el, refs, data) {
         let hasThumbnail = data?.fields?.post_thumbnail || data?.fields?.thumbnail;
         if (hasThumbnail) {
            const thumbnail = data.images[hasThumbnail] ?? {};
            refs.img.src = thumbnail.medium??'';
            refs.img.alt = thumbnail.alt??data.fields.post_title??'';
         }
      }
      T.define('gridView', {
         refs: {
            img: 'img',
            checkbox: '.select-item',
            selectLabel: 'label.select-item-label',
            edit: '[data-action="edit"]',
            trash: '[data-action="trash"]'
         },
         setup({ el, refs, manyRefs, data }) {
            baseSetup(el, refs, data);
            imageSetup(el, refs, data);
         }
      });
      T.define('listView', {
         refs: {
            img: 'img',
            checkbox: '.select-item',
            selectLabel: 'label.select-item-label',
            edit: '[data-action="edit"]',
            trash: '[data-action="trash"]'
         },
         manyRefs: {
            attrs: '[data-attr]',
            fields: '[data-field]'
         },
         setup({ el, refs, manyRefs, data }) {
            baseSetup(el, refs, data);
            imageSetup(el, refs, data);
            manyRefs?.attrs?.forEach(el => {
               const value = data[el.dataset.attr];
               if (value && value !=='') {
                  el.textContent = value;
               } else {
                  el.remove();
               }
            });
            manyRefs?.fields?.forEach(el => {
               const value = data.fields?.[el.dataset.field];
               if (value && value !== '') {
                  el.tagName === 'DIV' ? el.innerHTML = value : el.textContent = value;
               } else {
                  el.remove();
               }
            });
         }
      });
      let tableRefs = {};
      let tableMany = {};
      if (this.isTimeline) {
         tableRefs.sharedRow = 'tr.shared';
         tableRefs.point = 'tr.timeline-point';
      }
      T.define('tableView', {
         refs: {
            checkbox: '.select-item',
            selectLabel: 'label.select-item-label',
            ... tableRefs,
         },
         manyRefs: {
            inputs: 'input,select,textarea',
            status: 'input[name="post_status"]',
            selectors: '[data-type="selector"]',
            fields: '[data-field]',
            ... tableMany,
         },
         setup({ el, refs, manyRefs, data }) {
            baseSetup(el, refs, data);
            manyRefs?.inputs?.forEach(el => {
               let wrapper = el.closest('[data-field]');
               window.prefixInput(el, `${data.id}-`, wrapper);
            });
            manyRefs?.status?.forEach(el => {
               if (el.value === data.status) {
                  el.checked = true;
               }
            });
            if (crud.isTimeline) {
               if (refs.sharedRow) {
                  refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => {
                     let wrapper = input.closest('[data-field]');
                     window.prefixInput(input, `${data.id}-`, wrapper);
                  });
                  crud.populate.populate(refs.sharedRow, data);
                  // Handle status radios in shared row
                  refs.sharedRow.querySelectorAll('input[name="post_status"]').forEach(el => {
                     if (el.value === data.status) {
                        el.checked = true;
                     }
                  });
               }
               if (refs.point && data.fields?.timeline) {
                  Object.entries(data.fields.timeline).forEach(([nuthing, timeline], index) => {
                     const point = refs.point.cloneNode(true);
                     point.dataset.index = `${index}`;
                     point.dataset.itemId = timeline.id;
                     point.querySelectorAll('input,select,textarea').forEach(input => {
                        let wrapper = input.closest('[data-field]');
                        window.prefixInput(input, `${timeline.id}-`, wrapper);
                     });
                     crud.populate.populate(point, {
                        fields: timeline,
                        images: data.images,
                        taxonomies: data.taxonomies
                     });
                     const imgData = data.images?.[timeline.post_thumbnail];
                     if (imgData) {
                        point.querySelector('.field.upload')?.setAttribute('title', imgData['image-title']??'');
                     }
                     el.insertBefore(point, refs.point);
                  });
                  refs.point.remove();
               }
            } else {
               if (crud.ui.table.form?.dataset.edit !== undefined) {
                  // Non-timeline: prefix all inputs normally
                  manyRefs?.inputs?.forEach(input => {
                     let wrapper = input.closest('[data-field]');
                     window.prefixInput(input, `${data.id}-`, wrapper);
                  });
                  manyRefs?.status?.forEach(el => {
                     if (el.value === data.status) {
                        el.checked = true;
                     }
                  });
                  crud.populate.populate(el, data);
               } else {
                  const fields = (Object.hasOwn(data, 'fields')) ? data.fields : data;
                  manyRefs?.fields?.forEach(field => {
                     if (Object.hasOwn(fields, field.dataset.field) && fields[field.dataset.field] !== '') {
                        let value = fields[field.dataset.field];
                        let p = fields.children[0];
                        if (p) {
                           p.textContent = field.dataset.field === 'date'
                              ? window.formatTimeAgo(value)
                              : value;
                        }
                     }
                  });
               }
            }
            manyRefs?.selectors?.forEach(selector => selector.setAttribute('data-lazy', ''));
         }
      });
      T.define('emptyState');
      T.define('bulkItem', {
         refs: {
            checkbox: 'input',
            img: 'img',
            label: 'label'
         },
         setup({el, refs, manyRefs, data}) {
            if (refs.checkbox) {
               refs.checkbox.id = `bulk_${data.id}`;
               refs.checkbox.value = data.id;
               refs.checkbox.checked = true;
               refs.checkbox.name ='selected[]';
            }
            let thumbnail = data?.images[data?.fields?.post_thumnbail]??{};
            if (refs.img && Object.keys(thumbnail).length >0) {
               refs.img.src = thumbnail.medium??'';
               refs.img.alt = thumbnail.alt??'';
            }
            if (refs.label) {
               refs.label.title = item.fields.post_title;
            }
         }
      });
      T.define('trashOptions');
      T.define('notTrashOptions');
      T.define('contentTable');
   }
   initElements() {
      this.allowedFilters = ['status', 'orderby', 'order', 'search', 'date-filter', 'dateFrom', 'dateTo'];
      this.selectors = {
@@ -107,7 +324,11 @@
            },
            date: '[data-filter="date"]'
         },
         uploader: 'details.uploader'
         uploader: {
            details: 'details.uploader',
            form: 'details.uploader form',
            uploader: 'details.uploader [data-field-type="upload"]'
         }
      }
      this.ui = window.uiFromSelectors(this.selectors);
@@ -123,17 +344,32 @@
      this.isTimeline = !!document.querySelector('[data-timeline]');
   }
      initUploader() {
         if (!this.ui.uploader) return;
         if (!this.ui.uploader.form) return;
         this.uploadForm = this.forms.registerForm(this.ui.uploader.form).id??false;
         window.jvbUploads.scanFields(this.ui.uploader);
         // window.jvbUploads.scanFields(this.ui.uploader);
         window.jvbUploads.subscribe((event, data) => {
            if (event === 'sent-to-queue') {
               if (data === this.ui.uploader.dataset.uploader) {
               if (data.field.id === this.ui.uploader.uploader.dataset.uploader) {
                  if (this.uploadForm ) {
                     this.forms.store.delete(this.uploadForm);
                  }
                  window.debouncer.schedule('crud-complete', ()=> {
                     this.store.clearCache();
                  });
               }
            }
            if (event === 'sent-to-queue' && data.field) {
               const fieldName = data.field.config.name;
               const itemId = data.field.config.itemID;
               if (itemId && fieldName) {
                  if (this.changes.has(itemId)) {
                     delete this.changes.get(itemId)[fieldName];
                  }
               }
            }
         });
      }
      initModals() {
@@ -147,14 +383,20 @@
                  case 'modal-close':
                     const formId = this.ui.modals[name].form.dataset.formId;
                     if (formId) {
                        this.formController.cleanupForm(formId);
                        this.forms.clearForm(formId);
                     }
                     this.ui.modals[name].form.reset();
                     this.resetForm(this.ui.modals[name].form);
                     if (name === 'date') {
                        this.handleCustomDateSelection()
                     }
                     if (['edit','bulkEdit','create'].includes(name)) {
                        //handle escapes (not form submits)
                        if (window.debouncer.timeouts.has(`save-${this.content}`)) {
                           this.scheduleSave(0);
                        }
                     }
                     break;
                  case 'modal-open':
@@ -170,29 +412,40 @@
         ... this.defaults,
         ...cached
      };
      const store = window.jvbStore.register(
      const stores = window.jvbStore.register(
         this.content,
         {
            storeName: this.content,
            keyPath: 'id',
            endpoint: this.endpoint??'content', //for taxonomy stores
            headers: {
               'action_nonce': window.auth.getNonce('dash'),
      [
            {
               storeName: this.content,
               keyPath: 'id',
               endpoint: this.endpoint??'content', //for taxonomy stores
               headers: {
                  'X-Action-Nonce': window.auth.getNonce('dash'),
               },
               indexes: [
                  {name: 'id', keyPath: 'id'},
                  { name: 'status', keyPath: 'status'},
                  { name: 'date', keyPath: 'date'},
                  { name: 'modified', keyPath: 'modified'},
                  { name: 'title', keyPath: 'title'},
               ],
               isAuth: true,
               filters: filters,
               ignore: ['content', 'user'],
               TTL: 60 * 60 * 1000,       //1 hour cache
               showLoading: true,
            },
            indexes: [
               {name: 'id', keyPath: 'id'},
               { name: 'status', keyPath: 'status'},
               { name: 'date', keyPath: 'date'},
               { name: 'modified', keyPath: 'modified'},
               { name: 'title', keyPath: 'title'}
            ],
            filters: filters,
            ignore: ['content', 'user'],
            TTL: 60 * 60 * 1000,       //1 hour cache
            showLoading: true,
         }
            {
               storeName: 'changes',
               keyPath: 'id'
            }
         ]
      );
      this.store = store[this.content];
      this.changesStore = stores['changes'];
      this.store = stores[this.content];
      this.store.subscribe((event, data) => {
         switch (event) {
@@ -201,18 +454,35 @@
               this.selectionHandler.collectItems();
               break;
         }
      })
      });
      this.changesStore.subscribe((event, data) => {
         switch (event) {
            case 'data-ready':
               let changes = this.changesStore.getAll();
               if (changes.length > 0) {
                  changes.forEach(change => {
                     this.changes.set(change.id, change);
                  });
                  this.savePosts('', false).then(()=>{});
               }
               break;
         }
      });
   }
   initIntegrations() {
      this.selected = new Set();
      this.selectionHandler = new window.jvbHandleSelection(this.container, {
         selectAll: {
            checkbox: '.crud #select-all',
            checkbox: '#select-all',
            label: '.bulk-select label',
            span: '.bulk-select label span'
         },
         wrapper: {
            wrapper: '.wrap'
         },
         item: {
            idAttribute: 'itemId'
         }
      });
      this.selectionHandler.subscribe((event, data) => {
@@ -222,16 +492,24 @@
         this.ui.bulk.count.textContent = `${this.selected.size} ${this.plural} selected`;
      });
      this.formController = new window.jvbForm();
      this.forms = window.jvbForm;
      this.formController.subscribe((event, data) => {
         switch(event) {
            case 'form-submit':
            case 'form-autosave':
               this.handleFormChange(event,data);
               break;
         }
      });
      // this.forms.subscribe((event, data) => {
      //    switch(event) {
      //       case 'form-submit':
      //       case 'form-autosave':
      //          // this.handleFormChange(event,data);
      //          break;
      //    }
      // });
      if (window.jvbUploads) {
         window.jvbUploads.subscribe((event, data) => {
            if (event === 'groups_uploaded' && data.content === this.content) {
               this.handleGroupsUploaded(data);
            }
         });
      }
      this.queue.subscribe((event, data) => {
         if (['image_upload', 'video_upload', 'document_upload'].includes(data.type)
@@ -239,8 +517,92 @@
            && data.status === 'completed') {
            this.store.clearCache();
         }
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.endpoint === 'uploads/groups') {
            if (data.result && data.result.group_mappings) {
               console.log('Handling group mapping from queue response');
               this.handleGroupMappings(data.result.group_mappings);
            }
            this.store.clearCache();
         }
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.type === 'content_update') {
            this.store.clearCache();
            if (!data.result || !data.result.success || !data.result.errors)
            {
               console.warn('Content update completed but no results', data);
               return;
            }
            if (Object.keys(data.result.success).length > 0) {
               this.checkCompletedChanges(Object.entries(data.result.success));
            }
            if (Object.keys(data.result.errors).length > 0) {
               this.checkFailedChanges(Object.entries(data.result.errors));
               return;
            }
            if (Object.keys(data.result.success).length === 0) {
               console.log(data.result.success);
               data.result.success.forEach(id => this.changesStore.delete(id));
               this.store.clearCache();
            }
         }
         if (event === 'sent-to-server') {
            if (data instanceof FormData) return;
            for ( let [id, changes] of Object.entries(data.posts)) {
               this.compareStored(id, changes);
            }
         }
      });
   }
   checkCompletedChanges(items) {
      for (let [id, data] of items) {
         this.compareStored(id, data);
      }
   }
      compareStored(id, data) {
         let stored = this.changesStore.get(id);
         if (!stored) return;
         for (let [field, value] of Object.entries(data)) {
            if (Object.hasOwn(stored, field)) {
               let changes = window.getDifferences.map(stored[field], value);
               if (!changes) {
                  delete stored[field];
               } else {
                  stored[field] = changes;
               }
            }
         }
         let hasID = Object.hasOwn(stored, 'id');
         let hasContent = Object.hasOwn(stored, 'content');
         if ((hasID && hasContent && Object.keys(stored).length === 2)
            || ((hasID || hasContent) && Object.keys(stored).length === 1)
            || Object.keys(stored).length === 0
         ) {
            this.changesStore.delete(id);
            this.store.clearCache();
         } else {
            this.changesStore.save(stored);
         }
      }
   checkFailedChanges(items) {
      //TODO do something.
   }
   initSettings() {
      this.defaults = {
@@ -303,7 +665,7 @@
            default: 'closed',
         },
         showUploader: {
            element: this.ui.uploader,
            element: this.ui.uploader.details,
            default: 'open'
         }
      };
@@ -326,53 +688,123 @@
      this.changeHandler = this.handleChange.bind(this);
      this.clickHandler = this.handleClick.bind(this);
      this.inputHandler = this.handleInput.bind(this);
      this.submitHandler = this.handleModalSubmit.bind(this);
      document.addEventListener('change', this.changeHandler);
      document.addEventListener('click', this.clickHandler);
      if (this.ui.filters.search) {
         this.ui.filters.search.addEventListener('input', this.inputHandler);
      }
      for (let [name, modal] of Object.entries(this.ui.modals)) {
         if (modal.form) {
            modal.form.addEventListener('submit', this.submitHandler);
         }
      }
   }
   handleModalSubmit(e) {
      e.preventDefault();
      const form = e.target;
      const modal = form.closest('dialog');
      if (!modal) return;
      if (modal.classList.contains('create')) {
         this.handleCreateSubmit(modal);
         return;
      }
      let title = `Saving changes for multiple ${this.plural}`;
      this.scheduleSave(0);
      this.modals.edit.handleClose();
   }
   async handleCreateSubmit(modal) {
      const itemId = modal.dataset.itemId;
      // 1. Flush changes to store
      if (this.changes.size > 0) {
         this.cancelBackup();
         await this.handleBackup();
      }
      const changes = await this.changesStore.getAll();
      if (changes.length === 0) return;
      let allChanges = {};
      changes.forEach(change => {
         const { id, ...rest } = change;
         allChanges[id] = rest;
      });
      // 2. Queue content creation, get operationId
      let contentOpId = this.queue.addToQueue({
         endpoint: this.endpoint,
         headers: {
            'X-Action-Nonce': window.auth.getNonce('dash'),
         },
         data: {
            posts: allChanges,
         },
         popup: `Creating your new ${this.singular}`,
         title: `Creating your new ${this.singular}`,
      });
      if (!contentOpId) return;
      // 3. Queue any pending uploads with dependency on content creation
      const uploadFields = modal.querySelectorAll('[data-upload-field]');
      for (const fieldEl of uploadFields) {
         const fieldId = fieldEl.dataset.uploader;
         if (!fieldId) continue;
         const uploads = window.jvbUploads.stores.uploads.filterByIndex({ field: fieldId });
         if (uploads.length === 0) continue;
         await window.jvbUploads.queueUploads('uploads', fieldId, contentOpId);
      }
   }
   handleChange(e) {
      const isSearch = window.targetCheck(e, this.selectors.filters.search);
      if (isSearch) {
         return;
      }
      const bulkAction = window.targetCheck(e, this.selectors.bulk.action);
      if (bulkAction) {
         this.handleBulkAction(bulkAction);
      // Early bailout - target must be in an item or be a filter
      const inItem = e.target.closest('[data-item-id]');
      const isFilter = e.target.matches('[data-filter]');
      const isBulkAction = e.target.matches('.bulk-action-select');
      const isView = e.target.matches('[data-view]');
      if (!inItem && !isFilter && !isBulkAction && !isView) return;
      if (!this.isPopulating && inItem && !e.target.closest('[data-ignore], .select-item')) {
         this.handleItemUpdate(e);
         return;
      }
      let filter = window.targetCheck(e, '[data-filter]');
      if (filter) {
         this.handleFilterChange(filter);
      if (isView) {
         this.items.clear();
         this.handleViewChange(e.target);
         return;
      }
      let view = window.targetCheck(e, 'input[data-view]');
      if (view) {
         this.handleViewChange(view);
      if (isBulkAction) {
         this.handleBulkAction(e.target);
         return;
      }
      if (isFilter) {
         this.handleFilterChange(e.target);
         return;
      }
      // Table-specific handlers
      if (this.view === 'table') {
         let target = window.targetCheck(e, '[data-id]');
         if (target) {
            this.handleTableChange(e);
            return;
         }
         let multiSelect = window.targetCheck(e, 'details.multi-select');
         if (multiSelect) {
         if (e.target.matches('details.multi-select')) {
            this.toggleColumn(e.target.id, e.target.checked);
            return;
         }
         let tabNav = window.targetCheck(e, this.selectors.table.nav);
         if (tabNav) {
            this.tabNav = tabNav.checked;
            this.cache.set('tabNav', tabNav.checked ? 'vertical' : 'horizontal');
         if (e.target.matches(this.selectors.table.nav)) {
            this.tabNav = e.target.checked;
            this.cache.set('tabNav', e.target.checked ? 'vertical' : 'horizontal');
         }
      }
   }
@@ -411,11 +843,8 @@
   handleBulkTaxonomy(result) {
      if (!result.termIds.length || !this.selected.size) return;
      const changes = {};
      const taxonomyField = `tax_${result.taxonomy}`;
      this.selected.forEach(itemID => {
         const item = this.store.get(parseInt(itemID));
         const item = this.store.get(itemID);
         if (!item) return;
         // Merge existing terms with new ones
@@ -423,21 +852,117 @@
         const existingIds = existingTerms.map(t => t.id);
         const newIds = [...new Set([...existingIds, ...result.termIds])];
         changes[itemID] = {
            [taxonomyField]: newIds.join(','),
            content: this.content
         };
         this.updateItem(itemID, result.taxonomy, newIds);
      });
      if (Object.keys(changes).length > 0) {
         this.savePosts(
            changes,
            `Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`
         );
      }
      this.savePosts(`Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,).then(()=> {});
      this.selectionHandler.clearSelection();
   }
   handleItemUpdate(e) {
      let item = window.targetCheck(e, '[data-item-id]');
      if (!item) return;
      // Check if inside a collection field first
      const collection = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
      let name, value;
      if (collection) {
         name = collection.dataset.field;
         value = this.forms.getFieldValue(collection);
      } else {
         let field = e.target.closest('[data-field]');
         name = field.dataset.field;
         value = this.forms.getFieldValue(e.target);
      }
      item.dataset.itemId.split(',').forEach(itemId => {
         this.updateItem(itemId, name, value);
      });
   }
   updateItem(itemId, name, value) {
      if (this.isPopulating) {
         return;
      }
      name.replace(`[${itemId}]`, '');
      const stored = this.store.get(itemId);
      if (stored) {
         const storedValue = stored.fields?.[name] ?? stored[name];
         const diff = window.getDifferences.map(storedValue, value);
         if (diff === null) {
            // Value matches stored â€” clean up any pending change for this field
            if (this.changes.has(itemId)) {
               delete this.changes.get(itemId)[name];
               // If no real changes left, remove the item entirely
               const remaining = Object.keys(this.changes.get(itemId))
                  .filter(k => k !== 'id' && k !== 'content');
               if (remaining.length === 0) {
                  this.changes.delete(itemId);
                  this.changesStore.delete(itemId);
               }
            }
            return;
         }
      }
      if (!this.changes.has(itemId)) {
         this.changes.set(itemId, { id: itemId, content: this.content });
      }
      this.changes.get(itemId)[name] = value;
      this.scheduleBackup();
      if (typeof itemId === 'number' || !String(itemId).includes('group')) {
         this.scheduleSave();
      }
   }
   scheduleBackup() {
      window.debouncer.schedule(
         `changes-${this.content}`,
         async () => {
            if (this.changes.size > 0) {
               await this.handleBackup();
            }
         },
         2000
      );
   }
   cancelBackup() {
      window.debouncer.cancel(`changes-${this.content}`);
   }
   async handleBackup() {
      const changesArray = Array.from(this.changes.values());
      this.changes.clear();
      const ids = changesArray.map(c => c.id);
      const existing = await Promise.all(
         ids.map(id => this.changesStore.get(id))
      );
      const changes = changesArray.map((change, i) =>
         existing[i] ? window.deepMerge(existing[i], change) : change
      );
      await this.changesStore.saveMany(changes);
   }
   scheduleSave(delay = 10000) {
      window.debouncer.schedule(
         `save-${this.content}`,
         async () => {
            // Ensure latest changes are in IndexedDB
            if (this.changes.size > 0) {
               this.cancelBackup();
               await this.handleBackup();
            }
            await this.savePosts('', false);
         },
         delay
      );
   }
   handleFilterChange(target) {
      let filter = target.dataset.filter;
@@ -521,40 +1046,67 @@
         this.cache.set('view', this.view);
         this.render();
      }
   handleClick(e) {
      let clearSearch = window.targetCheck(e, '.clear-search');
      if (clearSearch) {
      // Use matches() instead of closest() where possible (faster)
      if (e.target.matches('.clear-search')) {
         this.deleteFilter('search', '');
         return;
      }
      let actionButton = window.targetCheck(e, '[data-action]');
      const actionButton = e.target.closest('[data-action]');
      if (actionButton) {
         e.preventDefault();
         let itemID = actionButton.dataset.id;
         this.handleActionButton(actionButton);
         return;
      }
         switch (actionButton.dataset.action) {
      if (e.target.matches('.apply-date-filter')) {
         this.handleCustomDateSelection();
         this.modals.date.handleClose();
         return;
      }
      if (e.target.matches(this.selectors.buttons.create) || e.target.closest(this.selectors.buttons.create)) {
         this.openCreateModal();
      }
   }
      openCreateModal(){
         this.forms.registerForm(this.ui.modals.create.form,{
            cache: false,
         });
         this.ui.modals.create.modal.dataset.itemId = window.generateID('new');
         this.modals.create.handleOpen();
      }
      handleActionButton(button) {
         const itemID = button.dataset.id;
         switch (button.dataset.action) {
            case 'edit':
               this.openEditModal(itemID);
               break;
            case 'delete':
               if (confirm('Delete this item? This cannot be undone')) {
                  let changes = {};
                  changes[itemID] = {
                     'post_status': 'delete',
                     'content': this.content
                  };
                  window.fade(actionButton.closest('.item'), false);
                  this.savePosts(changes, `Sending ${this.singular} to trash...`);
                  this.updateItem(itemID, 'post_status', 'delete');
                  window.fade(button.closest('.item'), false);
                  this.savePosts(`Permanently deleting ${this.singular}...`).then(()=>{});
                  this.store.delete(itemID);
               }
               break;
            case 'trash':
               let changes = {};
               changes[itemID] = {
                  'post_status': 'trash',
                  'content': this.content
               };
               window.fade(actionButton.closest('.item'), false);
               this.savePosts(changes, `Sending ${this.singular} to trash...`);
               if (this.status === 'trash') {
                  if (confirm('Delete this item? This cannot be undone')) {
                     this.updateItem(itemID, 'post_status', 'delete');
                     window.fade(button.closest('.item'), false);
                     this.savePosts(`Permanently deleting ${this.singular}...`).then(()=>{});
                     this.store.delete(itemID);
                  }
               } else {
                  this.updateItem(itemID, 'post_status', 'trash');
                  window.fade(button.closest('.item'), false);
                  this.savePosts(`Sending ${this.singular} to trash...`).then(()=>{});
               }
               break;
            case 'bulk-edit':
               if (this.selected.size > 0) {
@@ -562,10 +1114,7 @@
               }
               break;
            case 'bulk-delete':
               if (this.selected.size > 0 && confirm(`Delete  ${this.selected.size} items?`)) {
                  this.selected.forEach(id => this.store.delete(id));
                  this.selectionHandler.clearSelection();
               }
               this.handleBulkDelete();
               break;
            case 'refresh':
               this.store.clearCache();
@@ -576,22 +1125,21 @@
               break;
         }
      }
      const applyDate = window.targetCheck(e, '.apply-date-filter');
      if (applyDate) {
         this.handleCustomDateSelection();
         this.modals.date.handleClose();
         return;
      handleBulkDelete() {
         let isTrash = this.status === 'trash';
         if (this.selected.size > 0 && confirm(`${isTrash ? 'Permanently delete' : 'Send'} ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural}${isTrash ? '' : 'to trash'}?`)) {
            this.selected.forEach(id => {
               this.store.delete(id);
               this.updateItem(id, 'post_status', isTrash ? 'delete' : 'trash');
            });
            let title = isTrash
               ? `Permanently deleting ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural}`
               : `Sending ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural} to trash`;
            this.savePosts(title).then(()=>{});
            this.selectionHandler.clearSelection();
         }
      }
      const createButton = window.targetCheck(e, this.selectors.buttons.create);
      if (createButton) {
         this.ui.modals.create.form.reset();
         this.formController.registerForm(this.ui.modals.create.form);
         this.modals.create.handleOpen();
      }
   }
   handleInput(e) {
      e.preventDefault();
      e.stopPropagation();
@@ -688,7 +1236,7 @@
      }
      // For regular tables, check for data-id
      return !!row.dataset.id;
      return !!row.dataset.itemId;
   }
   focusFieldInRow(row, fieldName, fromAbove = false) {
@@ -726,82 +1274,6 @@
      return null;
   }
   toggleTimelineListeners(on = true) {
      if (!this.isTimeline || this.view !== 'table') return;
      // Cleanup existing instances
      if (this.timelineSortables) {
         this.timelineSortables.forEach(sortable => sortable.destroy());
         this.timelineSortables = [];
      }
      if (!on) return;
      // Initialize sortable for each tbody
      this.timelineSortables = [];
      const tbodies = this.ui.table.form.querySelectorAll('tbody.item');
      tbodies.forEach(tbody => {
         const sortable = new Sortable(tbody, {
            animation: 150,
            handle: '.drag-handle',
            draggable: 'tr.timeline-point',
            ghostClass: 'sortable-ghost',
            chosenClass: 'sortable-chosen',
            dragClass: 'sortable-drag',
            // Prevent dragging between different tbodies
            group: {
               name: `timeline-${tbody.dataset.id}`,
               pull: false,
               put: false
            },
            onEnd: (evt) => this.handleTimelineReorder(evt)
         });
         this.timelineSortables.push(sortable);
      });
   }
   handleTimelineReorder(evt) {
      const { item: draggedRow, oldIndex, newIndex, from: tbody } = evt;
      // No change
      if (oldIndex === newIndex) return;
      const itemID = parseInt(tbody.dataset.id);
      const item = this.store.get(itemID);
      if (!item?.fields?.timeline) return;
      // Get current order of image IDs from DOM
      const newOrder = Array.from(tbody.querySelectorAll('tr.timeline-point'))
         .map(row => row.dataset.imageId)
         .filter(Boolean);
      // Rebuild timeline object in new order
      const reorderedTimeline = {};
      newOrder.forEach((imgId, index) => {
         if (item.fields.timeline[imgId]) {
            reorderedTimeline[imgId] = {
               ...item.fields.timeline[imgId],
               order: index
            };
         }
      });
      item.fields.timeline = reorderedTimeline;
      // Save to store and server
      this.store.save(item);
      this.savePosts(
         { [itemID]: { timeline: reorderedTimeline, content: this.content } },
         'Reordering timeline...',
         true
      );
      this.a11y?.announce(`Moved to position ${newIndex + 1}`);
   }
   /*******************************************************************
    MODALS
@@ -812,119 +1284,110 @@
      this.activeItem = item.id;
      this.ui.modals.edit.modal.dataset.itemId = itemID;
      this.ui.modals.edit.modal.dataset.content = this.content;
      this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`;
      let title;
      if (Object.hasOwn(item.fields, 'post_title')) {
         title = item.fields.post_title;
      } else if (Object.hasOwn(item.fields, 'name')) {
         title = item.fields.name;
      }
      this.ui.modals.edit.h2.textContent = `Editing ${title === '' ? this.singular : title}`;
      this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`;
      this.ui.modals.edit.form.reset();
      new window.jvbPopulate(this.ui.modals.edit.form, item);
      this.formController.registerForm(this.ui.modals.edit.form);
      this.modals.edit.handleOpen();
      this.forms.registerForm(this.ui.modals.edit.form, {cache: false,
         autoUpload: true,});
      this.isPopulating = true;
      this.populate.populate(this.ui.modals.edit.form, item);
      //For quill/taxonomy selector's async setups
      requestAnimationFrame(() => {
         requestAnimationFrame(() => {
            this.isPopulating = false;
         });
      });
   }
   openBulkEditModal() {
      window.removeChildren(this.ui.modals.bulkEdit.selected);
      this.ui.modals.edit.form.reset();
      this.modals.bulkEdit.handleOpen();
      this.selected.forEach(itemId => {
         let template = window.getTemplate('bulkItem');
         if (!template) return;
         let item = this.store.get(parseInt(itemId));
         if (!item) return;
         let [checkbox, img, label] = [template.querySelector('input'), template.querySelector('img'), template.querySelector('label')];
         if (checkbox) {
            checkbox.id = `bulk_${item.id}`;
            checkbox.value = item.id;
            checkbox.checked = true;
            checkbox.name = 'selected[]';
         }
         let thumbnail = item.images[item.fields.post_thumbnail]??{};
         if (img) {
            img.src = thumbnail.medium??'';
            img.alt = thumbnail.alt??''
         }
      window.chunkIt(
         this.selected,
         (itemId) => {
            let item = this.store.get(parseInt(itemId));
            if (!item) return;
            itemIds.push(item.id);
         label.title = item.fields['post_title']??'';
         this.ui.modals.bulkEdit.selected.append(template);
      });
            return window.jvbTemplates.create('bulkItem', item);
         },
         (fragment) => this.ui.modals.bulkEdit.selected.append(fragment)
      ).then(()=>{});
      let itemIds = Array.from(this.selected).map(id => this.store.get(parseInt(id))).filter(Boolean);
      this.ui.modals.bulkEdit.modal.dataset.itemId = itemIds.join(',');
      if (this.ui.modals.bulkEdit.h2) {
         this.ui.modals.bulkEdit.h2.textContent = this.selected.size;
      }
      this.formController.registerForm(this.ui.modals.bulkEdit.form);
      this.modals.bulkEdit.handleOpen();
      this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
      this.isPopulating = true;
      this.populate.populate(this.ui.modals.edit.form, item);
      requestAnimationFrame(() => {
         requestAnimationFrame(() => {
            this.isPopulating = false;
         });
      });
   }
   /*****************************************************************
    FIELD HANDLING
   *****************************************************************/
   handleFormChange(event, data) {
      let title = data.fullData.post_title;
      let changes = (Object.hasOwn(data, 'changes')) ? data.changes : data.fullData;
      let theChanges = {};
      if (this.isTimeline) {
         theChanges[this.activeItem] = changes;
         if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
            this.removeItems([this.activeItem]);
         }
         this.savePosts(theChanges, title, event === 'form-submit');
         return;
   async savePosts(title = '', delay = false) {
      if (this.changes.size > 0) {
         this.cancelBackup();
         await this.handleBackup();
      }
      let changes = await this.changesStore.getAll();
      if (changes.length === 0) return;
      // Filter out false positives
      changes = this.validateChanges(changes);
      if (changes.length === 0) return;
      if (title === '') {
         title = `Saving ${changes.length} ${changes.length === 1 ? this.singular : this.plural}`;
      }
      let allChanges = {};
      let remove = [];
      let el = data.config.element;
      if (el === this.ui.modals.edit.form) {
         theChanges[this.activeItem] = changes;
         title = `Saving ${title} Changes`;
         if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
            remove.push(this.activeItem);
      changes.forEach(change => {
         let itemId = change.id;
         const { id, ...changeWithoutId } = change;
         allChanges[itemId] = changeWithoutId;
         if (change.post_status && this.shouldRemoveItemUI(change.post_status)) {
            remove.push(itemId);
         }
      } else if (el === this.ui.modals.bulkEdit.form) {
         let num = 0;
         el.querySelectorAll('.selected input:checked').forEach(selected => {
            theChanges[selected.value] = changes;
            if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
               remove.push(selected.value);
            }
            num++;
         });
         title = `Updating ${num} ${this.plural} Changes`;
      } else if (el === this.ui.modals.create.form) {
         if (event === 'form-submit') {
            theChanges[el.dataset.formId] = changes;
            title = `Saving ${title} Changes`;
         }
      }
      });
      if (remove.length > 0) {
         this.removeItems(remove);
      }
      this.selectionHandler.clearSelection();
      if (Object.keys(theChanges).length === 0) {
         return;
      }
      this.savePosts(theChanges, title, event === 'form-submit');
   }
   savePosts(changes, title, delay = false) {
      if (Object.keys(changes).length === 0) return;
      let validChanges = {};
      for (let itemId in changes) {
         // Validate ID exists and is not null/undefined
         if (!itemId || itemId === 'null' || itemId === 'undefined') {
            console.warn('Skipping save for invalid ID:', itemId);
            continue;
         }
         if (!changes[itemId]['content']) changes[itemId]['content'] = this.content;
         validChanges[itemId] = changes[itemId];
      }
      let operation = {
         endpoint: this.endpoint,
         headers: {
            'action_nonce': window.auth.getNonce('dash'),
            'X-Action-Nonce': window.auth.getNonce('dash'),
         },
         data: {
            posts: validChanges,
            posts: allChanges,
         },
         delay: delay,
         popup: `Saving changes`,
@@ -933,59 +1396,51 @@
      this.queue.addToQueue(operation);
   }
   handleTableChange(e) {
      const container = this.isTimeline
         ? e.target.closest('tbody[data-id]')
         : e.target.closest('tr[data-id]');
      if (!container) return;
   /**
    * Compare pending changes against the store, removing unchanged fields.
    * Returns cleaned array (may be empty if nothing actually changed).
    */
   validateChanges(changes) {
      return changes.reduce((valid, change) => {
         const { id, content, ...fields } = change;
         const stored = this.store.get(id);
      const input = e.target;
      const field = input.closest('[data-field]')?.dataset.field;
      if (!field) return;
      const itemID = parseInt(container.dataset.id);
      const item = this.store.get(itemID);
      if (!item) return;
      const value = this.getInputValue(input);
      // Timeline-specific: check if it's a point or shared value
      if (this.isTimeline) {
         const timelinePoint = input.closest('tr.timeline-point');
         if (timelinePoint) {
            const imgID = timelinePoint.dataset.imageId;
            item.fields.timeline ??= {};
            item.fields.timeline[imgID] ??= {};
            item.fields.timeline[imgID][field] = value;
         } else {
            item.fields[field] = value;
         if (!stored) {
            valid.push(change);
            return valid;
         }
      } else {
         item.fields[field] = value;
      }
      this.store.save(item);
      this.savePosts({ [itemID]: item.fields }, `Saving changes...`, true);
         const realChanges = { id, content };
         let hasRealChange = false;
         for (const [name, value] of Object.entries(fields)) {
            const storedValue = stored.fields?.[name] ?? stored[name];
            const diff = window.getDifferences.map(storedValue, value);
            if (diff !== null) {
               realChanges[name] = value;
               hasRealChange = true;
            }
         }
         if (hasRealChange) {
            valid.push(realChanges);
         } else {
            this.changes.delete(id);
            this.changesStore.delete(id);
         }
         return valid;
      }, []);
   }
   getInputValue(input) {
      if (input.type === 'checkbox') {
         return input.checked ? (input.value || '1') : '';
      }
      if (input.type === 'radio') {
         return input.checked ? input.value : null;
      }
      return input.value;
   }
   setBulkStatus(status) {
      if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
      let changes = {};
      let ids = [];
      this.selected.forEach(itemID => {
         changes[itemID] = {
            post_status: status,
            content: this.content
         };
         ids.push(itemID);
         this.updateItem(itemID, 'post_status', status);
      });
      let title;
      switch (status) {
@@ -996,12 +1451,12 @@
            title = window.uppercaseFirst(status)+'ing';
      }
      if (this.shouldRemoveItemUI(status)) {
         this.removeItems(Object.keys(changes));
         this.removeItems(ids);
      }
      this.selectionHandler.clearSelection();
      if (Object.keys(changes).length !== 0) {
         this.savePosts(changes, `${title} ${Object.keys(changes).length} ${this.plural}...`);
      }
      this.savePosts(`${title} ${ids.length} ${ids.length === 1 ? this.singular : this.plural}...`).then(()=>{});
   }
   /***************************************************************
    VIEW
@@ -1018,7 +1473,7 @@
            this.renderGrid(items);
            break;
         case 'table':
            this.renderTable(items);
            this.renderTable(items).then(()=>{});
            break;
         case 'list':
            this.renderList(items);
@@ -1029,13 +1484,15 @@
   updateUI() {
      if (this.ui.bulk.action) {
         let options = false;
         let hasEdit =this.ui.bulk.action.querySelector('[value="edit"]');
         if (this.status === 'trash' && hasEdit) {
         let hasEdit = this.ui.bulk.action.querySelector('[value="edit"]');
         let currentStatus = this.status;
         if (currentStatus === 'trash' && hasEdit) {
            window.removeChildren(this.ui.bulk.action);
            options = window.getTemplate('trashOptions');
         } else if (this.status !== 'trash' && !hasEdit) {
            options = window.jvbTemplates.create('trashOptions');
         } else if (currentStatus !== 'trash' && !hasEdit) {
            window.removeChildren(this.ui.bulk.action);
            options = window.getTemplate('notTrashOptions');
            options = window.jvbTemplates.create('notTrashOptions');
         }
         if (options) {
            options.querySelectorAll('option').forEach((option, index)=> {
@@ -1053,7 +1510,7 @@
   renderEmpty() {
      this.toggleTable(false);
      window.removeChildren(this.ui.grid);
      const empty = window.getTemplate('emptyState');
      const empty = window.jvbTemplates.create('emptyState');
      if (empty) {
         this.ui.grid.append(empty);
         this.a11y.announceItems(0,false,false);
@@ -1064,7 +1521,7 @@
      if (this.ui.table.selectedColumns) this.ui.table.selectedColumns.hidden = !on;
      if (on && !this.ui.table.form) {
         let table = window.getTemplate('contentTable');
         let table = window.jvbTemplates.create('contentTable');
         this.container.append(table);
         this.ui.table = window.uiFromSelectors(this.selectors.table);
         this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns);
@@ -1072,14 +1529,8 @@
      if (this.ui.table.form) {
         this.ui.table.form.hidden = !on;
         if (on) {
            this.formController.registerForm(this.ui.table.form, {
               autosave: false,
               formStatus: false,
               isTable: true
            });
         } else {
            this.formController.cleanupForm(this.ui.table.form.dataset.formId)
         if (!on){
            this.forms.clearForm(this.ui.table.form.dataset.formId)
         }
         if (this.ui.table.body) {
            window.removeChildren(this.ui.table.body);
@@ -1091,7 +1542,7 @@
      } else {
         document.removeEventListener('keydown', this.keyHandler);
      }
      if (this.isTimeline && !on) this.toggleTimelineListeners(on);
   }
   renderGrid(items) {
@@ -1101,12 +1552,11 @@
      this.ui.grid.classList.remove('list-view');
      this.ui.grid.classList.add('grid-view');
      const fragment = document.createDocumentFragment();
      items.forEach(item => {
         let card = this.renderGridItem(item);
         fragment.appendChild(card);
      });
      this.ui.grid.appendChild(fragment);
      window.chunkIt(
         items,
         (item) => this.renderGridItem(item),
         (fragment) => this.ui.grid.append(fragment)
      ).then(()=>{});
   }
   renderList(items) {
@@ -1115,227 +1565,153 @@
      this.ui.grid.classList.remove('grid-view');
      this.ui.grid.classList.add('list-view');
      const fragment = document.createDocumentFragment();
      items.forEach(item => {
         let row = this.renderListItem(item);
         fragment.append(row);
      });
      this.ui.grid.append(fragment);
      window.chunkIt(
         items,
         (item) => this.renderListItem(item),
         (fragment) => this.ui.grid.append(fragment)
      ).then(()=>{});
   }
   renderTable(items) {
   async renderTable(items) {
      this.toggleTable();
      window.removeChildren(this.ui.grid);
      let fragment = document.createDocumentFragment();
      items.forEach(item => {
         let row = this.renderTableItem(item);
         if (row) fragment.append(row);
      await window.chunkIt(
         items,
         (item) => this.renderTableItem(item),
         (fragment) => {
            if (this.ui.table.body) {
               this.ui.table.body.append(fragment);
            } else {
               this.ui.table.table.insertBefore(fragment, this.ui.table.foot);
            }
         },
         5
      );
      requestAnimationFrame(() => {
         window.jvbSelector?.scanExistingFields(this.ui.table.table);
      });
      if (this.ui.table.body) {
         this.ui.table.body.append(fragment);
      } else {
         this.ui.table.table.insertBefore(fragment, this.ui.table.foot);
      }
      if (this.isTimeline) {
         this.toggleTimelineListeners(true);
      }
   }
   /***************************************************************
   RENDER HELPERS
    ***************************************************************/
   setupItemElement(element, item, templateName) {
      if (this.items[this.view].has(item.id)) {
         return this.items[this.view].get(item.id);
      }
      element.dataset.id = item.id;
      if (item._pending) element.classList.add('pending');
      // Setup selection checkbox/label
      this.setupItemSelection(element, item);
      // Setup thumbnail
      this.setupItemThumbnail(element, item);
      // Setup action buttons
      this.setupItemActions(element, item);
      this.items[this.view].set(item.id, element);
      return element;
   }
   setupItemSelection(element, item) {
      const checkbox = element.querySelector('.select-item');
      const label = element.querySelector('.select-item + label, .select-item-label');
      if (!checkbox) return;
      checkbox.id = `select-${item.id}`;
      checkbox.value = item.id;
      checkbox.checked = this.selected.has(parseInt(item.id));
      if (label) {
         label.htmlFor = `select-${item.id}`;
      }
   }
   setupItemThumbnail(element, item) {
      const img = element.querySelector('img');
      if (!img) return;
      const thumbnail = item.images?.[item.fields?.post_thumbnail] ?? {};
      img.src = thumbnail.medium ?? '';
      img.alt = thumbnail.alt ?? '';
   }
   setupItemActions(element, item) {
      element.querySelectorAll('[data-action]').forEach(btn => {
         btn.dataset.id = item.id;
      });
   }
   renderGridItem(item) {
      if (this.items.grid.has(item.id)) {
         return this.items.grid.get(item.id);
      }
      const card = window.getTemplate('gridView');
      if (!card) return null;
      return this.setupItemElement(card, item, 'grid');
      let gridItem = window.jvbTemplates.create('gridView', item);
      this.items.set(item.id, gridItem);
      return gridItem;
   }
   renderListItem(item) {
      if (this.items.list.has(item.id)) {
         return this.items.list.get(item.id);
      }
      let row = window.getTemplate('listView');
      if (!row) return null;
      row = this.setupItemElement(row, item, 'list');
      // List-specific: populate data attributes
      row.querySelectorAll('[data-attr]').forEach(el => {
         const value = item[el.dataset.attr];
         if (value && value !== '') {
            el.textContent = value;
         } else {
            el.remove();
         }
      });
      row.querySelectorAll('[data-field]').forEach(el => {
         const value = item.fields?.[el.dataset.field];
         if (value && value !== '') {
            el.tagName === 'DIV' ? el.innerHTML = value : el.textContent = value;
         } else {
            el.remove();
         }
      });
      this.items.list.set(item.id, row);
      return row;
      let listItem = window.jvbTemplates.create('listView', item);
      this.items.set(item.id, listItem);
      return listItem;
   }
   renderTableItem(item) {
      if (this.items.table.has(item.id)) {
         return this.items.table.get(item.id);
      }
      let row = window.getTemplate('tableView');
      if (!row) return null;
      row = this.setupItemElement(row, item, 'table');
      const status = row.querySelector(`input[name="post_status"][value="${item.status}"]`);
      if (status) status.checked = true;
      if (this.isTimeline) {
         // Timeline: populate shared row, clone points
         const sharedRow = row.querySelector('tr.shared');
         if (sharedRow) {
            new window.jvbPopulate(sharedRow, item);
            this.cleanupTableRow(sharedRow);
         }
         const pointTemplate = row.querySelector('tr.timeline-point');
         if (pointTemplate && item.fields?.timeline) {
            Object.entries(item.fields.timeline).forEach(([imgId, timeline], index) => {
               const point = pointTemplate.cloneNode(true);
               point.dataset.index = index;
               point.dataset.imageId = imgId;
               new window.jvbPopulate(point, {
                  fields: timeline,
                  images: item.images,
                  taxonomies: item.taxonomies
               });
               this.cleanupTableRow(point);
               const imgData = item.images?.[timeline.post_thumbnail];
               if (imgData) {
                  point.querySelector('.field.upload')?.setAttribute('title', imgData['image-title'] || '');
               }
               row.insertBefore(point, pointTemplate);
            });
            pointTemplate.remove();
         }
      } else {
         // Standard table row
         if (this.ui.table.form?.dataset.edit !== undefined) {
            new window.jvbPopulate(row, item);
         } else {
            this.populateTableReadOnly(row, item);
         }
         this.cleanupTableRow(row);
      }
      this.items.table.set(item.id, row);
      return row;
      let tableItem = window.jvbTemplates.create('tableView', item);
      this.items.set(item.id, tableItem);
      return tableItem;
   }
   populateTableReadOnly(row, item) {
      for (const [key, value] of Object.entries(item)) {
         const col = row.querySelector(`[data-field="${key}"]`);
         if (!col) continue;
         const p = col.querySelector('p');
         if (p) {
            p.textContent = col.dataset.fieldType === 'date'
               ? window.formatTimeAgo(value)
               : value;
         }
      }
   }
   cleanupTableRow(row) {
      row.querySelectorAll('td[data-field]').forEach(field => {
         field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => {
            if (!label.closest('.radio-options')) {
               label.remove();
            }
         });
         //Remove toggle labels for true_false fields
         if (field.dataset.fieldType === 'true_false') {
            field.querySelector('.toggle-label')?.remove();
         }
         //Remove field label for checkbox/radio groups
         if (['checkbox','radio','select'].includes(field.dataset.fieldType)) {
            field.querySelector('.label')?.remove();
         }
      });
   }
   toggleColumn(column, show) {
      this.ui.table.table.querySelectorAll(`.${column}`).forEach(el =>{
         el.hidden = !show;
      });
   }
   /***************************************************************
    UPLOAD GROUP SUPPORT
    Handles:
      - immediate UI feedback once the uploaded groups are sent to server
   ***************************************************************/
   handleGroupsUploaded(data) {
      const { posts, fieldId } = data;
      let uploader = window.jvbUploads;
      let field = uploader.fields.get(fieldId);
      let added = [];
      posts.forEach(post => {
         const placeholderPost = {
            id: post.groupId,
            title: post.fields.post_title || `New ${this.singular}`,
            status: 'draft',
            date: new Date().toISOString(),
            modified: new Date().toISOString(),
            thumbnail: null,
            icon: this.content,
            taxonomies: {},
            fields: post.fields,
            images: {},
         };
         post.images.forEach((uploadId, index) => {
            let id = uploadId['upload_id'];
            if (index === 0) {
               placeholderPost.fields['post_thumbnail'] = uploadId;
            }
            let upload = uploader.stores.uploads.get(id);
            if (upload) {
               placeholderPost.images[id] = {
                  'image-alt-text': '',
                  'image-caption': '',
                  'image-title': upload.fields.originalName,
                  medium: uploader.createPreviewUrl(uploader.formatFile(upload))
               };
            }
         });
         //
         // // Add to store (won't persist since it's a fake ID)
         // this.store.data.set(post.groupId, placeholderPost);
         //
         //
         // // Render immediately
         // let element;
         // switch (this.view) {
         //    case 'grid':
         //       element = this.renderGridItem(placeholderPost);
         //       this.ui.grid.prepend(element);
         //       break;
         //    case 'list':
         //       element = this.renderListItem(placeholderPost);
         //       this.ui.grid.prepend(element);
         //       break;
         //    case 'table':
         //       element = this.renderTableItem(placeholderPost);
         //       if (this.ui.table.body) {
         //          this.ui.table.body.prepend(element);
         //       }
         //       break;
         // }
         // element.classList.add('uploading');
         added.push(placeholderPost);
      });
      this.store.saveMany(added).then(() => this.render());
      this.a11y.announce(`${posts.length} ${posts.length === 1 ? this.singular : this.plural} created. Waiting for server confirmation...`);
   }
   handleGroupMappings(mappings) {
      // mappings = { "group_abc123": 456, "group_def456": 789 }
      for (const [groupId, postId] of Object.entries(mappings)) {
         // Get any pending changes for this temp item
         let changes = {};
         if (this.changes.has(groupId)) {
            changes = this.changes.get(groupId);
            this.changes.delete(groupId);
         }
         let storedChanges = this.changesStore.get(groupId)??{};
         if (changes.size > 0 || storedChanges.size > 0) {
            changes = window.deepMerge(storedChanges, changes);
            this.changes.set(postId, changes);
            this.scheduleBackup();
         }
      }
   }
   /***************************************************************
    UTILITY
   ***************************************************************/
   shouldRemoveItemUI(newStatus) {
@@ -1343,15 +1719,11 @@
         || newStatus !== this.store.filters.status;
   }
   removeItems(items) {
      let delay = 0;
      items.forEach(itemId => {
         setTimeout(() => {
            let item = this.items[this.view].get(itemId);
            if (item) {
               window.fade(item, false);
            }
         }, delay);
         delay += 50;
         if (this.items.has(itemId)) {
            let item = this.items.get(itemId);
            if (item) window.fade(item, false);
         }
      });
   }
@@ -1371,9 +1743,13 @@
   setFilter(name, value) {
      if (!this.allowedFilters.includes(name)) return;
      this.cache.set(name, value);
      if (name === 'status') this.status = value;
      if (name === 'orderby') this.orderby = value;
      if (name === 'order') this.order = value;
      let el = this.findFilterEl(name, value);
      this.setElValue(el, value);
      //TODO: If we set the element to checked, does that automatically call the change listener, which then also sets the store filter and cache?
      this.store.setFilter(name, value);
   }
@@ -1445,19 +1821,52 @@
   /***************************************************************
    CLEANUP
   ***************************************************************/
   resetForm(form) {
      // Clear text inputs, textareas
      form.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach(input => {
         input.value = '';
      });
      // Uncheck checkboxes and radios
      form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
         input.checked = false;
      });
      // Reset selects to first option
      form.querySelectorAll('select').forEach(select => {
         select.selectedIndex = 0;
      });
      // Clear any selected items displays
      form.querySelectorAll('.selected-items').forEach(container => {
         window.removeChildren(container);
      });
      // Clear upload previews
      form.querySelectorAll('.item-grid.preview').forEach(grid => {
         window.removeChildren(grid);
      });
   }
   destroy() {
      window.debouncer.cancel(`changes-${this.content}`);
      if (this.changes.size > 0) {
         this.changesStore.saveMany(this.changes).then(()=>{});
         this.changes.clear();
      }
      if (this.timelineSortables) {
         this.timelineSortables.forEach(sortable => sortable.destroy());
         this.timelineSortables = [];
      }
      for (let [name, modal] of Object.entries(this.ui.modals)) {
         if (modal.form) {
            modal.form.removeEventListener('submit', this.submitHandler);
         }
      }
      document.removeEventListener('click', this.clickHandler);
      document.removeEventListener('change', this.changeHandler);
      if (this.ui.filters.search) {
         this.ui.filters.search.removeEventListener('input', this.handleInput);
      }
      for (let map of Object.values(this.items)) {
         map.clear();
      }
   }
}