Django + HTMX = Simple modals

I thought I would share a simple example of using HTMX to manage modals in your project without having to write javascript code. This example also shows how HTMX, a tiny JS framework can allow a python backend developer make an interactive front end while still using all the tools from the backend.
What is HTMX?
HTMX is a tiny Javascript framework that allows you to add attributes to html elements, that act like ajax calls. It will allow you to set the trigger for the calls, the url to call, the parameters needed, and when you get a response, what to replace. That last part my confuse you. HTMX expects html to be returned not JSON or anything else. It takes the HTML response and uses it to replace or change a current html element.
What this means is that when you make a call to your Django backend, Django will use the templating language to return html, but just snippets of html rather than a whole page.
Getting Started: The element must exist
The first thing to understand with HTMX, is that it needs a target element that it will use the results to change. If the target does not exist, then it will complain and not do anything.
So for htmx to have an effect on a modal, then the modal element must exist. This is quite simple. Use an include to add a blank modal element to the top of each page
<div class="is-hidden" id="modal-container"></div>Yay, an element that does nothing at all except have an id.
Making the Call
Now we need the html with the htmx that will do something to this element
{% load task_tags %}
<a hx-get="{% url 'notes_task' task.id %}" hx-target="#modal-container" class="dropdown-item has-tooltip" hx-swap="outerHTML">
<span class="icon">{% svg_inline "folder-add" task.group.background_css_class %}</span>
<span class="tooltip-text">Notes</span>
</a>Lets go through this one bit at a time. Since this in a link HTMX assumes the trigger will be a click. You can set other triggers, but in this case the default is ok. I then set the url using Django’s “url” tag and set the target to “#modal-container”. The last is the hx-swap attribute. This says what part of the target to swap or change. The default is “innerHTML”, but I want to replace the whole element, so I set it to “outerHTML”.
Now we have a simple set of instructions that say do a “get” to url “notes_task” when the element id clicked, then replace the whole “#modal-container” element with the returned results.
Answering the Call
@login_required
def display_task_notes(request, task_id):
"""
Display the notes modal for a task.
"""
try:
task = Tasks.objects.get(id=task_id, group__user=request.user)
if not isinstance(task.notes, list):
task.notes = []
task.save()
except Tasks.DoesNotExist:
return render(request, 'htmx_templates/blank_modal.html',)
return render(request, 'htmx_templates/notes_modal.html',
{'task': task})The view above is pretty self explanatory. If the record does not exist, replace the blank element with the blank element, else render the notes_modal.html template
{% load static %}
<div class="modal is-active" id="modal-container">
<div class="modal-background" hx-get="{% url "blank_modal" %}" hx-trigger="click" hx-target="#modal-container" hx-swap="outerHTML"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Task Notes: {{ task.title }}</p>
<button class="delete" aria-label="close" hx-get="{% url "blank_modal" %}" hx-trigger="click" hx-target="#modal-container" hx-swap="outerHTML"></button>
</header>
<section class="modal-card-body">
<!-- Notes List -->
<div class="content">
<ul>
{% for note in task.notes %}
<li class="mb-4">
<div class="box p-3">
<p class="is-size-7 has-text-grey">
{{ note.date }}
</p>
<p class="mt-2">{{ note.text }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
</section>
<footer class="modal-card-foot is-justify-content-space-between is-align-items-stretch">
<!-- Add Note Form -->
<form
id="add-note-form"
class="is-flex is-flex-direction-column is-flex-grow-1"
hx-post="{% url 'add_note_task' task.id %}"
hx-target="#modal-container"
hx-swap="outerHTML"
>
{% csrf_token %}
<div class="field">
<label class="label is-sr-only" for="note_text">Add a note</label>
<div class="control">
<textarea class="textarea" name="note_text"
id="note_text" placeholder="Write a new note..." rows="3" required></textarea>
</div>
</div>
<c-modal-form-buttons submit_text="Add Note"></c-modal-form-buttons>
</form>
</footer>
</div>
</div>Notice that the notes_modal.html only contains the one root element and that is marked to replace the “#modal-container” element. So what happens is the when the html is swapped, the modal will suddenly pop-up for the user. Yay, the modal is visible.
So what happens when we want to close the modal.
<div class="modal-background" hx-get="{% url "blank_modal" %}" hx-trigger="click" hx-target="#modal-container" hx-swap="outerHTML"></div>Notice this line. We have the HTMX all in place to call the “blank_modal” url, with the same settings as loading the the modal, but because its a div, we add in hx-trigger=”click”.
def blank_modal(request):
"""
Return a blank modal container for HTMX requests.
"""
return render(request, 'htmx_templates/blank_modal.html')So if the background is clicked, then a call to django is made and the blank, hidden element replaces the element, thus resetting it back to where it was.
Form Submission
You will notice that I’m using HTMX on the form as well.
<form
id="add-note-form"
class="is-flex is-flex-direction-column is-flex-grow-1"
hx-post="{% url 'add_note_task' task.id %}"
hx-target="#modal-container"
hx-swap="outerHTML"
>
{% csrf_token %}
<div class="field">
<label class="label is-sr-only" for="note_text">Add a note</label>
<div class="control">
<textarea class="textarea" name="note_text"
id="note_text" placeholder="Write a new note..." rows="3" required></textarea>
</div>
</div>
<c-modal-form-buttons submit_text="Add Note"></c-modal-form-buttons>
</form>HTMX will replace the submit with its own call and include all the fields in the form. Django will just see the form as usual.
@login_required
def add_task_note(request, task_id):
"""
Add a note to a task.
"""
try:
task = Tasks.objects.get(id=task_id, group__user=request.user)
except Tasks.DoesNotExist:
return render(request, 'htmx_templates/blank_modal.html',)
note_text = request.POST.get('note_text', '').strip()
if not note_text:
return render(request, 'htmx_templates/blank_modal.html',)
if not isinstance(task.notes, list):
task.notes = []
task.notes.append({'date': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'), 'text': note_text})
task.save()
return render(request, 'htmx_templates/notes_modal.html', {'task': task})So the above code will just re-render the notes modal with the new note showing, or if there is an error, return the blank one causing the modal to close.
That is It
That’s all that needs to be done to open and close modals with Django and htmx. Adding more modals is easy. The blank modal element is there to be used, as is the template to load it. All you need to do is write the view that will render the modal to be displayed.
There is a lot more to htmx than this. I mean we can replace multiple parts of the page, chain triggers and events together, add in custom parameters, alter the target of the returning html based on the results, delete elements, and add rows to tables etc.
It also has a nice Javascript API which makes life easy when it comes to making updates based on htmx and normal html events.
The code below will trigger an Javascript function after an element has finished being swapped.
<script>
activateMermaid();
htmx.on(".mermaid-content", 'htmx:afterSettle', function(event) { activateMermaid();})
</script>I used this so that mermaid.js re-rendered its charts after I changed the element that contained the data it was using.
In the end HTMX allows you to use the power of the backend templating language to create an interactive frontend without having a complex Javascript framework. This means no more sending JSON code to the FE and then having to render html there and then listen for events when other elements need changing. The whole Front End coding becomes very simple indeed.
