feat: htmx powered requests, improved loading indicator

feat: loading indicator and htmx form submit
This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 023ecdebc9
commit 83589ee677
20 changed files with 658 additions and 197 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Booking;
use App\Form\BookingCreateStep1Type;
use App\Htmx\HxTrait;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -21,6 +22,7 @@ class CreateStep1Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
private readonly BookingService $bookingService,
@@ -50,6 +52,12 @@ class CreateStep1Controller extends AbstractController
}
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
'attr' => [
'hx-post' => $this->generateUrl('app_booking_create_step_1'),
'hx-target' => '#form-wrapper',
'hx-select' => '#form-wrapper',
'hx-swap' => 'outerHTML',
],
'validation_groups' => ['booking_create_step_1'],
]);
@@ -66,7 +74,7 @@ class CreateStep1Controller extends AbstractController
// Clear baseline snapshot when moving to step 2
$this->bookingService->clearBaselineSnapshot($request);
return $this->redirectToRoute('app_booking_create_step_2');
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
}
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
@@ -85,33 +93,44 @@ class CreateStep1Controller extends AbstractController
}
/**
* HTMX endpoint for live summary updates in step 1.
* Handles HTMX requests for dynamic form updates when room selections change by submitting the form
* without validation and returning freshly rendered blocks.
*/
#[Route('/bookings/create/room-summary', name: 'app_booking_create_step_1_room_summary', methods: ['POST'])]
public function roomSummary(Request $request): Response
#[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])]
public function refreshRoomSelection(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
$result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Process the form to update the DTO with the latest room selection
// Process form data without validation to capture current state
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
'validation_groups' => false,
]);
$form->handleRequest($request);
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedRooms = $this->bookingService->groupRoomsBySelectionType($availableRooms);
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms);
return $this->render('booking/_summary.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
'roomSummary' => $summary['selectedRooms'],
'participantCount' => $summary['participantCount'],
'pricingData' => $summary['pricing'],
'groupedSelectedRooms' => $groupedSelectedRooms,
]);
// The DTO is now updated with the latest selection.
// We can now render the blocks with the fresh data.
return $this->htmxOobResponse(
'booking/create_step_1.html.twig',
['room_selection_form', 'booking_summary'],
[
'form' => $form->createView(),
'bookingCreateDto' => $bookingCreateDto,
'participantCount' => $summary['participantCount'],
'pricingData' => $summary['pricing'],
'groupedRooms' => $groupedRooms,
'groupedSelectedRooms' => $groupedSelectedRooms,
]
);
}
}
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
use App\Service\RoomAssignmentService;
@@ -27,7 +27,7 @@ class CreateStep2Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HtmxControllerTrait;
use HxTrait;
public function __construct(
private readonly BookingService $bookingService,
@@ -70,7 +70,13 @@ class CreateStep2Controller extends AbstractController
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [
'attr' => ['novalidate' => 'novalidate'],
'attr' => [
'novalidate' => 'novalidate',
'hx-post' => $this->generateUrl('app_booking_create_step_2'),
'hx-target' => '#form-wrapper',
'hx-select' => '#form-wrapper',
'hx-swap' => 'outerHTML',
],
'validation_groups' => ['booking_create_step_2'],
]);
@@ -80,7 +86,7 @@ class CreateStep2Controller extends AbstractController
$bookingCreateDto->currentStep = 3;
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
return $this->redirectToRoute('app_booking_create_step_3');
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3'));
}
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
@@ -6,8 +6,8 @@ namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Notification;
use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep3Type;
use App\Htmx\HxTrait;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
use Psr\Log\LoggerInterface;
@@ -23,7 +23,7 @@ class CreateStep3Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HtmxControllerTrait;
use HxTrait;
public function __construct(
private readonly BookingService $bookingService,
@@ -50,7 +50,14 @@ class CreateStep3Controller extends AbstractController
return $redirect;
}
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto);
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [
'attr' => [
'hx-post' => $this->generateUrl('app_booking_create_step_3'),
'hx-target' => '#form-wrapper',
'hx-select' => '#form-wrapper',
'hx-swap' => 'outerHTML',
],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -107,7 +114,7 @@ class CreateStep3Controller extends AbstractController
$bookingCreateDto->currentStep = 4;
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->redirectToRoute('app_booking_create_step_4');
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4'));
} catch (\Exception $e) {
$this->logger->error('Booking inquiry exception', [
'exception' => $e->getMessage(),
@@ -6,8 +6,8 @@ namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Notification;
use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep4Type;
use App\Htmx\HxTrait;
use App\Service\BookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -22,7 +22,7 @@ class CreateStep4Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HtmxControllerTrait;
use HxTrait;
public function __construct(
private readonly BookingService $bookingService,
@@ -48,7 +48,14 @@ class CreateStep4Controller extends AbstractController
return $redirect;
}
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto);
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'attr' => [
'hx-post' => $this->generateUrl('app_booking_create_step_4'),
'hx-target' => '#form-wrapper',
'hx-select' => '#form-wrapper',
'hx-swap' => 'outerHTML',
],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -80,7 +87,7 @@ class CreateStep4Controller extends AbstractController
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
$this->bookingService->clearBookingCreateDto($request);
return $this->redirectToRoute('app_booking_success');
return $this->hxRedirect($request, $this->generateUrl('app_booking_success'));
} catch (\Exception $e) {
$this->logger->error('Booking creation failed', [
'exception' => $e->getMessage(),
+147 -64
View File
@@ -8,10 +8,10 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\XmlLoader\PickupLoader;
use App\Controller\Traits\BookingDataTrait;
use App\Controller\Traits\HtmxControllerTrait;
use App\Entity\User;
use App\Form\BookingEditType;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
use App\Security\Crypt;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
@@ -29,7 +29,7 @@ use Symfony\Contracts\Cache\CacheInterface;
class EditController extends AbstractController
{
use BookingDataTrait;
use HtmxControllerTrait;
use HxTrait;
public function __construct(
private readonly ApiClient $apiClient,
@@ -54,42 +54,22 @@ class EditController extends AbstractController
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
// Fetch original booking data via API and cache result for a short ttl
$bookingData = $this->fetchBookingData($email, $password, $id);
// Load form data from session (or API on first load)
$formData = $this->loadFormData($request, $id, $email, $password);
if (null === $formData) {
return $this->redirectToRoute('app_bookings');
}
// Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->fetchBookingData($email, $password, $id);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
return $this->redirectToRoute('app_bookings');
}
$this->denyAccessUnlessGranted('EDIT', $bookingData);
// Load according travel data
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
if (null === $travelData) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return $this->redirectToRoute('app_bookings');
}
// Fetch mutability and availability information via service
// Fetch mutable data for form constraints
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
$availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId);
if (null === $mutableData || null === $availabilities) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return $this->redirectToRoute('app_bookings');
}
// Patch travel data with additional information from above
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create DTO for form
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
// Calculate pricing data for template
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData);
@@ -104,7 +84,13 @@ class EditController extends AbstractController
);
$form = $this->createForm(BookingEditType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
'attr' => [
'novalidate' => 'novalidate',
'hx-post' => $this->generateUrl('app_booking_edit', ['id' => $id]),
'hx-target' => '#form-wrapper',
'hx-select' => '#form-wrapper',
'hx-swap' => 'outerHTML',
],
'validation_groups' => ['booking_edit'],
]);
@@ -136,6 +122,9 @@ class EditController extends AbstractController
} catch (InvalidArgumentException $e) {
}
// Clear session on successful save
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->addFlash('success', 'Buchung erfolgreich aktualisiert');
$this->logger->info('Booking update successful', [
@@ -143,11 +132,13 @@ class EditController extends AbstractController
'booking_id' => $id,
]);
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
}
} catch (ApiClientException $e) {
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
}
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
}
return $this->render('booking/edit.html.twig', [
@@ -163,6 +154,21 @@ class EditController extends AbstractController
]);
}
/**
* Reloads booking data from API, discarding all session changes.
*/
#[Route('/bookings/{id}/edit/reload', name: 'app_booking_edit_reload', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function reloadFromApi(int $id, Request $request): Response
{
// Clear session to discard all changes
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->addFlash('success', 'Änderungen verworfen, Daten neu geladen');
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
}
/**
* HTMX endpoint for refreshing the participant form without validation.
*/
@@ -170,43 +176,20 @@ class EditController extends AbstractController
#[IsGranted('ROLE_USER')]
public function refreshParticipantForm(int $id, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
// Load form data from session
$formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
// Fetch original booking data via API and cache result for a short ttl
$bookingData = $this->fetchBookingData($email, $password, $id);
if (null === $bookingData || $bookingData instanceof Notification) {
return new Response('Buchungsdaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
if (null === $formData) {
return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST);
}
$this->denyAccessUnlessGranted('EDIT', $bookingData);
// Load travel data
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
if (null === $travelData) {
return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
// Refresh availability data
$availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id);
if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($formData->travel, $availabilities);
}
// Fetch mutability and availability information
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
$availabilities = $this->travelDataService->getAvailabilityDataCached($bookingData->dateId);
if (null === $mutableData || null === $availabilities) {
return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
}
// Patch travel data
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create fresh DTO from booking data
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
// Process form data without validation to capture current state
// Process form without validation to capture current state
$form = $this->createForm(BookingEditType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
'validation_groups' => false,
@@ -214,9 +197,23 @@ class EditController extends AbstractController
$form->handleRequest($request);
// Save updated DTO back to session
$this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
// Collect notifications from all participants
$notifications = $this->collectParticipantNotifications($formData);
// Fetch booking data and mutable data for display
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
$bookingData = $this->fetchBookingData($email, $password, $id);
$mutableData = null !== $bookingData && !($bookingData instanceof Notification)
? $this->travelDataService->getMutabilityData($bookingData->dateId)
: null;
// Calculate pricing and summary data
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData);
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($formData);
@@ -277,4 +274,90 @@ class EditController extends AbstractController
return $notifications;
}
/**
* Loads form data from session or initializes from API on first load.
*
* @return BookingDto|null The form data, or null on error
*/
private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto
{
// Try to load from session first
$formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
if (null === $formData) {
// First load: initialize from API
return $this->initializeFromApi($request, $bookingId, $email, $password);
}
// Subsequent load: refresh from session with staleness check
return $this->refreshFromSession($formData);
}
/**
* Initializes form data from API on first load and stores in session.
*
* @return BookingDto|null The form data, or null on error
*/
private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto
{
$bookingData = $this->fetchBookingData($email, $password, $bookingId);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
return null;
}
$this->denyAccessUnlessGranted('EDIT', $bookingData);
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
if (null === $travelData) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return null;
}
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
$availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId);
if (null === $mutableData || null === $availabilities) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return null;
}
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
$this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
return $formData;
}
/**
* Refreshes form data loaded from session with latest availability.
*
* @return BookingDto The refreshed form data
*/
private function refreshFromSession(BookingDto $formData): BookingDto
{
// Refresh availability data
$availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id);
if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($formData->travel, $availabilities);
}
// Show staleness warning if session is older than 5 minutes
if (null !== $formData->lastSessionUpdate) {
$ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp();
if ($ageInSeconds > 300) {
$minutes = (int) ceil($ageInSeconds / 60);
$this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes));
}
}
return $formData;
}
}