dev-resources.site
for different kinds of informations.
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.
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.
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: