| | |
| | | this.error = window.jvbError; |
| | | this.index = -1; |
| | | |
| | | 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'}, |
| | |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | } |
| | | }, |
| | | required: 'taxonomy' |
| | | }); |
| | | |
| | | // Central field management |
| | |
| | | |
| | | // Search debouncing |
| | | this.searchHandler = null; |
| | | this.autocompleteHandler = null; |
| | | this.isAutocompleteActive = false; |
| | | |
| | | this.init(); |
| | | } |
| | |
| | | this.initGlobalListeners(); |
| | | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | |
| | | /** |
| | | * 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); |
| | |
| | | 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(), |
| | |
| | | ...options |
| | | }; |
| | | |
| | | if (!this.hasAutocomplete && config.hasAutocomplete) { |
| | | this.hasAutocomplete = true; |
| | | this.initAutocomplete(); |
| | | } |
| | | |
| | | // Parse initial selected values |
| | | const value = input.value.trim(); |
| | | if (value !== '') { |
| | |
| | | this.fields.set(fieldId, config); |
| | | |
| | | // Ensure store exists for this taxonomy |
| | | this.store.setFilter('taxonomy', 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) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | |
| | | if (!field || field.selectedTerms.size === 0) return; |
| | | |
| | | const selectedIds = Array.from(field.selectedTerms); |
| | | |
| | | // Check store for cached terms first |
| | | const cachedTerms = []; |
| | | const needsFetch = []; |
| | | |
| | | selectedIds.forEach(termId => { |
| | | const term = this.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 this.store.fetch({ |
| | | filters: { |
| | | taxonomy: field.taxonomy, |
| | | termIDs: needsFetch.join(',') |
| | | } |
| | | }); |
| | | |
| | | if (response.terms) { |
| | | response.terms.forEach(term => { |
| | | this.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 |
| | | } |
| | | |
| | | /** |
| | |
| | | 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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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; |
| | | |
| | |
| | | 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), |
| | |
| | | /** |
| | | * 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(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | |
| | | delete this.creator; |
| | | } |
| | | |
| | | this.activeStore = null; |
| | | // Remove: this.activeStore = null; |
| | | this.activeField = null; |
| | | this.currentConfig = null; |
| | | } |
| | |
| | | /** |
| | | * 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; |
| | | |
| | | |
| | | const query = e.target.value.trim(); |
| | | |
| | | if (query.length < 2) { |
| | | if (field.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = true; |
| | | } |
| | | this.isAutocompleteActive = false; |
| | | return; |
| | | } |
| | | |
| | | this.activeField = fieldId; |
| | | this.currentConfig = field; |
| | | |
| | | |
| | | if (field.canCreate && ! this.creator) { |
| | | this.creator = new window.jvbTaxCreator(this); |
| | | } |
| | | 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); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Offer to create new term if creator is available |
| | | if (this.creator) { |
| | | const createOption = this.creator.createAutocompleteOption(query, field); |
| | | dropdown.appendChild(createOption); |
| | | } |
| | | |
| | | dropdown.hidden = false; |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | |
| | |
| | | * 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; |
| | | } |
| | |
| | | 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); |
| | | |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | | } |
| | | }); |
| | | } |
| | |
| | | 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` : |
| | |
| | | /** |
| | | * 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * Initialize singleton |
| | | */ |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | if (!window.jvbSelector) { |
| | | window.jvbSelector = new TaxonomySelector(); |
| | | } |
| | | window.jvbSelector = new TaxonomySelector(); |
| | | }); |