HTMX Examples using Django - Click to Edit

HTMX

I want to learn HTMX and the best way for me to do so is by example. What often happens is I start learning something, pick up the basics but don't immerse myself fully in what I am learning so don't get full value out of whats on offer.

I'm really exited about HTMX , it just makes sense to me. Firstly for the uninitiated what is it? This is what it says on its website:

htmx allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext

htmx is small (~10k min.gz'd), dependency-free, extendable & IE11 compatible

So what this means is that we can do ajaxy stuff without having to write jquery or implement something in React or Vue in order to avoid page refreshes. That is essentially what we want a web app that doesn't have to reload a page unnecessarily with minimal effort!!

Its been nagging on me for a couple months and I keep hearing about it so this week I decided to take the plunge and get to grips with htmx.

On the htmx website there are a bunch of really useful examples that explain how to use it. What I will be doing is taking each one of these examples and writing a blog post on how to implement it in Django. These examples will be available here:

https://github.com/chriswedgwood/django-htmx-examples

I hope this will prove helpful to anybody trying to learn Django. There is also every chance I may choose a method in Django that may not be best practice, if so I invite any Djangonauts with a lot of experience to let me know how it could be done better. Drop me a message @chriswedgwood on Twitter if you have any thoughts or suggestions. They will be most welcome.

Without further adieu lets look at the first example https://htmx.org/examples/click-to-edit/

Setup

This repo(https://github.com/chriswedgwood/django-htmx-examples) has all the examples, please fork/clone and follow the setup instructions.

URLS

# django_htmx_examples/urls.py

from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
import django_htmx_examples.click_to_edit.views as click_to_edit_views


urlpatterns = [
    path("", TemplateView.as_view(template_name="examples.html"), name="examples-list"),
    path("admin/", admin.site.urls),
    path(
        "click-to-edit/",
        click_to_edit_views.initial_state,
        name="click-to-edit-initial-state",
    ),
    path(
        "contact/<int:contact_id>/edit/",
        click_to_edit_views.contact_edit,
        name="contact-edit",
    ),
    path(
        "contact/<int:contact_id>/",
        click_to_edit_views.contact_detail,
        name="contact-update",
    ),
]

Views

# django_htmx_examples/click_to_edit/views.py

from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, Http404, HttpResponseRedirect
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from rest_framework.decorators import api_view
from rest_framework.response import Response

from .models import Contact
from .forms import ContactForm


def initial_state(request):
    context = {}
    contact = Contact.objects.first()
    context["contact"] = contact
    return render(request, "click_to_edit/initial_state.html", context)


@require_http_methods(["GET"])
def contact_edit(request, contact_id):
    context = {}
    contact = get_object_or_404(Contact, id=contact_id)

    form = ContactForm(instance=contact)
    context["form"] = form
    context["contact_id"] = contact_id
    return render(request, "click_to_edit/contact_edit.html", context)


@api_view(["GET", "PUT"])
def contact_detail(request, contact_id):

    contact = get_object_or_404(Contact, id=contact_id)
    if request.method == "GET":
        return HttpResponseRedirect(reverse("click-to-edit-initial-state"))
    form = ContactForm(request.POST, instance=contact)
    if form.is_valid():
        form.save()
        return HttpResponseRedirect(reverse("click-to-edit-initial-state"))
    else:
        raise Http404

Templates and Tailwind CSS

<!-- django_htmx_examples/templates/click_to_edit/initial_state.html  -->

{% extends "base.html" %}

{% block content %}
<div hx-target="this" hx-swap="outerHTML">
  {% if contact %}  
    <div class="bg-white shadow overflow-hidden sm:rounded-lg">
      
      <div class="px-4 py-5 sm:px-6">
          <h3 class="text-lg leading-6 font-medium text-gray-900">
           Click To Edit Person
          </h3>
          <p class="mt-1 max-w-2xl text-sm text-gray-500">
              
            <a class="font-medium text-indigo-600 hover:text-indigo-500" href="https://htmx.org/examples/click-to-edit/">HTMX example here</a>
        </p>
          
        </div>
        <div class="border-t border-gray-200">
          <dl>
            <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
              <dt class="text-sm font-medium text-gray-500 ">
                First Name
              </dt>
              <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                {{contact.first_name}} 
              </dd>
            </div>
            <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt class="text-sm font-medium text-gray-500">
                  Last Name
                </dt>
                <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                    {{contact.last_name}}
                </dd>
              </div>
            <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
              <dt class="text-sm font-medium text-gray-500">
                Email address
              </dt>
              <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
                {{contact.email_address}}
              </dd>
            </div>
          
           
          </dl>
        </div>
      </div>
      

 <button hx-get="/contact/1/edit/" type="button" class="ml-2 inline-flex mt-10 items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
        Click To Edit
      </button>
  {% else %}
  <div class="bg-white shadow overflow-hidden sm:rounded-lg">
      
    <div class="px-4 py-5 sm:px-6">
        <h3 class="text-lg leading-6 font-medium text-gray-900">
         You dont have a Contact , please add one in the admin!
        </h3>
        </div></div>
  {% endif %}
</div>
 
<!-- This example requires Tailwind CSS v2.0+ -->



{% endblock content %}
<!-- django_htmx_examples/templates/click_to_edit/contact_edit.html -->



<form hx-put="/contact/1/" hx-target="this" hx-swap="outerHTML">
 
    <div class="bg-white shadow overflow-hidden sm:rounded-lg">
        <div class="px-4 py-5 sm:px-6">
          <h3 class="text-lg leading-6 font-medium text-gray-900">
           Click To Edit Person
          </h3>
          <p class="mt-1 max-w-2xl text-sm text-gray-500">
              
            <a class="font-medium text-indigo-600 hover:text-indigo-500" href="https://htmx.org/examples/click-to-edit/">HTMX example here</a>
        </p>
          
        </div>
        <div class="border-t border-gray-200">
          <dl>
            {% for field in form %}
            <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                <dt class="text-sm font-medium text-gray-500 ">
                    {{ field.label_tag }} 
                </dt>
                <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 ">
                    {{ field }}
                </dd>
              </div>
           
               
              
         
        {% endfor %}
            
          
        
          
           
          </dl>
        </div>
      </div>
    
    

    <button  class="btn ml-2 inline-flex mt-10 items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50  ">
        Submit
      </button>
      <button hx-get="/contact/1/" type="button" class="ml-2 inline-flex mt-10 items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
        Cancel
      </button>
  </form>
Tests
# django_htmx_examples/click_to_edit/tests/test_urls.py

import pytest
from django.urls import resolve, reverse


def test_root():
    assert reverse("examples-list") == f"/"


def test_initial_state():
    assert reverse("click-to-edit-initial-state") == f"/click-to-edit/"


def test_contact_edit():
    assert reverse("contact-edit", args=[1]) == f"/contact/1/edit/"


def test_contact_update():
    assert reverse("contact-update", args=[1]) == f"/contact/1/"
# django_htmx_examples/click_to_edit/tests/test_models.py

import datetime
import pytest
from django.utils import timezone
from django_htmx_examples.test import TestCase
from django_htmx_examples.click_to_edit.models import Contact
from django_htmx_examples.click_to_edit.tests.factories import ContactFactory


class TestContact(TestCase):
    def test_str(self):
        contact = ContactFactory()

        assert str(contact) == f"{contact.first_name} {contact.last_name}"  # pytest

    def test_factory(self):
        contact = ContactFactory()

        assert contact is not None
        assert contact.email_address != ""
# django_htmx_examples/click_to_edit/tests/test_views.py

from django_htmx_examples.test import TestCase
from django_htmx_examples.click_to_edit.tests.factories import ContactFactory
from django_htmx_examples.click_to_edit.models import Contact
from http import HTTPStatus


class TestClickToEdit(TestCase):
    def test_get_status(self):
        response = self.get("")
        self.assert_http_200_ok(response)


class InitialStateTests(TestCase):
    def test_view_with_contact(self):
        contact = Contact.objects.first()  # Mr reinhart is creataed by the migrations
        response = self.client.get("/click-to-edit/")

        assert response.status_code == HTTPStatus.OK
        assert contact.first_name in str(response.content)
        assert contact.last_name in str(response.content)
        assert contact.email_address in str(response.content)


class ContactEditTests(TestCase):
    def test_edit_get(self):
        contact = Contact.objects.first()
        url = f"/contact/{contact.id}/edit/"
        response = self.client.get(url)
        assert response.status_code == HTTPStatus.OK
        assert contact.first_name in str(response.content)
        assert contact.last_name in str(response.content)
        assert contact.email_address in str(response.content)
        assert "Submit" in str(response.content)
        assert "Cancel" in str(response.content)


class ContactUpdateTests(TestCase):
    pass