forked from enviPath/enviPy
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>
266 lines
7.1 KiB
JavaScript
266 lines
7.1 KiB
JavaScript
/**
|
|
* 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
|
|
}));
|
|
});
|