Logo

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.

Creating and editing using Django forms inside a modal using HTMX

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:

  1. We render a template called post_form_modal.html during GET requests.
  2. If a form is valid, we return an empty response, and trigger an event called posts:update followed by modal:close.
  3. 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.


Email me if you have any questions about this post.

Subscribe to the RSS feed to keep updated.