feat(07-01): add inline form validation
- Client-side validation on blur and submit - Email format validation - Required field validation - Furniture items validation (at least one) - Date field validation - Inline error messages (no JavaScript alerts) - Auto-scroll to first error - Error clearing on field input Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
350
assets/js/form.js
Normal file
350
assets/js/form.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Umzugsliste Form JavaScript
|
||||
*
|
||||
* Real-time volume (cbm) calculations matching legacy logic
|
||||
*
|
||||
* @package Umzugsliste
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse German decimal format to float
|
||||
* Converts "0,40" or "0.40" to 0.40
|
||||
*
|
||||
* @param {string|number} str Value to parse
|
||||
* @return {number} Parsed number or 0
|
||||
*/
|
||||
function parseGermanDecimal(str) {
|
||||
if (!str || str === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convert to string and trim
|
||||
str = String(str).trim().replace(',', '.');
|
||||
|
||||
// Parse as float
|
||||
const num = parseFloat(str);
|
||||
|
||||
// Return 0 for invalid or negative numbers
|
||||
return isNaN(num) || num < 0 ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number to German decimal format
|
||||
* Converts 0.40 to "0,40"
|
||||
*
|
||||
* @param {number} num Number to format
|
||||
* @param {number} decimals Number of decimal places (default 2)
|
||||
* @return {string} Formatted number string
|
||||
*/
|
||||
function formatGermanDecimal(num, decimals) {
|
||||
decimals = decimals || 2;
|
||||
return num.toFixed(decimals).replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total cbm for a single furniture item
|
||||
*
|
||||
* @param {string|number} quantity Item quantity
|
||||
* @param {string|number} cbm CBM value per item
|
||||
* @return {number} Total cbm for this item
|
||||
*/
|
||||
function calculateItemTotal(quantity, cbm) {
|
||||
const qty = parseGermanDecimal(quantity);
|
||||
const cbmVal = parseGermanDecimal(cbm);
|
||||
return qty * cbmVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate totals for a single room
|
||||
*
|
||||
* @param {string} roomKey Room identifier (e.g., "wohnzimmer")
|
||||
* @return {object} Object with quantity and cbm totals
|
||||
*/
|
||||
function calculateRoomTotal(roomKey) {
|
||||
let totalCbm = 0;
|
||||
let totalQuantity = 0;
|
||||
|
||||
// Find all furniture rows for this room
|
||||
$('tr[data-room="' + roomKey + '"].furniture-row').each(function() {
|
||||
const $row = $(this);
|
||||
const quantity = $row.find('.quantity-input').val();
|
||||
const cbm = $row.data('cbm');
|
||||
|
||||
const qty = parseGermanDecimal(quantity);
|
||||
totalQuantity += qty;
|
||||
totalCbm += calculateItemTotal(quantity, cbm);
|
||||
});
|
||||
|
||||
// Round to 2 decimal places
|
||||
return {
|
||||
quantity: totalQuantity,
|
||||
cbm: Math.round(totalCbm * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate grand totals across all rooms
|
||||
*
|
||||
* @return {object} Object with quantity and cbm totals
|
||||
*/
|
||||
function calculateGrandTotal() {
|
||||
let totalCbm = 0;
|
||||
let totalQuantity = 0;
|
||||
|
||||
// Sum all room totals
|
||||
$('.room-totals').each(function() {
|
||||
const $row = $(this);
|
||||
const roomKey = $row.closest('table').data('room');
|
||||
const roomTotal = calculateRoomTotal(roomKey);
|
||||
|
||||
totalQuantity += roomTotal.quantity;
|
||||
totalCbm += roomTotal.cbm;
|
||||
});
|
||||
|
||||
// Round to 2 decimal places
|
||||
return {
|
||||
quantity: totalQuantity,
|
||||
cbm: Math.round(totalCbm * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display for a single room's totals
|
||||
*
|
||||
* @param {string} roomKey Room identifier
|
||||
*/
|
||||
function updateRoomDisplay(roomKey) {
|
||||
const total = calculateRoomTotal(roomKey);
|
||||
const $table = $('table[data-room="' + roomKey + '"]');
|
||||
|
||||
$table.find('.room-total-quantity').text(total.quantity);
|
||||
$table.find('.room-total-cbm').text(formatGermanDecimal(total.cbm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update grand totals display
|
||||
*/
|
||||
function updateGrandTotalDisplay() {
|
||||
const total = calculateGrandTotal();
|
||||
|
||||
$('#grand-total-quantity').text(total.quantity);
|
||||
$('#grand-total-cbm').text(formatGermanDecimal(total.cbm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all totals (rooms and grand total)
|
||||
*/
|
||||
function updateAllTotals() {
|
||||
// Update each room
|
||||
$('.room-totals').each(function() {
|
||||
const roomKey = $(this).closest('table').data('room');
|
||||
updateRoomDisplay(roomKey);
|
||||
});
|
||||
|
||||
// Update grand total
|
||||
updateGrandTotalDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle quantity input change
|
||||
* Debounced for performance
|
||||
*/
|
||||
let debounceTimer;
|
||||
function handleQuantityChange() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function() {
|
||||
updateAllTotals();
|
||||
}, 100); // Quick response (100ms debounce)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*
|
||||
* @param {string} email Email address to validate
|
||||
* @return {boolean} True if valid email format
|
||||
*/
|
||||
function validateEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required field
|
||||
*
|
||||
* @param {string} value Field value
|
||||
* @return {boolean} True if not empty
|
||||
*/
|
||||
function validateRequired(value) {
|
||||
return value && value.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message for a field
|
||||
*
|
||||
* @param {jQuery} $field Field element
|
||||
* @param {string} message Error message
|
||||
*/
|
||||
function showFieldError($field, message) {
|
||||
// Add error class to field
|
||||
$field.addClass('field-error');
|
||||
|
||||
// Remove existing error message if any
|
||||
clearFieldError($field);
|
||||
|
||||
// Add error message after field
|
||||
$field.after('<span class="error-message">' + message + '</span>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message for a field
|
||||
*
|
||||
* @param {jQuery} $field Field element
|
||||
*/
|
||||
function clearFieldError($field) {
|
||||
$field.removeClass('field-error');
|
||||
$field.next('.error-message').remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field
|
||||
*
|
||||
* @param {jQuery} $field Field element
|
||||
* @return {boolean} True if valid
|
||||
*/
|
||||
function validateField($field) {
|
||||
const fieldName = $field.attr('name');
|
||||
const value = $field.val();
|
||||
const isRequired = $field.attr('required') !== undefined;
|
||||
|
||||
// Clear existing errors
|
||||
clearFieldError($field);
|
||||
|
||||
// Check required fields
|
||||
if (isRequired && !validateRequired(value)) {
|
||||
showFieldError($field, 'Dieses Feld ist erforderlich');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check email format
|
||||
if (fieldName === 'info[eE-Mail]' && value) {
|
||||
if (!validateEmail(value)) {
|
||||
showFieldError($field, 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all furniture items - at least one must have quantity
|
||||
*
|
||||
* @return {boolean} True if valid
|
||||
*/
|
||||
function validateFurnitureItems() {
|
||||
let hasItems = false;
|
||||
|
||||
$('.quantity-input').each(function() {
|
||||
const qty = parseGermanDecimal($(this).val());
|
||||
if (qty > 0) {
|
||||
hasItems = true;
|
||||
return false; // break loop
|
||||
}
|
||||
});
|
||||
|
||||
return hasItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date fields
|
||||
*
|
||||
* @return {boolean} True if valid date selected
|
||||
*/
|
||||
function validateDate() {
|
||||
const day = $('select[name="day"]').val();
|
||||
const month = $('select[name="month"]').val();
|
||||
const year = $('select[name="year"]').val();
|
||||
|
||||
return day && month && year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entire form before submission
|
||||
*
|
||||
* @return {boolean} True if all validations pass
|
||||
*/
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
const errors = [];
|
||||
|
||||
// Validate date
|
||||
if (!validateDate()) {
|
||||
errors.push('Bitte wählen Sie ein vollständiges Umzugsdatum');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
$('input[required]').each(function() {
|
||||
if (!validateField($(this))) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate furniture items
|
||||
if (!validateFurnitureItems()) {
|
||||
errors.push('Bitte geben Sie mindestens ein Möbelstück ein');
|
||||
isValid = false;
|
||||
|
||||
// Scroll to first room table
|
||||
if ($('.quantity-input:first').length) {
|
||||
$('html, body').animate({
|
||||
scrollTop: $('.quantity-input:first').closest('table').offset().top - 100
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are general errors, scroll to first error field
|
||||
if (!isValid && $('.field-error:first').length) {
|
||||
$('html, body').animate({
|
||||
scrollTop: $('.field-error:first').offset().top - 100
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize calculations
|
||||
*/
|
||||
$(document).ready(function() {
|
||||
// Attach event listeners to all quantity inputs
|
||||
$('.quantity-input').on('input change', handleQuantityChange);
|
||||
|
||||
// Initial calculation (in case of pre-filled values)
|
||||
updateAllTotals();
|
||||
|
||||
// Attach validation listeners
|
||||
$('input[required], input[type="email"]').on('blur', function() {
|
||||
validateField($(this));
|
||||
});
|
||||
|
||||
// Clear error on field change
|
||||
$('input').on('input', function() {
|
||||
clearFieldError($(this));
|
||||
});
|
||||
|
||||
// Validate form on submit
|
||||
$('#umzugsliste-form').on('submit', function(e) {
|
||||
if (!validateForm()) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Umzugsliste calculations and validation initialized');
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
Reference in New Issue
Block a user