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 SubscribeWith Turbo Drive, when we click a link or submit a form, and that takes longer than 500 milliseconds to load, we get a loading animation on the top of the page... which we don't see here because this is all loading fast, but we saw it earlier. It's a built-in, global loading indicator that we don't even need to think about.
But the same thing does not happen for Turbo frames. When you click the read more link, that loads pretty fast, but there is a slight delay when nothing happens. And if clicking this loaded a heavier page.... it might not load so fast. It's pretty normal to add a loading indicator in situations like this. Can we add one with Turbo frames?
Sure! And we already have what we need. Head over to src/Controller/CartController.php
. In _cartFeaturedProduct()
, let's sleep for three seconds to fake a slow page.
Back at the browser, inspect this turbo-frame
and make sure it's highlighted. Watch the element closely when I refresh. Look! It has a busy
attribute! Yup, whenever a turbo-frame
is loading, it gets this attribute. If we click the "read more" link, we'll see it again.
This simple attribute makes it possible to add all sorts of loading indicators. For example, we could create two classes to help us hide or show an element during loading.
Open up templates/cart/_featuredSidebar.html.twig
. Ok, let's pretend that we want to hide the "read more" link once we click it. Add class=""
and let's invent a new class called frame-loading-hide
. We'll add the CSS for this in a minute. After this, add a <span>
and give it a different, new, class - frame-loading-show
- that will cause this element to only show when loading. Also give this fas fa-spinner fa-spin
to render a FontAwesome loading animation.
... lines 1 - 17 | |
{% if showDescription %} | |
{{ featuredProduct.description }} | |
{% else %} | |
{{ featuredProduct.description|u.truncate(25)|trim }}... | |
<a | |
data-turbo-frame="cart-sidebar" | |
class="frame-loading-hide" | |
href="{{ path('_app_cart_product_featured', { | |
description: true, | |
}) }}">(read more)</a> | |
<span class="frame-loading-show fas fa-spinner fa-spin"></span> | |
{% endif %} | |
... lines 31 - 36 |
To add styling for these, open up assets/styles/app.css
. Target the busy
attribute with turbo-frame[busy]
. So if there's a turbo-frame element that has a busy
attribute, then for any elements inside with a frame-loading-hide
class, display: none
.
For the other class - the frame-loading-show
- we want this to hide by default and then only show when loading. First, to hide it, copy the CSS selector, paste, make it apply to all turbo-frame elements, and look for the frame-loading-show
class. So, hide these by default.
And, whoops! That jumped a bit. Anyways, below this, override that: inside a turbo-frame[busy]
element, if you have a frame-loading-show
class, display: inline-block
.
... lines 1 - 18 | |
turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show { | |
display: none; | |
} | |
turbo-frame[busy] .frame-loading-show { | |
display: inline-block; | |
} | |
... lines 25 - 180 |
It's a little complicated, but that should get the job done and give us two classes that we can reuse across our site. Let's try it! Find your browser, refresh and... perfect! You can already see that my FontAwesome icon is not showing up because it's hidden by default. Now click this link. Beautiful!
And... that's it! You can leverage this busy
attribute to do whatever you want. For example, we can give every frame on our site loading behavior by lowering their opacity. This is pretty easy. Copy the turbo-frame from above to say that any turbo-frame
with a busy
attribute should have opacity
set to .2. That's an extreme level - but it'll be easy to see.
... lines 1 - 18 | |
turbo-frame[busy] { | |
opacity: .2; | |
} | |
... lines 22 - 183 |
When we refresh now, we should even see this during the initial load. And... we do! When we click the "read more" link... uh... hmm. I did not see the lower opacity. That's weird. Inspect the element... and hack a busy
attribute on the end of this.
Hmm. When I do this, our browser does see the correct opacity CSS... it just doesn't seem to be doing anything! Hover over the element... let me scroll up a bit. Check it out: it has no height! I see the arrow in the upper left... but it's not highlighting the element. You'd expect it to go around the element like this... but it's not!
So this is interesting. The problem is that <turbo-frame>
is a custom HTML element. And by default, your browser renders it as an inline element. You can see this over in the computed CSS: it has display: inline
. And so, when you put block elements inside of it, it just... doesn't expand in the way you'd expect it to. That's why it appears to have no height. And that's why nothing gets the lower opacity.
To fix this, we can make this element display: block
. As soon as I hack this in, the opacity does take effect. To make this work everywhere, we can make our turbo-frames display:
block by default with turbo-frame
, display: block
.
... lines 1 - 18 | |
turbo-frame { | |
display: block; | |
} | |
... lines 22 - 186 |
Try it now. The opacity on loading still works and when we click... that works too!
So now that this looks spectacular, let's go and make the opacity a little less @dramatic... and over in CartController
, take out the sleep.
... lines 1 - 18 | |
turbo-frame { | |
display: block; | |
} | |
turbo-frame[busy] { | |
opacity: .7; | |
} | |
turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show { | |
display: none; | |
} | |
turbo-frame[busy] .frame-loading-show { | |
display: inline-block; | |
} | |
... lines 31 - 186 |
Let's go play with the page. That feels much more natural.
Before we keep going and doing other cool Turbo frame stuff, we accidentally broke the checkout page! It... was my fault.
Variable
showDescription
does not exist
Coming from _featuredSidebar.html.twig
. The template for this page lives at templates/checkout/checkout.html.twig
.
... lines 1 - 10 | |
<div class="row"> | |
<aside class="col-12 col-lg-4"> | |
{% if featuredProduct %} | |
{{ include('cart/_featuredSidebar.html.twig') }} | |
{% endif %} | |
</aside> | |
... lines 18 - 66 |
Ooooh. This page also has a featured product sidebar... and it is still using the include
directly. When we added our new showDescription
variable, I didn't realize this was being included directly and... well... now things are mad.
We could fix this by passing in the variable... or even coding defensively inside _featuredSidebar.html.twig
. But, pfff. We have a working, lazy Turbo Frame! So let's just use that! In cart.html.twig
, steal the lazy frame and paste it inside checkout.html.twig
.
... lines 1 - 10 | |
<div class="row"> | |
<aside class="col-12 col-lg-4"> | |
<turbo-frame id="cart-sidebar" src="{{ path('_app_cart_product_featured') }}" target="_top"> | |
Loading... | |
</turbo-frame> | |
</aside> | |
... lines 18 - 66 |
Celebrate by opening up the controller for this page, which is CheckoutController
, and removing some variables that we don't need anymore: addToCartForm
and featuredProduct
... which means we can delete both variables... and we don't need to inject this argument.
... lines 1 - 20 | |
/** | |
* @Route("/checkout", name="app_checkout") | |
*/ | |
public function checkout(Request $request, CartStorage $cartStorage, EntityManagerInterface $entityManager, SessionInterface $session): Response | |
{ | |
$checkoutForm = $this->createForm(CheckoutFormType::class); | |
$checkoutForm->handleRequest($request); | |
if ($checkoutForm->isSubmitted() && $checkoutForm->isValid()) { | |
/** @var Purchase $purchase */ | |
$purchase = $checkoutForm->getData(); | |
$purchase->addItemsFromCart($cartStorage->getCart()); | |
$entityManager->persist($purchase); | |
$entityManager->flush(); | |
$session->set('purchase_id', $purchase->getId()); | |
$cartStorage->clearCart(); | |
return $this->redirectToRoute('app_confirmation'); | |
} | |
return $this->renderForm('checkout/checkout.html.twig', [ | |
'checkoutForm' => $checkoutForm, | |
]); | |
} | |
... lines 47 - 65 |
Cool! Refresh now and... all good. The "read more", of course, even works here because Turbo & Stimulus are awesome.
Next: below each product, if you're logged in, users can post a review. We can make this a bit more awesome by leveraging a turbo frame.
I just opened the comment section to inform about the exact same behaviour of that element in firefox compared to chromium based browsers, just to find the only existing comment to already inform about that :)
Hey Thomas A.!
Ah, interesting! Thanks for the note! This could be something that different browsers handle differently - I found a discussion from the W3c about custom elements and whether they should be inline or block by default. Either way, it looks like you either "don't need to do anything" or need to add the display: block CSS. Both paths are, hopefully, pretty simple.
Cheers and I'm really happy you're enjoying the tutorial!!
// 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
}
}
Just a quick note: I don't need
display:block;
onturbo-frame
with Firefox 90.0 (64 bits) on Linux Mint 20.2.Good job, keep going, I love it!