Skip to content

Instantly share code, notes, and snippets.

@viktor2097
Last active May 8, 2025 18:55
Show Gist options
  • Save viktor2097/68335e7862b357f8961daf276675dfbd to your computer and use it in GitHub Desktop.
Save viktor2097/68335e7862b357f8961daf276675dfbd to your computer and use it in GitHub Desktop.
Dynamically add formset form with HTMX

Dynamically add additional forms for a formset using HTMX and a little bit of hyperscript

Supports deleting and adding rows.

I'm using django-template-partials, django-htmx, htmx and hyperscript

I decorate my views with the @htmx_dynamic_handler decorator. This decorator allows you to specify the action that triggers the HTMX request and the function that renders the partial.

@htmx_dynamic_handler("action", _function_that_renders_a_partial)
def my_view(request):
  

action would for instance be the id of the button that was clicked to send the htmx request

In our case, if user clicks the add row button, we render the table row partial with a new form (from formsets)

This way we can keep related partial rendering with the associated view without having to define specific endpoints for partial rendering.

The template rendering code is longer than it needs to be, i manually render forms a bit to represent them in a html table.

{% extends 'base.html' %}
{% load bootstrap4 %}
{% load partials %}
{% load crispy_forms_tags %}
{% load i18n %}
{% partialdef table-row %}
<tr class="pb-0">
{% for field in form.visible_fields %}
{# Include the hidden fields in the form #}
{% if forloop.first %}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
{% endif %}
<td>
{% bootstrap_field field show_label=False %}
</td>
{% endfor %}
<td>
<button type="button"
class="btn btn-danger"
_="on click
if #id_{{ form.prefix }}-DELETE
then
set #id_{{ form.prefix }}-DELETE.value to 'on'
add .d-none to closest <tr/>
else
remove closest <tr/>
end">
Delete
</button>
</td>
</tr>
{% endpartialdef %}
{% block content %}
<div class="container container-margin">
<form action="{{ request.path }}" method="post" id="form">
{% csrf_token %}
{{ order_form }}
<table class="table" aria-describedby="Table of order related items">
<thead>
<tr>
<!-- Dynamically generate table headers based on the fields of the formset -->
{% for field in formset.forms.0.visible_fields %}
<th>{{ field.label|capfirst }}</th>
{% endfor %}
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody id="form-table-body">
{{ formset.management_form }}
{% for form in formset %}
{% partial table-row %}
{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary mr-1">Submit</button>
<button type="button"
class="btn btn-success"
id="add-formset-row"
hx-trigger="click"
hx-get="{{ request.path }}"
hx-swap="beforeend"
hx-target="#form-table-body"
hx-include="#id_form-TOTAL_FORMS"
hx-on:htmx:after-on-load="document.getElementById('id_form-TOTAL_FORMS').value++">
Add row
</button>
</form>
</div>
{% endblock %}
def htmx_dynamic_handler(trigger_action, action_function):
"""
Decorator for handling dynamic HTMX actions in Django views.
Parameters:
- trigger_action: A string representing the HTMX trigger action (e.g., 'add-formset-row').
- action_function: A function to partially render something based on the event.
Usage:
@htmx_dynamic_handler("add-formset-row", _add_order_item_formset_row)
def create_order(request):
...
In the above example, the private view function partially renders a row for the formset
"""
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if request.htmx and request.htmx.trigger == trigger_action:
return action_function(request)
return view_func(request, *args, **kwargs)
return wrapper
return decorator
def _add_order_item_formset_row(request):
"""
Renders a new row with a new index by accounting for the total number of forms in the formset.
"""
total_forms = int(request.GET.get("form-TOTAL_FORMS", 1))
order_item_formset = formset_factory(OrderItemForm, extra=total_forms + 1)
formset = order_item_formset(prefix="form")
new_form = formset.forms[-1]
return render(
request,
"orders/order.html#table-row",
context={"form": new_form},
)
@otp_required
@htmx_dynamic_handler("add-formset-row", _add_order_item_formset_row)
def create_order(request):
order_form = OrderForm(request.POST or None)
formset = OrderItemInlineFormset(request.POST or None, prefix="form")
if request.method == "POST" and order_form.is_valid() and formset.is_valid():
order = order_form.save()
formset.instance = order
formset.save()
return redirect("order:order_view")
return render(
request,
"orders/order.html",
context={"formset": formset, "order_form": order_form},
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment