feat: add htmx server-side plumbing

This commit is contained in:
fromme committed 2026-09-13 14:47:21 +02:00
1 parent 3bfb5afef6
commit ef8a88ed2a
9 files changed
+450

No files matched your search

@@ -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));
}
}
+124
View File
@@ -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);
}
};
}
}