Django and HTMX Live Scores Project #2

In this post, we'll extend our application to include HTMX-driven search functionality, as well as providing visual feedback to the user when HTMX polling requests are updating the UI.

This is a continuation from the first post, where we built out the models and the polling functionality for this page.

The video accompanying this post is below:


The final code for this post can be found on Github, on this branch.

Objectives

In this post, we will:

  • Add an hx-indicator attribute to indicate a spinner when our app makes its polling request
  • Add search functionality to the app
  • Learn how to attach extra key/value pairs to the HTMX request with the hx-vals attribute
  • Learn about the htmx:beforeRequest and htmx:afterRequest events

Adding Loading Spinner

For this, we want to show a circular loading spinner whenever HTMX is polling our backend for updates. To do this, add the following code below the <main> block of fixtures.html.

<div id="spinner" class="htmx-indicator indicator-style">
    <div class="spinner-grow text-primary" style="width: 6rem; height: 6rem;">
        <span class="sr-only">Loading...</span>
    </div>
</div>

To show the spinner, we add the hx-indicator="#spinner" attribute to the <div> element with our HTMX attributes. This div should now look like below:

<div id="fixturelist-container" 
    hx-get="{% url 'fixtures' %}" 
    hx-trigger="every 3s" 
    hx-swap="innerHTML" 
    hx-indicator="#spinner">

The value of the hx-indicator is set to the ID of the spinner code we added above. This means that, when HTMX sends the GET request to our backend, the indicator will be shown on the frontend. HTMX adds a custom class to the target spinner, changing its opacity from 0 to 1 under the hood. This is reversed when the response is received, hiding the spinner once again.

We're also going to add some styles to the spinner. Within the static\css\styles.css file, add the following class style.

.indicator-style {
    position: fixed;
    top: 50%;
    left: 50%;
    margin-left: -50px;
    margin-top: -50px;
}

This should center the spinner on our page. We can amend our backend view to sleep when HTMX requests come in, to see the effect of this. Change the view to the following:

def fixtures(request):
    fixtures = Fixture.objects.all()

    all_completed = all(f.game_finished for f in fixtures)

    search = request.GET.get('search')
    if search:
        fixtures = fixtures.filter(
            Q(home_team__name__icontains=search) | Q(away_team__name__icontains=search)
        )

    context = {
        'fixtures': fixtures,
        'all_completed': all_completed
    }

    if request.htmx:
        import time
        time.sleep(.6)
        if all_completed:
            response = render(request, 'partials/fixturelist.html', context)
            response['HX-Refresh'] = "true"
            return response
        return render(request, 'partials/fixturelist.html', context)
    else:
        return render(request, 'fixtures.html', context)

The sleep call on line 19 will add some manufactured latency to our HTMX AJAX call, allowing us to see the effect on the frontend. It should look similar to the following.

We might also want to reduce the opacity of the background results, while the polling request is in-flight. We can achieve this using a few custom HTMX events, and adding a small amount of JavaScript. In the scores\templates\base.html file, add the following <script> tag at the bottom, just before closing off the <body> tag.

<script>
        document.body.addEventListener('htmx:beforeRequest', function(evt) {
            const body = document.getElementsByTagName('main')[0]
            body.style.opacity = 0.25
        });

        document.body.addEventListener('htmx:afterRequest', function(evt) {
            const body = document.getElementsByTagName('main')[0]
            body.style.opacity = 1.0
        });
</script>

The htmx:beforeRequest event is used to reduce the opacity of the <main> tag to 0.25. This event fires just before HTMX sends each polling request, so it's the perfect time to reduce this opacity. The spinner, on the other hand, resides outside the <main> tag, so is unaffected by this reduction in opacity.

The htmx:afterRequest event is used to restore the opacity of the <main> tag to 1.0. This event fires after the polling response is received. This page should now look similar to the following:

We get a nicer effect here, where the background's opacity is reduced, and the spinner's opacity is increased during the HTMX request. This is all achievable via the hx-indicator attribute, along with a few lines of JavaScript that hooks into HTMX's events to modify the opacity of the <main> element.

And with that, we have given a bit of visual feedback to users when the updated list of fixtures is being loaded. Next, we'll build out the search functionality for this page.

Adding HTMX-Driven Search Bar

We'll now use HTMX to create search functionality that'll allow us to dynamically filter down our fixtures by the search input text. In the first post, we added a search input box at the top of our fixtures.html template. This is currently not functional, and does nothing - let's now give users the ability to perform a search!

Let's firstly use a more sensible polling interval of 15 seconds. Within the fixtures.html template, amend the polling <div> element to use a 15 second interval:

<div id="fixturelist-container" 
    hx-get="{% url 'fixtures' %}" 
    hx-trigger="every 15s" 
    hx-swap="innerHTML" 
    hx-indicator="#spinner"
    hx-vals='js:{search: document.getElementById("search-input").value}'>

Now, to enable search, let's add some HTMX attributes to the <input> element in the fixtures.html template. Amend this input to add the following:

<input type="text"
        id="search-input"
        name="search" 
        class="form-control" 
        placeholder="Enter team"
        hx-get="{% url 'fixtures' %}"
        hx-trigger="keyup delay:500ms"
        hx-target="#fixture-list"
        hx-indicator="#spinner" />

We are wiring up a GET request to the fixtures URL, with the trigger being set to the keyup event (with 500ms delay). This means that, when a user types into the search input, the request is sent 500ms after the last keyup event.

We specify the hx-target to be the <div> with ID fixture-list that wraps our template for-loop in the scores\templates\partials\fixturelist.html template. And finally, we also add the hx-indicator attribute to display the spinner when a search request is occurring.

We are now going to change the view to handle the potential search input from the user. Amend the fixtures view as follows:

from django.db.models import Q

def fixtures(request):
    fixtures = Fixture.objects.all()

    all_completed = all(f.game_finished for f in fixtures)

    search = request.GET.get('search')
    if search:
        fixtures = fixtures.filter(
            Q(home_team__name__icontains=search) | Q(away_team__name__icontains=search)
        )

    context = {
        'fixtures': fixtures,
        'all_completed': all_completed
    }

    if request.htmx:
        import time
        time.sleep(.6)
        if all_completed:
            response = render(request, 'partials/fixturelist.html', context)
            response['HX-Refresh'] = "true"
            return response
        return render(request, 'partials/fixturelist.html', context)
    else:
        return render(request, 'fixtures.html', context)

On line 4, we attempt to extract a search value from the query parameters. If this exists, then we filter down the fixture list by teams where either the home OR the away team contain the user's search text. We use a case insensitive icontains to perform our search, and use the Django Q object to perform a SQL OR query within our filter expression (lines 6-8).

We should now see our results updating according to what's entered into the search bar, since the fixtures are being filtered down by this value.

The results are filtered down based on the user's search input. One problem, though, is that the search results are lost when the polling request occurs after 15 seconds - this reloads the fixtures and does not attach the search text in the request (since it originates from the polling, not the search input).

We can attach extra parameters to a request using the hx-vals attribute. In this case, we will use a JavaScript expression and send the value of the search input element. Add the hx-vals attribute to the polling <div> element as below:

<div id="fixturelist-container" 
    hx-get="{% url 'fixtures' %}" 
    hx-trigger="every 15s" 
    hx-swap="innerHTML" 
    hx-indicator="#spinner"
    hx-vals='js:{search: document.getElementById("search-input").value}'>

The key will be search, and the value will be whatever is in the search text input. Adding this will ensure that the fixture QuerySet is filtered down by any search terms that the user has entered prior to the polling request occurring!

Summary

And that's it! We've built an app that can update based on new scores coming in, and gives users the ability to search and filter down the list of fixtures.

We've seen how to use many different HTMX attributes, including:

  • hx-get - to wire up GET requests to our Django backend
  • hx-trigger - to trigger requests on particular events (keyup and every, in this app)
  • hx-target - to specify the element to which the response HTML should target
  • hx-swap - to specify how the response HTML will be swapped into the DOM
  • hx-indicator - to specify the element that should be shown when HTMX requests are in-flight (in our app, a spinner)
  • hx-vals - to attach additional key/value pairs to the HTMX request

We also saw a couple of other cool, HTMX-specific tricks.

  • Using the HX-Refresh response header to trigger a client-side page refresh
  • Using the htmx:beforeRequest and htmx:afterRequest events to modify styles on our page while the request is in-flight

We've covered a lot of ground! With minimal JavaScript, we have a nice and interactive live-score page.

If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!

Please also consider buying us a coffee, to encourage us to create more posts and videos!

;