Buy Access to Course
22.

Fancy things on Modal Form Success

|

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

We have been busy. We've cooked up a reusable AJAX-powered modal system that I love. Submitting with validation errors already works. And success? It's nearly there. We when save... no toast notification, but the modal did close.

The reason it closed is important. In the new() action, we redirect to the index page. That page extends the normal base.html.twig... so it does have a <turbo-frame id="modal"> on it... but it's this empty one. This means the modal frame becomes empty, our modal Stimulus controller notices that then closes it.

Planning: When Forms are in Frames

In general, when you add a <turbo-frame> around something - like on the homepage with our planets sidebar - you need to think about where the links inside point to. We need to make sure each goes to a page that has a matching <turbo-frame>.

When a form lives inside a <turbo-frame>, we need to think about what happens on submit. The error case is easy: it always renders the same page that has the same frame with the errors inside. But on success, we need to think about where the form redirects to and ask: does that page have a matching <turbo-frame> and does it contain the right content?

In the case of this modal and the index page, it's perfect: there is a matching frame, it's empty and the modal closes.

Rendering Success Flashes with a Turbo Streams

Ok, back to the missing toast notification! This is a situation where we need to update the <turbo-frame> - to empty it - and we also need to update another area on the page: we need to render the success flash messages into the flash container.

This is a super common need when a form submits inside a <turbo-frame>. So we're going to solve this, I think, in a cool and global way. When we redirect on success, this <turbo-frame> is ultimately loaded on the page, which causes the modal to close. Inside it, add a <turbo-stream> with action="append" and target="flash-container":

99 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"
// ... lines 69 - 71
>
<turbo-stream action="append" target="flash-container">
// ... line 74
</turbo-stream>
</turbo-frame>
</div>
</div>
</dialog>
// ... lines 80 - 95
</div>
</body>
</html>

When we added the toast system, we added an element with id="flash-container:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 51
<div id="flash-container">
{{ include('_flashes.html.twig') }}
</div>
// ... lines 55 - 96
</body>
</html>

We didn't need that then, but now it's going to come in handy because we can target that to add flash messages into it.

Inside the stream, add the template tag, of course, then {{ include('_flashes.html.twig') }}:

99 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"
// ... lines 69 - 71
>
<turbo-stream action="append" target="flash-container">
<template>{{ include('_flashes.html.twig') }}</template>
</turbo-stream>
</turbo-frame>
</div>
</div>
</dialog>
// ... lines 80 - 95
</div>
</body>
</html>

This will render the flash messages... and the stream will append them into that container.

Let's try it! Fill out a new voyage, submit and... absolutely nothing happens. The problem... is subtle. When we redirect to the index page, Symfony renders that entire page... even though Turbo will only use the <turbo-frame id="modal">. This means that, right before we render this code, our flash container renders the flash messages... which removes them from the flash system. So the flashes messages are in the HTML that we return from the Ajax call... but because they're not inside the <turbo-frame>, they don't make it onto the page.

The fix is easy: make sure your flash container is after the modal:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 51
<div
data-controller="modal"
data-action="turbo:before-cache@window->modal#close"
>
// ... lines 56 - 91
</div>
<div id="flash-container">
{{ include('_flashes.html.twig') }}
</div>
</body>
</html>

Give this a go. Refresh... and fill in the form. Got it! The Modal closes, then the <turbo-stream> triggers the toast!

And this is really neat! When we redirect, the <turbo-frame> is now not empty: it contains the flash <turbo-stream>. But remember: as soon as a <turbo-stream> activates, it executes itself and then disappears. Once that happens, the <turbo-frame> becomes empty and the modal closes. I really dig that.

Stream Extras: Prepending the Table

What I love about the modal system is that it works... and we haven't needed to make any changes to our controller. But now, we get to think about any optional extra behavior that we might want.

For example, could we prepend the table with the new voyage? Because, right now we don't see it until after we refresh. Let's try!

In index.html.twig, find the table. We need to prepend into the tbody. To target this, on the table, add an id="voyage-list":

43 lines | templates/voyage/index.html.twig
// ... lines 1 - 4
{% block body %}
<div class="m-4 p-4 bg-gray-800 rounded-lg">
// ... lines 7 - 21
<table class="min-w-full bg-gray-800 text-white" id="voyage-list">
// ... lines 23 - 39
</table>
</div>
{% endblock %}

Let's think: this is another case where we need to update something that lives outside the <turbo-frame>. So, we need a stream.

Open new.html.twig and after the body block, add a new block called stream_success, then endblock. Inside, we'll add any Turbo streams we need to make the submit really shine. Add a <turbo-stream> action="prepend" then targets="". The "s" on targets means we can use a CSS selector: #voyage-list tbody. Add the <template> element... and, for now, a <tr><td> {{ voyage.purpose }}:

32 lines | templates/voyage/new.html.twig
// ... lines 1 - 24
{% block stream_success %}
<turbo-stream action="prepend" targets="#voyage-list tbody">
<template>
<tr><td>{{ voyage.purpose }}</td></tr>
</template>
</turbo-stream>
{% endblock %}

Ok, so we have a new block in our template... that nobody is using. Somehow, we need to grab this Turbo stream... and, after the redirect, render it on the next page in the modal <turbo-frame>.

How do we do that? We have two options - and I'll show the second on Day 24. But here's the system I like.

First, we only need to worry about prepending the table row when we're submitting inside a <turbo-frame>. If we went to the new voyage page directly - which doesn't have a frame - and submitted, we wouldn't need any Turbo Stream stuff. This would navigate the full page and render normally. Nice & simple.

So, in the controller, start with if $request->headers->has('turbo-frame'). So if this form submit is happening inside a <turbo-frame>, then we want to use our stream. Render that block with $stream equals then a relatively new controller method: $this->renderBlockView() passing voyage/new.html.twig. Instead of rendering the entire template, to render a single block pass this, you guessed it, stream_success. Actually... I think I'm missing an "s". I am! Better.

Pass the template a voyage variable.

To pass the <turbo-stream> string to the next page add it to a new flash called stream:

106 lines | src/Controller/VoyageController.php
// ... lines 1 - 15
class VoyageController extends AbstractController
{
// ... lines 18 - 25
#[Route('/new', name: 'app_voyage_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
// ... lines 29 - 32
if ($form->isSubmitted() && $form->isValid()) {
// ... lines 34 - 38
if ($request->headers->has('turbo-frame')) {
$stream = $this->renderBlockView('voyage/new.html.twig', 'stream_success', [
'voyage' => $voyage
]);
$this->addFlash('stream', $stream);
}
// ... lines 46 - 47
}
// ... lines 49 - 53
}
// ... lines 55 - 104
}

Finally, when we redirect to the index page and this <turbo-frame> is rendered, output that flash: for stream in app.flashes('stream'), endfor with {{ stream|raw }} so it renders the raw HTML elements:

102 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 51
<div
// ... lines 53 - 54
>
<dialog
// ... lines 57 - 59
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame
id="modal"
// ... lines 65 - 67
>
// ... lines 69 - 71
{% for stream in app.flashes('stream') %}
{{ stream|raw }}
{% endfor %}
</turbo-frame>
</div>
</div>
</dialog>
// ... lines 79 - 94
</div>
// ... lines 96 - 99
</body>
</html>

I think we're ready! Refresh... add a new voyage and... that's incredible! The Ajax call redirected to the index page, where the modal frame had 2 Turbo streams: one to render the toast and the other to prepend the table.

Prepending with Real Content

Last step, prepend the real content. What we want is this tr. To get that from inside of new.html.twig, we need to isolate it into its own template. Copy that, delete it, then include voyage/_row.html.twig:

43 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"
>
// ... lines 10 - 21
<table class="min-w-full bg-gray-800 text-white" id="voyage-list">
// ... lines 23 - 30
<tbody class="divide-y divide-gray-600">
{% for voyage in voyages %}
{{ include('voyage/_row.html.twig') }}
{% else %}
<tr>
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-gray-400">No records found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

Go create that template... then paste:

<tr class="even:bg-gray-700 odd:bg-gray-600">
<td class="px-6 py-4 whitespace-nowrap">{{ voyage.id }}</td>
<td class="px-6 py-4">{{ voyage.purpose }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ voyage.leaveAt ? voyage.leaveAt|date('Y-m-d H:i:s') : '' }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ path('app_voyage_show', {'id': voyage.id}) }}" class="text-blue-400 hover:text-blue-600">show</a>
<a href="{{ path('app_voyage_edit', {'id': voyage.id}) }}" class="ml-4 text-yellow-400 hover:text-yellow-600">edit</a>
</td>
</tr>

Easy.

Copy the include() statement and, in new.html.twig, use that for the stream:

32 lines | templates/voyage/new.html.twig
// ... lines 1 - 24
{% block stream_success %}
<turbo-stream action="prepend" targets="#voyage-list tbody">
<template>
{{ include('voyage/_row.html.twig') }}
</template>
</turbo-stream>
{% endblock %}

Let's try this! Create another voyage and... beautiful! Modal closes, toast notification renders & the page updates. It's everything we want.

Tomorrow we're going to put our new modal system to the test by opening the edit link inside a modal. I promised it would be reusable, and tomorrow we'll prove it... with a few curve balls to make it more realistic.