This is part 6 of a set of posts in which I am documenting how I'm learning HTMX with Django. You can find the whole series on dev.to/rodbv.
In this post, we will implement the ability to delete todo items, and this time around we'll follow a test-driven approach, in short cycles of writing tests, seeing them fail, and then making them pass (Red-Green-Refactor).
Creating the view and url
First, let's implement the task details view and map it to the url, which will called with the DELETE
method on /tasks/:id
.
# core/tests/test_view_tasks.py
... previous tests
@pytest.mark.django_db
def test_delete_task(client, make_todo, make_user):
user = make_user()
client.force_login(user)
todo = make_todo(title="New Todo", user=user)
response = client.delete(reverse("task_details", args=[todo.id]))
assert response.status_code == HTTPStatus.NO_CONTENT
assert not user.todos.filter(title="New Todo").exists()
Note that this view will return 204 - No content
, which indicated that the request was fulfilled but it has no return data, since the todo was deleted.
Running the test will make it fail, since we haven't implemented the url/view yet:
❯ uv run pytest
Test session starts (platform: darwin, Python 3.12.8, pytest 8.3.4, pytest-sugar 1.0.0)
django: version: 5.1.4, settings: todomx.settings (from ini)
rootdir: /Users/rodrigo/code/opensource/todo-mx
configfile: pyproject.toml
plugins: sugar-1.0.0, django-4.9.0, mock-3.14.0
collected 6 items
============= short test summary info =================================
FAILED core/tests/test_view_tasks.py::test_delete_task - django.urls.exceptions.NoReverseMatch: Reverse for 'task_details' not found. 'task_details' is not a valid view function or pattern name.
Results (0.45s):
5 passed
1 failed
- core/tests/test_view_tasks.py:66 test_delete_task
Let's add the url:
# core/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("tasks/", views.tasks, name="tasks"),
path(
"tasks/<int:task_id>/toggle/",
views.toggle_todo,
name="toggle_todo",
),
path( # <-- NEW
"tasks/<int:task_id>/",
views.task_details,
name="task_details",
),
]
Running the test again will yield a new error message. Note how the failing methods are guiding the development of the feature, which is a nice side-effect of TDD
FAILED core/tests/test_view_tasks.py::test_delete_task - AttributeError: module 'core.views' has no attribute 'task_details'
Results (0.45s):
1 failed
- core/tests/test_view_tasks.py:66 test_delete_task
5 deselected
Let's follow what was asked in the error message and add a task_details
view
# core/views.py
... previous code
@login_required
@require_http_methods(["DELETE"])
def task_details(request, task_id):
todo = request.user.todos.get(id=task_id)
todo.delete()
return HttpResponse(status=HTTPStatus.NO_CONTENT)
Running the tests now will have them all passing
❯ uv run pytest
collected 6 items
core/tests/test_todo_model.py ✓ 17% █▋
core/tests/test_view_tasks.py ✓✓✓✓✓ 100% ██████████
Results (0.37s):
6 passed
Adding the delete button on the template
We can now move to our template.
My first approach was to use hx-target
and hx-swap
, similarly to how I've done with the toggle function
<button hx-delete="{% url 'task_details' todo.id %}"
class="btn btn-circle btn-sm btn-error btn-outline"
hx-swap="outerHTML"
hx-target="closest li">x</button>
This rendered the button, and when I clicked on it the request was fulfilled accordingly, but the row was not removed automatically.
Checking the HTMX documentation, I learned that 204 No-content
responses do not fire swaps by default.
There are a few ways around it:
- Change the configuration of HTMX to let
204
promote swaps (explained in the article above); - Change the response to
200 OK
, which worked fine when I tested it - Fire an HTMX trigger and responding to it on the template with and
hx-on:[trigger-name]
handler.
Let's go for the third option as it's something new; it's pretty straightforward too.
First let's add the trigger to the response, as a header. We can first add the expected result to the test and see it fail
@pytest.mark.django_db
def test_delete_task(client, make_todo, make_user):
user = make_user()
client.force_login(user)
todo = make_todo(title="New Todo", user=user)
response = client.delete(reverse("task_details", args=[todo.id]))
assert response.status_code == HTTPStatus.NO_CONTENT
assert response["HX-Trigger"] == "todo-deleted" # <-- NEW
assert not user.todos.filter(title="New Todo").exists()
...and then add the code to make the test pass:
# core/views.py
...previous code
@login_required
@require_http_methods(["DELETE"])
def task_details(request, task_id):
todo = request.user.todos.get(id=task_id)
todo.delete()
# CHANGED
response = HttpResponse(status=HTTPStatus.NO_CONTENT)
response['HX-Trigger'] = 'todo-deleted'
return response
Now that we have the trigger header, we can respond to it in the template. If you want to see the whole code for the template, it's in the part 06 branch.
<button hx-delete="{% url 'task_details' todo.id %}"
class="btn btn-circle btn-sm btn-error btn-outline"
hx-on:todo-deleted="this.closest('li').remove()"
>x</button>
Adding confirmation
Our delete code is working but it's nice to have a confirmation, since this is a destructive action.
The simplest solution is to use hx-confirm
, but it's not very good-looking, but gets the work done.
<button hx-delete="{% url 'task_details' todo.id %}"
class="btn btn-circle btn-sm btn-error btn-outline"
hx-on:todo-deleted="this.closest('li').remove()"
hx-confirm="Delete this item?"
>x</button>
You can find the final version of the code so far in the part 6 branch.
In part 7 of this series, we will improve the confirm dialog with Alpine and DaisyUI's modal component.
Top comments (0)