mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-25 05:58:50 -06:00
- 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
614 lines
22 KiB
JavaScript
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;
|
|
}
|
|
|