Jake Vanderwerf
2026-02-11 4074c1ef4775f1f45f5de16b1afab77a3d586427
assets/js/concise/CRUD.js
@@ -12,6 +12,7 @@
      this.error = window.jvbError;
      this.populate = window.jvbPopulate;
      this.cache = new window.jvbCache(this.content);
      this.uploadedFields = new Set(); //tracks which upload fields are currently uploading; so don't send any of these changes to server
      this.activeItem = null;
      this.isTimeline = false;
@@ -42,8 +43,8 @@
      const baseSetup = (el, refs, data) => {
         el.dataset.itemId = data.id;
         window.prefixInput(refs.checkbox, `select-${data.id}`, true);
         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}`;
@@ -131,7 +132,8 @@
            baseSetup(el, refs, data);
            manyRefs?.inputs?.forEach(el => {
               window.prefixInput(el, `${data.id}-`);
               let wrapper = el.closest('[data-field]');
               window.prefixInput(el, `${data.id}-`, wrapper);
            });
            manyRefs?.status?.forEach(el => {
@@ -143,7 +145,8 @@
            if (crud.isTimeline) {
               if (refs.sharedRow) {
                  refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => {
                     window.prefixInput(input, `${data.id}-`);
                     let wrapper = input.closest('[data-field]');
                     window.prefixInput(input, `${data.id}-`, wrapper);
                  });
                  crud.populate.populate(refs.sharedRow, data);
@@ -164,7 +167,8 @@
                     point.dataset.itemId = timeline.id;
                     point.querySelectorAll('input,select,textarea').forEach(input => {
                        window.prefixInput(input, `${timeline.id}-`);
                        let wrapper = input.closest('[data-field]');
                        window.prefixInput(input, `${timeline.id}-`, wrapper);
                     });
                     crud.populate.populate(point, {
@@ -185,7 +189,8 @@
               if (crud.ui.table.form?.dataset.edit !== undefined) {
                  // Non-timeline: prefix all inputs normally
                  manyRefs?.inputs?.forEach(input => {
                     window.prefixInput(input, `${data.id}-`);
                     let wrapper = input.closest('[data-field]');
                     window.prefixInput(input, `${data.id}-`, wrapper);
                  });
                  manyRefs?.status?.forEach(el => {
@@ -346,6 +351,21 @@
                  });
               }
            }
            if (event === 'sent-to-queue' && data.field) {
               const fieldName = data.field.config.name;
               const itemId = data.field.config.itemID;
               if (itemId && fieldName) {
                  this.uploadedFields.add(`${itemId}_${fieldName}`);
                  if (this.changes.has(itemId)) {
                     delete this.changes.get(itemId)[fieldName];
                  }
               }
            }
            // if (event === 'upload_complete') {
            //    this.uploadedFields.delete(`${data['item_id']}_${data['field']}`);
            // }
         });
      }
      initModals() {
@@ -362,11 +382,17 @@
                        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':
@@ -392,7 +418,7 @@
               keyPath: 'id',
               endpoint: this.endpoint??'content', //for taxonomy stores
               headers: {
                  'action_nonce': window.auth.getNonce('dash'),
                  'X-Action-Nonce': window.auth.getNonce('dash'),
               },
               indexes: [
                  {name: 'id', keyPath: 'id'},
@@ -472,45 +498,51 @@
      //    }
      // });
      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)
            && event === 'operation-status'
            && data.status === 'completed') {
            this.store.clearCache();
         }
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.endpoint === 'content'
            && Object.keys(data.data?.posts??{}).length > 0) {
            && data.endpoint === 'uploads/groups') {
            if (data.result && data.result.group_mappings) {
               this.handleGroupMappings(data.result.group_mappings);
            }
            this.store.clearCache();
            let ids = Object.keys(data.data.posts);
            let storedChanges = this.changesStore.getMany(ids);
         }
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.type === 'content_update') {
            this.store.clearCache();
            this.changesStore.deleteMany(ids);
            for (let id of ids) {
               let stored = storedChanges.filter(change => change.id === id)[0]??false;
               let sentChanges = data.data.posts[id];
               let remainingChanges = {};
               for (let [key, value] of Object.entries(sentChanges)) {
                  if (stored && !Object.hasOwn(stored, key)) continue;
                  if (stored[key] === value) {
                     delete stored[key];
                  }
                  remainingChanges[key] = value;
               }
               if (Object.keys(remainingChanges).length > 0) {
                  remainingChanges['id'] = id;
                  remainingChanges['content'] = this.content;
                  this.changes.set(id, remainingChanges);
               }
            // Check for result data (from ContentExecutor)
            if (!data.result || !data.result.posts) {
               console.warn('Content update completed but no result.posts', data);
               return;
            }
            if (Object.values(this.changes).length > 0) {
               this.scheduleBackup();
            // Get successfully processed post IDs
            const successfulIds = Object.keys(data.result.posts);
            if (successfulIds.length === 0) {
               return;
            }
            // Clear from both persistent and in-memory storage
            this.changesStore.deleteMany(successfulIds);
            successfulIds.forEach(id => this.changes.delete(id));
         }
      });
@@ -620,13 +652,61 @@
      const form = e.target;
      const modal = form.closest('dialog');
      if (!modal) return;
      let title = `Saving changes for multiple ${this.plural}`;
      if (modal.classList.contains('edit')) {
         title = 'Saving your edits...';
      } else if (modal.classList.contains('create')) {
         title = `Creating your new ${this.singular}`;
      if (modal.classList.contains('create')) {
         this.handleCreateSubmit(modal);
         return;
      }
      this.savePosts(title,false);
      let title = `Saving changes for multiple ${this.plural}`;
      this.scheduleSave(0);
   }
   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) {
      // Early bailout - target must be in an item or be a filter
@@ -706,11 +786,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
@@ -718,18 +795,10 @@
         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(
            `Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,
            false
         ).then(()=>{});
      }
      this.savePosts(`Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,).then(()=> {});
      this.selectionHandler.clearSelection();
   }
@@ -740,11 +809,13 @@
      if (!item) return;
      item.dataset.itemId.split(',').forEach(itemId => {
         let field = this.forms.getField(e.target);
         if (['repeater', 'tag-list'].includes(field.dataset.fieldType)) {
            return;
         }
         let name = field.dataset.field;
         let value = this.forms.getFieldValue(e.target);
         this.updateItem(itemId, name, value);
      });
      this.savePosts('', true).then(()=>{});
   }
   updateItem(itemId, name, value) {
      if (!this.changes.has(itemId)) {
@@ -752,20 +823,72 @@
      }
      this.changes.get(itemId)[name] = value;
      for (const key of this.uploadedFields) {
         const [itemId, fieldName] = key.split('_');
         if (this.changes.has(itemId)) {
            delete this.changes.get(itemId)[fieldName];
         }
      }
      // Don't schedule if only base keys remain
      const change = this.changes.get(itemId);
      const realKeys = Object.keys(change).filter(k => k !== 'id' && k !== 'content');
      if (realKeys.length === 0) {
         this.changes.delete(itemId);
         return;
      }
      this.scheduleBackup();
      //Only send actual itemIds to server. If this is a recently uploaded item, just store changes for now
      if (typeof itemId === 'number' || !itemId.includes('group')) {
         this.scheduleSave();
      }
   }
   scheduleBackup() {
      window.debouncer.schedule(
         `changes-${this.content}`,
         async () => {
            if (this.changes.size > 0) {
               await this.changesStore.saveMany(this.changes);
               this.changes.clear();
               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;
@@ -875,7 +998,6 @@
      }
   }
      openCreateModal(){
         this.ui.modals.create.form.reset();
         this.forms.registerForm(this.ui.modals.create.form,{
            cache: false,
         });
@@ -899,9 +1021,18 @@
               }
               break;
            case 'trash':
               this.updateItem(itemID, 'post_status', 'trash');
               window.fade(button.closest('.item'), false);
               this.savePosts(`Sending ${this.singular} to trash...`).then(()=>{});
               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) {
@@ -921,7 +1052,7 @@
         }
      }
      handleBulkDelete() {
         let isTrash = this.store.filters.status === 'trash';
         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);
@@ -1082,9 +1213,8 @@
      this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`;
      this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`;
      this.ui.modals.edit.form.reset();
      this.forms.registerForm(this.ui.modals.edit.form, {cache: false});
      this.forms.registerForm(this.ui.modals.edit.form, {cache: false,
         autoUpload: true,});
      this.isPopulating = true;
      this.populate.populate(this.ui.modals.edit.form, item);
@@ -1118,9 +1248,9 @@
      this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
      // this.isPopulating = true;
      // this.populate.populate(this.ui.modals.edit.form, item);
      // this.isPopulating = false;
      this.isPopulating = true;
      this.populate.populate(this.ui.modals.edit.form, item);
      this.isPopulating = false;
   }
   /*****************************************************************
@@ -1128,9 +1258,11 @@
   *****************************************************************/
   async savePosts(title = '', delay = false) {
      const memoryChanges = Array.from(this.changes.values());
      const storedChanges = await this.changesStore.getAll();
      const changes = window.deepMerge(storedChanges, memoryChanges);
      if (this.changes.size > 0) {
         this.cancelBackup();
         await this.handleBackup();
      }
      const changes = await this.changesStore.getAll();
      if (changes.length === 0) return;
      if (title === '') {
@@ -1142,10 +1274,17 @@
      changes.forEach(change => {
         let itemId = change.id;
         const { id, content, ...fields } = change;
         // Create a new object without the id field (don't mutate original!)
         const { id, ...changeWithoutId } = change;
         allChanges[itemId] = changeWithoutId;
         // Filter out uploaded fields
         for (const key of this.uploadedFields) {
            const [uid, fieldName] = key.split('_');
            if (uid === itemId) delete fields[fieldName];
         }
         if (Object.keys(fields).length > 0) {
            allChanges[itemId] = { content, ...fields };
         }
         if (change.post_status && this.shouldRemoveItemUI(change.post_status)) {
            remove.push(itemId);
@@ -1159,7 +1298,7 @@
      let operation = {
         endpoint: this.endpoint,
         headers: {
            'action_nonce': window.auth.getNonce('dash'),
            'X-Action-Nonce': window.auth.getNonce('dash'),
         },
         data: {
            posts: allChanges,
@@ -1221,11 +1360,13 @@
   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.jvbTemplates.create('trashOptions');
         } else if (this.status !== 'trash' && !hasEdit) {
         } else if (currentStatus !== 'trash' && !hasEdit) {
            window.removeChildren(this.ui.bulk.action);
            options = window.jvbTemplates.create('notTrashOptions');
         }
@@ -1356,6 +1497,97 @@
      });
   }
   /***************************************************************
    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) {
@@ -1387,9 +1619,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);
   }
@@ -1461,6 +1697,32 @@
   /***************************************************************
    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) {