From 60176d19f432e23c46ba5315c5dc7795a88c7daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 23 Oct 2025 08:50:45 +0200 Subject: [PATCH] wip: improved validation and feedback --- .../Booking/Create/Step2Controller.php | 17 +--- .../Booking/Edit/IndexController.php | 15 +-- .../Traits/ParticipantValidationTrait.php | 98 ------------------- src/Form/Model/BookingDto.php | 2 +- src/Form/Model/ParticipantDto.php | 37 ++++--- templates/booking/_participant_card.html.twig | 33 ++----- templates/booking/create/step_2.html.twig | 6 +- templates/booking/edit/index.html.twig | 4 - 8 files changed, 36 insertions(+), 176 deletions(-) delete mode 100644 src/Controller/Booking/Traits/ParticipantValidationTrait.php diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index ee5db7c..e43d262 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -7,7 +7,6 @@ namespace App\Controller\Booking\Create; use App\Controller\Booking\Traits\BookingCreateTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Controller\Booking\Traits\ParticipantCardFlowTrait; -use App\Controller\Booking\Traits\ParticipantValidationTrait; use App\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -35,7 +34,6 @@ class Step2Controller extends AbstractController use BookingExceptionHandlerTrait; use HxTrait; use ParticipantCardFlowTrait; - use ParticipantValidationTrait; public function __construct( private readonly BookingService $bookingService, @@ -82,7 +80,7 @@ class Step2Controller extends AbstractController // Create validation form $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'validation_groups' => ['booking_create_step_2'], + 'validation_groups' => ['booking_create'], ]); $form->handleRequest($request); @@ -96,15 +94,6 @@ class Step2Controller extends AbstractController return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); } - // Extract participant validation errors - $participantErrorIndices = []; - $participantErrorMessages = []; - if (true === $form->isSubmitted() && false === $form->isValid()) { - $extractedErrors = $this->extractParticipantValidationErrors($form); - $participantErrorIndices = $extractedErrors['errorIndices']; - $participantErrorMessages = $extractedErrors['errorMessages']; - } - // Generate cards data $cardsData = $this->generateAllCardsData($bookingCreateDto); @@ -120,8 +109,6 @@ class Step2Controller extends AbstractController 'cardsData' => $cardsData, 'summaryData' => $summaryData, 'pricingData' => $summary['pricing'], - 'participantErrors' => $participantErrorIndices, - 'participantErrorMessages' => $participantErrorMessages, ]; // HTMX request: render blocks only @@ -159,7 +146,7 @@ class Step2Controller extends AbstractController // Create form with booking_context option $form = $this->createParticipantForm($bookingDto, $index, [ - 'validation_groups' => ['booking_create_step_2'], + 'validation_groups' => ['booking_create'], ]); $form->handleRequest($request); diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 3749439..5fd0d73 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -11,7 +11,6 @@ use App\BusProNet\Model\Notification; use App\Controller\Booking\Traits; use App\Controller\Booking\Traits\BookingDataTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; -use App\Controller\Booking\Traits\ParticipantValidationTrait; use App\Entity\User; use App\Form\BookingEditType; use App\Form\BookingParticipantType; @@ -49,7 +48,6 @@ class IndexController extends AbstractController use BookingExceptionHandlerTrait; use HxTrait; use Traits\ParticipantCardFlowTrait; - use ParticipantValidationTrait; public function __construct( private readonly ApiClient $apiClient, @@ -156,15 +154,6 @@ class IndexController extends AbstractController return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } - // Extract participant validation errors - $participantErrors = []; - $participantErrorMessages = []; - if ($form->isSubmitted() && false === $form->isValid()) { - $extractedErrors = $this->extractParticipantValidationErrors($form); - $participantErrors = $extractedErrors['errorIndices']; - $participantErrorMessages = $extractedErrors['errorMessages']; - } - // Generate card data for all participants $cardsData = $this->participantCardService->getAllCardsData($bookingDto); @@ -190,9 +179,7 @@ class IndexController extends AbstractController 'groupedSelectedRooms' => $groupedSelectedRooms, 'assignmentCounts' => $roomAssignmentCounts, 'isDirty' => $this->fingerprintService->isDirty($bookingDto), - 'hasValidationErrors' => count($participantErrors) > 0, - 'participantErrors' => $participantErrors, - 'participantErrorMessages' => $participantErrorMessages, + 'hasValidationErrors' => $form->isSubmitted() && false === $form->isValid(), ]; // If HTMX request, render only blocks to avoid layout duplication diff --git a/src/Controller/Booking/Traits/ParticipantValidationTrait.php b/src/Controller/Booking/Traits/ParticipantValidationTrait.php deleted file mode 100644 index 09a2d7f..0000000 --- a/src/Controller/Booking/Traits/ParticipantValidationTrait.php +++ /dev/null @@ -1,98 +0,0 @@ - ['E-Mail: ...', 'Vorname: ...']]) - * - * @return array{errorIndices: array, errorMessages: array>} - */ - private function extractParticipantValidationErrors($form): array - { - $errorIndices = []; - $errorMessages = []; - $errors = $form->getErrors(true); // Get all errors recursively - - foreach ($errors as $error) { - $propertyPath = $error->getCause()?->getPropertyPath(); - if (null === $propertyPath) { - continue; - } - - // Property paths from BookingDto validation look like "participants[0].email" - // Note: Using more flexible regex to capture field path after index - if (preg_match('/participants\[(\d+)\]\.?(.*)/', (string) $propertyPath, $matches)) { - $index = (int) $matches[1]; - $fieldPath = $matches[2] ?? ''; - $message = $error->getMessage(); - - // Mark this participant as having errors - $errorIndices[$index] = true; - - // Initialize error messages array for this participant if needed - if (false === isset($errorMessages[$index])) { - $errorMessages[$index] = []; - } - - // Add formatted error message - $errorMessages[$index][] = $this->formatErrorMessage($fieldPath, $message); - } - } - - return [ - 'errorIndices' => array_keys($errorIndices), - 'errorMessages' => $errorMessages, - ]; - } - - /** - * Formats an error message with field context. - * - * @param string $fieldPath The field path that has the error (may be empty or nested) - * @param string $message The error message - * - * @return string Formatted error message - */ - private function formatErrorMessage(string $fieldPath, string $message): string - { - // If no field path, return just the message (error is on participant level) - if ('' === trim($fieldPath)) { - return $message; - } - - // Extract the first part of the path for nested fields (e.g., "address.street" -> "address") - $fieldName = explode('.', $fieldPath)[0]; - - // Field name translations for better user understanding - $fieldLabels = [ - 'email' => 'E-Mail', - 'firstName' => 'Vorname', - 'lastName' => 'Nachname', - 'dateOfBirth' => 'Geburtsdatum', - 'assignedRoomId' => 'Zimmer', - 'skiPass' => 'Skipass', - 'transportationOutbound' => 'Anreise', - 'transportationInbound' => 'Rückreise', - 'mobile' => 'Mobilnummer', - 'address' => 'Adresse', - ]; - - $fieldLabel = $fieldLabels[$fieldName] ?? $fieldName; - - return sprintf('%s: %s', $fieldLabel, $message); - } -} diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index ceba0d8..a380726 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -196,7 +196,7 @@ class BookingDto } } - #[Assert\Callback(groups: ['booking_create_step_2', 'booking_edit'])] + #[Assert\Callback(groups: ['booking_create', 'booking_edit'])] public function validateEmailUniqueness(ExecutionContextInterface $context): void { // Build map of email addresses to participant indices (adults only) diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 59c0b3a..f99a508 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -10,8 +10,8 @@ use App\BusProNet\Model\Service; use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; -#[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])] -#[AppAssert\ApplicantAddress(groups: ['booking_create_step_2'])] +#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])] +#[AppAssert\ApplicantAddress(groups: ['booking_create'])] class ParticipantDto { /** @@ -41,10 +41,10 @@ class ParticipantDto public ?string $status = null; public bool $mutable = false; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?string $firstName = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?string $lastName = null; public ?string $title = null; public ?string $gender = null; @@ -54,20 +54,20 @@ class ParticipantDto public ?string $shoeSize = null; public ?string $weight = null; - #[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] public ?\DateTimeImmutable $dateOfBirth = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] - #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])] + #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])] public ?string $email = null; public ?string $mobile = null; - #[Assert\Valid(groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\Valid(groups: ['booking_edit', 'booking_create'])] #[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])] public ?Address $address = null; - #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])] + #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create'])] public ?int $assignedRoomId = null; public ?string $remarksRoom = null; @@ -75,7 +75,7 @@ class ParticipantDto public array $courses = []; public array $additionalServices = []; - #[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create'])] public ?Service $skiPass = null; public array $board = []; @@ -214,12 +214,21 @@ class ParticipantDto /** * Adds a notification message for user feedback. * - * @param string $type The notification type (info, warning, success) - * @param string $message The notification message + * Uses an optional ID to prevent duplicate notifications. If no ID is provided, + * generates one from the type and message combination. Duplicate IDs will + * overwrite previous notifications, ensuring each unique notification appears once. + * + * @param string $type The notification type (info, warning, success) + * @param string $message The notification message + * @param string|null $id Optional unique identifier (auto-generated if null) */ - public function addNotification(string $type, string $message): void + public function addNotification(string $type, string $message, ?string $id = null): void { - $this->notifications[] = [ + // Generate ID from type and message if not provided + $notificationId = $id ?? md5($type.'_'.$message); + + // Use ID as key to automatically prevent duplicates + $this->notifications[$notificationId] = [ 'type' => $type, 'message' => $message, ]; diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig index 3118daa..bc4c844 100644 --- a/templates/booking/_participant_card.html.twig +++ b/templates/booking/_participant_card.html.twig @@ -5,42 +5,25 @@ {% set mode = mode|default('create') %}
+ class="{{ html_classes('border rounded p-4', { 'border-gray-400 bg-gray-50': isCanceled, 'border-red-700': not isValid }) }}">
-

+

{{ cardData.name }}

{% if isCanceled %} storniert - {% elseif hasErrors %} - - - - - Unvollständig + {% endif %} + {% if not isValid %} + + unvollständige oder fehlerhafte Daten {% endif %}

{{ cardData.roomName }}

- - {# Display specific error messages #} - {% if hasErrors and errorMessages|length > 0 %} -
- {% for errorMessage in errorMessages %} -

- - - - {{ errorMessage }} -

- {% endfor %} -
- {% endif %}
{{ cardData.price }} @@ -54,7 +37,7 @@ {% else %} {% if mode == 'edit' %}
diff --git a/templates/booking/edit/index.html.twig b/templates/booking/edit/index.html.twig index c34f443..a2a83e2 100644 --- a/templates/booking/edit/index.html.twig +++ b/templates/booking/edit/index.html.twig @@ -60,15 +60,11 @@
{% for participant in bookingDto.participants %} {% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %} - {% set hasErrors = loop.index0 in participantErrors|default([]) %} - {% set errorMessages = participantErrorMessages[loop.index0]|default([]) %} {% include 'booking/_participant_card.html.twig' with { 'cardData': cardsData[loop.index0], 'index': loop.index0, 'mode': 'edit', 'isCanceled': isCanceled, - 'hasErrors': hasErrors, - 'errorMessages': errorMessages, 'bookingId': bookingData.id } %} {% endfor %}