Plain is headed towards 1.0! Subscribe for development updates →

  1# HTMX
  2
  3Integrate HTMX with templates and views.
  4
  5The `plain-htmx` package adds a couple of unique features for working with HTMX.
  6One is [template fragments](#template-fragments) and the other is [view actions](#view-actions).
  7
  8The combination of these features lets you build HTMX-powered views that focus on server-side rendering and avoid overly complicated URL structures or REST APIs that you may not otherwise need.
  9
 10The `HTMXViewMixin` is the starting point for the server-side HTMX behavior.
 11To use these feaures on a view,
 12simply inherit from the class (yes, this is designed to work with class-based views).
 13
 14```python
 15from plain.views import TemplateView
 16
 17from plain.htmx.views import HTMXViewMixin
 18
 19
 20class HomeView(HTMXViewMixin, TemplateView):
 21    template_name = "home.html"
 22```
 23
 24In your `base.html` template (or wherever need the HTMX scripts),
 25you can use the `{% htmx_js %}` template tag:
 26
 27```html
 28<!-- base.template.html -->
 29{% load htmx %}
 30<!DOCTYPE html>
 31<html lang="en">
 32<head>
 33    <meta charset="UTF-8">
 34    <title>My Site</title>
 35    {% htmx_js %}
 36</head>
 37<body>
 38    {% block content %}{% endblock %}
 39</body>
 40```
 41
 42## Installation
 43
 44```python
 45INSTALLED_PACKAGES = [
 46    # ...
 47    "plain.htmx",
 48]
 49```
 50
 51## Template Fragments
 52
 53An `{% htmxfragment %}` can be used to render a specific part of your template in HTMX responses.
 54When you use a fragment, all `hx-get`, `hx-post`, etc. elements inside that fragment will automatically send a request to the current URL,
 55render _only_ the updated content for the fragment,
 56and swap out the fragment.
 57
 58Here's an example:
 59
 60```html
 61<!-- home.html -->
 62{% extends "base.html" %}
 63
 64{% load htmx %}
 65
 66{% block content %}
 67<header>
 68  <h1>Page title</h1>
 69</header>
 70
 71<main>
 72  {% htmxfragment main %}
 73  <p>The time is {% now "jS F Y H:i" %}</p>
 74
 75  <button hx-get>Refresh</button>
 76  {% endhtmxfragment %}
 77</main>
 78{% endblock %}
 79```
 80
 81Everything inside `{% htmxfragment %}` will automatically update when "Refresh" is clicked.
 82
 83### Lazy template fragments
 84
 85If you want to render a fragment lazily,
 86you can add the `lazy` attribute to the `{% htmxfragment %}` tag.
 87
 88```html
 89{% htmxfragment main lazy=True %}
 90<!-- This content will be fetched with hx-get -->
 91{% endhtmxfragment %}
 92```
 93
 94This pairs nicely with passing a callable function or method as a context variable,
 95which will only get invoked when the fragment actually gets rendered on the lazy load.
 96
 97```python
 98def fetch_items():
 99    import time
100    time.sleep(2)
101    return ["foo", "bar", "baz"]
102
103
104class HomeView(HTMXViewMixin, TemplateView):
105    def get_context(self, **kwargs):
106        context = super().get_context(**kwargs)
107        context["items"] = fetch_items  # Missing () are on purpose!
108        return context
109```
110
111```html
112{% htmxfragment main lazy=True %}
113<ul>
114  {% for item in items %}
115    <li>{{ item }}</li>
116  {% endfor %}
117</ul>
118{% endhtmxfragment %}
119```
120
121### How does it work?
122
123When you use the `{% htmxfragment %}` tag,
124a standard `div` is output that looks like this:
125
126```html
127<div plain-hx-fragment="main" hx-swap="outerHTML" hx-target="this" hx-indicator="this">
128  {{ fragment_content }}
129</div>
130```
131
132The `plain-hx-fragment` is a custom attribute that we've added ("F" is for "Forge"),
133but the rest are standard HTMX attributes.
134
135When Plain renders the response to an HTMX request,
136it will get the `Plain-HX-Fragment` header,
137find the fragment with that name in the template,
138and render that for the response.
139
140Then the response content is automatically swapped in to replace the content of your `{% htmxfragment %}` tag.
141
142Note that there is no URL specified on the `hx-get` attribute.
143By default, HTMX will send the request to the current URL for the page.
144When you're working with fragments, this is typically the behavior you want!
145(You're on a page and want to selectively re-render a part of that page.)
146
147The `{% htmxfragment %}` tag is somewhat similar to a `{% block %}` tag --
148the fragments on a page should be named and unique,
149and you can't use it inside of loops.
150For fragment-like behavior inside of a for-loop,
151you'll most likely want to set up a dedicated URL that can handle a single instance of the looped items,
152and maybe leverage [dedicated templates](#dedicated-templates).
153
154## View Actions
155
156View actions let you define multiple "actions" on a class-based view.
157This is an alternative to defining specific API endpoints or form views to handle basic button interactions.
158
159With view actions you can design a single view that renders a single template,
160and associate buttons in that template with class methods in the view.
161
162As an example, let's say we have a `PullRequest` model and we want users to be able to open, close, or merge it with a button.
163
164In our template, we would use the `plain-hx-action` attribute to name the action:
165
166```html
167{% extends "base.html" %}
168
169{% load htmx %}
170
171{% block content %}
172<header>
173  <h1>{{ pullrequest }}</h1>
174</header>
175
176<main>
177  {% htmxfragment pullrequest %}
178  <p>State: {{ pullrequest.state }}</p>
179
180  {% if pullrequest.state == "open" %}
181    <!-- If it's open, they can close or merge it -->
182    <button hx-post plain-hx-action="close">Close</button>
183    <button hx-post plain-hx-action="merge">Merge</button>
184  {% else if pullrequest.state == "closed" %}
185    <!-- If it's closed, it can be re-opened -->
186    <button hx-post plain-hx-action="open">Open</button>
187  {% endif %}
188
189  {% endhtmxfragment %}
190</main>
191{% endblock %}
192```
193
194Then in the view class, we can define methods for each HTTP method + `plain-hx-action`:
195
196```python
197class PullRequestDetailView(HTMXViewMixin, DetailView):
198    def get_queryset(self):
199        # The queryset will apply to all actions on the view, so "permission" logic can be shared
200        return super().get_queryset().filter(users=self.request.user)
201
202    # Action handling methods follow this format:
203    # htmx_{method}_{action}
204    def htmx_post_open(self):
205        self.object = self.get_object()
206
207        if self.object.state != "closed":
208            raise ValueError("Only a closed pull request can be opened")
209
210        self.object.state = "closed"
211        self.object.save()
212
213        # Render the updated content the standard calls
214        # (which will selectively render our fragment if applicable)
215        context = self.get_context(object=self.object)
216        return self.render_to_response(context)
217
218    def htmx_post_close(self):
219        self.object = self.get_object()
220
221        if self.object.state != "open":
222            raise ValueError("Only a open pull request can be closed")
223
224        self.object.state = "open"
225        self.object.save()
226
227        context = self.get_context(object=self.object)
228        return self.render_to_response(context)
229
230    def htmx_post_merge(self):
231        self.object = self.get_object()
232
233        if self.object.state != "open":
234            raise ValueError("Only a open pull request can be merged")
235
236        self.object.state = "merged"
237        self.object.save()
238
239        context = self.get_context(object=self.object)
240        return self.render_to_response(context)
241```
242
243This can be a matter of preference,
244but typically you may end up building out an entire form, API, or set of URLs to handle these behaviors.
245If you application is only going to handle these actions via HTMX,
246then a single View may be a simpler way to do it.
247
248Note that currently we don't have many helper-functions for parsing or returning HTMX responses --
249this can basically all be done through standard request and response headers:
250
251```python
252class PullRequestDetailView(HTMXViewMixin, DetailView):
253    def get_queryset(self):
254        # The queryset will apply to all actions on the view, so "permission" logic can be shared
255        return super().get_queryset().filter(users=self.request.user)
256
257    # You can also leave off the "plain-hx-action" attribute and just handle the HTTP method
258    def htmx_delete(self):
259        self.object = self.get_object()
260
261        self.object.delete()
262
263        # Tell HTMX to do a client-side redirect when it receives the response
264        response = HttpResponse(status_code=204)
265        response.headers["HX-Redirect"] = "/"
266        return response
267```
268
269## Dedicated Templates
270
271A small additional features of `plain-htmx` is that it will automatically find templates named `{template_name}_htmx.html` for HTMX requests.
272More than anything, this is just a nice way to formalize a naming scheme for template "partials" dedicated to HTMX.
273
274Because template fragments don't work inside of loops,
275for example,
276you'll often need to define dedicated URLs to handle the HTMX behaviors for individual items in a loop.
277You can sometimes think of these as "pages within a page".
278
279So if you have a template that renders a collection of items,
280you can do the initial render using a Django `{% include %}`:
281
282```html
283<!-- pullrequests/pullrequest_list.html -->
284{% extends "base.html" %}
285
286{% block content %}
287
288{% for pullrequest in pullrequests %}
289<div>
290  {% include "pullrequests/pullrequest_detail_htmx.html" %}
291</div>
292{% endfor %}
293
294{% endblock %}
295```
296
297And then subsequent HTMX requests/actions on individual items can be handled by a separate URL/View:
298
299```html
300<!-- pullrequests/pullrequest_detail_htmx.html -->
301<div hx-url="{% url 'pullrequests:detail' pullrequest.uuid %}" hx-swap="outerHTML" hx-target="this">
302  <!-- Send all HTMX requests to a URL for single pull requests (works inside of a loop, or on a single detail page) -->
303  <h2>{{ pullrequest.title }}</h2>
304  <button hx-get>Refresh</button>
305  <button hx-post plain-hx-action="update">Update</button>
306</div>
307```
308
309_If_ you need a URL to render an individual item, you can simply include the same template fragment in most cases:
310
311```html
312<!-- pullrequests/pullrequest_detail.html -->
313{% extends "base.html" %}
314
315{% block content %}
316
317{% include "pullrequests/pullrequest_detail_htmx.html" %}
318
319{% endblock %}
320```
321
322```python
323# urls.py and views.py
324# urls.py
325default_namespace = "pullrequests"
326
327urlpatterns = [
328  path("<uuid:uuid>/", views.PullRequestDetailView, name="detail"),
329]
330
331# views.py
332class PullRequestDetailView(HTMXViewMixin, DetailView):
333  def htmx_post_update(self):
334      self.object = self.get_object()
335
336      self.object.update()
337
338      context = self.get_context(object=self.object)
339      return self.render_to_response(context)
340```
341
342## Tailwind CSS variant
343
344The standard behavior for `{% htmxfragment %}` is to set `hx-indicator="this"` on the rendered element.
345This tells HTMX to add the `htmx-request` class to the fragment element when it is loading.
346
347Since Plain emphasizes using Tailwind CSS,
348here's a simple variant you can add to your `tailwind.config.js` to easily style the loading state:
349
350```js
351const plugin = require('tailwindcss/plugin')
352
353module.exports = {
354  plugins: [
355    // Add variants for htmx-request class for loading states
356    plugin(({addVariant}) => addVariant('htmx-request', ['&.htmx-request', '.htmx-request &']))
357  ],
358}
359```
360
361You can then prefix any class with `htmx-request:` to decide what it looks like while HTMX requests are being sent:
362
363```html
364<!-- The "htmx-request" class will be added to the <form> by default -->
365<form hx-post="{{ url }}">
366    <!-- Showing an element -->
367    <div class="hidden htmx-request:block">
368        Loading
369    </div>
370
371    <!-- Changing a button's class -->
372    <button class="text-white bg-black htmx-request:opacity-50 htmx-request:cursor-wait" type="submit">Submit</button>
373</form>
374```
375
376## CSRF tokens
377
378We configure CSRF tokens for you with the HTMX JS API.
379You don't have to put `hx-headers` on the `<body>` tag, for example.
380
381## Error classes
382
383This app also includes an HTMX extension for adding error classes for failed requests.
384
385- `htmx-error-response` for `htmx:responseError`
386- `htmx-error-response-{{ status_code }}` for `htmx:responseError`
387- `htmx-error-send` for `htmx:sendError`
388
389To enable them, use `hx-ext="error-classes"`.
390
391You can add the ones you want as Tailwind variants and use them to show error messages.
392
393```js
394const plugin = require('tailwindcss/plugin')
395
396module.exports = {
397  plugins: [
398    // Add variants for htmx-request class for loading states
399    plugin(({addVariant}) => addVariant('htmx-error-response-429', ['&.htmx-error-response-429', '.htmx-error-response-429 &']))
400  ],
401}
402```
403
404## CSP
405
406```
407<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
408```