[Feature] Modern UI roll out (#236)

This PR moves all the collection pages into the new UI in a rough push.
I did not put the same amount of care into these as into search, index, and predict.

## Major changes

- All modals are now migrated to a state based alpine.js implementation.
- jQuery is no longer present in the base layout; ajax is replace by native fetch api
- most of the pps.js is now obsolte (as I understand it; the code is not referenced any more @jebus  please double check)
- in-memory pagination for large result lists (set to 50; we can make that configurable later; performance degrades at around 1k) stukk a bit rough tracked in #235

## Minor things

- Sarch and index also use alpine now
- The loading spinner is now CSS animated (not sure if it currently gets correctly called)

## Not done

- Ihave not even cheked the admin pages. Not sure If these need migrations
- The temporary migration pages still use the old template. Not sure what is supposed to happen with those? @jebus

## What I did to test

- opend all pages in browse, and user ; plus all pages reachable from there.
- Interacted and tested the functionality of each modal superfically with exception of the API key modal (no functional test).

---
This PR is massive sorry for that; just did not want to push half-brokenn state.
@jebus @liambrydon I would be glad if you could click around and try to break it :)

Finally closes #133

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#236
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2025-11-26 23:16:44 +13:00
committed by jebus
parent 7f6f209b4a
commit 1a2c9bb543
110 changed files with 10784 additions and 9465 deletions

265
static/js/alpine/index.js Normal file
View File

@ -0,0 +1,265 @@
/**
* Alpine.js Components for enviPath
*
* This module provides reusable Alpine.js data components for modals,
* form validation, and form submission.
*/
document.addEventListener('alpine:init', () => {
/**
* Modal Form Component
*
* Provides form validation using HTML5 Constraint Validation API,
* loading states for submission, and error message management.
*
* Basic Usage:
* <dialog x-data="modalForm()" @close="reset()">
* <form id="my-form">
* <input name="field" required>
* </form>
* <button @click="submit('my-form')" :disabled="isSubmitting">Submit</button>
* </dialog>
*
* With Custom State:
* <dialog x-data="modalForm({ state: { selectedItem: '', imageUrl: '' } })" @close="reset()">
* <select x-model="selectedItem" @change="updateImagePreview(selectedItem + '?image=svg')">
* <img :src="imageUrl" x-show="imageUrl">
* </dialog>
*
* With AJAX:
* <button @click="submitAsync('my-form', { onSuccess: (data) => console.log(data) })">
*/
Alpine.data('modalForm', (options = {}) => ({
isSubmitting: false,
errors: {},
// Spread custom initial state from options
...(options.state || {}),
/**
* Validate a single field using HTML5 Constraint Validation API
* @param {HTMLElement} field - The input/select/textarea element
*/
validateField(field) {
const name = field.name || field.id;
if (!name) return;
if (!field.validity.valid) {
this.errors[name] = field.validationMessage;
} else {
delete this.errors[name];
}
},
/**
* Clear error for a field (call on input)
* @param {HTMLElement} field - The input element
*/
clearError(field) {
const name = field.name || field.id;
if (name && this.errors[name]) {
delete this.errors[name];
}
},
/**
* Get error message for a field
* @param {string} name - Field name
* @returns {string|undefined} Error message or undefined
*/
getError(name) {
return this.errors[name];
},
/**
* Check if form has any errors
* @returns {boolean} True if there are errors
*/
hasErrors() {
return Object.keys(this.errors).length > 0;
},
/**
* Validate all fields in a form
* @param {string} formId - The form element ID
* @returns {boolean} True if form is valid
*/
validateAll(formId) {
const form = document.getElementById(formId);
if (!form) return false;
this.errors = {};
const fields = form.querySelectorAll('input, select, textarea');
fields.forEach(field => {
if (field.name && !field.validity.valid) {
this.errors[field.name] = field.validationMessage;
}
});
return !this.hasErrors();
},
/**
* Validate that two password fields match
* @param {string} password1Id - ID of first password field
* @param {string} password2Id - ID of second password field
* @returns {boolean} True if passwords match
*/
validatePasswordMatch(password1Id, password2Id) {
const pw1 = document.getElementById(password1Id);
const pw2 = document.getElementById(password2Id);
if (!pw1 || !pw2) return false;
if (pw1.value !== pw2.value) {
this.errors[pw2.name || password2Id] = 'Passwords do not match';
pw2.setCustomValidity('Passwords do not match');
return false;
}
delete this.errors[pw2.name || password2Id];
pw2.setCustomValidity('');
return true;
},
/**
* Submit a form with loading state
* @param {string} formId - The form element ID
*/
submit(formId) {
const form = document.getElementById(formId);
if (!form) return;
// Validate before submit
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Set action to current URL if empty
if (!form.action || form.action === window.location.href + '#') {
form.action = window.location.href;
}
// Set loading state and submit
this.isSubmitting = true;
form.submit();
},
/**
* Submit form via AJAX (fetch)
* @param {string} formId - The form element ID
* @param {Object} options - Options { onSuccess, onError, closeOnSuccess }
*/
async submitAsync(formId, options = {}) {
const form = document.getElementById(formId);
if (!form) return;
// Validate before submit
if (!form.checkValidity()) {
form.reportValidity();
return;
}
this.isSubmitting = true;
try {
const formData = new FormData(form);
const response = await fetch(form.action || window.location.href, {
method: form.method || 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
if (options.onSuccess) {
options.onSuccess(data);
}
if (data.redirect || data.success) {
window.location.href = data.redirect || data.success;
} else if (options.closeOnSuccess) {
this.$el.closest('dialog')?.close();
}
} else {
const errorMsg = data.error || data.message || `Error: ${response.status}`;
this.errors['_form'] = errorMsg;
if (options.onError) {
options.onError(errorMsg, data);
}
}
} catch (error) {
this.errors['_form'] = error.message;
if (options.onError) {
options.onError(error.message);
}
} finally {
this.isSubmitting = false;
}
},
/**
* Set form action URL dynamically
* @param {string} formId - The form element ID
* @param {string} url - The URL to set as action
*/
setFormAction(formId, url) {
const form = document.getElementById(formId);
if (form) {
form.action = url;
}
},
/**
* Update image preview
* @param {string} url - Image URL (with query params)
* @param {string} targetId - Target element ID for the image
*/
updateImagePreview(url) {
// Store URL for reactive binding with :src
this.imageUrl = url;
},
/**
* Reset form state (call on modal close)
* Resets to initial state from options
*/
reset() {
this.isSubmitting = false;
this.errors = {};
this.imageUrl = '';
// Reset custom state to initial values
if (options.state) {
Object.keys(options.state).forEach(key => {
this[key] = options.state[key];
});
}
// Call custom reset handler if provided
if (options.onReset) {
options.onReset.call(this);
}
}
}));
/**
* Simple Modal Component (no form)
*
* For modals that don't need form validation.
*
* Usage:
* <dialog x-data="modal()">
* <button @click="$el.closest('dialog').close()">Close</button>
* </dialog>
*/
Alpine.data('modal', () => ({
// Placeholder for simple modals that may need state later
}));
});

View File

@ -0,0 +1,133 @@
/**
* Alpine.js Pagination Component
*
* Provides client-side pagination for large lists.
*/
document.addEventListener('alpine:init', () => {
Alpine.data('paginatedList', (initialItems = [], options = {}) => ({
allItems: initialItems,
filteredItems: [],
currentPage: 1,
perPage: options.perPage || 50,
searchQuery: '',
isReviewed: options.isReviewed || false,
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
init() {
this.filteredItems = this.allItems;
},
get totalPages() {
return Math.ceil(this.filteredItems.length / this.perPage);
},
get paginatedItems() {
const start = (this.currentPage - 1) * this.perPage;
const end = start + this.perPage;
return this.filteredItems.slice(start, end);
},
get totalItems() {
return this.filteredItems.length;
},
get showingStart() {
if (this.totalItems === 0) return 0;
return (this.currentPage - 1) * this.perPage + 1;
},
get showingEnd() {
return Math.min(this.currentPage * this.perPage, this.totalItems);
},
search(query) {
this.searchQuery = query.toLowerCase();
if (this.searchQuery === '') {
this.filteredItems = this.allItems;
} else {
this.filteredItems = this.allItems.filter(item =>
item.name.toLowerCase().includes(this.searchQuery)
);
}
this.currentPage = 1;
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
}
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
}
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
// Handle empty case
if (total === 0) {
return pages;
}
if (total <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= total; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
} else {
// More than 7 pages - show first, last, and sliding window around current
// Always show first page
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
// Determine the start and end of the middle range
let rangeStart, rangeEnd;
if (current <= 4) {
// Near the beginning: show pages 2-5
rangeStart = 2;
rangeEnd = 5;
} else if (current >= total - 3) {
// Near the end: show last 4 pages before the last page
rangeStart = total - 4;
rangeEnd = total - 1;
} else {
// In the middle: show current page and one on each side
rangeStart = current - 1;
rangeEnd = current + 1;
}
// Add ellipsis before range if there's a gap
if (rangeStart > 2) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
}
// Add pages in the range
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
// Add ellipsis after range if there's a gap
if (rangeEnd < total - 1) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
}
// Always show last page
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
}
return pages;
}
}));
});

145
static/js/alpine/search.js Normal file
View File

@ -0,0 +1,145 @@
/**
* Search Modal Alpine.js Component
*
* Provides package selection, search mode switching, and results display
* for the search modal.
*/
document.addEventListener('alpine:init', () => {
/**
* Search Modal Component
*
* Usage:
* <dialog x-data="searchModal()" @close="reset()">
* ...
* </dialog>
*/
Alpine.data('searchModal', () => ({
// Package selector state
selectedPackages: [],
// Search state
searchMode: 'text',
searchModeLabel: 'Text',
query: '',
// Results state
results: null,
isSearching: false,
error: null,
// Initialize on modal open
init() {
// Load reviewed packages by default
this.loadInitialSelection();
// Watch for modal open to focus searchbar
this.$watch('$el.open', (open) => {
if (open) {
setTimeout(() => {
this.$refs.searchbar.focus();
}, 320);
}
});
},
loadInitialSelection() {
// Select all reviewed packages by default
const menuItems = this.$refs.packageDropdown.querySelectorAll('li');
for (const item of menuItems) {
// Stop at 'Unreviewed Packages' section
if (item.classList.contains('menu-title') &&
item.textContent.trim() === 'Unreviewed Packages') {
break;
}
const packageOption = item.querySelector('.package-option');
if (packageOption) {
this.selectedPackages.push({
url: packageOption.dataset.packageUrl,
name: packageOption.dataset.packageName
});
}
}
},
togglePackage(url, name) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
if (index !== -1) {
this.selectedPackages.splice(index, 1);
} else {
this.selectedPackages.push({ url, name });
}
},
removePackage(url) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
if (index !== -1) {
this.selectedPackages.splice(index, 1);
}
},
isPackageSelected(url) {
return this.selectedPackages.some(pkg => pkg.url === url);
},
setSearchMode(mode, label) {
this.searchMode = mode;
this.searchModeLabel = label;
this.$refs.modeDropdown.hidePopover();
},
async performSearch(serverBase) {
if (!this.query.trim()) {
return;
}
if (this.selectedPackages.length < 1) {
this.results = { error: 'no_packages' };
return;
}
const params = new URLSearchParams();
this.selectedPackages.forEach(pkg => params.append('packages', pkg.url));
params.append('search', this.query.trim());
params.append('mode', this.searchModeLabel.toLowerCase());
this.isSearching = true;
this.results = null;
this.error = null;
try {
const response = await fetch(`${serverBase}/search?${params.toString()}`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error('Search request failed');
}
this.results = await response.json();
} catch (err) {
console.error('Search error:', err);
this.error = 'Search failed. Please try again.';
} finally {
this.isSearching = false;
}
},
hasResults() {
if (!this.results || this.results.error) return false;
const categories = ['Compounds', 'Compound Structures', 'Rules', 'Reactions', 'Pathways'];
return categories.some(cat => this.results[cat] && this.results[cat].length > 0);
},
reset() {
this.query = '';
this.results = null;
this.error = null;
this.isSearching = false;
}
}));
});

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,22 @@
console.log("loaded pw.js")
function predictFromNode(url) {
$.post("", {node: url})
.done(function (data) {
console.log("Success:", data);
window.location.href = data.success;
})
.fail(function (xhr, status, error) {
console.error("Error:", xhr.status, xhr.responseText);
// show user-friendly message or log error
});
fetch("", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
body: new URLSearchParams({node: url})
})
.then(response => response.json())
.then(data => {
console.log("Success:", data);
window.location.href = data.success;
})
.catch(error => {
console.error("Error:", error);
});
}
// data = {{ pathway.d3_json | safe }};
@ -103,6 +110,9 @@ function draw(pathway, elem) {
}
function dragstarted(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
@ -117,6 +127,9 @@ function draw(pathway, elem) {
}
function dragged(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
d.fx = event.x;
d.fy = event.y;
@ -127,6 +140,9 @@ function draw(pathway, elem) {
}
function dragended(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0);
// Mark that dragging has ended
@ -192,52 +208,153 @@ function draw(pathway, elem) {
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
}
// Wait one second before showing popup
// Wait before showing popup (ms)
var popupWaitBeforeShow = 1000;
// Keep Popup at least for one second
var popushowAtLeast = 1000;
function pop_show_e(element) {
var e = element;
setTimeout(function () {
if ($(e).is(':hover')) { // if element is still hovered
$(e).popover("show");
// Custom popover element
let popoverTimeout = null;
// workaround to set fixed positions
pop = $(e).attr("aria-describedby")
h = $('#' + pop).height();
$('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`)
setTimeout(function () {
var close = setInterval(function () {
if (!$(".popover:hover").length // mouse outside popover
&& !$(e).is(':hover')) { // mouse outside element
$(e).popover('hide');
clearInterval(close);
}
}, 100);
}, popushowAtLeast);
function createPopover() {
const popover = document.createElement('div');
popover.id = 'custom-popover';
popover.className = 'fixed z-50';
popover.style.cssText = `
background: #ffffff;
border: 1px solid #d1d5db;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
max-width: 320px;
padding: 0.75rem;
border-radius: 0.5rem;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease-in-out, visibility 150ms ease-in-out;
pointer-events: auto;
`;
popover.setAttribute('role', 'tooltip');
popover.innerHTML = `
<div class="font-semibold mb-2 popover-title" style="font-weight: 600; margin-bottom: 0.5rem;"></div>
<div class="text-sm popover-content" style="font-size: 0.875rem;"></div>
`;
// Add styles for content images
const style = document.createElement('style');
style.textContent = `
#custom-popover img {
max-width: 100%;
height: auto;
display: block;
margin: 0.5rem 0;
}
}, popupWaitBeforeShow);
#custom-popover a {
color: #2563eb;
text-decoration: none;
}
#custom-popover a:hover {
text-decoration: underline;
}
`;
if (!document.getElementById('popover-styles')) {
style.id = 'popover-styles';
document.head.appendChild(style);
}
// Keep popover open when hovering over it
popover.addEventListener('mouseenter', () => {
if (popoverTimeout) {
clearTimeout(popoverTimeout);
popoverTimeout = null;
}
});
popover.addEventListener('mouseleave', () => {
hidePopover();
});
document.body.appendChild(popover);
return popover;
}
function getPopover() {
return document.getElementById('custom-popover') || createPopover();
}
function showPopover(element, title, content) {
const popover = getPopover();
popover.querySelector('.popover-title').textContent = title;
popover.querySelector('.popover-content').innerHTML = content;
// Make visible to measure
popover.style.visibility = 'hidden';
popover.style.opacity = '0';
popover.style.display = 'block';
// Smart positioning - avoid viewport overflow
const padding = 10;
const popoverRect = popover.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = clientX + 15;
let top = clientY - (popoverRect.height / 2);
// Prevent right overflow
if (left + popoverRect.width > viewportWidth - padding) {
left = clientX - popoverRect.width - 15;
}
// Prevent bottom overflow
if (top + popoverRect.height > viewportHeight - padding) {
top = viewportHeight - popoverRect.height - padding;
}
// Prevent top overflow
if (top < padding) {
top = padding;
}
popover.style.top = `${top}px`;
popover.style.left = `${left}px`;
popover.style.visibility = 'visible';
popover.style.opacity = '1';
currentElement = element;
}
function hidePopover() {
const popover = getPopover();
popover.style.opacity = '0';
popover.style.visibility = 'hidden';
currentElement = null;
}
function pop_add(objects, title, contentFunction) {
objects.attr("id", "pop")
.attr("data-container", "body")
.attr("data-toggle", "popover")
.attr("data-placement", "right")
.attr("title", title);
objects.each(function (d) {
const element = this;
objects.each(function (d, i) {
options = {trigger: "manual", html: true, animation: false};
this_ = this;
var p = $(this).popover(options).on("mouseenter", function () {
pop_show_e(this);
element.addEventListener('mouseenter', () => {
if (popoverTimeout) clearTimeout(popoverTimeout);
popoverTimeout = setTimeout(() => {
if (element.matches(':hover')) {
const content = contentFunction(d);
showPopover(element, title, content);
}
}, popupWaitBeforeShow);
});
p.on("show.bs.popover", function (e) {
// this is to dynamically ajdust the content and bounds of the popup
p.attr('data-content', contentFunction(d));
p.data("bs.popover").setContent();
p.data("bs.popover").tip().css({"max-width": "1000px"});
element.addEventListener('mouseleave', () => {
if (popoverTimeout) {
clearTimeout(popoverTimeout);
popoverTimeout = null;
}
// Delay hide to allow moving to popover
setTimeout(() => {
const popover = getPopover();
if (!popover.matches(':hover') && !element.matches(':hover')) {
hidePopover();
}
}, 100);
});
});
}
@ -255,7 +372,7 @@ function draw(pathway, elem) {
}
}
popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><br>"
popupContent += "<img src='" + n.image + "'><br>"
if (n.scenarios.length > 0) {
popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of n.scenarios) {
@ -265,7 +382,7 @@ function draw(pathway, elem) {
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
if (pathway.isIncremental && isLeaf) {
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
}
return popupContent;
@ -285,7 +402,7 @@ function draw(pathway, elem) {
popupContent += adcontent;
}
popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>"
popupContent += "<img src='" + e.image + "'><br>"
if (e.reaction_probability) {
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
}
@ -308,6 +425,23 @@ function draw(pathway, elem) {
});
const zoomable = d3.select("#zoomable");
const svg = d3.select("#pwsvg");
const container = d3.select("#vizdiv");
// Set explicit SVG dimensions for proper zoom behavior
svg.attr("width", width)
.attr("height", height);
// Add background rectangle FIRST to enable pan/zoom on empty space
// This must be inserted before zoomable group so it's behind everything
svg.insert("rect", "#zoomable")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", "transparent")
.attr("pointer-events", "all")
.style("cursor", "grab");
// Zoom Funktion aktivieren
const zoom = d3.zoom()
@ -316,7 +450,12 @@ function draw(pathway, elem) {
zoomable.attr("transform", event.transform);
});
d3.select("svg").call(zoom);
// Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom);
// Also apply zoom to container to catch events that might not reach SVG
// This ensures drag-to-pan works even when clicking on empty space
container.call(zoom);
nodes = pathway['nodes'];
links = pathway['links'];