wip: form refactoring

This commit is contained in:
Björn Fromme
2026-03-16 11:59:09 +01:00
parent 65a33922cf
commit d719b17aec
13 changed files with 158 additions and 100 deletions
@@ -23,8 +23,7 @@ class CreateStep1Controller extends AbstractController
public function __construct( public function __construct(
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
) ) {
{
} }
/** /**
@@ -34,6 +33,10 @@ class CreateStep1Controller extends AbstractController
public function index(Request $request): Response public function index(Request $request): Response
{ {
$bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request); $bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request);
// Capture the current room selection state before form processing
$oldRoomSelectionSnapshot = $this->bookingService->createRoomSelectionSnapshot($bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
// Validate step access - allow step 1 or redirect to current step // Validate step access - allow step 1 or redirect to current step
@@ -46,6 +49,9 @@ class CreateStep1Controller extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
if ($this->bookingService->hasRoomSelectionChanged($oldRoomSelectionSnapshot, $bookingCreateDto)) {
$this->bookingService->resetParticipantAssignments($bookingCreateDto);
}
$bookingCreateDto->currentStep = 2; $bookingCreateDto->currentStep = 2;
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
@@ -80,8 +86,6 @@ class CreateStep1Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms);
@@ -27,8 +27,7 @@ class CreateStep2Controller extends AbstractController
public function __construct( public function __construct(
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
) ) {
{
} }
/** /**
+8 -17
View File
@@ -3,9 +3,8 @@
namespace App\Form; namespace App\Form;
use App\BusProNet\Form\CountryType; use App\BusProNet\Form\CountryType;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider; use App\Form\Service\ParticipantFieldOptionsProvider;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
@@ -85,14 +84,12 @@ class BookingCreateParticipantType extends AbstractType
return; return;
} }
// Traverse up the form tree to get the root form's data. // Get the booking DTO from the root form
$rootForm = $form; $bookingDto = $this->fieldOptionsProvider->getBookingDtoFromForm($form);
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent();
}
/** @var BookingDtoInterface $bookingDto */ if (null === $bookingDto) {
$bookingDto = $rootForm->getData(); return;
}
$this->addDynamicFields($form, $bookingDto, $participantData->index); $this->addDynamicFields($form, $bookingDto, $participantData->index);
$this->applyFieldStates($form, $bookingDto, $participantData->index); $this->applyFieldStates($form, $bookingDto, $participantData->index);
@@ -110,14 +107,8 @@ class BookingCreateParticipantType extends AbstractType
return; return;
} }
// Get the root form data to access BookingDtoInterface // Get the booking DTO from the root form
$rootForm = $form; $bookingDto = $this->fieldOptionsProvider->getBookingDtoFromForm($form);
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent();
}
/** @var BookingDtoInterface $bookingDto */
$bookingDto = $rootForm->getData();
if (null === $bookingDto) { if (null === $bookingDto) {
return; return;
+14 -15
View File
@@ -7,7 +7,6 @@ use App\BusProNet\Form\CountryType;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\EditFieldStateProvider; use App\Form\Service\EditFieldStateProvider;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
@@ -23,7 +22,8 @@ class BookingEditParticipantType extends AbstractType
{ {
public function __construct( public function __construct(
private readonly EditFieldStateProvider $fieldStateProvider, private readonly EditFieldStateProvider $fieldStateProvider,
) {} ) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
@@ -39,13 +39,12 @@ class BookingEditParticipantType extends AbstractType
$form = $event->getForm(); $form = $event->getForm();
// Traverse up the form tree to get the root form's data (BookingEditDto) // Get the booking DTO from the root form
$rootForm = $form; $bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form);
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent(); if (null === $bookingDto) {
return;
} }
/** @var BookingDtoInterface $bookingDto */
$bookingDto = $rootForm->getData();
// Helper to get state for a field // Helper to get state for a field
$getState = fn (string $field) => $this->fieldStateProvider->getFieldState($field, $bookingDto, $participantIndex); $getState = fn (string $field) => $this->fieldStateProvider->getFieldState($field, $bookingDto, $participantIndex);
@@ -285,13 +284,12 @@ class BookingEditParticipantType extends AbstractType
return; return;
} }
// Traverse up the form tree to get the root form's data (BookingEditDto) // Get the booking DTO from the root form
$rootForm = $form; $bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form);
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent(); if (null === $bookingDto) {
return;
} }
/** @var BookingDtoInterface $bookingDto */
$bookingDto = $rootForm->getData();
$participantIndex = $form->getData()->index; $participantIndex = $form->getData()->index;
// Helper to get state for a field based on submitted data // Helper to get state for a field based on submitted data
@@ -299,7 +297,7 @@ class BookingEditParticipantType extends AbstractType
// Re-apply field states to all personal data fields // Re-apply field states to all personal data fields
$personalDataFields = [ $personalDataFields = [
'firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile' 'firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile',
]; ];
foreach ($personalDataFields as $field) { foreach ($personalDataFields as $field) {
if ($form->has($field)) { if ($form->has($field)) {
@@ -389,6 +387,7 @@ class BookingEditParticipantType extends AbstractType
$fieldOptions[$key] = $value; $fieldOptions[$key] = $value;
} }
} }
return $fieldOptions; return $fieldOptions;
} }
} }
-5
View File
@@ -40,11 +40,6 @@ class BookingCreateDto implements BookingDtoInterface
return $this->participants; return $this->participants;
} }
public function getTravel(): Travel
{
return $this->travel;
}
public function hasParticipant(int $index): bool public function hasParticipant(int $index): bool
{ {
return isset($this->participants[$index]); return isset($this->participants[$index]);
-16
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Form\Model; namespace App\Form\Model;
use App\BusProNet\Model\Travel;
/** /**
* Interface for unified access to booking data across create and edit workflows. * Interface for unified access to booking data across create and edit workflows.
* *
@@ -23,13 +21,6 @@ interface BookingDtoInterface
*/ */
public function getParticipants(): array; public function getParticipants(): array;
/**
* Gets the travel data for the booking.
*
* @return Travel The travel data containing services, hotels, and other booking options
*/
public function getTravel(): Travel;
/** /**
* Checks if a participant exists at the given index. * Checks if a participant exists at the given index.
* *
@@ -47,11 +38,4 @@ interface BookingDtoInterface
* @return ParticipantDto|null The participant DTO or null if not found * @return ParticipantDto|null The participant DTO or null if not found
*/ */
public function getParticipant(int $index): ?ParticipantDto; public function getParticipant(int $index): ?ParticipantDto;
/**
* Gets all selected rooms for the booking.
*
* @return array<int, RoomSelectionDto> Array of selected room DTOs (may be empty for edit DTOs)
*/
public function getSelectedRooms(): array;
} }
+6 -10
View File
@@ -10,18 +10,19 @@ use Symfony\Component\Validator\Constraints as Assert;
class BookingEditDto implements BookingDtoInterface class BookingEditDto implements BookingDtoInterface
{ {
public ?Booking $booking = null;
public ?Travel $travel = null;
/** /**
* @var array<int, ParticipantDto> * @var array<int, ParticipantDto>
*/ */
#[Assert\Valid] #[Assert\Valid]
public array $participants = []; public array $participants = [];
public function __construct(public Booking $booking, public Travel $travel)
{
}
public static function fromBooking(Booking $booking, Travel $travel): static public static function fromBooking(Booking $booking, Travel $travel): static
{ {
$instance = new static(); $instance = new static($booking, $travel);
$instance->booking = $booking; $instance->booking = $booking;
$instance->travel = $travel; $instance->travel = $travel;
@@ -69,11 +70,6 @@ class BookingEditDto implements BookingDtoInterface
return $this->participants; return $this->participants;
} }
public function getTravel(): Travel
{
return $this->travel;
}
public function hasParticipant(int $index): bool public function hasParticipant(int $index): bool
{ {
return isset($this->participants[$index]); return isset($this->participants[$index]);
@@ -87,7 +83,7 @@ class BookingEditDto implements BookingDtoInterface
/** /**
* Gets all selected rooms for the booking (edit context). * Gets all selected rooms for the booking (edit context).
* *
* @return array<int, RoomSelectionDto> Always returns an empty array for edit DTOs unless implemented. * @return array<int, RoomSelectionDto> always returns an empty array for edit DTOs unless implemented
*/ */
public function getSelectedRooms(): array public function getSelectedRooms(): array
{ {
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Form\ParticipantFieldHandler; namespace App\Form\ParticipantFieldHandler;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
/** /**
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Form\ParticipantFieldHandler; namespace App\Form\ParticipantFieldHandler;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
/** /**
+6 -2
View File
@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition;
use App\Form\ParticipantFieldHandler\Condition\ApplicantCondition; use App\Form\ParticipantFieldHandler\Condition\ApplicantCondition;
use App\Form\ParticipantFieldHandler\Condition\CompositeCondition; use App\Form\ParticipantFieldHandler\Condition\CompositeCondition;
use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition;
/** /**
* Field state provider for the booking edit workflow. * Field state provider for the booking edit workflow.
@@ -21,6 +21,8 @@ use App\Form\ParticipantFieldHandler\Condition\CompositeCondition;
*/ */
class EditFieldStateProvider implements FieldStateProviderInterface class EditFieldStateProvider implements FieldStateProviderInterface
{ {
use FormTraversalTrait;
/** @var array<string, array<string, FieldConditionInterface>> */ /** @var array<string, array<string, FieldConditionInterface>> */
private array $fieldStateConditions = []; private array $fieldStateConditions = [];
@@ -109,6 +111,7 @@ class EditFieldStateProvider implements FieldStateProviderInterface
foreach ($this->fieldStateConditions[$fieldName] as $condition) { foreach ($this->fieldStateConditions[$fieldName] as $condition) {
$dependencies = array_merge($dependencies, $condition->getDependentFields()); $dependencies = array_merge($dependencies, $condition->getDependentFields());
} }
return array_unique($dependencies); return array_unique($dependencies);
} }
@@ -121,6 +124,7 @@ class EditFieldStateProvider implements FieldStateProviderInterface
$allStates[$fieldName] = $fieldState; $allStates[$fieldName] = $fieldState;
} }
} }
return $allStates; return $allStates;
} }
} }
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use Symfony\Component\Form\FormInterface;
/**
* Trait providing form tree traversal utilities.
*
* This trait contains common form navigation logic used across different
* form services and types. It centralizes the logic for finding root forms
* and extracting booking DTOs from form hierarchies.
*/
trait FormTraversalTrait
{
/**
* Gets the BookingDtoInterface from the root of the form tree.
*
* This helper method traverses up the form tree to find the root form
* and extracts the BookingDtoInterface data. This is shared logic used
* by both create and edit participant forms.
*
* @param FormInterface $form The form to start traversing from
*
* @return BookingDtoInterface|null The booking DTO or null if not found
*/
public function getBookingDtoFromForm(FormInterface $form): ?BookingDtoInterface
{
// Traverse up the form tree to get the root form's data
$rootForm = $form;
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent();
}
$data = $rootForm->getData();
return $data instanceof BookingDtoInterface ? $data : null;
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface; use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
@@ -26,6 +27,8 @@ use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
*/ */
class ParticipantFieldOptionsProvider implements FieldStateProviderInterface class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
{ {
use FormTraversalTrait;
/** @var array<string, callable> Field option providers indexed by field name */ /** @var array<string, callable> Field option providers indexed by field name */
private array $fieldOptionProviders = []; private array $fieldOptionProviders = [];
@@ -120,7 +123,7 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
*/ */
private function registerFieldOptionProviders(): void private function registerFieldOptionProviders(): void
{ {
// Room assignment field provider // Room assignment field provider (only available for create workflow)
$this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Zimmer', 'label' => 'Zimmer',
'placeholder' => 'Bitte wählen', 'placeholder' => 'Bitte wählen',
@@ -128,11 +131,13 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
// - Shows only available rooms for this participant // - Shows only available rooms for this participant
// - Excludes rooms already assigned to other participants // - Excludes rooms already assigned to other participants
// - Respects room capacity and booking constraints // - Respects room capacity and booking constraints
'choice_loader' => $this->roomChoiceLoaderFactory->create( 'choice_loader' => $bookingDto instanceof BookingCreateDto
? $this->roomChoiceLoaderFactory->create(
$bookingDto->participants, $bookingDto->participants,
$bookingDto->getSelectedRooms(), $bookingDto->getSelectedRooms(),
$participantIndex $participantIndex
), )
: null,
]; ];
// Future field providers would be added here, for example: // Future field providers would be added here, for example:
+53 -12
View File
@@ -6,7 +6,6 @@ use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingCreateDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Service\TravelDataService;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -14,8 +13,7 @@ class BookingService
{ {
public function __construct( public function __construct(
private readonly TravelDataService $travelDataService, private readonly TravelDataService $travelDataService,
) { ) {}
}
public function getOrCreateBookingCreateDto(Request $request): BookingCreateDto public function getOrCreateBookingCreateDto(Request $request): BookingCreateDto
{ {
@@ -103,7 +101,6 @@ class BookingService
return $participantsCount; return $participantsCount;
} }
/** /**
* Calculates the number of participants assigned to each room ID. * Calculates the number of participants assigned to each room ID.
* *
@@ -124,7 +121,6 @@ class BookingService
/** /**
* Returns a summary of selected rooms and the resulting participant count for a booking. * Returns a summary of selected rooms and the resulting participant count for a booking.
* *
* @param BookingCreateDto $bookingCreateDto
* @return array{selectedRooms: array, participantCount: int} * @return array{selectedRooms: array, participantCount: int}
*/ */
public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array
@@ -142,19 +138,20 @@ class BookingService
* Groups available rooms by selection type ('by_pax' or 'by_room'). * Groups available rooms by selection type ('by_pax' or 'by_room').
* *
* @param array<int, Room> $rooms Rooms indexed by room ID * @param array<int, Room> $rooms Rooms indexed by room ID
*
* @return array{by_pax: array<int, Room>, by_room: array<int, Room>} * @return array{by_pax: array<int, Room>, by_room: array<int, Room>}
*/ */
public function groupRoomsBySelectionType(array $rooms): array public function groupRoomsBySelectionType(array $rooms): array
{ {
$groups = [ $groups = [
'by_pax' => [], Room::SELECTION_TYPE_BY_PAX => [],
'by_room' => [], Room::SELECTION_TYPE_BY_ROOM => [],
]; ];
foreach ($rooms as $room) { foreach ($rooms as $room) {
if (stripos($room->label, 'bett') !== false) { if (false !== stripos($room->label, 'bett')) {
$groups['by_pax'][$room->id] = $room; $groups[Room::SELECTION_TYPE_BY_PAX][$room->id] = $room;
} else { } else {
$groups['by_room'][$room->id] = $room; $groups[Room::SELECTION_TYPE_BY_ROOM][$room->id] = $room;
} }
} }
@@ -166,13 +163,14 @@ class BookingService
* *
* @param array $roomSelections Array of selected RoomSelectionDto * @param array $roomSelections Array of selected RoomSelectionDto
* @param array<int, Room> $roomsById Rooms indexed by room ID * @param array<int, Room> $roomsById Rooms indexed by room ID
*
* @return array{by_pax: array, by_room: array} * @return array{by_pax: array, by_room: array}
*/ */
public function groupRoomSelectionsByType(array $roomSelections, array $roomsById): array public function groupRoomSelectionsByType(array $roomSelections, array $roomsById): array
{ {
$groups = [ $groups = [
'by_pax' => [], Room::SELECTION_TYPE_BY_PAX => [],
'by_room' => [], Room::SELECTION_TYPE_BY_ROOM => [],
]; ];
foreach ($roomSelections as $roomSelection) { foreach ($roomSelections as $roomSelection) {
$room = $roomsById[$roomSelection->roomId] ?? null; $room = $roomsById[$roomSelection->roomId] ?? null;
@@ -184,4 +182,47 @@ class BookingService
return $groups; return $groups;
} }
/**
* Determines if the room selection has changed between two DTOs.
*/
public function shouldResetAssignments(BookingCreateDto $oldDto, BookingCreateDto $newDto): bool
{
$old = array_map(fn($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $oldDto->roomSelections);
$new = array_map(fn($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $newDto->roomSelections);
return $old !== $new;
}
/**
* Resets all participant room assignments in the DTO.
*/
public function resetParticipantAssignments(BookingCreateDto $dto): void
{
foreach ($dto->participants as $participant) {
$participant->assignedRoomId = null;
}
}
/**
* Creates a snapshot of the current room selection state.
*
* @return array<int, array{int, int}> Array of [roomId, quantity] pairs
*/
public function createRoomSelectionSnapshot(BookingCreateDto $dto): array
{
return array_map(
fn($roomSelection) => [$roomSelection->roomId, $roomSelection->quantity],
$dto->roomSelections
);
}
/**
* Checks if room selection has changed compared to a previous snapshot.
*/
public function hasRoomSelectionChanged(array $oldSnapshot, BookingCreateDto $newDto): bool
{
$newSnapshot = $this->createRoomSelectionSnapshot($newDto);
return $oldSnapshot !== $newSnapshot;
}
} }