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.
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 ?