feat: add htmx server-side plumbing
This commit is contained in:
1 parent
3bfb5afef6
commit
ef8a88ed2a
9 files changed
+450
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Closes an htmx-loaded modal by removing it from the DOM.
|
||||||
|
*
|
||||||
|
* Wired to the backdrop, the X button and a `modal-close` window event, so a fragment swapped
|
||||||
|
* into the modal can dismiss it with `htmx.trigger(window, 'modal-close')` or an HX-Trigger
|
||||||
|
* header. Opening is htmx's job; closing never needs a round trip.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
close() {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an error modal when an htmx request blows up.
|
||||||
|
*
|
||||||
|
* htmx does not swap a 5xx by default, so an uncaught exception on a fragment request produces
|
||||||
|
* no visible effect whatsoever -- the user clicks and nothing happens. This turns it into
|
||||||
|
* something they can see: a 200 carrying the error modal, retargeted at <body> so it appends
|
||||||
|
* regardless of what the original hx-target was.
|
||||||
|
*
|
||||||
|
* allowCustomResponseCode() is what keeps the 200; without it the kernel rewrites it to 500 and
|
||||||
|
* htmx discards the body again.
|
||||||
|
*/
|
||||||
|
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
|
||||||
|
final readonly class HtmxExceptionListener
|
||||||
|
{
|
||||||
|
public function __construct(private Environment $twig)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->getRequest()->headers->has('HX-Request')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event->hasResponse()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new Response($this->twig->render('htmx_error_modal.html.twig'), Response::HTTP_OK);
|
||||||
|
$response->headers->set('HX-Retarget', 'body');
|
||||||
|
$response->headers->set('HX-Reswap', 'beforeend');
|
||||||
|
|
||||||
|
$event->setResponse($response);
|
||||||
|
$event->allowCustomResponseCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Htmx;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response that makes htmx perform a full page navigation.
|
||||||
|
*
|
||||||
|
* htmx never follows a normal 3xx itself -- the browser does, transparently, and htmx then swaps
|
||||||
|
* whatever the final destination returned, which is a whole page in the wrong place. So a redirect
|
||||||
|
* has to be handed to htmx as an instruction instead: a 200 carrying HX-Redirect.
|
||||||
|
*
|
||||||
|
* Anything returning this from an event listener must also call
|
||||||
|
* ExceptionEvent::allowCustomResponseCode(), or the kernel overwrites the 200.
|
||||||
|
*/
|
||||||
|
class HxRedirectResponse extends Response
|
||||||
|
{
|
||||||
|
public function __construct(string $url)
|
||||||
|
{
|
||||||
|
parent::__construct('', Response::HTTP_OK, ['HX-Redirect' => $url]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Htmx;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* htmx helpers for controllers. Expects to be used inside an AbstractController.
|
||||||
|
*
|
||||||
|
* Detecting an htmx request stays a plain `$request->headers->has('HX-Request')` -- no bundle,
|
||||||
|
* no argument resolver, no helper service.
|
||||||
|
*/
|
||||||
|
trait HxTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Renders several Twig blocks into one response, for an htmx out-of-band swap.
|
||||||
|
*
|
||||||
|
* This is how one interaction updates more than one region: the response body carries the
|
||||||
|
* primary fragment plus any number of others marked `hx-swap-oob`, and htmx dispatches each
|
||||||
|
* to its own target by id. `htmx_oob_swap` is injected into the context so a block can emit
|
||||||
|
* that attribute only when rendered through this path, and stay a plain fragment otherwise.
|
||||||
|
*
|
||||||
|
* @param string[] $blockNames block names to render, in order
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @param string|null $pushUrl pushed onto browser history via HX-Push-Url
|
||||||
|
*/
|
||||||
|
protected function htmxOobResponse(string $templateName, array $blockNames, array $context = [], ?string $pushUrl = null): Response
|
||||||
|
{
|
||||||
|
$html = '';
|
||||||
|
$oobContext = $context + ['htmx_oob_swap' => true];
|
||||||
|
|
||||||
|
foreach ($blockNames as $blockName) {
|
||||||
|
$html .= $this->renderBlockView($templateName, $blockName, $oobContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new Response($html);
|
||||||
|
|
||||||
|
if (null !== $pushUrl) {
|
||||||
|
$response->headers->set('HX-Push-Url', $pushUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects in a way htmx understands, falling back to a normal redirect for ordinary requests.
|
||||||
|
*
|
||||||
|
* Note this is a genuine full page navigation. Prefer swapping the affected fragment (plus an
|
||||||
|
* out-of-band toast) and keep this for real destination changes.
|
||||||
|
*/
|
||||||
|
protected function htmxRedirect(Request $request, string $url): Response
|
||||||
|
{
|
||||||
|
if ($request->headers->has('HX-Request')) {
|
||||||
|
return new HxRedirectResponse($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect($url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% embed 'htmx_modal.html.twig' %}
|
||||||
|
{% block title %}Oops…{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-red-600 text-gray-100 rounded px-4 py-3 text-sm">
|
||||||
|
Da ist etwas schiefgelaufen. Wir haben uns Notizen gemacht und kümmern uns
|
||||||
|
schnellstmöglich darum.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% endembed %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{# Standalone modal fragment -- extends nothing, so it is valid on its own as an htmx response.
|
||||||
|
Openers are plain `hx-get` buttons with hx-target="body" hx-swap="beforeend", which append
|
||||||
|
this to the end of <body>. Closing is not htmx: modal_controller.js just removes the element.
|
||||||
|
Ported from myep's htmx_modal_admin.html.twig (bg-black/70 backdrop, no shadow). #}
|
||||||
|
<div id="htmx-modal"
|
||||||
|
class="fixed inset-0 w-full h-full z-50"
|
||||||
|
{{ stimulus_controller('modal') }}
|
||||||
|
{{ stimulus_action('modal', 'close', 'modal-close@window') }}>
|
||||||
|
<div class="absolute inset-0 w-full h-full bg-black/70" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
|
||||||
|
<div class="relative bg-white py-8 rounded-md">
|
||||||
|
<div class="flex justify-between pb-4 px-8">
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{% block title %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
|
||||||
|
{{ stimulus_action('modal', 'close') }}>
|
||||||
|
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-svh overflow-y-auto">
|
||||||
|
<div class="px-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the listener through the real kernel, which is the only way to prove
|
||||||
|
* allowCustomResponseCode() actually holds the 200 -- the kernel rewrites it otherwise, and a
|
||||||
|
* unit test on the event object cannot see that happen.
|
||||||
|
*
|
||||||
|
* An unrouted path is used as the exception source so no throwing fixture controller is needed.
|
||||||
|
*/
|
||||||
|
class HtmxExceptionListenerFunctionalTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testAnExceptionOnAnHtmxRequestArrivesAsA200ErrorModal(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->catchExceptions(true);
|
||||||
|
$client->request('GET', '/gibt-es-nicht', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
||||||
|
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
|
||||||
|
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSameExceptionOnAnOrdinaryRequestStillGivesAnHonestErrorStatus(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->catchExceptions(true);
|
||||||
|
$client->request('GET', '/gibt-es-nicht');
|
||||||
|
|
||||||
|
// Asserting on headers rather than body: in debug mode Symfony's exception page renders
|
||||||
|
// the source of every stack frame, this file included, so any marker string searched for
|
||||||
|
// here would match its own source code.
|
||||||
|
self::assertSame(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode());
|
||||||
|
self::assertFalse($client->getResponse()->headers->has('HX-Retarget'));
|
||||||
|
self::assertFalse($client->getResponse()->headers->has('HX-Reswap'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\EventListener;
|
||||||
|
|
||||||
|
use App\EventListener\HtmxExceptionListener;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the real Twig so the error modal template is exercised too -- an unrenderable modal here
|
||||||
|
* would throw from inside exception handling, which is the worst possible place for it.
|
||||||
|
*/
|
||||||
|
class HtmxExceptionListenerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testHtmxRequestsGetTheErrorModalAsA200(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch(['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertInstanceOf(Response::class, $response);
|
||||||
|
// 200, not 500: htmx does not swap 5xx, so an error response would be discarded unseen.
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
// Without this the kernel turns the 200 back into a 500.
|
||||||
|
self::assertTrue($event->isAllowingCustomResponseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheModalIsRetargetedAtTheBodySoItAppearsWhateverTheTriggerWas(): void
|
||||||
|
{
|
||||||
|
$response = $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse();
|
||||||
|
|
||||||
|
self::assertNotNull($response);
|
||||||
|
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
||||||
|
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheRenderedModalIsTheStandaloneModalFragment(): void
|
||||||
|
{
|
||||||
|
$content = (string) $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse()?->getContent();
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="htmx-modal"', $content);
|
||||||
|
self::assertStringContainsString('data-controller="modal"', $content);
|
||||||
|
self::assertStringNotContainsString('<html', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrdinaryRequestsAreLeftToSymfonysErrorHandling(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->dispatch()->getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAResponseSetByAnEarlierListenerIsNotOverwritten(): void
|
||||||
|
{
|
||||||
|
$event = $this->event(['HTTP_HX-Request' => 'true']);
|
||||||
|
$event->setResponse($alreadySet = new Response('handled elsewhere'));
|
||||||
|
|
||||||
|
($this->listener())($event);
|
||||||
|
|
||||||
|
self::assertSame($alreadySet, $event->getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $server
|
||||||
|
*/
|
||||||
|
private function dispatch(array $server = []): ExceptionEvent
|
||||||
|
{
|
||||||
|
$event = $this->event($server);
|
||||||
|
($this->listener())($event);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $server
|
||||||
|
*/
|
||||||
|
private function event(array $server): ExceptionEvent
|
||||||
|
{
|
||||||
|
return new ExceptionEvent(
|
||||||
|
self::bootKernel(),
|
||||||
|
Request::create('/buchungen', server: $server),
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
new \RuntimeException('boom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listener(): HtmxExceptionListener
|
||||||
|
{
|
||||||
|
return new HtmxExceptionListener(self::getContainer()->get(Environment::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Htmx;
|
||||||
|
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class HxTraitTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testHtmxRequestsGetAnHxRedirectInsteadOfA302(): void
|
||||||
|
{
|
||||||
|
$response = $this->controller()->redirectTo($this->request(['HTTP_HX-Request' => 'true']), '/buchungen');
|
||||||
|
|
||||||
|
self::assertInstanceOf(HxRedirectResponse::class, $response);
|
||||||
|
self::assertSame('/buchungen', $response->headers->get('HX-Redirect'));
|
||||||
|
// A 3xx would be followed by the browser and swapped as a whole page into the fragment.
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrdinaryRequestsGetANormalRedirect(): void
|
||||||
|
{
|
||||||
|
$response = $this->controller()->redirectTo($this->request(), '/buchungen');
|
||||||
|
|
||||||
|
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
self::assertSame('/buchungen', $response->getTargetUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOobResponseConcatenatesEveryRequestedBlock(): void
|
||||||
|
{
|
||||||
|
$controller = $this->controller();
|
||||||
|
|
||||||
|
$response = $controller->oob('booking/index.html.twig', ['grid', 'floorplan']);
|
||||||
|
|
||||||
|
self::assertSame('[grid][floorplan]', $response->getContent());
|
||||||
|
self::assertSame(
|
||||||
|
[['booking/index.html.twig', 'grid'], ['booking/index.html.twig', 'floorplan']],
|
||||||
|
$controller->renderedBlocks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOobResponseFlagsTheContextSoBlocksCanEmitHxSwapOob(): void
|
||||||
|
{
|
||||||
|
$controller = $this->controller();
|
||||||
|
|
||||||
|
$controller->oob('booking/index.html.twig', ['grid'], ['date' => '2026-09-13']);
|
||||||
|
|
||||||
|
self::assertSame(['date' => '2026-09-13', 'htmx_oob_swap' => true], $controller->renderedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCallerSuppliedContextWinsOverTheInjectedFlag(): void
|
||||||
|
{
|
||||||
|
$controller = $this->controller();
|
||||||
|
|
||||||
|
$controller->oob('booking/index.html.twig', ['grid'], ['htmx_oob_swap' => false]);
|
||||||
|
|
||||||
|
self::assertFalse($controller->renderedContext['htmx_oob_swap']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPushUrlIsOptional(): void
|
||||||
|
{
|
||||||
|
$controller = $this->controller();
|
||||||
|
|
||||||
|
self::assertFalse($controller->oob('t.html.twig', ['b'])->headers->has('HX-Push-Url'));
|
||||||
|
self::assertSame(
|
||||||
|
'/buchungen?datum=2026-09-13',
|
||||||
|
$controller->oob('t.html.twig', ['b'], [], '/buchungen?datum=2026-09-13')->headers->get('HX-Push-Url'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $server
|
||||||
|
*/
|
||||||
|
private function request(array $server = []): Request
|
||||||
|
{
|
||||||
|
return Request::create('/buchungen', server: $server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controller(): object
|
||||||
|
{
|
||||||
|
return new class extends AbstractController {
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
|
/** @var list<array{0: string, 1: string}> */
|
||||||
|
public array $renderedBlocks = [];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $renderedContext = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $blockNames
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function oob(string $template, array $blockNames, array $context = [], ?string $pushUrl = null): Response
|
||||||
|
{
|
||||||
|
return $this->htmxOobResponse($template, $blockNames, $context, $pushUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redirectTo(Request $request, string $url): Response
|
||||||
|
{
|
||||||
|
return $this->htmxRedirect($request, $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for Twig so the trait's own behaviour can be tested without a kernel.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
protected function renderBlockView(string $view, string $block, array $parameters = []): string
|
||||||
|
{
|
||||||
|
$this->renderedBlocks[] = [$view, $block];
|
||||||
|
$this->renderedContext = $parameters;
|
||||||
|
|
||||||
|
return \sprintf('[%s]', $block);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user