Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
assets/js/dash/TaxonomyCreator.js
@@ -1,6 +1,6 @@
/**
 * This separates out all create logic from the base TaxonomySelector.js, so that we only enqueue create logic if it's creatable
 * Updated to work with the refactored centralized TaxonomySelector
 * This separates out all create logic from the base TaxonomySelector.js
 * Updated to work with centralized DataStore architecture
 */
class TaxonomyCreator {
@@ -24,7 +24,8 @@
   }
   initListeners() {
      document.addEventListener('click', this.handleClick.bind(this));
      this.clickHandler = this.handleClick.bind(this);
      document.addEventListener('click', this.clickHandler);
   }
   handleClick(e) {
@@ -42,37 +43,52 @@
   async handleTermCreation(e) {
      const termName = this.form.querySelector('input[name="term_name"]').value.trim();
      const parentId = this.form.querySelector('input#select_parent')?.value;
      const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0;
      if (!termName) return;
      try {
         this.form.querySelector('button').disabled = true;
         const response = await this.createTerm(termName, parentId);
         if (response.success) {
         if (response.success && response.term) {
            let term = response.term;
            // Close the create new section
            this.createNew.open = false;
            // Add to the terms list UI
            this.selector.createTermElement({
               id: parseInt(term.id),
               name: term.name,
               hasChildren: term.hasChildren || false,
               path: term.path || term.name,
               show: false
            });
            // Invalidate the cache for this taxonomy
            await this.selector.store.invalidate({ taxonomy: this.taxonomy });
            // Add to current modal selection
            this.selector.addSelectedTermToModal(term.id, term.name, term.path);
            this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
            // If we're viewing the parent category where this was created, refresh the list
            const currentParent = this.selector.store.filters.parent || 0;
            if (currentParent === parentId) {
               await this.selector.store.setFilters({
                  taxonomy: this.taxonomy,
                  parent: parentId,
                  page: 1,
                  search: ''
               });
            }
            // Clear the form
            this.form.querySelector('input[name="term_name"]').value = '';
            // Clear suggestions
            const suggestionContainer = this.createNew.querySelector('.term-suggestions');
            if (suggestionContainer) {
               suggestionContainer.hidden = true;
            }
         }
      } catch (error) {
         console.error('Error creating term:', error);
         this.selector.showError?.('Failed to create term') ||
         console.error('Failed to create term');
         this.selector.error?.log(error, {
            component: 'TaxonomyCreator',
            action: 'handleTermCreation'
         }) || console.error('Failed to create term');
      } finally {
         this.form.querySelector('button').disabled = false;
      }
@@ -100,25 +116,39 @@
      window.removeChildren(select);
      select.append(defaultOption.cloneNode(true));
      // Add current parent if we're in a sub-category
      if (this.selector.currentParentName !== '') {
         let parentOption = defaultOption.cloneNode(true);
         parentOption.value = this.selector.currentParent;
         parentOption.textContent = this.selector.currentParentName;
         select.append(parentOption);
      // Get current parent from store filters
      const currentParent = this.selector.store.filters.parent || 0;
      // If we're in a sub-category, add the current parent as an option
      if (currentParent !== 0) {
         const parentTerm = this.selector.store.data.get(currentParent);
         if (parentTerm) {
            let parentOption = defaultOption.cloneNode(true);
            parentOption.value = parentTerm.id;
            parentOption.textContent = parentTerm.name;
            select.append(parentOption);
         }
      }
      // Add terms from current taxonomy cache
      const taxonomyTerms = this.selector.currentTerms;
      if (taxonomyTerms && taxonomyTerms.length > 0) {
         taxonomyTerms.forEach(term => {
            let option = defaultOption.cloneNode(true);
            option.id = `select-parent-${term.id}`;
            option.value = term.id;
            option.textContent = '  — ' + term.name;
            select.append(option);
         });
      }
      // Add all terms currently visible in the taxonomy (from store cache)
      const visibleTerms = [];
      this.selector.store.data.forEach(term => {
         if (term.taxonomy === this.taxonomy && term.parent === currentParent) {
            visibleTerms.push(term);
         }
      });
      // Sort by name
      visibleTerms.sort((a, b) => a.name.localeCompare(b.name));
      // Add to select
      visibleTerms.forEach(term => {
         let option = defaultOption.cloneNode(true);
         option.id = `select-parent-${term.id}`;
         option.value = term.id;
         option.textContent = '  — ' + term.name;
         select.append(option);
      });
   }
   async createTerm(name, parent = 0) {
@@ -136,36 +166,22 @@
            text.textContent = 'Checking term...';
         }
         // Check if term already exists by searching
         const originalSearchQuery = this.selector.searchQuery;
         const originalFetchSpecific = this.selector.fetchSpecificTerms;
         // Search for existing terms with this name
         const searchResults = await this.searchExistingTerms(name);
         this.selector.searchQuery = name;
         this.selector.fetchSpecificTerms = false; // We want to search, not fetch specific IDs
         const existingTerms = await this.selector.fetchTerms(
            this.selector.activeField,
            false,
            true // isSearch = true
         );
         // Restore original search state
         this.selector.searchQuery = originalSearchQuery;
         this.selector.fetchSpecificTerms = originalFetchSpecific;
         // Check if any existing terms match exactly
         const exactMatches = existingTerms.filter(term =>
         // Check for exact matches
         const exactMatches = searchResults.filter(term =>
            term.name.toLowerCase() === name.toLowerCase()
         );
         if (exactMatches.length > 0) {
            this.showTermSuggestions(exactMatches);
            this.showTermSuggestions(exactMatches, true);
            return { success: false, reason: 'exists' };
         }
         // Show similar terms if found
         if (existingTerms.length > 0) {
            this.showTermSuggestions(existingTerms);
         if (searchResults.length > 0) {
            this.showTermSuggestions(searchResults, false);
            return { success: false, reason: 'similar' };
         }
@@ -191,8 +207,7 @@
            throw new Error(`Server error: ${response.status}`);
         }
         const result = await response.json();
         return result;
         return await response.json();
      } catch (error) {
         console.error('Error creating term:', error);
@@ -205,8 +220,35 @@
      }
   }
   // Helper method to show term suggestions when similar terms exist
   showTermSuggestions(suggestions) {
   /**
    * Search for existing terms using the store
    */
   async searchExistingTerms(searchQuery) {
      return new Promise((resolve) => {
         // Set up a one-time listener for the search results
         const handleSearchResults = (event, data) => {
            if (event === 'data-loaded') {
               this.selector.store.unsubscribe(handleSearchResults);
               resolve(data.data?.items || []);
            }
         };
         this.selector.store.subscribe(handleSearchResults);
         // Trigger search
         this.selector.store.setFilters({
            taxonomy: this.taxonomy,
            search: searchQuery,
            page: 1,
            parent: 0
         });
      });
   }
   /**
    * Show term suggestions when similar terms exist
    */
   showTermSuggestions(suggestions, isExact = false) {
      const suggestionContainer = this.createNew.querySelector('.term-suggestions') ||
         this.createSuggestionContainer();
@@ -215,7 +257,9 @@
      // Add heading
      const heading = document.createElement('h4');
      heading.textContent = 'Similar terms already exist:';
      heading.textContent = isExact ?
         'This term already exists:' :
         'Similar terms already exist:';
      suggestionContainer.appendChild(heading);
      // Create list of suggestions
@@ -225,18 +269,15 @@
      suggestions.forEach(term => {
         const item = document.createElement('li');
         // Create term path display if available
         let termDisplay = term.path || term.name;
         const button = document.createElement('button');
         button.type = 'button';
         button.className = 'use-existing-term';
         button.setAttribute('data-id', term.id);
         button.textContent = termDisplay;
         button.textContent = term.path || term.name;
         button.addEventListener('click', () => {
            // Add this term to modal selection
            this.selector.addSelectedTermToModal(term.id, term.name, term.path);
            this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
            // Close the create new section
            this.createNew.open = false;
@@ -256,7 +297,9 @@
      suggestionContainer.hidden = false;
   }
   // Create container for term suggestions if it doesn't exist
   /**
    * Create container for term suggestions if it doesn't exist
    */
   createSuggestionContainer() {
      const container = document.createElement('div');
      container.className = 'term-suggestions';
@@ -268,10 +311,79 @@
   }
   /**
    * Create "Create new term" option for autocomplete dropdown
    */
   createAutocompleteOption(query, field) {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'autocomplete-item create-term';
      button.innerHTML = `<span>Create "${query}"</span>`;
      button.dataset.query = query;
      button.dataset.fieldId = field.id;
      button.addEventListener('click', async () => {
         await this.handleAutocompleteCreate(button, query, field);
      });
      return button;
   }
   /**
    * Handle term creation from autocomplete
    */
   async handleAutocompleteCreate(button, termName, field) {
      if (!field) return;
      const originalHTML = button.innerHTML;
      try {
         button.disabled = true;
         button.innerHTML = '<span>Creating...</span>';
         const parentId = 0; // Autocomplete always creates at root level
         const result = await this.createTerm(termName, parentId);
         if (result.success && result.term) {
            const term = result.term;
            // Add to field
            field.selectedTerms.add(parseInt(term.id));
            this.selector.addTermToDisplay(field.id, term.id, term.name, term.path || term.name);
            // Update input
            field.input.value = Array.from(field.selectedTerms).join(',');
            field.input.dispatchEvent(new Event('change', { bubbles: true }));
            // Invalidate cache
            await this.selector.store.invalidate({ taxonomy: field.taxonomy });
            // Clear and hide dropdown
            field.autocompleteDropdown.hidden = true;
            const input = field.container.querySelector('input[data-autocomplete]');
            if (input) input.value = '';
         }
         // If result.success is false, suggestions are already shown
      } catch (error) {
         console.error('Error creating term:', error);
         button.innerHTML = originalHTML;
         button.disabled = false;
         this.selector.error?.log(error, {
            component: 'TaxonomyCreator',
            action: 'handleAutocompleteCreate'
         });
      }
   }
   /**
    * Clean up when modal closes
    */
   destroy() {
      // Remove event listeners if needed
      // Remove event listeners
      if (this.clickHandler) {
         document.removeEventListener('click', this.clickHandler);
      }
      // Clear any pending operations
      const loadingMessage = this.createNew?.querySelector('.loading-message.create-term');
      if (loadingMessage) {