Project 1: To Do App

Project 1: To Do App

November 29 2021

The first project that we will build is a single-page "to do" application. We will focus first on user authentication. Then, we will build the "to do" functionality. The goal of this project is for us to get comfortable building with an HTMX/Django stack.

Something to keep in mind is that since this is our first project, we would dive deep into some of the basic Django concepts. In future projects, we would move a lot faster and focus only on HTMX techniques. Either way, The best way to go through this tutorial is to have your IDE open and code along.

You don't need an extensive Django experience for this tutorial. Doing the official Django tutorial or something similar is sufficient. Also, this tutorial assumes that you read the introduction to the HTMX/Djano stack (Project 0 for the course). You can read it here

Here is what we will go over

  1. Configuration
    • Boilerplate
    • Startapp
  2. Django first steps
    • Overview
    • Model, Views & Templates
  3. HTMX first steps
    • Introduction
    • The base template
    • Landing page
    • Sign up
    • Adding HTMX
    • <a> tags
    • Changing <a> tags to HTMX
    • HTMX forms
  4. Core Functionality
    • Conditional rendering
    • Login with HTMX
    • Logout with HTMX
    • Add and Retrieve tasks
    • Mark tasks done
    • Delete tasks
    • refactoring
  5. Conclusion

Configuration

Boilerplate

To start, we will clone the starter code. It is just a bare-bones Django boilerplate on Docker. You can read about how I built it here.

If you are new to Django, I encourage you to follow the steps and build it yourself. Otherwise, we can just clone it and get going.

git clone https://github.com/Jonathan-Adly/django-boilerplate

You should see a new directory in your file system named "django-boilerplate". Let's change its name to our project name "todo-htmx" and move to it.

mv django-boilerplate todo-htmx
cd todo-htmx

Our directory should now look like this.

The accounts folder is a Django application that will manage our user accounts. The backend is already implemented for us via the django-allauth package. We won't do much in there for this tutorial, so we will leave it as is.

The config folder is a Django application that will manage all our settings and configuration. Again, this boilerplate already took care of setting everything up for us.

If everything looks good, the next step is to build our docker image and then run migrations.

docker compose up -d --build
docker compose exec web python manage.py migrate

The magic of Docker is beyond this tutorial. There are a few commands to understand though. We will go through them as they come up.

  • docker compose up -d --build : starts your application in a "detached" mode. Meaning the application will be working in the background.
  • docker compose exec web: Web is the name of our service, you can see it in the docker-compose.yml file. "exec" stands for execute. We are executing a command in our docker service that is called "web".
  • docker compose down: Stops your docker containers and removes the images created by the up command

Let's make sure everything looks good by navigating to localhost:8000. We should see a "Hello, world. This is a django boilerplate!" message.

Now that our configuration is in shape, we will go ahead and start our application.

Startapp

Start a new Django application

docker compose exec web python manage.py startapp todo

Before working on our application code, we need to set up a couple of things in our config directory. Every time we start an app, we need to add that app to the settings.py file and configure our URLs to look for it.

Let's add new app to the installed applications in our settings.py file.

#in config/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.sites",
    "allauth",
    "allauth.account",
    "allauth.socialaccount",
    "accounts",
    #new
    "todo",
]

Then, in our config folder, we will tell Django to look for the URLs of our new app.

#in config/urls.py
from django.contrib import admin
from django.urls import path, include

"""
delete this block, or comment it out
from django.http import HttpResponse

def home(request):
    #return HttpResponse("Hello, world. This is a django boilerplate!")
"""

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("allauth.urls")),
    path("", include("todo.urls")),  #new              
    #path("", home, name="home"), - old line
]

A couple of things are happenings here. First, we are deleting the old function and URL path that gave us "Hello, world. This is a django boilerplate!" message.

Second, we are using the include function to tell Django about our app URLs.  Here, if a URL path is empty, it will be handled in our "todo" folder (i.e, application) in a file (i.e, module) called urls.


Django First Steps

Overview

One of the many great perks of Django is that it makes us think about projects in a systemic way. Every applications generally needs a model, views to handle data processings, and HTML templates to display to our users. The same pattern apply for HTMX SPA's with a few exceptions.

In our "to do" app, we need a Task model for our "to do" items. Views to handle creating, updating, retrieving, and deleting items (CRUD). The only difference is our templates. We will go through the Django code first, then handle our templates via HTMX.

Model, Views, and Templates

Here is the code, following the same pattern,

In our todo/models.py module,

#todo/models.py
from django.db import models
from django.contrib.auth import get_user_model

class Task(models.Model):
    user = models.ForeignKey(
        get_user_model(), related_name="tasks", on_delete=models.CASCADE
    )
    name = models.CharField(max_length=250)
    done = models.BooleanField(default=False)

    def __str__(self):
        return self.name

Here, the get_user_model refers to the CustomUser model that we have in the accounts application. If you need to follow the logic from the beginning, going through the boilerplate tutorial would help.

Anytime we create or edit a model in Django, we have to create and then perform a database migration. That is, we match our database tables to our code.

docker compose exec web python manage.py makemigrations

You should see an error, something like this

"No module named 'todo.urls'"

Indeed, we don't have a urls.py module in our todo application yet. No worries, we would go ahead and make it, then go back and perform our migrations.

Let's make our urls module and build our home page URL there.

touch todo/urls.py

This will be the code for our todo/urls.py file

from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
]

Next, we will write that home function in our views module. This is the view where all our components would live.

from django.shortcuts import render

def home(request):
    return render(request, "home.html")

The last thing to do is to build this template. All our HTMX magic would be in the HTML templates, so we will do something simple for now and then add our HTMX later.

mkdir templates
touch templates/home.html

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- Meta tags -->
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
  <meta name="viewport" content="width=device-width" />

  <title> To do Application </title>

  </head>
  <body>
<h1> Start a new list </h1>
</body>

Hopefully, you didn't forget about the migrations. We will go ahead and make, then perform the migrations now.

docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate

If the migrations are successful, you should see this at localhost:8000

 


HTMX First Steps

Introduction

Before going any further with our application, we need to plan about how will it all work together from a user perspective. Like programming, design and UX can be broken into a series of steps to tackle one by one.

This is not a UX course, so we won't spend a lot of time there. But since HTMX solves a UX problem, we have to think about our user before jumping into the code.

Our app is a simple "to do" app. Its job is to keep track of tasks for a specific user. So, here are the steps a typical user might take.

  1. User goes to landing page on Localhost:8000/ . We should clearly explain what problem does our app solves.
  2. From there, our user could leave if we aren't solving their problem, sign up to use our application, or sign in to update their list if they already have an account.
  3. If they are already signed in, then they should just see the "to do" list right away.
  4. In the core functionality, the to-do list, they should be able to add tasks, mark tasks done, or delete tasks altogether. We should also have the ability for them to log out of their account

So, let's go ahead and code that flow!

The base template

The first thing we should build is our base HTML page. The base is an HTML template with a code that doesn't change. Think the head, the style, if you have any global scripts, maybe the Navbar and the footer.

touch templates/base.html

I am a big fan of this CSS framework. It is based on Bootstrap, but slightly more simplified and with a few extra tricks, Dark mode for example. They have a page builder here. Let's generate our base this way. For simplicity, I unchecked all the boxes except the CSS variable box.

Let's put the page builder code in our base.html file. It should look like this after removing the comments. You can also copy this, instead of going through the page builder.

<!-- in templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>

  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
  <meta name="viewport" content="width=device-width" />

  <title>HTMX/Django To DO SPA </title>

  <link href="https://cdn.jsdelivr.net/npm/halfmoon@1.1.1/css/halfmoon-variables.min.css" rel="stylesheet" />
</head>

<body>

  <div class="page-wrapper">
    <div class="content-wrapper">
        <!-- our code would be here -->
    </div>

  </div>

  <script src="https://cdn.jsdelivr.net/npm/halfmoon@1.1.1/js/halfmoon.min.js"></script>
</body>
</html>

Next, let's work on our user flow one by one. Unlike a traditional Django project, we would use a components structure for our application. Where we build snippets of HTML that can be swapped when the user performs certain actions.

Landing page

"User goes to landing page on Localhost:8000/ . We should clearly explain what problem does our app solves."

First thing, we need a components directory where we keep all our components organized. We will have our landing page component there.

mkdir templates/components
touch templates/components/landing_page.html

In our landing_page.html, let's put this code in it.

<div class="container-fluid d-flex align-items-center justify-content-center" style="height: 100vh;">

  <div class= "content m-0 text-center mw-full h-full">
   <h1 class="text-primary" style="font-size:50px;"> HTMX/Django: To do App </h1> 
    <p> Keep track of what needs to be done! </p>
    <button 
        class="btn btn-primary mt-10" 
        role="button" 
        >
        Start Your List 
        </button> 
</div>
</div>

Now, if you go to localhost:8000 - what do you see? The old template of home.html, right? We don't want this. We want to see the HTML we just wrote.

To see our new HTML, we need to include a block for our content in the base.html file and rework our home.html to include the component we just built.

<!-- in base.html -->

<body>
  <div class="page-wrapper">
    <div class="content-wrapper">
    
    {% block content %} {% endblock content %} <!-- new -->

    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/halfmoon@1.1.1/js/halfmoon.min.js"></script>
</body>
<!-- home.html -->
{% extends "base.html" %}

{% block content %}
 
    {% include 'components/landing_page.html' %}

{% endblock %}

So, that takes care of user story #1.

Signup

"Our user could leave if we aren't solving their problem, sign up to use our application, or sign in to update their list if they already have an account."  

To leave, they will just close the browser tab. There is nothing for us to do there. But, we should implement sign up/sign in if they want to use our app.

Specifically, we want to swap the landing_page.html component with the sign-in/sign-up component when the user clicks the "Start Your List" button.

The sign-in/sign-up logic is already implemented for us in the boilerplate via django-allauth. So, we just have to do the HTMX part and override the default allauth HTML snippets with our own styles.

Here is how we can override the allauth HTML templates. Notice, the directory name is "account", not "accounts".

mkdir templates/account
touch templates/account/signup.html

In our signup.html, add the following

<div class="content mt-20">
    <h2> Registeration </h2>
    <form method="post">
        {% csrf_token %}
        {{ form }}
        <button type="submit" class='btn mt-5'> Sign up</button>
    </form>
</div>

Again, as mentioned before, that form will post to a backend view that is already implemented for us via allauth. Also, the {{ form }} tag is taken care of by allauth.

Tip: You can do install the crispy forms package, to have your forms styled nicely! We don't need it for this tutorial though.

Adding HTMX

Now, for the HTMX part, we need to add the HTMX library to our base template and change our "Start Your List" button to swap one component with another.

<!-- in base.html -->
<!-- At the bottom before the body tag -->

<!-- HTMX CDN -->
<script src="https://unpkg.com/htmx.org@1.1.0"></script> 

<!-- CSRF request in the headers -->
<script>
document.body.addEventListener('htmx:configRequest', (e) => {
  e.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
  })
</script>

</body>
</html>

Here we are adding the HTMX library via a CDN link. Also, Django expects a CSRF token with every request to the server. We can add it to every HTMX request automatically via the event listern script 

<!-- in components/landing_page.html -->
<div class="container-fluid d-flex align-items-center justify-content-center" 
style="height: 100vh;" 
id="landing_page"> <!-- we added an id -->

  <div class= "content m-0 text-center mw-full h-full">
    <h1 class="text-primary" style="font-size:50px;"> HTMX/Django: To do App </h1> 
    <p> Keep track of what needs to be done! </p>
    <!-- we changed this button -->
    <button 
        class="btn btn-primary mt-10" 
        role="button" 
        hx-get="{% url 'account_signup' %}"
        hx-trigger="click"
        hx-target="#landing_page"
        hx-swap="outerHTML"
        hx-push-url="/signup">
        Start Your List 
        </button> 
</div>
</div>

Here is how we are swapping our landing_page component with our signup component.

  1. hx-get: We are making a GET request to the account sign up URL (implemented for us via allauth)
  2. hx-trigger: The request is triggered when the user clicks the button
  3. hx-target: The component that will be swapped out. Notice, we added an ID to our landing_page component
  4. hx-swap: How are we going to swap it. In our case, we are swapping the entire div out.
  5. hx-push-url: This pushes a new URL to the url bar, and saves the old component to the history. Without it, the URL wouldn't change.

Now, if we click the "Start Your List" button, we should see this.

Tip: if your button is not working. The command "docker compose logs" would show the application logs, where you can see where is the problem.

Now, we can polish up our application a little.

<a> tags

First, let's add a Navbar

touch templates/components/navbar.html

<!-- in home.html -->
{% extends "base.html" %}

{% block content %}
    {% include 'components/navbar.html' %} <!-- new -->

    {% include 'components/landing_page.html' %}
    
{% endblock %}
<!-- in templates/components/navbar.html -->
<nav class="navbar">
 <!-- big font on big screens-->
<a href="{% url 'home' %}" class="navbar-brand ml-10 hidden-sm-and-down">
              Trackit
 </a>
 <!-- small font on small screens -->
 <a href="{% url 'home' %}" class="navbar-brand ml-10 font-size-12 hidden-md-and-up">
              Trackit
</a>
            
      <ul class="navbar-nav hidden-sm-and-down">
      <li class="nav-item"> 
           <a href= "{% url 'home' %}" class="nav-link">Home</a>
      </li>
      {% if user.is_authenticated %}
      <li class="nav-item">
         <a href="{% url 'account_logout' %}" class="nav-link"> Log out </a>
      </li>
      {% else %}
      <li class="nav-item">
         <a href="{% url 'account_signup' %}" class="nav-link"> Sign up </a>
      </li>

       <li class="nav-item">
         <a href="{% url 'account_login' %}" class="nav-link">Log in</a>
       </li>
      {% endif %}
       </ul>
            
<div class="custom-switch mr-5 navbar-text d-inline-block">      
<input 
type="checkbox" 
id="switch-1" 
value="" 
onclick="halfmoon.toggleDarkMode()">
   <label for="switch-1" class= "font-size-12">Dark Mode</label>
</div>
                
<div class="dropdown with-arrow hidden-md-and-up ml-auto">
<button class="btn navbar-menu-btn" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-btn" aria-haspopup="true" aria-expanded="false">
 <span class="text">Menu</span>
</button>
                    
<div 
class="dropdown-menu dropdown-menu-left" 
aria-labelledby="navbar-dropdown-toggle-btn">
     <a class="dropdown-item" href="{% url 'home' %}"> Home </a>
     {% if user.is_authenticated %}
     <a class="dropdown-item" href="{% url 'account_logout' %}">Log out </a>
      {% else %}
     <a class="dropdown-item" href="{% url 'account_signup' %}">Sign up </a>
     <a class="dropdown-item" href="{% url 'account_login' %}"> Log in </a>
     {% endif %}
                  
</div>
</div>           
</nav>

Then, let's clean up our landing_page.html and add a signup link with a description instead of the button.

<div class="d-flex align-items-center justify-content-center mt-20">
  <div class= "content text-center">
    <h1 class="text-primary" style="font-size:50px;"> Trackit: To do App </h1> 
    <p> Keep track of what needs to be done! </p>
      
    <p> To start, please make an <a href="{% url 'account_signup' %}"> account</a>.
        
    </div>
</div>

Did you notice anything about our application structure? All our links are <a> tags. So, we are back to a traditional web application.

We will fix this in a second. One neat trick for development with HTMX is that we can start with a normal Django application. Once we are confident that everything works, we can switch to a single-page application pattern.

If we wanted too, we can even leave those <a> tags as is and only do our To-Do functionality as a SPA. That's another great feature of HTMX, you could easily use it only in the parts that you want. For educational purposes though, we will make our entire application run on HTMX.

After our polish, you will notice that clicking on the navbar or the link renders our signup.html as a full page (i.e, without any styling or the navbar). We can solve this problem by extending our base.html to our signup.html component.

Also while we are there, we will go ahead and override the allauth login.html with our own.

touch templates/account/login.html

Here is the code for both the sign up and login pages.

<!-- account/signup.html -->
{% extends "base.html" %}

{% block content %}
<div class="content mt-20 text-center" >

    <h2> Sign up to start your list </h2>
    <form method="post" action="{% url 'account_signup' %}">
        {% csrf_token %}
        {{ form }}
        <button type="submit" class='btn mt-5'> Sign Up </button>
    </form>

</div>  
{% endblock %}
<!-- account/login.html -->
{% extends "base.html" %}

{% block content %}
    
<div class="content mt-20 text-center">
<h2> Log in  </h2>
    <form method="post" action="{% url 'account_login' %}">
        {% csrf_token %}
        {{ form }}
        <button type="submit" class='btn mt-5'> log in </button>
    </form>
</div>


  {% endblock %}

Go ahead and test signing up a test user and then log out and back in.

email: test_signup@test.com password: testpass123

Notice that the logout page is not well styled? That's the default allauth one. You can override it the same way we did with signup. All these templates are available to be overridden. We would handle logout later though, so don't override yet.

What we want to do next is get rid of these <a> tags and keep our single-page application goal.

Changing <a> tags to HTMX

There are a couple of ways of going about this. Since our app is empty right now, we can add an empty <div> below the landing_page component. This <div> will hold the signup component, so everything stays on one page.

<!-- home.html -->
{% extends "base.html" %}

{% block content %}
    {% include 'components/navbar.html' %}

    {% include 'components/landing_page.html' %}
    
    <div id="authentication"> </div>
{% endblock %}

Then in our landing_page.html, we will go ahead and change the <a> tag to HTMX.

<div class="d-flex align-items-center justify-content-center mt-20">
  <div class= "content text-center">
    <h1 class="text-primary" style="font-size:50px;"> Trackit: To do App </h1> 
    <p> Keep track of what needs to be done! </p>
      
    <p> To start, please make an 
    <span 
    hx-get="{% url 'account_signup' %}" 
    hx-target="#authentication"
    hx-trigger="click"
    class= "hyperlink-underline"
    >
    account
    </span>.
    
        
    </div>
</div>

Here, we are using a span as a pretend link. On click (hx-trigger), we are getting (hx-get) our signup URL. The response to our request, will go inside the authentication <div>.

Notice that we didn't write out the hx-swap attribute. Last time, we wrote hx-swap="outerHTML" to change the whole <div>. Here, we are changing only what's inside the <div> with the id "authentication". It is an "innerHTML" swap, which is the default for HTMX. So, we don't have to write it!

Lastly, since our span is only a pretend link, we need to style it to look like a real link.

<!-- in base.html just before the head tag ends-->
<style>
.hyperlink-underline {
  cursor: pointer;
}
</style>
</head>

<body>

 

Now, make sure you are logged out, and then try signing up another test user.

email: test_signup1@test.com password: testpass123

Did it work? Great!

Now try doing something like this.

email: test_signup2@test.com password: test

What happened? You got back the signup.html component as a full page with several errors! It's a bit jarring to the user.

HTMX forms

To fix this, we need to change the signup.html form itself to use HTMX, instead of the traditional POST method. Here is how,

<!-- signup.html -->
{% extends "base.html" %}

{% block content %}
<div class="content mt-20 text-center">

    <h2> Sign up to start your list </h2>
    <form
    hx-post="{% url 'account_signup' %}"
    hx-target= "#authentication"
    >
        {% csrf_token %}
        {{ form }}
        <button type="submit" 
        class='btn mt-5'> Sign Up </button>
    </form>

    <a href="{% url 'account_login' %}"> Need to Login?</a>

</div>  
{% endblock %}

Here, we left out both our hx-swap attribute and our hx-trigger attribute. For forms, the default trigger is submit, so we don't have to write it. And, since we want the response to go inside the authentication <div>, we are using the default innerHTML swap.

Now, let's try it again signing up this user.

email: test_signup2@test.com password: test

What happened?

The response that came back from the request wasn't a full page. Instead, it only went inside the div with the id="authentication". Hooray! It worked.

We could style it and make it look nicer with some CSS halfmoon classes and crispy forms. But, that's something you can experiment with on your own.

Using those same techniques, we will change all our <a> tags to HTMX. There is a hx-boost attribute that can make us write less code. But, it's a bit magical for us right now, so we will stick with our normal hx-post/hx-get, hx-swap, hx-trigger and hx-target.

Here is our new navbar with HTMX attributes instead of <a> tags. I left the home URL as <a> tag for us to see the difference.

<nav class="navbar">
 <!-- big font on big screens-->
<a href="{% url 'home' %}" class="navbar-brand ml-10 hidden-sm-and-down">
              Trackit
 </a>
 <!-- small font on small screens -->
 <a href="{% url 'home' %}" class="navbar-brand ml-10 font-size-12 hidden-md-and-up">
              Trackit
</a>
            
      <ul class="navbar-nav hidden-sm-and-down">
      <li class="nav-item"> 
           <a href= "{% url 'home' %}" class="nav-link">Home</a>
      </li>

      {% if user.is_authenticated %}
      <li 
      class="nav-item"
      hx-get="{% url 'account_logout' %}"
      hx-target= "#authentication"
      hx-trigger= "click"
      >
         <span class="nav-link"> Log out </span>
      </li>
      {% else %}
      <li 
      class="nav-item"
      hx-get="{% url 'account_signup' %}"
      hx-target= "#authentication"
      hx-trigger= "click"
      >
         <span class="nav-link"> Sign up </span>
      </li>

       <li 
       class="nav-item"
       hx-get= "{% url 'account_login' %}"
       hx-target= "#authentication"
       hx-trigger= "click"
       >
         <a class="nav-link">Log in</a>
       </li>
      {% endif %}
       </ul>
            
<div class="custom-switch mr-5 navbar-text d-inline-block">      
<input 
type="checkbox" 
id="switch-1" 
value="" 
onclick="halfmoon.toggleDarkMode()">
   <label for="switch-1" class= "font-size-12">Dark Mode</label>
</div>
                
<div class="dropdown with-arrow hidden-md-and-up ml-auto">
<button class="btn navbar-menu-btn" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-btn" aria-haspopup="true" aria-expanded="false">
 <span class="text">Menu</span>
</button>
                    
<div 
class="dropdown-menu dropdown-menu-left" 
aria-labelledby="navbar-dropdown-toggle-btn">
     <a class="dropdown-item" href="{% url 'home' %}"> Home </a>
     {% if user.is_authenticated %}
     <span 
     class="dropdown-item" 
     hx-get="{% url 'account_logout' %}"
     hx-target= "#authentication"
     hx-trigger= "click"
     >
     Log out 
     </span>
      {% else %}
     <span 
     class="dropdown-item" 
     hx-get="{% url 'account_signup' %}"
     hx-target= "#authentication"
     hx-trigger= "click"
     >
     Sign up 
     </span>
     <span 
     class="dropdown-item" 
     hx-get="{% url 'account_login' %}"
     hx-target= "#authentication"
     hx-trigger= "click"
     > 
     Log in 
     </span>
     {% endif %}
                  
</div>
</div>           
</nav>

Also, let's fix the small bug where a user can get the signup form if they are logged in.

<!-- landing_page.html -->
    {% if not user.is_authenticated %}
    <p> To start, please make an 
    <span 
    hx-get="{% url 'account_signup' %}" 
    hx-target="#authentication"
    hx-trigger="click"
    class= "hyperlink-underline"
    >
    account
    </span>.
    {% endif %}

Lastly, for good UX, it will be nice if the user can toggle back and forth between the signup and login components. Like this.

To do this, we will add a hx-get span to both components. Also, we will go ahead and change the login form to work with HTMX.

{% extends "base.html" %}

{% block content %}
    
<div class="content mt-20 text-center">
<h2> Log in  </h2>
<!-- new -->
    <form 
    hx-post="{% url 'account_login' %}"
    hx-target= "#authentication">
        <!-- no longer needed {% csrf_token %} -->
        {{ form }}
        <button type="submit" class='btn mt-5'> log in </button>
    </form>
    <!-- new -->
    <span 
    hx-get="{% url 'account_signup' %}" 
    hx-target="#authentication"
    hx-trigger="click"
    class= "hyperlink-underline"
    >
    Need to Signup?
    </span>
</div>


  {% endblock %}
<!-- signup.html -->
{% extends "base.html" %}

{% block content %}
<div class="content mt-20 text-center">

    <h2> Sign up to start your list </h2>
    <form
    hx-post="{% url 'account_signup' %}"
    hx-target= "#authentication"
    >
        <!-- no longer needed {% csrf_token %} -->
        {{ form }}
        <button type="submit" 
        class='btn mt-5'> Sign Up </button>
    </form>

    <span 
    hx-get="{% url 'account_login' %}" 
    hx-target="#authentication"
    hx-trigger="click"
    class= "hyperlink-underline"
    >
    Need to Login?
    </span>
   

</div>  
{% endblock %}

We made some serious progress. So, let's commit our progress to a Git repository.

git add -A
git commit -m "first commit

You can see the code up to this point here. If you tried experimenting and noticed something that is broken, we wil get to that in the next section.


Core functionality

We finished our first two user stories, we still have these left.

  1. If they are already signed in, then they should just see the "to do" list right away.
  2. In the core functionality, the to-do list, they should be able to add tasks, mark tasks done, or delete tasks altogether. We should also have the ability for them to log out of their account

If they are already signed in, then they should just see the "to do" list right away.

Conditional rendering

To do this, we could take advantage of Django conditional rendering. Here is our home template code would look like.

<!-- home.html -->
{% extends "base.html" %}

{% block content %}
    {% include 'components/navbar.html' %}
    {% include 'components/landing_page.html' %}
        
    {% if user.is_authenticated %}
        {% include 'components/tasks.html' %}
    {% endif %}
    <div id="authentication"> </div>
{% endblock %}

Here, the template filter "user.is_authenticated" checks if the user is logged in or not. If they are, we render a component called tasks.html that will hold our core functionality. If there aren't, then the component never renders!

Let's add that tasks.html component.

touch templates/components/tasks.html

<!-- in components/tasks.html -->
<div class="card w-600 mw-full mx-auto">
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
<li> {{task}} </li>
{% empty %}
<li> No items on your list yet </li>
{% endfor %}

</ul>
</div>

Now, let's log in with our test_signup user and see if it works. (log out if you are still logged in).

Looks like something is broken, right? In our login form, we said that we want the response to replace the innerHTML of the "authentication" <div>. The response for a successful login is the home.html template. So, it gets rendered twice.

Login with HTMX

We now have a problem. Authentication can return errors or a successful login/signup. Ideally, on error we want to return the errors plus the login (or signup) form inside the authentication <div>. But, on a successful log in, we would want to replace the whole DOM with the response.

The ideal solution is to override the allauth views, but that's too complicated for this tutorial. We may as well not use it and roll our own authentication if we are going to do that.

Another solution is to keep authentication as is with traditional forms and <a> tags. We don't get that much extra from a UX perspective, but we get a lot in terms of stability and well-tested software.

However, this breaks our single-application theme and you are here to learn how to build SPAs with Django and HTMX.

What we will do is change the allauth configuration to return a dedicated auth component on successful login or signup. This component will go in the authentication <div>. Then, as soon as that auth component is loaded, we would use HTMX to make a GET request to our home URL endpoint. Then the response from this GET request is going to replace our whole DOM.

So let's do it!

First, change the allauth configuration to return a dedicated auth view.

#in config/settings.py
LOGIN_REDIRECT_URL = "auth"  # previously "home"

Then, let's build the URL endpoint for this view, and the view itself.

#in todo/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("auth", views.auth, name="auth"), #new
]

#in todo/views.py

def auth(request):
    return render(request, "components/auth.html")

Let's go ahead and build that component using our HTMX magic.

touch templates/components/auth.html

<!-- auth.html -->
<div hx-get="{% url 'home' %}"
     hx-trigger="load"
     hx-target="#content">  <!-- no hx-swap means innerHTML -->
working...
</div>

Here, our trigger is "load". In other words, as soon as our auth component loads, we would trigger a GET request to the home URL, and put the response to that request inside the content <div>.

We need to change our base.html template to add the id "content" , so our auth component knows what to target.

<!-- in base.html -->
 <div class="page-wrapper">
    <div class="content-wrapper" id="content"> <!-- new -->
    
{% block content %} {% endblock content %}
        
    </div>
  </div>

Now, let's manually test the following:

  1. Signing up a new user with an error
  2. Signing up a new user successfully
  3. Logging out our user
  4. Logging in our user with an error
  5. Loggin in our user successfully

Email: test_user0@test.com - Password: testpass123. To trigger an error, use Password: test

On success, you should see something like this.

If we close our localhost tab and reopen it again, we should still see our empty tasks card. So, our goal is achieved. If a user is logged in, they will see their list right away!  

Logout with HTMX

Our logout is still ugly though, so let's improve it using a new HTMX trick - hx-confirm.

The way allauth works is if you make a "GET" request to the logout endpoint, you get a confirmation form, with a logout submit button. To log out right away, you need to make a "POST" request to the logout endpoint.

So, our plan is to make a POST request to logout and use the browser alert box as a confirmation. Here are the changes to our nav bar,

<!-- components/navbar.html -->
<!-- hx-confirm uses the browser alert for confirmation -->
<!-- the response will replace the innerHTML of #content -->
<!-- nothing else changed in the nav bar but the logout span -->
<li 
      class="nav-item"
      hx-post="{% url 'account_logout' %}"
      hx-trigger= "click"
      hx-confirm="Are you sure you wish to log out?"
      hx-target= "#content"
      >
      
         <span class="nav-link"> Log out </span>
      </li>
...
<!-- small screen view -->
<span 
     class="dropdown-item" 
     hx-post="{% url 'account_logout' %}"
     hx-trigger= "click"
     hx-confirm="Are you sure you wish to log out?"
     hx-target= "#content"
     >
     Log out 
     </span>

Now, we can go ahead and Git commit this new functionality.

Let's move to our last user story!

In the core functionality, the to-do list, they should be able to add tasks, mark tasks done, or delete tasks altogether. We should also have the ability for them to log out of their account

Okay, this is really a terrible user story. It has a lot of things bunched together. So, let's break it down to even smaller pieces.

Add and Retrieve tasks

Adding tasks is creating new data. So, we have to go back to think in Django. To add tasks, we need a form to capture the data, a URL endpoint that we can post the data to, and a view to handle the submission.

Also, we would use the same URL/View to handle retrieving tasks. A GET request to see our tasks, and a POST request to add a new task. You know, the Django way.

We could make a new URL endpoint, but using our "home" also works since we are not posting anything against it.

Let's do the form first

touch todo/forms.py

#in todo/forms.py
from django import forms
from django.forms import ModelForm
from .models import Task


class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ("name",)
        labels = {
            "name": "Add new task:",
        }

Here we are using Django model forms. A very handy Django helper class, that allows us to build forms quickly based on our model's fields. All we have to do is specify the model and what fields we want rendered and Django takes care of the rest.

Notice, I specified the label in that case. Without specifying, Django would have rendered the label as "Name", which is probably confusing in a to do list.

Our next step is to render that form in our component.

#in todo/views.py
from django.shortcuts import render
from allauth.account.forms import SignupForm
from .models import Task
from .forms import TaskForm #new


def home(request):
    form = TaskForm() #new
    return render(request, "home.html", {"form": form}) #new

 

<!-- in tasks.html -->
<div class="card w-600 mw-full mx-auto">
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
<li> {{task}} </li>
{% empty %}
<li> No items on your list yet </li>
{% endfor %}
<!-- new -->
    <form method="POST">
      {% csrf_token %}
        {{ form }}
        <button type="submit"> Add </button>
    </form>


</ul>
</div>

Now, our form is rendered. Notice that we are not using HTMX yet. Again, starting out with normal Django and then adding HTMX is a very useful pattern to master.

Next, we want to handle retrieving our tasks. We already wrote the HTML for it, we just need to update our view.

from django.shortcuts import render
from allauth.account.forms import SignupForm
from .models import Task
from .forms import TaskForm


def home(request):
    # if we have a GET request
    if request.method == "GET":
        # retrieve the old tasks if our user is logged in
        if request.user.is_authenticated:
            tasks = Task.objects.filter(user=request.user)
        else:
            tasks = None
        # render the form
        form = TaskForm()
        # Return our home template.
        return render(
            request, "home.html", {"form": form, "tasks": tasks, "errors": None}
        )
    # request method is POST
    else:
        pass

That should take care of retrieving tasks. Next, let's handle adding tasks.

To use HTMX, we would want to post a task, then return a component with all the user tasks, including the new one.

Our HTMX form would look like this,

<div class="card w-600 mw-full mx-auto" id="tasks"> <!-- new id -->
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
<li> {{task}} </li>
{% empty %}
<li> No items on your list yet </li>
{% endfor %}
<!-- new HTMX -->
    <form 
    hx-post="{% url 'home' %}"
    hx-swap= "outerHTML"
    hx-target = "#tasks"
    >
        {{ form }}
        <button type="submit"> Add </button>
    </form>

<!-- no hx-trigger on a form means it will be on submit -->
</ul>
</div>

Here, we are saying that on form submit (default HTMX trigger for forms), post the data to the Django url "home" and swap the "tasks" <div> with the response.

On our view, we should handle this POST request as such,

from django.shortcuts import render
from allauth.account.forms import SignupForm
from .models import Task
from .forms import TaskForm


def home(request):
    # if we have a GET request
    if request.method == "GET":
        # nothing changed here
    
    # request method is POST
    else:
        #new
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save(commit=False)
            task.user = request.user
            task.save()
            # we would only return our tasks components with the updated tasks
            tasks = Task.objects.filter(user=request.user)

            return render(
                request,
                "components/tasks.html",
                {
                    "form": TaskForm(),
                    "tasks": tasks,
                    "errors": None,
                },  # a new empty form, since we saved the posted one
            )

        # form is not valid, we have some kind of error
        else:
            errors = form.errors
            tasks = Task.objects.filter(user=request.user)
            # we would return only our tasks components with the old tasks, and the errors
            return render(
                request,
                "components/tasks.html",
                {
                    "form": form,
                    "tasks": tasks,
                    "errors": errors,
                },  # the posted form, since it didn't save
            )

This is more or less the standard way of handling form requests in Django. The only difference is, we are returning a component on POST requests, instead of a full HTML template.

Also, we should go ahead and render the errors in our component if there are any.

<!-- tasks.html -->
...
{% if errors %} {{errors}} {% endif %} <!-- new -->
    <form 
    hx-post="{% url 'home' %}"
    hx-swap= "outerHTML"
    hx-target = "#tasks"
    >
        {{ form }}
        <button type="submit"> Add </button>
    </form>

Okay, let's test it out by adding a few tasks. You should see something like this.

If everything works, let's go ahead and Git commit our work.

Mark tasks done

Our next job is for our user to toggle done/not done for tasks. Again, to do that, we would need a URL endpoint to post against, a view to handle the request, and some sort of a DOM representation of completion.

First our url endpoint needs to be task specific. So, we would put the task id (or primary key) in the URL, to know what task to mark complete.

from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("auth", views.auth, name="auth"),
    path("<int:task_id>/complete", views.complete, name="complete"), #new
]

And this is our view,

# todo/views.py
from django.views.decorators.http import require_POST #new
...
#new
@require_POST
def complete(request, task_id):
    task = Task.objects.get(id=task_id)
    if task.done == True:
        task.done = False
    else:
        task.done = True
    task.save()
    tasks = Task.objects.filter(user=request.user)
    # our tasks components needs a form, tasks, and errors to render
    return render(
        request,
        "components/tasks.html",
        {
            "form": TaskForm(),
            "tasks": tasks,
            "errors": None,
        },
    )

Finally, for our DOM representation, we would render a checkbox to mark completion. A task that is done, would be crossed off with a checked box. A task that is not done, would render normally with an unchecked box.

When a user clicks the checkbox, we make a POST request to the "complete" endpoint, which toggles the task on and off - returning a new, updated list of tasks.

<!-- tasks.html -->
<div class="card w-600 mw-full mx-auto" id="tasks">
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
<!-- tasks are done needs a checked box, and crossed off name -->
{% if task.done %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-done-{{task.id}}" 
        value="" 
        checked="checked" 
        hx-post= "{% url 'complete' task.id %}"
        hx-swap= "outerHTML"
        hx-target = "#tasks"
        hx-trigger= "click"
        >
        <label for="checkbox-done-{{task.id}}"><del> {{ task.name }} </del> </label>
         <!-- <del> crosses off the task  -->
    </div>
{% else %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-notdone-{{task.id}}" 
        value="" 
        hx-post= "{% url 'complete' task.id %}"
        hx-swap= "outerHTML"
        hx-target = "#tasks"
        hx-trigger= "click"
        >
        <label for="checkbox-notdone-{{task.id}}">{{ task.name }}  </label>
      
    </div>
{% endif %} 

{% empty %}
<li> No items on your list yet </li>
{% endfor %}
{% if errors %} {{errors}} {% endif %}
    <form 
    hx-post="{% url 'home' %}"
    hx-swap= "outerHTML"
    hx-target = "#tasks"
    >
        {{ form }}
        <button type="submit"> Add </button>
    </form>


</ul>
</div>

 

Now, we can refactor our code a bit. First, since everything in our tasks <div> uses the same hx-swap and hx-target. We can put these on the parent div, and the children will inherit it.

Here is the new tasks component,

<!-- tasks.html -->
<!-- the hx-swap and target moved to the parent div -->
<div class="card w-600 mw-full mx-auto" 
id="tasks"  
hx-swap= "outerHTML"
hx-target = "#tasks"
>
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
{% if task.done %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-done-{{task.id}}" 
        value="" 
        checked="checked" 
        hx-post= "{% url 'complete' task.id %}"
        hx-trigger= "click"
        >
        <label for="checkbox-done-{{task.id}}"><del> {{ task.name }} </del> </label>
         
    </div>
{% else %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-notdone-{{task.id}}" 
        value="" 
        hx-post= "{% url 'complete' task.id %}"
        hx-trigger= "click"
        >
        <label for="checkbox-notdone-{{task.id}}">{{ task.name }}  </label>
      
    </div>
{% endif %} 

{% empty %}
<li> No items on your list yet </li>
{% endfor %}
{% if errors %} {{errors}} {% endif %}
    <form 
    hx-post="{% url 'home' %}"
    >
        {{ form }}
        <button type="submit"> Add </button>
    </form>


</ul>
</div>

Also, the ordering of our tasks is not explicit, so it changes on each request. Let's specify our ordering in our model, so it renders the same each time.

# in todo/models.py

from django.db import models
from django.contrib.auth import get_user_model


class Task(models.Model):
    user = models.ForeignKey(
        get_user_model(), related_name="tasks", on_delete=models.CASCADE
    )
    name = models.CharField(max_length=250)
    done = models.BooleanField(default=False)
    
    #new
    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name

Let's go ahead and commit our changes.

Now, we only have one thing left to do. Delete tasks.

Delete Tasks

For deleting the tasks, we will do exactly as we did with updating.

# in todo/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("auth", views.auth, name="auth"),
    path("<int:task_id>/complete", views.complete, name="complete"),
    path("<int:task_id>/delete", views.delete, name="delete"), #new
]
#in todo/views.py
def delete(request, task_id):
    Task.objects.filter(id=task_id).delete()
    tasks = Task.objects.filter(user=request.user)
    return render(
        request,
        "components/tasks.html",
        {
            "form": TaskForm(),
            "tasks": tasks,
            "errors": None,
        },
    )

Then, for our tasks component, we will add a button that will delete the task for tasks that are done or not done.

<!-- tasks.html -->
<!-- right after the label on both checkboxes -->
<button class="close" hx-post= "{% url 'delete' task.id %}"> &times; </button>

We already did the log out functionality through the nav bar, so our user story is complete!

Go ahead and commit your code. Our next part, we will refactor our code even more, then we will be done.

You can see the code up to this point here.

Refactoring.

Our application is now complete. Now, the code in our views is a little messy. We are repeating ourselves a lot, and we are carrying the form and errors variable in our complete and delete views.

Also, our tasks component could be cleaned up.

To fix this, we are going to break our tasks component into two parts. The task form which doesn't need to change on every request, and the actual tasks. Again, this is really optional, but the point is to illustrate how can we break a big component into smaller pieces for a cleaner codebase. The idea is to keep what doesn't need to be changed the same, and to update only the parts that change.

touch templates/components/task_form.html
touch templates/components/task_list.html

Then, we will copy our form in task_form.html and our tasks in the task_list.html with minor modifications. The tasks component will include both of them.

<!-- tasks.html -->
<div class="card w-600 mw-full mx-auto">

{% include 'components/task_list.html' %}
{% include 'components/task_form.html' %}

</div>
<!-- task_form.html -->
   <form 
    hx-post="{% url 'home' %}"
    hx-target="#tasks" 
    hx-swap="outerHTML"
    >
        {{ form }}
        <button type="submit"> Add </button>
    </form>
<!-- task_list.html --> 
<!-- this is the only part that will change on POST requests -->
<div
id="tasks"  
hx-swap= "outerHTML"
hx-target = "#tasks"
>
<h3 class="card-title"> Your tasks </h3>
<ul> 
{% for task in tasks %}
{% if task.done %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-done-{{task.id}}" 
        value="" 
        checked="checked" 
        hx-post= "{% url 'complete' task.id %}"
        hx-trigger= "click"
        >
        <label for="checkbox-done-{{task.id}}"><del> {{ task.name }} </del> </label>
        <button class="close" hx-post= "{% url 'delete' task.id %}"> &times; </button>
    </div>
{% else %} 
    <div class="custom-checkbox">
        <input 
        type="checkbox" 
        id="checkbox-notdone-{{task.id}}" 
        value="" 
        hx-post= "{% url 'complete' task.id %}"
        hx-trigger= "click"
        >
        <label for="checkbox-notdone-{{task.id}}">{{ task.name }}  </label>
         <button class="close" hx-post= "{% url 'delete' task.id %}"> &times; </button>
      
    </div>
{% endif %} 

{% empty %}
<li> No items on your list yet </li>
{% endfor %}
</ul>

{% if errors %} {{errors}} {% endif %}
</div>

Now, the only dynamic component is the task_list.html, while the form doesn't need to travel back and forth. So, we can simplify our views.py as such,

# in todo/views.py
# we are only returning the task_list.html component in POST requests

from django.shortcuts import render
from django.views.decorators.http import require_POST

from .models import Task
from .forms import TaskForm


def home(request):
    # if we have a GET request
    ...
    # request method is POST
    else:
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save(commit=False)
            task.user = request.user
            task.save()
            # we would only return our task_list components with the updated tasks
            tasks = Task.objects.filter(user=request.user)
            return render(request, "components/task_list.html", {"tasks": tasks})

        # form is not valid, we have some kind of error
        else:
            errors = form.errors
            tasks = Task.objects.filter(user=request.user)
            # we would return only our tasks components with the old tasks, and the errors
            return render(
                request, "components/task_list.html", {"tasks": tasks, "errors": errors}
            )

@require_POST
def complete(request, task_id):
    task = Task.objects.get(id=task_id)
    if task.done == True:
        task.done = False
    else:
        task.done = True

    task.save()
    tasks = Task.objects.filter(user=request.user)
    return render(request, "components/task_list.html", {"tasks": tasks})


@require_POST
def delete(request, task_id):
    Task.objects.filter(id=task_id).delete()
    tasks = Task.objects.filter(user=request.user)
    return render(request, "components/task_list.html", {"tasks": tasks})

And that's it. We are done with our first single-page HTMX/Django application. You can see the full code here.

Conclusion

In this tutorial, we built a full to do application using Django and HTMX. We learned about the HTMX attributes of hx-get, hx-post, hx-target, hx-swap, and hx-trigger, and hx-confirm.

We saw how to handle authentication with HTMX, specifically the different patterns we can use. Also, we implemented our "to do" functionality in the same way.

This is the first project of an eight part interactive course of building single-page applications with HTMX and Django. If you want to be notified when the full course will come out, please subscribe below

Get notified about new HTMX/Django tutorials
You will only get an email whenever a post or a course is published!

Copyright © HTMX-Django 2021.

Built by Jonathan Adly. Contact for opportunities.