Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
353
resources/js/modules/form-handling/FormHandler.js
Normal file
353
resources/js/modules/form-handling/FormHandler.js
Normal file
@@ -0,0 +1,353 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { FormValidator } from './FormValidator.js';
|
||||
import { FormState } from './FormState.js';
|
||||
|
||||
export class FormHandler {
|
||||
constructor(form, options = {}) {
|
||||
this.form = form;
|
||||
this.options = {
|
||||
validateOnSubmit: true,
|
||||
validateOnBlur: false,
|
||||
validateOnInput: false,
|
||||
showInlineErrors: true,
|
||||
preventSubmitOnError: true,
|
||||
submitMethod: 'POST',
|
||||
ajaxSubmit: true,
|
||||
...options
|
||||
};
|
||||
|
||||
this.validator = FormValidator.create(form);
|
||||
this.state = FormState.create(form);
|
||||
this.isSubmitting = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
static create(form, options = {}) {
|
||||
return new FormHandler(form, options);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.setupErrorDisplay();
|
||||
|
||||
// Mark form as enhanced
|
||||
this.form.setAttribute('data-enhanced', 'true');
|
||||
|
||||
Logger.info(`[FormHandler] Initialized for form: ${this.form.id || 'unnamed'}`);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Submit event
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Field validation events
|
||||
if (this.options.validateOnBlur || this.options.validateOnInput) {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (this.options.validateOnBlur) {
|
||||
field.addEventListener('blur', () => this.validateSingleField(field));
|
||||
}
|
||||
|
||||
if (this.options.validateOnInput) {
|
||||
field.addEventListener('input', () => this.validateSingleField(field));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
if (this.isSubmitting) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate if enabled
|
||||
if (this.options.validateOnSubmit) {
|
||||
const isValid = this.validator.validate();
|
||||
|
||||
if (!isValid) {
|
||||
event.preventDefault();
|
||||
this.displayErrors();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// AJAX submission
|
||||
if (this.options.ajaxSubmit) {
|
||||
event.preventDefault();
|
||||
await this.submitViaAjax();
|
||||
}
|
||||
|
||||
// If not AJAX, let the form submit naturally
|
||||
}
|
||||
|
||||
async submitViaAjax() {
|
||||
try {
|
||||
this.setSubmitState(true);
|
||||
this.clearErrors();
|
||||
|
||||
const formData = new FormData(this.form);
|
||||
const url = this.form.action || window.location.href;
|
||||
const method = this.form.method || this.options.submitMethod;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await this.parseResponse(response);
|
||||
|
||||
if (response.ok) {
|
||||
this.handleSuccess(data);
|
||||
} else {
|
||||
this.handleError(data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[FormHandler] Submit error:', error);
|
||||
this.handleError({
|
||||
message: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
errors: {}
|
||||
});
|
||||
} finally {
|
||||
this.setSubmitState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async parseResponse(response) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// Try to parse as JSON, fallback to text
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccess(data) {
|
||||
Logger.info('[FormHandler] Form submitted successfully');
|
||||
|
||||
// Clear form if configured
|
||||
if (data.clearForm !== false) {
|
||||
this.form.reset();
|
||||
this.state.reset();
|
||||
}
|
||||
|
||||
this.showMessage(data.message || 'Formular erfolgreich gesendet!', 'success');
|
||||
|
||||
// Call success callback
|
||||
this.triggerEvent('form:success', { data });
|
||||
}
|
||||
|
||||
handleError(data) {
|
||||
Logger.warn('[FormHandler] Form submission error:', data);
|
||||
|
||||
// Handle field-specific errors
|
||||
if (data.errors && typeof data.errors === 'object') {
|
||||
for (const [fieldName, errorMessage] of Object.entries(data.errors)) {
|
||||
this.validator.errors.set(fieldName, errorMessage);
|
||||
}
|
||||
this.displayErrors();
|
||||
}
|
||||
|
||||
// Show general error message
|
||||
this.showMessage(data.message || 'Ein Fehler ist aufgetreten.', 'error');
|
||||
|
||||
// Call error callback
|
||||
this.triggerEvent('form:error', { data });
|
||||
}
|
||||
|
||||
validateSingleField(field) {
|
||||
this.validator.errors.delete(field.name);
|
||||
this.validator.validateField(field);
|
||||
|
||||
this.displayFieldError(field);
|
||||
}
|
||||
|
||||
displayErrors() {
|
||||
if (!this.options.showInlineErrors) return;
|
||||
|
||||
const errors = this.validator.getErrors();
|
||||
|
||||
for (const [fieldName, message] of Object.entries(errors)) {
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
if (field) {
|
||||
this.displayFieldError(field, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayFieldError(field, message = null) {
|
||||
const errorMessage = message || this.validator.getFieldError(field.name);
|
||||
const errorElement = this.getOrCreateErrorElement(field);
|
||||
|
||||
if (errorMessage) {
|
||||
errorElement.textContent = errorMessage;
|
||||
errorElement.style.display = 'block';
|
||||
field.classList.add('error');
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
field.setAttribute('aria-describedby', errorElement.id);
|
||||
} else {
|
||||
errorElement.textContent = '';
|
||||
errorElement.style.display = 'none';
|
||||
field.classList.remove('error');
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.removeAttribute('aria-describedby');
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateErrorElement(field) {
|
||||
const errorId = `error-${field.name}`;
|
||||
let errorElement = document.getElementById(errorId);
|
||||
|
||||
if (!errorElement) {
|
||||
errorElement = document.createElement('div');
|
||||
errorElement.id = errorId;
|
||||
errorElement.className = 'form-error';
|
||||
errorElement.setAttribute('role', 'alert');
|
||||
errorElement.style.display = 'none';
|
||||
|
||||
// Insert after field or field container
|
||||
const container = field.closest('.form-group') || field.parentElement;
|
||||
container.appendChild(errorElement);
|
||||
}
|
||||
|
||||
return errorElement;
|
||||
}
|
||||
|
||||
setupErrorDisplay() {
|
||||
// Add CSS for error states if not present
|
||||
if (!document.getElementById('form-handler-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'form-handler-styles';
|
||||
styles.textContent = `
|
||||
.form-error {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
input.error, textarea.error, select.error {
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 0 0 1px #dc2626;
|
||||
}
|
||||
.form-message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.form-message.success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
.form-message.error {
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
}
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
let messageContainer = this.form.querySelector('.form-messages');
|
||||
|
||||
if (!messageContainer) {
|
||||
messageContainer = document.createElement('div');
|
||||
messageContainer.className = 'form-messages';
|
||||
this.form.prepend(messageContainer);
|
||||
}
|
||||
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.className = `form-message ${type}`;
|
||||
messageElement.textContent = message;
|
||||
messageElement.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
|
||||
messageContainer.innerHTML = '';
|
||||
messageContainer.appendChild(messageElement);
|
||||
|
||||
// Auto-hide success messages after 5 seconds
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
if (messageElement.parentElement) {
|
||||
messageElement.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.validator.clearErrors();
|
||||
|
||||
// Clear visual error states
|
||||
const errorFields = this.form.querySelectorAll('.error');
|
||||
errorFields.forEach(field => {
|
||||
field.classList.remove('error');
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.removeAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
// Hide error messages
|
||||
const errorElements = this.form.querySelectorAll('.form-error');
|
||||
errorElements.forEach(element => {
|
||||
element.style.display = 'none';
|
||||
element.textContent = '';
|
||||
});
|
||||
|
||||
// Clear general messages
|
||||
const messageContainer = this.form.querySelector('.form-messages');
|
||||
if (messageContainer) {
|
||||
messageContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitState(isSubmitting) {
|
||||
this.isSubmitting = isSubmitting;
|
||||
|
||||
const submitButtons = this.form.querySelectorAll('button[type="submit"], input[type="submit"]');
|
||||
|
||||
submitButtons.forEach(button => {
|
||||
button.disabled = isSubmitting;
|
||||
|
||||
if (isSubmitting) {
|
||||
button.setAttribute('data-original-text', button.textContent);
|
||||
button.textContent = 'Wird gesendet...';
|
||||
} else {
|
||||
const originalText = button.getAttribute('data-original-text');
|
||||
if (originalText) {
|
||||
button.textContent = originalText;
|
||||
button.removeAttribute('data-original-text');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
triggerEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(event);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Remove event listeners and clean up
|
||||
this.form.removeAttribute('data-enhanced');
|
||||
Logger.info('[FormHandler] Destroyed');
|
||||
}
|
||||
}
|
||||
256
resources/js/modules/form-handling/FormState.js
Normal file
256
resources/js/modules/form-handling/FormState.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export class FormState {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.pristineValues = new Map();
|
||||
this.touchedFields = new Set();
|
||||
this.dirtyFields = new Set();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
static create(form) {
|
||||
return new FormState(form);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Store initial values
|
||||
this.captureInitialValues();
|
||||
|
||||
// Track field interactions
|
||||
this.bindEvents();
|
||||
|
||||
Logger.info(`[FormState] Initialized for form: ${this.form.id || 'unnamed'}`);
|
||||
}
|
||||
|
||||
captureInitialValues() {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
let value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
value = field.checked;
|
||||
break;
|
||||
default:
|
||||
value = field.value;
|
||||
}
|
||||
|
||||
this.pristineValues.set(field.name, value);
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
// Skip hidden fields for interaction tracking (they can't be touched/interacted with)
|
||||
if (field.type === 'hidden') return;
|
||||
|
||||
// Mark field as touched on first interaction
|
||||
field.addEventListener('focus', () => {
|
||||
this.markAsTouched(field.name);
|
||||
});
|
||||
|
||||
// Track changes for dirty state
|
||||
field.addEventListener('input', () => {
|
||||
this.checkDirtyState(field);
|
||||
});
|
||||
|
||||
field.addEventListener('change', () => {
|
||||
this.checkDirtyState(field);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
markAsTouched(fieldName) {
|
||||
this.touchedFields.add(fieldName);
|
||||
this.updateFormClasses();
|
||||
}
|
||||
|
||||
checkDirtyState(field) {
|
||||
const fieldName = field.name;
|
||||
const currentValue = this.getFieldValue(field);
|
||||
const pristineValue = this.pristineValues.get(fieldName);
|
||||
|
||||
if (currentValue !== pristineValue) {
|
||||
this.dirtyFields.add(fieldName);
|
||||
} else {
|
||||
this.dirtyFields.delete(fieldName);
|
||||
}
|
||||
|
||||
this.updateFormClasses();
|
||||
}
|
||||
|
||||
getFieldValue(field) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
return field.checked;
|
||||
default:
|
||||
return field.value;
|
||||
}
|
||||
}
|
||||
|
||||
updateFormClasses() {
|
||||
// Update form-level classes based on state
|
||||
if (this.isDirty()) {
|
||||
this.form.classList.add('form-dirty');
|
||||
this.form.classList.remove('form-pristine');
|
||||
} else {
|
||||
this.form.classList.add('form-pristine');
|
||||
this.form.classList.remove('form-dirty');
|
||||
}
|
||||
|
||||
if (this.hasTouchedFields()) {
|
||||
this.form.classList.add('form-touched');
|
||||
} else {
|
||||
this.form.classList.remove('form-touched');
|
||||
}
|
||||
}
|
||||
|
||||
// State check methods
|
||||
isPristine() {
|
||||
return this.dirtyFields.size === 0;
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
return this.dirtyFields.size > 0;
|
||||
}
|
||||
|
||||
hasTouchedFields() {
|
||||
return this.touchedFields.size > 0;
|
||||
}
|
||||
|
||||
isFieldTouched(fieldName) {
|
||||
return this.touchedFields.has(fieldName);
|
||||
}
|
||||
|
||||
isFieldDirty(fieldName) {
|
||||
return this.dirtyFields.has(fieldName);
|
||||
}
|
||||
|
||||
isFieldPristine(fieldName) {
|
||||
return !this.dirtyFields.has(fieldName);
|
||||
}
|
||||
|
||||
// Get field states
|
||||
getFieldState(fieldName) {
|
||||
return {
|
||||
pristine: this.isFieldPristine(fieldName),
|
||||
dirty: this.isFieldDirty(fieldName),
|
||||
touched: this.isFieldTouched(fieldName),
|
||||
pristineValue: this.pristineValues.get(fieldName),
|
||||
currentValue: this.getCurrentFieldValue(fieldName)
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentFieldValue(fieldName) {
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
return field ? this.getFieldValue(field) : undefined;
|
||||
}
|
||||
|
||||
// Form-level state
|
||||
getFormState() {
|
||||
return {
|
||||
pristine: this.isPristine(),
|
||||
dirty: this.isDirty(),
|
||||
touched: this.hasTouchedFields(),
|
||||
dirtyFields: Array.from(this.dirtyFields),
|
||||
touchedFields: Array.from(this.touchedFields),
|
||||
totalFields: this.pristineValues.size
|
||||
};
|
||||
}
|
||||
|
||||
// Reset methods
|
||||
reset() {
|
||||
this.touchedFields.clear();
|
||||
this.dirtyFields.clear();
|
||||
|
||||
// Re-capture values after form reset
|
||||
setTimeout(() => {
|
||||
this.captureInitialValues();
|
||||
this.updateFormClasses();
|
||||
}, 0);
|
||||
|
||||
Logger.info('[FormState] State reset');
|
||||
}
|
||||
|
||||
resetField(fieldName) {
|
||||
this.touchedFields.delete(fieldName);
|
||||
this.dirtyFields.delete(fieldName);
|
||||
|
||||
// Reset field to pristine value
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
const pristineValue = this.pristineValues.get(fieldName);
|
||||
|
||||
if (field && pristineValue !== undefined) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
field.checked = pristineValue;
|
||||
break;
|
||||
default:
|
||||
field.value = pristineValue;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateFormClasses();
|
||||
|
||||
Logger.info(`[FormState] Field "${fieldName}" reset to pristine state`);
|
||||
}
|
||||
|
||||
// Event handling for external state changes
|
||||
triggerStateEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: {
|
||||
...detail,
|
||||
formState: this.getFormState()
|
||||
},
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
hasChanges() {
|
||||
return this.isDirty();
|
||||
}
|
||||
|
||||
getChangedFields() {
|
||||
const changes = {};
|
||||
|
||||
this.dirtyFields.forEach(fieldName => {
|
||||
changes[fieldName] = {
|
||||
pristineValue: this.pristineValues.get(fieldName),
|
||||
currentValue: this.getCurrentFieldValue(fieldName)
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// Warning for unsaved changes
|
||||
enableUnsavedChangesWarning() {
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (this.isDirty()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.pristineValues.clear();
|
||||
this.touchedFields.clear();
|
||||
this.dirtyFields.clear();
|
||||
|
||||
Logger.info('[FormState] Destroyed');
|
||||
}
|
||||
}
|
||||
190
resources/js/modules/form-handling/FormValidator.js
Normal file
190
resources/js/modules/form-handling/FormValidator.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export class FormValidator {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.errors = new Map();
|
||||
}
|
||||
|
||||
static create(form) {
|
||||
return new FormValidator(form);
|
||||
}
|
||||
|
||||
validate() {
|
||||
this.errors.clear();
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.type === 'hidden' || field.disabled) continue;
|
||||
|
||||
this.validateField(field);
|
||||
}
|
||||
|
||||
return this.errors.size === 0;
|
||||
}
|
||||
|
||||
validateField(field) {
|
||||
const value = field.value;
|
||||
const fieldName = field.name;
|
||||
|
||||
// HTML5 required attribute
|
||||
if (field.hasAttribute('required') && (!value || value.trim() === '')) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'valueMissing') || `${this.getFieldLabel(field)} ist erforderlich`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip further validation if field is empty and not required
|
||||
if (!value || value.trim() === '') return;
|
||||
|
||||
// HTML5 type validation
|
||||
if (field.type === 'email' && !this.isValidEmail(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'typeMismatch') || 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'url' && !this.isValidUrl(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'typeMismatch') || 'Bitte geben Sie eine gültige URL ein');
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 minlength attribute
|
||||
const minLength = field.getAttribute('minlength');
|
||||
if (minLength && value.length < parseInt(minLength)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'tooShort') || `Mindestens ${minLength} Zeichen erforderlich`);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 maxlength attribute (usually enforced by browser, but for safety)
|
||||
const maxLength = field.getAttribute('maxlength');
|
||||
if (maxLength && value.length > parseInt(maxLength)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'tooLong') || `Maximal ${maxLength} Zeichen erlaubt`);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 min/max for number inputs
|
||||
if (field.type === 'number') {
|
||||
const min = field.getAttribute('min');
|
||||
const max = field.getAttribute('max');
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
if (min && numValue < parseFloat(min)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'rangeUnderflow') || `Wert muss mindestens ${min} sein`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (max && numValue > parseFloat(max)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'rangeOverflow') || `Wert darf maximal ${max} sein`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML5 pattern attribute
|
||||
const pattern = field.getAttribute('pattern');
|
||||
if (pattern) {
|
||||
const regex = new RegExp(pattern);
|
||||
if (!regex.test(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'patternMismatch') || 'Ungültiges Format');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation via data-validate attribute
|
||||
const customValidation = field.getAttribute('data-validate');
|
||||
if (customValidation) {
|
||||
const result = this.runCustomValidation(customValidation, value, field);
|
||||
if (!result.valid) {
|
||||
this.errors.set(fieldName, result.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCustomValidation(validationType, value, field) {
|
||||
switch (validationType) {
|
||||
case 'phone':
|
||||
const phoneRegex = /^[\+]?[0-9\s\-\(\)]{10,}$/;
|
||||
return {
|
||||
valid: phoneRegex.test(value),
|
||||
message: 'Bitte geben Sie eine gültige Telefonnummer ein'
|
||||
};
|
||||
|
||||
case 'postal-code-de':
|
||||
const postalRegex = /^[0-9]{5}$/;
|
||||
return {
|
||||
valid: postalRegex.test(value),
|
||||
message: 'Bitte geben Sie eine gültige Postleitzahl ein'
|
||||
};
|
||||
|
||||
case 'no-html':
|
||||
const hasHtml = /<[^>]*>/g.test(value);
|
||||
return {
|
||||
valid: !hasHtml,
|
||||
message: 'HTML-Code ist nicht erlaubt'
|
||||
};
|
||||
|
||||
default:
|
||||
Logger.warn(`Unknown custom validation: ${validationType}`);
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
}
|
||||
|
||||
isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
isValidUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getFieldLabel(field) {
|
||||
const label = this.form.querySelector(`label[for="${field.id}"]`) ||
|
||||
this.form.querySelector(`label:has([name="${field.name}"])`);
|
||||
|
||||
return label ? label.textContent.trim().replace(':', '') : field.name;
|
||||
}
|
||||
|
||||
getErrorMessage(field, validityType) {
|
||||
// Check for custom error messages via data-error-* attributes
|
||||
const customMessage = field.getAttribute(`data-error-${validityType}`) ||
|
||||
field.getAttribute('data-error');
|
||||
|
||||
return customMessage;
|
||||
}
|
||||
|
||||
getErrors() {
|
||||
return Object.fromEntries(this.errors);
|
||||
}
|
||||
|
||||
getFieldError(fieldName) {
|
||||
return this.errors.get(fieldName);
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
return this.errors.size > 0;
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.errors.clear();
|
||||
}
|
||||
|
||||
// Check HTML5 validity API as fallback
|
||||
validateWithHTML5() {
|
||||
const isValid = this.form.checkValidity();
|
||||
|
||||
if (!isValid) {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
for (const field of fields) {
|
||||
if (!field.validity.valid) {
|
||||
this.errors.set(field.name, field.validationMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
125
resources/js/modules/form-handling/index.js
Normal file
125
resources/js/modules/form-handling/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { FormHandler } from './FormHandler.js';
|
||||
import { FormValidator } from './FormValidator.js';
|
||||
import { FormState } from './FormState.js';
|
||||
|
||||
/**
|
||||
* Form Handling Module
|
||||
*
|
||||
* Provides comprehensive form handling with:
|
||||
* - HTML5-based validation (reads validation rules from HTML attributes)
|
||||
* - AJAX form submission with error handling
|
||||
* - Form state management (pristine, dirty, touched)
|
||||
* - Progressive enhancement
|
||||
*
|
||||
* Usage:
|
||||
* - Add data-module="form-handling" to any form element
|
||||
* - Configure via data-options attribute:
|
||||
* data-options='{"validateOnBlur": true, "ajaxSubmit": true}'
|
||||
*
|
||||
* HTML Validation Attributes Supported:
|
||||
* - required: Field is required
|
||||
* - pattern: Custom regex pattern
|
||||
* - minlength/maxlength: String length limits
|
||||
* - min/max: Number range limits
|
||||
* - type="email": Email validation
|
||||
* - type="url": URL validation
|
||||
* - data-validate: Custom validation (phone, postal-code-de, no-html)
|
||||
* - data-error-*: Custom error messages
|
||||
*/
|
||||
|
||||
const FormHandlingModule = {
|
||||
name: 'form-handling',
|
||||
|
||||
// Module-level init (called by module system)
|
||||
init(config = {}, state = null) {
|
||||
Logger.info('[FormHandling] Module initialized (ready for DOM elements)');
|
||||
// Module is now ready - no DOM operations here
|
||||
return this;
|
||||
},
|
||||
|
||||
// Element-specific init (called for individual form elements)
|
||||
initElement(element, options = {}) {
|
||||
Logger.info(`[FormHandling] Initializing on form: ${element.id || 'unnamed'}`);
|
||||
|
||||
// Default configuration
|
||||
const config = {
|
||||
validateOnSubmit: true,
|
||||
validateOnBlur: false,
|
||||
validateOnInput: false,
|
||||
showInlineErrors: true,
|
||||
preventSubmitOnError: true,
|
||||
ajaxSubmit: true,
|
||||
submitMethod: 'POST',
|
||||
enableStateTracking: true,
|
||||
enableUnsavedWarning: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize form handler
|
||||
const formHandler = FormHandler.create(element, config);
|
||||
|
||||
// Store reference for later access
|
||||
element._formHandler = formHandler;
|
||||
element._formValidator = formHandler.validator;
|
||||
element._formState = formHandler.state;
|
||||
|
||||
// Enable unsaved changes warning if configured
|
||||
if (config.enableUnsavedWarning) {
|
||||
formHandler.state.enableUnsavedChangesWarning();
|
||||
}
|
||||
|
||||
// Add module-specific CSS classes
|
||||
element.classList.add('form-enhanced');
|
||||
|
||||
// Trigger initialization event
|
||||
const event = new CustomEvent('form:initialized', {
|
||||
detail: {
|
||||
handler: formHandler,
|
||||
validator: formHandler.validator,
|
||||
state: formHandler.state,
|
||||
config: config
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
element.dispatchEvent(event);
|
||||
|
||||
Logger.info(`[FormHandling] Successfully initialized for form: ${element.id || 'unnamed'}`);
|
||||
|
||||
return formHandler;
|
||||
},
|
||||
|
||||
// Element-specific destroy (for form elements)
|
||||
destroyElement(element) {
|
||||
if (element._formHandler) {
|
||||
element._formHandler.destroy();
|
||||
delete element._formHandler;
|
||||
delete element._formValidator;
|
||||
delete element._formState;
|
||||
}
|
||||
|
||||
element.classList.remove('form-enhanced');
|
||||
element.removeAttribute('data-enhanced');
|
||||
|
||||
Logger.info(`[FormHandling] Destroyed for form: ${element.id || 'unnamed'}`);
|
||||
},
|
||||
|
||||
// Module-level destroy
|
||||
destroy() {
|
||||
Logger.info('[FormHandling] Module destroyed');
|
||||
}
|
||||
};
|
||||
|
||||
// Export individual classes for direct usage
|
||||
export { FormHandler, FormValidator, FormState };
|
||||
|
||||
// Export as default for module system
|
||||
export default FormHandlingModule;
|
||||
|
||||
// Export init function directly for compatibility with module system
|
||||
export const init = FormHandlingModule.init.bind(FormHandlingModule);
|
||||
export const initElement = FormHandlingModule.initElement.bind(FormHandlingModule);
|
||||
|
||||
// Also export named for direct usage
|
||||
export { FormHandlingModule };
|
||||
Reference in New Issue
Block a user