If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeAs usual, I'm going to complicate things! But I have a good reason: I really want us to get the most out of frames... and we have a bug hiding.
Head over to ProductAdminController
. As we just talked about, this redirects to the product_admin_index
page. Let's pretend that we want to redirect this to the "reviews" page for the new product. Change this to app_product_reviews
and pass the id
wildcard set to the new id: $product->getId()
.
... lines 1 - 31 | |
/** | |
* @Route("/new", name="product_admin_new", methods={"GET","POST"}) | |
*/ | |
public function new(Request $request): Response | |
{ | |
... lines 37 - 51 | |
return $this->redirectToRoute('app_product_reviews', [ | |
'id' => $product->getId(), | |
]); | |
} | |
... lines 56 - 107 |
Cool. But this change won't affect our modal. When the modal submit is successful, we're simply closing the modal, staying on the page and completely ignoring the frame that lives in the now-closed modal. This new redirect would only affect us if we went directly to the /new
admin page where the form targets the full page.
So, since this won't affect us, it shouldn't break anything! Famous last words. Refresh, open the modal, add some details and submit. Oh! The modal did close... but we have an error in the console!
Response has no matching
<turbo-frame id="product-info">
element.
Ah, the problem is that, even though we closed the modal, the turbo-frame
still followed the redirect to the product review page. Then, like it always does, it looked for a <turbo-frame>
with id="product-info"
... which that page doesn't have.
So what we really want to do is just... close the modal and tell turbo to not follow the redirect. Unfortunately, the turbo:submit-end
event is too late to tell Turbo to do that!
We could ignore this error... or hack an empty turbo-frame onto the reviews page... but let's fix this properly. It's a good challenge.
When we submit this form, four events are triggered in this order: turbo:before-fetch-request
, turbo:submit-start
, turbo:before-fetch-response
and finally turbo:submit-end
. Then the frame is rendered.
But, wait a second. If the frame isn't rendered until after turbo:submit-end
, why is it too late to tell Turbo to not render the frame? The truth is that turbo:submit-end
isn't actually too late. The real problem is that Turbo doesn't give us a way to cancel rendering from this event. But it does give us this power from the event right before this: turbo:before-fetch-response
.
This event is triggered right after the Ajax call finishes, actually after both Ajax calls have finished: the form submit POST and the second request to the redirected page. But at this point, the frame has not been re-rendered.
Tip
Starting in Turbo 7 RC4 (and so also in the stable Turbo 7), the turbo:before-fetch-response
event
is now triggered from whatever element triggered the Ajax call. This means that you can
now use this.element.addEventListener
instead of attaching it to document
. Nice!
This time, we do need to attach the event to document
because this event is dispatched directly there - not on the form. For now, I'm going to not hide the modal.
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 7 | |
connect() { | |
document.addEventListener('turbo:before-fetch-response', (event) => { | |
console.log(event); | |
if (event.detail.success) { | |
//this.modal.hide(); | |
} | |
}); | |
} | |
... lines 16 - 20 | |
} |
Refresh, open the modal and fill out the form so we can see what the event looks like for a successful form submit. Cool. In the console, we see two of these events. The first happened when we opened the modal: that's the GET request to load the form. The second is from the form submit.
Open this up and look at the detail
property: it has a fetchResponse
object and inside of it that... awesome! A succeeded
key and a redirected
key! So it tells us if the request was successful and also if it was redirected.
So here's the plan: when this event happens, if a modal is open and the Ajax call was successful and the Ajax call was a redirect, we'll assume that a form was just submitted and hide the modal.
Back in the listener function, delete the code. Then, if not this.modal
- so if the modal has never opened - or if not this.modal._isShown
- an internal way to detect whether a modal is visible - then we don't need to do anything. Just return
.
But if the modal is open, set const fetchResponse
to event.detail.fetchResponse
: that's the object we were just looking at. If fetchResponse.succeeded
and fetchResponse.redirected
, then we're going to assume this was a successful form submit and hide the modal.
If we stopped now, this would do the exact same thing as before... just with more code. It would hide the modal... but then the frame would still try to render and give us that annoying error. But there's a key difference between this event and turbo:submit-end
: this event is cancellable. In this event we're allowed to say event.preventDefault()
.
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 7 | |
connect() { | |
document.addEventListener('turbo:before-fetch-response', (event) => { | |
console.log(event); | |
if (!this.modal || !this.modal._isShown) { | |
return; | |
} | |
const fetchResponse = event.detail.fetchResponse; | |
if (fetchResponse.succeeded && fetchResponse.redirected) { | |
event.preventDefault(); | |
this.modal.hide(); | |
} | |
}); | |
} | |
... lines 22 - 26 | |
} |
Normally, we use event.preventDefault()
to prevent form submits or link clicks. Some custom events - like this one - also allow you to call this method... and it could mean anything based on the event. In this case, it communicates to Turbo that we would like to prevent this response from rendering.
Let's try it. Refresh, open, fill out the form and submit. Yes! The modal closed... this time with no error!
We're amazing! Oh, except... hmm... this still isn't quite what we want. The modal closed... but the page didn't reload or refresh... so we don't see the new product in the list immediately. Let's fix that next and finish our Turbo-powered modal system.
Ah! You're right! It's new in RC4! https://github.com/hotwired...
So that will allow us to simplify things. I'll investigate and add a note to the video to help people.
Thanks for the pointer!
// composer.json
{
"require": {
"php": ">=8.1",
"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.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
}
}
// 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
}
}
I guess it must have changed since this video, but turbo:before-fetch-request now fires on whatever element that triggers it, not on document.
Here's the docs: