Django CRUD with Bootstrap Modal Form with HTMX

Adi Ramadhan
6 min readJul 15, 2023

Django CRUD with bootstrap modal form with HTMX, include the validation. No leaving current page.

Add Object with Bootstrap Modal

References:
django, bootstrap, htmx. Special credit for Benoit Blanchon for inspiring this story. Please check his video here.

Step 1: Preparation, Create Django Project, Inital Migration
create virtualenv: virtualenv venv
start virtualenv: venv/Scripts/activate
install Django in virtualenv: pip install django==4.2
Create Django: django-admin startproject myproject
Go to myproject folder: cd myproject
Initial Migration: python manage.py migrate

Step 2: Create Django Apps
Create apps: python manage.py startapp myapp

Step 3: Project Setting: Register Apps, Set Templates Folder (myproject/settings.py)

...

INSTALLED_APPS = [
...
'myapp', #updated
]

...

TEMPLATES = [
...
'DIRS': [Path(BASE_DIR, 'templates')], #updated
...
]


...

Step 4: Create Models (myapp/models.py), Makemigration and Migrate

from django.db import models

class Book(models.Model):
title = models.CharField(db_column='title', max_length=100, blank=False)
description = models.TextField(db_column='description', max_length=1000, blank=False)
author = models.CharField(db_column='author', max_length=100, blank=False, null=False)
year = models.IntegerField(db_column='year',blank=False, default=2000)

class Meta:
db_table = 'book'
verbose_name = 'Book'
verbose_name_plural = 'Books'
def __unicode__(self):
return self.title
def __str__(self):
return self.title

Make migrations: python manage.py makemigrations
Migrate: python manage.py migrate

Step 5: Create Forms myapp/forms.py

from django import forms

class BookForm(forms.Form):

title = forms.CharField(label = "Title", widget = forms.TextInput( attrs={'class': 'form-control'} ))
author = forms.CharField(label = "Author", widget = forms.TextInput( attrs={'class': 'form-control'} ))
description = forms.CharField(label = "Description", widget = forms.Textarea( attrs={'class': 'form-control', 'rows': '5'} ))
year = forms.CharField(label = "Year", widget = forms.NumberInput( attrs={'class': 'form-control'} ))

#validation
def clean(self):
super(BookForm, self).clean()

title = self.cleaned_data.get('title')

if len(title)<5:
self.add_error('title','Can not save title less than 5 characters long')
self.fields['title'].widget.attrs.update({'class': 'form-control is-invalid'})

return self.cleaned_data

In this example, we add validation for title. No title less than 5 chars is allowed.

Step 6: Create HTML Files in Templates Folder

Create templates folder in main app

Create templates/index.html

<!DOCTYPE html>
<html lang="en">

<head>
<!-- Required meta tags -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" />

<title>Books</title>
</head>

<body>
<div class="container">
<div class="px-5 my-5 text-center">
<h1>Books</h1>
</div>
<button hx-get="{% url 'add_book' %}" hx-target="#dialog" class="btn btn-primary">
Add a book
</button>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Description</th>
<th>Year</th>
<th>Action</th>
</tr>
</thead>

<!-- load booklist with HTMX -->
<tbody hx-trigger="load, bookListChanged from:body" hx-get="{% url 'book_list' %}" hx-target="this">
<tr>
<td class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</td>
</tr>
</tbody>

</table>

</div>

<!-- Placeholder for the modal -->
<div id="modal" class="modal fade">
<div id="dialog" class="modal-dialog" hx-target="this"></div>
</div>

<!-- Empty toast to show the message -->
<div class="toast-container position-fixed top-0 end-0 p-3">
<div id="toast" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive"
aria-atomic="true">
<div class="d-flex">
<div id="toast-body" class="toast-body"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
</div>


<!-- Scripts: Bootstraps, HTMX, and custom JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.6.1/dist/htmx.min.js"></script>

<script>
; (function () {
const modal = new bootstrap.Modal(document.getElementById("modal"))

htmx.on("htmx:afterSwap", (e) => {
// Response targeting #dialog => show the modal
if (e.detail.target.id == "dialog") {
modal.show()
}
})

htmx.on("htmx:beforeSwap", (e) => {
// Empty response targeting #dialog => hide the modal
if (e.detail.target.id == "dialog" && !e.detail.xhr.response) {
modal.hide()
e.detail.shouldSwap = false
}
})

// Remove dialog content after hiding
htmx.on("hidden.bs.modal", () => {
document.getElementById("dialog").innerHTML = ""
})
})()
</script>
<script>
; (function () {
const toastElement = document.getElementById("toast")
const toastBody = document.getElementById("toast-body")
const toast = new bootstrap.Toast(toastElement, { delay: 2000 })

htmx.on("showMessage", (e) => {
toastBody.innerText = e.detail.value
toast.show()
})
})()
</script>
</body>

</html>

Create templates/book_list.html

{% for book in books %}
<tr id="book-{{ book.pk }}" class="book-row">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>{{ book.description }}</td>
<td>{{ book.year }}</td>
<td>
<button class="btn btn-primary btn-sm" hx-get="{% url 'edit_book' pk=book.pk %}" hx-target="#dialog">Edit</button>
<button class="btn btn-light btn-sm" hx-get="{% url 'remove_book_confirmation' pk=book.pk %}" hx-target="#dialog">Delete</button>
</td>
</tr>
{% endfor %}

Create templates/book_form.html

<form hx-post="{{ request.path }}" hx-headers='{"X-CSRFToken":"{{ csrf_token }}"}' class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Book Form</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
{{ form.title }}
<div class="invalid-feedback">{{ form.title.errors|first }}</div>
</div>

<div class="mb-3">
<label for="{{ form.author.id_for_label }}" class="form-label">Author</label>
{{ form.author }}
<div class="invalid-feedback">{{ form.author.errors|first }}</div>
</div>

<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
{{ form.description }}
<div class="invalid-feedback">{{ form.description.errors|first }}</div>
</div>

<div class="mb-3">
<label for="{{ form.year.id_for_label }}" class="form-label">Year</label>
{{ form.year }}
<div class="invalid-feedback">{{ form.year.errors|first }}</div>
</div>

</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>

Create templates/book_delete_confirmation.html

<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Confirmation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete {{book.title}}?</p>
<p class="text-muted">This action can not be undone</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button type="submit" class="btn btn-primary" hx-post = "{% url 'remove_book' pk=book.pk %}" hx-headers='{"X-CSRFToken":"{{ csrf_token }}"}' >Delete</button>
</div>
</div>
templates folder structures

Step 7: Create Function Views myapp/views.py

from django.shortcuts import render
import json
from django.shortcuts import render
from django.http import HttpResponse
from django.views.decorators.http import require_POST
from django.shortcuts import get_object_or_404
from .models import Book
from .forms import BookForm

# Create your views here.
def index(request):
return render(request, 'index.html', {})

def book_list(request):
books = Book.objects.all()
return render(request, 'book_list.html', {'books': books})

def add_book(request):
if request.method == "POST":
form = BookForm(request.POST)
if form.is_valid():
book = Book.objects.create(
title = form.cleaned_data.get('title'),
author = form.cleaned_data.get('author'),
description = form.cleaned_data.get('description'),
year = form.cleaned_data.get('year')
)
return HttpResponse(
status=204,
headers={
'HX-Trigger': json.dumps({
"bookListChanged": None,
"showMessage": f"{book.title} added."
})
})
else:
return render(request, 'book_form.html', {
'form': form,
})
else:
form = BookForm()
return render(request, 'book_form.html', {
'form': form,
})

def edit_book(request, pk):
book = get_object_or_404(Book, pk=pk)
if request.method == "POST":
form = BookForm(request.POST, initial={
'title' : book.title,
'author' : book.author,
'description' : book.description,
'year': book.year
})
if form.is_valid():
book.title = form.cleaned_data.get('title')
book.author = form.cleaned_data.get('author')
book.description = form.cleaned_data.get('description')
book.year = form.cleaned_data.get('year')

book.save()
return HttpResponse(
status=204,
headers={
'HX-Trigger': json.dumps({
"bookListChanged": None,
"showMessage": f"{book.title} updated."
})
}
)
else:
return render(request, 'book_form.html', {
'form': form,
'book': book,
})
else:
form = BookForm(initial={
'title' : book.title,
'author' : book.author,
'description' : book.description,
'year': book.year
})
return render(request, 'book_form.html', {
'form': form,
'book': book,
})

def remove_book_confirmation(request, pk):
book = get_object_or_404(Book, pk=pk)
return render(request, 'book_delete_confirmation.html', {
'book': book,
})

@ require_POST
def remove_book(request, pk):
book = get_object_or_404(Book, pk=pk)
book.delete()
return HttpResponse(
status=204,
headers={
'HX-Trigger': json.dumps({
"bookListChanged": None,
"showMessage": f"{book.title} deleted."
})
})

Step 8: Setup URLS

Create myapp/urls.py

from django.urls import path
from . import views

urlpatterns = [
path('', views.index, name = 'index'),
path('books', views.book_list, name='book_list'),
path('books/add', views.add_book, name='add_book'),
path('books/<int:pk>/remove_confirmation', views.remove_book_confirmation, name='remove_book_confirmation'),
path('books/<int:pk>/remove', views.remove_book, name='remove_book'),
path('books/<int:pk>/edit', views.edit_book, name='edit_book'),
]

Update myproject/urls.py

from django.urls import path, include

urlpatterns = [

path('', include('myapp.urls')) #updated
]

Step 9: Run Server and Testing
Run Server: python manage.py runserver
Testing: http://127.0.0.1:8000/

Initial Test
Add Object with Bootstrap Modal
Form Validation
Delete with Confirmation Dialog

--

--