Files
enviPy-bayer/templates/modals/search_modal.html
Tobias O 2b79adc2f7 [Feature] Implement Search modal in Modern UI (#185)
Implementing a search modal (stretching the level of dynamic that is possible without going to frameworks).

## Major Change
- Search needs packages and is available everywhere now; so had to add reviewed and user packages to global context.

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#185
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 01:52:41 +13:00

536 lines
22 KiB
HTML

{% load static %}
<dialog id="search_modal" class="modal @max-sm:modal-top justify-center">
<div class="modal-box w-lvw sm:w-[85vw] sm:max-w-5xl h-full sm:h-8/12 p-1" >
<!-- Search Input and Mode Selector -->
<div class="form-control mb-4 flex-shrink-0 w-full">
<div class="join w-full m-0 p-3 items-center">
<label class="input join-item input-ghost grow">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input type="text" autofocus
id="modal_searchbar"
placeholder="Benfuracarb"
class="grow" aria-label="Search" />
</label>
<!-- Mode Dropdown -->
<div>
<button type="button"
tabindex="0"
id="modal_mode_button"
popovertarget="search_dropdown_menu" style="anchor-name:--anchor-1"
class="btn join-item btn-ghost">
Text
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<ul tabindex="0"" class="dropdown dropdown-end menu bg-base-200 rounded-box z-[100] w-64 p-2 shadow-lg" popover id="search_dropdown_menu" style="position-anchor:--anchor-1">
<li class="menu-title">Text</li>
<li>
<a id="modal_dropdown_text"
class="tooltip tooltip-left"
data-tip="Search on object names and descriptions">
Text
</a>
</li>
<li class="menu-title">SMILES</li>
<li>
<a id="modal_dropdown_smiles_default"
class="tooltip tooltip-left"
data-tip="Ignores stereochemistry and charge">
Default
</a>
</li>
<li>
<a id="modal_dropdown_smiles_canonical"
class="tooltip tooltip-left"
data-tip="Ignores stereochemistry, preserves charge">
Canonical
</a>
</li>
<li>
<a id="modal_dropdown_smiles_exact"
class="tooltip tooltip-left"
data-tip="Exact match for stereochemistry and charge">
Exact
</a>
</li>
<li class="menu-title">InChI</li>
<li>
<a id="modal_dropdown_inchikey"
class="tooltip tooltip-left"
data-tip="Search by InChIKey">
InChIKey
</a>
</li>
</ul>
</div>
<button type="button" id="modal_search_button" class="btn btn-xs btn-ghost join-item">
<kbd class="kbd kbd-sm p-1 text-base-content/50">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-corner-down-left-icon lucide-corner-down-left"><path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/></svg>
</kbd>
</button>
</div>
</div>
<!-- Package Selector with Pills -->
<div class="form-control mb-4 flex-shrink-0">
<!-- Pills Container -->
<div id="modal_package_pills_container"
class="flex flex-wrap gap-2 p-3 border-2 border-dashed border-base-300 rounded-lg m-3 min-h-[3rem] items-center">
<!-- Pills will be added here dynamically -->
</div>
<!-- Package Dropdown Menu -->
<ul class="dropdown dropdown-end menu bg-base-200 rounded-box z-[100] w-80 max-h-96 overflow-y-auto p-2 shadow-lg"
popover
id="package_dropdown_menu"
style="position-anchor:--anchor-packages">
{% if unreviewed_packages %}
<li class="menu-title">Reviewed Packages</li>
{% for obj in reviewed_packages %}
<li>
<a class="package-option flex justify-between items-center"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}">
<span>{{ obj.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 package-checkmark hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</a>
</li>
{% endfor %}
<li class="menu-title">Unreviewed Packages</li>
{% for obj in unreviewed_packages %}
<li>
<a class="package-option flex justify-between items-center"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}">
<span>{{ obj.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 package-checkmark hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</a>
</li>
{% endfor %}
{% else %}
<li class="menu-title">Reviewed Packages</li>
{% for obj in reviewed_packages %}
<li>
<a class="package-option flex justify-between items-center"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}">
<span>{{ obj.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 package-checkmark hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</a>
</li>
{% endfor %}
{% endif %}
</ul>
</div>
<!-- Loading Indicator -->
<div id="search_loading" class="hidden justify-center py-8 flex-shrink-0">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<!-- Results Container - scrollable -->
<div id="search_results" class="flex-1 overflow-y-auto min-h-0 p-2"></div>
</div>
<!-- Backdrop to close -->
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
(function() {
// Package Selector Module - Data-driven multiselect package selection
const PackageSelector = {
// Single source of truth: array of selected packages
selectedPackages: [],
elements: {
pillsContainer: null,
packageDropdown: null,
packageOptions: null
},
init() {
this.cacheElements();
this.loadInitialSelection();
this.attachEventListeners();
this.render();
},
cacheElements() {
this.elements.pillsContainer = document.getElementById('modal_package_pills_container');
this.elements.packageDropdown = document.getElementById('package_dropdown_menu');
this.elements.packageOptions = document.querySelectorAll('.package-option');
},
loadInitialSelection() {
// Load pre-selected packages from server-rendered pills
const existingPills = this.elements.pillsContainer.querySelectorAll('.badge');
existingPills.forEach(pill => {
this.selectedPackages.push({
url: pill.dataset.packageUrl,
name: pill.dataset.packageName
});
});
// If no pills found, select all reviewed packages by default
if (this.selectedPackages.length === 0) {
// Iterate through all menu items and collect reviewed packages
const menuItems = this.elements.packageDropdown.querySelectorAll('li');
for (const item of menuItems) {
// Check if this is the "Unreviewed Packages" menu title
if (item.classList.contains('menu-title') &&
item.textContent.trim() === 'Unreviewed Packages') {
break; // Stop processing after this point
}
// Check for package options (only reviewed packages reach here)
const packageOption = item.querySelector('.package-option');
if (packageOption) {
this.selectedPackages.push({
url: packageOption.dataset.packageUrl,
name: packageOption.dataset.packageName
});
}
}
}
},
attachEventListeners() {
// Toggle package selection on dropdown item click
this.elements.packageOptions.forEach(option => {
option.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent dropdown from closing
const packageUrl = option.dataset.packageUrl;
const packageName = option.dataset.packageName;
this.togglePackageSelection(packageUrl, packageName);
});
});
// Remove package when X is clicked (using event delegation)
this.elements.pillsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('package-remove-btn') || e.target.closest('.package-remove-btn')) {
const pill = e.target.closest('.badge');
if (pill) {
const packageUrl = pill.dataset.packageUrl;
this.removePackage(packageUrl);
}
}
});
},
togglePackageSelection(packageUrl, packageName) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === packageUrl);
if (index !== -1) {
// Remove from selection
this.selectedPackages.splice(index, 1);
} else {
// Add to selection
this.selectedPackages.push({ url: packageUrl, name: packageName });
}
this.render();
},
removePackage(packageUrl) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === packageUrl);
if (index !== -1) {
this.selectedPackages.splice(index, 1);
this.render();
}
},
render() {
this.renderPills();
this.renderAddButton();
this.renderCheckmarks();
},
renderPills() {
// Clear existing pills and button (except placeholder)
const pills = this.elements.pillsContainer.querySelectorAll('.badge');
pills.forEach(pill => pill.remove());
const existingButton = this.elements.pillsContainer.querySelector('#modal_package_add_button');
if (existingButton) {
existingButton.remove();
}
// Create pills from data
this.selectedPackages.forEach(pkg => {
const pill = this.createPillElement(pkg.url, pkg.name);
this.elements.pillsContainer.appendChild(pill);
});
},
renderAddButton() {
// Only render button if there are packages available
if (this.elements.packageOptions.length === 0) {
return;
}
const button = document.createElement('button');
button.type = 'button';
button.id = 'modal_package_add_button';
button.setAttribute('popovertarget', 'package_dropdown_menu');
button.style.cssText = 'anchor-name:--anchor-packages';
button.className = 'btn btn-sm btn-ghost gap-2 text-base-content/50';
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Add Package
`;
this.elements.pillsContainer.appendChild(button);
},
createPillElement(packageUrl, packageName) {
const pill = document.createElement('span');
pill.className = 'badge badge-outline gap-2 max-w-xs';
pill.dataset.packageUrl = packageUrl;
pill.dataset.packageName = packageName;
pill.innerHTML = `
<span class="truncate" title="${packageName}">${packageName}</span>
<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 cursor-pointer hover:text-error package-remove-btn flex-shrink-0 rotate-45"
viewBox="0 0 24 24"
fill="none" stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14"/><path d="M12 5v14"/>
</svg>
`;
return pill;
},
renderCheckmarks() {
// Update all checkmarks based on selected packages
this.elements.packageOptions.forEach(option => {
const packageUrl = option.dataset.packageUrl;
const isSelected = this.selectedPackages.some(pkg => pkg.url === packageUrl);
const checkmark = option.querySelector('.package-checkmark');
if (checkmark) {
checkmark.classList.toggle('hidden', !isSelected);
}
});
},
getSelectedPackages() {
return this.selectedPackages.map(pkg => pkg.url);
}
};
// Modal and Search Management
const modal = document.getElementById('search_modal');
const searchbar = document.getElementById('modal_searchbar');
const searchButton = document.getElementById('modal_search_button');
const modeButton = document.getElementById('modal_mode_button');
const resultsDiv = document.getElementById('search_results');
const loadingDiv = document.getElementById('search_loading');
// MutationObserver to detect when modal opens
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'open' && modal.open) {
PackageSelector.render();
// Delay focus to allow CSS transitions to complete (modal has 0.3s transition)
setTimeout(() => {
searchbar.focus();
}, 320);
}
});
});
observer.observe(modal, { attributes: true });
// Clear results when modal closes
modal.addEventListener('close', function() {
resultsDiv.innerHTML = '';
loadingDiv.classList.add('hidden');
searchbar.value = '';
});
// Mode dropdown handlers
const dropdownMenu = document.getElementById('search_dropdown_menu');
const modeButtons = [
{ id: 'modal_dropdown_text', text: 'Text' },
{ id: 'modal_dropdown_smiles_default', text: 'Default' },
{ id: 'modal_dropdown_smiles_canonical', text: 'Canonical' },
{ id: 'modal_dropdown_smiles_exact', text: 'Exact' },
{ id: 'modal_dropdown_inchikey', text: 'InChIKey' }
];
modeButtons.forEach(({ id, text }) => {
document.getElementById(id).addEventListener('click', function(e) {
e.preventDefault();
modeButton.innerHTML = text + ` <svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>`;
// Close dropdown using popover API
if (dropdownMenu && typeof dropdownMenu.hidePopover === 'function') {
dropdownMenu.hidePopover();
}
});
});
// Initialize Package Selector
PackageSelector.init();
// Search Response Handler
function handleSearchResponse(data) {
resultsDiv.innerHTML = '';
function makeContent(objs) {
let links = '';
objs.forEach(obj => {
links += `<a href="${obj.url}" class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors">${obj.name}</a>`;
});
return links;
}
let allEmpty = true;
let content = '';
// Category order for better UX
const categoryOrder = ['Compounds', 'Compound Structures', 'Rules', 'Reactions', 'Pathways'];
categoryOrder.forEach(key => {
if (!data[key] || data[key].length < 1) {
return;
}
allEmpty = false;
content += `
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
${key} <span class="badge badge-neutral badge-sm ml-2">${data[key].length}</span>
</div>
<div class="collapse-content">
${makeContent(data[key])}
</div>
</div>
`;
});
if (allEmpty) {
resultsDiv.innerHTML = `
<div class="alert alert-warning">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>No results found</span>
</div>
`;
} else {
resultsDiv.innerHTML = `
<div class="mb-2">
<div class="text-sm font-semibold text-base-content/70 mb-2">Results</div>
${content}
</div>
`;
}
}
// Search Execution
function performSearch(e) {
e.preventDefault();
const query = searchbar.value.trim();
if (!query) {
console.log('Search phrase empty, won\'t do search');
return;
}
const selPacks = PackageSelector.getSelectedPackages();
if (selPacks.length < 1) {
console.log('No package selected, won\'t do search');
resultsDiv.innerHTML = `
<div class="alert alert-info">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Please select at least one package</span>
</div>
`;
return;
}
const mode = modeButton.textContent.trim().toLowerCase();
const params = new URLSearchParams();
selPacks.forEach(pack => params.append('packages', pack));
params.append('search', query);
params.append('mode', mode);
// Show loading
loadingDiv.classList.remove('hidden');
resultsDiv.innerHTML = '';
fetch(`{{ SERVER_BASE }}/search?${params.toString()}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Search request failed');
}
return response.json();
})
.then(result => {
loadingDiv.classList.add('hidden');
handleSearchResponse(result);
})
.catch(error => {
loadingDiv.classList.add('hidden');
console.error('Search error:', error);
resultsDiv.innerHTML = `
<div class="alert alert-error">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Search failed. Please try again.</span>
</div>
`;
});
}
// Event listeners for search
searchButton.addEventListener('click', performSearch);
searchbar.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
performSearch(e);
}
});
})();
</script>