django-filters and htmx not operating as expected (multiple fields)

I’m trying to build a view so that a user can filter various attributes of a primary entity and attributes of entities related to that primary entity.

In particular, I have a Supplier class that has one-to-many relation with a Riskset class.

I would like for a user to be able to - on the same page, using the same form - search for a supplier and have the suppliers render using htmx. I would also like for a user to be able to apply filters and have those filters also applied to the same query.

E.g., if a user searches for ‘abc’, suppliers ABC Industries and ABC Inc should appear. This works as expected. The complication is when other filters are included.

I would also like the user to be able to filter by the range of a Supplier’s RiskSet.score and by the Supplier’s RiskSet.threshold. The issue is that whenever a users enters text into any of these fields, the other filters are reset and the queryset object that is returned is only the most recent thing for which the user entered.

Here is the relevant Filter code:

class SupplierFilter(django_filters.FilterSet):
    name = django_filters.CharFilter(
        lookup_expr='icontains',
        label='',
        widget=forms.TextInput(attrs={
            'hx-get': reverse_lazy('home'),
            'hx-target': 'div.table-container',
            'hx-swap' : 'outerHTML',
            'hx-trigger': 'keyup',
            'placeholder': 'Find a supplier...',
            'class': 'form-inline form-control mt-3',
        })
    )
    score = django_filters.RangeFilter(
        field_name="riskset__score",
        label="Supplier Score",
        widget=django_filters.widgets.RangeWidget(
            attrs={
            'hx-get': reverse_lazy('home'),
            'hx-target': 'div.table-container',
            'hx-swap' : 'outerHTML',
            'hx-trigger': 'keyup',
            'class': 'form-inline form-control mb-2',
        }),
    )
    turnover = django_filters.RangeFilter(
        field_name="riskset__turnover",
        label="Turnover",
        widget=django_filters.widgets.RangeWidget(attrs={
            'hx-get': reverse_lazy('home'),
            'hx-target': 'div.table-container',
            'hx-swap': 'outerHTML',
            'hx-trigger': 'keyup',
            'class': "form-inline form-control mb-2",
        }),
    )
    business_type = django_filters.MultipleChoiceFilter(
        field_name="business_type",
        label="Business Type",
        widget=forms.CheckboxSelectMultiple(attrs={
            'hx-get': reverse_lazy('home'),
            'hx-target': 'div.table-container',
            'hx-swap': 'outerHTML',
            'hx-trigger': 'keyup',
            'class': "form-inline",
            'class': 'form-control mt-3',  # Make sure to include Bootstrap's form-control class
        }),
    )

    class Meta:
         model = Supplier
         fields = ['name',]

In action, when a user types a minimum score ‘20’, the query returns results as expected:

After this, if the user then types ‘h’ to search through the suppliers, ‘risk score’ filter is not applied:

Any ideas on how to build and execute this desired functionality using htmx?

Hello there!
In order to help you, we need to see the templates and views related to your screenshots.

Sure! Templates:

single_table.html (holds the table as well as the ‘find a supplier’ form element:

{% extends "_base.html" %}
{% load crispy_forms_filters %}
{% load crispy_forms_tags %}
{% block title %}
Suppliers
{% endblock title %}
{% block content %}
        {{ form.name|as_crispy_field }}
{% include "generic/htmx_table.html" %}
{% endblock content %}

#sidebar.html (contains the other elements of the form)

{% load crispy_forms_filters %}
{% load crispy_forms_tags %}
{% if user.is_authenticated %}
    {% if request.resolver_match.url_name == 'home' %}
<div class="container mb-3">
    <div class="row gx-2 mb-2">
        <div class="col">
        {{ form.score|as_crispy_field }}
        </div>
        <div class="col-5">{{ form.turnover|as_crispy_field }}</div>
        </div>
{#    {{ form.turnover|as_crispy_field }}#}
        {% endif %}
{% endif %}

htmx_table.html (that which is returned during htmx requests)

{% load render_table from django_tables2 %}
{% render_table table %}

View:

class SuppliersTableView(LoginRequiredMixin, SingleTableView): #, FilterView):
    queryset = Supplier.objects.all()
    model = Supplier
    paginate_by = 15
    table_class = SupplierTable
    filterset_class = SupplierFilter
    context_object_name = 'suppliers'

    def get_queryset(self):
        queryset = super().get_queryset()
        self.filterset = SupplierFilter(
            self.request.GET,
            queryset=queryset,
        )
        return self.filterset.qs

    def get_template_names(self):
        if self.request.htmx:
            return "generic/partials/htmx_table.html"
        else:
            return "generic/single_table.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        query = self.request.GET.get('query')
        context['form'] = self.filterset.form
        return context

Please note that all of the htmx is contained within the Filter class posted earlier.

In the general sense, HTMX is just a fancy/easy way to do AJAX. Django doesn’t know or care if the request coming in is from a browser request, plain JavaScript AJAX, HTMX, Vue, React, etc. It’s just another request to be handled.

What this means on the browser side, is that it’s your responsibility to ensure that the AJAX call being made by HTMX contains all the variables that it needs to supply for that request. This could require some additional JavaScript be written to get the necessary data to be submitted.

Your views then also don’t need to know or care that the submission is being made by HTMX. It just needs to handle the submitted data being supplied.

As a result of this, you may not want to tag each individual entry field with an hx-get tag. You might want to wrap all these fields in a form tag, allowing a single hx-get to submit all the fields at once.

1 Like

Thanks Ken! So, the refactoring guidance is:

  1. Remove ‘hx-get’ from all form elements. Also, do you advise that I remove all other ‘hx-*’ attributes? Seems like they’re not required if changes to the form itself will be what determine how the target changes.

  2. In the template, wrap all of the {{ form_element|as_crispy_field }} in form tags. Do I do this individually? E.g.,

<form>{{ form_element_a|as_crispy_field }}</form>
<form>{{ form_element_b|as_crispy_field }}</form>

etc?

  1. Where do you advise I put the hx-get? In the separate form tags? I’m unsure where/how to place a single hx-get given the structure of the templates.

OR, if I’m mistaken with 2, should it be something like

<form
    hx-get='{% url 'home' %}'
    hx-target='div.table-container'
    hx-swap='outerHTML'
    hx-trigger='change'
>
{{ form_element_a|as_crispy_field }}
...include other template..., which has:
{{ form_element_b|as_crispy_field }}
{{form_element_c|as_crispy_field }}
...after include....
</form>

And the form tag that envelopes all of the separate fields has that one hx-get?

Because I’m trying the <form ....hx-*....> {field}{field}{field} </form> approach and now there’s no requests being sent or responses received.

Yes, this.

You’ll then want to either have a submit button or use hx-trigger to cause the form to be submitted.

You may want to have a submit button if you want people to fill multiple fields before submitting. Or, you can use the trigger event to submit on any individual “changed” event. (See HTMLElement: change event - Web APIs | MDN for a list of what throws this event under what conditions.)

1 Like

Thanks Ken!

Because of the way my layout is, the <form> would have needed to cross multiple <div> tags, and when I tried to go this route, the functionality broke because the <form> was abruptly ended at the first </div> it encountered. So, I followed your other hint earlier and wrote some JavaScript code.

For anyone else who may come across a similar issue: here are some snippets of the refactored code that works!

filter.py

class SupplierFilter(django_filters.FilterSet):
    name = django_filters.CharFilter(
        lookup_expr='icontains',
        label='',
        widget=forms.TextInput(attrs={
            'placeholder': 'Find a supplier...',
            'id': 'supplier_filter_name', # Be sure to add an ID to each element, otherwise the js won't work
            'on_change': 'dynamic_supplier_filter()',
            'class': 'form-inline form-control mt-3',
        })
    )
    score = django_filters.RangeFilter(
        field_name="riskset__score",
        label="Supplier Score",
        widget=django_filters.widgets.RangeWidget(
            attrs={
           # id is rendered as 'supplier_filter_score_0' for min and '_1' for max
           'id': 'supplier_filter_score',
           'on_change': 'dynamic_supplier_filter()',
           'class': 'form-inline form-control mb-2',
        }),
    )

    class Meta:
         model = Supplier
         fields = ['name',]

dynamic_filtering.js

// event listener for dynamic_supplier_filter()
document.addEventListener('DOMContentLoaded', () => {
    // List the IDs of the filter elements
    const elementIds_keyup = [
        'supplier_filter_name',
        'supplier_filter_score_0',
        'supplier_filter_score_1',
    ];
    // Cycle through filter element IDs and add an event listener
    for (let id of elementIds_keyup) {
        document.getElementById(id).addEventListener('keyup', dynamic_supplier_filter);
    }
});

function dynamic_supplier_filter() {
    // As with the event listener, list the IDs and params of the filter
    // NB: Params will be either the element/attribute name in the django-filter Filter,
    // or will have _min and _max as suffixes...unsure about other filter elements (e.g. checkboxes)
    const filters = [
        {id: 'supplier_filter_name', paramName: 'name'},
        {id: 'supplier_filter_score_0', paramName: 'score_min'}, // range filter
        {id: 'supplier_filter_score_1', paramName: 'score_max'}, // range filter
    ];

    // Initialize the query URL
    let queryUrl = '/?';

    // Iterate over each filter to collect its value and append to queryUrl
    filters.forEach((filter, index) => {
        const value = document.getElementById(filter.id).value;
        queryUrl += `${encodeURIComponent(filter.paramName)}=${encodeURIComponent(value)}`;
        // Add '&' if it's not the last parameter
        if (index < filters.length - 1) {
            queryUrl += '&';
        }
    });
    // Send request via htmx, replace container with response
    htmx.ajax('GET', queryUrl, {
        target: 'div.table-container',
        swap: 'outerHTML'
    });
}

Be sure to include <script src="{% static 'js/dynamic_filtering.js' %}"></script> in the appropriate html template (I put it in base inside of a conditional request.resolver_match.url check because this is a file that will have similar code for other pages and should be loaded only when the user is on a page requiring it).

Views and HTML same as initially provided.