Open thekid opened 1 year ago
As an alternative to using configRequest
to add the CSRF header, we can use https://htmx.org/attributes/hx-headers/ on the body element:
<body {{#with request.values.token}}hx-headers='{"X-CSRF-Token": "{{.}}"}'{{/with}}>
<!-- ... -->
</body>
See https://www.mattlayman.com/blog/2021/how-to-htmx-django/ and https://django-htmx.readthedocs.io/en/latest/tips.html
See also https://laravel.io/articles/getting-started-with-htmx-in-laravel-an-overview and https://github.com/mauricius/laravel-htmx.
The article https://dev.to/turculaurentiu91/laravel-htmx--g0n features progressive enhanchement techniques:
Ensure that HTMX requests to the edit chirp endpoint receive only the edit chirp component, while other requests receive the whole page, aligning with the "progressive enhancement" pattern.
public function edit(Request $request, Chirp $chirp): Response
{
$this->authorize('update', $chirp);
if ($request->header('HX-Request')) {
return response()->view('components.chirps.edit', [
'chirp' => $chirp,
]);
}
return response()->view('chirps.edit', [
'chirp' => $chirp,
]);
}
To use the bundling mechansim, put the following inside the file package.json:
{
"dependencies": {
"htmx.org": "^1.9"
},
"bundles": {
"vendor": {"htmx.org": ["dist/htmx.min.js"]}
}
}
...and then run xp bundle src/main/webapp/static
to create vendor.js
(and compressed versions of it) in the given directory. These assets can then be delivered via the web.frontend.AssetsFrom
handler.
:shipit: CSRF Token header was released in https://github.com/xp-forge/frontend/releases/tag/v5.3.0
The article https://dev.to/turculaurentiu91/laravel-htmx--g0n features progressive enhanchement techniques
To realize this with the frontend library, we specify a full form including the hx-*
attributes as follows:
<form name="post" hx-post="/messages" action="/messages" method="POST">
<input type="hidden" name="token" value="{{request.values.token}}">
<input type="text" name="message" placeholder="Your message...">
<button type="submit">Post</button>
</form>
...and then declare the handler as follows:
#[Handler('/messages')]
class Messages {
private $posts= [];
#[Get]
public function list() {
return View::named('reactive')->with(['posts' => $this->posts]);
}
#[Post]
public function create(#[Value] $user, #[Param] $message, #[Header('Hx-Request')] $hx= false) {
$this->posts[]= ['message' => $message, 'author' => $user['name'], 'date' => date('Y-m-d H:i')];
if ($hx) {
return View::named('reactive')->fragment('list')->with(['posts' => $this->posts]);
} else {
return View::redirect('/');
}
}
}
Should JavaScript be disabled or error on loading, the user could still use the page, with the user being redirected back after their POST request has invoked the create handler. To make this work for HTTP method such as PUT
, PATCH
and DELETE
, see https://github.com/xp-forge/frontend/pull/42
The above could be generalized by the following:
<div class="segments" hx-target="#list"> <!-- The target here (w/o the leading "#")... -->
<div id="list" class="segments"> <!-- ...equals this ID -->
{{#*fragment "list"}} <!-- ...and this fragment's name -->
{{#each posts}}
...
{{/each}}
{{/fragment}}
</div>
<form name="post" hx-post="/messages">
<!-- Create post form -->
</form>
</div>
class Htmx {
public function rerender($req, $view) {
if ('true' === $req->header('Hx-Request')) {
return $view()->fragment($req->header('Hx-Fragment') ?? $req->header('Hx-Target'));
} else {
return View::redirect('/');
}
}
}
Note: If we cannot use an ID, we can pass the header with the fragment's name via hx-headers='{"Hx-Fragment":"list"}'
.
#[Handler('/messages')]
class Messages {
private $posts= [];
+ private $htmx;
+ public function __construct() {
+ $this->htmx= new Htmx();
+ }
#[Get]
public function list() {
return View::named('reactive')->with(['posts' => $this->posts]);
}
#[Post]
- public function create(#[Value] $user, #[Param] $message, #[Header('Hx-Request')] $hx= false) {
+ public function create(#[Value] $user, #[Param] $message, #[Request] $request) {
$this->posts[]= ['message' => $message, 'author' => $user['name'], 'date' => date('Y-m-d H:i')];
-
- if ($hx) {
- return View::named('reactive')->fragment('list')->with(['posts' => $this->posts]);
- } else {
- return View::redirect('/');
- }
+ return $this->htmx->rerender($req, $this->list(...));
}
}
A good example usecase is shown in https://www.youtube.com/watch?v=akd7u69k27k - implemented #44 to short-hand setting template and fragment, and #45 to support the DELETE
usecase.
In the PHP implementation, I chose to use hx-swap="delete"
(see here) instead of triggering an event when deleting.
This still has some minimal JS involved, and adds some HTMX-specific code to the PHP part:
<button hx-delete="/todos/{{id}}" hx-on:delete-todo="this.closest('tr').remove()">Delete</button>
#[Delete('/todos/{id}')]
public function remove($id) {
$this->conn->delete('from todo where id = %d', $id);
return View::empty()->status(204)->header('HX-Trigger', 'delete-todo');
}
This could be something like return $this->htmx->trigger('delete-todo');
, see above
This makes it necessary to a non-204 status code - otherwise, HTMX will not do anything.
<button hx-delete="/todos/{{id}}" hx-target="closest tr" hx-swap="delete">Delete</button>
#[Delete('/todos/{id}')]
public function remove($id) {
$this->conn->delete('from todo where id = %d', $id);
return View::empty()->status(202);
}
This could also just use return View::empty();
but using "Accepted" feels a bit more "resty" 😊
As an alternative to using
configRequest
to add the CSRF header, we can use https://htmx.org/attributes/hx-headers/ on the body element:
This does not work out of the box with AWS lambda & CloudFront - most probably needing an extra header configuration!
How this is done with other languages & frameworks: https://htmx.org/server-examples/#php - once https://github.com/thekid/crews reaches an MVP state, we could add it there!
The authentication part is now taken care of by https://github.com/xp-forge/htmx
Motivation
HTMX is growing in popularity, and is a perfect fit for making server-rendered apps like those created with this library feel "reactive".
Source: https://trends.google.de/trends/explore?q=htmx&date=today%205-y#TIMESERIES
Given that, this issue explores what is necessary to integrate it well.
The basics
To use this library, simply add a script tag:
Note: You should be bundling your dependencies when going into production - see below
Now, we can make use of the
hx-*
attributes:Handling errors
When an HTMX request cannot reach the server (flaky wifi, offline, ...), there is no immediate feedback to the user except for in the browser console. This can be solved by adding an event listener as follows:
For requests causing internal server errors, we should also handle htmx:responseErrors; a complete implementation would include handling htmx:timeouts, e.g. by asking the user whether to continue.
See https://htmx.org/events/#htmx:sendError, https://htmx.org/events/#htmx:responseError and https://htmx.org/events/#htmx:timeout
CSRF token
This library includes automatic CSRF token handling. To include the CSRF token in HTMX requests, the following can be done:
Note: Support was added in PR #40
This will save adding the CSRF token manually to each element, e.g.
<span hx-post="/clicked" #{{if request.values.token}}hx-vals='{"token":"{{request.values.token}}"}'{{/if}}>...</span>
.Authentication
When an HTMX request triggers re-authentication, e.g. because the session expired, it might cause redirects to the HTMX target (
/clicked
in the above example) when using protocols such as OAuth. This is not desireable, we instead want to show an error message to the user that his or her session has expired. Solving this consists of raising an error in the backend:...and handling that in the frontend:
We could think about using a dedicated response codes to disambiguate, e.g. https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/421 ("Misdirected Request [...] indicates that the request was directed to a server that is not able to produce a response")