forked from enviPath/enviPy
[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:
265
static/js/alpine/index.js
Normal file
265
static/js/alpine/index.js
Normal 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
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user