wip: improved validation and feedback
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Traits;
|
||||
|
||||
/**
|
||||
* Provides participant validation error extraction for card-based booking flows.
|
||||
*
|
||||
* Shared between CreateStep2Controller and EditController to identify which
|
||||
* participants have validation errors that should be displayed on their cards.
|
||||
*/
|
||||
trait ParticipantValidationTrait
|
||||
{
|
||||
/**
|
||||
* Extracts participant validation errors from form.
|
||||
*
|
||||
* Returns two arrays:
|
||||
* - errorIndices: Array of participant indices with errors (e.g., [0, 2, 5])
|
||||
* - errorMessages: Map of participant index to error messages (e.g., [0 => ['E-Mail: ...', 'Vorname: ...']])
|
||||
*
|
||||
* @return array{errorIndices: array<int>, errorMessages: array<int, array<string>>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -5,42 +5,25 @@
|
||||
{% set mode = mode|default('create') %}
|
||||
|
||||
<div id="participant-card-{{ index }}"
|
||||
class="border rounded p-4
|
||||
{{ isCanceled ? 'border-gray-400 bg-gray-50' : (hasErrors ? 'border-red-500 bg-red-50' : '') }}">
|
||||
class="{{ html_classes('border rounded p-4', { 'border-gray-400 bg-gray-50': isCanceled, 'border-red-700': not isValid }) }}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold {{ hasErrors ? 'text-red-800' : (isCanceled ? 'text-gray-600' : '') }}" {{ qa_attribute('participant-name', index) }}>
|
||||
<h3 class="font-semibold {{ isCanceled ? 'text-gray-600' : '' }}" {{ qa_attribute('participant-name', index) }}>
|
||||
{{ cardData.name }}
|
||||
</h3>
|
||||
{% if isCanceled %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
|
||||
storniert
|
||||
</span>
|
||||
{% elseif hasErrors %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800" {{ qa_attribute('participant-incomplete', index) }}>
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Unvollständig
|
||||
{% endif %}
|
||||
{% if not isValid %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-700 text-white" {{ qa_attribute('participant-invalid', index) }}>
|
||||
unvollständige oder fehlerhafte Daten
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
|
||||
|
||||
{# Display specific error messages #}
|
||||
{% if hasErrors and errorMessages|length > 0 %}
|
||||
<div class="mt-2 space-y-1" {{ qa_attribute('participant-errors', index) }}>
|
||||
{% for errorMessage in errorMessages %}
|
||||
<p class="text-sm text-red-700">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
|
||||
@@ -54,7 +37,7 @@
|
||||
{% else %}
|
||||
{% if mode == 'edit' %}
|
||||
<button type="button"
|
||||
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
|
||||
class="button bg-button bg-button--secondary"
|
||||
hx-get="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}"
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML"
|
||||
@@ -63,7 +46,7 @@
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
|
||||
class="button bg-button bg-button--secondary"
|
||||
hx-get="{{ path('app_booking_create_step_2_participant', {index: index}) }}"
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@@ -35,14 +35,10 @@
|
||||
|
||||
<div id="participant-cards-grid" class="space-y-4">
|
||||
{% for cardData in cardsData %}
|
||||
{% set hasErrors = loop.index0 in participantErrors|default([]) %}
|
||||
{% set errorMessages = participantErrorMessages[loop.index0]|default([]) %}
|
||||
{% include 'booking/_participant_card.html.twig' with {
|
||||
'cardData': cardData,
|
||||
'index': loop.index0,
|
||||
'mode': 'create',
|
||||
'hasErrors': hasErrors,
|
||||
'errorMessages': errorMessages
|
||||
'mode': 'create'
|
||||
} %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -60,15 +60,11 @@
|
||||
<div id="participant-cards-grid" class="space-y-4">
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user