feat: htmx powered requests, improved loading indicator

feat: loading indicator and htmx form submit
This commit is contained in:
Björn Fromme
2025-10-14 18:07:59 +02:00
parent 4939a1a9ad
commit 9eda12981a
20 changed files with 658 additions and 197 deletions
+73 -4
View File
@@ -1,12 +1,81 @@
import { Controller} from '@hotwired/stimulus'
import {Controller} from '@hotwired/stimulus'
export default class extends Controller {
static targets = [ 'indicator' ]
static classes = [ 'hidden' ]
static targets = ['indicator']
static classes = ['hidden']
connect() {
this.isVisible = false
this.debounceTimeout = null
// Bind event handlers to preserve context
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
// Listen to HTMX events
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
}
disconnect() {
// Clean up event listeners
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
// Clear any pending timeout
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
}
handleBeforeRequest(event) {
// Don't start a new debounce if already visible
if (this.isVisible) {
return
}
// Show indicator after 200ms delay for field refreshes
this.debounceTimeout = setTimeout(() => {
this.show()
}, 200)
}
handleAfterRequest(event) {
// Clear debounce timer if request completes before 200ms
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
this.debounceTimeout = null
}
// Check if response contains HX-Redirect header
const xhr = event.detail.xhr
const hxRedirect = xhr.getResponseHeader('HX-Redirect')
// Keep loading indicator visible if redirecting
if (hxRedirect) {
// Ensure indicator is visible during redirect
if (!this.isVisible) {
this.show()
}
return
}
// Hide indicator if it's visible
this.hide()
}
show() {
this.isVisible = true
this.indicatorTarget.classList.remove(this.hiddenClass)
}
hide() {
this.isVisible = false
this.indicatorTarget.classList.add(this.hiddenClass)
}
toggle() {
this.indicatorTarget.classList.remove(this.hiddenClass)
this.element.scrollIntoView({ block: 'start', behavior: 'smooth'})
}
}
@@ -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;
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Traits;
use Symfony\Component\HttpFoundation\Response;
/**
* Provides helper methods for handling HTMX responses.
*
* This trait should be used in controllers that extend Symfony's AbstractController,
* as it relies on the renderBlockView() method.
*/
trait HtmxControllerTrait
{
/**
* Renders multiple Twig blocks for an HTMX Out-of-Band swap response.
*
* @param string $templateName the name of the Twig template
* @param string[] $blockNames an array of block names to render
* @param array<string, mixed> $context the context to pass to the template
*/
protected function htmxOobResponse(string $templateName, array $blockNames, array $context = []): Response
{
$html = '';
// Add a flag to the context so templates can conditionally add the hx-swap-oob attribute.
$oobContext = $context + ['htmx_oob_swap' => true];
foreach ($blockNames as $blockName) {
// Use renderBlockView() to get the raw HTML string for each block.
$html .= $this->renderBlockView($templateName, $blockName, $oobContext);
}
return new Response($html);
}
}
+6
View File
@@ -58,6 +58,12 @@ class BookingDto
*/
public ?Booking $booking = null;
/**
* Timestamp of last session update (for staleness detection).
* Updated automatically by BookingService::saveBookingDto().
*/
public ?\DateTimeImmutable $lastSessionUpdate = null;
public function __construct(public Travel $travel, public int $hotelId)
{
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxRedirectResponse extends Response
{
/**
* Creates an HTMX redirect response.
*
* Constructs a response that instructs the HTMX client to navigate to the
* specified URL. The response includes the HX-Redirect header with the target
* URL. Optionally includes HX-Retarget header if a different target element
* is specified for the response content.
*
* @param string $url The URL to redirect to
* @param string|null $retarget Optional CSS selector for the target element
*/
public function __construct(string $url, ?string $retarget = null)
{
$headers = [
'HX-Redirect' => $url,
];
if (null !== $retarget) {
$headers['HX-Retarget'] = $retarget;
}
return parent::__construct(null, Response::HTTP_OK, $headers);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxRefreshResponse extends Response
{
/**
* Creates an HTMX refresh response.
*
* Constructs a response that instructs the HTMX client to perform a full page
* refresh. The response includes the HX-Refresh header set to 'true', which
* signals the HTMX client to reload the entire page. This response typically
* contains no content since the page will be completely reloaded.
*/
public function __construct()
{
return parent::__construct(null, Response::HTTP_OK, ['HX-Refresh' => 'true']);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxStopPollingResponse extends Response
{
/**
* Creates an HTMX stop polling response.
*
* Constructs a response that instructs the HTMX client to stop polling. The response
* includes the specified content and uses HTTP status code 286, which is recognized
* by HTMX as a signal to terminate polling. This allows the server to control
* client-side polling behavior and prevent unnecessary network requests.
*
* @param string $content The response content to send to the client
*/
public function __construct(string $content)
{
return parent::__construct($content, 286);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Trait providing HTMX-specific functionality for controllers.
*
* This trait provides methods to handle HTMX requests and responses, including
* conditional rendering based on HTMX headers, HTMX-specific redirects, and
* multi-block rendering capabilities. It simplifies the integration of HTMX
* functionality into Symfony controllers by providing common patterns for
* detecting HTMX requests and generating appropriate responses.
*/
trait HxTrait
{
/**
* Determines if the current request is an HTMX request.
*
* Checks the HX-Request header to identify if the request was made via HTMX.
* This allows controllers to provide different responses for HTMX vs regular
* HTTP requests, enabling progressive enhancement patterns.
*
* @param Request $request The current HTTP request
*
* @return bool True if the request is an HTMX request, false otherwise
*/
public function isHxRequest(Request $request): bool
{
return 'true' === $request->headers->get('HX-Request');
}
/**
* Renders a template with HTMX-aware block selection.
*
* Renders either a specific template block or the full template based on
* whether the request is an HTMX request. When an HTMX request is detected
* and a block is specified, only that block is rendered. Otherwise, the
* full template is rendered. This enables efficient partial page updates
* for HTMX requests while maintaining full page rendering for regular requests.
*
* @param Request $request The current HTTP request
* @param string $template The template name to render
* @param array $parameters Template parameters to pass to the view
* @param string|null $block The specific block to render for HTMX requests
*
* @return Response The rendered response
*/
public function hxRender(
Request $request,
string $template,
array $parameters = [],
?string $block = null,
): Response {
if (null !== $block && true === $this->isHxRequest($request)) {
return $this->renderBlock($template, $block, $parameters);
}
return $this->render($template, $parameters);
}
/**
* Performs an HTMX-aware redirect.
*
* Returns an HTMX-specific redirect response when the request is an HTMX
* request, or a standard redirect response for regular HTTP requests.
* HTMX redirects are handled differently by the client, allowing for
* smoother user experiences in single-page application contexts.
*
* @param Request $request The current HTTP request
* @param string $url The URL to redirect to
*
* @return Response Either an HxRedirectResponse or RedirectResponse
*/
public function hxRedirect(Request $request, string $url): Response
{
if (true === $this->isHxRequest($request)) {
return new HxRedirectResponse($url);
}
return new RedirectResponse($url);
}
/**
* Renders multiple template blocks in a single response.
*
* Combines multiple template blocks into a single response content. This is
* useful for HTMX requests that need to update multiple parts of the page
* simultaneously. Each block in the array should contain 'template', 'block',
* and 'parameters' keys to define what to render.
*
* @param array $blocks Array of block definitions, each containing:
* - 'template': The template name
* - 'block': The block name to render
* - 'parameters': Template parameters
*
* @return Response Response containing all rendered blocks concatenated
*/
public function hxRenderBlocks(array $blocks): Response
{
$content = '';
foreach ($blocks as $block) {
$content .= $this->renderBlockView($block['template'], $block['block'], $block['parameters']);
}
return new Response($content);
}
/**
* Renders multiple Twig blocks for an HTMX Out-of-Band swap response.
*
* This method is specifically designed for HTMX OOB (Out-of-Band) swaps,
* where multiple blocks need to be updated simultaneously. It adds an
* `htmx_oob_swap` flag to the template context, allowing templates to
* conditionally add the hx-swap-oob attribute to elements.
*
* @param string $templateName The name of the Twig template
* @param string[] $blockNames An array of block names to render
* @param array<string, mixed> $context The context to pass to the template
*
* @return Response Response containing all rendered blocks with OOB context
*/
protected function htmxOobResponse(string $templateName, array $blockNames, array $context = []): Response
{
$html = '';
// Add a flag to the context so templates can conditionally add the hx-swap-oob attribute.
$oobContext = $context + ['htmx_oob_swap' => true];
foreach ($blockNames as $blockName) {
// Use renderBlockView() to get the raw HTML string for each block.
$html .= $this->renderBlockView($templateName, $blockName, $oobContext);
}
return new Response($html);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxTriggerResponse extends Response
{
/**
* Creates an HTMX trigger response with custom event triggering capabilities.
*
* Constructs a response that will trigger a custom JavaScript event on the client
* when the HTMX request completes. The trigger can be a simple event name or a
* JSON object for more complex event data. Optionally disables content swapping
* to prevent the response content from being inserted into the DOM.
*
* @param string $content The response content (typically empty for trigger-only responses)
* @param string $trigger The event name or JSON object to trigger on the client
* @param bool $disableSwap Whether to disable HTMX content swapping (default: true)
*/
public function __construct(string $content, string $trigger, bool $disableSwap = true)
{
$headers = ['HX-Trigger' => $trigger];
if (true === $disableSwap) {
$headers['HX-Reswap'] = 'none';
}
return parent::__construct($content, Response::HTTP_OK, $headers);
}
}
+32 -12
View File
@@ -86,14 +86,17 @@ class BookingService
/**
* Saves the booking DTO to the session.
*
* @param Request $request The HTTP request with session
* @param object $bookingDto The booking DTO to persist
* @param string $mode The booking mode (create/edit)
* @param Request $request The HTTP request with session
* @param BookingDto $bookingDto The booking DTO to persist
* @param string $mode The booking mode (create/edit)
*/
public function saveBookingDto(Request $request, object $bookingDto, string $mode): void
public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
$request->getSession()->set($key, $bookingDto);
// Track last session update for staleness detection
$bookingDto->lastSessionUpdate = new \DateTimeImmutable();
$sessionKey = $this->getSessionKey($mode);
$request->getSession()->set($sessionKey, $bookingDto);
}
/**
@@ -102,13 +105,18 @@ class BookingService
* @param Request $request The HTTP request containing session data
* @param string $mode The booking mode (create/edit)
*
* @return object|null The booking DTO from session or null if not found
* @return BookingDto|null The booking DTO from session or null if not found
*/
public function getBookingDto(Request $request, string $mode): ?object
public function getBookingDto(Request $request, string $mode): ?BookingDto
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
$sessionKey = $this->getSessionKey($mode);
$session = $request->getSession();
return $request->getSession()->get($key);
if (false === $session->has($sessionKey)) {
return null;
}
return $session->get($sessionKey);
}
/**
@@ -119,8 +127,20 @@ class BookingService
*/
public function clearBookingDto(Request $request, string $mode): void
{
$key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY;
$request->getSession()->remove($key);
$sessionKey = $this->getSessionKey($mode);
$request->getSession()->remove($sessionKey);
}
/**
* Generates session key based on mode.
*
* @param string $mode 'create' or 'edit'
*
* @return string The session key
*/
private function getSessionKey(string $mode): string
{
return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY;
}
/**
@@ -1,4 +1,4 @@
<div class="invisible fixed top-0 left-0 inset-0 z-10 bg-white/90 flex items-center justify-center"
<div class="invisible fixed top-0 left-0 inset-0 z-10 bg-white/90 flex items-center justify-center transition-all duration-100"
{{ stimulus_target('loading', 'indicator') }}>
{% include '_partials/_spinner.html.twig' with { 'class': 'w-32 h-32 lg:w-48 lg:h-48'} %}
</div>
+48 -42
View File
@@ -17,56 +17,62 @@
</div>
{% endif %}
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<div id="form-wrapper" class="col-span-2">
<h2 class="pb-4">
Unterkunft
</h2>
{{ form_start(form) }}
{{ form_errors(form) }}
{% if groupedRooms.by_room is not empty %}
<h3>
Zimmer
</h3>
{% for roomId, room in groupedRooms.by_room %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_room_summary'),
'hx-target': '#booking-summary',
'hx-swap': 'innerHTML',
'hx-trigger': 'change'
}
}) }}
{% endfor %}
{% endif %}
{% if groupedRooms.by_pax is not empty %}
<h3>
Betten
</h3>
{% for roomId, room in groupedRooms.by_pax %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_room_summary'),
'hx-target': '#booking-summary',
'hx-swap': 'innerHTML',
'hx-trigger': 'change'
}
}) }}
{% endfor %}
{% endif %}
{% do form.roomSelections.setRendered %}
{# This block contains the room selection form fields #}
{% block room_selection_form %}
<div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{{ form_errors(form) }}
{% if groupedRooms.by_room is not empty %}
<h3>
Zimmer
</h3>
{% for roomId, room in groupedRooms.by_room %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change'
}
}) }}
{% endfor %}
{% endif %}
{% if groupedRooms.by_pax is not empty %}
<h3>
Betten
</h3>
{% for roomId, room in groupedRooms.by_pax %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change'
}
}) }}
{% endfor %}
{% endif %}
</div>
{% endblock %}
<div class="flex justify-end pt-4">
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'participantCount': participantCount,
'pricingData': pricingData,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': []
} %}
</div>
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'participantCount': participantCount,
'pricingData': pricingData,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': []
} %}
</div>
{% endblock %}
</div>
{% endblock %}
+2 -2
View File
@@ -55,7 +55,7 @@
<div {{ stimulus_controller('toast') }}></div>
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<div id="form-wrapper" class="col-span-2">
<h2>Teilnehmer</h2>
{{ form_start(form) }}
{% do form.participants.setRendered %}
@@ -318,7 +318,7 @@
{% endblock %}
<div class="flex justify-between">
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
+11 -3
View File
@@ -27,10 +27,18 @@
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<div id="form-wrapper" class="col-span-2">
<h2 class="mb-6">Zahlungsart</h2>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_3'),
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
{# Payment method selection with HTMX #}
<div id="form-payment"
@@ -63,7 +71,7 @@
<div class="flex justify-between pt-6">
<a href="{{ path('app_booking_create_step_2') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_end(form) }}
+11 -3
View File
@@ -21,7 +21,7 @@
</div>
{% endif %}
<div>
<div id="form-wrapper">
<h2 class="mb-6">Buchung bestätigen</h2>
{# Travel Summary #}
@@ -290,7 +290,15 @@
</div>
{# Confirmation Form #}
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_4'),
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
<div class="mb-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
{{ form_row(form.confirmationAccepted, {
@@ -300,7 +308,7 @@
<div class="flex justify-between pt-6">
<a href="{{ path('app_booking_create_step_3') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--primary">Verbindlich buchen</button>
<button type="submit" class="button bg-button bg-button--primary" {{ stimulus_action('loading', 'toggle') }}>Verbindlich buchen</button>
</div>
{{ form_end(form) }}
+16 -4
View File
@@ -53,11 +53,23 @@
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
<h1 class="text-3xl font-semibold pb-8">Buchung bearbeiten</h1>
{# Header with reload button #}
<div class="flex justify-between items-center pb-8">
<h1 class="text-3xl font-semibold">Buchung bearbeiten</h1>
<button type="button"
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}"
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?"
{{ stimulus_action('loading', 'toggle') }}
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded transition-colors">
🔄 Änderungen verwerfen
</button>
</div>
{# Grid layout with 2/3 form + 1/3 summary #}
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<div id="form-wrapper" class="col-span-2">
<h2>Teilnehmer</h2>
{{ form_start(form) }}
{% do form.participants.setRendered %}
@@ -362,7 +374,7 @@
{% endblock %}
<div class="flex justify-between">
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary">Aktualisieren</button>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Aktualisieren</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -379,4 +391,4 @@
</div>
{% endblock %}
</div>
{% endblock %}
{% endblock %}