fix: validation error indication on cards
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user