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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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');
}
}

View 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');
}
}

View 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;
}
}

View 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 };