Modal forms with Django+HTMX

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
Hello everyone! In this video, I'll show you how to  create modal forms with Django and HTMX. The result will look like this. As you can see, the form appears in a  modal dialog instead of a dedicated page. The solution I present in  this video has many advantages   over the one I couldn't find on the internet: First, it doesn't depend on  Hyperscript (unlike HTMX's example). 2. It requires only a few lines of JavaScript. 3. It's reusable (no need to  repeat the javascript code). 4. It supports server-side form validation. 5. It allows refreshing the page on success. 6. It can be adapted to any CSS framework. 7. It can support progressive enhancement  when JavaScript is unavailable   (but I won't show it in this video). We'll start from a regular Django project   and add a little bit of HTMX and finally  render the forms in dialog boxes. The video is quite long because I  take the time to explain every step,   but you'll see that this pattern  requires very few changes to your code. If you're in a hurry, I invite you to read  the blog post or look at the source code I   published on GitHub. You'll find the  addresses in the description below. Okay. Let's start! This is the original Django application  that I want to upgrade with HTMX. As you can see, on the main page (on the homepage)   there is a list of movies with the  title, the year, and the rating. We can click on Edit to modify  a movie, and if we try to...   if we save it, it updates  the list on the main page. And if we insert a mistake, like  this or like this, we see that   the server returns some errors  that are displayed inside the form. This is the classic (the  basic) Django application. As you can see, (just a note) on the first  edit, you can see that the server returned   a 302 to redirect the browser to the home page. Let me show you that with this one. If I add a movie "Back to the Future 3"...   the year is 1990 (I think)...  and then here is the rating. Let me clear this, save, and see what happened The browser sends the post request to this URL  (/movies/add/) and the server returned a 302. That's a redirect and it  redirects to the home page. Then, the browser requests the home  page and it renders this updated page. This is the classic Django form workflow;  you should be familiar with this. Let's see how it works in the code. Here is the main project folder. And, this is the application folder. As you can see, the URLs are set up here. This is the view that we called earlier. This is the home page. And the edit view. Now, let's see the views. The index simply renders "index.html"  with the list of movies as the context. add_movie employs the classic function-based view  pattern with "if (request.method == 'POST')"... And you can see that if the form is valid,   I'm saving the form and then "redirect()". That's the 302 that we saw. And if it wasn't a POST request (so if it  was a GET or a HEAD request), we return   an empty form then render the "movie_form"  template with the form as the context. Similarly, we have the "edit_movie" view that  is almost identical to the "add_movie" view,   except that it first gets a  movie object from the database   and it uses it here as the instance for the form. That's all for this file. And the models are pretty obvious: I don't  think it worth taking too much time on this. The form is also classic. "ModelForm" with three fields:   "title", "year", "rating". I think that's all i have to say about the  Python code; now, let's see the templates. I have a "base" template using the Bootstrap  CSS here... and the Bootstrap JavaScript here. And now, if we look at the "index"  template, you can see the title here,   the table there, and here is the list  of movies (each movie is a <tr> row). The movie form is roughly the same, except  that I wrapped everything in a <form>, like so. Of course, I include the CSRF token. And you can see that I used method POST of course. Maybe you're not used to this: I use  the "request.path" so that the POST   is made on the same URL as the GET. So that's   a small trick to avoid passing  a new URL in the context. You can see that I laid out each input  control as required by Bootstrap.  The label... The field is rendered here. As you can see, I'm using widget_tweaks. widget-tweaks is a python package that allows you   to change some values of a form widget. In this case, I'm injecting the class   "form-control" and the  placeholder, as you can see. Lastly, I insert here the error for each field. As you can see, here, I'm using a small trick to   automatically inject the class  "is-invalid" if the field is invalid. And this is... I can remove this. I know most of you are using crispy-forms  to render Bootstrap compatible forms.  I have personally had a bad experience  with crispy-forms and I prefer laying out   the HTML like this, using widget-tweaks  to make the small changes that I need. So, this is the original application  let's see how we can improve it with HTMX. Let's save this. The first thing we have to do, of course, is to  inject the HTMX JavaScript in our "base" template. Let's take the address. OK, let's copy this. There. Now, we have loaded the HTMX JavaScript. The first thing I want to do (just as a  demonstration of what can be done with   HTMX) is to load this asynchronously:  lazily load the movie list. Let's extract this. Create a new template named  "movie_list.html", and paste the code here. Here I could simply do {%  include "movie_list.html" %}   and it will not affect the  rendered site in any way. Let's check. OK, this is still working as expected. Let's insert some HTMX in the mix. I want to render this in a different route. Let me fix the indent here. OK. Now, let's create a new route (a new  view) that I will call "movie_list". And this will return... Let's put it up there; it will be better. Let's do the same thing as in the "index": just  render "movie_list" with the list of movies   (with the collection of movies). And now, "index.html" doesn't need anything. If we look at the status right  now, it should be empty, right? That's to be expected. Now, the next step is going to be to load this  view (the content of this view) as the page load. First, I'm going to add URL path, like so. So, "/movies/"... We'll call the view "movie_list". "movie_list" We'll have to load this when  the "index.html" is rendered. We can do something like this: "hx-get". This is an HTMX attribute that says  "perform a get request on this URL". And the URL will be the view named "movie_list". Like this. And let's see what happens now. This is still empty but... Oh, it's still not working... Let's see... "movie_list"... looks okay. Let's place a placeholder to  say something like "loading..." Now... yeah "loading..." I made a mistake, and I can see here that we  have an error 500: "template did not exist". Okay, I made a typo. Where is it? views... I keep typing "move". Okay: "movie". Let's refresh. Now if I click, you can see that  it downloads the movie list. Let's see what happens. This is the original page. As soon as I click, it performs an additional  query that returns only the movie rows. But now, we want this to load with the page. Instead of relying on the default behavior for  HTMX, we'll tell it to load when the page loads. This is done with the "hx-trigger" attribute. The value of "load" (the event "load") will   do the trick Let's see... Refresh the page. And it works. Let's see the requests that the browser made. This is the home page. And this is the movie list. Obviously, I didn't have to do this to show  you how to do a dialog box but I thought   this would be an interesting introduction to HTMX. Plus, I use this pattern very often in my   applications because most of the time  the list that you're showing is not   the only thing on the page. Sometimes, you have a lot   of things on the page and you want to not  load (to lazy load) some parts like this. HTMX also supports other triggers. Let's see in the documentation...  You can use some of them to only load the content  when the content (when the part) is visible. Let's see. There is... "load" that I used. And "reveal": this is excellent  if you want to do lazy loading.  You see: "triggered when an element is scrolled  into the viewport (also useful for lazy loading)" I recommend that you do that if  you have a huge page with a lot of   content. You can lazy load some parts so  that the initial request is much lighter. Where were we... Now we have the list of movies  that loads asynchronously.  Let's see how we can move  the form into a dialog box.  The first thing we have to do is to  insert a placeholder somewhere in our DOM. Somewhere around here. I'm gonna look at the Bootstrap  documentation to do that. Let's see the documentation. Here. "Content". No. "Components". Take the "Modal". And where is the HTML? We're gonna take only this part because  this will (this part will) be returned   by the form view and this  is gonna be our placeholder. Let's put it here. I'm gonna add the fade class so  that it fades in and fades out At some point, we'll have to identify  them so let's give this element a name.  The modal will be   named (will have the id) "model", and  the dialog will have the id of "dialog". For now, this is empty. This is invisible to the user, so I don't  need to show you what it looks like. Now, we have to change the  form to look like a dialog.  Let's add the class of... What was it? "modal-content", I think... Yes: "modal-content". Then a header of "modal-header"...   "modal-body"... "modal-footer". Let's do this And, "modal-footer" As you can see, Visual Studio  Code is giving me a hard time   with the shortcut. This is not my  computer, so I don't have my usual setup. So, this is exactly the same thing except that it  looks like a dialog. Let's see what it looks like. Okay, it looks like a dialog  box, so mission accomplished. Then, we have to render this piece of HTML  inside the modal placeholder that we just   added to the DOM. To do that we'll say... Here, we'll say: when you click on "Add" to  add a movie, instead of performing a regular   request we use HTMX to perform  the GET request for us.  And since it changes the semantic of the button,  let's use a <button> element of type "button". "hx-get": this will instruct HTMX  to perform a GET request on this URL   when the user clicks on the button.  That's not all: we'll tell HTMX to  insert the result into our placeholder.  So we'll say: okay, the result you  will place it in the dialog element.  As a reminder, the dialog element  is here in the "base" document. Right there. This here this "hx-target"  will tell HTMX to insert the content here. Inject HTML here. That's what "hx-target" will do. Let's try and see how it looks. It does nothing but... It doesn't show but we can see that the content  is here but it's wrapped in a "container".   That's because... That's because, here, we extend the "base"  template. We shouldn't do that and only return... (let's try again) ...only return the form. Where is the "modal-content"? This looks good. The only problem is that the modal is hidden. Maybe we can force it to show... I don't know. Let's inject a bit of JavaScript (a small piece  of JavaScript) that will show this dialog. Let's create a static folder  and a file named "dialog.js". We use the IIFE pattern.  I never know how we are supposed to do this thing. Something like this. Let's see how we are supposed to  open a dialog box with JavaScript. We have to create a new dialog,  like this (a new dialog object). I'm gonna use "const". modal = new bootstrap.Modal() Get the element by id, and as  you recall, the id is "modal". I don't care about the options. We want to show the modal as soon as we get   a response from our server that targets the modal. Let's see how we can do that with HTMX. I'm looking for the "htmx:afterSwap" event,  like this, and we'll use this event... Is there any sample code? No. We'll call "htmx.on()", which is a  shortcut for "document.addEventListener",   and listen to the "htmx:afterSwap" event. And in the event handler, we will show the modal. "modal.show()".  But we don't want to do that for every  HTMX request; we only want to do that if   "e.detail.target" (I think). We will check this now... "afterSwap"... "detail.elt":   the element that dispatched the  request (so that would be the button)  "detail.target" is the target of the request, so  in our case, we only want to show the dialog if   the request targets the dialog; and if  that's the case, then we show the modal.  Let's try. Not working... Oh, of course: I didn't  load our static "dialog.js" Where is the base? Here. Oh sorry. Again, not my computer. First, we have to load the "static"  module and here <script>, src equals   {%static%} and the name of the file: "static.js". You might be wondering why I don't  use folders in my template folder:   to be honest, this project doesn't  give a fair representation of how   I lay out my projects my real projects. I decided to simplify everything to the   maximum so his tutorial is easier  to understand (is more accessible).  Again, in no way this actually represents  the way I use Django in my everyday work. Let's try again. Okay, still nothing. Is that an error? No. Not found... Not found? Why?  Oh! It's not "static.js" It's "dialog". Try again. I think Django didn't realize there was a  "static" folder in "movie_collection". Let's   stop / start the server again. Let's try. Okay: there we have it. Oh! it's working! What happened? When I clicked it performed... Oh, you cannot see it. ...when I clicked it performed this  HTMX request (the "add" here) and HTMX   injected the results in the dialog box (here). Then, it raised the event   "afterSwap". We caught this event,   checked that this event concerns the dialog  box, and in that case, we decided to show it. Now, we need some buttons to close the dialog box. As you can see, I'm still using the old   "Cancel" link so when we click "Cancel" this  performs a full navigation to the home page.  Let's fix this. Let's have a look at how   we're supposed to do with a modal. Here is the close button The form is here. <button>... let's say "Cancel"...  "secondary"... and yeah. The important part here (I think) is  the "data-bootstrap-dismiss=modal",   which instructs Bootstrap to  close the modal. Let's try. Add movie. Cancel. Yes: you can see the modal fade away.  And it doesn't perform any additional requests. Now let's see what happens if  we try to submit some things.  "Toy Story", for example. The year was  1996. And I really love this movie. Save. This is still doing a full post. Here we see the  server returning a redirect to the home page.   We have to change this in order to use HTMX,  so we'll have the dialog nicely fade away. We can we have to work from the form, here. Instead of letting the browser do the usual work   with "method=POST" and "action", we  say "hx-post" and here is our URL.  This attribute will instruct  HTMX to perform a POST request   with this form (with the values from this form)  to this URL, which is the same as the GET request.  We don't have to insert the "hx-trigger"  as we did for the lazy loading part because   there is a default trigger for each element,  and in this case (for the form element),   the default trigger is the "submit"  event which, makes perfect sense.  Let's see how it goes. This may be ugly; I'm not sure  what we are going to see there...   "Toy Story 2". Year: 2000... no sorry 1999. Save. You see what happened: rendered   the resulting page inside the modal. That's  not what we want. Instead, we want the modal   to close, and the main page to update. Let's do this, one step at a time.  To close the modal, I found that the easiest way  was to return an empty response from the view.  Instead of returning the usual redirect, here,  I'm returning HttpResponse with the status   code (we could use 200, but I prefer to use) 204. 204 means   "No Content" (that's the meaning of the 204). I can see it added automatically HttpResponse.  And we will do the same for "edit_movie()"  and we can get rid of "redirect()". This will return an empty response,  we can try and see what it does. "Toy Story 3". Full disclosure: I'm  cheating with a sheet on the side.   I don't know all the dates by heart... Error 500... what is it? "status_code". Oh, of course. Because   I will never understand this: sometimes  "status," sometimes it's "status_code." Let's try again. "Toy Story 4". And I'm  happy I prepared many movies on my list...   I didn't see the movie actually,  I'm guessing it's pretty good. Okay! We can see here the success 204. There is nothing in the response of course  because it is empty and we see nothing on the   screen. That's because HTMX doesn't swap the  content for any status code that is different from   200. In other words, it only swaps the content  if the value (if the returned status) is 200. Now, we have an empty response. As I said, HTMX  doesn't swap the content. What we want to do,   in that case, is to close the modal. Let's  use exactly the same trick as before. When the event "afterSwap", or  better, I would say "beforeSwap"   occurs and this event targets the  dialog and the response is empty.  I'm sure there's something  like this... "response"...  Let's see... events...  "requestConfig"... The request is here. I'm not perfectly familiar with   XMLHttpRequest. Let's see it on MDN;  they will tell me what the fields are. "response"... a blob or a string.  So, if I do "response" like this,  and I say if the response is empty... Or, I'll do something like this. ...if I get an empty response  that targets the dialog,   I'm gonna hide the dialog. Let's try. Hit refresh. Add a movie. This time the Goonies.  As you can see, that's mainly movies from the   90s. No special reason, just what I  had in mind at the time I created this. Doesn't work. What does it say? 204 I'm wondering if it actually reloaded   my JavaScript file. Come on! It did. Is it "beforeSwap"? Let's add some... Let's see if I spelled it right... It seems. Let's see... "console.log()"...   and we'll include the event object, like this.  I'm gonna do hard-refresh, just to be sure. I'm running out of movies, there. What can  I write? "Jurassic Park", and that's 93.   I'm not a big fan. We should have something in the console. "beforeSwap"/"beforeSwap": OK.  Let's see. The target is... "from.model-content"... Oh! Of course, I let the default  behavior, so it tries to replace   the actual content of the form. We have to change  this and tell "every time we have a request   from within the dialog box we'll target this". So, every time we get a request (a POST, or and   like an "hx-post" or an "hx-get" or anything) from  within the dialog, we'll ask HTMX to change the   target and say "this" is the real target. Let's try again. I may need to try editing some movies.  The "edit" button is not ready yet.   Let's do this. Remember what I did for the "add" button?  We have to do the same for the "edit" button. I'm going to change the semantic: it's a   <button> of type "button" (You know the  default type for <button> is "submit", right?   So we have to change this).   Instead of having an "href" like this, we  give it the "hx-get" attribute, which will   trigger an HTMX request with the GET method, and  we'll say that this request targets the modal. This way, I don't have to find new movies.  It's not working. Why? Let's see.  Clear. Edit. "#modal". It's not "#modal"; it's "#dialog". There you go. Let's say, I really love this movie.  What is going to happen now? Bam! It worked. It works and, if I refresh the page, I can  see that the rating of Phantom of the Paradise   was indeed updated. So,   I don't know what was wrong with this. You  know what? Maybe I forgot to hard-reload   the page, so maybe it was still  using the old version of "dialog.js". Let's see with add movies, just to be sure. I'm going to do a hard reload, like so. Another movie: "Raiders of  the lost Ark". Year is 1981.  This is excellent, so five stars. The dialog is closing as expected.  Of course, the main page is not updating  instantly; we'll address this issue in a moment.  As you can see the film was  indeed added to the list. Now, let's see how we can refresh the main page. Remember, here, we used a trigger of "load" to  reload (to load) the document (to load this part)   when the page is loaded. Now,  we'll instruct HTMX to do the same   when some event occurs. We can do this with... I have to check the syntax.  No. Sorry. Reference. Attributes.   And it was the "trigger": "trigger specifies  the event that triggers the request". As a   reminder, we used "load" and now I'm gonna use  my-custom-event. We use this syntax: "from:body". There. Let's say "trigger on load or/and   when my custom event occurs". I will  name my custom event "movieListChanged".  Now, the question is how do we trigger this event? How would we raise this event?  I found that the easiest way to do  that is to go to the view and add   a special header called... Let me check the documentation of HTMX.  Oh, it's there: "Triggering  via the HX-Trigger header". Use the name of the header. I think I can remove that now.  And the name of the trigger (of the  event) was movieListChanged, I think.  "headers" And we'll do the same... This is the "edit" view there.  And we'll do the same for the  "add_movie" view, like this. In both cases, we return an empty response with  the status of 204, but this time we inject a new   event (a new header) that will  trigger a refresh of the main page.  Let's try. Let's change the rating of "Back to the Future  2". Save. And bam! You see? It's updated.  We can have a look at the request so  that everything is clear. Let's increase   the score. Okay: the 204 and refresh  of the movies list only. Of course,   it's not refreshing the whole page it's  only returning the rows of the table.  Everything seems to be working fine. Let's see what happens if we try to put   some errors in the mix. Okay, the errors  are correctly rendered in the form.  Cancel. Add a movie. Okay. Let's do the same here. Save.  Maybe this title already exists.  Fine. Cancel. Edit this one. Okay.  I was looking for something specific that didn't  occur there but I will talk about it anyway.  What I noticed... We, unfortunately,   cannot see it here but what I noticed is  when I do save like this, then cancel.  If we look at the DOM, you can see that the dialog  is still there and the error is still present in   the DOM. So, sometimes, when you click on "Add a  movie" or open another movie, you may see for a   fraction of a second... you may see the error. That's because there is a transition (I   think) in Bootstrap that fades away the  invalid feedback so the box here may   appear red for a fraction of a second. I'm  assuming that maybe it comes from the fade   transition. Let's see because I know this occurs  and I have a solution that I want to show you.  Let's just refresh. Removing the fade class will   remove the transition. Let's try.  You can see no transition here. Something before the invention of   the cinema. Not possible. Cancel. Yes! You saw it?  It was red for a fraction of a second. I can do it again if you want to see it.  Whatever it doesn't matter. So here you see the error. Look like this,   right there! You saw? It was red for a fraction of a second. Not a big deal? Yes, it is a  big deal: we have to fix this.  There is an easy fix: once the dialog is  hidden, we should get rid of the content   and the solution is CSS framework-specific,  so in this case, it's specific to Bootstrap.  Bootstrap raises an event when  the dialog is finally hidden.  This event fires when the modal has  finished being hidden from the user.  Let's copy the name and put it there: "dialog.js". Again, we'll use the HTMX shortcut. When this event occurs, I want to delete   (to remove) the content of the dialog. With  "document.getElementById()", we'll take our   dialog and reset the content entirely like this. I'm gonna do a hard refresh again.  What happened. Go back there. Hard refresh. Let's see.  If i try again to put a date in the past (before  the invention of the cinema). Cancel. The error   was there when I closed. You can see the dialog is now empty.  If i try to add a movie now, no problem. We can restore the fade transition because   I think it's worth it. class="modal fade". Now, all is good. Everything  works as expected. There is   only a small change (some small changes) that  I want to do before wrapping up this video ...to show you, there, some  tweaks to the "dialog.js". Here, you can see that I purposely didn't check  the status code of 204. That's because I want   to support cases where you decided that 204 was  not the right code and you prefer to use 200.   So instead of checking the code, I prefer checking  that the value is (the response is) empty. I think   it covers more cases. And the second thing I want to do is   to prevent HTMX from swapping the content,  in this case. We can see that in "reference".   It was the "event" reference. Our event is   "beforeSwap", here. And, here,  "detail.shouldSwap": if the content   will be swapped defaults to false for non-200,  as i told you. I want to set this to false.   This is not absolutely required in our case  because as I told you I'm returning 204   but in if you want to return to 200, you  have to say "shouldSwap=false". In this case,   it will prevent HTMX from flushing the content  of the dialog box during the transition. Because   when you click "Save", you don't want the dialog  box to be empty during this fade-away transition. I think that I covered everything. I hope this was clear. There you have it: a reusable pattern to  put Django forms in modal dialog boxes. Let's review what we did. We had a tour of the initial project. We included the HTMX JavaScript  file in the base template. We lazy-loaded the list of movies. We added a placeholder for the dialog box. We changed the form to look like a dialog. We injected the form into the dialog placeholder. We listened to the "htmx:afterSwap" event  to show the dialog when appropriate. We replaced the usual redirect  response with an empty HTTP response. We listened to the "htmx:beforeSwap" event to  hide the dialog box when the response is empty. We added the "HX-Trigger" header to the  response to trigger a refresh of the movie list. We listen to the "hidden.bs.modal" event  to flush the content of the dialog. We added some safeguards in case you  want to use status 200 instead of 204. As you saw, we added very few lines  to the JavaScript and they will be   reused for every modal form, so  the maintenance cost is very low. I've been using this pattern for a while now,   and I can confirm that it's easy  to set up and highly reusable. I hope you enjoyed this video. Let me know what you think in the comments. Bye!
Info
Channel: Benoit Blanchon
Views: 33,163
Rating: undefined out of 5
Keywords: django, htmx
Id: 3dyQigrEj8A
Channel Id: undefined
Length: 54min 21sec (3261 seconds)
Published: Fri Feb 18 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.