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