Jake Vanderwerf
2026-01-04 a81f7043fc44382775f9afac48e4c7a651e7ac6c
assets/js/concise/TaxonomySelector.js
@@ -12,27 +12,34 @@
      this.isInitializing = true;
      this.taxonomiesToFetch = new Set();
      this.store = new window.jvbStore({
         name: `taxonomies`,
         storeName: `terms`,
         keyPath: 'id',
         showLoading: false,
         indexes: [
            {name: 'taxonomy', keyPath: 'taxonomy'},
            {name: 'parent', keyPath: 'parent'},
            {name: 'slug', keyPath: 'slug', unique: true},
            {name: 'count', keyPath: 'count'},
         ],
         endpoint: 'terms',
         TTL: 7200000, //2 hours
         filters: {
            taxonomy: '',
            page: 1,
            search: '',
            parent: 0
         },
         required: 'taxonomy'
      });
      this.triggers = new Set(['.taxonomy-toggle']);
      this.subscribers = new Set();
      const store = window.jvbStore.register(
         'taxonomies',
         {
            storeName: `terms`,
            keyPath: 'id',
            showLoading: false,
            indexes: [
               {name: 'taxonomy', keyPath: 'taxonomy'},
               {name: 'parent', keyPath: 'parent'},
               {name: 'slug', keyPath: 'slug', unique: true},
               {name: 'count', keyPath: 'count'},
            ],
            endpoint: 'terms',
            TTL: 2 * 60 * 1000, //2 hours
            filters: {
               taxonomy: '',
               page: 1,
               search: '',
               parent: 0
            },
            required: 'taxonomy',
            delayFetch: true,
         });
      this.store = store.terms;
      // Central field management
      this.fields = new Map();
@@ -43,7 +50,6 @@
      this.currentConfig = null;
      this.currentSingular = null;
      this.currentPlural = null;
      this.activeStore = null;
      // Modal state
      this.disabled = false;
@@ -64,6 +70,9 @@
      this.scanExistingFields();
      this.initGlobalListeners();
      if (this.hasAutocomplete && window.jvbTaxCreator) {
         this.creator = new window.jvbTaxCreator(this);
      }
      this.store.subscribe(this.handleStoreEvent.bind(this));
      // Complete initialization
      this.isInitializing = false;
@@ -76,11 +85,28 @@
   handleStoreEvent(event, data) {
      switch (event) {
         case 'data-loaded':
            // Only render if modal is open OR if it's an autocomplete request
            const taxonomy = this.store.filters.taxonomy;
            // Handle batch taxonomy loading (comma-separated)
            if (taxonomy?.includes(',')) {
               this.handleBatchDataLoaded(taxonomy, data);
            }
            // Update button states for this taxonomy (or taxonomies)
            if (taxonomy) {
               // Handle comma-separated taxonomies from batch fetch
               const taxonomies = taxonomy.includes(',')
                  ? taxonomy.split(',').map(t => t.trim())
                  : [taxonomy];
               taxonomies.forEach(tax => {
                  this.updateFieldsForTaxonomy(tax);
               });
            }
            // Only render if modal is open OR autocomplete active
            if (this.modal?.open) {
               this.handleTermsLoaded(data);
            }
            // Handle autocomplete results
            if (this.isAutocompleteActive && this.activeField) {
               const field = this.fields.get(this.activeField);
               const terms = data.data?.items || [];
@@ -91,7 +117,6 @@
            break;
         case 'filters-changed':
            // Modal UI updates happen here if needed
            if (this.modal?.open) {
               this.showLoading();
            }
@@ -112,10 +137,12 @@
    */
   handleTermsLoaded(data) {
      this.hideLoading();
      const terms = data.data?.items || [];
      const pagination = data.data?.pagination || {};
      const terms = this.store.getFiltered();  // Use getFiltered() instead of getFilteredItems()
      const response = this.store.lastResponse?.page || {};
      const isSearch = data.filters?.search && data.filters.search.length > 0;
      const append = data.filters?.page > 1;
      const append = response.page > 1;
      this.notify('terms-loaded', { terms, filters: data.filters });
      if (terms.length === 0) {
         if (!append) {
@@ -124,9 +151,9 @@
         this.observer.unobserve(this.ui.sentinel);
      } else {
         this.renderTerms(terms, append, isSearch);
         this.currentTerms = terms;
         // Handle pagination
         if (pagination.has_more) {
         if (response.has_more) {
            this.observer.observe(this.ui.sentinel);
         } else {
            this.observer.unobserve(this.ui.sentinel);
@@ -154,28 +181,45 @@
      }
   }
   /**
    * Check if taxonomy has terms and update button states
    */
   updateFieldButtonState(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      // Check store for items of this specific taxonomy
      const hasTerms = Array.from(this.store.data.values())
         .some(term => term.taxonomy === field.taxonomy);
      if (field.toggle) {
         field.toggle.disabled = !hasTerms && !field.canCreate;
         field.toggle.title = !hasTerms
            ? `No ${this.getSingular(field.taxonomy)} available`
            : `Select ${this.getPlural(field.taxonomy)}`;
      }
   }
   /**
    * Update fields when taxonomy items are updated
    */
   updateFieldsForTaxonomy(taxonomy, items) {
      this.fields.forEach(field => {
         if (field.taxonomy === taxonomy && field.selectedTerms.size > 0) {
            // Update display with fresh term data
            field.selectedTerms.forEach(termId => {
               const term = items.find(item => item.id === termId);
               if (term) {
                  const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`);
                  if (selectedItem) {
                     selectedItem.dataset.path = term.path;
                     selectedItem.querySelector('span').textContent = term.path;
                  }
               }
            });
         }
   updateFieldsForTaxonomy(taxonomy) {
      this.getFieldsForTaxonomy(taxonomy).forEach(field => {
         this.updateFieldButtonState(field.id);
      });
   }
   /**
    * Get fields for a specific taxonomy
    */
   getFieldsForTaxonomy(taxonomy) {
      return Array.from(this.fields.values())
         .filter(field => field.taxonomy === taxonomy);
   }
   /**
    * Scan page for existing taxonomy fields and register them
    */
   scanExistingFields(container = null) {
@@ -203,14 +247,18 @@
   registerField(field, options = {}) {
      let input = field.querySelector('input[type=hidden]');
      if (!input) {
         return;
         return false;
      }
      if (!('fieldId' in field.dataset)) {
         field.dataset.fieldId = this.createFieldId(field);
      }
      let fieldId = field.dataset.fieldId;
      let button = field.querySelector('button.taxonomy-toggle');
      let button = (Object.hasOwn(options, 'button')) ? options.button : field.querySelector('button.taxonomy-toggle');
      if (Object.hasOwn(options, 'buttonSelector')) {
         this.triggers.add(options.buttonSelector);
      }
      let config = {
         id: fieldId,
@@ -226,7 +274,7 @@
         isRequired: 'required' in button.dataset,
         selectedTerms: new Set(),
         toggle: button,
         selectedContainer: field.querySelector('.selected-items'),
         selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'),
         ...options
      };
@@ -244,13 +292,19 @@
         selectedIds.forEach(id => config.selectedTerms.add(id));
      }
      if (Object.hasOwn(options, 'selectedItems')) {
         options.selectedItems.forEach(id => {
            config.selectedTerms.add(id);
         });
      }
      this.fields.set(fieldId, config);
      // Ensure store exists for this taxonomy
      if (this.isInitializing) {
         this.taxonomiesToFetch.add(config.taxonomy);
      } else {
         this.store.setFilter('taxonomy', config.taxonomy);
         // this.store.setFilter('taxonomy', config.taxonomy);
      }
      // Initialize display for any pre-selected values
@@ -262,32 +316,43 @@
   }
   /**
    * Batch fetch all unique taxonomies collected during init
    * Register a filter button (simplified registration for feed blocks)
    */
   async batchFetchTaxonomies() {
      if (this.taxonomiesToFetch.size === 0) return;
   registerFilterButton(button, options = {}) {
      const fieldId = this.createFieldId(button);
      button.dataset.fieldId = fieldId;
      const taxonomies = Array.from(this.taxonomiesToFetch);
      this.taxonomiesToFetch.clear();
      console.log(`Batch fetching ${taxonomies.length} unique taxonomies:`, taxonomies);
      // Fetch each taxonomy sequentially (cache will prevent duplicates)
      for (const taxonomy of taxonomies) {
         await this.store.setFilters({
            taxonomy: taxonomy,
            page: 1,
            search: '',
            parent: 0
         });
      if (options.buttonSelector) {
         this.triggers.add(options.buttonSelector);
      }
      // Now initialize field displays
      this.fields.forEach((config, fieldId) => {
         if (config.selectedTerms.size > 0) {
            this.initFieldDisplay(fieldId);
         }
      });
      const config = {
         id: fieldId,
         input: null,
         container: options.container || button.closest('.filters') || button.parentElement,
         taxonomy: button.dataset.taxonomy,
         name: `filter_${button.dataset.taxonomy}`,
         maxSelection: parseInt(button.dataset.max) || 0,
         canSearch: 'search' in button.dataset,
         hasAutocomplete: false,
         canCreate: false,
         isRequired: false,
         selectedTerms: new Set(options.selectedItems || []),
         toggle: button,
         selectedContainer: options.selected || null,
         isFilterMode: true,
         ...options
      };
      this.fields.set(fieldId, config);
      if (this.isInitializing) {
         this.taxonomiesToFetch.add(config.taxonomy);
      } else {
         this.store.setFilter('taxonomy', config.taxonomy);
      }
      return fieldId;
   }
   /**
@@ -306,21 +371,13 @@
      if (!field || field.selectedTerms.size === 0) return;
      const selectedIds = Array.from(field.selectedTerms);
      const cachedTerms = [];
      selectedIds.forEach(termId => {
         const term = this.store.data.get(termId);
         const term = this.store.get(termId);  // Changed from getItem
         if (term) {
            cachedTerms.push(term);
            this.addTermToDisplay(fieldId, term.id, term.name, term.path);
         }
      });
      // Display all found terms
      cachedTerms.forEach(term => {
         this.addTermToDisplay(fieldId, term.id, term.name, term.path);
      });
      // Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
   }
   /**
@@ -346,7 +403,6 @@
      this.modalInstance.subscribe((event, data) => {
         switch (event) {
            case 'modal-open':
               console.log(data);
               this.openModal(data);
               break;
            case 'modal-close':
@@ -401,7 +457,7 @@
      // Initialize intersection observer for infinite scroll
      this.observer = new IntersectionObserver((entries) => {
         entries.forEach(entry => {
            if (entry.isIntersecting && this.activeStore) {
            if (entry.isIntersecting) {
               this.loadMoreTerms();
            }
         });
@@ -424,10 +480,29 @@
   initAutocomplete()
   {
      console.log('Autocomplete init');
      this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
      this.autocompleteHandler = (e) => {
         window.debouncer.schedule(
            'taxonomy-autocomplete',
            () => this.handleAutocomplete(e),
            300
         );
      };
      document.addEventListener('input', this.autocompleteHandler);
      document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
      // Preload taxonomy data on focus
      document.addEventListener('focus', (e) => {
         if (!('autocomplete' in e.target.dataset)) {
            return;
         }
         const fieldId = this.getFieldId(e.target);
         const field = this.fields.get(fieldId);
         if (!field) return;
         // Preload this taxonomy's data
         this.preloadTaxonomy(field.taxonomy);
      }, true); // Use capture phase
   }
   /**
@@ -435,7 +510,8 @@
    */
   handleClick(e) {
      // Handle taxonomy toggle buttons
      const toggleButton = window.targetCheck(e, '.taxonomy-toggle');
      const toggleButton = window.targetCheck(e, Array.from(this.triggers));
      if (toggleButton) {
         e.preventDefault();
         this.handleToggleClick(toggleButton);
@@ -496,65 +572,53 @@
            return;
         }
         this.setActiveField(fieldId);
         this.modalInstance.handleOpen();
         this.setActiveField(fieldId, true);
      } catch (error) {
         console.error('Error handling toggle click:', error);
         this.error?.handleError(error, {
            component: 'TaxonomySelector',
            action: 'handleToggleClick'
         });
         if (this.error?.log) {
            this.error.log(error, {
               component: 'TaxonomySelector',
               action: 'handleToggleClick'
            });
         }
      }
   }
   /**
    * Set the active field for modal operations
    */
   setActiveField(fieldId) {
   setActiveField(fieldId, openModal = false) {
      this.activeField = fieldId;
      this.currentConfig = this.fields.get(fieldId);
      this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
      this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
      this.currentSingular = this.getSingular(this.currentConfig.taxonomy);
      this.currentPlural = this.getPlural(this.currentConfig.taxonomy);
      // Get or create store for this taxonomy
      if (openModal) {
         this.modalInstance.handleOpen();
      }
      // Set taxonomy filter - store handles the rest
      this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
      // Clear modal selection state
      this.selectedTerms.clear();
      // Copy field's current selections to modal state
      if (this.currentConfig.selectedTerms) {
         let termsToFetch = [];
         this.currentConfig.selectedTerms.forEach(termId => {
            const term = this.store.getItem(termId);
            if (term) {
               this.selectedTerms.set(termId, {
                  id: termId,
                  name: term.name,
                  path: term.path
               });
            } else {
               termsToFetch.push(termId);
            }
         });
         if (termsToFetch.length > 0) {
            let terms = this.fetchSpecificTerms(termsToFetch);
            terms.forEach(term => {
               this.selectedTerms.set(term.id, {
                  id: term.id,
                  name: term.name,
                  path: term.path
               });
      this.currentConfig.selectedTerms.forEach(termId => {
         const term = this.store.get(termId);
         if (term) {
            this.selectedTerms.set(termId, {
               id: termId,
               name: term.name,
               path: term.path
            });
         }
      }
      });
   }
   fetchSpecificTerms(terms) {
      return [];
   }
   /**
    * Handle clicks within modal
@@ -627,43 +691,70 @@
         filterCallback: callback // Store the callback
      });
      this.setActiveField(virtualFieldId);
      this.setActiveField(virtualFieldId, true);
      this.modalInstance.handleOpen();
   }
   /**
    * Open modal and initialize
    */
   openModal(config) {
      this.activeField = config.fieldId;
      this.currentConfig = config;
   openModal() {
      if (!this.currentConfig) {
         console.error('No active field set');
         return;
      }
      // Initialize creator if available
      if (config.canCreate && 'jvbTaxCreator' in window) {
      if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
         this.creator = new window.jvbTaxCreator(this);
      } else if (this.creator) {
         delete this.creator;
      }
      // Load selected terms into modal state
      this.selectedTerms = new Set(config.selectedTerms);
      // Update modal UI
      this.updateModalForTaxonomy();
      // Only fetch if taxonomy changed
      const currentTaxonomy = this.store.filters.taxonomy;
      if (currentTaxonomy !== config.taxonomy) {
         this.store.setFilters({
            taxonomy: config.taxonomy,
            page: 1,
            search: '',
            parent: 0
         });
      }
      // Reset UI
      window.removeChildren(this.ui.termsList);
      this.ui.search.value = '';
      // Load selected terms display
      this.updateModalSelections();
      this.updateSelectionCount();
      this.modalInstance.open();
      // Clear terms list and show loading
      window.removeChildren(this.ui.termsList);
      this.showLoading();
   }
   /**
    * Update selection count display in modal
    */
   updateSelectionCount() {
      if (!this.currentConfig) return;
      const count = this.selectedTerms.size;
      const max = this.currentConfig.maxSelection;
      // Update any count display elements
      const countElement = this.modal?.querySelector('.selection-count');
      if (countElement) {
         if (max > 0) {
            countElement.textContent = `${count} of ${max} selected`;
         } else {
            countElement.textContent = `${count} selected`;
         }
      }
   }
   /**
    * Get singular label for taxonomy
    */
   getSingular(taxonomy) {
      return jvbSettings.labels[taxonomy]?.single || taxonomy;
   }
   /**
    * Get plural label for taxonomy
    */
   getPlural(taxonomy) {
      return jvbSettings.labels[taxonomy]?.plural || taxonomy;
   }
   /**
@@ -673,12 +764,17 @@
      this.observer.unobserve(this.ui.sentinel);
      window.removeChildren(this.ui.termsList);
      this.notify('selected-terms', {
         terms: this.selectedTerms,
         taxonomy: this.currentConfig.taxonomy
      });
      if (this.currentConfig?.isFilterMode) {
         if (this.currentConfig.filterCallback) {
            const selectedIds = Array.from(this.selectedTerms.keys());
            this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
         }
         this.fields.delete(this.activeField);
         // this.fields.delete(this.activeField);
      } else if (this.activeField) {
         this.saveSelectionsToField(this.activeField);
      }
@@ -688,7 +784,7 @@
         this.ui.search.input.removeEventListener('input', this.searchHandler);
      }
      if (this.creator) {
      if (!this.hasAutocomplete && this.creator) {
         delete this.creator;
      }
@@ -960,8 +1056,9 @@
      if (!field) return;
      // Store current value immediately (fixes fast typing issue)
      const query = e.target.value.trim();
      field.currentAutocompleteQuery = query;
      if (query.length < 2) {
         if (field.autocompleteDropdown) {
@@ -972,12 +1069,6 @@
      }
      this.activeField = fieldId;
      this.currentConfig = field;
      if (field.canCreate && ! this.creator) {
         this.creator = new window.jvbTaxCreator(this);
      }
      this.isAutocompleteActive = true;
      if (field.autocompleteDropdown) {
@@ -1041,15 +1132,26 @@
         });
      }
      // Offer to create new term if creator is available
      if (this.creator) {
         const createOption = this.creator.createAutocompleteOption(query, field);
      // Use stored current query instead of debounced one
      const currentQuery = field.currentAutocompleteQuery || query;
      if (field.canCreate && currentQuery && window.jvbTaxCreator) {
         const createOption = this.createNewTermOption(currentQuery);
         dropdown.appendChild(createOption);
      }
      dropdown.hidden = false;
   }
   createNewTermOption(query) {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'autocomplete-item create-term';
      button.dataset.query = query;
      button.innerHTML = `<strong>Create:</strong> "${query}"`;
      return button;
   }
   createAutocompleteTermElement(field, term) {
      const item = document.createElement('button');
      item.type = 'button';
@@ -1081,7 +1183,7 @@
    * Navigate to parent term
    */
   navigateToParent() {
      // Single call instead of two setFilter + manual fetch
      // Store handles fetch automatically
      this.store.setFilters({
         parent: 0,
         page: 1
@@ -1095,7 +1197,7 @@
    * Navigate to child term
    */
   navigateToChild(termId, termName) {
      // Single call - auto-fetches
      // Store handles fetch automatically
      this.store.setFilters({
         parent: termId,
         page: 1
@@ -1112,7 +1214,7 @@
   navigateToPath(pathLevel) {
      const parentId = parseInt(pathLevel.dataset.id) || 0;
      // Single call - auto-fetches
      // Store handles fetch automatically
      this.store.setFilters({
         parent: parentId,
         page: 1
@@ -1126,17 +1228,19 @@
    * Load more terms (pagination)
    */
   loadMoreTerms() {
      if (!this.activeStore) return;
      const currentPage = this.activeStore.filters.page || 1;
      const currentPage = this.store.filters.page || 1;
      this.store.setFilter('page', currentPage + 1);
   }
   /**
    * Render terms list
    */
   renderTerms(terms, append = false, showPath = false) {
   renderTerms(terms = null, append = false, showPath = false) {
      // If no terms provided, get from store
      if (!terms) {
         terms = this.store.getFiltered();
      }
      if (!append) {
         window.removeChildren(this.ui.termsList);
      }
@@ -1148,10 +1252,10 @@
         return;
      }
      // Use this.store instead of this.activeStore
      const currentParent = this.store.filters.parent || 0;
      this.ui.breadcrumbs.back.hidden = currentParent === 0;
      const fragment = document.createDocumentFragment();
      terms.forEach(term => {
         const element = this.createTermElement({
            id: parseInt(term.id),
@@ -1162,9 +1266,11 @@
         });
         if (element) {
            this.ui.termsList.appendChild(element);
            fragment.appendChild(element);
         }
      });
      this.ui.termsList.appendChild(fragment);
   }
   /**
@@ -1308,6 +1414,117 @@
      return null;
   }
   /********************************************
   BATCH FETCH: fetches first page for all taxonomies in one call
    ********************************************/
   async batchFetchTaxonomies() {
      if (this.taxonomiesToFetch.size === 0) return;
      const taxonomies = Array.from(this.taxonomiesToFetch);
      this.taxonomiesToFetch.clear();
      // Single fetch - the data-loaded event will handle cache splitting
      this.store.setFilters({
         taxonomy: taxonomies.join(','),
         page: 1,
         search: '',
         parent: 0
      });
   }
   handleBatchDataLoaded(taxonomyString, data) {
      const taxonomies = taxonomyString.split(',').map(t => t.trim());
      const storeInstance = this.store.getStore(); // Access actual store instance
      taxonomies.forEach(taxonomy => {
         const filters = {
            taxonomy: taxonomy,
            page: 1,
            search: '',
            parent: 0
         };
         // Use the internal generateCacheKey method via store instance
         const cacheKey = this.generateCacheKeyForFilters(filters);
         // Filter items for this specific taxonomy
         const items = Array.from(this.store.data.values())
            .filter(item => item.taxonomy === taxonomy)
            .map(item => item.id);
         const cacheEntry = {
            key: cacheKey,
            items: items,
            timestamp: Date.now(),
            endpoint: storeInstance.config.endpoint,
            filters: filters
         };
         // Set in both memory and IndexedDB cache
         storeInstance.cache.set(cacheKey, cacheEntry);
         // Persist to IndexedDB (if available)
         if (storeInstance.db?.objectStoreNames.contains('cache')) {
            const tx = storeInstance.db.transaction(['cache'], 'readwrite');
            const objectStore = tx.objectStore('cache');
            objectStore.put(cacheEntry);
         }
         // Update button states for this taxonomy
         this.updateFieldsForTaxonomy(taxonomy);
      });
      // Initialize field displays
      this.fields.forEach((config, fieldId) => {
         if (config.selectedTerms.size > 0) {
            this.initFieldDisplay(fieldId);
         }
      });
   }
   /**
    * Generate cache key for given filters (matching DataStore's internal logic)
    */
   generateCacheKeyForFilters(filters) {
      const normalized = Object.keys(filters)
         .sort()
         .reduce((acc, key) => {
            acc[key] = filters[key];
            return acc;
         }, {});
      return JSON.stringify(normalized);
   }
   /**
    * Preload taxonomy data on hover
    */
   async preloadTaxonomy(taxonomy) {
      // Trigger fetch for this taxonomy
      this.store.setFilters({
         taxonomy: taxonomy,
         page: 1,
         search: '',
         parent: 0
      });
   }
   /*****************************************
   SUBSCRIBERS
    *****************************************/
   subscribe(callback) {
      this.subscribers.add(callback);
      return () => this.subscribers.delete(callback);
   }
   notify(event, data = {}) {
      this.subscribers.forEach( callback => {
         try {
            callback(event, data);
         } catch (error) {
            console.error('Subscriber error:', error);
         }
      });
   }
   /**
    * Clean up
@@ -1323,6 +1540,7 @@
      // Destroy all stores
      this.store.destroy();
      this.subscribers.clear();
      // Clear all maps
      this.fields.clear();
      this.selectedTerms.clear();
@@ -1333,5 +1551,10 @@
 * Initialize singleton
 */
document.addEventListener('DOMContentLoaded', function() {
   window.jvbSelector = new TaxonomySelector();
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {
         window.jvbSelector = new TaxonomySelector();
      }
   });
});