[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;
}
}));
});