DEV Community

rodbv
rodbv

Posted on • Edited on

Creating a To-Do app with Django and HTMX, part 6: implementing Delete with tests

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
    ),
]

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This rendered the button, and when I clicked on it the request was fulfilled accordingly, but the row was not removed automatically.

Delete todo item: response 204 returned, but item not removed

Checking the HTMX documentation, I learned that 204 No-content responses do not fire swaps by default.

There are a few ways around it:

  1. Change the configuration of HTMX to let 204 promote swaps (explained in the article above);
  2. Change the response to 200 OK, which worked fine when I tested it
  3. 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()
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Confirm dialog, HTML native

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)