How to Use a `<dialog>` with HTMX and hyperscript to Update a Form in Django

Conclusion

The finished code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# forms.py
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["items"].choices = [
            (choice.id, choice)
            for choice in Item.objects.all().order_by("id")
            if Item.objects.all().exists()
        ]

class CreateItemForm(Form):
    # some form stuff

# views.py
from utils import for_htmx

@for_htmx(use_block_from_params=True)
def create_warrior(request):
    create_warrior_form = CreateWarriorForm()
    create_item_form = CreateItemForm()
    if request.method == "POST"
        if "create-warrior" in request.POST:
            form = CreateWarriorForm(request.POST)
            if form.is_valid():
                warrior = Warrior.objects.create_warrior(request)
                messages.success(request, f"{ warrior } was created successfully.")
        if "create-item" in request.POST:
            form = CreateItemForm(request.POST)
            if form.is_valid()
                item = Item.objects.create_item(request)
                messages.success(request, f"{ item } was created successfully.")
            create_warrior_form = CreateWarriorForm(request.POST) # Maintain form state.
    context = {"create_warrior_form": create_warrior_form, "create_item_form": create_item_form}
    return TemplateResponse(request, "my_template.html", context)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<!-- my_template.html -->
{% extends 'base.html' %}

{% block content %}
<div id="content-wrapper">
  <!-- novalidate to allow the create-item action to take place regardless of {{ create_warrior_form }} data validity -->
  <form action="." method="post" novalidate
        hx-post="."
        hx-target="#content-wrapper"
        hx-ext="morph"
        hx-swap="morph:outerHTML"
        hx-vals='{"use_block": "content"}'>
    <div _="on htmx:afterSettle from #content-wrapper 
                 set input to the last <input/> in me then add @checked to the input">
      {{ room_create_form }}
    </div>
    <button id="open-dialog-button" type="button"
            _="on click set dialog to the next <dialog/>
                  if dialog does not match @open
                    call dialog.showModal()
                  end">
      Create Item
    </button>
    <button name="create-warrior" type="submit">Submit</button>
    <dialog _="on click
                     measure me
                 set is_in_me to result.top    <= event.clientY              and
                                 event.clientY <= result.top + result.height and
                                 result.left   <= event.clientX              and
                                 event.clientX <= result.left + result.width
                 if is_in_me == false call me.close()">
      <button id="close-dialog-button" type="button"
              _="on click set dialog to the closest <dialog/>
                          if the dialog matches @open 
                                    call dialog.close() 
                                end">
        <svg fill="none"
             viewBox="0 0 24 24"
             stroke-width="1.5"
             stroke="currentColor"
             aria-hidden="true">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
      {{ create_item_form }}
      <button name="create-item" type="button"
              _="on click set dialog to the next <dialog/>
                    if dialog does not match @open
                      call dialog.showModal()
                    end">
        Submit
      </button>
    </dialog>
  </form>
</div>
{% endblock %}

Problem: Unintuitive UI/UX When Creating an Object in Django

Let's say you have a page for creating an object. The page has a form that uses a MultipleChoiceField.

1
2
3
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

items should be populated with Item objects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class CreateWarriorForm(Form):
    name = CharField()
    items = MultipleChoiceField(widget=CheckboxSelectMultiple)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["items"].choices = [
            (choice.id, choice)
            for choice in Item.objects.all().order_by("id")
            if Item.objects.all().exists()
        ]

Your template:

1
2
3
4
5
6
7
{% block content %}
<div id="content-wrapper">
    <form>
        {{ create_warrior_form }}
    </form>
</div>
{% endblock %}

But, maybe the MultipleChoiceField doesn't have the choice you want (the Item you want to give your Warrior doesn't exist). You could make a whole new page just for adding those items and have the user make the Item first before creating the Warrior. That workflow is a bit counter-intuitive.

Solution: Inline Object Creation

Let's make it intuitive by allowing users to create Items inline using a <dialog>.

Adding the <dialog>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{% block content %}
<div id="content-wrapper">
    <form>
        {{ create_warrior_form }}
        <button name="create-warrior" type="submit">Submit</button>
        <dialog>
            {{ create_item_form }}
            <button name="create-item" type="button">Submit</button>
        </dialog>
    </form>
</div>
{% endblock %}

The name of the button that the user clicks will be present in the request object. You can run different actions based on which button's name is in the response.

The <dialog> element creates what is commonly called a pop-up or modal. It will overlay the current page with the a backdrop and the contents of the dialog's innerHTML (centered).

The <dialog>'s UI will work essentially the same no matter where in the DOM it's located. However, it and the {{ create_item_form }} need to remain inside the <form> with the {{ create_warrior_form }}. It's easy to understand once we take a look at the view.

Writing the view

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def create_warrior(request):
    create_warrior_form = CreateWarriorForm()
    create_item_form = CreateItemForm()
    if request.method == "POST"
        if "create-warrior" in request.POST:
            form = CreateWarriorForm(request.POST)
            if form.is_valid():
                warrior = Warrior.objects.create_warrior(request)
                messages.success(request, f"{ warrior } was created successfully.")
        if "create-item" in request.POST:
            form = CreateItemForm(request.POST)
            if form.is_valid()
                item = Item.objects.create_item(request)
                messages.success(request, f"{ item } was created successfully.")
            create_warrior_form = CreateWarriorForm(request.POST) # Maintain form state.
    context = {"create_warrior_form": create_warrior_form, "create_item_form": create_item_form}
    return TemplateResponse(request, "my_template.html", context)

If a user clicks on the button with name="create-item", then the CreateItemForm will be validated, but the data for the CreateWarriorForm won't. Even if the rest of input for the rest of the form is incomplete or invalid, if the CreateItemForm data is valid, then this action will complete successfully. The POST request contains the data in the {{ create_warrior_form }}, so we use it to create a new CreateWarriorForm in order to maintain the state of the form. This will require one small change to the <form>, so we can do that when we add the HTMX portions next.

Adding HTMX

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{% block content %}
<div id="content-wrapper">
    <!-- novalidate to allow the create-item action to take place regardless of {{ create_warrior_form }} data validity -->
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML">
        {{ create_warrior_form }}
        <button name="create-warrior" type="submit">Submit</button>
        <dialog>
            {{ create_item_form }}
            <button name="create-item" type="button">Submit</button>
        </dialog>
    </form>
</div>
{% endblock %}

First we set up the form with all the necessary attributes. We're using HTMX for everything, and I assume that the user will have JavaScript turned on, but I've found that doing what the browser expects with forms prevents bad surprises.

Next, hx-post to point to the endpoint, which is the current page. hx-target will set the element with the id content-wrapper, which is the div above, as the target of the swap. hx-swap here is set to swap the outerHTML of the target.

Setting up template partials with @for_htmx

Right now, we have a problem:

1
    return TemplateResponse(request, "my_template.html", context)

The whole page template is being returned in the response. That template extends some base.html template that includes our global header, footer, etc. We don't want to duplicate those parts. We just want the content of the current page. In short, we want to return the content of {% block content %}. To do that, we'll use a decorator that Luke Plant made for returning template partials (using the django-render-block extension). He demonstrates how do use the decorator in his django-htmx-patterns repo on Github.

1
2
+ @for_htmx(use_block_from_params=True)
  def create_warrior(request):
1
2
3
4
5
6
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML"
+         hx-vals='{"use_block": "content"}'>

With that small change, the view will return the content block in our template in the response.

Add hyperscript to open and close the dialog

The MDN documentation on the <dialog> element shows the JavaScript necessary to make the dialog open and close (how retarded is it that?). It essentially boils down to using showModal() and close() on the dialog. Instead of using pure JavaScript, we can use hyperscript. Following the principle of Locality of Behavior, we'll be able to make all of the functions of the buttons easy to understand simply by looking at the <button> elements themselves.

First, our button:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{% block content %}
<div id="content-wrapper">
    <!-- novalidate to allow the create-item action to take place regardless of {{ create_warrior_form }} data validity -->
    <form action="." method="post" novalidate
          hx-post="."
          hx-target="#content-wrapper"
          hx-ext="morph"
          hx-swap="morph:outerHTML"
          hx-vals='{"use_block": "content"}'>
        {{ create_warrior_form }}
+       <button id="open-dialog-button" type="button"
+                _="on click set dialog to the next <dialog/>
+                      if dialog does not match @open
+                        call dialog.showModal()
+                      end">
+           Create Item
+       </button>
        <button name="create-warrior" type="submit">Submit</button>
+       <dialog _="on click
+                     measure me
+                     set is_in_me to result.top    <= event.clientY              and
+                                     event.clientY <= result.top + result.height and
+                                     result.left   <= event.clientX              and
+                                     event.clientX <= result.left + result.width
+                     if is_in_me == false call me.close()">
+           <button id="close-dialog-button" type="button"
+                    _="on click set dialog to the closest <dialog/>
                           if the dialog matches @open 
                               call dialog.close() 
                           end">
+              <svg fill="none"
+                   viewBox="0 0 24 24"
+                   stroke-width="1.5"
+                   stroke="currentColor"
+                   aria-hidden="true">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
+              </svg>
+            </button>
            {{ create_item_form }}
            <button name="create-item" type="button"
                    _="on click set dialog to the next <dialog/>
+                      if dialog does not match @open
+                        call dialog.showModal()
+                      end">
                Submit
            </button>
        </dialog>
    </form>
</div>
{% endblock %}

The hyperscript is pretty self-explanatory. For the open-dialog-button, first we set the dialog local variable to the next dialog element in the DOM. If that dialog element doesn't have the open parameter set, then we call showModal() on the dialog. We do the exact same with the create-item button. We also have a close button that calls close() on the dialog.

One other quality of life change is making the dialog close if the user clicks outside of the dialog window (on the backdrop). In the dialog element, on click, we measure the dialog window, set the is_in_me variable to the coordinates inside the dialog, and then if the click event isn't in the dialog, we call close() on the dialog.

We're basically complete, but we can go one step further. When a user creates a new Item, it's added to the MultipleSelectField, but the user need to click the checkbox to select it. What if we help them out?

1
2
3
4
<div _="on htmx:afterSettle from #content-wrapper 
           set input to the last <input/> in me then add @checked to the input">
    {{ room_create_form }}
</div>

We take advantage of the fact that HTMX and hyperscript are both designed to work well with events, and that HTMX shoots off plentiful events to respond to. htmx:afterSettle is the event that's called when HTMX is completely finished with swaping in content. When HTMX is all done swapping, hyperscript sets the newly created <input type="checkbox"> to checked.

And that's it. You now have an elegant solution to the problem of an unintuitive UI/UX when creating an object in Django.

Wrapping up

To wrap things up, here is the order of events.

  1. A user opens the page and decides to add an item by clicking the Create Item button.
  2. hyperscript opens the dialog.
  3. The user fills out the {{ create_item_form }} and clicks the submit button named create-item.
  4. hyperscript closes the dialog.
  5. HTMX sends a POST request to the view.
  6. The view processes the form and creates a new CreateWarriorForm with the POST request contents to maintain form state.
  7. The view--thanks to the @for_htmx decorator and hx-vals in the template--returns a response containing just the contents of {% block content %}
  8. HTMX takes <div id="content-wrapper"> and replaces it with the response content.
  9. Once HTMX has finished the process of swapping in the new content and fires the htmx:afterSettle event, hyperscript will set checked on the new checkbox that was just added.
  10. After the user has added the item, they click the submit button named create-warrior.
  11. Again, the form data is sent to the view, the create-warrior action is processed, and the content of {% block content %} is sent with the new data included.
  12. HTMX does the swap.
Profile Photo

Author

Added

Feb. 7, 2024

Technologies
HTMX HTMX
hyperscript hyperscript
Django Django