Files
TimeTracker/app/static/form-validation.js
Dries Peeters ac465d9612 feat: Enhance UI/UX with improved form validation and error handling
- Add comprehensive form validation system with real-time feedback
- Implement enhanced error handling with retry mechanisms and offline support
- Update route handlers for improved error responses
- Enhance list templates with better error handling and validation
- Update dashboard, timer, and report templates with enhanced UI
- Improve project service with better error handling
- Update config manager utilities
- Bump version to 4.2.0

Files updated:
- Routes: auth, clients, invoices, projects, quotes, tasks, timer, custom_reports
- Templates: base, dashboard, all list views, timer pages, reports
- Static: enhanced-ui.js, error-handling-enhanced.js, form-validation.js
- Services: project_service.py
- Utils: config_manager.py
- Version: setup.py
2025-11-30 10:51:09 +01:00

614 lines
22 KiB
JavaScript

/**
* Comprehensive Form Validation System
* Provides real-time validation with inline errors, success feedback, and visual indicators
*/
class FormValidator {
constructor(formElement, options = {}) {
this.form = formElement;
this.options = {
validateOnBlur: true,
validateOnInput: true,
validateOnSubmit: true,
showSuccessMessages: true,
showErrorMessages: true,
debounceDelay: 300,
...options
};
this.fields = new Map();
this.debounceTimers = new Map();
this.init();
}
init() {
if (!this.form) return;
// Prevent duplicate initialization
if (this.form.dataset.validatorInitialized === 'true') {
return;
}
// Find all form fields
const inputs = this.form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
// Skip hidden inputs and submit buttons
if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') {
return;
}
// Skip if already has a validator attached
if (input.dataset.validatorAttached === 'true') {
return;
}
const field = this.setupField(input);
if (field) {
this.fields.set(input.name || input.id, field);
input.dataset.validatorAttached = 'true';
}
});
// Mark form as initialized
this.form.dataset.validatorInitialized = 'true';
// Setup form submission validation
if (this.options.validateOnSubmit) {
// Remove existing listener if any
const existingHandler = this.form._validationSubmitHandler;
if (existingHandler) {
this.form.removeEventListener('submit', existingHandler);
}
const submitHandler = (e) => this.handleSubmit(e);
this.form._validationSubmitHandler = submitHandler;
this.form.addEventListener('submit', submitHandler);
}
}
setupField(input) {
const field = {
element: input,
name: input.name || input.id,
label: this.getLabel(input),
required: input.hasAttribute('required') || input.getAttribute('aria-required') === 'true',
validators: [],
errors: [],
isValid: null,
errorContainer: null,
successContainer: null
};
// Create error and success message containers
field.errorContainer = this.createMessageContainer(input, 'error');
field.successContainer = this.createMessageContainer(input, 'success');
// Add visual indicator for required fields
if (field.required && field.label) {
this.markAsRequired(field.label);
}
// Setup validation rules
this.setupValidationRules(field);
// Setup event listeners
if (this.options.validateOnInput) {
input.addEventListener('input', (e) => this.debouncedValidate(field, e));
}
if (this.options.validateOnBlur) {
input.addEventListener('blur', (e) => this.validateField(field, e));
}
// Add initial state
this.updateFieldState(field);
return field;
}
getLabel(input) {
// Try multiple methods to find the label
const id = input.id;
const name = input.name;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label) return label;
}
// Try to find label by parent
const parent = input.closest('div');
if (parent) {
const label = parent.querySelector('label');
if (label) return label;
}
return null;
}
markAsRequired(label) {
if (!label) return;
// Check if already marked with our indicator
if (label.querySelector('.required-indicator')) return;
// Check if label already has an asterisk or required text
const labelText = label.textContent || label.innerText || '';
const labelHTML = label.innerHTML || '';
// Check for existing asterisks or required indicators
if (labelText.includes('*') ||
labelHTML.includes('text-red-500') ||
labelHTML.includes('required-indicator') ||
label.querySelector('span.text-red-500') ||
label.querySelector('[class*="red"]')) {
return; // Already marked
}
// Only add if label doesn't already have required markers
const indicator = document.createElement('span');
indicator.className = 'required-indicator text-red-500 ml-1';
indicator.textContent = '*';
indicator.setAttribute('aria-label', 'Required field');
label.appendChild(indicator);
}
createMessageContainer(input, type) {
// Check if container already exists - search more broadly
const form = input.closest('form');
const inputId = input.id || input.name;
const existing = form ? form.querySelector(`.field-message.field-${type}[data-field="${inputId}"]`) : null;
if (existing) return existing;
// Check if there's already a message container near this input (within same form group)
const parent = input.closest('.form-group, .mb-4, .mb-6, div');
if (parent) {
const nearbyExisting = parent.querySelector(`.field-message.field-${type}[data-field="${inputId}"]`);
if (nearbyExisting) return nearbyExisting;
// Also check for any existing message container of same type
const anyExisting = parent.querySelector(`.field-message.field-${type}`);
if (anyExisting && !anyExisting.getAttribute('data-field')) {
// Reuse existing if it's not assigned to a specific field
anyExisting.setAttribute('data-field', inputId || input.name || '');
return anyExisting;
}
}
const container = document.createElement('div');
container.className = `field-message field-${type} mt-1 text-sm hidden`;
container.setAttribute('role', type === 'error' ? 'alert' : 'status');
container.setAttribute('aria-live', 'polite');
container.setAttribute('data-field', inputId || input.name || '');
// Find the best insertion point - look for existing help text or description
const inputWrapper = input.parentElement;
let inserted = false;
// Try to find existing help text (usually <p class="text-xs">)
let nextSibling = input.nextElementSibling;
while (nextSibling && !inserted) {
if (nextSibling.classList &&
(nextSibling.classList.contains('text-xs') ||
nextSibling.classList.contains('text-sm') ||
nextSibling.classList.contains('field-message'))) {
// Insert before help text
inputWrapper.insertBefore(container, nextSibling);
inserted = true;
break;
}
nextSibling = nextSibling.nextElementSibling;
}
// If no help text found, try to insert after the input but within the same wrapper
if (!inserted) {
if (inputWrapper && inputWrapper.tagName === 'DIV') {
// Insert right after input
if (input.nextSibling) {
inputWrapper.insertBefore(container, input.nextSibling);
} else {
inputWrapper.appendChild(container);
}
inserted = true;
}
}
// Final fallback: append to parent
if (!inserted) {
(inputWrapper || input.parentElement).appendChild(container);
}
return container;
}
setupValidationRules(field) {
const input = field.element;
// Required validation
if (field.required) {
field.validators.push({
name: 'required',
validate: (value) => {
const trimmed = typeof value === 'string' ? value.trim() : value;
return trimmed !== '' && trimmed !== null && trimmed !== undefined;
},
message: window.i18n?.messages?.requiredField || 'This field is required'
});
}
// Type-specific validations
if (input.type === 'email') {
field.validators.push({
name: 'email',
validate: (value) => {
if (!value || value.trim() === '') return true; // Optional fields can be empty
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
},
message: 'Please enter a valid email address'
});
}
// Phone/tel validation
if (input.type === 'tel' ||
input.name?.toLowerCase().includes('phone') ||
input.name?.toLowerCase().includes('tel') ||
input.id?.toLowerCase().includes('phone') ||
input.id?.toLowerCase().includes('tel')) {
field.validators.push({
name: 'phone',
validate: (value) => {
if (!value || value.trim() === '') return true; // Optional fields can be empty
// Remove common phone formatting characters
const cleaned = value.replace(/[\s\-\(\)\+]/g, '');
// Check if it contains only digits (and optional + at start)
const phoneRegex = /^\+?\d{7,15}$/;
return phoneRegex.test(cleaned);
},
message: 'Please enter a valid phone number (7-15 digits, with optional country code)'
});
}
if (input.type === 'url') {
field.validators.push({
name: 'url',
validate: (value) => {
if (!value || value.trim() === '') return true;
try {
new URL(value);
return true;
} catch {
return false;
}
},
message: 'Please enter a valid URL'
});
}
if (input.type === 'number') {
const min = input.getAttribute('min');
const max = input.getAttribute('max');
if (min !== null) {
field.validators.push({
name: 'min',
validate: (value) => {
if (!value || value === '') return true;
return parseFloat(value) >= parseFloat(min);
},
message: `Value must be at least ${min}`
});
}
if (max !== null) {
field.validators.push({
name: 'max',
validate: (value) => {
if (!value || value === '') return true;
return parseFloat(value) <= parseFloat(max);
},
message: `Value must be at most ${max}`
});
}
}
if (input.hasAttribute('minlength')) {
const minLength = parseInt(input.getAttribute('minlength'));
field.validators.push({
name: 'minlength',
validate: (value) => {
if (!value || value.trim() === '') return true;
return value.length >= minLength;
},
message: `Must be at least ${minLength} characters`
});
}
if (input.hasAttribute('maxlength')) {
const maxLength = parseInt(input.getAttribute('maxlength'));
field.validators.push({
name: 'maxlength',
validate: (value) => {
if (!value || value.trim() === '') return true;
return value.length <= maxLength;
},
message: `Must be at most ${maxLength} characters`
});
}
// Pattern validation
if (input.hasAttribute('pattern')) {
const pattern = new RegExp(input.getAttribute('pattern'));
field.validators.push({
name: 'pattern',
validate: (value) => {
if (!value || value.trim() === '') return true;
return pattern.test(value);
},
message: input.getAttribute('title') || 'Please match the required format'
});
}
// Date validations
if (input.type === 'date') {
// Check for date range validation (end date after start date, etc.)
const startDateField = input.getAttribute('data-start-date-for');
const endDateField = input.getAttribute('data-end-date-for');
if (startDateField) {
field.validators.push({
name: 'dateRange',
validate: (value) => {
if (!value) return true;
const startDateInput = this.form.querySelector(`[name="${startDateField}"], [id="${startDateField}"]`);
if (!startDateInput || !startDateInput.value) return true;
return new Date(value) >= new Date(startDateInput.value);
},
message: 'End date must be after start date'
});
}
}
}
debouncedValidate(field, event) {
const timer = this.debounceTimers.get(field.name);
if (timer) {
clearTimeout(timer);
}
const newTimer = setTimeout(() => {
this.validateField(field, event);
}, this.options.debounceDelay);
this.debounceTimers.set(field.name, newTimer);
}
validateField(field, event) {
const input = field.element;
const value = input.type === 'checkbox' ? input.checked : input.value;
field.errors = [];
// Run all validators
for (const validator of field.validators) {
const isValid = validator.validate(value);
if (!isValid) {
field.errors.push(validator.message);
}
}
// Check for custom validation
const customValidation = input.getAttribute('data-validate');
if (customValidation) {
try {
const validationFn = new Function('value', 'field', customValidation);
const result = validationFn(value, field);
if (result !== true && result !== undefined) {
field.errors.push(typeof result === 'string' ? result : 'Invalid value');
}
} catch (e) {
console.warn('Custom validation error:', e);
}
}
field.isValid = field.errors.length === 0;
// Update UI
this.updateFieldState(field);
return field.isValid;
}
updateFieldState(field) {
const input = field.element;
// Remove all validation classes
input.classList.remove('is-valid', 'is-invalid', 'field-required', 'field-optional');
if (field.isValid === null) {
// Initial state - no validation yet
if (field.required) {
input.classList.add('field-required');
} else {
input.classList.add('field-optional');
}
return;
}
if (field.isValid) {
input.classList.add('is-valid');
input.classList.remove('is-invalid');
// Show success message
if (this.options.showSuccessMessages && field.successContainer) {
this.showSuccessMessage(field);
} else {
this.hideSuccessMessage(field);
}
// Hide error message
this.hideErrorMessage(field);
} else {
input.classList.add('is-invalid');
input.classList.remove('is-valid');
// Show error message
if (this.options.showErrorMessages && field.errorContainer) {
this.showErrorMessage(field);
}
// Hide success message
this.hideSuccessMessage(field);
}
}
showErrorMessage(field) {
if (!field.errorContainer) return;
field.errorContainer.textContent = field.errors[0] || 'Invalid value';
field.errorContainer.classList.remove('hidden');
field.errorContainer.setAttribute('aria-hidden', 'false');
}
hideErrorMessage(field) {
if (!field.errorContainer) return;
field.errorContainer.classList.add('hidden');
field.errorContainer.setAttribute('aria-hidden', 'true');
}
showSuccessMessage(field) {
if (!field.successContainer) return;
// Don't show success messages for checkboxes or radio buttons
// They don't need validation feedback like text inputs do
const input = field.element;
if (input.type === 'checkbox' || input.type === 'radio') {
return;
}
const successMessage = field.element.getAttribute('data-success-message') ||
'Looks good!';
field.successContainer.textContent = successMessage;
field.successContainer.classList.remove('hidden');
field.successContainer.setAttribute('aria-hidden', 'false');
}
hideSuccessMessage(field) {
if (!field.successContainer) return;
field.successContainer.classList.add('hidden');
field.successContainer.setAttribute('aria-hidden', 'true');
}
handleSubmit(event) {
let isValid = true;
// Validate all fields
this.fields.forEach((field) => {
const fieldValid = this.validateField(field);
if (!fieldValid) {
isValid = false;
}
});
if (!isValid) {
event.preventDefault();
event.stopPropagation();
// Focus on first invalid field
const firstInvalid = Array.from(this.fields.values()).find(f => !f.isValid);
if (firstInvalid) {
firstInvalid.element.focus();
firstInvalid.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Show form-level error message
this.showFormError('Please fix the errors in the form before submitting.');
}
return isValid;
}
showFormError(message) {
let formErrorContainer = this.form.querySelector('.form-error-message');
if (!formErrorContainer) {
formErrorContainer = document.createElement('div');
formErrorContainer.className = 'form-error-message mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400';
formErrorContainer.setAttribute('role', 'alert');
this.form.insertBefore(formErrorContainer, this.form.firstChild);
}
formErrorContainer.textContent = message;
formErrorContainer.classList.remove('hidden');
}
hideFormError() {
const formErrorContainer = this.form.querySelector('.form-error-message');
if (formErrorContainer) {
formErrorContainer.classList.add('hidden');
}
}
// Public API methods
validate() {
let isValid = true;
this.fields.forEach((field) => {
if (!this.validateField(field)) {
isValid = false;
}
});
return isValid;
}
reset() {
this.fields.forEach((field) => {
field.element.classList.remove('is-valid', 'is-invalid');
field.isValid = null;
this.hideErrorMessage(field);
this.hideSuccessMessage(field);
this.updateFieldState(field);
});
this.hideFormError();
}
}
// Initialize validation on all forms when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Auto-initialize forms with data-validate-form attribute
document.querySelectorAll('form[data-validate-form]').forEach(form => {
// Prevent duplicate initialization
if (!form.dataset.validatorInitialized) {
try {
new FormValidator(form);
form.dataset.validatorInitialized = 'true';
} catch (e) {
console.warn('Form validation initialization failed:', e);
}
}
});
// Initialize forms with novalidate attribute (for custom validation)
// Only if they also have data-validate-form to avoid conflicts
document.querySelectorAll('form[novalidate][data-validate-form]').forEach(form => {
// Only initialize if not already initialized
if (!form.dataset.validatorInitialized) {
try {
new FormValidator(form);
form.dataset.validatorInitialized = 'true';
} catch (e) {
console.warn('Form validation initialization failed:', e);
}
}
});
});
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = FormValidator;
}