Jake Vanderwerf
2026-02-17 a24a06002081ad71a78ffeff9072725ba39cf121
assets/js/concise/CRUD.js
@@ -350,6 +350,16 @@
                  });
               }
            }
            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() {
@@ -402,7 +412,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'},
@@ -482,23 +492,33 @@
      //    }
      // });
      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 === 'uploads/groups') {
            console.log('Cleared local cache. Refresh to see changes');
            if (data.result && data.result.group_mappings) {
               this.handleGroupMappings(data.result.group_mappings);
            }
            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)
@@ -508,9 +528,7 @@
            }
            // Get successfully processed post IDs
            const successfulIds = Object.keys(data.result.posts).filter(id => {
               return data.result.posts[id]?.success === true;
            });
            const successfulIds = Object.keys(data.result.posts);
            if (successfulIds.length === 0) {
               return;
@@ -628,14 +646,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);
   }
   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
      const inItem = e.target.closest('[data-item-id]');
@@ -732,24 +798,52 @@
   }
   handleItemUpdate(e) {
      let item = window.targetCheck(e, '[data-item-id]');
      let item = window.targetCheck(e, '[data-item-id]');
      if (!item) return;
      let field = e.target.closest('[data-field-type="repeater"],[data-field-type="tag-list"],[data-field]');
      let name = field.dataset.field;
      let value = this.forms.getFieldValue(e.target);
      item.dataset.itemId.split(',').forEach(itemId => {
         let field = this.forms.getField(e.target);
         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;
      }
      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();
      this.scheduleSave();
      if (typeof itemId === 'number' || !String(itemId).includes('group')) {
         this.scheduleSave();
      }
   }
   scheduleBackup() {
      window.debouncer.schedule(
@@ -762,7 +856,6 @@
         2000
      );
   }
   cancelBackup() {
      window.debouncer.cancel(`changes-${this.content}`);
   }
@@ -909,8 +1002,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) {
@@ -1121,11 +1214,17 @@
      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.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);
      this.isPopulating = false;
      //For quill/taxonomy selector's async setups
      requestAnimationFrame(() => {
         requestAnimationFrame(() => {
            this.isPopulating = false;
         });
      });
      this.modals.edit.handleOpen();
   }
@@ -1153,11 +1252,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;
         });
      });
   }
   /*****************************************************************
@@ -1169,8 +1272,11 @@
         this.cancelBackup();
         await this.handleBackup();
      }
      const changes = await this.changesStore.getAll();
      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 === '') {
@@ -1182,8 +1288,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;
@@ -1199,7 +1303,7 @@
      let operation = {
         endpoint: this.endpoint,
         headers: {
            'action_nonce': window.auth.getNonce('dash'),
            'X-Action-Nonce': window.auth.getNonce('dash'),
         },
         data: {
            posts: allChanges,
@@ -1211,6 +1315,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;
@@ -1398,6 +1540,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) {