DEV Community

guillaume-sy
guillaume-sy

Posted on

Dynamic selection modal in Django with htmx and django-tables2

This article will show a possible implementation of a modal-based form selection in Django using htmx (and a little bit of JavaScript).

Django comes with a very handy form implementation but the user experience can be complicated to scale when you have a large amount of Foreign keys selectable for a model.

Let's take the example of a model we will call Product that a company purchases for the business. This Product is stored in a Factory.

This would work very well to use the standard coming from the Django form when you have very few factories but when the company grow to have thousand of factories to put the product, the form registration can become a nightmare.

Another annoying feature of the (common in django for Foreign keys forms) is the language accessibility. When you do not understand the language of a form, you usually can by-pass this issue by copy-paste every field and translate. For , however, an user cannot copy-paste easily the option of a select menu and you cannot expect a user to go pathfind a selectable text in the page using the Chrome Dev tool.

This is why I feel the form registration should be done from another interface, for example a table with pagination and search bar.

With a JavaScript framework such as React, you can build various components calling your API from a searching menu and return clickables items that can be kept in memory as a state since React allows to operate smoothly without refreshing the page, the well-known Single Page Application (SPA) experience.

When I started htmx, one of my first motivation was to see how to recreate this SPA feeling on a “pure” Django page and this is arrived at this very nice article from Joash Xu that gave me a core to build a prototype (https://dev.to/joashxu/responsive-table-with-django-and-htmx-1fob).

Table is a very interesting framework for a modal selection as you can implement as many fields as you feel relevant for the user to be informed of during the choice.

Image description

You can find the final code of this project here :

https://github.com/guillaume-sy/django_htmx_modal

Summary

1 - Set the app overview

2- Build some additional pages

3- Build the modal

4- Rewrite django-tables2 templatestag

5- Insert the modal in our form

6- Additional JS to save the selected item in the DOM

7- Final form editing

1- Set the app overview

Let us start a django project django_htmx_modal. I will not go into details on the main settings as there is nothing specific, you can copy it from the project repository.

We will use the same tools than in the article of Joash

  • htmx, coming as a JS file
  • django-tables2
  • django-filter
  • django-htmx

As well as

  • django-crispy-form
  • And an additional JS module called Hyperscript

Hyperscript corresponds to the html tag “__=” that allows us to make DOM modifications directly from our template and is a good complement of htmx.

Hyperscript is not mandatory and you can do without but this is a pretty convenient tool.
(An example of a django-htmx modal without hyperscript https://blog.benoitblanchon.fr/django-htmx-modal-form/)

Otherwise, make sure to add the django-htmx middleware line as well as the specific custom template tag that we will see in detail later, and we are good to go.

The additional app “products” has 2 models.

Factory and then Product that take Factory as a ForeignKey.

2- Build some additional pages

Above a basic authentification and a html file that contain scripts, css and basic feature, we will keep it very simple.

A page to see the product list with id
(products/template/product_index.html)
A page to register a new product with a name.
(products/template/product_form.html)

3- Build the modal

In order to look same as in the article of Joash, we create a factory table

products/tables.py

import django_tables2 as tables
from .models import Factory


class FactoryTable(tables.Table):

    class Meta:
        model = Factory
        fields = ['factory_name', 'factory_reg_date', 'id']
        template_name = "table/factory_table.html"

Enter fullscreen mode Exit fullscreen mode

And the filter

products/filters.py

from .models import Factory
from django.db.models import Q
import django_filters


class FactoryFilter(django_filters.FilterSet):
    query = django_filters.CharFilter(method='universal_search',
                                      label="")

    class Meta:
        model = Factory
        fields = ['query']

    def universal_search(self, queryset, name, value):
        return Factory.objects.filter(
            Q(factory_name__icontains=value))

Enter fullscreen mode Exit fullscreen mode

Now let us build the template for the table

products/template/table/factory_table.html

{% extends "django_tables2/bootstrap4.html" %}
{% load django_tables2 %}
{% load i18n %}
{% load newquery_tag %}



{% block table.thead %}
  {% if table.show_header %}
      <thead {{ table.attrs.thead.as_html }}>
      <tr>
          {% for column in table.columns %}
              <th {{ column.attrs.th.as_html }}
                  hx-get="{% querystring_upd /products/factory_modal/ table.prefixed_order_by_field=column.order_by_alias.next %}"
                  hx-trigger="click"
                  hx-target="div.table-container"
                  hx-swap="outerHTML"
                  hx-indicator=".progress"
                  style="cursor: pointer;">
                  {{ column.header }}
              </th>
          {% endfor %}
      </tr>
      </thead>
  {% endif %}
{% endblock table.thead %}

{% block table.tbody %}
    <tbody {{ table.attrs.tbody.as_html }}>
    {% for row in table.paginated_rows %}
        {% block table.tbody.row %}
        <tr {{ row.attrs.as_html }}>
            {% for column, cell in row.items %}
                {% if column.header  == "ID" %}
                    <td {{ column.attrs.td.as_html }} class="{{ column}}">
                        <button  type="button" onclick="setFactoryValue({{ cell }})"  class="product-form_modal_button" id="factory-button-{{ cell }}" value={{ cell }}  >Select</button>
                    </td>
                    {% else %}
                            <td {{ column.attrs.td.as_html }} class="{{column}}">{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}</td>
                {% endif %}

            {% endfor %}
        </tr>
        {% endblock table.tbody.row %}
    {% empty %}
        {% if table.empty_text %}
        {% block table.tbody.empty_text %}
            <tr><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr>
        {% endblock table.tbody.empty_text %}
        {% endif %}
    {% endfor %}
    </tbody>
{% endblock table.tbody %}


{% block pagination.previous %}
    <li class="previous page-item">
        <div hx-get="{% querystring_upd /products/factory_modal/ table.prefixed_page_field=table.page.previous_page_number %}"
             hx-trigger="click"
             hx-target="div.table-container"
             hx-swap="outerHTML"
             hx-indicator=".progress"
             class="page-link">
            <span aria-hidden="true">&laquo;</span>
            {% trans 'previous' %}
        </div>
    </li>
{% endblock pagination.previous %}
{% block pagination.range %}
    {% for p in table.page|table_page_range:table.paginator %}
        <li class="page-item{% if table.page.number == p %} active{% endif %}">
            <div class="page-link"
                 {% if p != '...' %}hx-get="{% querystring_upd /products/factory_modal/ table.prefixed_page_field=p %}"{% endif %}
                 hx-trigger="click"
                 hx-target="div.table-container"
                 hx-swap="outerHTML"
                 hx-indicator=".progress">
                {{ p }}
            </div>
        </li>
    {% endfor %}
{% endblock pagination.range %}

{% block pagination.next %}
    <li class="next page-item">
        <div hx-get="{% querystring_upd /products/factory_modal/ table.prefixed_page_field=table.page.next_page_number %}"
             hx-trigger="click"
             hx-target="div.table-container"
             hx-swap="outerHTML"
             hx-indicator=".progress"
             class="page-link">
            {% trans 'next' %}
            <span aria-hidden="true">&raquo;</span>
        </div>
    </li>
{% endblock pagination.next %}


Enter fullscreen mode Exit fullscreen mode

Several points to mention.

  • I use the “id” columns as a specific trigger to get a clickable item on the table that will allow me to dump the specific factory when coming back to the form.

  • The id button comes with a small script that I will explain on the section 6

I will explain to the querystring_upd tag in the next section.

To build the modal template, I will use a custom version of the template done by Ben Pate based on a ukit standard modal https://github.com/benpate/htmx-modal-example.

Inside the modal, we therefore have, as in the article of Joash, the search bar, the table and the pagination.

products/template/modal/factory_modal.html

{% load render_table from django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}


<div id="factory-modal" class="uk-modal" style="display:block;">
    <div class="uk-modal-dialog uk-modal-body">
        <h2 class="uk-modal-title">Factory selection</h2>
        <div class="product_list__main_container">
            <form hx-get="{% url 'factory_modal' %}"
                  hx-target="div.table-container"
                  hx-swap="outerHTML"
                  hx-indicator=".progress"
                  class="form-inline">
                {% crispy filter.form %}
            </form>
            <div class="progress">
                <div class="indeterminate"></div>
            </div>
            {% render_table table %}
        </div>
        <form _="on submit take .uk-open from #factory-modal">
            <button type="button" class="uk-button uk-button-default"
                    _="on click take .uk-open from #factory-modal wait 200ms then remove #factory-modal">Close
            </button>
        </form>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

And the partial version returning only the table.

{% load render_table from django_tables2 %}

{% render_table table %}
Enter fullscreen mode Exit fullscreen mode

products/template/modal/factory_modal.html

We just want the full version to be called when we click on the modal button in the form.
Since the render of the partial table is an htmx inside an htmx, we need to provide for the view some additional information about from which event we want to call the full modal.

products/views.py

class FactoryTableModalView(SingleTableMixin, FilterView):
    table_class = FactoryTable
    queryset = Factory.objects.all().order_by("-factory_reg_date")
    filterset_class = FactoryFilter
    paginate_by = 5

    def get_template_names(self):
        if self.request.htmx.target == "show-factory-modal-here":
            template_name = "modal/factory_modal.html"
        else:
            template_name = "modal/factory_modal_partial.html"
        return template_name

Enter fullscreen mode Exit fullscreen mode

By notifying the target of your htmx request, you can build as much modal as you want and they would act as a React component, rendering only when the associated target event is triggered.

4- Rewrite django-tables2 templatestag

Using the table as written now would work correctly on a standing page but not in the modal. The modal will set up the first page of the unfiltered query but as soon as you would try to change the page or query the filter, some unexpected behavior will occur.
This is because the template tag used in the table for the htmx get request is designed to return the views from the standing page and not the modal.

This behavior can be modified by rewriting the querystring template tag of django-tables 2.
We add an additional parameter that would inform the template to set the htmx get request on the modal and not on a landing page.

products/templatetags/newquery_tag.py

import re
from collections import OrderedDict

from django import template
from django.core.exceptions import ImproperlyConfigured
from django.template import Node, TemplateSyntaxError
from django.utils.html import escape
from django.utils.http import urlencode

register = template.Library()
kwarg_re = re.compile(r"(?:(.+)=)?(.+)")
context_processor_error_msg = (
    "Tag {%% %s %%} requires django.template.context_processors.request to be "
    "in the template configuration in "
    "settings.TEMPLATES[]OPTIONS.context_processors) in order for the included "
    "template tags to function correctly."
)


def token_kwargs(bits, parser):
    """
    Based on Django's `~django.template.defaulttags.token_kwargs`, but with a
    few changes:

    - No legacy mode.
    - Both keys and values are compiled as a filter
    """
    if not bits:
        return {}
    kwargs = OrderedDict()
    while bits:
        match = kwarg_re.match(bits[0])
        if not match or not match.group(1):
            return kwargs
        key, value = match.groups()
        del bits[:1]
        kwargs[parser.compile_filter(key)] = parser.compile_filter(value)
    return kwargs


class QuerystringNode(Node):
    def __init__(self, updates, removals, asvar=None, modal_node=None):
        super().__init__()
        self.updates = updates
        self.removals = removals
        self.asvar = asvar
        # We initalize the Node with an additional parameter modal_node
        self.modal_node = modal_node

    def render(self, context):
        if "request" not in context:
            raise ImproperlyConfigured(context_processor_error_msg % "querystring")

        params = dict(context["request"].GET)
        for key, value in self.updates.items():
            if isinstance(key, str):
                params[key] = value
                continue
            key = key.resolve(context)
            value = value.resolve(context)
            if key not in ("", None):
                params[key] = value
        for removal in self.removals:
            params.pop(removal.resolve(context), None)

        value = escape("?" + urlencode(params, doseq=True))

        # if there is a modal_node, the modal_node is added to the returned query value
        if self.modal_node:
            value = str(self.modal_node) + value

        if self.asvar:
            context[str(self.asvar)] = value
            return ""
        else:
            return value


# {% querystring_upd "name"="abc" "age"=15 as=qs %}
@register.tag
def querystring_upd(parser, token):
    """
    Creates a URL (containing only the query string [including "?"]) derived
    from the current URL's query string, by updating it with the provided
    keyword arguments.

    Example (imagine URL is ``/abc/?gender=male&name=Brad``)::

        # {% querystring "name"="abc" "age"=15 %}
        ?name=abc&gender=male&age=15
        {% querystring "name"="Ayers" "age"=20 %}
        ?name=Ayers&gender=male&age=20
        {% querystring "name"="Ayers" without "gender" %}
        ?name=Ayers

    Additional parameter : if a modal_node sub domain is added to the tag, rewrite the url with another URL query
    """

    bits = token.split_contents()

    modal_sub_domain = None
    # if there is an additonal parameter modal_node indicated in the template tag indication,
    # this will be indicated from the token and attached as modal_sub_domain on the retuning value
    if len(bits) == 3:
        modal_sub_domain = bits[1]
        bits.pop(1)

    tag = bits.pop(0)
    updates = token_kwargs(bits, parser)

    asvar_key = None
    for key in updates:
        if str(key) == "as":
            asvar_key = key

    if asvar_key is not None:
        asvar = updates[asvar_key]
        del updates[asvar_key]
    else:
        asvar = None

    # ``bits`` should now be empty of a=b pairs, it should either be empty, or
    # have ``without`` arguments.
    if bits and bits.pop(0) != "without":
        raise TemplateSyntaxError("Malformed arguments to '%s'" % tag)
    removals = [parser.compile_filter(bit) for bit in bits]

    # modal_node is added here to the custom node
    return QuerystringNode(updates, removals, asvar=asvar, modal_node=modal_sub_domain)

Enter fullscreen mode Exit fullscreen mode

5- Insert the modal in our form

Now, we will insert 3 elements in our form.

  1. The modal of course
  2. A element to display the ForeignKey name selected by the user
  3. A hidden element that will store the id of the ForeignKey selected by the user
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
{% load static %}

<body>
{% block content %}
    <div class="product-form">
        <form method="POST" enctype="multipart/form-data" class="post-form">
            {% csrf_token %}
            <div class="product-form__container">

                <div class="product-form__element">
                    <label class="product-form__label"> Name </label>
                    <input id="product-name" name="product-name">
                </div>

                <div class="product-form__element">
                    <div class="product-form__element__top">
                        <label class="product-form__label"> Factory</label>
                        <div class="product-form__button_container">
                            <button hx-get="{% url 'factory_modal' %}"
                                    hx-target="#show-factory-modal-here"
                                    class="product-form_addButton"
                                    _="on htmx:afterOnLoad wait 10ms then add .uk-open to #factory-modal">
                                <ion-icon class="product-form__icon" name="search"></ion-icon>
                            </button>
                        </div>
                    </div>
                    <div class="product-form__visible">
                        <p class="product-form__name_display" id="form__visible__factory_name_display"> &nbsp;&nbsp;------</p>
                    </div>
                    <div class="product-form__hidden">
                        <input class="form-control" id="hidden_factory_value" type="hidden" name="factory" value="0">
                    </div>
                </div>


            </div>

            <div id="show-factory-modal-here"></div>

            <br>
            <br>
            <button type="submit" class="product-form__button" id="entry-button"> Submit</button>
            <br>
            <br>
        </form>
    </div>

{% endblock %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Once again, here, we use the combined power of htmx and hyperscript to send the request to open the modal on click.
When the modal is opened, the querystring_upd tag navigate in the table as smoothly as if we were on a standard page.

6- Additional JS to save the selected item in the DOM

During the fabrication of the table, we added a specific column for the ID that would call a Javascript function.
This function will be used to display the click result and store the id in the hidden element for the latter form POST request.

let removeFadeOut=( el, speed )=> {
    const seconds = speed/1000;
    el.style.transition = "opacity "+seconds+"s ease";

    el.style.opacity = 0;
    setTimeout(function() {
        el.parentNode.removeChild(el);
    }, speed);
}

let setFactoryValue = (id) => {
    let selectedFactory= document.getElementById(`factory-button-${id}`)
    let parentDiv = selectedFactory.parentNode.parentNode;
    let selectedFactoryNameValue = parentDiv.firstElementChild.innerHTML
    let factoryValueToSet= document.getElementById(`hidden_factory_value`)
    factoryValueToSet.value= selectedFactory.value
    let factoryNameToShow= document.getElementById(`form__visible__factory_name_display`)
     factoryNameToShow.innerHTML= selectedFactoryNameValue
    let modalDisplayed= document.getElementById(`factory-modal`)
    if(modalDisplayed){
        removeFadeOut(modalDisplayed, 200);
    }
};

Enter fullscreen mode Exit fullscreen mode

7- Final form editing

We can finally update the view of the POST form to inform Django to use the value from our hidden element as the ForeignKey we want to set for the factory of our new Product.

@login_required(login_url='/authen/')
def product_form(request):

    if request.method == 'POST':
        form = ProductForm(request.POST, request.FILES)
        selected_factory_id = int(request.POST['factory'])
        selected_factory = Factory.objects.filter(id=selected_factory_id).order_by("-factory_reg_date")[0]
        selected_product_name = request.POST['product-name']
        form.product_name = selected_product_name
        updated_request = request.POST.copy()
        updated_request.update({'product_name': selected_product_name})
        updated_form = ProductForm(updated_request)

        if updated_form.is_valid():
            product = updated_form.save(commit=False)
            product.product_creating_user = request.user
            product.product_factory_name = selected_factory
            updated_form.save()
            return render(request, 'product_form.html', {'form': form})
        else:
            print(form.errors)
    else:
        form = ProductForm()
    return render(request, 'product_form.html', {'form': form})

Enter fullscreen mode Exit fullscreen mode

You should now have a form where you can add multiple, large sets of Foreign Key and where the user can easily search and review all information with a SPA UI experience.

Image description

Top comments (0)