Logo

dev-resources.site

for different kinds of informations.

Creating a To-do app with HTMX and Django, part 9: active search

Published at
1/11/2025
Categories
htmx
django
Author
rodbv
Categories
2 categories in total
htmx
open
django
open
Creating a To-do app with HTMX and Django, part 9: active search

Welcome back! In the part 9, we will add one more feature of that showcases how HTMX can provide common rich web experience with very little code: active search.

This is how the result will look like, sending POST requests to /tasks/search and swapping the results in the list.

Active search filters the todo list as the user types, automatically

Adding the URL and view

We will need a new URL, /tasks/search, which will receive a POST request with a parameter called query.

In core/urls.py we add the route

# 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("tasks/<int:task_id>/", views.task_details, name="task_details"),
    path("tasks/<int:task_id>/edit/", views.edit_task, name="edit_task"),
    path("tasks/search/", views.search, name="search"), # <-- NEW
]

In core/views.py we add the new method

# core/views.py

... previous code

@login_required
@require_http_methods(["POST"])
def search(request):
    query = request.POST.get("query")

    if not query:
        return redirect("tasks")

    results = request.user.todos.filter(title__icontains=query).order_by("-created_at")

    return render(
        request,
        "tasks.html#todo-items-partial",
        {"todos": results},
        status=HTTPStatus.OK,
    )

The first if is to handle empty queries; when the user clears the search field we can simply redirect to the tasks view, which returns all the todo items and preserve the infinite scrolling behavior.

When the query is not empty, we're filtering the todo items with icontains, which is a case-insensitive clause, and returning the matching items. Once again we're using our partial template, todo-items-partial, which accepts a list of todo's.

Adding the search input in the template

In the tasks.html template we'll add a new input on the top of the page, and connect it to our new url/view


..previous code

  <div class="w-full max-w-2xl">
    <div class="mb-4">

      <!-- NEW -->
      <div class="flex justify-center">
        <label class="rounded-full input my-4 w-1/2">
          <span class="icon-search text-gray-500"></span> 
          <input type="search" name="query" class="grow text-md" placeholder="Type to search..." 
          hx-post="{% url 'search' %}"
          hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
          hx-target="#todo-items" />
        </label>
      </div>

   <form ...

The code to fire the search url on change, with debouncing, is straight from the examples section of the official HTMX site.

It's nice how self-explanatory the hx-trigger attribute reads: whenever the input is changed, with a delay (debounce) of 500 milliseconds, or when the ENTER key is pressed.

hx-target informs that we want to place the results in the #todo-items element (the ul), and since we want to change its children, we don't need to specify hx-swap, its default value is what we want already, innerHTML.

While we're checking the code working,let's have a look in developer tools/network, to see the requests being fired.

Active search in action, showing the requests and responses in developer tools

Testing the view

Let's add some tests to our new view, in test_view_tasks.py

The first test is our "happy path", we want to ensure we just return the items searched, which are two of the three fake items we have in the test. The other two test for no matches and empty searches.

@pytest.mark.django_db
def test_search_filtering(client, make_todo, make_user):
    user = make_user()
    client.force_login(user)

    make_todo(title="Todo 1", user=user)
    make_todo(title="Another Todo", user=user)
    make_todo(title="Something else", user=user)

    response = client.post(reverse("search"), {"query": "Todo"})
    content = response.content.decode()

    assert "Todo 1" in content
    assert "Another Todo" in content

    assert "Something else" not in content

@pytest.mark.django_db
def test_search_zero_matches_returns_empty_list(client, make_todo, make_user):
    user = make_user()
    client.force_login(user)

    make_todo(title="Todo 1", user=user)
    make_todo(title="Another Todo", user=user)
    make_todo(title="Something else", user=user)

    response = client.post(reverse("search"), {"query": "Nonexistent"})
    content = response.content.decode()

    assert not any(
        todo in content for todo in ["Todo 1", "Another Todo", "Something else"]
    )

@pytest.mark.django_db
def test_search_empty_query_redirects_to_all_tasks(client, make_todo, make_user):
    user = make_user()
    client.force_login(user)

    make_todo(title="Todo 1", user=user)
    make_todo(title="Another Todo", user=user)
    make_todo(title="Something else", user=user)

    response = client.post(reverse("search"), {"query": ""})
    assert response.status_code == HTTPStatus.FOUND  # redirect
    assert response.url == reverse("tasks")

Running all tests to ensure it's all good

āÆ 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)
configfile: pyproject.toml
plugins: sugar-1.0.0, django-4.9.0
collected 10 items

 core/tests/test_todo_model.py āœ“                                                                                                                                    10% ā–ˆ
 core/tests/test_view_tasks.py āœ“āœ“āœ“āœ“āœ“āœ“āœ“āœ“āœ“                                                                                                                           100% ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ

Results (0.53s):
      10 passed

A note on testing context and templates

Someone more experienced with testing Django templates may wonder why we're testing the content of the rendered template, and not the context being passed and the template invoked, which would be more assertive, as such:

   ...
   assertTemplateUsed(response, "tasks.html#todo-items-partial")
   todos = {todo.title for todo in response.context["todos"]}
   assert todos == {"Todo 1", "Another Todo"}

This is because of a current issue in django-template-partials, issue #54, which has not been fixed yet (I'm giving it a go, but it's not trivial..)

Due to this issue the response does not contain values for context and templates.

Anyway, this is it for active search! all in all, not much code for some awesome improvement on UX. As usual, the final version of this code can be found in its branch, https://github.com/rodbv/todo-mx/tree/part-9. Cheers!

Featured ones: