Lazy Modal & Big Cleanup

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

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

Login Subscribe

Our modal is now powered by a turbo-frame: the form was Ajax loaded by the frame system. But when we submit, wah, wah. It submits to the whole page.

Let's see what's going on. Reopen the modal and inspect it. Hmm. Ah, look at the form. It has data-turbo-frame="_top"! That's coming from _form.html.twig.

{{ form_start(form, {
attr: { 'data-turbo-frame': formTarget|default('_top') }
}) }}
{{ form_widget(form) }}
<button class="btn btn-primary" formnovalidate>{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

Remember: a few minutes ago, we set the data-turbo-frame attribute to a dynamic formTarget variable. The point of this was so that if the form is being loaded into a frame, then we target that frame. Else, if the form is being loaded via a normal page load, target _top.

The problem is that... we only set the variable for the edit page. Open src/Controller/ProductAdminController.php. Right here - this is the edit() action - we did pass in the formTarget variable that's set to the Turbo-Frame request header. Go us! But... I did not do that for the new action. And since that does not pass a formTarget variable, it defaulted to _top.

... lines 1 - 81
return $this->renderForm('product_admin/edit.html.twig', [
'product' => $product,
'form' => $form,
'formTarget' => $request->headers->get('Turbo-Frame', '_top')
]);
}
... lines 89 - 104

So let's pass that variable in for the new page as well. This is yet another spot where, to get this turbo-frame-powered modal working, we're making things simpler and more consistent.

... lines 1 - 31
/**
* @Route("/new", name="product_admin_new", methods={"GET","POST"})
*/
public function new(Request $request): Response
{
... lines 37 - 55
return $this->renderForm('product_admin/' . $template, [
'product' => $product,
'form' => $form,
'formTarget' => $request->headers->get('Turbo-Frame', '_top')
]);
}
... lines 63 - 105

Ok: refresh again, open the modal, submit and... oh, that is positively heart-warming.

Lazy Modal Loading

We still need to work on what happens when we submit the form successfully... but before we do, let's do something cool. Refresh the page and inspect element on the button. Dig a little to find the turbo-frame that contains the modal. Here it is. If you expand this, you'll notice that Turbo has already made the Ajax request for the form and put the HTML here. That happens as soon as the page loads.

But we don't really need to make that Ajax call until the modal opens. Could we somehow delay that? Totally! And we did this earlier.

In _modal.html.twig, on the turbo-frame, add loading="lazy".

... lines 1 - 14
<turbo-frame
class="modal-body"
src="{{ modalSrc }}"
id="{{ id }}"
loading="lazy"
>
{{ modalContent|default('Loading...') }}
</turbo-frame>
... lines 23 - 26

Let's see how this looks. Refresh and inspect the frame. It still says "Loading": it has not made the Ajax request yet. Open your network tools and watch the Ajax requests. Click to open the modal! There's the Ajax call!

Remember: with loading="lazy", the frame system won't make the Ajax request until the frame becomes visible in the viewport. And... that works pretty awesomely with modals which don't become visible until you open them.

Big Ol' Cleanup

At this point, if you look at the modal-form controller, its only job is to... open the modal! The turbo-frame inside handles the rest... and that's pretty cool. Let's cleanup a few more things: we don't need useDispatch anymore: we're not dispatching any events... whoops. And... we don't need to import useDispatch or jQuery... and we can also delete the formUrl value.

import { Controller } from 'stimulus';
import { Modal } from 'bootstrap';
import $ from 'jquery';
import { useDispatch } from 'stimulus-use';
export default class extends Controller {
static targets = ['modal'];
static values = {
formUrl: String,
}
modal = null;
connect() {
useDispatch(this);
}
async openModal(event) {
this.modal = new Modal(this.modalTarget);
this.modal.show();
}
}

Cool. In the template for the product index page, we still do need the modal-form controller but we do not need to pass in the formUrl variable.

Above this, we have some fanciness with the reload-content controller. That helped us reload the product list via Ajax after the modal closed. We're going to completely replace that with something simpler in a few minutes. So delete all of that stuff.

Finally, near the bottom, remove this target, which was for the reload-content controller.

Honestly, I'm wondering if it might have been easier to start this feature from scratch! Because most of the work we just did was deleting and simplifying.

... lines 1 - 4
{% block body %}
<div
class="container-fluid container-xl mt-4"
>
<div class="d-flex flex-row">
<h1 class="me-3 mb-4">Product index</h1>
<div
{{ stimulus_controller('modal-form') }}
>
... lines 15 - 25
</div>
<div class="table-responsive">
{{ include('product_admin/_list.html.twig') }}
</div>
<a href="{{ path('product_admin_new') }}">Add a new product</a>
</div>
{% endblock %}

Let's make sure we didn't break anything. Refresh, open the modal and submit the form empty. That feels great!

But what happens on a successful form submit? Fill in a title, price and... go! Woh. That's... interesting. It says "loading". Next, let's figure out what just happened. And then, we'll code up the real solution: after a successful form submit, we want to close the modal and reload the list behind us. We're about to bend the frame system to our will!

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}