diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index fd5a668..3a32028 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -6,12 +6,8 @@ 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\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; -use App\Form\Service\DummyDataFillService; -use App\Form\Service\ParticipantFieldOptionsProvider; -use App\Htmx\HxTrait; use App\Service\BookingService; use App\Service\BookingSummaryDataService; use App\Service\ParticipantCardDataService; @@ -19,7 +15,6 @@ use App\Service\ParticipantPrepopulationService; use App\Service\RoomAssignmentService; use App\Service\TravelDataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -34,8 +29,6 @@ class Step2Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; - use HxTrait; - use ParticipantCardFlowTrait; public function __construct( private readonly BookingService $bookingService, @@ -43,9 +36,7 @@ class Step2Controller extends AbstractController private readonly TravelDataService $travelDataService, private readonly RoomAssignmentService $roomAssignmentService, private readonly ParticipantCardDataService $participantCardService, - private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, private readonly ParticipantPrepopulationService $prepopulationService, - private readonly DummyDataFillService $dummyDataFillService, ) { } @@ -121,125 +112,4 @@ class Step2Controller extends AbstractController return $this->render('booking/create/step_2.html.twig', $templateData); } - /** - * Show or submit individual participant form. - */ - #[Route( - path: '/bookings/create/participants/{index}', - name: 'app_booking_create_step_2_participant', - requirements: ['index' => '\d+'] - )] - public function editParticipant(int $index, Request $request): Response - { - $result = $this->loadBookingDtoOrRedirect($request, BookingDto::MODE_CREATE); - if ($result instanceof Response) { - return $result; - } - $bookingDto = $result; - - // Validate participant index - if (false === isset($bookingDto->participants[$index])) { - throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); - } - - // Use cached availability data (populated during booking init) - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); - - // Create form with booking_context option - $form = $this->createParticipantForm($bookingDto, $index); - - $form->handleRequest($request); - $isSubmitted = $form->isSubmitted(); - - // Detect dummy data fill token — render pre-filled form immediately, skipping validation - $isDummyDataFill = $this - ->dummyDataFillService - ->isTokenMatch($bookingDto->participants[$index], $bookingDto->getMode()) - ; - if (true === $isSubmitted && true === $isDummyDataFill) { - $this->dummyDataFillService->fill($bookingDto->participants[$index], $index); - - // Keep behavior consistent with cards view: newly filled participant data - // (especially dateOfBirth) must immediately trigger mandatory service preselection. - $this->bookingService->preselectDefaultServices($bookingDto); - $this->bookingService->applyCreateBookingStatusRules($bookingDto); - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); - - // Recreate form with filled DTO so the view shows the dummy data - $form = $this->createParticipantForm($bookingDto, $index); - - return $this->renderParticipantForm($form, $index, $bookingDto); - } - - if (true === $isSubmitted) { - // Re-run default preselection after participant form input changes (e.g. DOB). - // This ensures auto-book defaults are applied as soon as eligibility becomes known. - $this->bookingService->preselectDefaultServices($bookingDto); - $this->bookingService->applyCreateBookingStatusRules($bookingDto); - } - - // Collect notifications from field handlers (run during PRE_SUBMIT) - $notifications = $this->collectAndClearNotifications($bookingDto); - - if (true === $isSubmitted && true === $form->isValid()) { - // Save BookingDto to session - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); - - // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) - $this->addNotificationsAsFlashMessages($notifications); - - // HTMX redirect to cards view - return $this->redirectToRoute('app_booking_create_step_2'); - } - - return $this->renderParticipantForm($form, $index, $bookingDto); - } - - /** - * HTMX refresh endpoint for individual participant form. - */ - #[Route( - path: '/bookings/create/participants/{index}/refresh', - name: 'app_booking_create_step_2_participant_refresh', - requirements: ['index' => '\d+'], - methods: ['POST'] - )] - public function refreshParticipantForm(int $index, Request $request): Response - { - $result = $this->loadBookingDtoOrRedirect($request, BookingDto::MODE_CREATE); - if ($result instanceof Response) { - return $result; - } - $bookingDto = $result; - - // Validate participant index - if (false === isset($bookingDto->participants[$index])) { - throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); - } - - // Enrich with fresh availability data - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); - - // Use trait method for refresh handling - return $this->handleParticipantRefresh( - $request, - $bookingDto, - $index, - 'app_booking_create_step_2_participant_refresh' - ); - } - - private function renderParticipantForm( - FormInterface $form, - int $index, - BookingDto $bookingDto, - ): Response { - return $this->render('booking/create/step_2_participant.html.twig', [ - 'form' => $form->createView(), - 'participantIndex' => $index, - 'bookingDto' => $bookingDto, - 'summaryData' => $this->summaryDataService->getSummaryData($bookingDto), - 'refreshRouteName' => 'app_booking_create_step_2_participant_refresh', - ]); - } } diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php new file mode 100644 index 0000000..11285cd --- /dev/null +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -0,0 +1,223 @@ + '\d+'] + )] + public function editParticipant(int $index, Request $request): Response + { + $result = $this->loadBookingDtoOrRedirect($request); + if ($result instanceof Response) { + return $result; + } + $bookingDto = $result; + + $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); + + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); + + $form = $this->createParticipantForm($bookingDto, $index); + + $form->handleRequest($request); + $isSubmitted = $form->isSubmitted(); + + $isDummyDataFill = $this + ->dummyDataFillService + ->isTokenMatch($bookingDto->participants[$index], $bookingDto->getMode()) + ; + if (true === $isSubmitted && true === $isDummyDataFill) { + $this->dummyDataFillService->fill($bookingDto->participants[$index], $index); + + $this->bookingService->preselectDefaultServices($bookingDto); + $this->bookingService->applyCreateBookingStatusRules($bookingDto); + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + + $form = $this->createParticipantForm($bookingDto, $index); + + return $this->renderParticipantForm($form, $index, $bookingDto); + } + + if (true === $isSubmitted) { + $this->bookingService->preselectDefaultServices($bookingDto); + $this->bookingService->applyCreateBookingStatusRules($bookingDto); + } + + $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); + + if (true === $isSubmitted && true === $form->isValid()) { + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $this->addNotificationsAsFlashMessages($notifications); + + return $this->redirectToRoute('app_booking_create_step_2'); + } + + return $this->renderParticipantForm($form, $index, $bookingDto); + } + + /** + * HTMX refresh endpoint for individual participant form. + */ + #[Route( + path: '/bookings/create/participants/{index}/refresh', + name: 'app_booking_create_step_2_participant_refresh', + requirements: ['index' => '\d+'], + methods: ['POST'] + )] + public function refreshParticipantForm(int $index, Request $request): Response + { + $result = $this->loadBookingDtoOrRedirect($request); + if ($result instanceof Response) { + return $result; + } + $bookingDto = $result; + + $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); + + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); + + return $this->handleParticipantRefresh( + $request, + $bookingDto, + $index, + 'app_booking_create_step_2_participant_refresh' + ); + } + + private function renderParticipantForm( + FormInterface $form, + int $index, + BookingDto $bookingDto, + ): Response { + return $this->render('booking/create/step_2_participant.html.twig', [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'summaryData' => $this->summaryDataService->getSummaryData($bookingDto), + 'refreshRouteName' => 'app_booking_create_step_2_participant_refresh', + ]); + } + + private function loadBookingDtoOrRedirect(Request $request): BookingDto|Response + { + $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_CREATE); + + if (null === $bookingDto) { + $this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.'); + + return $this->redirectToRoute('app_login'); + } + + return $bookingDto; + } + + private function createParticipantForm(BookingDto $bookingDto, int $index): FormInterface + { + $wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index); + + return $this->createForm( + BookingParticipantType::class, + $wrapper, + $this->participantFormSupportService->getParticipantFormOptions($bookingDto) + ); + } + + private function handleParticipantRefresh( + Request $request, + BookingDto $bookingDto, + int $index, + string $refreshRouteName, + ): Response { + $form = $this->createForm( + BookingParticipantType::class, + $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index), + $this->participantFormSupportService->getParticipantFormOptions($bookingDto, true) + ); + + $form->handleRequest($request); + + $this->bookingService->preselectDefaultServices($bookingDto); + if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { + $this->bookingService->applyCreateBookingStatusRules($bookingDto); + } + + $form = $this->createForm( + BookingParticipantType::class, + $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index), + $this->participantFormSupportService->getParticipantFormOptions($bookingDto, true) + ); + + $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); + + $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); + + $summaryData = $this->summaryDataService->getSummaryData($bookingDto); + + $response = $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'summaryData' => $summaryData, + 'refreshRouteName' => $refreshRouteName, + ] + ); + + if (false === empty($notifications)) { + $response->headers->set('HX-Trigger', json_encode([ + 'showNotifications' => $notifications, + ])); + } + + return $response; + } + + /** + * @param array $notifications + */ + private function addNotificationsAsFlashMessages(array $notifications): void + { + foreach ($notifications as $notification) { + $this->addFlash($notification['type'], $notification['message']); + } + } +} diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 7bd1a22..a4a9d72 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -8,14 +8,11 @@ use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\Notification; -use App\Controller\Booking\Traits; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Entity\User; use App\Exception\TravelNotFoundException; use App\Form\BookingEditType; -use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; -use App\Form\Model\ParticipantEditDto; use App\Htmx\HxTrait; use App\Service\BookingEditDataLoaderService; use App\Service\BookingEditDraftService; @@ -45,7 +42,6 @@ class IndexController extends AbstractController { use BookingExceptionHandlerTrait; use HxTrait; - use Traits\ParticipantCardFlowTrait; public function __construct( private readonly ApiClient $apiClient, @@ -151,194 +147,6 @@ class IndexController extends AbstractController return $this->render('booking/edit/index.html.twig', $templateData); } - /** - * Edit single participant form. - */ - #[Route( - path: '/bookings/{id}/edit/participants/{index}', - name: 'app_booking_edit_participant', - requirements: ['id' => '\d+', 'index' => '\d+'] - )] - #[IsGranted('ROLE_USER')] - public function editParticipant(int $id, int $index, Request $request): Response - { - /** @var User $user */ - $user = $this->getUser(); - - // Load form data from session - $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); - - if (null === $bookingDto) { - $this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.'); - - return $this->redirectToRoute('app_booking_edit', ['id' => $id]); - } - - $participant = $bookingDto->participants[$index] ?? null; - - if (null === $participant) { - throw new \InvalidArgumentException('Invalid participant index'); - } - - // Fetch booking data to check for canceled status - $bookingData = $this->dataLoader->fetchBookingData($id, $user); - if (null === $bookingData || $bookingData instanceof Notification) { - $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); - - return $this->redirectToRoute('app_bookings'); - } - - // Check if participant is canceled - $isCanceled = 'S' === ($bookingData->participantsStatus[$index] ?? null); - - if ($isCanceled) { - $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); - - return $this->redirectToRoute('app_booking_edit', ['id' => $id]); - } - - // Use cached availability data (populated during booking init) - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); - - // Create wrapper DTO for email uniqueness validation - $wrapper = new ParticipantEditDto( - participant: $participant, - bookingContext: $bookingDto, - ); - - // Create form for participant with booking context - $form = $this->createForm(BookingParticipantType::class, $wrapper, [ - 'booking_context' => $bookingDto, - 'height_choices' => $this->getParameter('body_dimensions.height_choices'), - 'weight_choices' => $this->getParameter('body_dimensions.weight_choices'), - 'shoe_size_min' => $this->getParameter('body_dimensions.shoe_size_min'), - 'shoe_size_max' => $this->getParameter('body_dimensions.shoe_size_max'), - ]); - - $form->handleRequest($request); - - // Collect notifications from field handlers (run during PRE_SUBMIT) - $notifications = $this->collectAndClearNotifications($bookingDto); - - if ($form->isSubmitted() && $form->isValid()) { - // Save updated booking data to session - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); - - // Auto-save draft to database for data persistence - $this->draftService->saveDraft($user, $id, $bookingDto); - - // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) - $this->addNotificationsAsFlashMessages($notifications); - - // Redirect back to cards - return $this->redirectToRoute('app_booking_edit', ['id' => $id]); - } - - // Get complete summary data (pricing, rooms, CMS data) - $summaryData = $this->summaryDataService->getSummaryData($bookingDto); - - // Fetch mutable data for form constraints - $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - - $templateData = [ - 'form' => $form->createView(), - 'participantIndex' => $index, - 'bookingDto' => $bookingDto, - 'bookingData' => $bookingData, - 'mutableData' => $mutableData, - 'summaryData' => $summaryData, - 'refreshRouteName' => 'app_booking_edit_participant_refresh', - 'refreshRouteParams' => ['id' => $id, 'index' => $index], - 'cancelRouteName' => 'app_booking_edit', - 'cancelRouteParams' => ['id' => $id], - ]; - - return $this->render('booking/edit/participant.html.twig', $templateData); - } - - /** - * HTMX endpoint for refreshing participant form without validation. - */ - #[Route( - path: '/bookings/{id}/edit/participants/{index}/refresh', - name: 'app_booking_edit_participant_refresh', - requirements: ['id' => '\d+', 'index' => '\d+'], - methods: ['POST'] - )] - #[IsGranted('ROLE_USER')] - public function refreshParticipantForm(int $id, int $index, Request $request): Response - { - /** @var User $user */ - $user = $this->getUser(); - - // Load form data from session - $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); - - if (null === $bookingDto) { - return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); - } - - // Refresh availability data - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); - - // Fetch booking data and mutable data - $bookingData = $this->dataLoader->fetchBookingData($id, $user); - $mutableData = null !== $bookingData && !($bookingData instanceof Notification) - ? $this->travelDataService->getMutabilityData($bookingData->dateId) - : null; - - // Create wrapper DTO for form - $wrapper = new ParticipantEditDto( - participant: $bookingDto->participants[$index], - bookingContext: $bookingDto, - ); - - // Create form with validation disabled - $form = $this->createForm(BookingParticipantType::class, $wrapper, [ - 'booking_context' => $bookingDto, - 'validation_groups' => false, - 'height_choices' => $this->getParameter('body_dimensions.height_choices'), - 'weight_choices' => $this->getParameter('body_dimensions.weight_choices'), - 'shoe_size_min' => $this->getParameter('body_dimensions.shoe_size_min'), - 'shoe_size_max' => $this->getParameter('body_dimensions.shoe_size_max'), - ]); - - $form->handleRequest($request); - - // Collect notifications from field handlers - $notifications = $this->collectAndClearNotifications($bookingDto); - - // Get complete summary data (pricing, rooms, CMS data) - $summaryData = $this->summaryDataService->getSummaryData($bookingDto); - - // Render form + sidebar using htmxOobResponse - $response = $this->htmxOobResponse( - 'booking/_participant_form.html.twig', - ['participant_form', 'booking_summary'], - [ - 'form' => $form, - 'participantIndex' => $index, - 'bookingDto' => $bookingDto, - 'bookingData' => $bookingData, - 'mutableData' => $mutableData, - 'summaryData' => $summaryData, - 'refreshRouteName' => 'app_booking_edit_participant_refresh', - 'refreshRouteParams' => ['id' => $id, 'index' => $index], - 'cancelRouteName' => 'app_booking_edit', - 'cancelRouteParams' => ['id' => $id], - ] - ); - - // Add notifications to HX-Trigger header if present - if (false === empty($notifications)) { - $response->headers->set('HX-Trigger', json_encode([ - 'showNotifications' => $notifications, - ])); - } - - return $response; - } - /** * Reloads booking data from API, discarding all session changes. * diff --git a/src/Controller/Booking/Edit/ParticipantController.php b/src/Controller/Booking/Edit/ParticipantController.php new file mode 100644 index 0000000..1586da8 --- /dev/null +++ b/src/Controller/Booking/Edit/ParticipantController.php @@ -0,0 +1,185 @@ + '\d+', 'index' => '\d+'] + )] + #[IsGranted('ROLE_USER')] + public function editParticipant(int $id, int $index, Request $request): Response + { + /** @var User $user */ + $user = $this->getUser(); + + $bookingDto = $this->participantFormService->loadBookingDto($request); + + if (null === $bookingDto) { + $this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.'); + + return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + } + + $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); + + $bookingData = $this->participantFormService->fetchBookingData($id, $user); + if (null === $bookingData || $bookingData instanceof Notification) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + + return $this->redirectToRoute('app_bookings'); + } + + if ($this->participantFormService->isParticipantCanceled($bookingData, $index)) { + $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); + + return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + } + + $this->participantFormService->enrichTravelData($bookingDto); + + $wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index); + + $form = $this->createForm( + BookingParticipantType::class, + $wrapper, + $this->participantFormSupportService->getParticipantFormOptions($bookingDto) + ); + + $form->handleRequest($request); + + $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); + + if ($form->isSubmitted() && $form->isValid()) { + $this->participantFormService->saveBookingDto($request, $bookingDto); + $this->participantFormService->saveDraft($user, $id, $bookingDto); + $this->addNotificationsAsFlashMessages($notifications); + + return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + } + + $summaryData = $this->participantFormService->getSummaryData($bookingDto); + $mutableData = $this->participantFormService->getMutableData($bookingData->dateId); + + return $this->render('booking/edit/participant.html', [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'bookingData' => $bookingData, + 'mutableData' => $mutableData, + 'summaryData' => $summaryData, + 'refreshRouteName' => 'app_booking_edit_participant_refresh', + 'refreshRouteParams' => ['id' => $id, 'index' => $index], + 'cancelRouteName' => 'app_booking_edit', + 'cancelRouteParams' => ['id' => $id], + ]); + } + + /** + * HTMX endpoint for refreshing participant form without validation. + */ + #[Route( + path: '/bookings/{id}/edit/participants/{index}/refresh', + name: 'app_booking_edit_participant_refresh', + requirements: ['id' => '\d+', 'index' => '\d+'], + methods: ['POST'] + )] + #[IsGranted('ROLE_USER')] + public function refreshParticipantForm(int $id, int $index, Request $request): Response + { + /** @var User $user */ + $user = $this->getUser(); + + $bookingDto = $this->participantFormService->loadBookingDto($request); + + if (null === $bookingDto) { + return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); + } + + $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); + $this->participantFormService->enrichTravelData($bookingDto); + + $bookingData = $this->participantFormService->fetchBookingData($id, $user); + $mutableData = null !== $bookingData && !($bookingData instanceof Notification) + ? $this->participantFormService->getMutableData($bookingData->dateId) + : null; + + $wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index); + + $form = $this->createForm( + BookingParticipantType::class, + $wrapper, + $this->participantFormSupportService->getParticipantFormOptions($bookingDto, true) + ); + + $form->handleRequest($request); + + $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); + $summaryData = $this->participantFormService->getSummaryData($bookingDto); + + $response = $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form, + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'bookingData' => $bookingData, + 'mutableData' => $mutableData, + 'summaryData' => $summaryData, + 'refreshRouteName' => 'app_booking_edit_participant_refresh', + 'refreshRouteParams' => ['id' => $id, 'index' => $index], + 'cancelRouteName' => 'app_booking_edit', + 'cancelRouteParams' => ['id' => $id], + ] + ); + + if (false === empty($notifications)) { + $response->headers->set('HX-Trigger', json_encode([ + 'showNotifications' => $notifications, + ])); + } + + return $response; + } + + /** + * @param array $notifications + */ + private function addNotificationsAsFlashMessages(array $notifications): void + { + foreach ($notifications as $notification) { + $this->addFlash($notification['type'], $notification['message']); + } + } +} diff --git a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php deleted file mode 100644 index 203624f..0000000 --- a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php +++ /dev/null @@ -1,204 +0,0 @@ -bookingService->getBookingDto($request, $mode); - - if (null === $bookingDto) { - $this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.'); - - return $this->redirectToRoute('app_login'); - } - - return $bookingDto; - } - - /** - * Generate card data for all participants. - * - * @return array - */ - private function generateAllCardsData(BookingDto $bookingDto): array - { - return $this->participantCardService->getAllCardsData($bookingDto); - } - - /** - * Create form for single participant. - * - * This creates an autonomous participant form with booking_context option - * so it can process field handlers independently. - */ - private function createParticipantForm( - BookingDto $bookingDto, - int $index, - array $options = [], - ): FormInterface { - $participant = $bookingDto->participants[$index] ?? null; - - if (null === $participant) { - throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index)); - } - - // Create wrapper DTO for email uniqueness validation - $wrapper = new ParticipantEditDto( - participant: $participant, - bookingContext: $bookingDto, - ); - - // Merge default options with provided options and body dimensions - $formOptions = array_merge([ - 'booking_context' => $bookingDto, - 'height_choices' => $this->getParameter('body_dimensions.height_choices'), - 'weight_choices' => $this->getParameter('body_dimensions.weight_choices'), - 'shoe_size_min' => $this->getParameter('body_dimensions.shoe_size_min'), - 'shoe_size_max' => $this->getParameter('body_dimensions.shoe_size_max'), - ], $options); - - return $this->createForm(BookingParticipantType::class, $wrapper, $formOptions); - } - - /** - * Collects notifications from all participants and clears them from DTOs. - * - * This is important for scenarios where field handlers add notifications - * during form processing (e.g., voucher validation, auto-unassignment). - * Notifications from ALL participants are collected, not just the current one, - * because cross-participant logic may add notifications to multiple participants. - * - * Note: ParticipantDto stores notifications with MD5 keys to prevent duplicates, - * but the JavaScript toast controller expects a simple indexed array. We use - * array_values() to convert the associative array to an indexed array. - * - * @return array Array of notification messages - */ - private function collectAndClearNotifications(BookingDto $bookingDto): array - { - $notifications = []; - foreach ($bookingDto->participants as $participant) { - if (false === empty($participant->notifications)) { - $notifications = array_merge($notifications, $participant->notifications); - $participant->notifications = []; - } - } - - // Convert MD5-keyed associative array to simple indexed array - return array_values($notifications); - } - - /** - * Process single participant form refresh. - * - * Handles HTMX form refresh without validation, updates sidebar via OOB swap. - */ - private function handleParticipantRefresh( - Request $request, - BookingDto $bookingDto, - int $index, - string $refreshRouteName, - ): Response { - $refreshFormOptions = ['validation_groups' => false]; - - // Create form with validation disabled - $form = $this->createParticipantForm($bookingDto, $index, $refreshFormOptions); - - $form->handleRequest($request); - - // Refresh endpoint is POST-only and always processes submitted participant data. - $this->bookingService->preselectDefaultServices($bookingDto); - if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { - $this->bookingService->applyCreateBookingStatusRules($bookingDto); - } - - // Recreate form so the rendered state reflects any new auto-preselections. - $form = $this->createParticipantForm($bookingDto, $index, $refreshFormOptions); - - // Collect notifications from field handlers - $notifications = $this->collectAndClearNotifications($bookingDto); - - // Save updated booking data to session (after collecting & clearing notifications) - $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); - - // Get complete summary data (pricing, rooms, CMS data) - $summaryData = $this->summaryDataService->getSummaryData($bookingDto); - - // Render form and sidebar with OOB swap using htmxOobResponse - // This renders ONLY the specific blocks, not the entire template - $response = $this->htmxOobResponse( - 'booking/_participant_form.html.twig', - ['participant_form', 'booking_summary'], - [ - 'form' => $form->createView(), - 'participantIndex' => $index, - 'bookingDto' => $bookingDto, - 'summaryData' => $summaryData, - 'refreshRouteName' => $refreshRouteName, - ] - ); - - // Add notifications to HX-Trigger header if present - if (false === empty($notifications)) { - $response->headers->set('HX-Trigger', json_encode([ - 'showNotifications' => $notifications, - ])); - } - - return $response; - } - - /** - * Converts collected notifications to flash messages. - * - * Used when redirecting after form submission, as HTMX-triggered toasts - * are destroyed on redirect. Flash messages persist across the redirect. - * - * @param array $notifications The notifications to convert - */ - private function addNotificationsAsFlashMessages(array $notifications): void - { - foreach ($notifications as $notification) { - $this->addFlash($notification['type'], $notification['message']); - } - } - - /** - * Required services - implementing controllers must inject these. - * - * Controllers using this trait must have the following properties: - * - BookingService $bookingService - * - ParticipantCardDataService $participantCardService - * - BookingSummaryDataService $summaryDataService - */ - abstract private function createForm(string $type, $data = null, array $options = []): FormInterface; - - abstract private function render(string $view, array $parameters = [], ?Response $response = null): Response; - - abstract protected function addFlash(string $type, mixed $message): void; - - abstract protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse; -} diff --git a/src/Service/BookingEditParticipantFormService.php b/src/Service/BookingEditParticipantFormService.php new file mode 100644 index 0000000..6fb7790 --- /dev/null +++ b/src/Service/BookingEditParticipantFormService.php @@ -0,0 +1,72 @@ +bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + } + + public function fetchBookingData(int $bookingId, User $user): Booking|Notification|null + { + return $this->dataLoader->fetchBookingData($bookingId, $user); + } + + public function isParticipantCanceled(Booking $bookingData, int $index): bool + { + return 'S' === ($bookingData->participantsStatus[$index] ?? null); + } + + public function enrichTravelData(BookingDto $bookingDto): void + { + $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); + } + + public function saveBookingDto(Request $request, BookingDto $bookingDto): void + { + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + } + + public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void + { + $this->draftService->saveDraft($user, $bookingId, $bookingDto); + } + + public function getSummaryData(BookingDto $bookingDto): BookingSummaryDto + { + return $this->summaryDataService->getSummaryData($bookingDto); + } + + public function getMutableData(int $dateId): ?BaseData + { + return $this->travelDataService->getMutabilityData($dateId); + } +} diff --git a/src/Service/ParticipantFormSupportService.php b/src/Service/ParticipantFormSupportService.php new file mode 100644 index 0000000..475c144 --- /dev/null +++ b/src/Service/ParticipantFormSupportService.php @@ -0,0 +1,79 @@ +participants[$index] ?? null; + + if (null === $participant) { + throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index)); + } + + return $participant; + } + + public function createParticipantEditDto(BookingDto $bookingDto, int $index): ParticipantEditDto + { + return new ParticipantEditDto( + participant: $this->ensureParticipantExists($bookingDto, $index), + bookingContext: $bookingDto, + ); + } + + /** + * @return array + */ + public function getParticipantFormOptions(BookingDto $bookingDto, bool $disableValidation = false): array + { + $options = [ + 'booking_context' => $bookingDto, + 'height_choices' => $this->parameterBag->get('body_dimensions.height_choices'), + 'weight_choices' => $this->parameterBag->get('body_dimensions.weight_choices'), + 'shoe_size_min' => $this->parameterBag->get('body_dimensions.shoe_size_min'), + 'shoe_size_max' => $this->parameterBag->get('body_dimensions.shoe_size_max'), + ]; + + if (true === $disableValidation) { + $options['validation_groups'] = false; + } + + return $options; + } + + /** + * Collects participant notifications and clears them from the DTO. + * + * @return array + */ + public function collectAndClearNotifications(BookingDto $bookingDto): array + { + $notifications = []; + + foreach ($bookingDto->participants as $participant) { + if (false === empty($participant->notifications)) { + $notifications = array_merge($notifications, $participant->notifications); + $participant->notifications = []; + } + } + + return array_values($notifications); + } +} diff --git a/tests/Controller/Booking/ParticipantCardFlowTraitTest.php b/tests/Controller/Booking/ParticipantCardFlowTraitTest.php deleted file mode 100644 index a4254a8..0000000 --- a/tests/Controller/Booking/ParticipantCardFlowTraitTest.php +++ /dev/null @@ -1,147 +0,0 @@ -createMock(BookingService::class); - $summaryDataService = $this->createMock(BookingSummaryDataService::class); - - $formOne = $this->createMock(FormInterface::class); - $formOne->expects($this->once()) - ->method('handleRequest'); - $formOne->method('isSubmitted') - ->willReturn(true); - - $formTwo = $this->createMock(FormInterface::class); - $formTwo->expects($this->once()) - ->method('createView') - ->willReturn(new FormView()); - - $bookingDto = $this->createEditModeBookingDto(); - $request = new Request(); - - $bookingService->expects($this->once()) - ->method('preselectDefaultServices') - ->with($bookingDto); - - $bookingService->expects($this->once()) - ->method('saveBookingDto') - ->with($request, $bookingDto, BookingDto::MODE_EDIT); - - $summaryDataService->expects($this->once()) - ->method('getSummaryData') - ->with($bookingDto) - ->willReturn(new BookingSummaryDto([], 1, 0.0, 0.0, [], [], [], null)); - - $controller = new class($bookingService, $summaryDataService, [$formOne, $formTwo]) { - use ParticipantCardFlowTrait; - - public BookingService $bookingService; - public BookingSummaryDataService $summaryDataService; - public object $participantCardService; - - /** @var FormInterface[] */ - private array $forms; - - public function __construct(BookingService $bookingService, BookingSummaryDataService $summaryDataService, array $forms) - { - $this->bookingService = $bookingService; - $this->summaryDataService = $summaryDataService; - $this->forms = $forms; - $this->participantCardService = new class() { - public function getAllCardsData(BookingDto $bookingDto): array - { - return []; - } - }; - } - - public function callHandleParticipantRefresh( - Request $request, - BookingDto $bookingDto, - int $index, - string $refreshRouteName, - ): Response { - $method = new \ReflectionMethod($this, 'handleParticipantRefresh'); - $method->setAccessible(true); - - return $method->invoke($this, $request, $bookingDto, $index, $refreshRouteName); - } - - private function htmxOobResponse(string $template, array $blocks, array $parameters): Response - { - return new Response('ok'); - } - - protected function getParameter(string $name): mixed - { - return []; - } - - private function createForm(string $type, $data = null, array $options = []): FormInterface - { - return array_shift($this->forms); - } - - private function render(string $view, array $parameters = [], ?Response $response = null): Response - { - return new Response('rendered'); - } - - protected function addFlash(string $type, mixed $message): void - { - } - - protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse - { - return new RedirectResponse('/'); - } - }; - - $response = $controller->callHandleParticipantRefresh( - $request, - $bookingDto, - 0, - 'app_booking_edit_step_2_participant_refresh' - ); - - $this->assertSame('ok', $response->getContent()); - } - - private function createEditModeBookingDto(): BookingDto - { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2030-01-01'); - $travel->dateTo = new \DateTimeImmutable('2030-01-06'); - - $bookingDto = new BookingDto($travel, 157047); - $bookingDto->booking = new Booking(); // Marks DTO as edit mode - - $participant = new ParticipantDto(); - $participant->index = 0; - $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); - $bookingDto->participants[0] = $participant; - - return $bookingDto; - } -} diff --git a/tests/Service/BookingEditParticipantFormServiceTest.php b/tests/Service/BookingEditParticipantFormServiceTest.php new file mode 100644 index 0000000..35ada5f --- /dev/null +++ b/tests/Service/BookingEditParticipantFormServiceTest.php @@ -0,0 +1,94 @@ +createBookingDto(); + $bookingService = $this->createMock(BookingService::class); + $bookingService->expects($this->once()) + ->method('getBookingDto') + ->with($request, BookingDto::MODE_EDIT) + ->willReturn($bookingDto); + + $service = $this->createService(bookingService: $bookingService); + + $this->assertSame($bookingDto, $service->loadBookingDto($request)); + } + + public function testIsParticipantCanceledUsesBookingStatus(): void + { + $service = $this->createService(); + $booking = new Booking(); + $booking->participantsStatus = [0 => 'S', 1 => 'A']; + + $this->assertTrue($service->isParticipantCanceled($booking, 0)); + $this->assertFalse($service->isParticipantCanceled($booking, 1)); + } + + public function testGetMutableDataDelegatesToTravelDataService(): void + { + $mutableData = new BaseData([]); + $travelDataService = $this->createMock(TravelDataService::class); + $travelDataService->expects($this->once()) + ->method('getMutabilityData') + ->with(1234) + ->willReturn($mutableData); + + $service = new BookingEditParticipantFormService( + $this->createMock(BookingEditDataLoaderService::class), + $this->createMock(BookingEditDraftService::class), + $this->createMock(BookingService::class), + $this->createMock(BookingSummaryDataService::class), + $travelDataService, + ); + + $this->assertSame($mutableData, $service->getMutableData(1234)); + } + + private function createService( + ?BookingEditDataLoaderService $dataLoader = null, + ?BookingEditDraftService $draftService = null, + ?BookingService $bookingService = null, + ?BookingSummaryDataService $summaryDataService = null, + ?TravelDataService $travelDataService = null, + ): BookingEditParticipantFormService { + return new BookingEditParticipantFormService( + $dataLoader ?? $this->createMock(BookingEditDataLoaderService::class), + $draftService ?? $this->createMock(BookingEditDraftService::class), + $bookingService ?? $this->createMock(BookingService::class), + $summaryDataService ?? $this->createMock(BookingSummaryDataService::class), + $travelDataService ?? $this->createMock(TravelDataService::class), + ); + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2030-01-01'); + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $bookingDto = new BookingDto($travel, 157047); + $bookingDto->booking = new Booking(); + + return $bookingDto; + } +} diff --git a/tests/Service/ParticipantFormSupportServiceTest.php b/tests/Service/ParticipantFormSupportServiceTest.php new file mode 100644 index 0000000..70f8c7e --- /dev/null +++ b/tests/Service/ParticipantFormSupportServiceTest.php @@ -0,0 +1,114 @@ +createService(); + $bookingDto = $this->createBookingDto(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Participant at index 2 does not exist'); + + $service->ensureParticipantExists($bookingDto, 2); + } + + public function testCreateParticipantEditDtoWrapsParticipant(): void + { + $service = $this->createService(); + $bookingDto = $this->createBookingDto(); + + $wrapper = $service->createParticipantEditDto($bookingDto, 0); + + $this->assertSame($bookingDto, $wrapper->bookingContext); + $this->assertSame($bookingDto->participants[0], $wrapper->participant); + } + + public function testCollectAndClearNotificationsFlattensAndClearsNotifications(): void + { + $service = $this->createService(); + $bookingDto = $this->createBookingDto(); + + $bookingDto->participants[0]->notifications = [ + ['type' => 'info', 'message' => 'First'], + ]; + $bookingDto->participants[1]->notifications = [ + ['type' => 'warning', 'message' => 'Second'], + ]; + + $notifications = $service->collectAndClearNotifications($bookingDto); + + $this->assertSame([ + ['type' => 'info', 'message' => 'First'], + ['type' => 'warning', 'message' => 'Second'], + ], $notifications); + $this->assertSame([], $bookingDto->participants[0]->notifications); + $this->assertSame([], $bookingDto->participants[1]->notifications); + } + + public function testGetParticipantFormOptionsIncludesValidationToggle(): void + { + $parameterBag = $this->createMock(ParameterBagInterface::class); + $parameterBag->expects($this->exactly(4)) + ->method('get') + ->willReturnMap([ + ['body_dimensions.height_choices', ['bis 148cm' => '-148']], + ['body_dimensions.weight_choices', ['42 - 48kg' => '42-48']], + ['body_dimensions.shoe_size_min', 36], + ['body_dimensions.shoe_size_max', 48], + ]); + + $service = $this->createService(parameterBag: $parameterBag); + $bookingDto = $this->createBookingDto(); + + $options = $service->getParticipantFormOptions($bookingDto, true); + + $this->assertSame($bookingDto, $options['booking_context']); + $this->assertSame(['bis 148cm' => '-148'], $options['height_choices']); + $this->assertSame(['42 - 48kg' => '42-48'], $options['weight_choices']); + $this->assertSame(36, $options['shoe_size_min']); + $this->assertSame(48, $options['shoe_size_max']); + $this->assertFalse($options['validation_groups']); + } + + private function createService(?ParameterBagInterface $parameterBag = null): ParticipantFormSupportService + { + $parameterBag ??= $this->createMock(ParameterBagInterface::class); + + return new ParticipantFormSupportService($parameterBag); + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2030-01-01'); + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $bookingDto = new BookingDto($travel, 157047); + $bookingDto->booking = new Booking(); + + $participantOne = new ParticipantDto(); + $participantOne->index = 0; + $participantOne->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $bookingDto->participants[0] = $participantOne; + + $participantTwo = new ParticipantDto(); + $participantTwo->index = 1; + $participantTwo->dateOfBirth = new \DateTimeImmutable('1995-01-01'); + $bookingDto->participants[1] = $participantTwo; + + return $bookingDto; + } +}