/** * Centralized Taxonomy Selector with DataStore Integration * Handles all taxonomy selection fields using DataStore for state management */ class TaxonomySelectorOld { constructor() { this.a11y = window.jvbA11y; this.error = window.jvbError; this.index = -1; this.hasAutocomplete = false; this.isInitializing = true; this.taxonomiesToFetch = new Set(); 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(); this.selectedTerms = new Map(); // Current modal selection // Current modal context this.activeField = null; this.currentConfig = null; this.currentSingular = null; this.currentPlural = null; // Modal state this.disabled = false; // Search debouncing this.searchHandler = null; this.autocompleteHandler = null; this.isAutocompleteActive = false; this.init(); } /** * Initialize the selector */ init() { this.initModal(); 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; this.batchFetchTaxonomies(); } /** * Handle DataStore events */ handleStoreEvent(event, data) { switch (event) { case 'data-loaded': 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); } 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; case 'filters-changed': 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; } } /** * Handle loaded terms from DataStore */ handleTermsLoaded(data) { this.hideLoading(); 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 = response.page > 1; this.notify('terms-loaded', { terms, filters: data.filters }); if (terms.length === 0) { if (!append) { this.showEmptyState(isSearch ? 'No results found.' : 'No items available.'); } this.observer.unobserve(this.ui.sentinel); } else { this.renderTerms(); // Handle pagination if (response.has_more) { this.observer.observe(this.ui.sentinel); } else { this.observer.unobserve(this.ui.sentinel); } } // Announce to screen readers this.a11y?.announce(terms.length, append); } /** * Handle fetch errors */ handleFetchError(error) { console.error('Taxonomy fetch error:', error); this.hideLoading(); if (this.error?.log) { this.error.log(error, { component: 'TaxonomySelector', action: 'fetchTerms' }, () => this.fetchCurrentTerms()); } else { this.showEmptyState('Error loading terms. Please try again.'); } } /** * 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) { 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) { if (!container) { container = document.body; } const selectors = container.querySelectorAll('.field.taxonomy, .field.post'); selectors.forEach(selector => { try { this.registerField(selector); } catch (error) { this.error.log(error, { component: 'TaxonomySelector', action: 'scanExistingFields', container: selector.dataset.name }); } }); } /** * Register a taxonomy field */ registerField(field, options = {}) { let input = field.querySelector('input[type=hidden]'); if (!input) { return false; } if (!('fieldId' in field.dataset)) { field.dataset.fieldId = this.createFieldId(field); } let fieldId = field.dataset.fieldId; 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, input: input, container: field, taxonomy: button.dataset.taxonomy, 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(), toggle: button, selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'), ...options }; if (!this.hasAutocomplete && config.hasAutocomplete) { this.hasAutocomplete = true; this.initAutocomplete(); } // Parse initial selected values const value = input.value.trim(); if (value !== '') { const selectedIds = value.split(',') .map(id => parseInt(id.trim())) .filter(id => !isNaN(id)); 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); } // Initialize display for any pre-selected values if (config.selectedTerms.size > 0) { this.initFieldDisplay(fieldId); } return fieldId; } /** * Register a filter button (simplified registration for feed blocks) */ registerFilterButton(button, options = {}) { const fieldId = this.createFieldId(button); button.dataset.fieldId = fieldId; if (options.buttonSelector) { this.triggers.add(options.buttonSelector); } 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; } /** * Create unique field ID */ createFieldId(field) { this.index++; return 'selector-' + this.index; } /** * Initialize display for a field with existing values */ async initFieldDisplay(fieldId) { const field = this.fields.get(fieldId); if (!field || field.selectedTerms.size === 0) return; const selectedIds = Array.from(field.selectedTerms); selectedIds.forEach(termId => { const term = this.store.get(termId); // Changed from getItem if (term) { this.addTermToDisplay(fieldId, term.id, term.name, term.path); } }); } /** * Initialize modal elements */ initModal() { this.modalID = 'dialog#jvb-selector'; this.modal = document.querySelector(this.modalID); if (!this.modal) { console.warn('Taxonomy selector modal not found'); return; } this.initModalElements(); // Initialize modal instance this.modalInstance = new window.jvbModal(this.modal, { handleForm: false, save: null, open: null }); this.modalInstance.subscribe((event, data) => { switch (event) { case 'modal-open': this.openModal(data); break; case 'modal-close': this.closeModal(data); break; } }); } /** * Initialize modal element references */ initModalElements() { this.selectors = { search: { input: '[type=search]', clear: '.clear-search', container: '.search-wrapper' }, termsList: '.items-container', termsWrap: '.items-wrap', breadcrumbs: { nav: 'nav.term-navigation', back: '.back-to-parent', }, loading: { loading: '.loading', text: '.loading span' }, selectedTerms: '.selected-items', sentinel: '.scroll-sentinel', modal: { title: '#modal-title', content: '.modal-content' }, create: { details: '.create-new-term', parent: '#select_parent', summary: '.create-new-term summary', name: '#term_name', button: '.submit-term', label: { name: '[for=term_name]', parent: '[for=select_parent]' } }, favouriteTerms: '.favourite-terms' } this.ui = window.uiFromSelectors(this.selectors); // Initialize intersection observer for infinite scroll this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.loadMoreTerms(); } }); }, { root: this.ui.termsWrap, threshold: 0.5 }); } /** * Set up global event delegation */ initGlobalListeners() { document.addEventListener('click', this.handleClick.bind(this)); document.addEventListener('change', this.handleChange.bind(this)); if (this.hasAutocomplete) { this.initAutocomplete(); } } initAutocomplete() { 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 } /** * Handle global click events */ handleClick(e) { // Handle taxonomy toggle buttons const toggleButton = window.targetCheck(e, Array.from(this.triggers)); if (toggleButton) { e.preventDefault(); this.handleToggleClick(toggleButton); return; } // Handle remove selected term buttons const removeButton = window.targetCheck(e, 'button.remove-item'); if (removeButton && e.target.closest('.jvb-selector')) { const fieldId = this.getFieldId(removeButton); const termId = removeButton.closest('.selected-item').dataset.id; this.removeSelectedTerm(fieldId, termId); return; } // Handle modal close button if (e.target.matches('.modal-close')) { if (this.modalInstance) { this.modalInstance.handleClose(); } return; } // Handle clicks within the modal if (this.modal && this.modal.contains(e.target)) { this.handleModalClick(e); } } /** * Handle global change events */ handleChange(e) { // Handle hidden input changes for taxonomy fields const taxonomyField = window.targetCheck(e, '.taxonomy.field, .post.field'); if (taxonomyField && e.target.type === 'hidden') { const fieldId = this.getFieldId(e.target); this.updateFieldFromInput(fieldId); return; } // Handle modal changes if (this.modal && this.modal.contains(e.target)) { this.handleModalChange(e); } } /** * Handle toggle button click */ handleToggleClick(toggle) { try { const fieldId = this.getFieldId(toggle); const field = this.fields.get(fieldId); if (!field) { console.error('Field not found for toggle:', fieldId); return; } this.setActiveField(fieldId, true); } catch (error) { console.error('Error handling toggle click:', error); if (this.error?.log) { this.error.log(error, { component: 'TaxonomySelector', action: 'handleToggleClick' }); } } } /** * Set the active field for modal operations */ setActiveField(fieldId, openModal = false) { this.activeField = fieldId; this.currentConfig = this.fields.get(fieldId); this.currentSingular = this.getSingular(this.currentConfig.taxonomy); this.currentPlural = this.getPlural(this.currentConfig.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 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 }); } }); } /** * Handle clicks within modal */ handleModalClick(e) { if (window.targetCheck(e, '.remove-item')) { let selectedItem = window.targetCheck(e, '.selected-item'); if (selectedItem) { this.removeSelectedTermFromModal(selectedItem.dataset.id); } } else if (window.targetCheck(e, '.back-to-parent')) { this.navigateToParent(); } else if (window.targetCheck(e, '.toggle-children')) { let termItem = e.target.closest('li'); this.navigateToChild( parseInt(termItem.dataset.id), termItem.querySelector('.term-name').textContent ); } else if (window.targetCheck(e, '.path-level')) { let pathLevel = window.targetCheck(e, '.path-level'); this.navigateToPath(pathLevel); } } /** * Handle changes within modal (checkboxes) */ handleModalChange(e) { if (window.targetCheck(e, this.modalID) && e.target.type === 'checkbox') { e.preventDefault(); e.stopPropagation(); const termId = parseInt(e.target.closest('li').dataset.id); const label = e.target.closest('li').querySelector('label'); if (e.target.checked) { this.addSelectedTermToModal(termId, label.title, label.dataset.path); } else { this.removeSelectedTermFromModal(termId); } } } /** * Open modal for filtering (without a field) * @param {string} taxonomy - The taxonomy to filter by * @param {Function} callback - Callback when terms are selected * @param {Array} preselected - Array of term IDs already selected */ openForFilter(taxonomy, callback, preselected = []) { // Create a temporary virtual field config const virtualFieldId = `filter-${taxonomy}-${Date.now()}`; this.fields.set(virtualFieldId, { id: virtualFieldId, input: null, // No input for filter mode container: null, taxonomy: taxonomy, 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), toggle: null, selectedContainer: null, isFilterMode: true, // Flag for filter mode filterCallback: callback // Store the callback }); this.setActiveField(virtualFieldId, true); this.modalInstance.handleOpen(); } /** * Open modal and initialize */ openModal() { if (!this.currentConfig) { console.error('No active field set'); return; } // Initialize creator if available if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) { this.creator = new window.jvbTaxCreator(this); } // Update modal UI this.updateModalForTaxonomy(); // Load selected terms display this.updateModalSelections(); this.updateSelectionCount(); // 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; } /** * Close modal and save selections */ closeModal() { 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); } else if (this.activeField) { this.saveSelectionsToField(this.activeField); } // Cleanup if (this.currentConfig?.canSearch && this.searchHandler) { this.ui.search.input.removeEventListener('input', this.searchHandler); } if (!this.hasAutocomplete && this.creator) { delete this.creator; } // Remove: this.activeStore = null; this.activeField = null; this.currentConfig = null; } /** * Reset modal state */ resetModalState() { this.disabled = false; window.removeChildren(this.ui.termsList); window.removeChildren(this.ui.selectedTerms); this.ui.search.input.value = ''; // Clear navigation breadcrumbs window.removeChildren(this.ui.breadcrumbs.nav); this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back); this.ui.breadcrumbs.back.hidden = true; } /** * Update modal content for current taxonomy */ updateModalForTaxonomy() { if (!this.currentConfig) return; this.ui.modal.title.textContent = `Select ${this.currentPlural}`; if (this.ui.search.container) { this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none'; } if (this.ui.create.details) { this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none'; this.ui.create.details.hidden = !this.currentConfig.canCreate; if (this.ui.create.summary) { this.ui.create.summary.textContent = `Add new ${this.currentSingular}`; } if (this.ui.create.label.name) { this.ui.create.label.name.textContent = `Name this ${this.currentSingular}`; } if (this.ui.create.label.parent) { this.ui.create.label.parent.textContent = `Nest it under`; } if (this.ui.create.parent) { } } const openMessage = `Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`; this.a11y?.announce(openMessage); } /** * Update modal selections display */ updateModalSelections() { window.removeChildren(this.ui.selectedTerms); this.selectedTerms.forEach((termData, id) => { this.addTermToModalDisplay(id, termData.name, termData.path); }); this.checkSelectionLimits(); } /** * Add selected term to modal */ addSelectedTermToModal(id, name, path) { this.selectedTerms.set(id, { id: id, name: name, path: path }); this.addTermToModalDisplay(id, name, path); this.checkSelectionLimits(); // Check the corresponding checkbox const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); if (checkbox) { checkbox.checked = true; } } /** * Remove selected term from modal */ removeSelectedTermFromModal(id) { this.selectedTerms.delete(parseInt(id)); // Remove from modal display const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`); if (selectedItem) { selectedItem.remove(); } // Uncheck the corresponding checkbox const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); if (checkbox) { checkbox.checked = false; } this.checkSelectionLimits(); } /** * Add term to modal display */ addTermToModalDisplay(id, name, path) { const item = window.getTemplate('selectedTerm').cloneNode(true); item.dataset.id = id; item.dataset.path = path; item.dataset.name = name; item.dataset.taxonomy = this.currentConfig.taxonomy; item.querySelector('span').textContent = path; item.querySelector('button').title = `Remove ${name}`; this.ui.selectedTerms.appendChild(item); } /** * Check selection limits and disable/enable checkboxes */ checkSelectionLimits() { if (!this.currentConfig || this.currentConfig.maxSelection === 0) { return; } this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection; this.setCheckboxes(this.disabled); } /** * Set checkbox disabled state */ setCheckboxes(disabled) { this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { if (!checkbox.checked) { checkbox.disabled = disabled; } }); } /** * Save modal selections to field */ saveSelectionsToField(fieldId) { const field = this.fields.get(fieldId); if (!field) return; // Clear current field selections field.selectedTerms.clear(); window.removeChildren(field.selectedContainer); // Add modal selections to field this.selectedTerms.forEach((termData, id) => { field.selectedTerms.add(id); this.addTermToDisplay(fieldId, id, termData.name, termData.path); }); // Update hidden input const selectedIds = Array.from(field.selectedTerms); field.input.value = selectedIds.join(','); field.input.dispatchEvent(new Event('change', { bubbles: true })); } /** * Remove selected term from field */ removeSelectedTerm(fieldId, termId) { const field = this.fields.get(fieldId); if (!field) return; const id = parseInt(termId); field.selectedTerms.delete(id); // Remove from display const selectedItem = field.selectedContainer.querySelector(`[data-id="${id}"]`); if (selectedItem) { selectedItem.remove(); } // Update hidden input const selectedIds = Array.from(field.selectedTerms); field.input.value = selectedIds.join(','); field.input.dispatchEvent(new Event('change', { bubbles: true })); } /** * Add term to field display */ addTermToDisplay(fieldId, id, name, path) { const field = this.fields.get(fieldId); if (!field || field.selectedContainer.querySelector(`[data-id="${id}"]`)) { return; // Already displayed } const item = window.getTemplate('selectedTerm').cloneNode(true); item.dataset.id = id; item.dataset.path = path; item.dataset.name = name; item.dataset.taxonomy = field.taxonomy; item.querySelector('span').textContent = path; item.querySelector('button').title = `Remove ${name}`; field.selectedContainer.appendChild(item); } /** * Update field from hidden input value */ updateFieldFromInput(fieldId) { const field = this.fields.get(fieldId); if (!field) return; const value = field.input.value.trim(); field.selectedTerms.clear(); window.removeChildren(field.selectedContainer); if (value !== '') { const selectedIds = value.split(',') .map(id => parseInt(id.trim())) .filter(id => !isNaN(id)); selectedIds.forEach(id => field.selectedTerms.add(id)); this.initFieldDisplay(fieldId); } } /** * Handle search input */ handleSearch(e) { const query = e.target.value.trim(); // 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); } 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 = `Create: "${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() { // Store handles fetch automatically this.store.setFilters({ parent: 0, page: 1 }); window.removeChildren(this.ui.termsList); this.ui.breadcrumbs.back.hidden = true; } /** * Navigate to child term */ navigateToChild(termId, termName) { // Store handles fetch automatically this.store.setFilters({ parent: termId, page: 1 }); window.removeChildren(this.ui.termsList); this.updateBreadcrumbs(termId, termName); this.ui.breadcrumbs.back.hidden = false; } /** * Navigate to specific path level */ navigateToPath(pathLevel) { const parentId = parseInt(pathLevel.dataset.id) || 0; // Store handles fetch automatically this.store.setFilters({ parent: parentId, page: 1 }); window.removeChildren(this.ui.termsList); this.ui.breadcrumbs.back.hidden = parentId === 0; } /** * Load more terms (pagination) */ loadMoreTerms() { const currentPage = this.store.filters.page || 1; this.store.setFilter('page', currentPage + 1); } /** * Render terms list */ 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); } if (terms.length === 0) { if (!append) { this.showEmptyState(); } return; } 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), name: term.name, hasChildren: term.hasChildren, path: term.path || null, show: showPath }); if (element) { fragment.appendChild(element); } }); this.ui.termsList.appendChild(fragment); } /** * Create individual term element */ createTermElement(termData) { if (!termData || !termData.name) return null; const listItem = window.getTemplate('termListItem').cloneNode(true); listItem.dataset.id = termData.id; const isSelected = this.selectedTerms.has(termData.id); const checkbox = listItem.querySelector('input'); const label = listItem.querySelector('label'); const nameSpan = listItem.querySelector('span, .term-name'); if (checkbox && label && nameSpan) { checkbox.id = `${this.currentConfig.container.id}${termData.id}`; checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`; checkbox.value = termData.id; checkbox.disabled = !isSelected && this.disabled; checkbox.checked = isSelected; label.htmlFor = checkbox.id; label.title = termData.path || termData.name; label.dataset.path = termData.path; nameSpan.textContent = termData.show ? termData.path : termData.name; } if (termData.hasChildren) { const childrenToggle = window.getTemplate ? window.getTemplate('termChildrenToggle') : this.createChildrenToggle(); if (childrenToggle) { childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`; listItem.appendChild(childrenToggle); } } return listItem; } /** * Create children toggle button */ createChildrenToggle() { const button = document.createElement('button'); button.type = 'button'; button.className = 'toggle-children'; button.innerHTML = '→'; return button; } /** * Update breadcrumb navigation */ updateBreadcrumbs(termId, termName) { // This is a simplified version - you'd want to maintain a proper breadcrumb trail const breadcrumb = window.getTemplate('termBreadcrumb').cloneNode(true); breadcrumb.dataset.id = termId; breadcrumb.textContent = termName; breadcrumb.title = termName; // Remove any existing breadcrumbs after this level const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`); if (existingCrumb) { // Remove all breadcrumbs after this one while (existingCrumb.nextElementSibling) { existingCrumb.nextElementSibling.remove(); } } else { this.ui.breadcrumbs.nav.appendChild(breadcrumb); } } /** * Show loading state */ showLoading() { this.ui.loading.loading.hidden = false; this.modal.classList.add('loading'); const searchQuery = this.store?.filters?.search || ''; const currentParent = this.store?.filters?.parent || 0; let message = searchQuery !== '' ? `searching for "${searchQuery}" items` : currentParent === 0 ? 'loading items' : `loading child items`; if (window.typeLoop) { this.stopTyping = window.typeLoop(this.ui.loading.text, message); } else { this.ui.loading.text.textContent = message; } } /** * Hide loading state */ hideLoading() { this.ui.loading.loading.hidden = true; this.modal.classList.remove('loading'); if (this.stopTyping) { this.stopTyping(); } } /** * Show empty state message */ 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; } container.appendChild(emptyElement); } /** * Get field ID from any element within the field */ getFieldId(element) { if (element.dataset.fieldId) { return element.dataset.fieldId; } const fieldContainer = element.closest('[data-field-id]'); if (fieldContainer) { return fieldContainer.dataset.fieldId; } 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 */ destroy() { // Remove event listeners document.removeEventListener('click', this.handleClick); document.removeEventListener('change', this.handleChange); // Clear intervals and cleanup this.observer?.disconnect(); // Destroy all stores this.store.destroy(); this.subscribers.clear(); // Clear all maps this.fields.clear(); this.selectedTerms.clear(); } } /** * Initialize singleton */ document.addEventListener('DOMContentLoaded', function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbSelector = new TaxonomySelectorOld(); } }); });