Jake Vanderwerf
2025-11-10 e9967fa22781d922ba4eb8fb44fe72d200ac4b14
assets/js/concise/TaxonomySelector.js
@@ -8,9 +8,31 @@
      this.error = window.jvbError;
      this.index = -1;
      // DataStore instances per taxonomy
      this.stores = new Map();
      this.storeSubscriptions = new Map();
      this.hasAutocomplete = false;
      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'
      });
      // Central field management
      this.fields = new Map();
@@ -28,6 +50,8 @@
      // Search debouncing
      this.searchHandler = null;
      this.autocompleteHandler = null;
      this.isAutocompleteActive = false;
      this.init();
   }
@@ -39,62 +63,47 @@
      this.initModal();
      this.scanExistingFields();
      this.initGlobalListeners();
   }
   /**
    * Get or create a DataStore for a taxonomy
    */
   getOrCreateStore(taxonomy) {
      if (!this.stores.has(taxonomy)) {
         const store = new window.jvbStore({
            name: `tax_${taxonomy}`,
            endpoint: 'terms',
            TTL: 3600000, // 1 hour cache
            filters: {
               taxonomy: taxonomy,
               page: 1,
               search: '',
               parent: 0
            }
         });
         // Subscribe to store events
         const unsubscribe = store.subscribe((event, data) => {
            this.handleStoreEvent(taxonomy, event, data);
         });
         this.stores.set(taxonomy, store);
         this.storeSubscriptions.set(taxonomy, unsubscribe);
      }
      return this.stores.get(taxonomy);
      this.store.subscribe(this.handleStoreEvent.bind(this));
      // Complete initialization
      this.isInitializing = false;
      this.batchFetchTaxonomies();
   }
   /**
    * Handle DataStore events
    */
   handleStoreEvent(taxonomy, event, data) {
      // Only process events for the active taxonomy in modal
      if (this.activeStore && this.activeStore.config.name === `tax_${taxonomy}`) {
         switch (event) {
            case 'items-loaded':
            case 'data-fetched':
            case 'data-cached':
            case 'stale-cache-used':
   handleStoreEvent(event, data) {
      switch (event) {
         case 'data-loaded':
            // Only render if modal is open OR if it's an autocomplete request
            if (this.modal?.open) {
               this.handleTermsLoaded(data);
               break;
            case 'fetch-error':
               this.handleFetchError(data.error);
               break;
            case 'filters-changed':
               // Could trigger UI updates for active filters
               break;
         }
      }
            }
            // Handle autocomplete results
            if (this.isAutocompleteActive && this.activeField) {
               const field = this.fields.get(this.activeField);
               const terms = data.data?.items || [];
               const query = data.filters?.search || '';
               this.showAutocompleteResults(field, terms, query);
               this.isAutocompleteActive = false;
            }
            break;
      // Handle field-specific updates outside modal
      if (event === 'items-updated' || event === 'items-loaded') {
         this.updateFieldsForTaxonomy(taxonomy, data.items);
         case 'filters-changed':
            // Modal UI updates happen here if needed
            if (this.modal?.open) {
               this.showLoading();
            }
            break;
         case 'fetch-error':
            if (this.isAutocompleteActive && this.activeField) {
               this.showAutocompleteError(this.activeField);
               this.isAutocompleteActive = false;
            }
            this.handleFetchError(data.error);
            break;
      }
   }
@@ -169,8 +178,12 @@
   /**
    * Scan page for existing taxonomy fields and register them
    */
   scanExistingFields() {
      const selectors = document.querySelectorAll('.field.taxonomy, .field.post');
   scanExistingFields(container = null) {
      if (!container) {
         container = document.body;
      }
      const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
      selectors.forEach(selector => {
         try {
            this.registerField(selector);
@@ -207,6 +220,8 @@
         name: field.dataset.field,
         maxSelection: parseInt(button.dataset.max) || 0,
         canSearch: 'search' in button.dataset,
         hasAutocomplete: 'autocomplete' in button.dataset,
         autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false,
         canCreate: 'creatable' in button.dataset,
         isRequired: 'required' in button.dataset,
         selectedTerms: new Set(),
@@ -215,6 +230,11 @@
         ...options
      };
      if (!this.hasAutocomplete && config.hasAutocomplete) {
         this.hasAutocomplete = true;
         this.initAutocomplete();
      }
      // Parse initial selected values
      const value = input.value.trim();
      if (value !== '') {
@@ -227,7 +247,11 @@
      this.fields.set(fieldId, config);
      // Ensure store exists for this taxonomy
      this.getOrCreateStore(config.taxonomy);
      if (this.isInitializing) {
         this.taxonomiesToFetch.add(config.taxonomy);
      } else {
         this.store.setFilter('taxonomy', config.taxonomy);
      }
      // Initialize display for any pre-selected values
      if (config.selectedTerms.size > 0) {
@@ -238,6 +262,35 @@
   }
   /**
    * Batch fetch all unique taxonomies collected during init
    */
   async batchFetchTaxonomies() {
      if (this.taxonomiesToFetch.size === 0) return;
      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
         });
      }
      // Now initialize field displays
      this.fields.forEach((config, fieldId) => {
         if (config.selectedTerms.size > 0) {
            this.initFieldDisplay(fieldId);
         }
      });
   }
   /**
    * Create unique field ID
    */
   createFieldId(field) {
@@ -252,47 +305,22 @@
      const field = this.fields.get(fieldId);
      if (!field || field.selectedTerms.size === 0) return;
      const store = this.getOrCreateStore(field.taxonomy);
      const selectedIds = Array.from(field.selectedTerms);
      // Check store for cached terms first
      const cachedTerms = [];
      const needsFetch = [];
      selectedIds.forEach(termId => {
         const term = store.getItem(termId);
         const term = this.store.data.get(termId);
         if (term) {
            cachedTerms.push(term);
         } else {
            needsFetch.push(termId);
         }
      });
      // Display cached terms immediately
      // Display all found terms
      cachedTerms.forEach(term => {
         this.addTermToDisplay(fieldId, term.id, term.name, term.path);
      });
      // Fetch missing terms if needed
      if (needsFetch.length > 0) {
         try {
            const response = await store.fetch('terms', {
               filters: {
                  taxonomy: field.taxonomy,
                  termIDs: needsFetch.join(',')
               }
            });
            if (response.terms) {
               response.terms.forEach(term => {
                  store.setItem(term.id, term);
                  this.addTermToDisplay(fieldId, term.id, term.name, term.path);
               });
            }
         } catch (error) {
            console.error('Failed to fetch missing terms:', error);
         }
      }
      // Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
   }
   /**
@@ -389,6 +417,17 @@
   initGlobalListeners() {
      document.addEventListener('click', this.handleClick.bind(this));
      document.addEventListener('change', this.handleChange.bind(this));
      if (this.hasAutocomplete) {
         this.initAutocomplete();
      }
   }
   initAutocomplete()
   {
      console.log('Autocomplete init');
      this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
      document.addEventListener('input', this.autocompleteHandler);
      document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
   }
   /**
@@ -476,22 +515,20 @@
      this.activeField = fieldId;
      this.currentConfig = this.fields.get(fieldId);
      console.log('Current Taxonomy:',this.currentConfig.taxonomy);
      console.log('Labels: ',jvbSettings.labels[this.currentConfig.taxonomy]);
      this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
      this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
      // Get or create store for this taxonomy
      this.activeStore = this.getOrCreateStore(this.currentConfig.taxonomy);
      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.activeStore.getItem(termId);
            const term = this.store.getItem(termId);
            if (term) {
               this.selectedTerms.set(termId, {
                  id: termId,
@@ -499,17 +536,26 @@
                  path: term.path
               });
            } else {
               // If not in store, create minimal entry
               this.selectedTerms.set(termId, {
                  id: termId,
                  name: `Term ${termId}`,
                  path: `Term ${termId}`
               });
               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
               });
            });
         }
      }
   }
   fetchSpecificTerms(terms) {
      return [];
   }
   /**
    * Handle clicks within modal
    */
@@ -570,6 +616,8 @@
         name: `filter_${taxonomy}`,
         maxSelection: 0, // No limit for filters
         canSearch: true,
         hasAutocomplete: false,
         autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false,
         canCreate: false, // Disable creation for filters
         isRequired: false,
         selectedTerms: new Set(preselected),
@@ -586,38 +634,36 @@
   /**
    * Open modal and initialize
    */
   openModal() {
      if (!this.activeField || !this.currentConfig) {
         console.error('No active field set for modal');
         return;
      }
      this.resetModalState();
      this.updateModalForTaxonomy();
      // Reset store filters to default state
      this.activeStore.clearFilters();
      // Set up search if enabled
      if (this.currentConfig.canSearch) {
         this.ui.search.input.focus();
         this.searchHandler = window.debounce(() => this.handleSearch(), 300);
         this.ui.search.input.addEventListener('input', this.searchHandler);
      }
   openModal(config) {
      this.activeField = config.fieldId;
      this.currentConfig = config;
      // Initialize creator if available
      if (this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
      if (config.canCreate && 'jvbTaxCreator' in window) {
         this.creator = new window.jvbTaxCreator(this);
      } else if (this.creator) {
         delete this.creator;
      }
      // Display current selections
      this.updateModalSelections();
      // Load selected terms into modal state
      this.selectedTerms = new Set(config.selectedTerms);
      // Start observing for infinite scroll
      this.observer.observe(this.ui.sentinel);
      // 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
         });
      }
      // Fetch initial terms
      this.fetchCurrentTerms();
      // Reset UI
      window.removeChildren(this.ui.termsList);
      this.ui.search.value = '';
      this.updateSelectionCount();
      this.modalInstance.open();
   }
   /**
@@ -628,13 +674,10 @@
      window.removeChildren(this.ui.termsList);
      if (this.currentConfig?.isFilterMode) {
         // Call the filter callback with selected terms
         if (this.currentConfig.filterCallback) {
            const selectedIds = Array.from(this.selectedTerms.keys());
            this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
         }
         // Clean up the virtual field
         this.fields.delete(this.activeField);
      } else if (this.activeField) {
         this.saveSelectionsToField(this.activeField);
@@ -649,7 +692,7 @@
         delete this.creator;
      }
      this.activeStore = null;
      // Remove: this.activeStore = null;
      this.activeField = null;
      this.currentConfig = null;
   }
@@ -887,45 +930,170 @@
   /**
    * Handle search input
    */
   handleSearch() {
      const query = this.ui.searchInput.value.trim();
   handleSearch(e) {
      const query = e.target.value.trim();
      if (query.length >= 2 || query.length === 0) {
         // Reset pagination when searching
         this.activeStore.setFilter('page', 1);
         this.activeStore.setFilter('search', query);
      // Clear existing debounce
      if (this.searchHandler) {
         clearTimeout(this.searchHandler);
      }
      this.searchHandler = setTimeout(() => {
         // Single call - auto-fetches
         this.store.setFilters({
            search: query,
            page: 1,
            parent: query ? 0 : (this.store.filters.parent || 0)
         });
         window.removeChildren(this.ui.termsList);
      }, 300);
   }
         if (query.length >= 2) {
            this.showLoading();
            this.fetchCurrentTerms();
         } else if (query.length === 0) {
            // Clear search and reload
            this.showLoading();
            this.fetchCurrentTerms();
         }
      } else {
         this.hideLoading();
         this.showEmptyState('Enter at least 2 characters to search.');
   async handleAutocomplete(e) {
      if (!('autocomplete' in e.target.dataset)) {
         return;
      }
      const fieldId = this.getFieldId(e.target);
      const field = this.fields.get(fieldId);
      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) {
            field.autocompleteDropdown.hidden = true;
         }
         this.isAutocompleteActive = false;
         return;
      }
      this.activeField = fieldId;
      this.isAutocompleteActive = true;
      if (field.autocompleteDropdown) {
         field.autocompleteDropdown.hidden = false;
      }
      this.store.setFilters({
         taxonomy: field.taxonomy,
         search: query,
         page: 1
      });
   }
   cleanupAutocomplete(e) {
      if (!('autocomplete' in e.target.dataset)) {
         return;
      }
      const fieldId = this.getFieldId(e.target);
      const field = this.fields.get(fieldId);
      if (!field) return;
      if (this.creator) {
         delete this.creator;
      }
   }
   showAutocompleteError(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) {
         return;
      }
      if (!field.config.autocompleteDropdown) {
         field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
      }
      const dropdown = field.config.autocompleteDropdown;
      if (dropdown) {
         window.removeChildren(dropdown);
         this.showEmptyState('Hmmm... something went wrong', dropdown);
      }
   }
   showAutocompleteResults(field, terms, query) {
      if (!field || !field.autocompleteDropdown) {
         return;
      }
      const dropdown = field.autocompleteDropdown;
      window.removeChildren(dropdown);
      if (terms.length === 0) {
         this.showEmptyState('No items found.', dropdown);
      } else {
         terms.forEach(term => {
            const element = this.createAutocompleteTermElement(field, term);
            if (element) {
               dropdown.appendChild(element);
            }
         });
      }
      // 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';
      item.className = 'autocomplete-item';
      item.dataset.id = term.id;
      item.dataset.name = term.name;
      item.dataset.path = term.path || term.name;
      item.textContent = term.path || term.name;
      item.addEventListener('click', () => {
         // Add term to field
         field.selectedTerms.add(parseInt(term.id));
         this.addTermToDisplay(field.id, term.id, term.name, term.path);
         // Update input
         field.input.value = Array.from(field.selectedTerms).join(',');
         field.input.dispatchEvent(new Event('change', { bubbles: true }));
         // Clear and hide dropdown
         field.autocompleteDropdown.hidden = true;
         const input = field.container.querySelector('input[data-autocomplete]');
         if (input) input.value = '';
      });
      return item;
   }
   /**
    * Navigate to parent term
    */
   navigateToParent() {
      const currentParent = this.activeStore.filters.parent || 0;
      // Find parent of current parent (could enhance this with breadcrumb tracking)
      this.activeStore.setFilter('parent', 0);
      this.activeStore.setFilter('page', 1);
      // Single call instead of two setFilter + manual fetch
      this.store.setFilters({
         parent: 0,
         page: 1
      });
      window.removeChildren(this.ui.termsList);
      this.showLoading();
      this.fetchCurrentTerms();
      // Update breadcrumbs
      this.ui.breadcrumbs.back.hidden = true;
   }
@@ -933,14 +1101,13 @@
    * Navigate to child term
    */
   navigateToChild(termId, termName) {
      this.activeStore.setFilter('parent', termId);
      this.activeStore.setFilter('page', 1);
      // Single call - auto-fetches
      this.store.setFilters({
         parent: termId,
         page: 1
      });
      window.removeChildren(this.ui.termsList);
      this.showLoading();
      this.fetchCurrentTerms();
      // Update breadcrumbs
      this.updateBreadcrumbs(termId, termName);
      this.ui.breadcrumbs.back.hidden = false;
   }
@@ -951,37 +1118,25 @@
   navigateToPath(pathLevel) {
      const parentId = parseInt(pathLevel.dataset.id) || 0;
      this.activeStore.setFilter('parent', parentId);
      this.activeStore.setFilter('page', 1);
      // Single call - auto-fetches
      this.store.setFilters({
         parent: parentId,
         page: 1
      });
      window.removeChildren(this.ui.termsList);
      this.showLoading();
      this.fetchCurrentTerms();
      // Update breadcrumbs to this level
      // You'd need to track the full path to properly implement this
      this.ui.breadcrumbs.back.hidden = parentId === 0;
   }
   /**
    * Fetch terms using current store filters
    */
   fetchCurrentTerms() {
      if (!this.activeStore) return;
      this.showLoading();
      this.activeStore.fetch();
   }
   /**
    * Load more terms (pagination)
    */
   loadMoreTerms() {
      if (!this.activeStore) return;
      const currentPage = this.activeStore.filters.page || 1;
      this.activeStore.setFilter('page', currentPage + 1);
      // fetch() will be called automatically by setFilter
      this.store.setFilter('page', currentPage + 1);
   }
   /**
@@ -999,36 +1154,21 @@
         return;
      }
      // Update breadcrumbs if needed
      const currentParent = this.activeStore.filters.parent || 0;
      // Use this.store instead of this.activeStore
      const currentParent = this.store.filters.parent || 0;
      this.ui.breadcrumbs.back.hidden = currentParent === 0;
      terms.forEach(term => {
         // Check if we have a cached DOM element
         const cachedElement = this.activeStore.getDOMElement(term.id, 'list-item');
         const element = this.createTermElement({
            id: parseInt(term.id),
            name: term.name,
            hasChildren: term.hasChildren,
            path: term.path || null,
            show: showPath
         });
         if (cachedElement) {
            // Update checkbox state if needed
            const checkbox = cachedElement.querySelector('input[type="checkbox"]');
            if (checkbox) {
               checkbox.checked = this.selectedTerms.has(term.id);
               checkbox.disabled = !checkbox.checked && this.disabled;
            }
            this.ui.termsList.appendChild(cachedElement);
         } else {
            // Create new element and cache it
            const element = this.createTermElement({
               id: parseInt(term.id),
               name: term.name,
               hasChildren: term.hasChildren,
               path: term.path || null,
               show: showPath
            });
            if (element) {
               this.activeStore.storeDOMElement(term.id, 'list-item', element);
               this.ui.termsList.appendChild(element);
            }
         if (element) {
            this.ui.termsList.appendChild(element);
         }
      });
   }
@@ -1115,8 +1255,8 @@
      this.ui.loading.loading.hidden = false;
      this.modal.classList.add('loading');
      const searchQuery = this.activeStore?.filters?.search || '';
      const currentParent = this.activeStore?.filters?.parent || 0;
      const searchQuery = this.store?.filters?.search || '';
      const currentParent = this.store?.filters?.parent || 0;
      let message = searchQuery !== '' ?
         `searching for "${searchQuery}" items` :
@@ -1146,14 +1286,17 @@
   /**
    * Show empty state message
    */
   showEmptyState(message = 'No items found.') {
   showEmptyState(message = 'No items found.', container = null) {
      if (!container) {
         container = this.ui.termsList;
      }
      const emptyElement = window.getTemplate('noResults').cloneNode(true);
      if (message && emptyElement.querySelector('span')) {
         emptyElement.querySelector('span').textContent = message;
      }
      this.ui.termsList.appendChild(emptyElement);
      container.appendChild(emptyElement);
   }
   /**
@@ -1183,16 +1326,11 @@
      // Clear intervals and cleanup
      this.observer?.disconnect();
      // Unsubscribe from all stores
      this.storeSubscriptions.forEach(unsubscribe => unsubscribe());
      // Destroy all stores
      this.stores.forEach(store => store.destroy());
      this.store.destroy();
      // Clear all maps
      this.fields.clear();
      this.stores.clear();
      this.storeSubscriptions.clear();
      this.selectedTerms.clear();
   }
}
@@ -1201,7 +1339,5 @@
 * Initialize singleton
 */
document.addEventListener('DOMContentLoaded', function() {
   if (!window.jvbSelector) {
      window.jvbSelector = new TaxonomySelector();
   }
   window.jvbSelector = new TaxonomySelector();
});