Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Close the Modal after turbo-frame Success

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

We just submitted the form in the modal successfully and... well, this happened. Weird. If you refresh, the submit did work: this is our new product on top. Inspect element on the frame so we can see what's going on... it's interesting and... subtle. Dig a little to find the frame.

Ok, the src starts set to /admin/product/new, which means that when we open the modal, we see the contents of the turbo-frame from that page. Fill in some data and then submit.

Hmm, the src changed to /admin/product/. Well, that does make sense: if you look in ProductAdminController, after success, the controller redirects to /admin/product - this is inside of the new action.

So we submit to /admin/product/new and it redirects to /admin/product/. When that happens, the frame system does two things. First, it makes a second request to the redirected URL - /admin/product. We've seen that before. And second, it updates the src attribute to match the redirected URL.

This is all perfectly expected. Open the network tab. The second to last request is the POST request to /admin/product/new. That's the form submit. And the last request is Turbo following the redirect to /admin/product/.

Look at the response for that request... let's actually look at the raw HTML. Let's see if we can dig and find the turbo-frame. There it is! Yup, it contains nothing more than "Loading...". That is what we're seeing in the modal.

Remember: when the frame system finds a matching turbo-frame, it only takes the frames HTML: it does not also use the new frame's src attribute or anything else. So even though this frame has src="/admin/product/new, that is not used. It grabs the "Loading..." text and... that's it!

So once again, Turbo is behaving exactly like it should... but not necessarily how we want!

Speaking of that... how do we want this to work? If we wanted the modal to stay open but show a new, empty form, we could simply change the controller to redirect back to the new product page. Done.

Doing Something After a Form Submit

But I want to do something different: after a successful form submit, I want to close the modal. How can we do something after a turbo frame navigates?


Starting in Turbo 7.0 RC2, there are two frame-specific events: turbo:frame-render and turbo:frame-load.

We already know that Turbo triggers a bunch of events... but there aren't any events specific to turbo frames. There's no, turbo:frame-start or anything like that. However, Turbo does trigger an event right before and after a form submits.

In modal-form_controller.js, add a connect() method. Until now, we've listened to all of our turbo events inside of assets/turbo/turbo-helper.js. The reason is that all of this code represents global behaviors: stuff that we we're adding to the entire page.

turbo:submit-end in Stimulus

But in this case, we want to listen to an event only when a specific controller is active... so we can run some custom code that affect just that controller. Say this.element.addEventListener() and listen to an event called turbo:submit-end. Pass this an arrow function with an event argument.

Earlier we listened to turbo:submit-start. As you can see, there is also a turbo:submit-end event, which happens after the submit Ajax call has finished. Let's console.log(event) to see what it looks like.

... lines 1 - 3
export default class extends Controller {
... lines 5 - 7
connect() {
this.element.addEventListener('turbo:submit-end', (event) => {
... lines 13 - 17

Oh, and you probably noticed one big difference between this event and the other events that we've listened to. Most Turbo events are dispatched directly on document. But the form events - like turbo:submit-start and turbo:submit-end - are actually dispatched on the form element. Then, they bubble up.

This means that you can attach a listener to document... or any element that contains the form, including the form itself. By attaching the event listener to this.element, our callback will only be executed when a form is submitted inside of this: so inside of the modal. That's... pretty awesome.

Ok, let's see what this event looks like. Move over, refresh the page, open the modal and submit. Go check the console. There it is! Like other events, this has a detail key with a formSubmission inside. Oh, but there's also a success key set to false! That would be true if this was a successful form submit. That's handy: we can use it to know if the submit was successful and then close the modal.

Let's go do it! If event.detail.success, then this.modal.hide().

... lines 1 - 3
export default class extends Controller {
... lines 5 - 7
connect() {
this.element.addEventListener('turbo:submit-end', (event) => {
if (event.detail.success) {
... lines 16 - 20

Cool. Refresh, open the modal, fill in some details and submit. Go team!

Next: even though we closed the modal, the frame system still followed the redirect and updated the HTML in the modal. In this case, that's not a problem. In other cases, it could cause an error. Let's find out when and dive even deeper into the event system to fix it.

Leave a comment!

Login or Register to join the conversation
Nick-F Avatar

Is there a reason why the event listener was attached in the controller and not as a data-action attribute in the twig template?


Hey Nick F.!

Excellent question! No reason I can think of - for whatever reason my mind went straight to this approach - but I can't see any problem with adding it to data-action and it makes a lot of sense :).


Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "", //
        "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.18.5
        "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

What JavaScript libraries does this tutorial use?

// package.json
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0