fix: validation error indication on cards

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent 89979d6ce4
commit 03ac430be4
6 changed files with 208 additions and 17 deletions
@@ -94,8 +94,10 @@ class Step2Controller extends AbstractController
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3'));
}
// Generate cards data
$cardsData = $this->generateAllCardsData($bookingCreateDto);
// Generate cards data with validation state if form was submitted and failed
$cardsData = (true === $form->isSubmitted() && false === $form->isValid())
? $this->participantCardService->getAllCardsDataWithValidation($bookingCreateDto, ['booking_create'])
: $this->generateAllCardsData($bookingCreateDto);
// Calculate summary data
$summaryData = $this->calculateSummaryData($bookingCreateDto);
+64 -2
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantEditDto;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Extracts card display data for participants in card-based booking flows.
@@ -16,13 +18,14 @@ class ParticipantCardDataService
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ValidatorInterface $validator,
) {
}
/**
* Get card data for a single participant.
*
* @return array{name: string, roomName: string, price: string}
* @return array{name: string, roomName: string, price: string, isCanceled: bool}
*/
public function getCardData(BookingDto $bookingDto, int $index): array
{
@@ -41,17 +44,21 @@ class ParticipantCardDataService
// Calculate and format individual price
$price = $this->getFormattedPrice($bookingDto, $index);
// Check if participant is canceled
$isCanceled = $participant->isCanceled();
return [
'name' => $name,
'roomName' => $roomName,
'price' => $price,
'isCanceled' => $isCanceled,
];
}
/**
* Get card data for all participants.
*
* @return array<int, array{name: string, roomName: string, price: string}>
* @return array<int, array{name: string, roomName: string, price: string, isCanceled: bool}>
*/
public function getAllCardsData(BookingDto $bookingDto): array
{
@@ -121,4 +128,59 @@ class ParticipantCardDataService
return number_format($price, 2, ',', '.').' €';
}
/**
* Get card data for a single participant with validation state.
*
* @return array{name: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}
*/
public function getCardDataWithValidation(BookingDto $bookingDto, int $index, array $validationGroups): array
{
$participant = $bookingDto->participants[$index] ?? null;
if (null === $participant) {
throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index));
}
// Get basic card data
$cardData = $this->getCardData($bookingDto, $index);
// Wrap participant for validation
$wrapper = new ParticipantEditDto(
participant: $participant,
bookingContext: $bookingDto,
);
// Validate the wrapper DTO
$violations = $this->validator->validate($wrapper, null, $validationGroups);
// Extract validation state
$isValid = 0 === count($violations);
$errorMessages = [];
foreach ($violations as $violation) {
$errorMessages[] = $violation->getMessage();
}
return array_merge($cardData, [
'isValid' => $isValid,
'errorMessages' => $errorMessages,
]);
}
/**
* Get card data for all participants with validation state.
*
* @return array<int, array{name: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}>
*/
public function getAllCardsDataWithValidation(BookingDto $bookingDto, array $validationGroups): array
{
$cardsData = [];
foreach ($bookingDto->participants as $index => $participant) {
$cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index, $validationGroups);
}
return $cardsData;
}
}
@@ -1,8 +1,7 @@
{# Compact participant card with name, room, price, and edit button #}
{% set isCanceled = isCanceled|default(false) %}
{% set hasErrors = hasErrors|default(false) %}
{% set isValid = true %}
{% set errorMessages = errorMessages|default([]) %}
{% set isCanceled = cardData.isCanceled is defined ? cardData.isCanceled : false %}
{% set isValid = cardData.isValid is defined ? cardData.isValid : true %}
{% set errorMessages = cardData.errorMessages|default([]) %}
{% set mode = mode|default('create') %}
<div id="participant-card-{{ index }}"
@@ -60,14 +60,7 @@
{# Contact information #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.email, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{{ form_row(form.email) }}
{{ form_row(form.mobile) }}
</div>
@@ -9,6 +9,8 @@
{% block participant_cards %}
<div id="main-content" class="col-span-2">
{{ form_start(form, {
'method': 'POST',
'action': path('app_booking_create_step_2'),
'attr': {
'hx-post': path('app_booking_create_step_2'),
'hx-target': '#main-content',
@@ -11,16 +11,19 @@ use App\Form\Model\ParticipantDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\ParticipantCardDataService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ParticipantCardDataServiceTest extends TestCase
{
private ParticipantCardDataService $service;
private BookingPriceCalculatorService $priceCalculator;
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$this->service = new ParticipantCardDataService($this->priceCalculator);
$this->validator = $this->createMock(ValidatorInterface::class);
$this->service = new ParticipantCardDataService($this->priceCalculator, $this->validator);
}
public function testGetCardDataWithFullParticipantData(): void
@@ -347,4 +350,134 @@ class ParticipantCardDataServiceTest extends TestCase
$this->assertEquals('Teilnehmer:in 1', $result2['name']);
$this->assertEquals('Teilnehmer:in 2', $result3['name']);
}
public function testGetCardDataWithValidationReturnsValidCard(): void
{
$room = new Room();
$room->id = 1;
$room->label = 'Doppelzimmer';
$travel = new Travel();
$travel->rooms = [$room];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$participant->assignedRoomId = 1;
$participant->email = '[email protected]';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([450.50]);
// Mock validator to return no violations (valid)
$violations = $this->createMock(\Symfony\Component\Validator\ConstraintViolationListInterface::class);
$violations->expects($this->once())
->method('count')
->willReturn(0);
$this->validator
->expects($this->once())
->method('validate')
->willReturn($violations);
$result = $this->service->getCardDataWithValidation($bookingDto, 0, ['booking_create']);
$this->assertEquals('Max Mustermann', $result['name']);
$this->assertEquals('Doppelzimmer', $result['roomName']);
$this->assertEquals('450,50 €', $result['price']);
$this->assertTrue($result['isValid']);
$this->assertEmpty($result['errorMessages']);
}
public function testGetCardDataWithValidationReturnsInvalidCard(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$participant->email = '[email protected]';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
// Mock validator to return violations (invalid)
$violation = $this->createMock(\Symfony\Component\Validator\ConstraintViolationInterface::class);
$violation->expects($this->once())
->method('getMessage')
->willReturn('Diese E-Mail Adresse wird bereits von einem anderen Teilnehmer verwendet');
// Use ConstraintViolationList directly instead of mocking
$violations = new \Symfony\Component\Validator\ConstraintViolationList([$violation]);
$this->validator
->expects($this->once())
->method('validate')
->willReturn($violations);
$result = $this->service->getCardDataWithValidation($bookingDto, 0, ['booking_create']);
$this->assertEquals('Max Mustermann', $result['name']);
$this->assertFalse($result['isValid']);
$this->assertCount(1, $result['errorMessages']);
$this->assertEquals('Diese E-Mail Adresse wird bereits von einem anderen Teilnehmer verwendet', $result['errorMessages'][0]);
}
public function testGetAllCardsDataWithValidationReturnsAllCards(): void
{
$room = new Room();
$room->id = 1;
$room->label = 'Doppelzimmer';
$travel = new Travel();
$travel->rooms = [$room];
$participant1 = new ParticipantDto();
$participant1->firstName = 'Max';
$participant1->lastName = 'Mustermann';
$participant1->assignedRoomId = 1;
$participant1->email = '[email protected]';
$participant2 = new ParticipantDto();
$participant2->firstName = 'Anna';
$participant2->lastName = 'Schmidt';
$participant2->assignedRoomId = 1;
$participant2->email = '[email protected]';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$this->priceCalculator
->expects($this->exactly(2))
->method('calculateAllParticipantIndividualPrices')
->willReturn([450.0, 500.0]);
// Mock validator to return no violations for both participants
$violations = $this->createMock(\Symfony\Component\Validator\ConstraintViolationListInterface::class);
$violations->expects($this->exactly(2))
->method('count')
->willReturn(0);
$this->validator
->expects($this->exactly(2))
->method('validate')
->willReturn($violations);
$result = $this->service->getAllCardsDataWithValidation($bookingDto, ['booking_create']);
$this->assertCount(2, $result);
$this->assertTrue($result[0]['isValid']);
$this->assertTrue($result[1]['isValid']);
$this->assertEquals('Max Mustermann', $result[0]['name']);
$this->assertEquals('Anna Schmidt', $result[1]['name']);
}
}