Empty ModelForm select box, then replacing with HTMX

Hi all

I am trying to create a (second) select dropdown where the options are conditional on an earlier (first) select dropdown in the same form using HTMX. This is part of guestbook application, and I’m trying to ensure that when a user is filling out a form to create a new entry, they select a “Visit” , and then they choose only from the names of “authors” (Contact) that are associated with that Visit.

I think I’m pretty close, but I have one last wrinkle to iron out, which is how to make the second dropdown display no options until the first dropdown is selected.

  • A GET request delivers the form, as expected - including a blank second dropdown (visitors), because I’ve used the init method in forms.py to specify the queryset for that field as None.
  • If I use the browser to change the value of the first select box (visit), the htmx bit of the view swaps in the partial and Django renders the updated selection options for the second select box (author). This works for multiple different Visits, showing different possible Authors for an Entry. (If I change the first select box back to blank (“----”) then the second select box also goes back to blank.)
  • If I make a POST request by submitting the form, I am returned to the form, with an error displayed above the Author field (looks like a field-level validation error): “Select a valid choice. That choice is not one of the available choices.”. When I print out the POST request data though, the data looks fine:
    QueryDict: {'csrfmiddlewaretoken': ['...], 'visit': ['2'], 'author': ['6'], 'message': ['asd'], 'signoff': ['asd']}> If I remove the empty queryset from the initialisation of the author form field, the form submits fine (and the database is updated).

Is there a workaround here - either another way to blank out the values in the second dropbox initially or another step to ensure the POST request is (actually) valid?

I found a post on here, which included this line for someone with a similar error:
“It appears that you didn’t set the queryset attribute to the same queryset when you were binding the form data from the POST data.” I’m afraid don’t quite follow what that’s trying to say, after looking through the Forms and ModelForms documentation at length. I tried defining a FormField in forms.py that had a blank queryset, but that didn’t seem to help.

Any thoughts?

Many thanks
David

models.py:

class Entry(models.Model):
    created = models.DateTimeField(
        null=False, blank=False, auto_now_add=True
    )   # Required, default is current date and time in server timezone
    visit = models.ForeignKey(
        Visit, on_delete=models.CASCADE, related_name="visit_entry",
        null=False, blank=False,
    )   # Required, links to contacts.Visit
    author = models.ForeignKey(
        Contact, on_delete=models.CASCADE, related_name="entry_contacts",
        null=False, blank=False,
    )   # Required, links to contacts.Contact
    message = models.TextField(
        null=False, blank=False,
    )   # Required
    signoff = models.CharField(
        max_length=150,
        null=False, blank=False,
    )   # Required
...

forms.py

class EntryForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Limit choices for the visit field, to visits +/- 7 days from today. 
        self.fields["visit"].queryset = Visit.objects.current_visits()
        # Set the default choices for the author field to an empty queryset.
        self.fields["author"].queryset = Contact.objects.none()
        # These choices are updated dynamically, to contacts that are linked to the selected Visit, with HTMX in views.py.

    class Meta:
        model = Entry
        fields = ["visit", "author", "message", "signoff"]

views.py

def entry_new(request):
    # on POST request, process form and re-direct to index view (entry_list)
    if request.method == "POST":
        form = EntryForm(request.POST)
        print(request.POST)
        if form.is_valid():
            # Save a new entry from the form data
            form.save()
            # Add message and re-direct to the index view
            messages.add_message(request, messages.SUCCESS, "Thank you for leaving an entry in our guestbook!")
            return HttpResponseRedirect(reverse("guestbook:index"))

    # otherwise, render the form - with variations if an HTMX request - or as normal for a GET request.
    else:
        form = EntryForm()
        # with some adjustments if it's an HTMX request.
        if request.htmx:
            # Retrieve the id of the Visit selected in the browser
            if request.GET.get('visit') != "":
                visit = request.GET.get('visit')
                # Filter the list of Contacts displayed on the form to those from the identified Visit.
                form.fields["author"].queryset = Contact.objects.filter(visitors__in=
                    Visit.objects.filter(pk=visit)
                ).order_by("name_second", "name_first")
            return render(request, "guestbook/partials/author_select.html", {"form": form})
        
    return render(request, "guestbook/entry_add.html", {"form": form})

entry_add.html

...
          <form action="" method="post">
                {% csrf_token %}
                <div class="fieldWrapper row-start-1">
                        {{ form.visit.label_tag }}
                        {% render_field form.visit hx-trigger="change" hx-get="" hx-target="#author-field-group" %}
                </div>
                <div class="fieldWrapper row-start-2 mt-5 mb-5 gap-0"
                     id="author-field-group">{% include "guestbook/partials/author_select.html" %}</div>
                <div class="fieldWrapper row-start-3 mt-5 mb-5">
                    <span class="mr-5">{{ form.message.label_tag }}</span>
                    <span>{{ form.message }}</span>
                </div>
                <div class="fieldWrapper row-start-4 mt-5 mb-5">{{ form.signoff.as_field_group }}</div>
                    <input type="submit" value="Submit" class="btn btn-primary">
                </div>
            </form>
...

author_select.html

{{ form.author.as_field_group }}

When you bind the posted data to the form, the field validation is performed based upon the queryset defined for that field.

This means that in a POST, you either want to:

  • Set the queryset for all fields, then use a custom clean function to ensure the submission is valid for that combination of fields.
  • Add more logic to your form’s __init__ method to alter the queryset as necessary.
  • Create the instance of the form, and modify the field in the view after the instance of the form has been created but before it is validated.

You might want to see the thread at cascading selection and fetch data from database (htmx and django) and the threads that it references.

1 Like

Thanks Ken - that was a helpful steer. I think I was convinced I had an HTMX issue, when I actually had a Django forms and views issue.

I ended up with a combination of the second and third approaches you suggested above, because I was trying to keep the form logic in forms.py. Not sure that has been entirely successful …but it seems to work.

In forms.py, I set the author field queryset to none:

def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
       ...
        # Set the default choices for the author field to an empty queryset.
        self.fields["author"].queryset = Contact.objects.none()

Then adapted the handling in the views to update the queryset appropriately:

def entry_new(request):
    # on POST request, process form and re-direct to index view (entry_list)
    if request.method == "POST":
        form = EntryForm(request.POST)
        form.fields["author"].queryset = Contact.objects.all() # actually, a custom queryset in models.py, but this shows the fix
        if form.is_valid():
            # Save a new entry from the form data
            form.save()
            # Add message and re-direct to the index view
            messages.add_message(request, messages.SUCCESS, "Thank you for leaving an entry in our guestbook!")
            return HttpResponseRedirect(reverse("guestbook:index"))
    # otherwise, render the form - with variations if an HTMX request - or as normal for a GET request.
    else:
        form = EntryForm()
        # with some adjustments if it's an HTMX request.
        if request.htmx:
            # Retrieve the id of the Visit selected in the browser
            if request.GET.get('visit') != "":
                visit = request.GET.get('visit')
                # Filter the list of Contacts displayed on the form to those from the identified Visit.
                form.fields["author"].queryset = Contact.objects.filter(visitors__in=
                    Visit.objects.filter(pk=visit)
                ).order_by("name_second", "name_first")
            return render(request, "guestbook/partials/author_select.html", {"form": form})
        
    return render(request, "guestbook/entry_add.html", {"form": form})

Thanks again!
David