HTMX and FormWizard Not 'Next'-ing

Hi there!

I’m using a SessionWizardView to iterate over forms and templates during user-creation of an entity and its relations. One such template uses htmx to dynamically render the dropdown selections.

The template itself works - that is, when a user selects a country, the appropriate states appear in the ‘states’ dropdown; likewise, when a user selects a state, the cities selection list is correctly populated using City.objects.filter(state_id=state). The rendering here works.

However, when the form is introduced into a special htmx template in the formtools SessionWizardView, the ‘next’ button does not move to the next form in the list. This is the issue. I have inspected the Network and there is nothing being sent when I click on ‘Next’, ‘First’, or ‘Previous’. It seems like the htmx templates I’ve made have broken the SessionWizardView from progressing.

Relevant code:
address_form.html

{% extends "base.html" %}
{% load i18n %}
{% load widget_tweaks %}

{% block head %}
{{ wizard.form.media }}
{% endblock %}

{% block content %}
<script src="//unpkg.com/htmx.org"></script>
<p>Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}</p>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<table>
{{ wizard.management_form }}

    <label class="" for="id_address_line_one">Address line one*</label>
    {{ wizard.form.address_line_one }}
<p></p>
    <label for="id_address_line_two">Address line two</label>
    {{ wizard.form.address_line_two }}

    <label for="id_postal_code">Postal code</label>
    {{ wizard.form.postal_code }}
<div id="countries">
<label for="id_country">Country*</label>
    <select class="custom-select mb-4"
        name="country"
        hx-get="{% url 'states' %}"
        hx-trigger="change"
        hx-target="#states">

    <option selected>Country</option>
    {% for country in wizard.form.country %}
        <option value="{{country.pk}}">{{ country }}</option>
    {% endfor %}
</select>
</div>
    <div id="states" class="mb-4">
        {% include 'addresses/partials/states.html' %}
    </div>

    <div id="cities" class="mb-4" hx-swap-oob="true">
        {% include 'addresses/partials/cities.html' %}
    </div>
    <!-- Supplier field is hidden using inline CSS style -->
    <div style="display:none;">
        {{ wizard.form.supplier }}
    </div>
</table>
<div class="form-group">

{% if not wizard.steps.next %}
<input class="btn btn-outline-info" type="submit" value="{% translate "Submit" %}"/>
{% else %}
<input class="btn btn-outline-info" type="submit" value="{% translate 'Next' %}"/>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.first }}">{% translate "First" %}</button>
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.prev }}">{% translate "Previous" %}</button>
{% endif %}
</div>
</form>
{% endblock %}

states.html

{% load i18n %}
{% load widget_tweaks %}

<label for="id_state">State*</label>
<select class="custom-select mb-4"
        name="state"
        id="id_state"
        hx-get="{% url 'cities' %}"
        hx-trigger="change"
        hx-target="#cities"
>
    {% if states %}
        {% for state in states %}
            <option value="{{ state.pk }}">{{ state }}</option>
        {% endfor %}
    {% else %}
        <option>Select a Country first</option>
    {% endif %}
</select>

cities.html

{% load i18n %}
{% load widget_tweaks %}

<label for="id_city">City*</label>
<select class="custom-select mb-4"
        name="city"
        id="id_city">
    {% if cities %}
        {% for city in cities %}
            <option value="{{ city.pk }}">{{ city }}</option>
        {% endfor %}
    {% else %}
        <option>Select a Country first</option>
    {% endif %}
</select>

addresses/forms.py

from django import forms
from .models import Address, Country, State, City
from suppliers.models import Supplier
from dynamic_forms import DynamicField, DynamicFormMixin

class AddressForm(DynamicFormMixin, forms.ModelForm):

    def state_choices(form):
        country = form['country'].value()
        return State.objects.filter(country=country)

    def initial_state(form):
        country = form['country'].value()
        return State.objects.filter(country=country).first()

    def city_choices(form):
        state = form['state'].value()
        return City.objects.filter(state=state)

    def initial_city(form):
        state = form['state'].value()
        return City.objects.filter(state=state).first()

    supplier = forms.ModelChoiceField(queryset=Supplier.objects.all())
    address_line_one = forms.CharField(max_length=132)
    address_line_two = forms.CharField(max_length=132, required=False)
    postal_code = forms.CharField(max_length=10, required=False)

    # State should be dynamic based on country; similarly,
    # city should be dynamic based on state
    country = forms.ModelChoiceField(
        queryset=Country.objects.all(),
        required=True,
        initial=Country.objects.first()
    )
    state = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: State.objects.filter(country=form['country'].value()),
        initial=initial_state,
        required=True,
    )
    city = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: City.objects.filter(state=form['state'].value()),
        initial=initial_city,
        required=True,
    )

    class Meta:
        model = Address
        fields = ['address_line_one', 'address_line_two', 'postal_code', 'country', 'state', 'city']

addresses/urls.py

from django.urls import path
from .views import *

urlpatterns = [
    path("cities/", cities, name='cities'),
    path("states/", states, name='states'),
]

addresses/views.py

from django.shortcuts import render
from .forms import *

def cities(request):
    state = request.GET.get('state')
    cities = City.objects.filter(state_id=state)
    context = {'cities': cities} # i have also tried 'wizard.form.states' here and in the templates
    return render(request, 'addresses/partials/cities.html', context)

def states(request):
    country = request.GET.get('country')
    states = State.objects.filter(country_id=country)
    context = {'states': states} # i have also tried 'wizard.form.states' here and in the templates
    return render(request, 'addresses/partials/states.html', context)

relevant WizardView:

class SupplierWizardView(LoginRequiredMixin, SessionWizardView):
    form_list = [('supplier', SupplierForm),
                 ('address', AddressForm),
                 ('contact', ContactPersonForm),
                 ('risks', RiskForm),
                 ('documents', DocumentsForm),
                 ('history', HistoryForm), ]
    templates = {'supplier': "suppliers/supplier_create_wizard.html",
                 'address': "addresses/address_form.html",
                 'contact': "suppliers/supplier_create_wizard.html",
                 'risks': "suppliers/supplier_create_wizard.html",
                'documents': "suppliers/supplier_create_wizard.html",
                'history':"suppliers/supplier_create_wizard.html",
                 }
    file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, "tmp"))

    def get_template_names(self):
        return self.templates[self.steps.current]

    def done(self, form_list, **kwargs):
        supplier = form_list[0].save() # Save supplier to DB
        # Iterate through other forms; add supplier as FK to that form, then save it.
        for i in range(1, len(form_list)):
            instance = form_list[i].save(commit=False)
            instance.supplier = supplier
            instance.save()
        messages.success(self.request, f'Supplier "{supplier.name}" created successfully!')
        return redirect('home')

I’m really not sure where to begin. I’ve tried a lot of options but I think that maybe htmx and WizardViews just don’t work. Advice? Guidance?

Thanks!

What you probably want to do at this point is to look at the HTML as it exists in the browser, before and after HTMX has gotten involved. You want to look for any changes to that HTML that would prevent the form from submitting data - such as overwriting the form tag itself or any attributes it may need.

look at the HTML as it exists in the browser, before and after HTMX has gotten involved.

Just to clarify, do you mean: look at the form in question before touching any elements that would initiate an HTMX action, and then look at the form in question after making such a change?

E.g., load the address form in the browser as usual, inspect the HTML, select a country, inspect the HTML, see what changed?

Or, do you mean compare the address form HTML as it renders in the browser with another form that’s in the Wizard and see what might be missing?

Yes, this. If the wizard is working when you don’t make changes using HTMX, then HTMX is doing something to the HTML to prevent the form from being submitted.

Ah, yeah, that makes sense.

I just dry-tested the template on its own without it being in a wizard and it doesn’t save. The POST goes through with the form contents, but for some reason it isn’t reaching the db and saving.

I’ve made some progress, but not enough.

Outside of the Wizard, the form’s POST request goes through and saves.

In the Wizard, the form POST request goes through, but errors are thrown:

web-1  | [19/Mar/2024 18:45:11] "GET /addresses/states/?country=a9598762-a88e-474f-9c52-47695ed14215 HTTP/1.1" 200 1319
web-1  | [19/Mar/2024 18:45:12] "GET /addresses/cities/?state=275f0522-debd-4a5c-88a1-0f7bb97cd669 HTTP/1.1" 200 1006
web-1  | IN TEST_FUNC
web-1  | IN POST()
web-1  | <ul class="errorlist"><li>country<ul class="errorlist"><li>This field is required.</li></ul></li><li>state<ul class="errorlist"><li>This field is required.</li></ul></li><li>city<ul class="errorlist"><li>This field is required.</li></ul></li><li>supplier<ul class="errorlist"><li>This field is required.</li></ul></li></ul>
web-1  | [19/Mar/2024 18:45:16] "POST /suppliers/create/ HTTP/1.1" 200 34797

This is strange, because the POST request looks like this:

-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="csrfmiddlewaretoken"

LR3hyWIOcPkv2FlLmju8DjVCIPjBUAl09WYjDDTuogKNZmw1OUDOc8crxBMkRyE7
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-address_line_one"

test_address_line1
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-address_line_two"


-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-postal_code"

64232
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="country"

a9598762-a88e-474f-9c52-47695ed14215
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="state"

275f0522-debd-4a5c-88a1-0f7bb97cd669
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="city"

f0573a2e-9349-49a6-bb75-8bfe949f0df3
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="supplier_wizard_create_view-current_step"

address
-----------------------------306349318716571423832440912607--

The UUIDs are the same as the ones selected
The structure above is the same exact structure as the preceding form in the wizard: CSRF-Token, Form Contents, then supplier_wizard_create_view_-current_step with the name of the form, as shown in the preceding form’s POST request:

-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="csrfmiddlewaretoken"

mbfQRlkMquIUUMQ5hDVRwAqUtcnLl3tPKgaSW2vsCV8cRt1lJe4x5pHJiYQui1MW
-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="supplier-name"

name
-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="supplier-business_type"

WA
-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="supplier_wizard_create_view-current_step"

supplier   
-----------------------------1616825465201841182420177588—

Views Below:

class SupplierWizardCreateView(ProcurementCreateUpdateMixin, SessionWizardView):
    file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, "tmp"))
    form_list = FORMS
    templates = TEMPLATES
    def get_template_names(self):
        return self.templates[self.steps.current]

    def done(self, form_list, **kwargs):
        supplier = form_list[0].save() # Save supplier to DB
        # Iterate through forms without supplier name;
        # add supplier's id (PK) to that form, then save the form with the relation.
        for i in range(1, len(form_list)):
            instance = form_list[i].save(commit=False)
            instance.supplier = supplier
            instance.save()
        messages.success(self.request, f'Supplier "{supplier.name}" created successfully!')
        return redirect('home')

    def post(self, *args, **kwargs):
        print("IN POST()")
        form = self.get_form(data=self.request.POST, files=self.request.FILES)
        if form.is_valid():
            print("FORM IS VALID")
            return super().post(*args, **kwargs)
        else:
            print(form.errors)  # Bug hunting
            return self.render(form)  # Render the form with errors




class AddressForm(DynamicFormMixin, forms.ModelForm):

    def initial_state(form):
        country = form['country'].value()
        return State.objects.filter(country=country).first()

    def initial_city(form):
        state = form['state'].value()
        return City.objects.filter(state=state).first()

    supplier = forms.ModelChoiceField(queryset=Supplier.objects.all().order_by('name'))
    address_line_one = forms.CharField(max_length=132)
    address_line_two = forms.CharField(max_length=132, required=False)
    postal_code = forms.CharField(max_length=10, required=False)

    # State should be dynamic based on country; similarly,
    # city should be dynamic based on state
    country = forms.ModelChoiceField(
        queryset=Country.objects.all().order_by('name'),
        #required=True,
        empty_label="Select a Country",
    )
    state = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: State.objects.filter(country=form['country'].value()).order_by('name'),
        initial=initial_state,
        #required=True,
        widget=lambda _: forms.Select(),
    )
    city = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: City.objects.filter(state=form['state'].value()).order_by('name'),
        initial=initial_city,
        #required=True,
        widget=lambda _: forms.Select(),
    )

    class Meta:
        model = Address
        fields = ['address_line_one', 'address_line_two', 'postal_code', 'country', 'state', 'city']


Form Below (only AddressForm, as this is the problematic one):

class AddressForm(DynamicFormMixin, forms.ModelForm):

    def initial_state(form):
        country = form['country'].value()
        return State.objects.filter(country=country).first()

    def initial_city(form):
        state = form['state'].value()
        return City.objects.filter(state=state).first()

    supplier = forms.ModelChoiceField(queryset=Supplier.objects.all().order_by('name'))
    address_line_one = forms.CharField(max_length=132)
    address_line_two = forms.CharField(max_length=132, required=False)
    postal_code = forms.CharField(max_length=10, required=False)

    # State should be dynamic based on country; similarly,
    # city should be dynamic based on state
    country = forms.ModelChoiceField(
        queryset=Country.objects.all().order_by('name'),
        #required=True,
        empty_label="Select a Country",
    )
    state = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: State.objects.filter(country=form['country'].value()).order_by('name'),
        initial=initial_state,
        #required=True,
        widget=lambda _: forms.Select(),
    )
    city = DynamicField(
        forms.ModelChoiceField,
        queryset=lambda form: City.objects.filter(state=form['state'].value()).order_by('name'),
        initial=initial_city,
        #required=True,
        widget=lambda _: forms.Select(),
    )

    class Meta:
        model = Address
        fields = ['address_line_one', 'address_line_two', 'postal_code', 'country', 'state', 'city']

HTML templates below:

address_form_wizard.html

{% extends "_base.html" %}
{% load i18n %}
{% load widget_tweaks %}
{% load crispy_forms_field %}
{% load crispy_forms_filters %}
{% load crispy_forms_tags %}
{% load crispy_forms_utils %}

{% block head %}
{{ wizard.form.media }}
{% endblock %}

{% block content %}
<p>Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}</p>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<table>
{{ wizard.management_form }}
    {{ form.address_line_one|as_crispy_field }}
<p></p>
    {{ form.address_line_two|as_crispy_field }}
    {{ form.postal_code|as_crispy_field }}
<div id="countries">
<label for="id_country">Country*</label>
    <select class="custom-select mb-4"
        name="country"
        hx-get="{% url 'states' %}"
        hx-trigger="change"
        hx-target="#states">

    <option selected>Country</option>
    {% for country in form.country %}
        <option value="{{country.pk}}">{{ country }}</option>
    {% endfor %}
</select>
</div>
    <div id="states" class="mb-4">
        {% include 'addresses/partials/states.html' %}
    </div>

    <div id="cities" class="mb-4" hx-swap-oob="true">
        {% include 'addresses/partials/cities.html' %}
    </div>
</table>
<div class="form-group">

{% if not wizard.steps.next %}
<input class="btn btn-outline-info" type="submit" value="{% translate "Submit" %}"/>
{% else %}
<input class="btn btn-outline-info" type="submit" value="{% translate "Next" %}"/>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.first }}">{% translate "First" %}</button>
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.prev }}">{% translate "Previous" %}</button>
{% endif %}
</div>
</form>
{% endblock %}

states.html (partial)

{% load i18n %}
{% load widget_tweaks %}

<label for="id_state">State*</label>
<select class="custom-select mb-4"
        name="state"
        id="id_state"
        hx-get="{% url 'cities' %}"
        hx-trigger="change"
        hx-target="#cities"
>
    {% if states %}
        {% for state in states %}
            <option value="{{ state.pk }}">{{ state }}</option>
        {% endfor %}
    {% else %}
        <option>Select a Country first</option>
    {% endif %}
</select>

cities.html (partial)

{% load i18n %}
{% load widget_tweaks %}


<label for="id_city">City*</label>
<select class="custom-select mb-4"
        name="city"
        id="id_city">
    {% if cities %}
        {% for city in cities %}
            <option value="{{ city.pk }}">{{ city }}</option>
        {% endfor %}
    {% else %}
        <option>Select a Country first</option>
    {% endif %}
</select>

supplier_create_wizard.html

{% extends "_base.html" %}
{% load i18n %}
{% load crispy_forms_field %}
{% load crispy_forms_filters %}
{% load crispy_forms_tags %}
{% load crispy_forms_utils %}

{% block head %}
{{ wizard.form.media }}
{% endblock %}

{% block content %}
<p>Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}</p>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<table>
{{ wizard.management_form }}
{% if wizard.form.forms %}
    {{ wizard.form.management_form }}
    {% for form in wizard.form.forms %}
        {{ form|crispy }}
    {% endfor %}
{% else %}
    {{ wizard.form|crispy }}
{% endif %}
</table>
<div class="form-group">

{% if not wizard.steps.next %}
<input class="btn btn-outline-info" type="submit" value="{% translate "Submit" %}"/>
{% else %}
<input class="btn btn-outline-info" type="submit" value="{% translate "Next" %}"/>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.first }}">{% translate "First" %}</button>
<button name="wizard_goto_step" class="btn btn-outline-info" type="submit"
        value="{{ wizard.steps.prev }}">{% translate "Previous" %}</button>
{% endif %}
</div>
</form>
{% endblock %}

The page renders fine, the ‘next’ button clicks, but as shown in the error messages, the UUIDs (country, state, and city PKs) don’t seem to be acknowledged by the form wizard. If I change the required fields to ‘False’, then the form in the wizard appears to save without issue, but upon checking the admin page, none of the PKs to country, city, and state were saved.

When I try this with the base form - outside of the wizard - all PKs are saved as expected.

Any ideas?

If anyone encounters the same or a similar issues, these steps worked for me and they just might work for you

  1. Closely look at the POST request. Each form’s field should be of the following structure:
    Content-Disposition: form-data; name="formname-fieldname"

E.g:

-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="csrfmiddlewaretoken"

mbfQRlkMquIUUMQ5hDVRwAqUtcnLl3tPKgaSW2vsCV8cRt1lJe4x5pHJiYQui1MW
-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="supplier-name"

name
-----------------------------1616825465201841182420177588
Content-Disposition: form-data; name="supplier-business_type"

WA
-----------------------------1616825465201841182420177588

Look for POST requests that are missing the formname, as these are the problematic fields, e.g., ‘country’, ‘state’, and ‘city’ in the example below (should be ‘address-country’, ‘address-state’, and ‘address-city’).

-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="csrfmiddlewaretoken"

LR3hyWIOcPkv2FlLmju8DjVCIPjBUAl09WYjDDTuogKNZmw1OUDOc8crxBMkRyE7
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-address_line_one"

test_address_line1
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-address_line_two"


-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="address-postal_code"

64232
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="country"

a9598762-a88e-474f-9c52-47695ed14215
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="state"

275f0522-debd-4a5c-88a1-0f7bb97cd669
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="city"

f0573a2e-9349-49a6-bb75-8bfe949f0df3
-----------------------------306349318716571423832440912607
Content-Disposition: form-data; name="supplier_wizard_create_view-current_step"

address
-----------------------------306349318716571423832440912607--
  1. Make note of the fields missing the required prefix, and write a client-side javascript listener that will prepend the required prefix to the missing fields. Ensure that your template has a form tag with an ID, e.g., <form id="special-form-id-here" so that the JS knows what ID element to find and look for. The script can look something like this:
document.addEventListener("DOMContentLoaded", function() {
    var form = document.getElementById("address-wizard"); // this is where your special form ID goes
    form.addEventListener("submit", function(event) {
        // Rename fields before submission
        const fieldsToRename = ['country', 'state', 'city'];
        fieldsToRename.forEach((fieldName) => {
        let field = document.getElementsByName(fieldName)[0];
        if (field) { // if field exists, rename it, prepending with 'address-
            field.setAttribute("name", `address-${fieldName}`);
                } // end if (field)
        }); // end fieldsToRename.foreach
    }); // end form.addEventListener
}); // end document.addEventListener

Hope this helps anyone else who encounters this issue!