turbo-frame inside a Modal

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

Let's do one more big thing with the frame system. Go to the product admin page and click to add a new product. In the last tutorial, we used Stimulus to open this in a modal, make this form submit via Ajax inside the modal, make the modal close on success and then reload the list with Ajax. An entire experience with no full page refreshes.

The stimulus controller for this lives at assets/controllers/modal-form_controller.js. This openModal() is called when we click to add a new product: it opens the modal and makes an Ajax call to populate that modal with the form HTML. The submitForm() is called when the form is submitted and its job is to Ajax-submit the form and close the modal on success.

We're revisiting this example because, by leveraging Turbo frames, I think we can simplify this... like, a lot. And you can probably guess how: we can use a turbo frame to load the initial contents of the modal and to make the form submit stay in the modal.

Refactoring to a turbo-frame

The modal's markup lives in templates/_modal.html.twig and this is meant to be reusaable in multiple places. This modal-body element holds the actual content.

Let's transform this into a <turbo-frame>. To keep things usable, set the frame's src="" to a new modalSrc variable that we will pass into this template.

<div
class="modal fade"
tabindex="-1"
aria-hidden="true"
data-modal-form-target="modal"
>
... lines 7 - 14
<turbo-frame
class="modal-body"
data-modal-form-target="modalBody"
data-action="submit->modal-form#submitForm"
src="{{ modalSrc }}"
>
{{ modalContent|default('Loading...') }}
</turbo-frame>
... lines 23 - 32
</div>

Now open the template for the product admin list page: templates/product_admin/index.html.twig. There's a lot going on here: we activate the modal-form Stimulus controller here. We also have a Stimulus controller for reload-content. It's job was to reload the product list after the modal closed successfully. We're going to be removing a lot of this stuff soon.

What I want to focus on right now is down here where we include that modal. Pass in that new modalSrc variable set to path('product_admin_new) because that's the page that holds the "new product form" that we want.

{% extends 'base.html.twig' %}
{% block title %}Product index{% endblock %}
{% block body %}
<div
class="container-fluid container-xl mt-4"
{{ stimulus_controller('reload-content', {
url: path('product_admin_index', { ajax: 1 })
}) }}
data-action="modal-form:success->reload-content#refreshContent"
>
... lines 13 - 15
<div
{{ stimulus_controller('modal-form', {
formUrl: path('product_admin_new')
}) }}
>
<button
class="btn btn-primary btn-sm"
data-action="modal-form#openModal"
>+ Add new product</button>
{{ include('_modal.html.twig', {
modalTitle: 'Add a new Product',
modalSrc: path('product_admin_new'),
}) }}
</div>
... lines 31 - 37
</div>
{% endblock %}

Before we try this, let's delete some code in modal-form_controller.js. In openModal(), we don't need to set the innerHTML to "Loading" - that can live directly in the frame - and... we don't need to manually make an Ajax call at all! That's going to happen automatically just because we're setting the src attribute on the <turbo-frame>.

Also submitForm()... yea, we're not going to need this at all. The turbo frame will handle the form submit all on its own. And thanks to these changes, one of the targets up on top - modalBody - is no longer used. So we can remove that too.

... lines 1 - 4
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();
}
}

Yup, the job of this controller is getting... pretty simple!

Back in _modal.html.twig, to finish our cleanup, we don't need the modalBody target... and we also don't need the data-action that called the submitForm method that we just deleted.

... lines 1 - 14
<turbo-frame
class="modal-body"
src="{{ modalSrc }}"
>
{{ modalContent|default('Loading...') }}
</turbo-frame>
... lines 21 - 32

Forgetting the id Attribute

Ok team: let's try this! Refresh the page. Hmm, nothing happened. In the console, whoa!

Failed to execute querySelector on element: turbo-frame# is not a valid selector.

What is that? Well, it's not a great error, but something is looking for a turbo-frame with a certain id - that's this # part. But oh! I forgot to give our frame an id! Whoops.

Head back to _modal.html.twig. I want to keep this dynamic because different modals may need different frame ids. So say id="{{ id }}".

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

Over in index.html.twig, pass in the new id variable set to product-info. That's the id we've been using... and it really could be anything, as long as it matches a frame on the new product page.

... lines 1 - 24
{{ include('_modal.html.twig', {
modalTitle: 'Add a new Product',
modalSrc: path('product_admin_new'),
id: 'product-info',
}) }}
... lines 31 - 41

Ok: let's keep trying. Refresh and add a new product. Error!

Response has no matching <turbo-frame id="product-info"> element.

Ah, I remember. In edit.html.twig, we added a <turbo-frame> there... but we never added the <turbo-frame> in new.html.twig. We could just move the turbo-frame into _form.html.twig because that's included on both pages. The disadvantage is that we added the frame in edit.html.twig on purpose so that our inline editing feature would include the "edit product" h1 tag and the delete button. So instead, let's just add the same <turbo-frame> over here in new.html.twig.

... lines 1 - 4
{% block body %}
<div class="container mt-4">
<a href="{{ path('product_admin_index') }}"><i class="fas fa-caret-left"></i> Back to list</a>
<h1 class="mt-3">Create new Product</h1>
<turbo-frame id="product-info" target="_top">
{{ include('product_admin/_form.html.twig') }}
</turbo-frame>
</div>
{% endblock %}

Attempt number 3! Refresh and click. Got it!

But if we try to submit this... error!

Error invoking action click->modal-form#submitForm.

Ok, so something is still trying to call the submitForm() method that we deleted a few minutes ago. In _modal.html.twig, this is coming from the modal-footer. In this last tutorial, we added a button down here to submit the form. But this button is actually outside of the form, which lives in the turbo-frame. What we need to do, yet again, is simplify. Remove the modal-footer entirely.

<div
class="modal fade"
tabindex="-1"
aria-hidden="true"
data-modal-form-target="modal"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTitle }}</h5>
<button type="button" class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<turbo-frame
class="modal-body"
src="{{ modalSrc }}"
id="{{ id }}"
>
{{ modalContent|default('Loading...') }}
</turbo-frame>
</div>
</div>
</div>

If you refresh and open the form... the footer buttons are gone... but there is now no submit button on the form! Well, there is one, but it's hiding: you can see it if you inspect element and do some digging. Yup, we hid this button in the last tutorial when it's inside a modal via CSS so that the modal-footer buttons could take precedence. Now, we're going to undo that so that our form is perfectly boring and normal: a form... with a button.

Open assets/styles/app.css and search for modal-body. Delete this section.

Try the modal again... and... it works! And it's so boring, I absolutely love it. Try to submit the form. Um, well... that did work, but it submitted the whole page! Next, let's fix this, make the modal load lazily and delete even more code from the modal system.

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
    }
}