TreeTrack / static /js /modules /autocomplete-manager.js
RoyAalekh's picture
Major refactoring: Modular architecture implementation (v5.0.0)
afc0068
raw
history blame
11.8 kB
/**
* AutoComplete Manager Module
* Handles intelligent auto-suggestions and field auto-completion
*/
export class AutoCompleteManager {
constructor(apiClient, uiManager) {
this.apiClient = apiClient;
this.uiManager = uiManager;
this.searchTimeouts = {};
this.activeDropdowns = new Set();
this.selectedIndex = -1;
this.availableTreeCodes = [];
}
async initialize() {
try {
this.availableTreeCodes = await this.apiClient.loadTreeCodes();
this.setupAutocomplete('localName', 'tree-suggestions');
this.setupAutocomplete('scientificName', 'tree-suggestions');
this.setupAutocomplete('commonName', 'tree-suggestions');
this.setupAutocomplete('treeCode', 'tree-codes');
} catch (error) {
console.error('Error initializing auto-suggestions:', error);
}
}
setupAutocomplete(fieldId, apiType) {
const input = document.getElementById(fieldId);
if (!input) return;
this.createAutocompleteContainer(input, fieldId);
this.attachEventListeners(input, fieldId, apiType);
}
createAutocompleteContainer(input, fieldId) {
if (input.parentElement.classList.contains('autocomplete-container')) return;
const container = document.createElement('div');
container.className = 'autocomplete-container';
input.parentNode.insertBefore(container, input);
container.appendChild(input);
const dropdown = document.createElement('div');
dropdown.className = 'autocomplete-dropdown';
dropdown.id = `${fieldId}-dropdown`;
container.appendChild(dropdown);
}
attachEventListeners(input, fieldId, apiType) {
input.addEventListener('input', (e) => this.handleInputChange(e, apiType));
input.addEventListener('keydown', (e) => this.handleKeyDown(e, fieldId));
input.addEventListener('blur', (e) => this.handleInputBlur(e, fieldId));
input.addEventListener('focus', (e) => this.handleInputFocus(e, fieldId, apiType));
}
async handleInputChange(event, apiType) {
const input = event.target;
const query = input.value.trim();
const fieldId = input.id;
this.clearSearchTimeout(fieldId);
if (query.length < 2) {
this.hideDropdown(fieldId);
return;
}
this.showLoadingState(fieldId);
this.debounceSearch(fieldId, query, apiType);
}
clearSearchTimeout(fieldId) {
if (this.searchTimeouts[fieldId]) {
clearTimeout(this.searchTimeouts[fieldId]);
}
}
debounceSearch(fieldId, query, apiType) {
this.searchTimeouts[fieldId] = setTimeout(async () => {
try {
const suggestions = await this.fetchSuggestions(query, apiType, fieldId);
this.showSuggestions(fieldId, suggestions, query);
} catch (error) {
console.error('Error fetching suggestions:', error);
this.hideDropdown(fieldId);
}
}, 300);
}
async fetchSuggestions(query, apiType, fieldId) {
if (apiType === 'tree-codes') {
return this.filterTreeCodes(query);
} else {
return this.searchTreeSuggestions(query, fieldId);
}
}
filterTreeCodes(query) {
return this.availableTreeCodes
.filter(code => code.toLowerCase().includes(query.toLowerCase()))
.slice(0, 10)
.map(code => ({
primary: code,
secondary: 'Tree Reference Code',
type: 'code'
}));
}
async searchTreeSuggestions(query, fieldId) {
const suggestions = await this.apiClient.searchTreeSuggestions(query, 10);
return suggestions.map(suggestion => ({
primary: this.getPrimaryText(suggestion, fieldId),
secondary: this.getSecondaryText(suggestion, fieldId),
badges: this.getBadges(suggestion),
data: suggestion
}));
}
getPrimaryText(suggestion, fieldId) {
const fieldMap = {
'localName': suggestion.local_name,
'scientificName': suggestion.scientific_name,
'commonName': suggestion.common_name
};
return fieldMap[fieldId] ||
suggestion.local_name ||
suggestion.scientific_name ||
suggestion.common_name;
}
getSecondaryText(suggestion, fieldId) {
const parts = [];
if (fieldId !== 'localName' && suggestion.local_name) {
parts.push(`Local: ${suggestion.local_name}`);
}
if (fieldId !== 'scientificName' && suggestion.scientific_name) {
parts.push(`Scientific: ${suggestion.scientific_name}`);
}
if (fieldId !== 'commonName' && suggestion.common_name) {
parts.push(`Common: ${suggestion.common_name}`);
}
if (suggestion.tree_code) {
parts.push(`Code: ${suggestion.tree_code}`);
}
return parts.join(' • ');
}
getBadges(suggestion) {
const badges = [];
if (suggestion.tree_code) {
badges.push(suggestion.tree_code);
}
if (suggestion.fruiting_season) {
badges.push(`Season: ${suggestion.fruiting_season}`);
}
return badges;
}
showLoadingState(fieldId) {
const dropdown = document.getElementById(`${fieldId}-dropdown`);
if (!dropdown) return;
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
dropdown.style.display = 'block';
this.activeDropdowns.add(fieldId);
}
showSuggestions(fieldId, suggestions, query) {
const dropdown = document.getElementById(`${fieldId}-dropdown`);
if (!dropdown) return;
if (suggestions.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-no-results">No matching suggestions found</div>';
dropdown.style.display = 'block';
this.activeDropdowns.add(fieldId);
return;
}
const html = this.buildSuggestionsHTML(suggestions, query, fieldId);
dropdown.innerHTML = html;
dropdown.style.display = 'block';
this.activeDropdowns.add(fieldId);
this.selectedIndex = -1;
this.attachSuggestionClickHandlers(dropdown, suggestions);
}
buildSuggestionsHTML(suggestions, query, fieldId) {
return suggestions.map((suggestion, index) => `
<div class="autocomplete-item" data-index="${index}" data-field="${fieldId}">
<div class="autocomplete-primary">${this.highlightMatch(suggestion.primary, query)}</div>
${suggestion.secondary ? `<div class="autocomplete-secondary">${suggestion.secondary}</div>` : ''}
${this.buildBadgesHTML(suggestion.badges)}
</div>
`).join('');
}
buildBadgesHTML(badges) {
if (!badges || badges.length === 0) return '';
const badgeElements = badges.map(badge => `<span class="autocomplete-badge">${badge}</span>`).join('');
return `<div>${badgeElements}</div>`;
}
highlightMatch(text, query) {
if (!query || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
attachSuggestionClickHandlers(dropdown, suggestions) {
dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
item.addEventListener('mousedown', (e) => this.handleSuggestionClick(e, suggestions));
});
}
handleSuggestionClick(event, suggestions) {
event.preventDefault();
const item = event.target.closest('.autocomplete-item');
const index = parseInt(item.dataset.index);
const fieldId = item.dataset.field;
const suggestion = suggestions[index];
this.applySuggestion(fieldId, suggestion);
this.hideDropdown(fieldId);
}
applySuggestion(fieldId, suggestion) {
const input = document.getElementById(fieldId);
if (suggestion.type === 'code') {
input.value = suggestion.primary;
} else {
this.applySuggestionData(input, fieldId, suggestion);
this.autoFillRelatedFields(suggestion.data, fieldId);
}
input.dispatchEvent(new Event('input', { bubbles: true }));
}
applySuggestionData(input, fieldId, suggestion) {
const data = suggestion.data;
const fieldValueMap = {
'localName': data.local_name,
'scientificName': data.scientific_name,
'commonName': data.common_name
};
input.value = fieldValueMap[fieldId] || suggestion.primary;
}
autoFillRelatedFields(data, excludeFieldId) {
const fields = {
'localName': data.local_name,
'scientificName': data.scientific_name,
'commonName': data.common_name,
'treeCode': data.tree_code
};
Object.entries(fields).forEach(([fieldId, value]) => {
if (fieldId !== excludeFieldId && value) {
this.fillEmptyField(fieldId, value);
}
});
}
fillEmptyField(fieldId, value) {
const input = document.getElementById(fieldId);
if (input && !input.value.trim()) {
input.value = value;
this.uiManager.highlightAutoFilledField(fieldId);
}
}
handleKeyDown(event, fieldId) {
const dropdown = document.getElementById(`${fieldId}-dropdown`);
if (!dropdown || dropdown.style.display === 'none') return;
const items = dropdown.querySelectorAll('.autocomplete-item');
if (items.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
this.updateHighlight(items);
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
this.updateHighlight(items);
break;
case 'Enter':
event.preventDefault();
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
items[this.selectedIndex].click();
}
break;
case 'Escape':
event.preventDefault();
this.hideDropdown(fieldId);
break;
}
}
updateHighlight(items) {
items.forEach((item, index) => {
item.classList.toggle('highlighted', index === this.selectedIndex);
});
}
handleInputBlur(event, fieldId) {
setTimeout(() => this.hideDropdown(fieldId), 150);
}
handleInputFocus(event, fieldId, apiType) {
const input = event.target;
if (input.value.length >= 2) {
this.handleInputChange(event, apiType);
}
}
hideDropdown(fieldId) {
const dropdown = document.getElementById(`${fieldId}-dropdown`);
if (dropdown) {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
this.activeDropdowns.delete(fieldId);
this.selectedIndex = -1;
}
}
hideAllDropdowns() {
this.activeDropdowns.forEach(fieldId => {
this.hideDropdown(fieldId);
});
}
}