mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 05:11:10 -05:00
552 lines
17 KiB
JavaScript
552 lines
17 KiB
JavaScript
/**
|
|
* ThrillWiki Form Validation Module
|
|
*
|
|
* Provides client-side form validation helpers with HTMX integration.
|
|
* Works with Alpine.js form components and Django form fields.
|
|
*
|
|
* Features:
|
|
* - Debounced HTMX validation triggers
|
|
* - Field state management (pristine, dirty, valid, invalid)
|
|
* - Real-time validation feedback
|
|
* - Integration with Alpine.js stores
|
|
* - Accessible error announcements
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Form Field State Management
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Field state constants
|
|
*/
|
|
const FieldState = {
|
|
PRISTINE: 'pristine', // Field has not been touched
|
|
DIRTY: 'dirty', // Field has been modified
|
|
VALIDATING: 'validating', // Field is being validated
|
|
VALID: 'valid', // Field passed validation
|
|
INVALID: 'invalid', // Field failed validation
|
|
};
|
|
|
|
/**
|
|
* FormValidator class for managing form validation state
|
|
*/
|
|
class FormValidator {
|
|
constructor(formElement, options = {}) {
|
|
this.form = formElement;
|
|
this.options = {
|
|
validateOnBlur: true,
|
|
validateOnChange: true,
|
|
debounceMs: 500,
|
|
showSuccessState: true,
|
|
scrollToFirstError: true,
|
|
...options,
|
|
};
|
|
|
|
this.fields = new Map();
|
|
this.debounceTimers = new Map();
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialize form validation
|
|
*/
|
|
init() {
|
|
if (!this.form) return;
|
|
|
|
// Find all form fields
|
|
const inputs = this.form.querySelectorAll('input, textarea, select');
|
|
inputs.forEach(input => {
|
|
if (input.name) {
|
|
this.registerField(input);
|
|
}
|
|
});
|
|
|
|
// Prevent default form submission if validation fails
|
|
this.form.addEventListener('submit', (e) => {
|
|
if (!this.validateAll()) {
|
|
e.preventDefault();
|
|
if (this.options.scrollToFirstError) {
|
|
this.scrollToFirstError();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register a field for validation
|
|
*/
|
|
registerField(input) {
|
|
const fieldName = input.name;
|
|
|
|
this.fields.set(fieldName, {
|
|
element: input,
|
|
state: FieldState.PRISTINE,
|
|
errors: [],
|
|
touched: false,
|
|
});
|
|
|
|
// Add event listeners
|
|
if (this.options.validateOnBlur) {
|
|
input.addEventListener('blur', () => this.onFieldBlur(fieldName));
|
|
}
|
|
|
|
if (this.options.validateOnChange) {
|
|
input.addEventListener('input', () => this.onFieldChange(fieldName));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle field blur event
|
|
*/
|
|
onFieldBlur(fieldName) {
|
|
const field = this.fields.get(fieldName);
|
|
if (!field) return;
|
|
|
|
field.touched = true;
|
|
this.validateField(fieldName);
|
|
}
|
|
|
|
/**
|
|
* Handle field change event with debouncing
|
|
*/
|
|
onFieldChange(fieldName) {
|
|
const field = this.fields.get(fieldName);
|
|
if (!field) return;
|
|
|
|
field.state = FieldState.DIRTY;
|
|
this.updateFieldUI(fieldName);
|
|
|
|
// Debounce validation
|
|
clearTimeout(this.debounceTimers.get(fieldName));
|
|
this.debounceTimers.set(fieldName, setTimeout(() => {
|
|
if (field.touched) {
|
|
this.validateField(fieldName);
|
|
}
|
|
}, this.options.debounceMs));
|
|
}
|
|
|
|
/**
|
|
* Validate a single field
|
|
*/
|
|
validateField(fieldName) {
|
|
const field = this.fields.get(fieldName);
|
|
if (!field) return true;
|
|
|
|
field.state = FieldState.VALIDATING;
|
|
this.updateFieldUI(fieldName);
|
|
|
|
const input = field.element;
|
|
const errors = [];
|
|
|
|
// Built-in HTML5 validation
|
|
if (!input.validity.valid) {
|
|
if (input.validity.valueMissing) {
|
|
errors.push(`${this.getFieldLabel(fieldName)} is required`);
|
|
}
|
|
if (input.validity.typeMismatch) {
|
|
errors.push(`Please enter a valid ${input.type}`);
|
|
}
|
|
if (input.validity.tooShort) {
|
|
errors.push(`Must be at least ${input.minLength} characters`);
|
|
}
|
|
if (input.validity.tooLong) {
|
|
errors.push(`Must be no more than ${input.maxLength} characters`);
|
|
}
|
|
if (input.validity.patternMismatch) {
|
|
errors.push(input.title || 'Please match the requested format');
|
|
}
|
|
if (input.validity.rangeUnderflow) {
|
|
errors.push(`Must be at least ${input.min}`);
|
|
}
|
|
if (input.validity.rangeOverflow) {
|
|
errors.push(`Must be no more than ${input.max}`);
|
|
}
|
|
}
|
|
|
|
// Custom validation rules from data attributes
|
|
if (input.dataset.validateUrl && input.value) {
|
|
try {
|
|
new URL(input.value);
|
|
} catch {
|
|
errors.push('Please enter a valid URL');
|
|
}
|
|
}
|
|
|
|
if (input.dataset.validateMatch) {
|
|
const matchField = this.form.querySelector(`[name="${input.dataset.validateMatch}"]`);
|
|
if (matchField && input.value !== matchField.value) {
|
|
errors.push(`Must match ${this.getFieldLabel(input.dataset.validateMatch)}`);
|
|
}
|
|
}
|
|
|
|
// Update field state
|
|
field.errors = errors;
|
|
field.state = errors.length > 0 ? FieldState.INVALID : FieldState.VALID;
|
|
this.updateFieldUI(fieldName);
|
|
|
|
return errors.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Validate all fields
|
|
*/
|
|
validateAll() {
|
|
let isValid = true;
|
|
|
|
this.fields.forEach((field, fieldName) => {
|
|
field.touched = true;
|
|
if (!this.validateField(fieldName)) {
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Update field UI based on state
|
|
*/
|
|
updateFieldUI(fieldName) {
|
|
const field = this.fields.get(fieldName);
|
|
if (!field) return;
|
|
|
|
const input = field.element;
|
|
const wrapper = input.closest('.form-field');
|
|
const feedback = wrapper?.querySelector('.field-feedback');
|
|
|
|
// Remove all state classes
|
|
input.classList.remove(
|
|
'border-red-500', 'border-green-500', 'border-gray-300',
|
|
'focus:ring-red-500', 'focus:ring-green-500', 'focus:ring-blue-500'
|
|
);
|
|
|
|
// Set aria attributes
|
|
input.setAttribute('aria-invalid', field.state === FieldState.INVALID);
|
|
|
|
switch (field.state) {
|
|
case FieldState.VALIDATING:
|
|
input.classList.add('border-blue-300');
|
|
break;
|
|
|
|
case FieldState.INVALID:
|
|
input.classList.add('border-red-500', 'focus:ring-red-500');
|
|
if (feedback) {
|
|
feedback.innerHTML = this.renderErrors(field.errors);
|
|
}
|
|
break;
|
|
|
|
case FieldState.VALID:
|
|
if (this.options.showSuccessState && field.touched) {
|
|
input.classList.add('border-green-500', 'focus:ring-green-500');
|
|
if (feedback) {
|
|
feedback.innerHTML = this.renderSuccess();
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
input.classList.add('border-gray-300', 'focus:ring-blue-500');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render error messages HTML
|
|
*/
|
|
renderErrors(errors) {
|
|
if (errors.length === 0) return '';
|
|
|
|
return `
|
|
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1 animate-slide-down" role="alert" aria-live="assertive">
|
|
${errors.map(error => `
|
|
<li class="flex items-start gap-1.5">
|
|
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
|
|
<span>${error}</span>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render success indicator HTML
|
|
*/
|
|
renderSuccess() {
|
|
return `
|
|
<div class="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5 animate-slide-down" role="status">
|
|
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Get field label text
|
|
*/
|
|
getFieldLabel(fieldName) {
|
|
const field = this.fields.get(fieldName);
|
|
if (!field) return fieldName;
|
|
|
|
const wrapper = field.element.closest('.form-field');
|
|
const label = wrapper?.querySelector('label');
|
|
return label?.textContent?.replace('*', '').trim() || fieldName;
|
|
}
|
|
|
|
/**
|
|
* Scroll to first error
|
|
*/
|
|
scrollToFirstError() {
|
|
for (const [fieldName, field] of this.fields) {
|
|
if (field.state === FieldState.INVALID) {
|
|
field.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
field.element.focus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set external errors (from server)
|
|
*/
|
|
setServerErrors(errors) {
|
|
Object.entries(errors).forEach(([fieldName, messages]) => {
|
|
const field = this.fields.get(fieldName);
|
|
if (field) {
|
|
field.errors = Array.isArray(messages) ? messages : [messages];
|
|
field.state = FieldState.INVALID;
|
|
field.touched = true;
|
|
this.updateFieldUI(fieldName);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all errors
|
|
*/
|
|
clearErrors() {
|
|
this.fields.forEach((field, fieldName) => {
|
|
field.errors = [];
|
|
field.state = FieldState.PRISTINE;
|
|
this.updateFieldUI(fieldName);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reset form to initial state
|
|
*/
|
|
reset() {
|
|
this.clearErrors();
|
|
this.fields.forEach(field => {
|
|
field.touched = false;
|
|
});
|
|
this.form.reset();
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// HTMX Validation Integration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Setup HTMX validation for a field
|
|
* @param {HTMLElement} input - The input element
|
|
* @param {string} validateUrl - URL for validation endpoint
|
|
* @param {object} options - Configuration options
|
|
*/
|
|
function setupHTMXValidation(input, validateUrl, options = {}) {
|
|
const defaults = {
|
|
trigger: 'blur changed delay:500ms',
|
|
indicator: true,
|
|
targetSelector: null,
|
|
};
|
|
|
|
const config = { ...defaults, ...options };
|
|
|
|
// Generate target ID
|
|
const targetId = config.targetSelector || `#${input.name}-feedback`;
|
|
|
|
// Set HTMX attributes
|
|
input.setAttribute('hx-post', validateUrl);
|
|
input.setAttribute('hx-trigger', config.trigger);
|
|
input.setAttribute('hx-target', targetId);
|
|
input.setAttribute('hx-swap', 'innerHTML');
|
|
|
|
if (config.indicator) {
|
|
const indicatorId = `#${input.name}-indicator`;
|
|
input.setAttribute('hx-indicator', indicatorId);
|
|
|
|
// Create indicator if it doesn't exist
|
|
const wrapper = input.closest('.form-field');
|
|
if (wrapper && !wrapper.querySelector(indicatorId)) {
|
|
const indicator = document.createElement('span');
|
|
indicator.id = `${input.name}-indicator`;
|
|
indicator.className = 'htmx-indicator absolute right-3 top-1/2 -translate-y-1/2';
|
|
indicator.innerHTML = '<i class="fas fa-spinner fa-spin text-gray-400" aria-hidden="true"></i>';
|
|
const inputWrapper = wrapper.querySelector('.relative') || wrapper;
|
|
inputWrapper.appendChild(indicator);
|
|
}
|
|
}
|
|
|
|
// Process HTMX attributes
|
|
if (typeof htmx !== 'undefined') {
|
|
htmx.process(input);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Alpine.js Integration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Alpine.js form validation component
|
|
* Usage: x-data="formValidation('/api/validate/')"
|
|
*/
|
|
document.addEventListener('alpine:init', () => {
|
|
if (typeof Alpine === 'undefined') return;
|
|
|
|
Alpine.data('formValidation', (validateUrl = null) => ({
|
|
fields: {},
|
|
errors: {},
|
|
touched: {},
|
|
validating: {},
|
|
submitted: false,
|
|
|
|
init() {
|
|
// Find all form fields within this component
|
|
this.$el.querySelectorAll('input, textarea, select').forEach(input => {
|
|
if (input.name) {
|
|
this.fields[input.name] = input.value;
|
|
this.errors[input.name] = [];
|
|
this.touched[input.name] = false;
|
|
this.validating[input.name] = false;
|
|
}
|
|
});
|
|
},
|
|
|
|
async validateField(fieldName) {
|
|
if (!validateUrl) return true;
|
|
|
|
this.validating[fieldName] = true;
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
|
|
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
|
|
|
const response = await fetch(validateUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
},
|
|
body: JSON.stringify({
|
|
field: fieldName,
|
|
value: this.fields[fieldName],
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.errors && data.errors[fieldName]) {
|
|
this.errors[fieldName] = data.errors[fieldName];
|
|
return false;
|
|
} else {
|
|
this.errors[fieldName] = [];
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
return true; // Don't block on network errors
|
|
} finally {
|
|
this.validating[fieldName] = false;
|
|
}
|
|
},
|
|
|
|
onBlur(fieldName) {
|
|
this.touched[fieldName] = true;
|
|
this.validateField(fieldName);
|
|
},
|
|
|
|
hasError(fieldName) {
|
|
return this.touched[fieldName] && this.errors[fieldName]?.length > 0;
|
|
},
|
|
|
|
isValid(fieldName) {
|
|
return this.touched[fieldName] && this.errors[fieldName]?.length === 0;
|
|
},
|
|
|
|
getErrors(fieldName) {
|
|
return this.errors[fieldName] || [];
|
|
},
|
|
|
|
setServerErrors(errors) {
|
|
Object.entries(errors).forEach(([field, messages]) => {
|
|
this.errors[field] = Array.isArray(messages) ? messages : [messages];
|
|
this.touched[field] = true;
|
|
});
|
|
},
|
|
|
|
clearErrors() {
|
|
Object.keys(this.errors).forEach(field => {
|
|
this.errors[field] = [];
|
|
});
|
|
},
|
|
|
|
async validateAll() {
|
|
let isValid = true;
|
|
|
|
for (const fieldName of Object.keys(this.fields)) {
|
|
this.touched[fieldName] = true;
|
|
const fieldValid = await this.validateField(fieldName);
|
|
if (!fieldValid) isValid = false;
|
|
}
|
|
|
|
return isValid;
|
|
},
|
|
|
|
async submitForm(url, options = {}) {
|
|
this.submitted = true;
|
|
|
|
const isValid = await this.validateAll();
|
|
if (!isValid) {
|
|
return { success: false, errors: this.errors };
|
|
}
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
|
|
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
...options.headers,
|
|
},
|
|
body: JSON.stringify(this.fields),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
if (data.errors) {
|
|
this.setServerErrors(data.errors);
|
|
}
|
|
return { success: false, errors: data.errors || data };
|
|
}
|
|
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
console.error('Form submission error:', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
},
|
|
}));
|
|
});
|
|
|
|
// =============================================================================
|
|
// Global Exports
|
|
// =============================================================================
|
|
|
|
window.FormValidator = FormValidator;
|
|
window.setupHTMXValidation = setupHTMXValidation;
|
|
window.FieldState = FieldState;
|