Buy Access to Course
20.

AJAX Modal!

|

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

Yesterday we built a killer modal system thanks to the dialog element. With just this markup and a small Stimulus controller, I'm feeling dangerous.

So let me tell you about today's goal, which is big and bold! When I click "New Voyage", I want to AJAX-load the "new modal form" and pop it into the modal. But more than that! When I submit the form, validation errors should stay in the modal, it should close on success & we should see toast notifications. And, maybe most importantly, I want this entire system to be reusable so that we can quickly load any form on our site in a modal. We're going to do it, or die trying. Hopefully we'll do it... I think we'll do it.

Adding a modal Frame to the Layout

To get this going, copy the entire modal markup. There we go. Then go into base.html.twig and, all the way at the bottom, before the closing body tag, paste:

104 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
// ... lines 18 - 70
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80"
data-modal-target="dialog"
data-action="close->modal#close click->modal#clickOutside"
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<div class="text-white space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">Create new Voyage</h2>
<form method="dialog">
<button class="text-lg absolute top-5 right-5">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12"/><path d="M6 6l12 12"/></svg>
</button>
</form>
</div>
<p class="text-gray-400">
Join us on an exciting journey through the cosmos! Discover the
mysteries of the universe and explore distant galaxies.
</p>
<div class="flex justify-end">
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Let's Go!
</button>
</div>
</div>
</div>
</div>
</dialog>
</div>
</body>
</html>

Back in index.html.twig, remove the dialog... and we don't need the modal controller stuff anymore:

50 lines | templates/voyage/index.html.twig
// ... lines 1 - 4
{% block body %}
<div class="m-4 p-4 bg-gray-800 rounded-lg">
<div
class="flex justify-between"
>
<h1 class="text-xl font-semibold text-white mb-4">Voyages</h1>
<button
// ... line 13
class="flex items-center space-x-1 bg-blue-500 hover:bg-blue-700 text-white text-sm font-bold px-4 rounded"
>
// ... lines 16 - 17
</button>
</div>
// ... lines 20 - 47
</div>
{% endblock %}

This is now a normal h1 and a normal button... that doesn't do anything. In base.html.twig, do the opposite: remove the button, the h1 and the class on the div:

93 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 55
<div
data-controller="modal"
data-action="turbo:before-cache@window->modal#close"
>
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80"
data-modal-target="dialog"
data-action="close->modal#close click->modal#clickOutside"
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<div class="text-white space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">Create new Voyage</h2>
<form method="dialog">
<button class="text-lg absolute top-5 right-5">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12"/><path d="M6 6l12 12"/></svg>
</button>
</form>
</div>
<p class="text-gray-400">
Join us on an exciting journey through the cosmos! Discover the
mysteries of the universe and explore distant galaxies.
</p>
<div class="flex justify-end">
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Let's Go!
</button>
</div>
</div>
</div>
</div>
</dialog>
</div>
</body>
</html>

It's now a div that contains a dialog... that's closed.

Now for the magic touch: remove the guts of the dialog: only keep these two divs: they help give us padding and nice scroll behavior. Inside, add a <turbo-frame> with id="modal":

74 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 55
<div
data-controller="modal"
data-action="turbo:before-cache@window->modal#close"
>
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80"
data-modal-target="dialog"
data-action="close->modal#close click->modal#clickOutside"
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame id="modal"></turbo-frame>
</div>
</div>
</dialog>
</div>
</body>
</html>

That, my friends, was a coding power move. On every page, we now have a <turbo-frame id="modal"> that we can dynamically load content into! And, it lives inside a dialog!

Loading Content into the modal Frame

Watch: on the index page, change the new voyage button to an a tag and set its href to the app_voyage_new route. It's a normal tag that would take us to that page. But now add data-turbo-frame="modal":

51 lines | templates/voyage/index.html.twig
// ... lines 1 - 4
{% block body %}
<div class="m-4 p-4 bg-gray-800 rounded-lg">
<div
class="flex justify-between"
>
<h1 class="text-xl font-semibold text-white mb-4">Voyages</h1>
<a
href="{{ path('app_voyage_new') }}"
data-turbo-frame="modal"
class="flex items-center space-x-1 bg-blue-500 hover:bg-blue-700 text-white text-sm font-bold px-4 rounded"
>
// ... lines 17 - 18
</a>
</div>
// ... lines 21 - 48
</div>
{% endblock %}

Check it out. Refresh and click. Instead of changing the page, it loaded the content into the modal frame. But... nothing happened.

Ok, it did make an AJAX call to the new voyage page. But if we open that up in a new tab, there's no modal frame on this page. Well, actually there is. Like every page, at the bottom, it has an empty modal frame. So when we click that link, it does work... but the result is that the Turbo frame stays empty. Not super helpful.

To fix this, in new.html.twig, add a <turbo-frame id="modal"> around everything... with a closing tag at the bottom:

26 lines | templates/voyage/new.html.twig
// ... lines 1 - 4
{% block body %}
<turbo-frame id="modal">
<div class="m-4 p-4 bg-gray-800 rounded-lg">
// ... lines 8 - 22
</div>
</turbo-frame>
{% endblock %}

Check it out. When we click now, yes! Inside the <turbo-frame>, we have the form! The modal isn't opening yet, but it's ready.

Adding the modal Base Layout

Now, before we figure out how to open the modal, we have a problem... and an opportunity. If we went directly to the new voyage page, we would have two <turbo-frame id="modal"> elements: the one around the form, and the empty one on the bottom. That's... kind of invalid.

Also, even though I want to be able to load this form inside the modal, I also want it to behave like normal if we go to the page directly. Watch: right now, if I fill this out successfully and save, weird things happen! I submitted that into a <turbo-frame id="modal">... it redirected to the index page... which has that matching frame... but it's empty.

That's not what I want. If I go to this page directly, I want it to act like normal.

We're going to handle this with a trick. In new.html.twig, remove the <turbo-frame>... and extend a new base template called modalBase.html.twig:

24 lines | templates/voyage/new.html.twig
{% extends 'modalBase.html.twig' %}
// ... lines 2 - 24

Ooh. Copy that name and in the templates/ directory, create it: modalBase.html.twig. This will have one line: an extends tag that's dynamic. If app.request.headers.get('turbo-frame') equals modal - so if an AJAX request is being made to this page from the modal turbo frame, extend a new modalFrame.html.twig. Else, extend the normal base.html.twig:

{% extends app.request.headers.get('turbo-frame') == 'modal' ? 'modalFrame.html.twig' : 'base.html.twig' %}

If we go to the page like normal, it will extend base.html.twig. There's no turbo frame here, it's completely normal, and it will submit like normal.

Let's create that other base template. Copy its name and, in templates/, create modalFrame.html.twig. All this needs is a <turbo-frame id="modal">... with {% block body %} and {% endblock %} inside:

<turbo-frame id="modal">
{% block body %}{% endblock %}
</turbo-frame>

So if we make a request to this page from the modal frame, the entire response will be this single frame with the page's content inside. That solves our problem. And it means that if we want a page to be able to load its form inside a modal... the only line we to need to change is right here. I'll prove that on Day 23.

Auto-Opening the Modal

But right now, we're back to the situation where we click this link and... if I dig into the modal elements, it is loading the form into the turbo-frame... but the modal isn't opening. How can we do that?

Well, I have 2 requirements for opening the modal. The first is that I want it to be super easy to open. If HTML appears inside this turbo-frame - no matter how it's added - I want the system to be smart enough to see that and open the modal. And second, I want the modal to open instantly. I don't want to click this button... then wait for 500 milliseconds before I see the modal. That's not a good user experience.

For part one - opening this modal as soon as there's content in the turbo-frame - we're going to use a trick inside our Stimulus controller. Let me close a few files. In base.html.twig, make this turbo-frame a target: data-modal-target="dynamicContent":

74 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"></turbo-frame>
</div>
</div>
</dialog>
</div>
</body>
</html>

Here's the idea: if a modal has this target and HTML gets inside of this element for any reason, I want our code to notice that and open the modal. To do that, in modal_controller.js, add that target:

53 lines | assets/controllers/modal_controller.js
// ... lines 1 - 2
export default class extends Controller {
static targets = ['dialog', 'dynamicContent'];
// ... lines 5 - 51
}

And then I'll paste in the most complex code that we're going to see in this tutorial:

53 lines | assets/controllers/modal_controller.js
// ... lines 1 - 2
export default class extends Controller {
// ... lines 4 - 5
observer = null;
connect() {
if (this.hasDynamicContentTarget) {
// when the content changes, call this.open()
this.observer = new MutationObserver(() => {
const shouldOpen = this.dynamicContentTarget.innerHTML.trim().length > 0;
if (shouldOpen && !this.dialogTarget.open) {
this.open();
} else if (!shouldOpen && this.dialogTarget.open) {
this.close();
}
});
this.observer.observe(this.dynamicContentTarget, {
childList: true,
characterData: true,
subtree: true
});
}
}
disconnect() {
if (this.observer) {
this.observer.disconnect();
}
if (this.dialogTarget.open) {
this.close();
}
}
// ... lines 36 - 51
}

But, hold on: even if it looks complex, what it's doing is simple. If we have a dynamicContent target, this code watches that element for any changes. Anytime there is a change, it calls our function. Then we check to see if the dynamicContentTarget element has any HTML. If it does, open it! If it doesn't, close it. It's that simple.

In disconnect(), we deactivate that system. And also, just in case, if our modal controller element is ever removed from the page for any reason, this will close the dialog and do the cleanup.

The result of this is... pretty incredible. Refresh the page. Let's play. I'm going to edit the <turbo-frame> as HTML and type: "will this open?". Boom! It does! And if we empty the content... it closes.

And, more importantly, when we click the "New" link, it pops open with the form! Amazing!

Ok, I think that's enough for today. Tomorrow, we're going to make sure this form submits. And because I can't help myself, we'll add a few more goodies: like opening the modal instantly instead of waiting for the AJAX call to finish.