Fantastic Modal UX with a Loading State
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet's pick up where we left off yesterday. The Ajax-powered modal loads! Try to submit it. Uh oh - something went wrong. It went to some page that didn't have a <turbo-frame id="modal">... which is odd, because every page now has one. That's because... the response was an error. If we look down on the web debug toolbar, there was a 405 status code. Open that up. Interesting:
No route found for
POST/voyage/
That's weird because we're submitting the new voyage form... so the URL should be /voyage/new.
Adding action Attributes to the Forms
Here's the problem: when I generated the voyage crud from MakerBundle, it created forms that don't have an action attribute. That's fine when the form lives on /voyage/new because no action means it submits back to the current URL. But as soon as we decide to embed our forms on other pages, we need to be responsible and always set the action attribute.
To do that, open up src/Controller/VoyageController.php. At the bottom, I'll paste in a simple private method. Hit Okay to add the use statement:
| // ... lines 1 - 9 | |
| use Symfony\Component\Form\FormInterface; | |
| // ... lines 11 - 15 | |
| class VoyageController extends AbstractController | |
| { | |
| // ... lines 18 - 88 | |
| private function createVoyageForm(Voyage $voyage = null): FormInterface | |
| { | |
| $voyage = $voyage ?? new Voyage(); | |
| return $this->createForm(VoyageType::class, $voyage, [ | |
| 'action' => $voyage->getId() ? $this->generateUrl('app_voyage_edit', ['id' => $voyage->getId()]) : $this->generateUrl('app_voyage_new'), | |
| ]); | |
| } | |
| } |
We can pass a voyage or not... and this creates the form but sets the action. If the voyage has an id, it sets the action to the edit page, else it sets it to the new page.
Thanks to this, up in the new action, we can say this->createVoyageForm($voyage). Copy that... because we need the exact line down in edit:
| // ... lines 1 - 15 | |
| class VoyageController extends AbstractController | |
| { | |
| // ... lines 18 - 26 | |
| public function new(Request $request, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... line 29 | |
| $form = $this->createVoyageForm($voyage); | |
| // ... lines 31 - 45 | |
| } | |
| // ... lines 47 - 56 | |
| public function edit(Request $request, Voyage $voyage, EntityManagerInterface $entityManager): Response | |
| { | |
| $form = $this->createVoyageForm($voyage); | |
| // ... lines 60 - 73 | |
| } | |
| // ... lines 75 - 96 | |
| } |
Lovely. Back over, we don't even need to refresh. Open the modal, save and... Ah, that is absolutely lovely! It's submitted and we got the response right back inside the modal. Because... of course! That's the whole point of a Turbo frame. It keeps the navigation inside itself.
Loading the Modal Instantly
Before we talk about what happens on success, I want to perfect this. My second requirement for opening the modal was that it needs to open immediately. Over in the new action, add a sleep(2)... to pretend our site is getting slammed by aliens planning their spring break trips:
| // ... lines 1 - 15 | |
| class VoyageController extends AbstractController | |
| { | |
| // ... lines 18 - 26 | |
| public function new(Request $request, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 29 - 31 | |
| sleep(2); | |
| // ... lines 33 - 46 | |
| } | |
| // ... lines 48 - 97 | |
| } |
When we click the button now... nothing happens. No user feedback at all until the Ajax request finishes. That is not good enough. Instead, I want the modal to open immediately with a loading animation.
Over in the modal controller, add a new target called loadingContent:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| static targets = ['dialog', 'dynamicContent', 'loadingContent']; | |
| // ... lines 5 - 60 | |
| } |
Here's my idea: if you want some loading content, you'll define what that looks like in Twig and set this target on it. We'll do that in a moment.
At the bottom, create a new method called showLoading(). If this.dialogTarget.open, so if the dialog is already open, we don't need to show the loading, so return. Otherwise, say this.dynamicContentTarget - for us, that's the <turbo-frame> that the Ajax content will eventually be loaded into - .innerHTML equals this.loadingContentTarget.innerHTML:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| // ... lines 4 - 52 | |
| showLoading() { | |
| // do nothing if the dialog is already open | |
| if (this.dialogTarget.open) { | |
| return; | |
| } | |
| this.dynamicContentTarget.innerHTML = this.loadingContentTarget.innerHTML; | |
| } | |
| } |
Finally, add that target. In base.html.twig, after the dialog, I'll add a template element. Yes, my beloved template element: it's perfect for this situation because anything inside won't be visible or active on the page. It's a template we can steal from. Add a data-modal-target="loadingContent". I'll paste some content inside:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| // ... lines 17 - 55 | |
| <div | |
| // ... lines 57 - 58 | |
| > | |
| // ... lines 60 - 75 | |
| <template data-modal-target="loadingContent"> | |
| <div class="bg-space-pattern bg-cover rounded-lg p-8"> | |
| <div class="space-y-2"> | |
| <div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div> | |
| <div class="h-4 bg-gray-700 rounded animate-pulse"></div> | |
| <div class="h-4 bg-gray-700 rounded animate-pulse"></div> | |
| <div class="h-4"></div> | |
| <div class="h-4 bg-gray-700 rounded animate-pulse"></div> | |
| <div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div> | |
| <div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div> | |
| <div class="h-4"></div> | |
| <div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </body> | |
| </html> |
Nothing special here: just some Tailwind classes with a cool pulse animation.
If we try this now... no loading content! That's because nothing is calling the new showLoading() method. Over in base.html.twig, find the frame. I'll break this onto multiple lines. Let's think: as soon as the turbo-frame starts loading, we want to call showLoading(). Fortunately, Turbo dispatches an event when it starts an AJAX request. And we can listen to that.
Add a data-action to listen to turbo:before-fetch-request - that's the name of the event - then ->modal#showLoading:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| // ... lines 17 - 55 | |
| <div | |
| // ... lines 57 - 58 | |
| > | |
| <dialog | |
| // ... lines 61 - 63 | |
| > | |
| <div class="flex grow p-5"> | |
| <div class="grow overflow-auto p-1"> | |
| <turbo-frame | |
| id="modal" | |
| data-modal-target="dynamicContent" | |
| data-action="turbo:before-fetch-request->modal#showLoading" | |
| ></turbo-frame> | |
| </div> | |
| </div> | |
| </dialog> | |
| // ... lines 75 - 90 | |
| </div> | |
| </body> | |
| </html> |
All right, let's check out the effect! Refresh the page and... oh, it's wonderful! It opens instantly, we see that loading content... and it's replaced when the frame finishes!
I love how this works. When this calls showLoading(), that method puts content into dynamicContentTarget. And... do you remember what happens the moment any HTML goes into that? Our controller notices it, and opens the dialog. That's some great teamwork!
Loading Indication on Form Submit
We're nearly there to making this perfect, but I'm not satisfied! While we still have the sleep, submit the form. Nothing happens! There's no feedback while that's loading.
Tip
For an even nicer effect, you can also change the opacity only if loading
takes longer than, for example, 700ms. Do that by adding an aria-busy:delay-700 class.
Lucky for us, we've been down this road before with a different Turbo frame. Add class aria-busy:opacity-50, and transition-opacity:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| // ... lines 17 - 55 | |
| <div | |
| // ... lines 57 - 58 | |
| > | |
| <dialog | |
| // ... lines 61 - 63 | |
| > | |
| <div class="flex grow p-5"> | |
| <div class="grow overflow-auto p-1"> | |
| <turbo-frame | |
| // ... lines 68 - 70 | |
| class="aria-busy:opacity-50 transition-opacity" | |
| ></turbo-frame> | |
| </div> | |
| </div> | |
| </dialog> | |
| // ... lines 76 - 91 | |
| </div> | |
| </body> | |
| </html> |
I'll reload... click, loading animation and submit. Yes! The low opacity tells us that something is happening.
And with that, I will happily remove our sleep:
| // ... lines 1 - 15 | |
| class VoyageController extends AbstractController | |
| { | |
| // ... lines 18 - 26 | |
| public function new(Request $request, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 29 - 31 | |
| sleep(2); | |
| // ... lines 33 - 46 | |
| } | |
| // ... lines 48 - 97 | |
| } |
Conditional Modal Styling
Ok, one final detail that I want to get right: this extra padding. This exists because the content from the new page has an element with m-4 and p-4. So the modal has some padding... and then extra padding comes from that page.
On the page, the margin and padding make sense. It comes from over here in new.html.twig. So we do want this on the full page... but not in the modal.
To help us do this, we're going to use a Tailwind trick. In tailwind.config.js, add one more variant. Call this modal, and activate it whenever we are inside a dialog element:
| // ... lines 1 - 3 | |
| module.exports = { | |
| // ... lines 5 - 22 | |
| plugins: [ | |
| plugin(function({ addVariant }) { | |
| // ... line 25 | |
| addVariant('modal', 'dialog &'); | |
| }), | |
| ], | |
| } |
Now, in new.html.twig, keep the margin and padding for the normal situation. But if we're in a modal, use modal:m-0, and modal:p-0:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="m-4 p-4 modal:m-0 modal:p-0 bg-gray-800 rounded-lg"> | |
| // ... lines 7 - 21 | |
| </div> | |
| {% endblock %} |
Back on the new page, this shouldn't change. Looks good! But in the modal... that is what we want.
Our modal system now opens instantly, AJAX-loads content, we can submit it and even closes itself on success! Watch: fill in a purpose, select a planet... and... the modal closed!
How? It's cool! The new action redirects to the index page. And because index.html.twig extends the normal base.html.twig, it does have a modal frame... but it's that empty one at the bottom. That causes the turbo-frame on the page to become empty. And thanks to our modal controller, we notice that and close the dialog.
The only thing we're missing now, if you were watching closely, is the toast notification! Tomorrow, we'll talk all about handling success when a form is submitted inside a frame... including doing cool things like automatically adding the new row to the table on this page. See ya tomorrow.
15 Comments
Hi,
Thanks for the tutorial! :)
I believe there’s an issue with the recent update regarding async CSRF. Since I’m using the StimulusBundle Flex recipe, I’m encountering problems with the new csrf_protection_controller.js. Now, every form inside a Turbo modal stops working (even though it works fine with a regular request outside the modal).
The issue might stem from the
document.addEventListener('submit', function (event) { … })found in the controller. This event never gets triggered when using the modal, as described in the tutorial. I suspect it could be something related to the modal Stimulus controller of the tuto, but I can’t figure out what exactly.hey @sylvaindeloux
yeah, that's pretty weird, but I'd recommend disabling stateless csrf the code here is not configured to use it, so it causes issues, and the problem is not in this controller, but in new
csrf.yamlwhere stateless csrf is configured. To solve issue just disable it.Cheers!
Looks like this can be fixed by removing / stimulusFetch: 'lazy' / from the csrf_protection_controller.js
https://github.com/symfony/symfony/issues/59571#issuecomment-2610555055
Thanks for the tip
Hello !
I'm having a really weird issue and I found nothing about it in the Symfony UX or the Turbo documentation, GitHub repos or internet at all.
I have CSRF protection enabled on my website and when sumitting the form in the turbo frame I have a form validation error "Invalid CSRF Token" and this log "CSRF validation failed: double-submit info was used in a previous request but is now missing.''
The form is working perfectly on the dedicated page, but inside a frame this happen. Also the form in the frame works again for a short period when deleting the PHPSESSID cookie.
Is there something to know about turbo frame and submitting form with CSRF protection ? Or should I write an issue in symfony UX or turbo in GitHub ?
Hey Hugo,
As a workaround, you can just turn off the CSRF protection for some forms that are handled in turbo frames. Yeah, CSRF may indeed cause some issues in some cases, especially when we're talking about async requests.
But actually, even with Turbo frames it should work well I think, though probably you have a more complex setup? Maybe the problem in Turbo drive cache. You should try to find steps to reproduce. When you will have them, and when you will be 100% sure that following those steps you will get the issue with CSRF protection - analyze the steps to see the problem, I bet it will be more obvious to see the problem and a possible fixes when you take a look at the steps to reproduce.
I hope that helps!
Cheers!
Hi, I'm confusing about "loadingContent" and "loadingTemplate".
For example, I read: "Over in the modal controller, add a new target called loadingContent:" but in the code the target is "loadingTemplate".
Also in other parts of tutorial there are these little mistakes... or am I missing something?
Hey @Fedale
Thanks for reporting it. Indeed this is a little mistake. I'll look for a way to make it right
Cheers!
Hi Ryan,
Cool stuff, really! I do however have a question, I have implemented the ajax modal and use the openModal way so we have the out-of-the-box escape close handler. I also have the stimulus action to listen to the native close event so we can close everything.
But.... (there's always one), how do you handle the following:
Let's say the user clicks a link, modal with turbo frame opens but the loading time is longer then expected. During this process the user presses the Escape key, the modal closes BUT (there it is) the fetch call is still running, when it's finished it still replaces the turbo-frame and through the MutationObserver the modal gets re-opened.
I tried watching if the turbo-frame has a aria-busy when the close method is being called, but it always is undefined. So my guess these two events are triggered passed each other.
The ultimate solution would be to maybe abort the turbo fetch request all together, or have a way to block the modal from opening again.
Maybe I missed something in the other tutorials and there is a solutions somewhere.
Keep up the nice work!
Sometimes you spend a good amount of time typing a message and find a solution a few minutes after it, I stumbled upon this tutorial, which has the answer for now:
https://symfonycasts.com/screencast/turbo/prevent-frame-rendering
Putting the code below in the controller now prevents the frame rendering from taking place if the modal isn't open (anymore).
If there is more to it, maybe you can let us now.
Hey @webstack
Ha! Yea, sometimes you just need to put your problem into words. Thank you for sharing your solution, and by the way, you can use our search form at the top of the page to look for course-related topics (choose the "In this course" option)
Cheers!
Ah Thanks! Have a good one.
This might just be my favourite episode so far. So many things coming together!
Woo! It might be mine too :)
Hey Hauke,
Thank you for your feedback! We're super happy you liked it :)
Cheers!
"Houston: no signs of life"
Start the conversation!