From ef8a88ed2a906af74f816445c34db98d6be592c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 13 Sep 2026 14:47:21 +0200 Subject: [PATCH] feat: add htmx server-side plumbing --- assets/controllers/modal_controller.js | 14 ++ src/EventListener/HtmxExceptionListener.php | 48 +++++++ src/Htmx/HxRedirectResponse.php | 25 ++++ src/Htmx/HxTrait.php | 62 +++++++++ templates/htmx_error_modal.html.twig | 9 ++ templates/htmx_modal.html.twig | 29 ++++ .../HtmxExceptionListenerFunctionalTest.php | 45 +++++++ .../HtmxExceptionListenerTest.php | 94 +++++++++++++ tests/Htmx/HxTraitTest.php | 124 ++++++++++++++++++ 9 files changed, 450 insertions(+) create mode 100644 assets/controllers/modal_controller.js create mode 100644 src/EventListener/HtmxExceptionListener.php create mode 100644 src/Htmx/HxRedirectResponse.php create mode 100644 src/Htmx/HxTrait.php create mode 100644 templates/htmx_error_modal.html.twig create mode 100644 templates/htmx_modal.html.twig create mode 100644 tests/EventListener/HtmxExceptionListenerFunctionalTest.php create mode 100644 tests/EventListener/HtmxExceptionListenerTest.php create mode 100644 tests/Htmx/HxTraitTest.php diff --git a/assets/controllers/modal_controller.js b/assets/controllers/modal_controller.js new file mode 100644 index 0000000..997708e --- /dev/null +++ b/assets/controllers/modal_controller.js @@ -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(); + } +} diff --git a/src/EventListener/HtmxExceptionListener.php b/src/EventListener/HtmxExceptionListener.php new file mode 100644 index 0000000..d42a03e --- /dev/null +++ b/src/EventListener/HtmxExceptionListener.php @@ -0,0 +1,48 @@ + 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(); + } +} diff --git a/src/Htmx/HxRedirectResponse.php b/src/Htmx/HxRedirectResponse.php new file mode 100644 index 0000000..d52f974 --- /dev/null +++ b/src/Htmx/HxRedirectResponse.php @@ -0,0 +1,25 @@ + $url]); + } +} diff --git a/src/Htmx/HxTrait.php b/src/Htmx/HxTrait.php new file mode 100644 index 0000000..f8a2b95 --- /dev/null +++ b/src/Htmx/HxTrait.php @@ -0,0 +1,62 @@ +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 $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); + } +} diff --git a/templates/htmx_error_modal.html.twig b/templates/htmx_error_modal.html.twig new file mode 100644 index 0000000..c5c959d --- /dev/null +++ b/templates/htmx_error_modal.html.twig @@ -0,0 +1,9 @@ +{% embed 'htmx_modal.html.twig' %} + {% block title %}Oops…{% endblock %} + {% block content %} +
+ Da ist etwas schiefgelaufen. Wir haben uns Notizen gemacht und kümmern uns + schnellstmöglich darum. +
+ {% endblock %} +{% endembed %} diff --git a/templates/htmx_modal.html.twig b/templates/htmx_modal.html.twig new file mode 100644 index 0000000..3ebc53c --- /dev/null +++ b/templates/htmx_modal.html.twig @@ -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 . 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). #} +
+
+
+
+
+
+ {% block title %}{% endblock %} +
+ +
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
diff --git a/tests/EventListener/HtmxExceptionListenerFunctionalTest.php b/tests/EventListener/HtmxExceptionListenerFunctionalTest.php new file mode 100644 index 0000000..c31c43d --- /dev/null +++ b/tests/EventListener/HtmxExceptionListenerFunctionalTest.php @@ -0,0 +1,45 @@ +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')); + } +} diff --git a/tests/EventListener/HtmxExceptionListenerTest.php b/tests/EventListener/HtmxExceptionListenerTest.php new file mode 100644 index 0000000..e715e62 --- /dev/null +++ b/tests/EventListener/HtmxExceptionListenerTest.php @@ -0,0 +1,94 @@ +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('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 $server + */ + private function dispatch(array $server = []): ExceptionEvent + { + $event = $this->event($server); + ($this->listener())($event); + + return $event; + } + + /** + * @param array $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)); + } +} diff --git a/tests/Htmx/HxTraitTest.php b/tests/Htmx/HxTraitTest.php new file mode 100644 index 0000000..1ad152a --- /dev/null +++ b/tests/Htmx/HxTraitTest.php @@ -0,0 +1,124 @@ +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 $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 */ + public array $renderedBlocks = []; + + /** @var array */ + public array $renderedContext = []; + + /** + * @param string[] $blockNames + * @param array $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 $parameters + */ + protected function renderBlockView(string $view, string $block, array $parameters = []): string + { + $this->renderedBlocks[] = [$view, $block]; + $this->renderedContext = $parameters; + + return \sprintf('[%s]', $block); + } + }; + } +}