Show Django forms inside a modal using HTMX
Learn how to create and edit models using Django forms inside a modal using HTMX
Published: 25 Dec, 2024
Previously, you learned how to show modals in Django using HTMX. But some readers were confused about the pattern, and wanted a more concrete example.
One reader wanted to know how to use this pattern to do CRUD against a model using regular Django forms inside a modal. In this post, I’ll show you how to build a small app called Eks, where users are able to post something. The screencast below shows what we’re going to build.
Notice how the list of posts is updated without any page refresh, and that the modal is destroyed either after a valid form is submitted, or when the user manually closes it. After going through this tutorial, you’ll see how HTMX makes building user friendly interfaces a piece of cake.
See this repository for the full project.
Post Model and Form
Start a new app called posts and create a model for the posts:
from django.db import models
class Post(models.Model):
body = models.TextField()
And a form to manipulate it:
from django import forms
from django.core.exceptions import ValidationError
from eks.posts.models import Post
class PostForm(forms.ModelForm):
def clean_body(self):
value = self.cleaned_data['body']
if len(value) < 10:
raise ValidationError("Posts must be longer than 10 characters.")
return value
class Meta:
model = Post
fields = ["body"]
Listing posts
Create a view to list posts:
def list_posts(request):
posts = Post.objects.all()
list_only = request.GET.get("list_only", "no") == "yes"
if list_only:
tmpl = "_posts.html"
else:
tmpl = "index.html"
return render(request, tmpl, {"posts": posts})
This view will render different templates based on the list_only query parameter. You’ll understand why later.
Here’s the index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eks</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<div class="container">
<h1 class="mb-5">Eks</h1>
<section>
<div class="d-flex align-items-center justify-content-between mb-4">
<h2>Posts</h2>
<button hx-get="{% url 'add-post' %}" hx-target="body" hx-swap="beforeend" class="btn btn-primary">Add
post</button>
</div>
{% include "_posts.html" %}
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
<script>
function getModal() {
const modalEl = document.getElementById("modal");
const bsModal = window.bootstrap.Modal.getOrCreateInstance(modalEl);
return [modalEl, bsModal]
}
document.addEventListener("modal:show", () => {
const [modalEl, bsModal] = getModal();
bsModal.show();
modalEl.addEventListener("hidden.bs.modal", () => {
modalEl.remove();
});
});
document.addEventListener("modal:close", () => {
const bsModal = getModal()[1];
bsModal.hide();
})
</script>
</body>
</html>
This template contains a button for adding posts. We’ll implement the view for that later. It also includes a partial called _posts.html. Create that next:
<div data-posts-container hx-get="{% url 'posts' %}?list_only=yes" hx-trigger="posts:update from:body">
{% if posts %}
<ul class="list-group">
{% for post in posts %}
<li class="list-group-item">
<p>{{ post.body }}</p>
<button hx-get="{{ post.edit_url }}" hx-target="body" hx-swap="beforeend" class="btn btn-outline-secondary">Edit
post</button>
</li>
{% endfor %}
</ul>
{% else %}
<p class="alert alert-info">You don't have any posts yet.</p>
{% endif %}
</div>
This template renders the list of posts from the context. Notice also that we’re using HTMX to issue a GET request to the list view, with the list_only query parameter set to yes. This request will be triggered after an event called posts:update is fired from anywhere in the HTML body. You’ll understand why we’re doing this later. For now, know that this allows us to update the list of posts independently from the whole page.
Notice the edit button as well. We’ll implement this next.
Post form views
The list of posts will be empty for now and there’s not way to create new posts from the UI.
Let’s add view to show a form inside a modal:
def post_form(request, pk=None):
ctx = {}
if pk is not None:
post = get_object_or_404(Post, pk=pk)
ctx["action_url"] = post.edit_url
ctx["modal_title"] = "Edit post"
else:
post = None
ctx["action_url"] = reverse_lazy("add-post")
ctx["modal_title"] = "Post something"
if request.method == "POST":
form = PostForm(request.POST, instance=post)
if form.is_valid():
form.save()
response = HttpResponse()
response = trigger_client_event(response, "posts:update", after="swap")
return trigger_client_event(response, "modal:close", after="swap")
else:
ctx["form"] = form
response = render(request, "_post_form.html", ctx)
response = reswap(response, "outerHTML")
return retarget(response, "[data-post-form]")
else:
form = PostForm(instance=post)
ctx["form"] = form
response = render(request, "post_form_modal.html", ctx)
return trigger_client_event(response, "modal:show", after="swap")
There’s a lot going on in this view. It basically allows both creating, and updating posts.
Notice the following:
- We render a template called post_form_modal.html during GET requests.
- If a form is valid, we return an empty response, and trigger an event called posts:update followed by modal:close.
- If the form is invalid, we render a template called _post_form.html, and instruct HTMX to swap out the element with attribute [data-post-form].
Remember how we were issuing a GET request to the post lists view after an event called posts:update is triggered? Well, this is where we’re triggering the request. It allows us to update the list after a post is created and the modal is closed.
Take a look at the post_form_modal.html template:
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">{{ modal_title }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% include '_post_form.html' %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
We’re including the _post_form.html partial in the body. Create it now:
<form hx-post="{{ action_url }}" hx-swap="none" data-post-form>
{% csrf_token %}
<div class="mb-3">
<textarea class="form-control {% if form.errors.body %}is-invalid{% endif %}" name="body" id="postBody"
placeholder="What is happening?!">{{ form.body.value|default:"" }}</textarea>
{% if form.errors.body %}
{% for error in form.errors.body %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
Now it’s time to hook those views up in the URL dispatcher. Here’s what mine looks like:
path("new/", views.post_form, name="add-post"),
path("edit/<int:pk>/", views.post_form, name="edit-post"),
path("", views.list_posts, name="posts"),
The add button will issue a GET to the URL named add-post.
Add a property to the Post modal to make it easier to edit posts:
from functools import cached_property
from django.db import models
from django.urls import reverse_lazy
class Post(models.Model):
body = models.TextField()
@cached_property
def edit_url(self):
return reverse_lazy("edit-post", kwargs={"pk": self.pk})
The edit button will use the URL returned by the edit_url property.
Cool, now click the add button to add a post. Next, try to edit it. Then, try to create a post containing less than ten characters, and you’ll see that the form is rerendered with errors. Everything should work as in the screencast above.
Conclusion
As you saw, using HTMX with Django makes building these kind of CRUD interfaces very easy because you’re able to communicate with your front-end using regular Javascript events and special headers understood by HTMX.
Apply this pattern in your own apps now, and you’ll be amazed how much you can do with this simple tool.