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 |
|
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 |
|
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 |
|
items
should be populated with Item objects.
1 2 3 4 5 6 7 8 9 10 11 |
|
Your template:
1 2 3 4 5 6 7 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
1 2 3 4 5 6 |
|
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 |
|
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 |
|
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.
- A user opens the page and decides to add an item by clicking the Create Item button.
- hyperscript opens the dialog.
- The user fills out the
{{ create_item_form }}
and clicks the submit button namedcreate-item
. - hyperscript closes the dialog.
- HTMX sends a POST request to the view.
- The view processes the form and creates a new
CreateWarriorForm
with the POST request contents to maintain form state. - The view--thanks to the
@for_htmx
decorator andhx-vals
in the template--returns a response containing just the contents of{% block content %}
- HTMX takes
<div id="content-wrapper">
and replaces it with the response content. - Once HTMX has finished the process of swapping in the new content and fires the
htmx:afterSettle
event, hyperscript will setchecked
on the new checkbox that was just added. - After the user has added the item, they click the submit button named
create-warrior
. - 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. - HTMX does the swap.
Author
Added
Feb. 7, 2024