| | |
| | | |
| | | this.activeField = null; |
| | | this.isInitializing = true; |
| | | this.lazyInit = false; |
| | | this.messageText = {} |
| | | this.init(); |
| | | } |
| | |
| | | init() { |
| | | this.initStore(); |
| | | this.initElements(); |
| | | this.defineTemplates(); |
| | | this.initModal(); |
| | | this.scanExistingFields(); |
| | | this.initListeners(); |
| | |
| | | indexes: [ |
| | | {name: 'taxonomy', keyPath: 'taxonomy'}, |
| | | {name: 'parent', keyPath: 'parent'}, |
| | | {name: 'slug', keyPath: 'slug', unique: true}, |
| | | {name: 'slug', keyPath: 'slug'}, |
| | | {name: 'count', keyPath: 'count'}, |
| | | ], |
| | | endpoint: 'terms', |
| | |
| | | this.store.subscribe(this.handleStoreEvent.bind(this)); |
| | | } |
| | | |
| | | defineTemplates() { |
| | | const T = window.jvbTemplates; |
| | | const terms = this; |
| | | |
| | | T.define('emptyState'); |
| | | T.define('selectedTerm', { |
| | | refs: { |
| | | name: '.item-name', |
| | | btn: 'button', |
| | | }, |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | el.dataset.taxonomy = data.taxonomy; |
| | | if (refs.name) refs.name.textContent = data.path; |
| | | if (refs.button) refs.button.title = `Remove ${data.name}`; |
| | | } |
| | | }); |
| | | T.define('termListItem', { |
| | | refs: { |
| | | checkbox: 'input', |
| | | label: 'label', |
| | | name: 'span, .term-name' |
| | | }, |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | |
| | | let field = terms.currentField(); |
| | | let isSelected = terms.selectedTerms.get(terms.activeField).has(data.id); |
| | | let limitReached = field.limit > 0 && terms.selectedTerms.get(terms.activeField).size >= field.limit; |
| | | |
| | | if (refs.checkbox) { |
| | | refs.checkbox.dataset.id = data.id; |
| | | refs.checkbox.id = `${field.id}-${data.id}`; |
| | | refs.checkbox.name = `${field.id}-${field.taxonomy}-select`; |
| | | refs.checkbox.value = data.id; |
| | | refs.checkbox.disabled = !isSelected && limitReached; |
| | | refs.checkbox.checked = isSelected; |
| | | } |
| | | if (refs.label) { |
| | | refs.label.htmlFor = `${field.id}-${data.id}`; |
| | | refs.label.title = data.path??data.name; |
| | | refs.label.dataset.path = data.path; |
| | | } |
| | | if (refs.name) { |
| | | refs.name.textContent = data.show ? data.path : data.name; |
| | | } |
| | | |
| | | if (data.hasChildren) { |
| | | let temp = { |
| | | plural: field.plural, |
| | | name: data.name |
| | | }; |
| | | const toggle = window.jvbTemplates.create('termChildrenToggle', temp); |
| | | el.append(toggle); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | T.define('termChildrenToggle', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.ariaLabel = `View ${data.plural} nested under ${data.name}`; |
| | | } |
| | | }); |
| | | |
| | | T.define('termBreadcrumb', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | el.textContent = data.name; |
| | | el.title = data.name; |
| | | } |
| | | }); |
| | | |
| | | T.define('autocompleteItem', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | el.textContent = data.path||data.name; |
| | | el.title = `Select ${data.name}`; |
| | | } |
| | | }); |
| | | |
| | | |
| | | } |
| | | /****************************************************************** |
| | | ELEMENTS |
| | | ******************************************************************/ |
| | |
| | | }, |
| | | favourites: '.favourite-terms', |
| | | field: { |
| | | toggle: 'button.taxonomy-toggle, [data-filter="taxonomy"]', |
| | | toggle: 'button.selector-toggle, [data-filter="taxonomy"]', |
| | | value: 'input[type="hidden"]', |
| | | selected: '.selected-items', |
| | | dropdown: { |
| | |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { |
| | | return; |
| | | } |
| | | const fieldId = this.getFieldId(e.target) || this.activeField; |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || !field) return; |
| | | |
| | | const autoComplete = window.targetCheck(e, '.item.autocomplete'); |
| | | |
| | | if (autoComplete) { |
| | | let termId = parseInt(autoComplete.dataset.id); |
| | | this.addSelected(termId, fieldId); |
| | | this.scheduleHideDropdown(fieldId); |
| | | if (field.ui.search) { |
| | | field.ui.search.value = ''; |
| | | if (this.creator) { |
| | | let button = window.targetCheck(e, this.selectors.create.button); |
| | | if (button) { |
| | | this.maybeCreateTerm(e).then(()=>{}); |
| | | } |
| | | } |
| | | |
| | | const toggleButton = window.targetCheck(e, this.selectors.field.toggle); |
| | | |
| | | if (toggleButton) { |
| | | e.preventDefault(); |
| | | this.openModal(fieldId); |
| | | return; |
| | | } |
| | | |
| | | const removeButton = window.targetCheck(e, '.remove-term'); |
| | | if (removeButton) { |
| | | const termId = removeButton.closest('[data-id]').dataset.id??false; |
| | |
| | | return; |
| | | } |
| | | |
| | | const autocomplete = window.targetCheck(e, '.item.autocomplete'); |
| | | |
| | | if (autocomplete) { |
| | | let termId = parseInt(autocomplete.dataset.id); |
| | | this.addSelected(termId, fieldId); |
| | | this.scheduleHideDropdown(fieldId, 6000); |
| | | if (field.ui.search) { |
| | | field.ui.search.value = ''; |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const toggleButton = window.targetCheck(e, this.selectors.field.toggle); |
| | | |
| | | if (toggleButton) { |
| | | e.preventDefault(); |
| | | this.openModal(fieldId); |
| | | return; |
| | | } |
| | | |
| | | |
| | | if (e.target.matches('.modal-close')) { |
| | | this.updateFieldValue(fieldId); |
| | | this.modal?.handleClose(); |
| | |
| | | if (pathLevel) { |
| | | const termId = parseInt(pathLevel.dataset.id)??0; |
| | | this.navigateTo(termId); |
| | | return; |
| | | } |
| | | |
| | | const dropdown = window.targetCheck(e, this.selectors.field.dropdown); |
| | | if (dropdown) { |
| | | // reset the timer for hiding the dropdown |
| | | this.scheduleHideDropdown(fieldId); |
| | | return; |
| | | } |
| | | |
| | | const clearSearch = window.targetCheck(e, this.selectors.search.clear); |
| | |
| | | this.ui.search.input.value = ''; |
| | | } |
| | | } |
| | | |
| | | if (this.creator) { |
| | | let button = window.targetCheck(e, this.selectors.create.button); |
| | | if (button) { |
| | | this.maybeCreateTerm(e).then(()=>{}); |
| | | } |
| | | } |
| | | |
| | | } |
| | | handleChange(e) { |
| | | if (!this.container.contains(e.target)) { |
| | | if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { |
| | | return; |
| | | } |
| | | if (!['checkbox', 'button'].includes(e.target.type)) return; |
| | |
| | | } |
| | | //For search in modal or field autocomplete |
| | | handleInput(e) { |
| | | if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { |
| | | return; |
| | | } |
| | | let fieldId = this.getFieldId(e.target)??this.activeField; |
| | | if (!fieldId) return; |
| | | const field = this.fields.get(fieldId); |
| | |
| | | } |
| | | |
| | | let query = e.target.value.trim(); |
| | | this.setMessage(true, `Searching for "${query}" in ${field.plural??'items'}`); |
| | | this.setMessage(field,true, `Searching for "${query}" in ${field.plural??'items'}`); |
| | | window.debouncer.schedule( |
| | | `${fieldId}-search`, |
| | | async () => { |
| | |
| | | return; |
| | | } |
| | | this.activeField = fieldId; |
| | | this.setMessage(true, `Loading ${field.plural}...`); |
| | | this.setMessage(field,true, `Loading ${field.plural}...`); |
| | | this.resetFilters({taxonomy: field.taxonomy}); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | handleFocus(e) { |
| | | if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { |
| | | return; |
| | | } |
| | | const fieldId = this.getFieldId(e.target); |
| | | if (!fieldId) return; |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || !field) return; |
| | | if (!field) return; |
| | | if (!field.hasAutocomplete && !field.hasSearch) return; |
| | | |
| | | window.debouncer.cancel(`${fieldId}-search-results`); |
| | |
| | | |
| | | //Hide autocomplete dropdown on blur |
| | | handleBlur(e) { |
| | | if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { |
| | | return; |
| | | } |
| | | const fieldId = this.getFieldId(e.target); |
| | | if (!fieldId) return; |
| | | const field = this.fields.get(fieldId); |
| | | if (!fieldId || ! field) return; |
| | | if (!field) return; |
| | | if (!field.hasAutocomplete || this.container.open) return; |
| | | if (e.target.closest('.remove-item')) return; |
| | | |
| | | if (e.relatedTarget && field.ui.dropdown.wrapper?.contains(e.relatedTarget)) return; |
| | | |
| | | this.scheduleHideDropdown(fieldId); |
| | | } |
| | | |
| | | scheduleHideDropdown(fieldId){ |
| | | scheduleHideDropdown(fieldId, delay = 1500){ |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | |
| | | field.ui.dropdown.wrapper.hidden = true; |
| | | } |
| | | }, |
| | | 1500 |
| | | delay |
| | | ); |
| | | } |
| | | |
| | |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | openEmpty(taxonomy, singular, plural, onComplete) { |
| | | // Store the callback for when modal closes |
| | | this.emptyCallback = onComplete; |
| | | |
| | | // Create a temporary "field" for bulk operations |
| | | const bulkFieldId = `empty-${taxonomy}-${Date.now()}`; |
| | | |
| | | if (!this.fields.has(bulkFieldId)) { |
| | | this.fields.set(bulkFieldId, { |
| | | id: bulkFieldId, |
| | | taxonomy: taxonomy, |
| | | singular: singular, |
| | | plural: plural, |
| | | canSearch: true, |
| | | canCreate: false, |
| | | hasAutocomplete: false, |
| | | isFilter: false, |
| | | isEmpty: true, |
| | | limit: 0, |
| | | ui: {}, |
| | | element: null, |
| | | value: null, |
| | | toggle: null, |
| | | checked: true |
| | | }); |
| | | this.selectedTerms.set(bulkFieldId, new Set()); |
| | | } |
| | | |
| | | this.setField(bulkFieldId); |
| | | this.ui.modal.title.textContent = `Add to ${plural}`; |
| | | |
| | | if (this.ui.search?.container) { |
| | | this.ui.search.container.hidden = false; |
| | | } |
| | | |
| | | window.removeChildren(this.ui.selected); |
| | | window.removeChildren(this.ui.terms.list); |
| | | |
| | | this.modal.handleOpen(); |
| | | } |
| | | |
| | | closeModal() { |
| | | const field = this.fields.get(this.activeField); |
| | | if (!field) return; |
| | | |
| | | |
| | | this.updateFieldValue(this.activeField); |
| | | |
| | | this.observer.unobserve(this.ui.terms.sentinel); |
| | | window.removeChildren(this.ui.terms.list); |
| | | |
| | | this.notify('selected-terms', { |
| | | terms: this.selectedTerms.get(this.activeField), |
| | | taxonomy: field.taxonomy |
| | | }); |
| | | if (field.isEmpty && this.emptyCallback) { |
| | | const selectedTermIds = Array.from(this.selectedTerms.get(this.activeField) || []); |
| | | const selectedTerms = selectedTermIds.map(id => this.store.get(id)).filter(Boolean); |
| | | |
| | | this.emptyCallback({ |
| | | taxonomy: field.taxonomy, |
| | | termIds: selectedTermIds, |
| | | terms: selectedTerms |
| | | }); |
| | | |
| | | // Cleanup temporary bulk field |
| | | this.fields.delete(this.activeField); |
| | | this.selectedTerms.delete(this.activeField); |
| | | this.emptyCallback = null; |
| | | this.bulkAssignmentTaxonomy = null; |
| | | } else { |
| | | this.notify('selected-terms', { |
| | | terms: this.selectedTerms.get(this.activeField), |
| | | taxonomy: field.taxonomy |
| | | }); |
| | | } |
| | | |
| | | this.activeField = null; |
| | | |
| | |
| | | if (!field) return; |
| | | if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return; |
| | | |
| | | const item = window.getTemplate('selectedTerm'); |
| | | if (!item) return; |
| | | this.ui.selected.append(this.getSelectedTermUI(term)); |
| | | } |
| | | |
| | | item.dataset.id = termId; |
| | | item.dataset.taxonomy = field.taxonomy; |
| | | item.querySelector('.item-name').textContent = term.path; |
| | | item.querySelector('button').title = `Remove ${term.name}`; |
| | | |
| | | this.ui.selected.append(item); |
| | | getSelectedTermUI(term, showPath = true) { |
| | | return window.jvbTemplates.create('selectedTerm', term); |
| | | } |
| | | /****************************************************************** |
| | | FIELDS |
| | | ******************************************************************/ |
| | | scanExistingFields(container = document.body) { |
| | | container.querySelectorAll('[data-type="selector"]').forEach( |
| | | container.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach( |
| | | selector => { |
| | | try { |
| | | this.registerField(selector); |
| | | if (selector.dataset.lazy) { |
| | | this.lazyInit = true; |
| | | } else { |
| | | // Register field if not already registered |
| | | // registerField will check if already registered and return early if so |
| | | this.registerField(selector); |
| | | } |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | |
| | | } |
| | | } |
| | | ); |
| | | if (this.lazyInit) { |
| | | this.initObserver(container); |
| | | } |
| | | } |
| | | |
| | | unregisterFields(container) { |
| | | container.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach( |
| | | selector=> { |
| | | this.fields.delete(selector.dataset.fieldId); |
| | | } |
| | | ); |
| | | } |
| | | initObserver(container){ |
| | | this.lazyObserver = new IntersectionObserver((entries) => { |
| | | entries.forEach(entry => { |
| | | if (entry.isIntersecting && entry.target.dataset.lazy) { |
| | | delete entry.target.dataset.lazy; |
| | | this.registerField(entry.target); |
| | | this.lazyObserver.unobserve(entry.target); |
| | | } |
| | | }); |
| | | }, {rootMargin: '50px'}); |
| | | |
| | | container.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach(field => { |
| | | this.lazyObserver.observe(field); |
| | | }); |
| | | } |
| | | |
| | | registerField(element, options = {}) { |
| | | if (element.dataset.fieldId && this.fields.has(element.dataset.fieldId)) { |
| | | return element.dataset.fieldId; // Already registered |
| | | } |
| | | |
| | | let input = element.querySelector('input[type="hidden"]'); |
| | | if (!input && !Object.hasOwn(element.dataset, 'filter')) { |
| | | console.warn('TaxonomySelector: No hidden input found for field', element); |
| | | return; |
| | | } |
| | | |
| | |
| | | |
| | | let selectors = this.selectors.field; |
| | | const isFilter = Object.hasOwn(element.dataset,'filter') && element.dataset.filter === 'taxonomy'; |
| | | let button = (isFilter) ? element : element.querySelector('button.taxonomy-toggle'); |
| | | let button = (isFilter) ? element : element.querySelector('button.selector-toggle'); |
| | | |
| | | if (Object.keys(options).length === 0){ |
| | | if (!button) return; |
| | |
| | | autocomplete: Object.hasOwn(button.dataset, 'autocomplete'), |
| | | creatable: Object.hasOwn(button.dataset, 'creatable') |
| | | }; |
| | | if (Object.keys(options).length === 0) return; |
| | | } else if (Object.hasOwn(options, 'toggle')) { |
| | | button = document.querySelector(options.toggle); |
| | | selectors.toggle = options.toggle; |
| | |
| | | if (this.isInitializing) { |
| | | this.batchFetch.add(config.taxonomy); |
| | | } |
| | | this.updateFieldUI(fieldId); |
| | | |
| | | if (element.offsetParent !== null) { |
| | | this.updateFieldUI(fieldId); |
| | | } else { |
| | | // Defer until visible |
| | | requestIdleCallback(() => { |
| | | if (element.offsetParent !== null) { |
| | | this.updateFieldUI(fieldId); |
| | | } |
| | | }, {timeout: 2000}); |
| | | |
| | | } |
| | | |
| | | return fieldId; |
| | | } |
| | |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | let selected = Array.from(this.selectedTerms.get(fieldId)); |
| | | field.ui.value.value = selected.join(','); |
| | | if (field.ui.value) { |
| | | field.ui.value.value = selected.join(',')??''; |
| | | field.ui.value.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | } |
| | | |
| | | checkLimits(fieldId) { |
| | |
| | | |
| | | updateFieldsForTaxonomy(taxonomy) { |
| | | let fields = Array.from(this.fields.values()) |
| | | .filter(field => !field.checked && field.taxonomy === taxonomy); |
| | | .filter(field => field.taxonomy === taxonomy); |
| | | const hasItems = Array.from(this.store.data.values()) |
| | | .some(term=>term.taxonomy === taxonomy); |
| | | .some(term => term && term.taxonomy === taxonomy); |
| | | |
| | | fields.forEach(field => { |
| | | if (!field.toggle) return; |
| | | field.toggle.disabled = !hasItems && !field.canCreate; |
| | | field.toggle.title = !hasItems |
| | | ? `No ${field.singular} available` |
| | |
| | | if (this.store.filters.page??1 === 1) { |
| | | window.removeChildren(this.ui.terms.list); |
| | | } |
| | | this.setMessage(true, this.store.filters.search === '' |
| | | this.setMessage(field,true, this.store.filters.search === '' |
| | | ? `No matching ${field.plural}.` |
| | | : `No ${field.plural} found.`, |
| | | false); |
| | | if (this.ui.terms.sentinel) { |
| | | this.observer.unobserve(this.ui.terms.sentinel); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | this.setCreateButton(true); |
| | | this.setCreateButton(field,true); |
| | | |
| | | if (this.ui.terms.sentinel) { |
| | | if (this.store.lastResponse?.has_more) { |
| | |
| | | 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({ |
| | | show: showPath, |
| | | ... term |
| | | }); |
| | | if (element) { |
| | | fragment.append(element); |
| | | } |
| | | }); |
| | | window.chunkIt( |
| | | terms, |
| | | (term) => this.createTermElement({show:showPath, ... term}), |
| | | (fragment) => this.ui.terms.list.append(fragment), |
| | | 10 |
| | | ).then(()=>{}); |
| | | |
| | | if (terms.length > 0) { |
| | | this.setMessage(false); |
| | | this.setMessage(field,false); |
| | | } |
| | | |
| | | this.ui.terms.list.append(fragment); |
| | | } |
| | | createTermElement(term) { |
| | | if (!term || !term.name) return null; |
| | | |
| | | const item = window.getTemplate('termListItem'); |
| | | item.dataset.id = term.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') |
| | | ]; |
| | | |
| | | let field = this.currentField(); |
| | | let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit; |
| | | if (checkbox && label && nameSpan) { |
| | | [ |
| | | checkbox.dataset.id, |
| | | checkbox.id, |
| | | checkbox.name, |
| | | checkbox.value, |
| | | checkbox.disabled, |
| | | checkbox.checked, |
| | | label.htmlFor, |
| | | label.title, |
| | | label.dataset.path, |
| | | nameSpan.textContent |
| | | ] = [ |
| | | term.id, |
| | | `${field.element.id}-${term.id}`, |
| | | `${field.element.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 item; |
| | | return window.jvbTemplates.create('termListItem', term); |
| | | } |
| | | |
| | | showAutocompleteTerms() { |
| | | const field = this.currentField(); |
| | | const terms = this.currentTerms(); |
| | | if (!field) return; |
| | | |
| | | if (!field || !field.hasAutocomplete || !field.ui.dropdown?.list) return; |
| | | const dropdown = field.ui.dropdown.list; |
| | | if (!dropdown) return; |
| | | const terms = this.currentTerms(); |
| | | |
| | | window.removeChildren(dropdown); |
| | | if (terms.length === 0) { |
| | | this.setMessage(true, `No ${field.plural} found.`, false); |
| | | this.setMessage(field,true, `No ${field.plural} found.`, false); |
| | | } else { |
| | | terms.forEach(term => { |
| | | const item = this.createAutocompleteTerm(term); |
| | | if (item) { |
| | | dropdown.append(item); |
| | | } |
| | | }); |
| | | this.setMessage(false); |
| | | window.chunkIt( |
| | | terms, |
| | | (term) => this.createAutocompleteTerm(term), |
| | | (fragment) => dropdown.append(fragment) |
| | | ).then(()=>{}); |
| | | |
| | | this.setMessage(field,false); |
| | | } |
| | | this.setCreateButton(true); |
| | | this.setCreateButton(field,true); |
| | | |
| | | if (field.ui.dropdown.wrapper) { |
| | | field.ui.dropdown.wrapper.hidden = false; |
| | | } |
| | | } |
| | | |
| | | createAutocompleteTerm(term) { |
| | | const item = window.getTemplate('autocompleteItem'); |
| | | if (!item) return; |
| | | |
| | | item.dataset.id = term.id; |
| | | item.textContent = term.path || term.name; |
| | | |
| | | return item; |
| | | return window.jvbTemplates.create('autocompleteItem', term); |
| | | } |
| | | /****************************************************************** |
| | | UI |
| | |
| | | const term = this.store.get(termId); |
| | | const field = this.fields.get(fieldId); |
| | | if (!term || !field) return; |
| | | |
| | | //if the term already exists in the selected items, bail early |
| | | if (field.ui.selected && field.ui.selected.querySelector(`[data-id="${termId}"]`)) return; |
| | | |
| | | 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}`; |
| | | let item = this.getSelectedTermUI(term); |
| | | |
| | | if (field.ui.selected) { |
| | | field.ui.selected.append(item); |
| | |
| | | // 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; |
| | | const crumb = window.jvbTemplates.create('termBreadcrumb', term); |
| | | |
| | | nav.append(crumb); |
| | | } |
| | |
| | | /****************************************************************** |
| | | UTILITY |
| | | ******************************************************************/ |
| | | checkRendered(collection, term) { |
| | | if (!collection) return; |
| | | if (!Object.hasOwn(collection, term.taxonomy)) { |
| | | collection[term.taxonomy] = new Map(); |
| | | } |
| | | return collection[term.taxonomy].has(term.id); |
| | | } |
| | | currentField() { |
| | | return this.fields.get(this.activeField)??false; |
| | | } |
| | |
| | | handlers[event]?.(data); |
| | | } catch (error) { |
| | | console.error(`Error handling store event "${event}":`, error); |
| | | this.setMessage(true, 'An error occurred loading data', false); |
| | | } |
| | | } |
| | | handleDataLoaded() { |
| | | const taxonomy = this.store.filters.taxonomy; |
| | | |
| | | // Always update fields for loaded taxonomies (handles both single and batch) |
| | | if (taxonomy) { |
| | | const taxonomies = taxonomy.split(',').map(t => t.trim()); |
| | | taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax)); |
| | |
| | | this.showResults(true); |
| | | return; |
| | | } |
| | | this.setMessage(false); |
| | | } |
| | | |
| | | showResults(isAutoComplete = false) { |
| | | this.setMessage(false); |
| | | const terms = this.store.getFiltered(); |
| | | const filters = this.store.filters; |
| | | const isSearch = filters.search && filters.search.length > 0; |
| | |
| | | filters |
| | | }); |
| | | |
| | | |
| | | if (!this.activeField && isAutoComplete) { |
| | | return; |
| | | } |
| | | this.setMessage(this.currentField(), false); |
| | | if (isAutoComplete) { |
| | | this.showAutocompleteTerms(); |
| | | } else { |
| | |
| | | ? `Failed to load ${field.plural}` |
| | | : 'Failed to load data'; |
| | | |
| | | this.setMessage(true, message, false); |
| | | this.setMessage(field,true, message, false); |
| | | console.error('Store fetch error:', error); |
| | | } |
| | | async batchFetchTaxonomies() { |
| | | if (this.batchFetch.size === 0) return; |
| | | |
| | | const taxonomies = Array.from(this.batchFetch); |
| | | this.batchFetch.clear(); |
| | | |
| | | try { |
| | | await this.store.setFilters({ |
| | | taxonomy: taxonomies.join(','), |
| | |
| | | /************************************************** |
| | | LOADING |
| | | **************************************************/ |
| | | setCreateButton(show = true) { |
| | | const field = this.currentField(); |
| | | if (!field || !field.canCreate || !this.creator) return; |
| | | setCreateButton(field, show = true) { |
| | | if (!field.canCreate || !this.creator) return; |
| | | |
| | | const conf = (this.container.open) ? this.ui : field.ui; |
| | | if (!conf.create?.button || !conf.create?.span) return; |
| | | |
| | | const createButton = conf.create.button; |
| | | createButton.hidden = !show; |
| | | const buttonSpan = conf.create.span; |
| | | const input = (this.container.open) ? conf.search.input : conf.search; |
| | | if (!input) return; |
| | |
| | | if (!field) return; |
| | | |
| | | window.debouncer.cancel(`${field.id}-search-results`); |
| | | |
| | | let data = { |
| | | taxonomy: field.taxonomy, |
| | | parent: this.store.filters.parent??0 |
| | | } |
| | | //If it's autocomplete or the selector's search input, we just need the name |
| | | |
| | | if (!this.container.open || this.ui.search.input.value !== '') { |
| | | data.name = (this.container.open) ? this.ui.search.input.value : field.ui.search.value; |
| | | } else { |
| | | //Otherwise, we've created it from the details element |
| | | data.parent = this.creator.ui.parent.value??data.parent; |
| | | data.name = this.creator.ui.name.value??false; |
| | | } |
| | | |
| | | if (data.parent !== undefined && data.name) { |
| | | this.setMessage(true, `Creating "${data.name}"...`); |
| | | this.setCreateButton(false); |
| | | this.setMessage(field,true, `Creating "${data.name}"...`); |
| | | this.setCreateButton(field,false); |
| | | |
| | | if (this.container.open) { |
| | | window.removeChildren(this.ui.terms.list); |
| | | } else { |
| | | field.ui.search.disabled = true; |
| | | window.removeChildren(field.ui.dropdown.list); |
| | | if (field.ui.dropdown.wrapper) { |
| | | field.ui.dropdown.wrapper.hidden = false; |
| | | } |
| | | } |
| | | |
| | | let term = await this.creator.handleTermCreation(data); |
| | | |
| | | if (term) { |
| | | // Stop any typeLoop animation and show success message WITHOUT typeLoop |
| | | this.setMessage(field,true, `"${term.name}" created!`, false); |
| | | |
| | | this.addSelected(term.id, field.id); |
| | | this.updateFieldValue(field.id); |
| | | // For autocomplete, show the newly created term in dropdown |
| | | if (!this.container.open && field.ui.dropdown.list) { |
| | | window.removeChildren(field.ui.dropdown.list); |
| | | const termElement = this.createAutocompleteTerm(term); |
| | | if (termElement) { |
| | | termElement.classList.add('newly-created'); |
| | | field.ui.dropdown.list.append(termElement); |
| | | } |
| | | } |
| | | this.scheduleHideDropdown(field.id, 300); |
| | | this.setMessage(field,false); |
| | | } else { |
| | | // Creation failed - hide immediately |
| | | this.setMessage(field,false); |
| | | if (!this.container.open && field.ui.dropdown.wrapper) { |
| | | field.ui.dropdown.wrapper.hidden = true; |
| | | } |
| | | } |
| | | |
| | | if (!this.container.open) { |
| | | field.ui.search.disabled = false; |
| | | field.ui.search.value = ''; |
| | | } |
| | | this.scheduleHideDropdown(field.id); |
| | | this.setMessage(false); |
| | | } |
| | | } |
| | | setMessage(show = true, message = '', type = true) { |
| | | const field = this.currentField(); |
| | | if (!field) return; |
| | | |
| | | setMessage(field, show = true, message = '', type = true) { |
| | | const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui); |
| | | if (!conf?.message?.message) return; |
| | | |
| | |
| | | this.observer?.unobserve(this.ui.terms.sentinel); |
| | | } |
| | | this.observer?.disconnect(); |
| | | this.lazyObserver?.disconnect(); |
| | | |
| | | // Remove event listeners |
| | | document.removeEventListener('click', this.clickHandler); |