Jake Vanderwerf
2026-05-12 457c329237f97069063e641b10f384a52d584f21
assets/js/concise/CRUD.js
@@ -52,8 +52,9 @@
         if (refs.trash) refs.trash.dataset.id = data.id;
      };
      const imageSetup = function(el, refs, data) {
         if (data?.fields?.post_thumbnail) {
            const thumbnail = data.images[data.fields.post_thumbnail] ?? {};
         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??'';
         }
@@ -323,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);
@@ -339,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() {
@@ -411,6 +431,7 @@
                  { name: 'modified', keyPath: 'modified'},
                  { name: 'title', keyPath: 'title'},
               ],
               isAuth: true,
               filters: filters,
               ignore: ['content', 'user'],
               TTL: 60 * 60 * 1000,       //1 hour cache
@@ -496,44 +517,92 @@
            && data.status === 'completed') {
            this.store.clearCache();
         }
         console.log('CRUD.js queue subscription');
         console.log(event, data);
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.endpoint === 'uploads/groups') {
            console.log('Grouped Uploads completed');
            if (data.result && data.result.group_mappings) {
               console.log('Handling group mapping from queue response');
               this.handleGroupMappings(data.result.group_mappings);
            }
            console.log('Cleared local cache. Refresh to see changes');
            this.store.clearCache();
         }
         if (event === 'operation-status'
            && data.status === 'completed'
            && data.type === 'content_update') {
            console.log('Cleared local cache. Refresh to see changes');
            this.store.clearCache();
            // Check for result data (from ContentExecutor)
            if (!data.result || !data.result.posts) {
               console.warn('Content update completed but no result.posts', data);
            if (!data.result || !data.result.success || !data.result.errors)
            {
               console.warn('Content update completed but no results', data);
               return;
            }
            // Get successfully processed post IDs
            const successfulIds = Object.keys(data.result.posts);
            if (successfulIds.length === 0) {
            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;
            }
            // Clear from both persistent and in-memory storage
            this.changesStore.deleteMany(successfulIds);
            successfulIds.forEach(id => this.changes.delete(id));
            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 = {
@@ -596,7 +665,7 @@
            default: 'closed',
         },
         showUploader: {
            element: this.ui.uploader,
            element: this.ui.uploader.details,
            default: 'open'
         }
      };
@@ -639,13 +708,62 @@
      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;
      }
      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) {
      // Early bailout - target must be in an item or be a filter
@@ -744,27 +862,59 @@
   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 => {
         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);
      });
   }
   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();
      //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')) {
      if (typeof itemId === 'number' || !String(itemId).includes('group')) {
         this.scheduleSave();
      }
   }
@@ -917,7 +1067,7 @@
         return;
      }
      if (e.target.matches(this.selectors.buttons.create)) {
      if (e.target.matches(this.selectors.buttons.create) || e.target.closest(this.selectors.buttons.create)) {
         this.openCreateModal();
      }
   }
@@ -925,8 +1075,8 @@
         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) {
@@ -1134,16 +1284,30 @@
      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.forms.registerForm(this.ui.modals.edit.form, {cache: false});
      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);
      this.isPopulating = false;
      //For quill/taxonomy selector's async setups
      requestAnimationFrame(() => {
         requestAnimationFrame(() => {
            this.isPopulating = false;
         });
      });
      this.modals.edit.handleOpen();
   }
   openBulkEditModal() {
      window.removeChildren(this.ui.modals.bulkEdit.selected);
@@ -1169,11 +1333,15 @@
      }
      this.modals.bulkEdit.handleOpen();
      this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
      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;
      requestAnimationFrame(() => {
         requestAnimationFrame(() => {
            this.isPopulating = false;
         });
      });
   }
   /*****************************************************************
@@ -1185,8 +1353,11 @@
         this.cancelBackup();
         await this.handleBackup();
      }
      const changes = await this.changesStore.getAll();
      console.log('Saving Changes: ', changes);
      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 === '') {
@@ -1198,8 +1369,6 @@
      changes.forEach(change => {
         let itemId = change.id;
         // Create a new object without the id field (don't mutate original!)
         const { id, ...changeWithoutId } = change;
         allChanges[itemId] = changeWithoutId;
@@ -1227,6 +1396,44 @@
      this.queue.addToQueue(operation);
   }
   /**
    * 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);
         if (!stored) {
            valid.push(change);
            return valid;
         }
         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;
      }, []);
   }
   setBulkStatus(status) {
      if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
@@ -1423,10 +1630,6 @@
      let uploader = window.jvbUploads;
      let field = uploader.fields.get(fieldId);
      console.log(posts);
      console.log(field);
      let added = [];
      posts.forEach(post => {
         const placeholderPost = {
@@ -1491,7 +1694,6 @@
   }
   handleGroupMappings(mappings) {
      console.log('[CRUD] Applying group mappings:', mappings);
      // mappings = { "group_abc123": 456, "group_def456": 789 }
      for (const [groupId, postId] of Object.entries(mappings)) {