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 | 556 ++++++++++++++++++++++++++++++++++---------------------
1 files changed, 343 insertions(+), 213 deletions(-)
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index b13e9ae..3262ea8 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -8,9 +8,31 @@
this.error = window.jvbError;
this.index = -1;
- // DataStore instances per taxonomy
- this.stores = new Map();
- this.storeSubscriptions = new Map();
+ 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'},
+ {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'count', keyPath: 'count'},
+ ],
+ endpoint: 'terms',
+ TTL: 7200000, //2 hours
+ filters: {
+ taxonomy: '',
+ page: 1,
+ search: '',
+ parent: 0
+ },
+ required: 'taxonomy'
+ });
// Central field management
this.fields = new Map();
@@ -28,6 +50,8 @@
// Search debouncing
this.searchHandler = null;
+ this.autocompleteHandler = null;
+ this.isAutocompleteActive = false;
this.init();
}
@@ -39,62 +63,47 @@
this.initModal();
this.scanExistingFields();
this.initGlobalListeners();
- }
- /**
- * Get or create a DataStore for a taxonomy
- */
- getOrCreateStore(taxonomy) {
- if (!this.stores.has(taxonomy)) {
- const store = new window.jvbStore({
- name: `tax_${taxonomy}`,
- endpoint: 'terms',
- TTL: 3600000, // 1 hour cache
- filters: {
- taxonomy: taxonomy,
- page: 1,
- search: '',
- parent: 0
- }
- });
-
- // Subscribe to store events
- const unsubscribe = store.subscribe((event, data) => {
- this.handleStoreEvent(taxonomy, event, data);
- });
-
- this.stores.set(taxonomy, store);
- this.storeSubscriptions.set(taxonomy, unsubscribe);
- }
-
- return this.stores.get(taxonomy);
+ 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;
}
}
@@ -169,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);
@@ -207,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(),
@@ -215,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 !== '') {
@@ -227,7 +247,11 @@
this.fields.set(fieldId, config);
// Ensure store exists for this taxonomy
- this.getOrCreateStore(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) {
@@ -238,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) {
@@ -252,47 +305,22 @@
const field = this.fields.get(fieldId);
if (!field || field.selectedTerms.size === 0) return;
- const store = this.getOrCreateStore(field.taxonomy);
const selectedIds = Array.from(field.selectedTerms);
-
- // Check store for cached terms first
const cachedTerms = [];
- const needsFetch = [];
selectedIds.forEach(termId => {
- const term = 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 store.fetch('terms', {
- filters: {
- taxonomy: field.taxonomy,
- termIDs: needsFetch.join(',')
- }
- });
-
- if (response.terms) {
- response.terms.forEach(term => {
- 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
}
/**
@@ -389,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));
}
/**
@@ -476,22 +515,20 @@
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;
// Get or create store for this taxonomy
- this.activeStore = this.getOrCreateStore(this.currentConfig.taxonomy);
+ this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
// Clear modal selection state
this.selectedTerms.clear();
// Copy field's current selections to modal state
if (this.currentConfig.selectedTerms) {
+ let termsToFetch = [];
this.currentConfig.selectedTerms.forEach(termId => {
- const term = this.activeStore.getItem(termId);
+ const term = this.store.getItem(termId);
if (term) {
this.selectedTerms.set(termId, {
id: termId,
@@ -499,17 +536,26 @@
path: term.path
});
} else {
- // If not in store, create minimal entry
- this.selectedTerms.set(termId, {
- id: termId,
- name: `Term ${termId}`,
- path: `Term ${termId}`
- });
+ termsToFetch.push(termId);
}
});
+ if (termsToFetch.length > 0) {
+ let terms = this.fetchSpecificTerms(termsToFetch);
+ terms.forEach(term => {
+ this.selectedTerms.set(term.id, {
+ id: term.id,
+ name: term.name,
+ path: term.path
+ });
+ });
+ }
}
}
+ fetchSpecificTerms(terms) {
+ return [];
+ }
+
/**
* Handle clicks within modal
*/
@@ -570,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),
@@ -586,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();
}
/**
@@ -628,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);
@@ -649,7 +692,7 @@
delete this.creator;
}
- this.activeStore = null;
+ // Remove: this.activeStore = null;
this.activeField = null;
this.currentConfig = null;
}
@@ -887,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;
}
@@ -933,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;
}
@@ -951,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);
+
}
/**
@@ -999,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);
}
});
}
@@ -1115,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` :
@@ -1146,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);
}
/**
@@ -1183,16 +1320,11 @@
// Clear intervals and cleanup
this.observer?.disconnect();
- // Unsubscribe from all stores
- this.storeSubscriptions.forEach(unsubscribe => unsubscribe());
-
// Destroy all stores
- this.stores.forEach(store => store.destroy());
+ this.store.destroy();
// Clear all maps
this.fields.clear();
- this.stores.clear();
- this.storeSubscriptions.clear();
this.selectedTerms.clear();
}
}
@@ -1201,7 +1333,5 @@
* Initialize singleton
*/
document.addEventListener('DOMContentLoaded', function() {
- if (!window.jvbSelector) {
- window.jvbSelector = new TaxonomySelector();
- }
+ window.jvbSelector = new TaxonomySelector();
});
--
Gitblit v1.10.0