From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
assets/js/concise/TaxonomySelector.js | 474 ++++++++++++++++++++++++++++++++++++++---------------------
1 files changed, 305 insertions(+), 169 deletions(-)
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index b571e2c..3262ea8 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -8,10 +8,15 @@
this.error = window.jvbError;
this.index = -1;
+ this.hasAutocomplete = false;
+ this.isInitializing = true;
+ this.taxonomiesToFetch = new Set();
+
this.store = new window.jvbStore({
name: `taxonomies`,
storeName: `terms`,
keyPath: 'id',
+ showLoading: false,
indexes: [
{name: 'taxonomy', keyPath: 'taxonomy'},
{name: 'parent', keyPath: 'parent'},
@@ -25,7 +30,8 @@
page: 1,
search: '',
parent: 0
- }
+ },
+ required: 'taxonomy'
});
// Central field management
@@ -44,6 +50,8 @@
// Search debouncing
this.searchHandler = null;
+ this.autocompleteHandler = null;
+ this.isAutocompleteActive = false;
this.init();
}
@@ -57,33 +65,45 @@
this.initGlobalListeners();
this.store.subscribe(this.handleStoreEvent.bind(this));
+ // Complete initialization
+ this.isInitializing = false;
+ this.batchFetchTaxonomies();
}
/**
* Handle DataStore events
*/
- handleStoreEvent(taxonomy, event, data) {
- // Only process events for the active taxonomy in modal
- if (this.activeStore && this.activeStore.config.name === `tax_${taxonomy}`) {
- switch (event) {
- case 'items-loaded':
- case 'data-fetched':
- case 'data-cached':
- case 'stale-cache-used':
+ handleStoreEvent(event, data) {
+ switch (event) {
+ case 'data-loaded':
+ // Only render if modal is open OR if it's an autocomplete request
+ if (this.modal?.open) {
this.handleTermsLoaded(data);
- break;
- case 'fetch-error':
- this.handleFetchError(data.error);
- break;
- case 'filters-changed':
- // Could trigger UI updates for active filters
- break;
- }
- }
+ }
+ // Handle autocomplete results
+ if (this.isAutocompleteActive && this.activeField) {
+ const field = this.fields.get(this.activeField);
+ const terms = data.data?.items || [];
+ const query = data.filters?.search || '';
+ this.showAutocompleteResults(field, terms, query);
+ this.isAutocompleteActive = false;
+ }
+ break;
- // Handle field-specific updates outside modal
- if (event === 'items-updated' || event === 'items-loaded') {
- this.updateFieldsForTaxonomy(taxonomy, data.items);
+ case 'filters-changed':
+ // Modal UI updates happen here if needed
+ if (this.modal?.open) {
+ this.showLoading();
+ }
+ break;
+
+ case 'fetch-error':
+ if (this.isAutocompleteActive && this.activeField) {
+ this.showAutocompleteError(this.activeField);
+ this.isAutocompleteActive = false;
+ }
+ this.handleFetchError(data.error);
+ break;
}
}
@@ -158,8 +178,12 @@
/**
* Scan page for existing taxonomy fields and register them
*/
- scanExistingFields() {
- const selectors = document.querySelectorAll('.field.taxonomy, .field.post');
+ scanExistingFields(container = null) {
+ if (!container) {
+ container = document.body;
+ }
+ const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
+
selectors.forEach(selector => {
try {
this.registerField(selector);
@@ -196,6 +220,8 @@
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(),
@@ -204,6 +230,11 @@
...options
};
+ if (!this.hasAutocomplete && config.hasAutocomplete) {
+ this.hasAutocomplete = true;
+ this.initAutocomplete();
+ }
+
// Parse initial selected values
const value = input.value.trim();
if (value !== '') {
@@ -216,7 +247,11 @@
this.fields.set(fieldId, config);
// Ensure store exists for this taxonomy
- this.store.setFilter('taxonomy', config.taxonomy);
+ if (this.isInitializing) {
+ this.taxonomiesToFetch.add(config.taxonomy);
+ } else {
+ this.store.setFilter('taxonomy', config.taxonomy);
+ }
// Initialize display for any pre-selected values
if (config.selectedTerms.size > 0) {
@@ -227,6 +262,35 @@
}
/**
+ * Batch fetch all unique taxonomies collected during init
+ */
+ async batchFetchTaxonomies() {
+ if (this.taxonomiesToFetch.size === 0) return;
+
+ const taxonomies = Array.from(this.taxonomiesToFetch);
+ this.taxonomiesToFetch.clear();
+
+ console.log(`Batch fetching ${taxonomies.length} unique taxonomies:`, taxonomies);
+
+ // Fetch each taxonomy sequentially (cache will prevent duplicates)
+ for (const taxonomy of taxonomies) {
+ await this.store.setFilters({
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+
+ // Now initialize field displays
+ this.fields.forEach((config, fieldId) => {
+ if (config.selectedTerms.size > 0) {
+ this.initFieldDisplay(fieldId);
+ }
+ });
+ }
+
+ /**
* Create unique field ID
*/
createFieldId(field) {
@@ -242,46 +306,21 @@
if (!field || field.selectedTerms.size === 0) return;
const selectedIds = Array.from(field.selectedTerms);
-
- // Check store for cached terms first
const cachedTerms = [];
- const needsFetch = [];
selectedIds.forEach(termId => {
- const term = this.store.getItem(termId);
+ const term = this.store.data.get(termId);
if (term) {
cachedTerms.push(term);
- } else {
- needsFetch.push(termId);
}
});
- // Display cached terms immediately
+ // Display all found terms
cachedTerms.forEach(term => {
this.addTermToDisplay(fieldId, term.id, term.name, term.path);
});
- // Fetch missing terms if needed
- if (needsFetch.length > 0) {
- try {
-
- const response = await this.store.fetch({
- filters: {
- taxonomy: field.taxonomy,
- termIDs: needsFetch.join(',')
- }
- });
-
- if (response.terms) {
- response.terms.forEach(term => {
- this.store.setItem(term.id, term);
- this.addTermToDisplay(fieldId, term.id, term.name, term.path);
- });
- }
- } catch (error) {
- console.error('Failed to fetch missing terms:', error);
- }
- }
+ // Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
}
/**
@@ -378,6 +417,17 @@
initGlobalListeners() {
document.addEventListener('click', this.handleClick.bind(this));
document.addEventListener('change', this.handleChange.bind(this));
+ if (this.hasAutocomplete) {
+ this.initAutocomplete();
+ }
+ }
+
+ initAutocomplete()
+ {
+ console.log('Autocomplete init');
+ this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
+ document.addEventListener('input', this.autocompleteHandler);
+ document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
}
/**
@@ -465,9 +515,6 @@
this.activeField = fieldId;
this.currentConfig = this.fields.get(fieldId);
- console.log('Current Taxonomy:',this.currentConfig.taxonomy);
- console.log('Labels: ',jvbSettings.labels[this.currentConfig.taxonomy]);
-
this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
@@ -569,6 +616,8 @@
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),
@@ -585,38 +634,36 @@
/**
* Open modal and initialize
*/
- openModal() {
- if (!this.activeField || !this.currentConfig) {
- console.error('No active field set for modal');
- return;
- }
-
- this.resetModalState();
- this.updateModalForTaxonomy();
-
- // Reset store filters to default state
- this.activeStore.clearFilters();
-
- // Set up search if enabled
- if (this.currentConfig.canSearch) {
- this.ui.search.input.focus();
- this.searchHandler = window.debounce(() => this.handleSearch(), 300);
- this.ui.search.input.addEventListener('input', this.searchHandler);
- }
-
+ openModal(config) {
+ this.activeField = config.fieldId;
+ this.currentConfig = config;
// Initialize creator if available
- if (this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
+ if (config.canCreate && 'jvbTaxCreator' in window) {
this.creator = new window.jvbTaxCreator(this);
+ } else if (this.creator) {
+ delete this.creator;
}
- // Display current selections
- this.updateModalSelections();
+ // Load selected terms into modal state
+ this.selectedTerms = new Set(config.selectedTerms);
- // Start observing for infinite scroll
- this.observer.observe(this.ui.sentinel);
+ // Only fetch if taxonomy changed
+ const currentTaxonomy = this.store.filters.taxonomy;
+ if (currentTaxonomy !== config.taxonomy) {
+ this.store.setFilters({
+ taxonomy: config.taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
- // Fetch initial terms
- this.fetchCurrentTerms();
+ // Reset UI
+ window.removeChildren(this.ui.termsList);
+ this.ui.search.value = '';
+ this.updateSelectionCount();
+
+ this.modalInstance.open();
}
/**
@@ -627,13 +674,10 @@
window.removeChildren(this.ui.termsList);
if (this.currentConfig?.isFilterMode) {
- // Call the filter callback with selected terms
if (this.currentConfig.filterCallback) {
const selectedIds = Array.from(this.selectedTerms.keys());
this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
}
-
- // Clean up the virtual field
this.fields.delete(this.activeField);
} else if (this.activeField) {
this.saveSelectionsToField(this.activeField);
@@ -648,7 +692,7 @@
delete this.creator;
}
- this.activeStore = null;
+ // Remove: this.activeStore = null;
this.activeField = null;
this.currentConfig = null;
}
@@ -886,45 +930,164 @@
/**
* Handle search input
*/
- handleSearch() {
- const query = this.ui.searchInput.value.trim();
+ handleSearch(e) {
+ const query = e.target.value.trim();
- if (query.length >= 2 || query.length === 0) {
- // Reset pagination when searching
- this.activeStore.setFilter('page', 1);
- this.activeStore.setFilter('search', query);
+ // Clear existing debounce
+ if (this.searchHandler) {
+ clearTimeout(this.searchHandler);
+ }
+
+ this.searchHandler = setTimeout(() => {
+ // Single call - auto-fetches
+ this.store.setFilters({
+ search: query,
+ page: 1,
+ parent: query ? 0 : (this.store.filters.parent || 0)
+ });
window.removeChildren(this.ui.termsList);
+ }, 300);
+ }
- if (query.length >= 2) {
- this.showLoading();
- this.fetchCurrentTerms();
- } else if (query.length === 0) {
- // Clear search and reload
- this.showLoading();
- this.fetchCurrentTerms();
- }
- } else {
- this.hideLoading();
- this.showEmptyState('Enter at least 2 characters to search.');
+ async handleAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
}
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+
+ const query = e.target.value.trim();
+
+ if (query.length < 2) {
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = true;
+ }
+ this.isAutocompleteActive = false;
+ return;
+ }
+
+ this.activeField = fieldId;
+ this.currentConfig = field;
+
+
+ if (field.canCreate && ! this.creator) {
+ this.creator = new window.jvbTaxCreator(this);
+ }
+ this.isAutocompleteActive = true;
+
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = false;
+ }
+
+ this.store.setFilters({
+ taxonomy: field.taxonomy,
+ search: query,
+ page: 1
+ });
+ }
+
+ cleanupAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ if (this.creator) {
+ delete this.creator;
+ }
+ }
+
+ showAutocompleteError(fieldId) {
+
+ const field = this.fields.get(fieldId);
+ if (!field) {
+ return;
+ }
+ if (!field.config.autocompleteDropdown) {
+ field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
+ }
+ const dropdown = field.config.autocompleteDropdown;
+ if (dropdown) {
+ window.removeChildren(dropdown);
+ this.showEmptyState('Hmmm... something went wrong', dropdown);
+ }
+ }
+
+ showAutocompleteResults(field, terms, query) {
+ if (!field || !field.autocompleteDropdown) {
+ return;
+ }
+
+ const dropdown = field.autocompleteDropdown;
+ window.removeChildren(dropdown);
+
+ if (terms.length === 0) {
+ this.showEmptyState('No items found.', dropdown);
+ } else {
+ terms.forEach(term => {
+ const element = this.createAutocompleteTermElement(field, term);
+ if (element) {
+ dropdown.appendChild(element);
+ }
+ });
+ }
+
+ // Offer to create new term if creator is available
+ if (this.creator) {
+ const createOption = this.creator.createAutocompleteOption(query, field);
+ dropdown.appendChild(createOption);
+ }
+
+ dropdown.hidden = false;
+ }
+
+ createAutocompleteTermElement(field, term) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'autocomplete-item';
+ item.dataset.id = term.id;
+ item.dataset.name = term.name;
+ item.dataset.path = term.path || term.name;
+ item.textContent = term.path || term.name;
+
+ item.addEventListener('click', () => {
+ // Add term to field
+ field.selectedTerms.add(parseInt(term.id));
+ this.addTermToDisplay(field.id, term.id, term.name, term.path);
+
+ // Update input
+ field.input.value = Array.from(field.selectedTerms).join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // Clear and hide dropdown
+ field.autocompleteDropdown.hidden = true;
+ const input = field.container.querySelector('input[data-autocomplete]');
+ if (input) input.value = '';
+ });
+
+ return item;
}
/**
* Navigate to parent term
*/
navigateToParent() {
- const currentParent = this.activeStore.filters.parent || 0;
-
- // Find parent of current parent (could enhance this with breadcrumb tracking)
- this.activeStore.setFilter('parent', 0);
- this.activeStore.setFilter('page', 1);
+ // Single call instead of two setFilter + manual fetch
+ this.store.setFilters({
+ parent: 0,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs
this.ui.breadcrumbs.back.hidden = true;
}
@@ -932,14 +1095,13 @@
* Navigate to child term
*/
navigateToChild(termId, termName) {
- this.activeStore.setFilter('parent', termId);
- this.activeStore.setFilter('page', 1);
+ // Single call - auto-fetches
+ this.store.setFilters({
+ parent: termId,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs
this.updateBreadcrumbs(termId, termName);
this.ui.breadcrumbs.back.hidden = false;
}
@@ -950,37 +1112,25 @@
navigateToPath(pathLevel) {
const parentId = parseInt(pathLevel.dataset.id) || 0;
- this.activeStore.setFilter('parent', parentId);
- this.activeStore.setFilter('page', 1);
+ // Single call - auto-fetches
+ this.store.setFilters({
+ parent: parentId,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs to this level
- // You'd need to track the full path to properly implement this
this.ui.breadcrumbs.back.hidden = parentId === 0;
}
/**
- * Fetch terms using current store filters
- */
- fetchCurrentTerms() {
- if (!this.activeStore) return;
-
- this.showLoading();
- this.activeStore.fetch();
- }
-
- /**
* Load more terms (pagination)
*/
loadMoreTerms() {
if (!this.activeStore) return;
const currentPage = this.activeStore.filters.page || 1;
- this.activeStore.setFilter('page', currentPage + 1);
- // fetch() will be called automatically by setFilter
+ this.store.setFilter('page', currentPage + 1);
+
}
/**
@@ -998,36 +1148,21 @@
return;
}
- // Update breadcrumbs if needed
- const currentParent = this.activeStore.filters.parent || 0;
+ // Use this.store instead of this.activeStore
+ const currentParent = this.store.filters.parent || 0;
this.ui.breadcrumbs.back.hidden = currentParent === 0;
terms.forEach(term => {
- // Check if we have a cached DOM element
- const cachedElement = this.activeStore.getDOMElement(term.id, 'list-item');
+ const element = this.createTermElement({
+ id: parseInt(term.id),
+ name: term.name,
+ hasChildren: term.hasChildren,
+ path: term.path || null,
+ show: showPath
+ });
- if (cachedElement) {
- // Update checkbox state if needed
- const checkbox = cachedElement.querySelector('input[type="checkbox"]');
- if (checkbox) {
- checkbox.checked = this.selectedTerms.has(term.id);
- checkbox.disabled = !checkbox.checked && this.disabled;
- }
- this.ui.termsList.appendChild(cachedElement);
- } else {
- // Create new element and cache it
- const element = this.createTermElement({
- id: parseInt(term.id),
- name: term.name,
- hasChildren: term.hasChildren,
- path: term.path || null,
- show: showPath
- });
-
- if (element) {
- this.activeStore.storeDOMElement(term.id, 'list-item', element);
- this.ui.termsList.appendChild(element);
- }
+ if (element) {
+ this.ui.termsList.appendChild(element);
}
});
}
@@ -1114,8 +1249,8 @@
this.ui.loading.loading.hidden = false;
this.modal.classList.add('loading');
- const searchQuery = this.activeStore?.filters?.search || '';
- const currentParent = this.activeStore?.filters?.parent || 0;
+ const searchQuery = this.store?.filters?.search || '';
+ const currentParent = this.store?.filters?.parent || 0;
let message = searchQuery !== '' ?
`searching for "${searchQuery}" items` :
@@ -1145,14 +1280,17 @@
/**
* Show empty state message
*/
- showEmptyState(message = 'No items found.') {
+ showEmptyState(message = 'No items found.', container = null) {
+ if (!container) {
+ container = this.ui.termsList;
+ }
const emptyElement = window.getTemplate('noResults').cloneNode(true);
if (message && emptyElement.querySelector('span')) {
emptyElement.querySelector('span').textContent = message;
}
- this.ui.termsList.appendChild(emptyElement);
+ container.appendChild(emptyElement);
}
/**
@@ -1195,7 +1333,5 @@
* Initialize singleton
*/
document.addEventListener('DOMContentLoaded', function() {
- if (!window.jvbSelector) {
- window.jvbSelector = new TaxonomySelector();
- }
+ window.jvbSelector = new TaxonomySelector();
});
--
Gitblit v1.10.0