feat: boost pagination and sorting links with htmx

This commit is contained in:
fromme committed 2026-10-16 10:39:47 +02:00
1 parent 493a43d952
commit 13e5b6c3af
8 files changed
+37 -11

No files matched your search

+9
View File
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false
// A boosted link is a page navigation: show the error page like the browser would, instead of
// swallowing the 4xx/5xx and leaving the click without effect.
document.addEventListener('htmx:beforeSwap', (event) => {
if (true === event.detail.boosted && event.detail.xhr.status >= 400) {
event.detail.shouldSwap = true
event.detail.isError = false
}
})
import './bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
+4 -4
View File
@@ -21,7 +21,7 @@
"dropzone": "^6.0.0-beta.2",
"file-loader": "^6.2.0",
"flatpickr": "^4.6.13",
"htmx.org": "^1.9.10",
"htmx.org": "^2.0.11",
"mjml": "^4.15.3",
"postcss": "^8.4.29",
"postcss-import": "^15.1.0",
@@ -5139,9 +5139,9 @@
}
},
"node_modules/htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.11.tgz",
"integrity": "sha512-Thx/WtpeOQqSrqBCw/A1cwGJGg4UrVa3+sW0GmrM3p4gJgO89ecH4qtbnyzDDWFvBTqjnIMCgELTNt636dtamA==",
"license": "0BSD"
},
"node_modules/http-deceiver": {
+1 -1
View File
@@ -15,7 +15,7 @@
"dropzone": "^6.0.0-beta.2",
"file-loader": "^6.2.0",
"flatpickr": "^4.6.13",
"htmx.org": "^1.9.10",
"htmx.org": "^2.0.11",
"mjml": "^4.15.3",
"postcss": "^8.4.29",
"postcss-import": "^15.1.0",
+6 -2
View File
@@ -51,11 +51,15 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
* The firewall saves the requested url as the target path right before it calls this. An
* htmx request is a fragment - a modal, a table body - that cannot stand as a page, so
* after the login it must not be where the user lands. htmx does not send the
* X-Requested-With header that would make Symfony skip it on its own.
* X-Requested-With header that would make Symfony skip it on its own. A boosted request
* (a paginator or sort link) is the exception: it fetches a whole page and stays the deeplink.
*/
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
if (true === $request->headers->has('HX-Request') && true === $request->hasSession()) {
if (true === $request->headers->has('HX-Request')
&& false === $request->headers->has('HX-Boosted')
&& true === $request->hasSession()
) {
$this->removeTargetPath($request->getSession(), self::FIREWALL_NAME);
}
+2 -2
View File
@@ -8,7 +8,7 @@
<select name="limit"
class="text-sm py-1 border-gray-300"
hx-get="{{ path(route, query|merge({(pageParameterName): 1})) }}"
hx-replace-url="{{ path(route, query|merge({(pageParameterName): 1})) }}"
hx-replace-url="true"
hx-target="body">
<option value="10"{% if numItemsPerPage == 10 %} selected{% endif %}>10 pro Seite</option>
<option value="20"{% if numItemsPerPage == 20 %} selected{% endif %}>20 pro Seite</option>
@@ -16,7 +16,7 @@
</select>
</div>
<div>
<nav class="relative z-0 inline-flex">
<nav class="relative z-0 inline-flex" hx-boost="true">
{% if pageCount > 1 %}
{% if first is defined and current != first %}
<a href="{{ path(route, query|merge({(pageParameterName): first})) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white">
+1 -1
View File
@@ -5,7 +5,7 @@
<p class="text-sm leading-5 text-gray-700">{{ 'paginator.info'|trans({ '{from}': firstItemNumber, '{to}': lastItemNumber, '{total}': totalCount })|raw }}</p>
</div>
<div>
<nav class="relative z-0 inline-flex">
<nav class="relative z-0 inline-flex" hx-boost="true">
{% if pageCount > 1 %}
{% if previous is defined %}
<a href="{{ path(route, query|merge({(pageParameterName): previous})) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium hover:bg-secondary hover:text-white" aria-label="Previous">
+1 -1
View File
@@ -1,4 +1,4 @@
<a href="{{ options.href }}" class="flex items-center space-x-2">
<a href="{{ options.href }}" class="flex items-center space-x-2" hx-boost="true">
<span>{{ title|raw }}</span>
{%- if sorted -%}
{%- if direction == 'desc' -%}
+13
View File
@@ -169,6 +169,19 @@ class BpnAuthenticatorTest extends TestCase
$this->assertFalse($request->getSession()->has('_security.main.target_path'));
}
public function testABoostedHtmxRequestIsKeptAsTheDeeplink(): void
{
// a paginator or sort link fetches a whole page, so it is worth landing on after the login
$request = $this->createRequest();
$request->headers->set('HX-Request', 'true');
$request->headers->set('HX-Boosted', 'true');
$request->getSession()->set('_security.main.target_path', 'https://team.example.com/administrative/assignment?page=3');
$this->createAuthenticator()->start($request);
$this->assertSame('https://team.example.com/administrative/assignment?page=3', $request->getSession()->get('_security.main.target_path'));
}
public function testAPageRequestIsKeptAsTheDeeplink(): void
{
$request = $this->createRequest();