=Complete TaxonomySelector.js and TaxonomyCreator.js refactor
1 files added
5 files deleted
6 files modified
| | |
| | | /** |
| | | * TaxonomyCreator - Handles term creation for TaxonomySelector |
| | | * Simplified to focus only on creation logic |
| | | */ |
| | | class TaxonomyCreator { |
| | | |
| | | constructor(selector) { |
| | | this.selector = selector; |
| | | this.queue = window.jvbQueue; |
| | | |
| | | // Only initialize modal elements if modal exists |
| | | if (selector.modal) { |
| | | this.createNew = selector.modal.querySelector('.create-new-term'); |
| | | this.toggle = selector.modal.querySelector('.new-term-toggle'); |
| | | this.form = this.createNew?.querySelector('.create-new-term-section'); |
| | | } |
| | | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | } |
| | | |
| | | // Only init term creation UI if we have modal elements |
| | | if (this.form) { |
| | | this.initTermCreation(); |
| | | } |
| | | initElements() { |
| | | this.selectors = { |
| | | details: 'details.create-new', |
| | | parent: '#select_parent', |
| | | summary: '.create-new summary', |
| | | suggestion: '.term-suggestions', |
| | | name: '#term_name', |
| | | button: '.submit-term', |
| | | form: 'form.create-term', |
| | | label: { |
| | | name: '[for="term_name"]', |
| | | parent: '[for="select_parent"]' |
| | | }, |
| | | loading: '.loading-message.create-term' |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.selectors, this.selector.container); |
| | | } |
| | | |
| | | /** |
| | |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | document.addEventListener('click', this.clickHandler); |
| | | |
| | | if (this.ui.form) { |
| | | this.ui.form.addEventListener('change', (e) => { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | }) |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | handleClick(e) { |
| | | // Handle opening create term form |
| | | if (window.targetCheck(e, '.create-new-term summary')) { |
| | | if (this.createNew.open) { |
| | | this.createNew.querySelector('input[name="term_name"]').focus(); |
| | | if (window.targetCheck(e, this.selectors.summary)) { |
| | | if (this.ui.details.open) { |
| | | this.ui.name?.focus(); |
| | | } |
| | | this.resetParentOptions(); |
| | | } |
| | | |
| | | // Handle term creation submission |
| | | if (window.targetCheck(e, '.submit-term')) { |
| | | if (window.targetCheck(e, this.selectors.button)) { |
| | | this.handleTermCreation(e).then(() => {}); |
| | | } |
| | | |
| | | // Handle autocomplete create button |
| | | if (window.targetCheck(e, '.create-term')) { |
| | | this.handleAutocompleteCreate(e).then(() => {}); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle term creation from modal form |
| | | */ |
| | | async handleTermCreation(e) { |
| | | const taxonomy = this.selector.currentConfig?.taxonomy; |
| | | let field = this.selector.currentField(); |
| | | const taxonomy = field.taxonomy; |
| | | if (!taxonomy) return; |
| | | |
| | | const termName = this.form.querySelector('input[name="term_name"]').value.trim(); |
| | | const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0; |
| | | |
| | | if (!termName) return; |
| | | |
| | | const submitButton = this.form.querySelector('button'); |
| | | let data = { |
| | | parent: 0, |
| | | taxonomy: taxonomy |
| | | }; |
| | | // If it's open, we can get the parent element and everything |
| | | if (this.selector.container.open) { |
| | | data.name = this.ui.name.value.trim(); |
| | | data.parent = parseInt(this.ui.parent?.value??0); |
| | | } else if (this.selector.activeField) { |
| | | //It's autocomplete, so just the term name |
| | | data.name = this.selector.store.query; |
| | | } |
| | | if (!data.name || data.name.length < 2) return; |
| | | |
| | | try { |
| | | if (submitButton) { |
| | | submitButton.disabled = true; |
| | | if (this.ui.button) { |
| | | this.ui.button.disabled = true; |
| | | } |
| | | |
| | | const response = await this.createTerm(termName, parentId, taxonomy); |
| | | const response = await this.createTerm(data); |
| | | |
| | | if (response.success && response.term) { |
| | | await this.handleSuccessfulCreation(response.term, taxonomy, parentId); |
| | | await this.handleSuccessfulCreation(response.term, data); |
| | | this.clearForm(); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error creating term:', error); |
| | | this.selector.handleError(error, 'handleTermCreation'); |
| | | } finally { |
| | | if (submitButton) { |
| | | submitButton.disabled = false; |
| | | if (this.ui.button) { |
| | | this.ui.button.disabled = false; |
| | | } |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Handle successful term creation |
| | | */ |
| | | async handleSuccessfulCreation(term, taxonomy, parentId) { |
| | | async handleSuccessfulCreation(term, data) { |
| | | const termPath = term.path || term.name; |
| | | |
| | | // Close create form |
| | | this.createNew.open = false; |
| | | this.ui.details.open = false; |
| | | |
| | | // Clear cache to ensure fresh data |
| | | await this.selector.store.clearCache(); |
| | |
| | | id: term.id, |
| | | name: term.name, |
| | | path: termPath, |
| | | taxonomy: taxonomy, |
| | | parent: parentId, |
| | | count: 0, |
| | | hasChildren: false, |
| | | slug: term.slug || termName.toLowerCase().replace(/\s+/g, '-') |
| | | }); |
| | | |
| | | // Add to modal selection |
| | | this.selector.addSelectedTermToModal(term.id, term.name, termPath); |
| | | |
| | | // Refresh current view if we're viewing the same parent |
| | | const currentParent = this.selector.store.filters.parent || 0; |
| | | if (currentParent === parentId) { |
| | | await this.selector.store.setFilters({ |
| | | taxonomy, |
| | | parent: parentId, |
| | | page: 1, |
| | | search: '' |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle autocomplete create button |
| | | */ |
| | | async handleAutocompleteCreate(e) { |
| | | const button = e.target.closest('.create-term'); |
| | | const fieldId = this.selector.getFieldId(button); |
| | | const field = this.selector.fields.get(fieldId); |
| | | |
| | | if (!field) return; |
| | | |
| | | const input = field.container.querySelector('input[data-autocomplete]'); |
| | | const termName = input?.value.trim() || button.dataset.query; |
| | | |
| | | if (!termName) return; |
| | | |
| | | const originalHTML = button.innerHTML; |
| | | |
| | | try { |
| | | button.disabled = true; |
| | | button.textContent = 'Creating...'; |
| | | |
| | | const response = await this.createTerm(termName, 0, field.taxonomy); |
| | | |
| | | if (response.success && response.term) { |
| | | await this.handleAutocompleteSuccess(response.term, field, input); |
| | | } else if (response.reason === 'exists' && response.term) { |
| | | this.handleExistingTerm(response.term, field, input); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error creating term:', error); |
| | | this.selector.handleError(error, 'handleAutocompleteCreate'); |
| | | } finally { |
| | | button.innerHTML = originalHTML; |
| | | button.disabled = false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle successful autocomplete creation |
| | | */ |
| | | async handleAutocompleteSuccess(term, field, input) { |
| | | const termPath = term.path || term.name; |
| | | |
| | | // Add to field |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | |
| | | // Add to DataStore |
| | | this.selector.store.data.set(term.id, { |
| | | id: term.id, |
| | | name: term.name, |
| | | path: termPath, |
| | | taxonomy: field.taxonomy, |
| | | parent: 0, |
| | | taxonomy: data.taxonomy, |
| | | parent: data.parent, |
| | | count: 0, |
| | | hasChildren: false, |
| | | slug: term.slug || term.name.toLowerCase().replace(/\s+/g, '-') |
| | | }); |
| | | |
| | | // Update display |
| | | this.selector.addTermDisplay(term.id, term.name, termPath, 'field', field.id); |
| | | // Add to modal selection |
| | | this.selector.addSelected(term.id,this.selector.activeField); |
| | | |
| | | // Update input and trigger change |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | // Clear and hide dropdown |
| | | field.autocompleteDropdown.hidden = true; |
| | | if (input) input.value = ''; |
| | | |
| | | // Clear cache for this taxonomy |
| | | await this.selector.store.clearCache(); |
| | | } |
| | | |
| | | /** |
| | | * Handle selecting existing term from autocomplete |
| | | */ |
| | | handleExistingTerm(term, field, input) { |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | this.selector.addTermDisplay(term.id, term.name, term.path || term.name, 'field', field.id); |
| | | |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | field.autocompleteDropdown.hidden = true; |
| | | if (input) input.value = ''; |
| | | } |
| | | |
| | | /** |
| | | * Initialize term creation form |
| | | */ |
| | | initTermCreation() { |
| | | if (!this.form) return; |
| | | |
| | | this.form.addEventListener('change', (e) => { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | }); |
| | | // Refresh current view if we're viewing the same parent |
| | | if (this.selector.container.open) { |
| | | const currentParent = this.selector.store.filters.parent || 0; |
| | | if (currentParent === data.parent) { |
| | | await this.selector.store.setFilters({ |
| | | taxonomy: data.taxonomy, |
| | | parent: data.parent, |
| | | page: 1, |
| | | search: '' |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Reset parent options in create form |
| | | */ |
| | | resetParentOptions() { |
| | | const taxonomy = this.selector.currentConfig?.taxonomy; |
| | | const field = this.selector.currentField(); |
| | | if (!field) return; |
| | | const taxonomy = field.taxonomy; |
| | | if (!taxonomy) return; |
| | | |
| | | let select = this.createNew.querySelector('#select_parent'); |
| | | if (!select) return; |
| | | if (!this.ui.parent) return; |
| | | |
| | | let defaultOption = select.querySelector('option'); |
| | | let defaultOption = this.ui.parent.querySelector('option'); |
| | | if (!defaultOption) return; |
| | | |
| | | // Clear existing options |
| | | window.removeChildren(select); |
| | | select.append(defaultOption.cloneNode(true)); |
| | | window.removeChildren(this.ui.parent); |
| | | this.ui.parent.append(defaultOption.cloneNode(true)); |
| | | |
| | | // 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); |
| | | const parentTerm = this.selector.store.get(currentParent); |
| | | if (parentTerm) { |
| | | let parentOption = defaultOption.cloneNode(true); |
| | | parentOption.value = parentTerm.id; |
| | | parentOption.textContent = parentTerm.name; |
| | | select.append(parentOption); |
| | | this.ui.parent.append(parentOption); |
| | | } |
| | | } |
| | | |
| | | // Add all terms currently visible in the taxonomy |
| | | const visibleTerms = []; |
| | | this.selector.store.data.forEach(term => { |
| | | this.selector.store.getFiltered().forEach(term => { |
| | | if (term.taxonomy === taxonomy && term.parent === currentParent) { |
| | | visibleTerms.push(term); |
| | | } |
| | |
| | | option.id = `select-parent-${term.id}`; |
| | | option.value = term.id; |
| | | option.textContent = ' — ' + term.name; |
| | | select.append(option); |
| | | this.ui.parent.append(option); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create a new term |
| | | */ |
| | | async createTerm(name, parent = 0, taxonomy) { |
| | | async createTerm(data) { |
| | | if (!data.name || data.parent === undefined || !data.taxonomy) return; |
| | | try { |
| | | // Search to check for duplicates |
| | | await this.selector.store.setFilters({ |
| | | taxonomy: taxonomy, |
| | | search: name, |
| | | page: 1, |
| | | parent: 0 |
| | | }); |
| | | |
| | | // Wait for data to load |
| | | await new Promise(resolve => setTimeout(resolve, 100)); |
| | | |
| | | // Check if exact match exists |
| | | const exactMatch = Array.from(this.selector.store.data.values()) |
| | | .find(term => |
| | | term.taxonomy === taxonomy && |
| | | term.name.toLowerCase() === name.toLowerCase() |
| | | ); |
| | | |
| | | if (exactMatch) { |
| | | // For modal context, show suggestions |
| | | if (this.createNew) { |
| | | this.showTermSuggestions([exactMatch], true); |
| | | } |
| | | return { success: false, reason: 'exists', term: exactMatch }; |
| | | } |
| | | |
| | | // Term doesn't exist, create it |
| | | const response = await fetch(`${jvbSettings.api}terms`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': window.auth.getNonce() |
| | | }, |
| | | body: JSON.stringify({ |
| | | taxonomy: taxonomy, |
| | | name: name, |
| | | parent: parent |
| | | }) |
| | | body: JSON.stringify(data) |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Show term suggestions when similar terms exist |
| | | */ |
| | | showTermSuggestions(suggestions, isExact = false) { |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions') || |
| | | this.createSuggestionContainer(); |
| | | |
| | | // Clear existing suggestions |
| | | window.removeChildren(suggestionContainer); |
| | | |
| | | // Add heading |
| | | const heading = document.createElement('h4'); |
| | | heading.textContent = isExact ? |
| | | 'This term already exists:' : |
| | | 'Similar terms already exist:'; |
| | | suggestionContainer.appendChild(heading); |
| | | |
| | | // Create list of suggestions |
| | | const list = document.createElement('ul'); |
| | | list.className = 'term-suggestion-list'; |
| | | |
| | | suggestions.forEach(term => { |
| | | const item = document.createElement('li'); |
| | | const button = this.createSuggestionButton(term); |
| | | item.appendChild(button); |
| | | list.appendChild(item); |
| | | }); |
| | | |
| | | suggestionContainer.appendChild(list); |
| | | suggestionContainer.hidden = false; |
| | | } |
| | | |
| | | /** |
| | | * Create suggestion button |
| | | */ |
| | | createSuggestionButton(term) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'use-existing-term'; |
| | | button.dataset.id = term.id; |
| | | button.textContent = term.path || term.name; |
| | | |
| | | button.addEventListener('click', () => { |
| | | // Add this term to modal selection |
| | | this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name); |
| | | |
| | | // Close the create new section |
| | | this.createNew.open = false; |
| | | |
| | | // Clear suggestions and form |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | } |
| | | |
| | | this.clearForm(); |
| | | }); |
| | | |
| | | return button; |
| | | } |
| | | |
| | | /** |
| | | * Create container for term suggestions |
| | | */ |
| | | createSuggestionContainer() { |
| | | const container = document.createElement('div'); |
| | | container.className = 'term-suggestions'; |
| | | container.hidden = true; |
| | | |
| | | // Insert after the form |
| | | this.createNew.querySelector('form').after(container); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Clear the creation form |
| | | */ |
| | | clearForm() { |
| | | const nameInput = this.form.querySelector('input[name="term_name"]'); |
| | | if (nameInput) { |
| | | nameInput.value = ''; |
| | | } |
| | | |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | if (this.ui.name) { |
| | | this.ui.name.value = ''; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // Clear any pending operations |
| | | const loadingMessage = this.createNew?.querySelector('.loading-message.create-term'); |
| | | if (loadingMessage) { |
| | | loadingMessage.hidden = true; |
| | | } |
| | | |
| | | // Clear suggestions |
| | | const suggestionContainer = this.createNew?.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | if (this.ui.loading) { |
| | | this.ui.loading.hidden = true; |
| | | } |
| | | } |
| | | } |
| | |
| | | /** |
| | | * TaxonomySelector - Streamlined version |
| | | * Manages taxonomy selection fields with DataStore integration |
| | | */ |
| | | class TaxonomySelector { |
| | | constructor() { |
| | | this.container = document.querySelector('dialog#jvb-selector'); |
| | | if (!this.container) return; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | this.index = -1; |
| | | |
| | | this.isInitializing = true; |
| | | this.taxonomiesToFetch = new Set(); |
| | | this.subscribers = new Set(); |
| | | |
| | | // Register DataStore |
| | | 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, |
| | | filters: { |
| | | taxonomy: '', |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }, |
| | | required: 'taxonomy', |
| | | delayFetch: true, |
| | | }); |
| | | this.store = store.terms; |
| | | |
| | | // Field management |
| | | this.fields = new Map(); |
| | | this.selectedTerms = new Map(); // Current modal selection |
| | | this.selectedTerms = new Map(); // a map of fieldId => Set of selected term Ids |
| | | this.loadedTaxonomies = new Set(); // a set of taxonomies, to know whether we should preload a newly registered field |
| | | this.batchFetch = new Set(); |
| | | |
| | | // Modal context |
| | | this.activeField = null; |
| | | this.currentConfig = null; |
| | | this.disabled = false; |
| | | |
| | | // Search contexts |
| | | this.searchContexts = new Map(); |
| | | |
| | | this.isInitializing = true; |
| | | this.init(); |
| | | } |
| | | |
| | | init() { |
| | | this.initStore(); |
| | | this.initElements(); |
| | | this.initModal(); |
| | | this.scanExistingFields(); |
| | | this.initGlobalListeners(); |
| | | this.initListeners(); |
| | | |
| | | // Initialize creator if needed |
| | | if (this.needsCreator() && window.jvbTaxCreator) { |
| | | this.creator = new window.jvbTaxCreator(this); |
| | | } |
| | | this.isInitializing = false |
| | | this.batchFetchTaxonomies().then(()=> {}); |
| | | } |
| | | |
| | | initStore() { |
| | | 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, |
| | | filters: { |
| | | taxonomy: '', |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }, |
| | | required: 'taxonomy', |
| | | delayFetch: true, |
| | | } |
| | | ); |
| | | this.store = store.terms; |
| | | |
| | | this.store.subscribe(this.handleStoreEvent.bind(this)); |
| | | |
| | | this.isInitializing = false; |
| | | this.batchFetchTaxonomies(); |
| | | } |
| | | |
| | | needsCreator() { |
| | | return Array.from(this.fields.values()).some(field => |
| | | field.canCreate || field.hasAutocomplete |
| | | ); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * DATASTORE EVENT HANDLING |
| | | ***********************************************************************/ |
| | | |
| | | handleStoreEvent(event, data) { |
| | | const handlers = { |
| | | 'data-loaded': () => this.handleDataLoaded(data), |
| | | 'filters-changed': () => this.handleFiltersChanged(data), |
| | | 'fetch-error': () => this.handleFetchError(data.error), |
| | | }; |
| | | |
| | | handlers[event]?.(); |
| | | } |
| | | |
| | | handleDataLoaded(data) { |
| | | const taxonomy = this.store.filters.taxonomy; |
| | | |
| | | // Update field states for affected taxonomies |
| | | if (taxonomy) { |
| | | const taxonomies = taxonomy.includes(',') |
| | | ? taxonomy.split(',').map(t => t.trim()) |
| | | : [taxonomy]; |
| | | |
| | | taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax)); |
| | | } |
| | | |
| | | // Initialize displays on first load |
| | | if (this.isInitializing) { |
| | | this.fields.forEach((config, fieldId) => { |
| | | if (config.selectedTerms.size > 0) { |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Render based on context |
| | | this.renderSearchResults(data); |
| | | } |
| | | |
| | | renderSearchResults(data) { |
| | | const context = this.getActiveSearchContext(); |
| | | |
| | | if (context === 'modal') { |
| | | this.renderModalResults(data); |
| | | } else if (context === 'autocomplete') { |
| | | this.renderAutocompleteResults(data); |
| | | } |
| | | } |
| | | |
| | | getActiveSearchContext() { |
| | | if (this.modal?.open) return 'modal'; |
| | | if (this.activeField && this.searchContexts.has(this.activeField)) { |
| | | return this.searchContexts.get(this.activeField); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | renderModalResults(data) { |
| | | this.hideLoading(); |
| | | const terms = this.store.getFiltered(); |
| | | const response = this.store.lastResponse?.page || {}; |
| | | const isSearch = 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(terms, append, isSearch); |
| | | |
| | | if (response.has_more) { |
| | | this.observer.observe(this.ui.sentinel); |
| | | } else { |
| | | this.observer.unobserve(this.ui.sentinel); |
| | | } |
| | | } |
| | | |
| | | this.a11y?.announce(terms.length, append); |
| | | } |
| | | |
| | | renderAutocompleteResults(data) { |
| | | const field = this.fields.get(this.activeField); |
| | | if (!field?.autocompleteDropdown) return; |
| | | |
| | | const terms = this.store.getFiltered(); |
| | | const query = data.filters?.search || ''; |
| | | |
| | | this.showAutocompleteResults(field, terms, query); |
| | | this.searchContexts.delete(this.activeField); |
| | | } |
| | | |
| | | handleFiltersChanged(data) { |
| | | if (this.modal?.open) { |
| | | this.showLoading(); |
| | | } |
| | | } |
| | | |
| | | handleFetchError(error) { |
| | | this.hideLoading(); |
| | | |
| | | const context = this.getActiveSearchContext(); |
| | | |
| | | if (context === 'autocomplete') { |
| | | this.showAutocompleteError(this.activeField); |
| | | this.searchContexts.delete(this.activeField); |
| | | } else { |
| | | this.handleError(error, 'fetch'); |
| | | } |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * FIELD MANAGEMENT |
| | | ***********************************************************************/ |
| | | |
| | | updateFieldsForTaxonomy(taxonomy) { |
| | | this.getFieldsForTaxonomy(taxonomy).forEach(field => { |
| | | this.updateFieldButtonState(field.id); |
| | | }); |
| | | } |
| | | |
| | | updateFieldButtonState(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | 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.getLabel(field.taxonomy, 'single')} available` |
| | | : `Select ${this.getLabel(field.taxonomy, 'plural')}`; |
| | | } |
| | | } |
| | | |
| | | getFieldsForTaxonomy(taxonomy) { |
| | | return Array.from(this.fields.values()) |
| | | .filter(field => field.taxonomy === taxonomy); |
| | | } |
| | | |
| | | scanExistingFields(container = document.body) { |
| | | container.querySelectorAll('.field.taxonomy, .field.post').forEach(selector => { |
| | | try { |
| | | this.registerField(selector); |
| | | } catch (error) { |
| | | this.handleError(error, 'scanExistingFields', selector.dataset.name); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | registerField(field) { |
| | | const input = field.querySelector('input[type=hidden]'); |
| | | if (!input) return false; |
| | | |
| | | const fieldId = this.createFieldId(field); |
| | | field.dataset.fieldId = fieldId; |
| | | |
| | | const button = field.querySelector('button.taxonomy-toggle'); |
| | | const 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') || null, |
| | | canCreate: 'creatable' in button.dataset, |
| | | isRequired: 'required' in button.dataset, |
| | | selectedTerms: new Set(), |
| | | toggle: button, |
| | | selectedContainer: field.querySelector('.selected-items'), |
| | | }; |
| | | |
| | | // Parse initial values |
| | | const value = input.value.trim(); |
| | | if (value) { |
| | | value.split(',') |
| | | .map(id => parseInt(id.trim())) |
| | | .filter(id => !isNaN(id)) |
| | | .forEach(id => config.selectedTerms.add(id)); |
| | | } |
| | | |
| | | this.fields.set(fieldId, config); |
| | | |
| | | // Queue for batch fetch |
| | | if (this.isInitializing) { |
| | | this.taxonomiesToFetch.add(config.taxonomy); |
| | | } |
| | | |
| | | // Initialize display |
| | | if (config.selectedTerms.size > 0) { |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | |
| | | return fieldId; |
| | | } |
| | | |
| | | createFieldId(field) { |
| | | this.index++; |
| | | return 'selector-' + this.index; |
| | | } |
| | | |
| | | async initFieldDisplay(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || field.selectedTerms.size === 0) return; |
| | | |
| | | Array.from(field.selectedTerms).forEach(termId => { |
| | | const term = this.store.get(termId); |
| | | if (term) { |
| | | this.addTermDisplay(termId, term.name, term.path, 'field', fieldId); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * MODAL INITIALIZATION |
| | | ***********************************************************************/ |
| | | |
| | | initModal() { |
| | | this.modal = document.querySelector('dialog#jvb-selector'); |
| | | if (!this.modal) { |
| | | console.warn('Taxonomy selector modal not found'); |
| | | return; |
| | | } |
| | | |
| | | this.initModalElements(); |
| | | |
| | | this.modalInstance = new window.jvbModal(this.modal, { |
| | | handleForm: false |
| | | }); |
| | | |
| | | this.modalInstance.subscribe((event) => { |
| | | if (event === 'modal-open') this.openModal(); |
| | | if (event === 'modal-close') this.closeModal(); |
| | | }); |
| | | } |
| | | |
| | | initModalElements() { |
| | | const selectors = { |
| | | /****************************************************************** |
| | | ELEMENTS |
| | | ******************************************************************/ |
| | | initElements() { |
| | | this.selectors = { |
| | | search: { |
| | | input: '[type=search]', |
| | | container: '.search-wrapper' |
| | | clear: '.clear-search', |
| | | container: '.search-wrapper', |
| | | results: '.search-results' |
| | | }, |
| | | termsList: '.items-container', |
| | | termsWrap: '.items-wrap', |
| | | breadcrumbs: { |
| | | terms: { |
| | | list: '.items-container', |
| | | wrap: '.items-wrap', |
| | | sentinel: '.scroll-sentinel', |
| | | }, |
| | | nav: { |
| | | nav: 'nav.term-navigation', |
| | | back: '.back-to-parent', |
| | | child: '.toggle-children', |
| | | pathLevel: '.path-level', |
| | | }, |
| | | loading: { |
| | | loading: '.loading', |
| | | text: '.loading span' |
| | | text: '.loading span', |
| | | }, |
| | | selectedTerms: '.selected-items', |
| | | sentinel: '.scroll-sentinel', |
| | | selected: '.selected-items', |
| | | modal: { |
| | | title: '#modal-title', |
| | | content: '.modal-content', |
| | | count: '.selection-count' |
| | | }, |
| | | create: { |
| | | details: '.create-new-term', |
| | | summary: '.create-new-term summary', |
| | | label: { |
| | | name: '[for=term_name]', |
| | | parent: '[for=select_parent]' |
| | | } |
| | | favourites: '.favourite-terms', |
| | | field: { |
| | | toggle: 'button.taxonomy-toggle', |
| | | value: 'input[type="hidden"]', |
| | | selected: '.selected-items', |
| | | dropdown: '.search-results', |
| | | search: '[data-autocomplete]', |
| | | } |
| | | }; |
| | | } |
| | | |
| | | this.ui = window.uiFromSelectors(selectors); |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | | } |
| | | |
| | | // Initialize infinite scroll observer |
| | | initListeners() { |
| | | this.observer = new IntersectionObserver((entries) => { |
| | | entries.forEach(entry => { |
| | | if (entry.isIntersecting) { |
| | | this.loadMoreTerms(); |
| | | this.nextPage(); |
| | | } |
| | | }); |
| | | }, { |
| | | root: this.ui.termsWrap, |
| | | root: this.ui.terms.sentinel, |
| | | threshold: 0.5 |
| | | }); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * GLOBAL EVENT LISTENERS |
| | | ***********************************************************************/ |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | this.inputHandler = this.handleInput.bind(this); |
| | | this.focusHandler = this.handleFocus.bind(this); |
| | | this.blurHandler = this.handleBlur.bind(this); |
| | | |
| | | initGlobalListeners() { |
| | | document.addEventListener('click', this.handleClick.bind(this)); |
| | | document.addEventListener('change', this.handleChange.bind(this)); |
| | | document.addEventListener('input', this.handleInput.bind(this)); |
| | | document.addEventListener('focus', this.handleFocus.bind(this), true); |
| | | document.addEventListener('blur', this.handleBlur.bind(this), true); |
| | | document.addEventListener('click', this.clickHandler); |
| | | document.addEventListener('change', this.changeHandler); |
| | | document.addEventListener('input', this.inputHandler); |
| | | document.addEventListener('focus', this.focusHandler, true); |
| | | document.addEventListener('blur', this.blurHandler, true); |
| | | } |
| | | |
| | | handleClick(e) { |
| | | // Toggle button |
| | | if (window.targetCheck(e, '.taxonomy-toggle')) { |
| | | e.preventDefault(); |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | if (field) this.setActiveField(fieldId, true); |
| | | return; |
| | | } |
| | | |
| | | // Remove selected term |
| | | 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; |
| | | } |
| | | |
| | | // Modal close |
| | | if (e.target.matches('.modal-close')) { |
| | | this.modalInstance?.handleClose(); |
| | | return; |
| | | } |
| | | |
| | | // Modal clicks |
| | | if (this.modal?.contains(e.target)) { |
| | | this.handleModalClick(e); |
| | | } |
| | | } |
| | | |
| | | handleChange(e) { |
| | | // Hidden input changes |
| | | 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; |
| | | } |
| | | |
| | | // Modal checkboxes |
| | | if (this.modal?.contains(e.target)) { |
| | | this.handleModalChange(e); |
| | | } |
| | | } |
| | | |
| | | handleInput(e) { |
| | | // Modal search |
| | | if (this.modal?.contains(e.target) && e.target.type === 'search') { |
| | | this.performSearch(e.target.value.trim(), 'modal'); |
| | | return; |
| | | } |
| | | |
| | | // Autocomplete |
| | | if ('autocomplete' in e.target.dataset) { |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | if (field?.hasAutocomplete) { |
| | | this.performSearch(e.target.value.trim(), 'autocomplete', fieldId); |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleFocus(e) { |
| | | if (!('autocomplete' in e.target.dataset)) return; |
| | | |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || !field) return; |
| | | |
| | | if (field?.hasAutocomplete) { |
| | | this.preloadTaxonomy(field.taxonomy); |
| | | } |
| | | } |
| | | |
| | | handleBlur(e) { |
| | | if (!('autocomplete' in e.target.dataset)) return; |
| | | |
| | | setTimeout(() => { |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | |
| | | if (field?.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = true; |
| | | const autoComplete = window.targetCheck(e, '[data-autocomplete-select]'); |
| | | if (autoComplete) { |
| | | let termId = parseInt(autoComplete.dataset.id); |
| | | this.addSelected(termId, fieldId); |
| | | if (field.ui.dropdown) { |
| | | field.ui.dropdown.hidden = true; |
| | | } |
| | | |
| | | this.searchContexts.delete(fieldId); |
| | | }, 200); |
| | | if (field.ui.search) { |
| | | field.ui.search.value = ''; |
| | | } |
| | | } |
| | | |
| | | const toggleButton = window.targetCheck(e, field.ui.toggle); |
| | | if (toggleButton) { |
| | | e.preventDefault(); |
| | | this.openModal(fieldId); |
| | | return; |
| | | } |
| | | |
| | | const removeButton = window.targetCheck(e, 'button.remove-item'); |
| | | if (removeButton) { |
| | | const fieldId = this.getFieldId(removeButton); |
| | | const termId = removeButton.closest('.selected-item').dataset.id??false; |
| | | if (fieldId && termId) { |
| | | this.removeSelected(termId, fieldId); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | if (e.target.matches('.modal-close')) { |
| | | this.modal?.handleClose(); |
| | | return; |
| | | } |
| | | |
| | | const backToParent = window.targetCheck(e, this.selectors.nav.back); |
| | | if (backToParent) { |
| | | this.navigateToParent(); |
| | | return; |
| | | } |
| | | |
| | | const toChild = window.targetCheck(e, this.selectors.nav.child); |
| | | if (toChild) { |
| | | const termItem = e.target.closest('li'); |
| | | const termId = parseInt(termItem.dataset.id); |
| | | |
| | | if (termId) { |
| | | this.navigateTo(termId); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const pathLevel = window.targetCheck(e, this.selectors.nav.pathLevel); |
| | | if (pathLevel) { |
| | | const termId = parseInt(pathLevel.dataset.id)??0; |
| | | this.navigateTo(termId); |
| | | } |
| | | |
| | | const dropdown = window.targetCheck(e, field.selectors.dropdown); |
| | | if (dropdown) { |
| | | // reset the timer for hiding the dropdown |
| | | this.scheduleHideDropdown(fieldId); |
| | | return; |
| | | } |
| | | |
| | | const clearSearch = window.targetCheck(e, this.selectors.search.clear); |
| | | if (clearSearch) { |
| | | const field = this.currentField(); |
| | | if (field && field.ui.search) { |
| | | field.ui.search.value = ''; |
| | | this.store.setFilters({ |
| | | search: '', |
| | | page: 1, |
| | | parent: this.store.filters.parent || 0 |
| | | }); |
| | | } |
| | | if (this.ui.search.input) { |
| | | this.ui.search.input.value = ''; |
| | | } |
| | | } |
| | | |
| | | } |
| | | handleChange(e) { |
| | | if (!this.container.contains(e.target)) { |
| | | return; |
| | | } |
| | | if (e.target.type !== 'checkbox') return; |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | |
| | | /*********************************************************************** |
| | | * UNIFIED SEARCH |
| | | ***********************************************************************/ |
| | | |
| | | performSearch(query, context = 'modal', fieldId = null) { |
| | | const field = context === 'autocomplete' |
| | | ? this.fields.get(fieldId) |
| | | : this.currentConfig; |
| | | |
| | | const termId = parseInt(e.target.dataset.id); |
| | | let fieldId = this.getFieldId(e.target); |
| | | if (e.target.checked) { |
| | | this.addSelected(termId, fieldId); |
| | | } else { |
| | | this.removeSelected(termId, fieldId); |
| | | } |
| | | } |
| | | //For search in modal or field autocomplete |
| | | handleInput(e) { |
| | | let fieldId = this.getFieldId(e.target)??this.activeField; |
| | | if (!fieldId) return; |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | // Autocomplete validation |
| | | if (context === 'autocomplete') { |
| | | field.currentAutocompleteQuery = query; |
| | | |
| | | if (query.length < 2) { |
| | | if (field.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = true; |
| | | } |
| | | return; |
| | | } |
| | | |
| | | this.searchContexts.set(fieldId, 'autocomplete'); |
| | | if (!this.container.open) { |
| | | this.activeField = fieldId; |
| | | |
| | | if (field.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = false; |
| | | } |
| | | } |
| | | |
| | | // Debounced search |
| | | const query = e.target.value.trim(); |
| | | window.debouncer.schedule( |
| | | `taxonomy-search-${context}-${fieldId || 'modal'}`, |
| | | `${fieldId}-search`, |
| | | async () => { |
| | | await this.store.setFilters({ |
| | | taxonomy: field.taxonomy, |
| | |
| | | page: 1, |
| | | parent: query ? 0 : (this.store.filters.parent || 0) |
| | | }); |
| | | |
| | | if (context === 'modal') { |
| | | window.removeChildren(this.ui.termsList); |
| | | if (this.container.open) { |
| | | window.removeChildren(this.ui.terms.list); |
| | | } |
| | | }, |
| | | 300 |
| | | 100 |
| | | ); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * MODAL OPERATIONS |
| | | ***********************************************************************/ |
| | | handleFocus(e) { |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || !field) return; |
| | | if (!field.hasAutocomplete && !field.hasSearch) return; |
| | | |
| | | setActiveField(fieldId, openModal = false) { |
| | | this.activeField = fieldId; |
| | | this.currentConfig = this.fields.get(fieldId); |
| | | window.debouncer.cancel(`${fieldId}-search-results`); |
| | | |
| | | if (openModal) { |
| | | this.modalInstance.handleOpen(); |
| | | if (!this.container.open){ |
| | | this.activeField = fieldId; |
| | | this.preloadTaxonomy(field.taxonomy); |
| | | } |
| | | } |
| | | |
| | | this.store.setFilter('taxonomy', this.currentConfig.taxonomy); |
| | | //Hide autocomplete dropdown on blur |
| | | handleBlur(e) { |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || ! field) return; |
| | | if (!field.hasAutocomplete) return; |
| | | |
| | | // Reset modal selection state |
| | | this.selectedTerms.clear(); |
| | | this.scheduleHideDropdown(fieldId); |
| | | } |
| | | |
| | | // Copy field selections to modal |
| | | 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 |
| | | }); |
| | | scheduleHideDropdown(fieldId){ |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | window.debouncer.schedule( |
| | | `${fieldId}-search-results`, |
| | | () => { |
| | | this.activeField = null; |
| | | field.ui.dropdown.hidden = true; |
| | | }, |
| | | 1500 |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | MODAL |
| | | ******************************************************************/ |
| | | initModal() { |
| | | this.modalID = 'dialog#jvb-selector'; |
| | | this.container = document.querySelector(this.modalID); |
| | | |
| | | this.modal = new window.jvbModal( |
| | | this.container, |
| | | { |
| | | handleForm: false, |
| | | save: null, |
| | | open: null |
| | | } |
| | | ); |
| | | this.modal.subscribe((event, data) => { |
| | | switch (event) { |
| | | |
| | | } |
| | | }); |
| | | } |
| | | |
| | | handleModalClick(e) { |
| | | if (window.targetCheck(e, '.remove-item')) { |
| | | const 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')) { |
| | | const termItem = e.target.closest('li'); |
| | | this.navigateToChild( |
| | | parseInt(termItem.dataset.id), |
| | | termItem.querySelector('.term-name').textContent |
| | | ); |
| | | } else if (window.targetCheck(e, '.path-level')) { |
| | | const pathLevel = window.targetCheck(e, '.path-level'); |
| | | this.navigateToPath(parseInt(pathLevel.dataset.id) || 0); |
| | | } |
| | | } |
| | | toggleModal(fieldId, open = true) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | handleModalChange(e) { |
| | | if (e.target.type !== 'checkbox') return; |
| | | |
| | | 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); |
| | | if (open) { |
| | | this.openModal(fieldId); |
| | | } else { |
| | | this.removeSelectedTermFromModal(termId); |
| | | this.closeModal(); |
| | | } |
| | | } |
| | | |
| | | openModal() { |
| | | if (!this.currentConfig) { |
| | | console.error('No active field set'); |
| | | return; |
| | | } |
| | | openModal(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | this.updateModalUI(); |
| | | this.updateModalSelections(); |
| | | |
| | | window.removeChildren(this.ui.termsList); |
| | | this.showLoading(); |
| | | } |
| | | |
| | | 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.activeField) { |
| | | this.saveSelectionsToField(this.activeField); |
| | | } |
| | | |
| | | this.activeField = null; |
| | | this.currentConfig = null; |
| | | } |
| | | |
| | | updateModalUI() { |
| | | const singular = this.getLabel(this.currentConfig.taxonomy, 'single'); |
| | | const plural = this.getLabel(this.currentConfig.taxonomy, 'plural'); |
| | | |
| | | this.ui.modal.title.textContent = `Select ${plural}`; |
| | | |
| | | this.activeField = fieldId; |
| | | this.ui.modal.title.textContent = `Select ${field.plural}`; |
| | | if (this.ui.search.container) { |
| | | this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none'; |
| | | this.ui.search.container.hidden = !field.canSearch; |
| | | } |
| | | |
| | | if (this.ui.create.details) { |
| | | this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none'; |
| | | this.ui.create.details.hidden = !this.currentConfig.canCreate; |
| | | this.ui.create.details.hidden = !field.canCreate; |
| | | |
| | | if (this.ui.create.summary) { |
| | | this.ui.create.summary.textContent = `Add new ${singular}`; |
| | | this.ui.create.summary.textContent = `Add new ${field.singular}`; |
| | | } |
| | | |
| | | if (this.ui.create.label.name) { |
| | | this.ui.create.label.name.textContent = `Name this ${singular}`; |
| | | this.ui.create.label.name.textContent = `Name this ${field.singular}`; |
| | | } |
| | | if (this.ui.create.label.parent) { |
| | | this.ui.create.label.parent.textContent = `Nest it under`; |
| | | } |
| | | } |
| | | let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`; |
| | | |
| | | this.a11y?.announce(`Opened ${singular} selection. Choose from checkboxes or search to filter results.`); |
| | | } |
| | | window.removeChildren(this.ui.terms.list); |
| | | this.modal.handleOpen(); |
| | | this.setLoading(); |
| | | |
| | | updateModalSelections() { |
| | | window.removeChildren(this.ui.selectedTerms); |
| | | |
| | | this.selectedTerms.forEach((termData, id) => { |
| | | this.addTermDisplay(id, termData.name, termData.path, 'modal'); |
| | | this.store.setFilters({ |
| | | taxonomy: field.taxonomy, |
| | | page: 1, |
| | | search: '', |
| | | parent: 0, |
| | | }); |
| | | |
| | | this.checkSelectionLimits(); |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | addSelectedTermToModal(id, name, path) { |
| | | this.selectedTerms.set(id, { id, name, path }); |
| | | |
| | | this.addTermDisplay(id, name, path, 'modal'); |
| | | this.checkSelectionLimits(); |
| | | |
| | | const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); |
| | | if (checkbox) checkbox.checked = true; |
| | | } |
| | | |
| | | removeSelectedTermFromModal(id) { |
| | | this.selectedTerms.delete(parseInt(id)); |
| | | |
| | | const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`); |
| | | if (selectedItem) selectedItem.remove(); |
| | | |
| | | const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); |
| | | if (checkbox) checkbox.checked = false; |
| | | |
| | | this.checkSelectionLimits(); |
| | | } |
| | | |
| | | checkSelectionLimits() { |
| | | if (!this.currentConfig || this.currentConfig.maxSelection === 0) { |
| | | return; |
| | | } |
| | | |
| | | this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection; |
| | | |
| | | this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { |
| | | if (!checkbox.checked) { |
| | | checkbox.disabled = this.disabled; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | saveSelectionsToField(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | closeModal() { |
| | | this.modal.handleClose(); |
| | | const field = this.fields.get(this.activeField); |
| | | if (!field) return; |
| | | this.observer.unobserve(this.ui.terms.sentinel); |
| | | window.removeChildren(this.ui.terms.list); |
| | | |
| | | field.selectedTerms.clear(); |
| | | window.removeChildren(field.selectedContainer); |
| | | |
| | | this.selectedTerms.forEach((termData, id) => { |
| | | field.selectedTerms.add(id); |
| | | this.addTermDisplay(id, termData.name, termData.path, 'field', fieldId); |
| | | this.notify('selected-terms', { |
| | | terms: this.selectedTerms.get(this.activeField), |
| | | taxonomy: field.taxonomy |
| | | }); |
| | | |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | this.activeField = null; |
| | | |
| | | let message = `Closed ${field.singular} selector.`; |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * TERM DISPLAY |
| | | ***********************************************************************/ |
| | | navigateToParent() { |
| | | const current = this.store.filters.parent; |
| | | if (current === 0) return; |
| | | let term = this.store.get(parseInt(current)); |
| | | if (!term) return; |
| | | let parent = term.parent; |
| | | this.navigateTo(parseInt(parent)); |
| | | } |
| | | navigateTo(termId = 0) { |
| | | termId = parseInt(termId)??0; |
| | | this.store.setFilters({parent: termId, page: 1}); |
| | | window.removeChildren(this.ui.terms.list); |
| | | this.updateBreadcrumbs(termId); |
| | | } |
| | | |
| | | addTermDisplay(termId, termName, termPath, context = 'field', fieldId = null) { |
| | | const config = context === 'field' |
| | | ? this.fields.get(fieldId) |
| | | : this.currentConfig; |
| | | nextPage() { |
| | | let current = this.store.filters.page; |
| | | let page = Math.min(current++, this.store.lastResponse.total); |
| | | this.store.setFilters({page:page}); |
| | | } |
| | | prevPage() { |
| | | let current = this.store.filters.page; |
| | | let page = Math.max(current - 1, 1); |
| | | this.store.setFilters({page:page}); |
| | | } |
| | | |
| | | const container = context === 'field' |
| | | ? config.selectedContainer |
| | | : this.ui.selectedTerms; |
| | | |
| | | if (container.querySelector(`[data-id="${termId}"]`)) return; |
| | | addTermToModal(termId) { |
| | | const term = this.store.get(termId); |
| | | if (!term) return; |
| | | |
| | | const item = window.getTemplate('selectedTerm'); |
| | | item.dataset.id = termId; |
| | | item.dataset.path = termPath; |
| | | item.dataset.name = termName; |
| | | item.dataset.taxonomy = config.taxonomy; |
| | | item.querySelector('.item-name').textContent = termPath; |
| | | item.querySelector('button').title = `Remove ${termName}`; |
| | | item.querySelector('span').textContent = term.path; |
| | | item.querySelector('button').title = `Remove ${name}`; |
| | | |
| | | container.appendChild(item); |
| | | |
| | | if (context === 'modal') { |
| | | const checkbox = this.ui.termsList.querySelector(`input[value="${termId}"]`); |
| | | if (checkbox) checkbox.checked = true; |
| | | } |
| | | this.ui.selected.append(item); |
| | | } |
| | | |
| | | removeSelectedTerm(fieldId, termId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | field.selectedTerms.delete(parseInt(termId)); |
| | | |
| | | const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`); |
| | | if (selectedItem) selectedItem.remove(); |
| | | |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | |
| | | 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) { |
| | | value.split(',') |
| | | .map(id => parseInt(id.trim())) |
| | | .filter(id => !isNaN(id)) |
| | | .forEach(id => field.selectedTerms.add(id)); |
| | | |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * NAVIGATION |
| | | ***********************************************************************/ |
| | | |
| | | navigateToParent() { |
| | | this.store.setFilters({ parent: 0, page: 1 }); |
| | | window.removeChildren(this.ui.termsList); |
| | | this.ui.breadcrumbs.back.hidden = true; |
| | | } |
| | | |
| | | navigateToChild(termId, termName) { |
| | | this.store.setFilters({ parent: termId, page: 1 }); |
| | | window.removeChildren(this.ui.termsList); |
| | | this.updateBreadcrumbs(termId, termName); |
| | | this.ui.breadcrumbs.back.hidden = false; |
| | | } |
| | | |
| | | navigateToPath(parentId) { |
| | | this.store.setFilters({ parent: parentId, page: 1 }); |
| | | window.removeChildren(this.ui.termsList); |
| | | this.ui.breadcrumbs.back.hidden = parentId === 0; |
| | | } |
| | | |
| | | loadMoreTerms() { |
| | | const currentPage = this.store.filters.page || 1; |
| | | this.store.setFilter('page', currentPage + 1); |
| | | } |
| | | |
| | | updateBreadcrumbs(termId, termName) { |
| | | const breadcrumb = window.getTemplate('termBreadcrumb'); |
| | | breadcrumb.dataset.id = termId; |
| | | breadcrumb.textContent = termName; |
| | | breadcrumb.title = termName; |
| | | |
| | | const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`); |
| | | if (existingCrumb) { |
| | | while (existingCrumb.nextElementSibling) { |
| | | existingCrumb.nextElementSibling.remove(); |
| | | /****************************************************************** |
| | | FIELDS |
| | | ******************************************************************/ |
| | | scanExistingFields(container = document.body) { |
| | | container.querySelectorAll('[data-type="selector"]').forEach( |
| | | selector => { |
| | | try { |
| | | this.registerField(selector); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | | action: 'scanExistingFields', |
| | | container: selector.dataset.name |
| | | }); |
| | | } |
| | | } |
| | | } else { |
| | | this.ui.breadcrumbs.nav.appendChild(breadcrumb); |
| | | } |
| | | ); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * RENDERING |
| | | ***********************************************************************/ |
| | | |
| | | renderTerms(terms = null, append = false, showPath = false) { |
| | | if (!terms) terms = this.store.getFiltered(); |
| | | |
| | | if (!append) window.removeChildren(this.ui.termsList); |
| | | |
| | | if (terms.length === 0) { |
| | | if (!append) this.showEmptyState(); |
| | | registerField(element, options = {}) { |
| | | let input = element.querySelector('input[type="hidden"]'); |
| | | if (!input) { |
| | | console.warn('TaxonomySelector: No hidden input found for field', element); |
| | | return; |
| | | } |
| | | |
| | | const currentParent = this.store.filters.parent || 0; |
| | | this.ui.breadcrumbs.back.hidden = currentParent === 0; |
| | | if (!('fieldId' in element.dataset)) { |
| | | element.dataset.fieldId = window.generateID('selector'); |
| | | } |
| | | const fieldId = element.dataset.fieldId; |
| | | |
| | | |
| | | let selectors = this.selectors.field; |
| | | let button = element.querySelector('button.taxonomy-toggle'); |
| | | if (options.size === 0){ |
| | | if (!button) return; |
| | | options = button.dataset; |
| | | if (options.size === 0) return; |
| | | } else if (Object.hasOwn(options, 'toggle')) { |
| | | button = document.querySelector(options.toggle); |
| | | selectors.toggle = options.toggle; |
| | | } |
| | | |
| | | const config = { |
| | | id: fieldId, |
| | | value: input, |
| | | element: element, |
| | | taxonomy: options.taxonomy??false, |
| | | singular: options.single??'', |
| | | plural: options.plural??'', |
| | | name: element.dataset.field, |
| | | canSearch: Object.hasOwn(options, 'search'), |
| | | limit: options.limit??0, |
| | | hasAutocomplete: Object.hasOwn(options, 'autocomplete'), |
| | | canCreate: Object.hasOwn(options, 'creatable'), |
| | | isRequired: Object.hasOwn(options, 'required'), |
| | | toggle: button, |
| | | selectors: selectors, |
| | | ui: window.uiFromSelectors(selectors, element), |
| | | checked: false, |
| | | }; |
| | | if (!config.taxonomy) return; |
| | | this.fields.set(fieldId, config); |
| | | |
| | | //Check for stored selected terms in hidden input |
| | | let selected = new Set(); |
| | | input.value.value.trim() |
| | | .split(',') |
| | | .map(id => parseInt(id.trim())) |
| | | .filter(id => !isNaN(id)) |
| | | .forEach(id => selected.add(id)); |
| | | this.selectedTerms.set(fieldId, selected); |
| | | |
| | | if (this.isInitializing) { |
| | | this.batchFetch.add(config.taxonomy); |
| | | } |
| | | this.updateFieldUI(fieldId); |
| | | |
| | | return fieldId; |
| | | } |
| | | |
| | | addSelected(termId, fieldId = null) { |
| | | if (!fieldId) fieldId = this.activeField; |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | const term = this.store.get(termId); |
| | | if (!field || !term) return; |
| | | |
| | | const selected = this.selectedTerms.get(fieldId); |
| | | if (field.limit !== 0 && selected.size >= field.limit) return; |
| | | |
| | | selected.add(parseInt(termId)); |
| | | this.addTermToDisplay(termId, fieldId); |
| | | this.updateFieldValue(fieldId); |
| | | this.checkLimits(fieldId); |
| | | } |
| | | removeSelected(termId, fieldId = null) { |
| | | if (!fieldId) fieldId = this.activeField; |
| | | const field = this.fields.get(fieldId); |
| | | const term = this.store.get(termId); |
| | | if (!field || !term) return; |
| | | this.selectedTerms.get(fieldId).delete(parseInt(termId)); |
| | | |
| | | const selectedItem = field.ui.selected.querySelector(`[data-i"${termId}"]`); |
| | | if (selectedItem) selectedItem.remove(); |
| | | if (this.container.open) { |
| | | let item = this.ui.selected.querySelector(`[data-id="${termId}"]`); |
| | | if (item) item.remove(); |
| | | } |
| | | this.updateFieldValue(fieldId); |
| | | this.checkLimits(fieldId); |
| | | } |
| | | updateFieldValue(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | let selected = Array.from(this.selectedTerms.get(fieldId)); |
| | | field.ui.value = selected.join(','); |
| | | } |
| | | |
| | | checkLimits(fieldId) { |
| | | if (!this.container.open) return; |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || field.limit === 0) return; |
| | | const disabled = this.selectedTerms.get(fieldId).size >= field.limit; |
| | | this.setCheckboxes(disabled); |
| | | } |
| | | |
| | | updateFieldUI(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | let selected = this.selectedTerms.get(fieldId); |
| | | if (!field || selected.size === 0) return; |
| | | |
| | | Array.from(selected).forEach(termId => { |
| | | this.addTermToDisplay(termId, fieldId); |
| | | }); |
| | | } |
| | | |
| | | updateFieldsForTaxonomy(taxonomy) { |
| | | let fields = Array.from(this.fields.values()) |
| | | .filter(field => !field.checked && field.taxonomy === taxonomy); |
| | | const hasItems = Array.from(this.store.data.values()) |
| | | .some(term=>term.taxonomy === taxonomy); |
| | | |
| | | fields.forEach(field => { |
| | | field.ui.toggle.disabled = !hasItems && !field.canCreate; |
| | | field.ui.toggle.title = !hasItems |
| | | ? `No ${field.singular} available` |
| | | : `Select ${field.plural}`; |
| | | |
| | | field.checked = true; |
| | | }); |
| | | } |
| | | |
| | | showModalTerms(append = true, showPath = false) { |
| | | const terms = this.store.getFiltered(); |
| | | if (terms.size === 0) return; |
| | | if (!append) { |
| | | window.removeChildren(this.ui.terms.list); |
| | | } |
| | | |
| | | const currentParent = this.store.filters.parent??0; |
| | | this.ui.nav.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 |
| | | show: showPath, |
| | | ... term |
| | | }); |
| | | |
| | | if (element) fragment.appendChild(element); |
| | | if (element) { |
| | | fragment.appendChild(element); |
| | | } |
| | | }); |
| | | |
| | | this.ui.termsList.appendChild(fragment); |
| | | this.ui.terms.list.append(fragment); |
| | | } |
| | | createTermElement(term) { |
| | | if (!term || !term.name) return null; |
| | | |
| | | createTermElement(termData) { |
| | | if (!termData?.name) return null; |
| | | const item = window.getTemplate('termListItem'); |
| | | item.dataset.id = term.id; |
| | | |
| | | const listItem = window.getTemplate('termListItem'); |
| | | listItem.dataset.id = termData.id; |
| | | const isSelected = this.selectedTerms.get(this.activeField).has(term.id); |
| | | let [ |
| | | checkbox, |
| | | label, |
| | | nameSpan |
| | | ] = [ |
| | | item.querySelector('input'), |
| | | item.querySelector('label'), |
| | | item.querySelector('span, .term-name') |
| | | ]; |
| | | |
| | | const isSelected = this.selectedTerms.has(termData.id); |
| | | const checkbox = listItem.querySelector('input'); |
| | | const label = listItem.querySelector('label'); |
| | | const nameSpan = listItem.querySelector('.term-name'); |
| | | |
| | | 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('termChildrenToggle'); |
| | | childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`; |
| | | listItem.appendChild(childrenToggle); |
| | | let field = this.currentField(); |
| | | let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit; |
| | | if (checkbox && label && nameSpan) { |
| | | [ |
| | | checkbox.id, |
| | | checkbox.name, |
| | | checkbox.value, |
| | | checkbox.disabled, |
| | | checkbox.checked, |
| | | label.htmlFor, |
| | | label.title, |
| | | label.dataset.path, |
| | | nameSpan.textContent |
| | | ] = [ |
| | | `${field.element.id}-${term.id}`, |
| | | `${field.container.id}-${field.taxonomy}-select`, |
| | | term.id, |
| | | !isSelected && limitReached, |
| | | isSelected, |
| | | `${field.element.id}-${term.id}`, |
| | | term.path??term.name, |
| | | term.path, |
| | | term.show ? term.path : term.name |
| | | ]; |
| | | if (term.hasChildren) { |
| | | const toggle = window.getTemplate('termChildrenToggle'); |
| | | if (toggle) { |
| | | toggle.ariaLabel = `View ${field.plural} nested under ${term.name}`; |
| | | item.append(toggle); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return listItem; |
| | | return item; |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * AUTOCOMPLETE |
| | | ***********************************************************************/ |
| | | showAutocompleteTerms() { |
| | | const field = this.currentField(); |
| | | const terms = this.currentTerms(); |
| | | if (!field || terms.size ===0) return; |
| | | |
| | | showAutocompleteResults(field, terms, query) { |
| | | if (!field?.autocompleteDropdown) return; |
| | | |
| | | const dropdown = field.autocompleteDropdown; |
| | | const dropdown = field.ui.dropdown; |
| | | window.removeChildren(dropdown); |
| | | |
| | | if (terms.length === 0) { |
| | | this.showEmptyState('No items found.', dropdown); |
| | | this.showEmptyState(`No ${field.plural} found.`, dropdown); |
| | | } else { |
| | | const fragment = document.createDocumentFragment(); |
| | | |
| | | terms.forEach(term => { |
| | | const item = this.createAutocompleteItem(field, term); |
| | | if (item) fragment.appendChild(item); |
| | | }); |
| | | |
| | | dropdown.appendChild(fragment); |
| | | const item = this.createAutocompleteTerm(term); |
| | | if (item) { |
| | | dropdown.append(item); |
| | | } |
| | | }) |
| | | } |
| | | |
| | | // Create button if allowed and no exact match |
| | | const currentQuery = field.currentAutocompleteQuery || query; |
| | | if (field.canCreate && currentQuery) { |
| | | const exactMatch = terms.find(term => |
| | | term.name.toLowerCase() === currentQuery.toLowerCase() |
| | | ); |
| | | |
| | | if (!exactMatch) { |
| | | dropdown.appendChild(this.createAutocompleteCreateButton(currentQuery)); |
| | | const query = field.ui.search?.value; |
| | | if (field.canCreate && query.length >= 2 && this.creator) { |
| | | const createButton = this.createTermButton(query); |
| | | if (createButton) { |
| | | dropdown.append(createButton); |
| | | } |
| | | } |
| | | |
| | | dropdown.hidden = false; |
| | | } |
| | | createAutocompleteTerm(term) { |
| | | const item = window.getTemplate('autocompleteItem'); |
| | | if (!item) return; |
| | | |
| | | createAutocompleteItem(field, term) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'autocomplete-item'; |
| | | button.dataset.id = term.id; |
| | | button.dataset.name = term.name; |
| | | button.dataset.path = term.path || term.name; |
| | | button.textContent = term.path || term.name; |
| | | |
| | | button.addEventListener('click', () => { |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | this.addTermDisplay(term.id, term.name, term.path, 'field', field.id); |
| | | |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | field.autocompleteDropdown.hidden = true; |
| | | const input = field.container.querySelector('input[data-autocomplete]'); |
| | | if (input) input.value = ''; |
| | | }); |
| | | |
| | | return button; |
| | | item.dataset.id = term.id; |
| | | item.textContent = term.path || term.name; |
| | | return item; |
| | | } |
| | | |
| | | createAutocompleteCreateButton(query) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'autocomplete-item create-term'; |
| | | button.dataset.query = query; |
| | | |
| | | const strong = document.createElement('strong'); |
| | | strong.textContent = 'Create: '; |
| | | |
| | | button.appendChild(strong); |
| | | button.appendChild(document.createTextNode(`"${query}"`)); |
| | | |
| | | return button; |
| | | } |
| | | |
| | | showAutocompleteError(fieldId) { |
| | | /****************************************************************** |
| | | UI |
| | | ******************************************************************/ |
| | | addTermToDisplay(termId, fieldId) { |
| | | const term = this.store.get(termId); |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.autocompleteDropdown) return; |
| | | if (!term || !field) return; |
| | | //if the term already exists in the selected items, bail early |
| | | if (field.ui.selected.querySelector(`[data-id="${termId}"]`)) return; |
| | | |
| | | window.removeChildren(field.autocompleteDropdown); |
| | | this.showEmptyState('Hmmm... something went wrong', field.autocompleteDropdown); |
| | | const item = window.getTemplate('selectedTerm'); |
| | | if (!item) return; |
| | | |
| | | item.dataset.id = termId; |
| | | item.dataset.taxonomy = field.taxonomy; |
| | | item.querySelector('.item-name').textContent = term.path; |
| | | item.querySelector('button').title = `Remove ${term.name}`; |
| | | |
| | | field.ui.selected.append(item); |
| | | |
| | | if (this.container.open) { |
| | | this.addTermToModal(termId); |
| | | const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`); |
| | | if (checkbox) checkbox.checked = true; |
| | | } |
| | | } |
| | | createTermButton(query) { |
| | | const button = window.getTemplate('autocompleteButton'); |
| | | if(!button) return; |
| | | |
| | | let queryEl = button.querySelector('span'); |
| | | queryEl.textContent = `"${query}"`; |
| | | |
| | | return button; |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * UI STATES |
| | | ***********************************************************************/ |
| | | updateBreadcrumbs(termId) { |
| | | const nav = this.ui.nav.nav; |
| | | if (!nav) return; |
| | | const existingCrumb = Array.from(nav.children) |
| | | .find(crumb => parseInt(crumb.dataset.id) === termId); |
| | | |
| | | 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; |
| | | |
| | | const 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); |
| | | if (existingCrumb) { |
| | | // Remove all siblings after this crumb |
| | | let nextSibling = existingCrumb.nextElementSibling; |
| | | while (nextSibling) { |
| | | const toRemove = nextSibling; |
| | | nextSibling = nextSibling.nextElementSibling; |
| | | toRemove.remove(); |
| | | } |
| | | } else { |
| | | this.ui.loading.text.textContent = message; |
| | | // Add new breadcrumb |
| | | const term = this.store.get(termId); |
| | | if (!term) return; |
| | | |
| | | const crumb = window.getTemplate('termBreadcrumb'); |
| | | if (!crumb) return; |
| | | |
| | | crumb.dataset.id = termId; |
| | | crumb.textContent = term.name; |
| | | crumb.title = term.name; |
| | | |
| | | nav.append(crumb); |
| | | } |
| | | } |
| | | |
| | | hideLoading() { |
| | | this.ui.loading.loading.hidden = true; |
| | | this.modal.classList.remove('loading'); |
| | | updateSelectionCount() { |
| | | if (!this.container.open) return; |
| | | const field = this.fields.get(this.activeField); |
| | | if (!field) return; |
| | | |
| | | if (this.stopTyping) { |
| | | this.stopTyping(); |
| | | } |
| | | } |
| | | if (this.ui.modal.count) { |
| | | const total = this.selectedTerms.get(this.activeField).size; |
| | | |
| | | showEmptyState(message = 'No items found.', container = null) { |
| | | if (!container) container = this.ui.termsList; |
| | | |
| | | const emptyElement = window.getTemplate('noResults'); |
| | | const messageSpan = emptyElement.querySelector('span'); |
| | | |
| | | if (message && messageSpan) { |
| | | messageSpan.textContent = message; |
| | | this.ui.modal.count.textContent = field.limit > 0 |
| | | ? `${total} of ${field.limit} ${field.plural} selected` |
| | | : `${total} ${field.plural} selected`; |
| | | } |
| | | |
| | | container.appendChild(emptyElement); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * UTILITIES |
| | | ***********************************************************************/ |
| | | /****************************************************************** |
| | | UTILITY |
| | | ******************************************************************/ |
| | | currentField() { |
| | | return this.fields.get(this.activeField)??false; |
| | | } |
| | | currentTerms() { |
| | | return this.store.getFiltered(); |
| | | } |
| | | needsCreator() { |
| | | return Array.from(this.fields.values()).some(field => |
| | | field.canCreate || field.hasAutocomplete |
| | | ); |
| | | } |
| | | |
| | | getFieldId(element) { |
| | | if (element.dataset.fieldId) return element.dataset.fieldId; |
| | |
| | | return fieldContainer?.dataset.fieldId || null; |
| | | } |
| | | |
| | | getLabel(taxonomy, type = 'single') { |
| | | return jvbSettings.labels[taxonomy]?.[type] || taxonomy; |
| | | } |
| | | |
| | | async batchFetchTaxonomies() { |
| | | if (this.taxonomiesToFetch.size === 0) return; |
| | | |
| | | const taxonomies = Array.from(this.taxonomiesToFetch); |
| | | this.taxonomiesToFetch.clear(); |
| | | |
| | | this.store.setFilters({ |
| | | taxonomy: taxonomies.join(','), |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | /** |
| | | * Sets all checkbox disabled (or not) |
| | | * @param {Boolean} disabled |
| | | */ |
| | | setCheckboxes(disabled) { |
| | | this.ui.terms.list.querySelectorAll('input[type=checkbox]').forEach(checkbox => { |
| | | if (!checkbox.checked) { |
| | | checkbox.disabled = disabled; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | async preloadTaxonomy(taxonomy) { |
| | | await this.store.setFilters({ |
| | | /****************************************************************** |
| | | DATASTORE HELPERS |
| | | ******************************************************************/ |
| | | handleStoreEvent(event, data) { |
| | | const handlers = { |
| | | 'data-loaded': () => this.handleDataLoaded(), |
| | | 'filters-changed': () => this.handleFiltersChanged(), |
| | | 'fetch-error': () => this.handleFetchError() |
| | | }; |
| | | |
| | | handlers[event]?.(); |
| | | } |
| | | handleDataLoaded() { |
| | | const taxonomy = this.store.filters.taxonomy; |
| | | if (taxonomy?.includes(',')) { |
| | | const taxonomies = taxonomy.split(',').map(t => t.trim()); |
| | | taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax)); |
| | | } |
| | | |
| | | if (this.container.open) { |
| | | this.showResults(); |
| | | return; |
| | | } |
| | | if (this.activeField) { |
| | | this.showResults(true); |
| | | } |
| | | } |
| | | |
| | | showResults(isAutoComplete = false) { |
| | | this.setLoading(false); |
| | | const terms = this.store.getFiltered(); |
| | | const filters = this.store.filters; |
| | | const response = this.store.lastResponse?.page || {}; |
| | | const isSearch = filters.search && filters.search.length > 0; |
| | | const append = filters.page > 1; |
| | | const field = this.currentField(); |
| | | |
| | | this.notify('terms-loaded', { |
| | | terms, |
| | | filters |
| | | }); |
| | | |
| | | if (terms.length === 0) { |
| | | if (!append) { |
| | | this.showEmptyState(isSearch ? `No matching ${field.plural}.` : `No ${field.plural} available.`); |
| | | } |
| | | this.observer.unobserve(this.ui.terms.sentinel); |
| | | } else { |
| | | if (!isAutoComplete) { |
| | | this.showModalTerms(append, isSearch); |
| | | |
| | | if (response.has_more) { |
| | | this.observer.observe(this.ui.terms.sentinel); |
| | | } else { |
| | | this.observer.unobserve(this.ui.terms.sentinel); |
| | | } |
| | | } else { |
| | | this.showAutocompleteTerms() |
| | | } |
| | | } |
| | | |
| | | this.a11y.announce(terms.length, append); |
| | | } |
| | | handleFiltersChanged() { |
| | | // if (this.modal?.open) { |
| | | // this.setLoading(); |
| | | // } |
| | | } |
| | | |
| | | handleFetchError(error) { |
| | | this.setLoading(false); |
| | | } |
| | | async batchFetchTaxonomies() { |
| | | if (this.batchFetch.size === 0) return; |
| | | |
| | | const taxonomies = Array.from(this.batchFetch); |
| | | taxonomies.forEach(tax => this.loadedTaxonomies.add(tax)); |
| | | this.batchFetch.clear(); |
| | | |
| | | try { |
| | | taxonomies.forEach(tax => this.loadedTaxonomies.add(tax)); |
| | | |
| | | await this.store.setFilters({ |
| | | taxonomy: taxonomies.join(','), |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }); |
| | | } catch (error) { |
| | | console.error('Failed to batch fetch taxonomies:', error); |
| | | } |
| | | } |
| | | |
| | | preloadTaxonomy(taxonomy) { |
| | | if (this.loadedTaxonomies.has(taxonomy)) return; |
| | | |
| | | this.store.setFilters( { |
| | | taxonomy: taxonomy, |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }); |
| | | |
| | | this.loadedTaxonomies.add(taxonomy); |
| | | } |
| | | |
| | | handleError(error, context, detail = null) { |
| | | console.error(`Taxonomy ${context} error:`, error, detail); |
| | | /************************************************** |
| | | LOADING |
| | | **************************************************/ |
| | | setLoading(on = true) { |
| | | this.ui.loading.loading.hidden = on; |
| | | this.modal.classList.toggle('loading', on); |
| | | |
| | | if (this.error?.log) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | | action: context, |
| | | detail: detail |
| | | }); |
| | | } |
| | | if (on) { |
| | | let searchQuery = this.store.filters.search || ''; |
| | | searchQuery = searchQuery === '' ? false : searchQuery; |
| | | const currentParent = this.store.filters.parent || 0; |
| | | const message = searchQuery |
| | | ? `Searching for "${searchQuery} items` : |
| | | currentParent === 0 |
| | | ? 'loading items' |
| | | : 'loading child items'; |
| | | |
| | | if (this.modal?.open) { |
| | | this.showEmptyState('Error loading. Please try again.'); |
| | | if (window.typeLoop && this.ui.loading.text) { |
| | | this.stopTyping = window.typeLoop(this.ui.loading.text, message); |
| | | } else { |
| | | this.ui.loading.text.textContenet = message; |
| | | } |
| | | } else { |
| | | if (this.stopTyping) { |
| | | this.stopTyping(); |
| | | this.stopTyping = null; |
| | | } |
| | | } |
| | | } |
| | | |
| | | showEmptyState(message = 'No items found.', container = null) { |
| | | if (!container) container = this.ui.terms.list; |
| | | const emptyElement = window.getTemplate('noTermResults'); |
| | | const span = emptyElement.querySelector('span'); |
| | | if (message && span) { |
| | | span.textContent = message; |
| | | } |
| | | container.append(emptyElement); |
| | | } |
| | | /************************************************** |
| | | SUBSCRIBERS |
| | | **************************************************/ |
| | | subscribe(callback) { |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | | |
| | | notify(event, data = {}) { |
| | | notify(event, data={}) { |
| | | this.subscribers.forEach(callback => { |
| | | try { |
| | | callback(event, data); |
| | |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /****************************************************** |
| | | CLEANUP |
| | | ******************************************************/ |
| | | destroy() { |
| | | document.removeEventListener('click', this.handleClick); |
| | | document.removeEventListener('change', this.handleChange); |
| | | document.removeEventListener('input', this.handleInput); |
| | | document.removeEventListener('focus', this.handleFocus); |
| | | document.removeEventListener('blur', this.handleBlur); |
| | | document.removeEventListener('click', this.clickHandler); |
| | | document.removeEventListener('change', this.changeHandler); |
| | | document.removeEventListener('input', this.inputHandler); |
| | | document.removeEventListener('focus', this.focusHandler); |
| | | document.removeEventListener('blur', this.blurHandler); |
| | | |
| | | this.observer?.disconnect(); |
| | | this.store.destroy(); |
| | | this.subscribers.clear(); |
| | | this.fields.clear(); |
| | | this.selectedTerms.clear(); |
| | | this.searchContexts.clear(); |
| | | } |
| | | } |
| | | |
| | | // Initialize on auth ready |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbSelector = new TaxonomySelector(); |
| New file |
| | |
| | | /** |
| | | * 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 = `<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() { |
| | | // 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(); |
| | | } |
| | | }); |
| | | |
| | | }); |
| | |
| | | window.jvbTaxCreator=class{constructor(e){this.selector=e,e.modal&&(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew?.querySelector(".create-new-term-section")),this.initListeners(),this.form&&this.initTermCreation()}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e).then((()=>{})),window.targetCheck(e,".create-term")&&this.handleAutocompleteCreate(e).then((()=>{}))}async handleTermCreation(e){const t=this.selector.currentConfig?.taxonomy;if(!t)return;const r=this.form.querySelector('input[name="term_name"]').value.trim(),n=parseInt(this.form.querySelector("input#select_parent")?.value)||0;if(!r)return;const s=this.form.querySelector("button");try{s&&(s.disabled=!0);const e=await this.createTerm(r,n,t);e.success&&e.term&&(await this.handleSuccessfulCreation(e.term,t,n),this.clearForm())}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleTermCreation")}finally{s&&(s.disabled=!1)}}async handleSuccessfulCreation(e,t,r){const n=e.path||e.name;this.createNew.open=!1,await this.selector.store.clearCache(),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:n,taxonomy:t,parent:r,count:0,hasChildren:!1,slug:e.slug||termName.toLowerCase().replace(/\s+/g,"-")}),this.selector.addSelectedTermToModal(e.id,e.name,n),(this.selector.store.filters.parent||0)===r&&await this.selector.store.setFilters({taxonomy:t,parent:r,page:1,search:""})}async handleAutocompleteCreate(e){const t=e.target.closest(".create-term"),r=this.selector.getFieldId(t),n=this.selector.fields.get(r);if(!n)return;const s=n.container.querySelector("input[data-autocomplete]"),a=s?.value.trim()||t.dataset.query;if(!a)return;const o=t.innerHTML;try{t.disabled=!0,t.textContent="Creating...";const e=await this.createTerm(a,0,n.taxonomy);e.success&&e.term?await this.handleAutocompleteSuccess(e.term,n,s):"exists"===e.reason&&e.term&&this.handleExistingTerm(e.term,n,s)}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleAutocompleteCreate")}finally{t.innerHTML=o,t.disabled=!1}}async handleAutocompleteSuccess(e,t,r){const n=e.path||e.name;t.selectedTerms.add(parseInt(e.id)),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:n,taxonomy:t.taxonomy,parent:0,count:0,hasChildren:!1,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-")}),this.selector.addTermDisplay(e.id,e.name,n,"field",t.id),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})),t.autocompleteDropdown.hidden=!0,r&&(r.value=""),await this.selector.store.clearCache()}handleExistingTerm(e,t,r){t.selectedTerms.add(parseInt(e.id)),this.selector.addTermDisplay(e.id,e.name,e.path||e.name,"field",t.id),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})),t.autocompleteDropdown.hidden=!0,r&&(r.value="")}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){const e=this.selector.currentConfig?.taxonomy;if(!e)return;let t=this.createNew.querySelector("#select_parent");if(!t)return;let r=t.querySelector("option");if(!r)return;window.removeChildren(t),t.append(r.cloneNode(!0));const n=this.selector.store.filters.parent||0;if(0!==n){const e=this.selector.store.data.get(n);if(e){let n=r.cloneNode(!0);n.value=e.id,n.textContent=e.name,t.append(n)}}const s=[];this.selector.store.data.forEach((t=>{t.taxonomy===e&&t.parent===n&&s.push(t)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let n=r.cloneNode(!0);n.id=`select-parent-${e.id}`,n.value=e.id,n.textContent=" — "+e.name,t.append(n)}))}async createTerm(e,t=0,r){try{await this.selector.store.setFilters({taxonomy:r,search:e,page:1,parent:0}),await new Promise((e=>setTimeout(e,100)));const n=Array.from(this.selector.store.data.values()).find((t=>t.taxonomy===r&&t.name.toLowerCase()===e.toLowerCase()));if(n)return this.createNew&&this.showTermSuggestions([n],!0),{success:!1,reason:"exists",term:n};const s=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({taxonomy:r,name:e,parent:t})});if(!s.ok)throw new Error(`Server error: ${s.status}`);return await s.json()}catch(e){throw console.error("Error creating term:",e),e}}showTermSuggestions(e,t=!1){const r=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(r);const n=document.createElement("h4");n.textContent=t?"This term already exists:":"Similar terms already exist:",r.appendChild(n);const s=document.createElement("ul");s.className="term-suggestion-list",e.forEach((e=>{const t=document.createElement("li"),r=this.createSuggestionButton(e);t.appendChild(r),s.appendChild(t)})),r.appendChild(s),r.hidden=!1}createSuggestionButton(e){const t=document.createElement("button");return t.type="button",t.className="use-existing-term",t.dataset.id=e.id,t.textContent=e.path||e.name,t.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path||e.name),this.createNew.open=!1;const t=this.createNew.querySelector(".term-suggestions");t&&(t.hidden=!0),this.clearForm()})),t}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}clearForm(){const e=this.form.querySelector('input[name="term_name"]');e&&(e.value="");const t=this.createNew.querySelector(".term-suggestions");t&&(t.hidden=!0)}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler);const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}}; |
| | | window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-new",parent:"#select_parent",summary:".create-new summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){window.targetCheck(e,this.selectors.summary)&&(this.ui.details.open&&this.ui.name?.focus(),this.resetParentOptions()),window.targetCheck(e,this.selectors.button)&&this.handleTermCreation(e).then((()=>{}))}async handleTermCreation(e){const t=this.selector.currentField().taxonomy;if(!t)return;let r={parent:0,taxonomy:t};if(this.selector.container.open?(r.name=this.ui.name.value.trim(),r.parent=parseInt(this.ui.parent?.value??0)):this.selector.activeField&&(r.name=this.selector.store.query),r.name&&!(r.name.length<2))try{this.ui.button&&(this.ui.button.disabled=!0);const e=await this.createTerm(r);e.success&&e.term&&(await this.handleSuccessfulCreation(e.term,r),this.clearForm())}catch(e){console.error("Error creating term:",e),this.selector.handleError(e,"handleTermCreation")}finally{this.ui.button&&(this.ui.button.disabled=!1)}}async handleSuccessfulCreation(e,t){const r=e.path||e.name;this.ui.details.open=!1,await this.selector.store.clearCache(),this.selector.store.data.set(e.id,{id:e.id,name:e.name,path:r,taxonomy:t.taxonomy,parent:t.parent,count:0,hasChildren:!1,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-")}),this.selector.addSelected(e.id,this.selector.activeField),this.selector.container.open&&(this.selector.store.filters.parent||0)===t.parent&&await this.selector.store.setFilters({taxonomy:t.taxonomy,parent:t.parent,page:1,search:""})}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let r=this.ui.parent.querySelector("option");if(!r)return;window.removeChildren(this.ui.parent),this.ui.parent.append(r.cloneNode(!0));const i=this.selector.store.filters.parent||0;if(0!==i){const e=this.selector.store.get(i);if(e){let t=r.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const n=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===i&&n.push(e)})),n.sort(((e,t)=>e.name.localeCompare(t.name))),n.forEach((e=>{let t=r.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent=" — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}}; |
| | |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.subscribers=new Set;const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.disabled=!1,this.searchContexts=new Map,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}handleStoreEvent(e,t){const i={"data-loaded":()=>this.handleDataLoaded(t),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError(t.error)};i[e]?.()}handleDataLoaded(e){const t=this.store.filters.taxonomy;if(t){(t.includes(",")?t.split(",").map((e=>e.trim())):[t]).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.isInitializing&&this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)})),this.renderSearchResults(e)}renderSearchResults(e){const t=this.getActiveSearchContext();"modal"===t?this.renderModalResults(e):"autocomplete"===t&&this.renderAutocompleteResults(e)}getActiveSearchContext(){return this.modal?.open?"modal":this.activeField&&this.searchContexts.has(this.activeField)?this.searchContexts.get(this.activeField):null}renderModalResults(e){this.hideLoading();const t=this.store.getFiltered(),i=this.store.lastResponse?.page||{},s=e.filters?.search?.length>0,o=i.page>1;this.notify("terms-loaded",{terms:t,filters:e.filters}),0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}renderAutocompleteResults(e){const t=this.fields.get(this.activeField);if(!t?.autocompleteDropdown)return;const i=this.store.getFiltered(),s=e.filters?.search||"";this.showAutocompleteResults(t,i,s),this.searchContexts.delete(this.activeField)}handleFiltersChanged(e){this.modal?.open&&this.showLoading()}handleFetchError(e){this.hideLoading();"autocomplete"===this.getActiveSearchContext()?(this.showAutocompleteError(this.activeField),this.searchContexts.delete(this.activeField)):this.handleError(e,"fetch")}updateFieldsForTaxonomy(e){this.getFieldsForTaxonomy(e).forEach((e=>{this.updateFieldButtonState(e.id)}))}updateFieldButtonState(e){const t=this.fields.get(e);if(!t)return;const i=Array.from(this.store.data.values()).some((e=>e.taxonomy===t.taxonomy));t.toggle&&(t.toggle.disabled=!i&&!t.canCreate,t.toggle.title=i?`Select ${this.getLabel(t.taxonomy,"plural")}`:`No ${this.getLabel(t.taxonomy,"single")} available`)}getFieldsForTaxonomy(e){return Array.from(this.fields.values()).filter((t=>t.taxonomy===e))}scanExistingFields(e=document.body){e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.handleError(t,"scanExistingFields",e.dataset.name)}}))}registerField(e){const t=e.querySelector("input[type=hidden]");if(!t)return!1;const i=this.createFieldId(e);e.dataset.fieldId=i;const s=e.querySelector("button.taxonomy-toggle"),o={id:i,input:t,container:e,taxonomy:s.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(s.dataset.max)||0,canSearch:"search"in s.dataset,hasAutocomplete:"autocomplete"in s.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")||null,canCreate:"creatable"in s.dataset,isRequired:"required"in s.dataset,selectedTerms:new Set,toggle:s,selectedContainer:e.querySelector(".selected-items")},a=t.value.trim();return a&&a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>o.selectedTerms.add(e))),this.fields.set(i,o),this.isInitializing&&this.taxonomiesToFetch.add(o.taxonomy),o.selectedTerms.size>0&&this.initFieldDisplay(i),i}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);t&&0!==t.selectedTerms.size&&Array.from(t.selectedTerms).forEach((t=>{const i=this.store.get(t);i&&this.addTermDisplay(t,i.name,i.path,"field",e)}))}initModal(){this.modal=document.querySelector("dialog#jvb-selector"),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1}),this.modalInstance.subscribe((e=>{"modal-open"===e&&this.openModal(),"modal-close"===e&&this.closeModal()}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.ui=window.uiFromSelectors({search:{input:"[type=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"},create:{details:".create-new-term",summary:".create-new-term summary",label:{name:"[for=term_name]",parent:"[for=select_parent]"}}}),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),document.addEventListener("input",this.handleInput.bind(this)),document.addEventListener("focus",this.handleFocus.bind(this),!0),document.addEventListener("blur",this.handleBlur.bind(this),!0)}handleClick(e){if(window.targetCheck(e,".taxonomy-toggle")){e.preventDefault();const t=this.getFieldId(e.target);return void(this.fields.get(t)&&this.setActiveField(t,!0))}const t=window.targetCheck(e,"button.remove-item");if(t&&e.target.closest(".jvb-selector")){const e=this.getFieldId(t),i=t.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,i)}else e.target.matches(".modal-close")?this.modalInstance?.handleClose():this.modal?.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal?.contains(e.target)&&this.handleModalChange(e)}handleInput(e){if(this.modal?.contains(e.target)&&"search"===e.target.type)this.performSearch(e.target.value.trim(),"modal");else if("autocomplete"in e.target.dataset){const t=this.getFieldId(e.target),i=this.fields.get(t);i?.hasAutocomplete&&this.performSearch(e.target.value.trim(),"autocomplete",t)}}handleFocus(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);i?.hasAutocomplete&&this.preloadTaxonomy(i.taxonomy)}handleBlur(e){"autocomplete"in e.target.dataset&&setTimeout((()=>{const t=this.getFieldId(e.target),i=this.fields.get(t);i?.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),this.searchContexts.delete(t)}),200)}performSearch(e,t="modal",i=null){const s="autocomplete"===t?this.fields.get(i):this.currentConfig;if(s){if("autocomplete"===t){if(s.currentAutocompleteQuery=e,e.length<2)return void(s.autocompleteDropdown&&(s.autocompleteDropdown.hidden=!0));this.searchContexts.set(i,"autocomplete"),this.activeField=i,s.autocompleteDropdown&&(s.autocompleteDropdown.hidden=!1)}window.debouncer.schedule(`taxonomy-search-${t}-${i||"modal"}`,(async()=>{await this.store.setFilters({taxonomy:s.taxonomy,search:e,page:1,parent:e?0:this.store.filters.parent||0}),"modal"===t&&window.removeChildren(this.ui.termsList)}),300)}}setActiveField(e,t=!1){this.activeField=e,this.currentConfig=this.fields.get(e),t&&this.modalInstance.handleOpen(),this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms.forEach((e=>{const t=this.store.get(e);t&&this.selectedTerms.set(e,{id:e,name:t.name,path:t.path})}))}handleModalClick(e){if(window.targetCheck(e,".remove-item")){const t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){const t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){const t=window.targetCheck(e,".path-level");this.navigateToPath(parseInt(t.dataset.id)||0)}}handleModalChange(e){if("checkbox"!==e.target.type)return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}openModal(){this.currentConfig?(this.updateModalUI(),this.updateModalSelections(),window.removeChildren(this.ui.termsList),this.showLoading()):console.error("No active field set")}closeModal(){this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.notify("selected-terms",{terms:this.selectedTerms,taxonomy:this.currentConfig.taxonomy}),this.activeField&&this.saveSelectionsToField(this.activeField),this.activeField=null,this.currentConfig=null}updateModalUI(){const e=this.getLabel(this.currentConfig.taxonomy,"single"),t=this.getLabel(this.currentConfig.taxonomy,"plural");this.ui.modal.title.textContent=`Select ${t}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${e}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${e}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under")),this.a11y?.announce(`Opened ${e} selection. Choose from checkboxes or search to filter results.`)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermDisplay(t,e.name,e.path,"modal")})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermDisplay(e,t,i,"modal"),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((e=>{e.checked||(e.disabled=this.disabled)})))}saveSelectionsToField(e){const t=this.fields.get(e);t&&(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermDisplay(s,i.name,i.path,"field",e)})),t.input.value=Array.from(t.selectedTerms).join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0})))}addTermDisplay(e,t,i,s="field",o=null){const a="field"===s?this.fields.get(o):this.currentConfig,n="field"===s?a.selectedContainer:this.ui.selectedTerms;if(n.querySelector(`[data-id="${e}"]`))return;const r=window.getTemplate("selectedTerm");if(r.dataset.id=e,r.dataset.path=i,r.dataset.name=t,r.dataset.taxonomy=a.taxonomy,r.querySelector(".item-name").textContent=i,r.querySelector("button").title=`Remove ${t}`,n.appendChild(r),"modal"===s){const t=this.ui.termsList.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;i.selectedTerms.delete(parseInt(t));const s=i.selectedContainer.querySelector(`[data-id="${t}"]`);s&&s.remove(),i.input.value=Array.from(i.selectedTerms).join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),i&&(i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e))}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===e}loadMoreTerms(){const e=this.store.filters.page||1;this.store.setFilter("page",e+1)}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb");i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}renderTerms(e=null,t=!1,i=!1){if(e||(e=this.store.getFiltered()),t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s;const o=document.createDocumentFragment();e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&o.appendChild(t)})),this.ui.termsList.appendChild(o)}createTermElement(e){if(!e?.name)return null;const t=window.getTemplate("termListItem");t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),a=t.querySelector(".term-name");if(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,a.textContent=e.show?e.path:e.name,e.hasChildren){const i=window.getTemplate("termChildrenToggle");i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i)}return t}showAutocompleteResults(e,t,i){if(!e?.autocompleteDropdown)return;const s=e.autocompleteDropdown;if(window.removeChildren(s),0===t.length)this.showEmptyState("No items found.",s);else{const i=document.createDocumentFragment();t.forEach((t=>{const s=this.createAutocompleteItem(e,t);s&&i.appendChild(s)})),s.appendChild(i)}const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o){t.find((e=>e.name.toLowerCase()===o.toLowerCase()))||s.appendChild(this.createAutocompleteCreateButton(o))}s.hidden=!1}createAutocompleteItem(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermDisplay(t.id,t.name,t.path,"field",e.id),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}createAutocompleteCreateButton(e){const t=document.createElement("button");t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e;const i=document.createElement("strong");return i.textContent="Create: ",t.appendChild(i),t.appendChild(document.createTextNode(`"${e}"`)),t}showAutocompleteError(e){const t=this.fields.get(e);t?.autocompleteDropdown&&(window.removeChildren(t.autocompleteDropdown),this.showEmptyState("Hmmm... something went wrong",t.autocompleteDropdown))}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store.filters.search||"",t=this.store.filters.parent||0,i=e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults"),s=i.querySelector("span");e&&s&&(s.textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}getLabel(e,t="single"){return jvbSettings.labels[e]?.[t]||e}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}async preloadTaxonomy(e){await this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}handleError(e,t,i=null){console.error(`Taxonomy ${t} error:`,e,i),this.error?.log&&this.error.log(e,{component:"TaxonomySelector",action:t,detail:i}),this.modal?.open&&this.showEmptyState("Error loading. Please try again.")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),document.removeEventListener("input",this.handleInput),document.removeEventListener("focus",this.handleFocus),document.removeEventListener("blur",this.handleBlur),this.observer?.disconnect(),this.store.destroy(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.searchContexts.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.loadedTaxonomies=new Set,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.init())}init(){this.initStore(),this.initElements(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}initElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper",results:".search-results"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},loading:{loading:".loading",text:".loading span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:"button.taxonomy-toggle",value:'input[type="hidden"]',selected:".selected-items",dropdown:".search-results",search:"[data-autocomplete]"}},this.ui=window.uiFromSelectors(this.selectors)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){const t=this.getFieldId(e.target),i=this.fields.get(t);if(!t||!i)return;const s=window.targetCheck(e,"[data-autocomplete-select]");if(s){let e=parseInt(s.dataset.id);this.addSelected(e,t),i.ui.dropdown&&(i.ui.dropdown.hidden=!0),i.ui.search&&(i.ui.search.value="")}if(window.targetCheck(e,i.ui.toggle))return e.preventDefault(),void this.openModal(t);const n=window.targetCheck(e,"button.remove-item");if(n){const e=this.getFieldId(n),t=n.closest(".selected-item").dataset.id??!1;return void(e&&t&&this.removeSelected(t,e))}if(e.target.matches(".modal-close"))return void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),i=parseInt(t.dataset.id);return void(i&&this.navigateTo(i))}const r=window.targetCheck(e,this.selectors.nav.pathLevel);if(r){const e=parseInt(r.dataset.id)??0;this.navigateTo(e)}if(window.targetCheck(e,i.selectors.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target))return;if("checkbox"!==e.target.type)return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let i=this.getFieldId(e.target);e.target.checked?this.addSelected(t,i):this.removeSelected(t,i)}handleInput(e){let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const i=this.fields.get(t);if(!i)return;this.container.open||(this.activeField=t);const s=e.target.value.trim();window.debouncer.schedule(`${t}-search`,(async()=>{await this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1,parent:s?0:this.store.filters.parent||0}),this.container.open&&window.removeChildren(this.ui.terms.list)}),100)}handleFocus(e){const t=this.getFieldId(e.target),i=this.fields.get(t);t&&i&&(i.hasAutocomplete||i.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||(this.activeField=t,this.preloadTaxonomy(i.taxonomy)))}handleBlur(e){const t=this.getFieldId(e.target),i=this.fields.get(t);t&&i&&i.hasAutocomplete&&this.scheduleHideDropdown(t)}scheduleHideDropdown(e){const t=this.fields.get(e);t&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.activeField=null,t.ui.dropdown.hidden=!0}),1500)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,save:null,open:null}),this.modal.subscribe(((e,t)=>{e}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.activeField=e,this.ui.modal.title.textContent=`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.ui.create.details&&(this.ui.create.details.hidden=!t.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${t.singular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${t.singular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"));let i=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.setLoading(),this.store.setFilters({taxonomy:t.taxonomy,page:1,search:"",parent:0}),this.a11y.announce(i)}closeModal(){this.modal.handleClose();const e=this.fields.get(this.activeField);if(!e)return;this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy}),this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return;let i=t.parent;this.navigateTo(parseInt(i))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;const i=window.getTemplate("selectedTerm");i.dataset.id=e,i.querySelector("span").textContent=t.path,i.querySelector("button").title=`Remove ${name}`,this.ui.selected.append(i)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"]').forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector('input[type="hidden"]');if(!i)return void console.warn("TaxonomySelector: No hidden input found for field",e);"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const s=e.dataset.fieldId;let n=this.selectors.field,r=e.querySelector("button.taxonomy-toggle");if(0===t.size){if(!r)return;if(0===(t=r.dataset).size)return}else Object.hasOwn(t,"toggle")&&(r=document.querySelector(t.toggle),n.toggle=t.toggle);const a={id:s,value:i,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:Object.hasOwn(t,"search"),limit:t.limit??0,hasAutocomplete:Object.hasOwn(t,"autocomplete"),canCreate:Object.hasOwn(t,"creatable"),isRequired:Object.hasOwn(t,"required"),toggle:r,selectors:n,ui:window.uiFromSelectors(n,e),checked:!1};if(!a.taxonomy)return;this.fields.set(s,a);let o=new Set;return i.value.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>o.add(e))),this.selectedTerms.set(s,o),this.isInitializing&&this.batchFetch.add(a.taxonomy),this.updateFieldUI(s),s}addSelected(e,t=null){t||(t=this.activeField);const i=this.fields.get(t),s=this.store.get(e);if(!i||!s)return;const n=this.selectedTerms.get(t);0!==i.limit&&n.size>=i.limit||(n.add(parseInt(e)),this.addTermToDisplay(e,t),this.updateFieldValue(t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const i=this.fields.get(t),s=this.store.get(e);if(!i||!s)return;this.selectedTerms.get(t).delete(parseInt(e));const n=i.ui.selected.querySelector(`[data-i"${e}"]`);if(n&&n.remove(),this.container.open){let t=this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove()}this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let i=Array.from(this.selectedTerms.get(e));t.ui.value=i.join(",")}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||0===t.limit)return;const i=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(i)}updateFieldUI(e){const t=this.fields.get(e);let i=this.selectedTerms.get(e);t&&0!==i.size&&Array.from(i).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>!t.checked&&t.taxonomy===e));const i=Array.from(this.store.data.values()).some((t=>t.taxonomy===e));t.forEach((e=>{e.ui.toggle.disabled=!i&&!e.canCreate,e.ui.toggle.title=i?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0}))}showModalTerms(e=!0,t=!1){const i=this.store.getFiltered();if(0===i.size)return;e||window.removeChildren(this.ui.terms.list);const s=this.store.filters.parent??0;this.ui.nav.back.hidden=0===s;const n=document.createDocumentFragment();i.forEach((e=>{const i=this.createTermElement({show:t,...e});i&&n.appendChild(i)})),this.ui.terms.list.append(n)}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem");t.dataset.id=e.id;const i=this.selectedTerms.get(this.activeField).has(e.id);let[s,n,r]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("span, .term-name")],a=this.currentField(),o=a.limit>0&&this.selectedTerms.get(this.activeField).size>=a.limit;if(s&&n&&r&&([s.id,s.name,s.value,s.disabled,s.checked,n.htmlFor,n.title,n.dataset.path,r.textContent]=[`${a.element.id}-${e.id}`,`${a.container.id}-${a.taxonomy}-select`,e.id,!i&&o,i,`${a.element.id}-${e.id}`,e.path??e.name,e.path,e.show?e.path:e.name],e.hasChildren)){const i=window.getTemplate("termChildrenToggle");i&&(i.ariaLabel=`View ${a.plural} nested under ${e.name}`,t.append(i))}return t}showAutocompleteTerms(){const e=this.currentField(),t=this.currentTerms();if(!e||0===t.size)return;const i=e.ui.dropdown;window.removeChildren(i),0===t.length?this.showEmptyState(`No ${e.plural} found.`,i):t.forEach((e=>{const t=this.createAutocompleteTerm(e);t&&i.append(t)}));const s=e.ui.search?.value;if(e.canCreate&&s.length>=2&&this.creator){const e=this.createTermButton(s);e&&i.append(e)}i.hidden=!1}createAutocompleteTerm(e){const t=window.getTemplate("autocompleteItem");if(t)return t.dataset.id=e.id,t.textContent=e.path||e.name,t}addTermToDisplay(e,t){const i=this.store.get(e),s=this.fields.get(t);if(!i||!s)return;if(s.ui.selected.querySelector(`[data-id="${e}"]`))return;const n=window.getTemplate("selectedTerm");if(n&&(n.dataset.id=e,n.dataset.taxonomy=s.taxonomy,n.querySelector(".item-name").textContent=i.path,n.querySelector("button").title=`Remove ${i.name}`,s.ui.selected.append(n),this.container.open)){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}createTermButton(e){const t=window.getTemplate("autocompleteButton");if(!t)return;return t.querySelector("span").textContent=`"${e}"`,t}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const i=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(i){let e=i.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const i=this.store.get(e);if(!i)return;const s=window.getTemplate("termBreadcrumb");if(!s)return;s.dataset.id=e,s.textContent=i.name,s.title=i.name,t.append(s)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const i={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(),"fetch-error":()=>this.handleFetchError()};i[e]?.()}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e?.includes(",")){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){this.setLoading(!1);const t=this.store.getFiltered(),i=this.store.filters,s=this.store.lastResponse?.page||{},n=i.search&&i.search.length>0,r=i.page>1,a=this.currentField();this.notify("terms-loaded",{terms:t,filters:i}),0===t.length?(r||this.showEmptyState(n?`No matching ${a.plural}.`:`No ${a.plural} available.`),this.observer.unobserve(this.ui.terms.sentinel)):e?this.showAutocompleteTerms():(this.showModalTerms(r,n),s.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel)),this.a11y.announce(t.length,r)}handleFiltersChanged(){}handleFetchError(e){this.setLoading(!1)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);e.forEach((e=>this.loadedTaxonomies.add(e))),this.batchFetch.clear();try{e.forEach((e=>this.loadedTaxonomies.add(e))),await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.loadedTaxonomies.has(e)||(this.store.setFilters({taxonomy:e,page:1,search:"",parent:0}),this.loadedTaxonomies.add(e))}setLoading(e=!0){if(this.ui.loading.loading.hidden=e,this.modal.classList.toggle("loading",e),e){let e=this.store.filters.search||"";e=""!==e&&e;const t=this.store.filters.parent||0,i=e?`Searching for "${e} items`:0===t?"loading items":"loading child items";window.typeLoop&&this.ui.loading.text?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContenet=i}else this.stopTyping&&(this.stopTyping(),this.stopTyping=null)}showEmptyState(e="No items found.",t=null){t||(t=this.ui.terms.list);const i=window.getTemplate("noTermResults"),s=i.querySelector("span");e&&s&&(s.textContent=e),t.append(i)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler),document.removeEventListener("blur",this.blurHandler),this.observer?.disconnect(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})(); |
| | |
| | | </div> |
| | | |
| | | <!-- Create new term section --> |
| | | <details class="create-new-term" hidden> |
| | | <details class="create-term" hidden> |
| | | <summary class="row btw">Add New Term</summary> |
| | | <div class="create-new-term-section"> |
| | | <form class="create-term-form" data-nocache data-form-id="create-term" data-save="terms"> |
| | | <form class="create-term" data-nocache data-form-id="create-term" data-save="terms"> |
| | | <div class="form-group"> |
| | | <label for="term_name">Term Name:</label> |
| | | <input type="text" name="term_name" id="term_name" required> |
| | |
| | | |
| | | <button type="button" class="submit-term">Add Term</button> |
| | | </form> |
| | | |
| | | <div class="term-suggestions" hidden><h4></h4><ul class="term-suggestion-list"></ul></div> |
| | | <div class="loading-message create-term" hidden> |
| | | <span id="typed-text"></span> |
| | | <span class="cursor">|</span> |
| | |
| | | <template class="loadingItems"> |
| | | <p>{ <span>loading items</span> }</p> |
| | | </template> |
| | | <template class="noResults"> |
| | | <template class="autocompleteButton"> |
| | | <button class="autocomplete submit-term" type="button"><strong>Create: </strong><span></span></button> |
| | | </template> |
| | | <template class="autocompleteItem"> |
| | | <button class="autocomplete item" type="button" data-autocomplete-select></button> |
| | | </template> |
| | | <template class="noTermResults"> |
| | | <p>{ <span>nothing found</span> }</p> |
| | | </template> |
| | | <template class="termListItem"> |
| | |
| | | </button> |
| | | <?php if ($hasAutocomplete !== '') { ?> |
| | | <input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete> |
| | | <ul class="autocomplete-dropdown" hidden> |
| | | <ul class="search-results" hidden> |
| | | </ul> |
| | | <?php } ?> |
| | | </div> |
| | |
| | | } |
| | | |
| | | ?> |
| | | <div class="field <?= esc_attr($type) ?> <?= esc_attr($name) ?>" |
| | | <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-type="selector" data-subtype="<?= esc_attr($type)?>" |
| | | <?= $validationAttrs ?> |
| | | <?= $describedBy ?>> |
| | | |