Full Page Redirect from a Frame

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 Turbo-frame-powered modal is now almost perfect. When we submit successfully, it closes the modal. But... dang! That's all it did. The product list did not update... so it's not super obvious that this worked!

Look at the console log of the event for the successful form submit. Let's see. Inside response, ooh! We can see what URL the frame was redirected to! You can also get this from fetchResponse: this fetchResponse.location is an object that points to the final, redirected URL.

So the reason we're looking at this is that what we really want to do is, after the form submits successfully, read this URL and navigate the entire page to it with Turbo! We want a frame that's, sort of a "hybrid". We want the form submit to stay in the frame... but then once the submit is successful, we want to navigate the whole page to the redirected URL as if we were not in a frame.

And... yea! We can do that! At the top of the controller, import Turbo: import * as Turbo from '@hotwired/turbo'.

Below, remove the console.log, then Turbo.visit(fetchResponse.location).

... lines 1 - 2
import * as Turbo from '@hotwired/turbo';
export default class extends Controller {
... lines 6 - 8
connect() {
document.addEventListener('turbo:before-fetch-response', (event) => {
if (!this.modal || !this.modal._isShown) {
return;
}
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
event.preventDefault();
Turbo.visit(fetchResponse.location);
this.modal.hide();
}
});
}
... lines 23 - 27
}

Let's do this! Refresh, open the modal, typy, typy, submit and... cool! The whole page navigated to the reviews page! Oh, and back in our code, we can remove this.modal.hide(). We don't need that anymore: we're navigating the entire page, so that will naturally replace the modal.

... lines 1 - 4
export default class extends Controller {
... lines 6 - 8
connect() {
document.addEventListener('turbo:before-fetch-response', (event) => {
if (!this.modal || !this.modal._isShown) {
return;
}
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
event.preventDefault();
Turbo.visit(fetchResponse.location);
}
});
}
... lines 23 - 27

"Binding" this for a Listener Method

I'm pretty happy with this, but let's clean things up a bit. Copy the code inside the arrow function, scroll down, and create a new method called beforeFetchResponse() with an event argument. I'm doing this for readability.

In connect(), call that. We don't even need an arrow function: just reference this.beforeFetchResponse.

... lines 1 - 4
export default class extends Controller {
... lines 6 - 8
connect() {
document.addEventListener('turbo:before-fetch-response', this.beforeFetchResponse);
}
... lines 12 - 17
beforeFetchResponse(event) {
if (!this.modal || !this.modal._isShown) {
return;
}
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
event.preventDefault();
Turbo.visit(fetchResponse.location);
}
}
}

There is a problem with this... but let's try it! Refresh, go back to the admin page, open up the modal and fill this out with real data. Submit!

It didn't redirect! And we have that error back in the console. What happened? It's not super obvious at first, but in our new method, the this variable is no longer referencing the controller object. This is the classic problem with callback functions, and we normally work around "this" by passing an arrow function. But if you do want to point directly to the method, you can by binding the method.

Check it out: say this.boundBeforeFetchResponse - I'm actually creating a new property = this.beforeFetchResponse.bind(this). Then, below, point to the bound method.

... lines 1 - 4
export default class extends Controller {
... lines 6 - 8
connect() {
this.boundBeforeFetchResponse = this.beforeFetchResponse.bind(this);
document.addEventListener('turbo:before-fetch-response', this.boundBeforeFetchResponse);
}
... lines 13 - 31

This creates a new property that points to the method.... but where we have guaranteed that the this variable in that method will point to this object. That's the job of bind. And this isn't a Stimulus problem, it's a problem you run into whenever you combine JavaScript, callbacks and objects.

It looks weird at first... but when we submit the form... it does solve our issue: back to the good behavior!

Disconnecting the Event Listener

Oh, but I do want to handle one small detail. Over in the controller, add a disconnect() method. Then copy the document.addEventListener() line, paste, and change it to document.removeEventListener().

... lines 1 - 4
export default class extends Controller {
... lines 6 - 13
disconnect() {
document.removeEventListener('turbo:before-fetch-response', this.boundBeforeFetchResponse);
}
... lines 17 - 33
}

Why are we doing this? If we add an event listener to a controller's element, like this.element, then if that element is removed from the page, it's no big deal that our listener is still technically attached to it. Nothing can interact or trigger events on that element anymore. And your browser will probably garbage collect that element - and the listener - anyways.

But if we add an event listener to the document, then every time a new data-controller="modal-form" appears on the page, our connect method will be called and we'll attach yet another listener. Even after a controller's element is removed from the page, its beforeFetchResponse() would still be called!

So, to be the responsible developers that we are, we remove the listener in disconnect(), which is called when the element attached to this controller is removed from the page.

Changing the Redirect Back to the List Page

Anyways, to put the cherry on top of our new feature, head back to ProductAdminController. Change the redirect back to product_admin_index, which just makes more sense.

... lines 1 - 31
/**
* @Route("/new", name="product_admin_new", methods={"GET","POST"})
*/
public function new(Request $request): Response
{
... lines 37 - 42
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($product);
$entityManager->flush();
if ($request->isXmlHttpRequest()) {
return new Response(null, 204);
}
return $this->redirectToRoute('product_admin_index');
}
... lines 54 - 61
}
... lines 63 - 105

Time to try the entire process. Go to the admin area and do a full refresh. Click to open the modal - that loaded via the frame - hit save - that submitted via the frame - and if fill in some real data. This is going to submit - like normal - to the frame. Then, we'll detect that it was successful and... boom! The new product shows up! That's because we just navigated to this page with Turbo. That's smooth.

Next: we just did something pretty custom. We submitted a form into a turbo frame... but then navigated the entire page on success. This is not something a turbo frame does natively... but it's kind of handy. So let's add a reusable way to do this whenever we want.

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