Динамический модальный выбор в Django с помощью htmx и django-tables2

В этой статье будет показана возможная реализация модального выбора формы в Django с помощью htmx (и немного JavaScript).

Django поставляется с очень удобной реализацией форм, но пользовательский опыт может быть сложно масштабировать, когда у вас есть большое количество иностранных ключей, выбираемых для модели.

Рассмотрим пример модели, которую мы назовем Product (продукт), который компания покупает для бизнеса. Этот продукт хранится на фабрике.

Это будет очень хорошо работать, если использовать стандартную форму Django, когда у вас очень мало фабрик, но когда компания вырастет и будет иметь тысячи фабрик для размещения продукта, регистрация формы может стать кошмаром.

Еще одна раздражающая особенность (обычная в django для форм с иностранными ключами) — это языковая доступность. Когда вы не понимаете языка формы, вы обычно можете обойти эту проблему, скопировав-вставив каждое поле и переведя его. Однако, пользователь не может легко скопировать-вставить опцию меню выбора, и вы не можете ожидать, что пользователь будет искать текст, который можно выбрать на странице, используя инструмент Chrome Dev.

Вот почему я считаю, что регистрация формы должна осуществляться из другого интерфейса, например, из таблицы с пагинацией и строкой поиска.

С помощью JavaScript-фреймворка, такого как React, вы можете создавать различные компоненты, вызывающие ваш API из меню поиска и возвращающие кликабельные элементы, которые могут храниться в памяти как состояние, поскольку React позволяет работать плавно, не обновляя страницу, — известный опыт одностраничного приложения (SPA).

Когда я начал заниматься htmx, одним из моих первых побуждений было посмотреть, как воссоздать это ощущение SPA на «чистой» странице Django, и к этому меня привела эта очень хорошая статья от Joash Xu, которая дала мне основу для создания прототипа (https://dev.to/joashxu/responsive-table-with-django-and-htmx-1fob).

Таблица — это очень интересная основа для модального выбора, поскольку вы можете реализовать столько полей, сколько считаете нужным, чтобы пользователь был проинформирован о них во время выбора.

Вы можете найти окончательный код этого проекта здесь :

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

Резюме

1 — Создание обзора приложения

2 — Создайте несколько дополнительных страниц

3- Построить модальную страницу

4- Переписать django-tables2 templatestag

5- Вставляем модал в нашу форму

6- Дополнительный JS для сохранения выбранного элемента в DOM

7- Окончательное редактирование формы

1 — Настройка обзора приложения

Давайте начнем django проект django_htmx_modal. Я не буду подробно останавливаться на основных настройках, так как там нет ничего специфического, вы можете скопировать их из репозитория проекта.

Мы будем использовать те же инструменты, что и в статье Джоаша

  • htmx, приходящий в виде JS файла
  • django-tables2
  • django-filter
  • django-htmx

А также

  • django-crispy-form
  • И дополнительный JS модуль под названием Hyperscript

Hyperscript соответствует html-тегу «__=», который позволяет нам делать модификации DOM прямо из нашего шаблона и является хорошим дополнением к htmx.

Hyperscript не является обязательным и вы можете обойтись без него, но это довольно удобный инструмент.
(Пример модала django-htmx без гиперскрипта https://blog.benoitblanchon.fr/django-htmx-modal-form/).

В противном случае, не забудьте добавить строку django-htmx middleware, а также специальный тег пользовательского шаблона, который мы подробно рассмотрим позже, и все готово.

Дополнительное приложение «products» имеет 2 модели.

Factory и затем Product, которые принимают Factory в качестве ForeignKey.

2- Создайте несколько дополнительных страниц

Помимо базовой аутентификации и html-файла, содержащего скрипты, css и основные функции, мы сделаем все очень просто.

Страница для просмотра списка продуктов с id
(products/template/product_index.html)
Страница для регистрации нового продукта с именем.
(products/template/product_form.html)

3 — Построение модала

Для того чтобы выглядеть так же, как в статье Джоаша, мы создаем фабрику таблиц

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"

Вход в полноэкранный режим Выйти из полноэкранного режима

И фильтр

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))

Войти в полноэкранный режим Выход из полноэкранного режима

Теперь давайте создадим шаблон для таблицы

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 %}


Войти в полноэкранный режим Выход из полноэкранного режима

Следует отметить несколько моментов.

  • Я использую столбцы «id» как специфический триггер для получения кликабельного элемента в таблице, который позволит мне сбросить конкретную фабрику при возвращении на форму.

  • Кнопка id поставляется с небольшим скриптом, который я объясню в разделе 6.

Тег querystring_upd я объясню в следующем разделе.

Для создания модального шаблона я буду использовать пользовательскую версию шаблона, сделанную Беном Пейтом на основе стандартного модала ukit https://github.com/benpate/htmx-modal-example.

Внутри модала мы, как и в статье Иоаша, имеем строку поиска, таблицу и пагинацию.

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>
Вход в полноэкранный режим Выход из полноэкранного режима

И частичная версия, возвращающая только таблицу.

{% load render_table from django_tables2 %}

{% render_table table %}
Войти в полноэкранный режим Выход из полноэкранного режима

products/template/modal/factory_modal.html

Мы просто хотим, чтобы полная версия вызывалась при нажатии на модальную кнопку в форме.
Поскольку рендер частичной таблицы представляет собой htmx внутри htmx, нам нужно предоставить представлению некоторую дополнительную информацию о том, из какого события мы хотим вызвать полный модал.

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

Вход в полноэкранный режим Выйти из полноэкранного режима

Уведомляя цель вашего htmx запроса, вы можете создать столько модалов, сколько захотите, и они будут действовать как React компонент, рендеринг только при срабатывании связанного с целью события.

4- Переписать django-tables2 templatestag

Использование таблицы, как написано сейчас, будет правильно работать на обычной странице, но не в модале. Модал установит первую страницу нефильтрованного запроса, но как только вы попытаетесь изменить страницу или запрос фильтра, произойдет неожиданное поведение.
Это происходит потому, что тег шаблона, используемый в таблице для запроса htmx get, предназначен для возврата представлений со стоящей страницы, а не модала.

Это поведение можно изменить, переписав тег шаблона querystring в django-tables 2.
Мы добавим дополнительный параметр, который будет сообщать шаблону, что запрос htmx get должен быть установлен на модале, а не на целевой странице.

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)

Вход в полноэкранный режим Выйти из полноэкранного режима

5 — Вставка модала в нашу форму

Теперь мы вставим 3 элемента в нашу форму.

  1. Конечно же, модальный
  2. Элемент для отображения имени иностранного ключа, выбранного пользователем
  3. Скрытый элемент, который будет хранить id выбранного пользователем иностранного ключа.
<!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>
Вход в полноэкранный режим Выход из полноэкранного режима

И снова здесь мы используем объединенную мощь htmx и hyperscript для отправки запроса на открытие модала по щелчку.
После открытия модала тег querystring_upd перемещается по таблице так же плавно, как если бы мы находились на обычной странице.

6- Дополнительный JS для сохранения выбранного элемента в DOM

Во время создания таблицы мы добавили специальный столбец для ID, который будет вызывать функцию Javascript.
Эта функция будет использоваться для отображения результата клика и сохранения id в скрытом элементе для последнего POST-запроса формы.

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);
    }
};

Вход в полноэкранный режим Выход из полноэкранного режима

7- Окончательное редактирование формы

Наконец, мы можем обновить вид POST-формы, чтобы сообщить Django использовать значение из нашего скрытого элемента в качестве ForeignKey, который мы хотим установить для фабрики нашего нового продукта.

@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})

Вход в полноэкранный режим Выход из полноэкранного режима

Теперь у вас должна быть форма, в которую можно добавить несколько больших наборов Foreign Key и где пользователь может легко искать и просматривать всю информацию с помощью SPA UI.

Оцените статью
Procodings.ru
Добавить комментарий