How I Start: Django, Tailwind, HTMX (part 4)
In part 1 and part 2, we got the basic project workflow set up. Part 3 saw some initial models and views that were tested using the Django admin UI. Now it’s time to add library CRUD to the reading_log
application itself.
Where code is discussed below, I try to copy everything that’s relevant inline. I’m sure I missed a few things. The entire project is available here.
Library Management Page
While I do want to list a few books on the main “dashboard” page, I don’t want that to be the primary view for managing them. Copying that table to a “Library” view gives me a place to hang “Add Book”, “Edit Book” and “Delete Book” buttons:
{% extends "reading_journal/base.html" %}
{% block content %}
<div class="border-gray-700 border-1 rounded-xl p-4 m-4">
<div class="flex space-x-4">
<div class="flex items-end"><h2 class="text-xl text-gray-900 font-bold">Library</h2></div>
<div class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-sky-700 bg-sky-100 hover:bg-sky-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"><a href="{% url "add_book" %}" class="text-l text-sky-500 font-medium">Add Book</a></div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md p-2 m-2">
<ul class="divide-y divide-gray 200" role="list">
{% for book in books %}
<li>
<div class="flex justify-between">
<a href="{% url "book" book.id %}" class="block hover:bg-sky-100">
<p class="text-lg font-medium text-gray-700">{{ book.title }}</p>
<p class="text-sm text-gray-500">{{ book.authors.all | join:", " }}</p>
</a>
<div class="flex items-center justify-center md:space-x-2">
<a href="#" class="inline-flex items-center px-2.5 py-1.5 border border-blue-300 shadow-sm text-xs font-medium rounded text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Edit</a>
<a class="inline-flex items-center px-2.5 py-1.5 border border-blue-300 shadow-sm text-xs font-medium rounded text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" href="#">Delete</a>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock content %}
urlpatterns = [
path("", views.index, name="index"),
path("books", views.library, name="library"),
path("book/<int:book_id>", views.book_details, name="book"),
]
@login_required
def library(request: HttpRequest):
nav_links = get_nav_links("library")
books = get_books_for_current_user(request)
ctx = {
"nav_links": nav_links,
"books": books,
}
return render(request, "reading_journal/library.html", ctx)
For now, the queryset is the same for the library page as it was for the dashboard page. At some point, I expect to make the one that shows on the dashboard more restrictive.
Book Creation
Even with models as simple as these, adding and editing a book without leaning on the admin UI turns out to be more demanding than I’d expect, mostly due to the ManyToManyField
for author on the book model.
Basic Form
Start with a bare bones template in edit_book.html
:
{% extends "reading_journal/base.html" %}
{% block content %}
<form action="{% url 'add_book' %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" name="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save Book</button>
</form>
{% endblock content %}
and hook up an URL:
urlpatterns = [
path("", views.index, name="index"),
path("books", views.library, name="library"),
path("book/<int:book_id>", views.book_details, name="book"),
path("book/add", views.add_book, name="add_book"),
]
a view:
@login_required
def add_book(request: HttpRequest):
form = BookForm(request.POST or None)
nav_links = get_nav_links("")
ctx = {
"form": form,
"nav_links": nav_links,
}
return render(request, "reading_journal/edit_book.html", ctx)
and create a form in forms.py
:
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ['title', 'authors']
then see how it looks:
It’s ugly, but it’ll be enough to finish writing the view function and confirm that books are created.
First, make form submission create a new book when submitted data is valid and redirect to the book detail page:
@login_required
def add_book(request: HttpRequest):
form = BookForm(request.POST or None)
nav_links = get_nav_links("")
ctx = {
"form": form,
"nav_links": nav_links,
}
if request.method == "POST":
if form.is_valid():
form.save()
book = form.instance
return redirect(reverse("book", kwargs={"book_id":book.pk}))
return render(request, "reading_journal/edit_book.html", ctx)
Normally at this point, I’d proceed to get editing and deletion working. This time, though, it pays off to go ahead and get the form rendering a little better first, before reusing it.
Enable Crispy Forms and Crispy Tailwind
In the initial project setup, I installed crispy forms and crispy tailwind, but didn’t enable them yet. Now’s the time to do that. First, I add them to my INSTALLED_APPS
after tailwind
and theme
.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"tailwind",
"theme",
"crispy_forms",
"crispy_tailwind",
"django_browser_reload",
"authuser",
"reading_journal",
]
then make sure to set the template pack for crispy:
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind"
Vendor the Templates for Crispy Tailwind
In order for Tailwind 3+’s watcher to play well with crispy tailwind, it needs to be able to find the templates. There are two options for this right now:
- Modify tailwind’s configuration to search the templates in your virtualenv.
- Copy the templates into your application’s tree.
I am currently favoring (2) because I find myself wanting to modify them sometimes anyway, and because crispy_tailwind
doesn’t seem to move very quickly.
SITE_PACKAGES=`python3 -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])'`
cp -rv $SITE_PACKAGES/crispy_tailwind/templates/tailwind theme/templates/
By doing it this way, I can add these templates to source control and see if my customizations need to be altered to pick up any changes from upstream. I should probably automate this so that it happens whenever crispy-tailwind is updated.
Modify the Form to use Crispy and Tailwind
The simplest modification to the form:
{% extends "reading_journal/base.html" %}
{% load tailwind_filters %}
{% block content %}
<form action="{% url 'add_book' %}" method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" name="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save Book</button>
</form>
{% endblock content %}
improves the rendering some, but exposes two problems: The ModelMultipleChoice
widget is now rendered as a single choice, and that choice has a rendering issue.
The rendering issue with the arrow at the end of the choice is due to my tailwind configuration including the forms plugin. I prefer to remove it from the crispy theme rather than disable the plugin, so I change theme/templates/tailwind/layout/select.html
to accommodate that, as well as add multiple
support for the select
widget:
{% load crispy_forms_filters %}
{% load l10n %}
<div class="relative">
<select {% if field.field.widget.allow_multiple_selected %}multiple {% endif %}class="{% if field.errors %}border border-red-500 {% endif %}bg-white focus:outline-none border border-gray-300 rounded-lg py-2 px-4 block w-full appearance-none leading-normal text-gray-700" name="{{ field.html_name }}" {{ field.field.widget.attrs|flatatt }}>
{% for value, label in field.field.choices %}
{% include "tailwind/layout/select_option.html" with value=value label=label %}
{% endfor %}
</select>
</div>
With that sorted, I’d like to give the form’s template a bit more attention. Wrapping the form in a div that constrains its maximum width and centers it makes things much nicer to use:
{% extends "reading_journal/base.html" %}
{% load tailwind_filters %}
{% block content %}
<div class="max-w-2xl w-full mx-auto">
<form action="{% url 'add_book' %}" method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" name="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save Book</button>
</form>
</div>
{% endblock content %}
That said, it’s still quite incomplete. The interface will be terrible once there are more than a few existing authors and there’s no way to add a new author yet.
Editing Existing Books
With a basic form in place, it’s simple enough to extend the ability to edit existing books.
path("book/<int:book_id>/edit", views.edit_book, name="edit_book"),
gets added to the URLs, edit buttons/links get wired up with {% url 'edit_book' book.pk %}
and the edit view is very similar to the add view:
@login_required
def edit_book(request: HttpRequest, book_id: int):
book = get_object_or_404(Book, pk=book_id)
form = BookForm(request.POST or None, instance=book)
nav_links = get_nav_links("")
ctx = {
"form": form,
"nav_links": nav_links,
"action_url": reverse("edit_book", kwargs={"book_id": book.pk}),
}
if request.method == "POST":
if form.is_valid():
form.save()
book = form.instance
return redirect(reverse("book", kwargs={"book_id":book.pk}))
return render(request, "reading_journal/edit_book.html", ctx)
Then the add and edit views both get modified to pass action_url
in the context and the edit_book.html
template gets modified to POST there instead of the hardcoded add location from before:
<form action="{{ action_url }}" method="post">
Really fix multiple item selection
The very first attempt to edit a book shows that multiple item selections don’t work yet. To address this, one additional fix for crispy-tailwind
is necessary, this time in theme/templates/tailwind/layout/select_option.html
:
{% load crispy_forms_filters %}
{% load l10n %}
<option value="{{ value|stringformat:'s' }}" {{ field.field.widget.attrs|flatatt }}
{% if field.field.widget.allow_multiple_selected %}
{% if value in field.value %} selected="selected"{% endif %}
{% else %}
{% if field.value == value %} selected="selected"{% endif %}
{% endif %}
>{{ label }}</option>
This all shows that Crispy Tailwind is a little less “done” than I thought it was. At the same time, it’s very useful for prototyping, and this exercise shows me that I might favor widget tweaks over Crispy’s layout helpers when I want more control over my forms. (I was already tending to do that because I found widget tweaks easier for non-trivial things but crispy easier for situations where there was not much tweaking anyway.)
With this diversion into the innards of Crispy, I’m leaving deletion and making the forms better for Part 5.
I’m trying on Kev Quirk’s “100 Days To Offload” idea. You can see details and join yourself by visiting 100daystooffload.com.
This is day 6.