Buy Access to Course
21.

Fantastic Modal UX with a Loading State

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let'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:

98 lines | src/Controller/VoyageController.php
// ... 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:

98 lines | src/Controller/VoyageController.php
// ... 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:

99 lines | src/Controller/VoyageController.php
// ... 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:

62 lines | assets/controllers/modal_controller.js
// ... 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:

62 lines | assets/controllers/modal_controller.js
// ... 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:

94 lines | templates/base.html.twig
<!DOCTYPE html>
<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:

94 lines | templates/base.html.twig
<!DOCTYPE html>
<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:

95 lines | templates/base.html.twig
<!DOCTYPE html>
<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:

99 lines | src/Controller/VoyageController.php
// ... 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:

30 lines | tailwind.config.js
// ... 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:

24 lines | templates/voyage/new.html.twig
// ... 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.