/**
|
* Centralized Taxonomy Selector with DataStore Integration
|
* Handles all taxonomy selection fields using DataStore for state management
|
*/
|
class TaxonomySelector {
|
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();
|
|
this.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,
|
});
|
|
// 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(terms, append, isSearch);
|
|
// 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 = window.debounce((e) => 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.jvbSelector = new TaxonomySelector();
|
});
|