Chapters
-
Course Code
Subscribe to download the code!
Subscribe to download the code!
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Streams: Reusing Templates
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen we submit the product review form, instead of redirecting like we were before, we're now returning this TurboStreamResponse
. When the Ajax call finishes, Turbo notices that we're returning this type of response... instead of a real HTML page. And so, instead of handling the HTML like a frame normally would, it passes it to the Turbo Stream system.
Right now, we're using it to update the quick stats area of the page with this random HTML. If you refresh, the real goal is to update the review count and review average as soon as the new review is submitted.
Reusing Templates in a Stream
To do that, without repeating ourselves, over in show.html.twig
- the template for the product show page - copy the quick stats code... and create a new template in templates/product/
called, how about _quickStats.html.twig
. Paste the code here.
<strong>{{ product.priceString|format_currency('USD') }}</strong> | |
<br> | |
<strong>{{ product.reviews|length }}</strong> Reviews | |
<br/> | |
<strong>{{ product.averageStars }}/5</strong><i class="fas fa-star ms-2"></i> |
We can now reuse this in two places. In show.html.twig
include it: product/_quickStats.html.twig
{% extends 'product/productBase.html.twig' %} | |
{% block productBody %} | |
<turbo-frame id="product-info" target="_top" class="row pt-3 product-show"> | |
Show Lines
|
// ... lines 5 - 31 |
<div class="p-3 mt-4 d-flex justify-content-between flex-wrap flex-lg-nowrap"> | |
<div id="product-quick-stats"> | |
{{ include('product/_quickStats.html.twig') }} | |
</div> | |
<div> | |
{{ include('product/_cart_add_controls.html.twig') }} | |
</div> | |
</div> | |
Show Lines
|
// ... line 40 |
</turbo-frame> | |
Show Lines
|
// ... lines 42 - 46 |
{{ include('product/_reviews.html.twig') }} | |
{% endblock %} |
Then, in the stream template, do the same thing!
<turbo-stream action="update" target="product-quick-stats"> | |
<template> | |
{{ include('product/_quickStats.html.twig') }} | |
</template> | |
</turbo-stream> |
Pretty cool, right?
Let's try that. Refresh. This still works and shows 10 reviews. Scroll down and add review number 11. Submit and... oh! The entire reviews section is gone! My web debug toolbar is angry: that Ajax call returned a 500 error.
Open up its profiler.
Variable product does not exist
Coming from _quickStats.html.twig
. Of course, the problem is that we're including _quickStats.html.twig
from reviews.stream.html.twig
. In ProductController
, we're not passing any variables to that template... but in quick stats, we need a product
!
No problem: pass product set to $product
, which will make it available all the way into the quick stats template.
Show Lines
|
// ... lines 1 - 18 |
class ProductController extends AbstractController | |
{ | |
Show Lines
|
// ... lines 21 - 69 |
/** | |
* @Route("/product/{id}/reviews", name="app_product_reviews") | |
*/ | |
public function productReviews(Product $product, CategoryRepository $categoryRepository, Request $request, EntityManagerInterface $entityManager) | |
{ | |
Show Lines
|
// ... lines 75 - 85 |
if ($reviewForm->isSubmitted() && $reviewForm->isValid()) { | |
Show Lines
|
// ... lines 87 - 89 |
return $this->render('product/reviews.stream.html.twig', [ | |
'product' => $product, | |
], new TurboStreamResponse()); | |
Show Lines
|
// ... lines 93 - 98 |
} | |
Show Lines
|
// ... lines 100 - 107 |
} | |
Show Lines
|
// ... lines 109 - 116 |
} |
Okay: take two. Refresh again. We now have 11 reviews... so let's go add number 12. Submit. The reviews section is still weird - but that's ok. Scroll up. Yes! Our Turbo Stream updated the area with the real data! That is so cool!
Return a Stream or HTML, not both (yet)
Now we need to fix the reviews area... because showing a filled-in form with a disabled button... doesn't exactly scream "the review was successfully saved".
The entire reviews area lives in templates/product/_reviews.html.twig
... and all of it is surrounded by the product-review
frame. So both the list of reviews and the form are in this frame.
Thanks to this, before we started messing around with turbo streams, after submit, we redirected to the reviews page. That page includes this template with this frame. And so, the entire frame updated, including the new review and a fresh, empty form.
At this point, we have two choices. First, we could redirect on success like we were doing before and let the normal turbo frame logic do its magic. Or we can return a turbo stream and update whatever elements we want. But, we can't do both. Our controller can only return one thing, so we need to choose between returning a redirect or returning a stream.
Well, actually we can do both... but let's keep that a secret between you and I for now. It's a topic for later... and requires one extra piece of technology.
So... what can we do? Well, if we want to be able to update the quick stats area and the reviews area, the answer is to return a stream that contains multiple instructions. Let's see how next.
6 Comments
Yo @Ryan-L!
Other Ryan here! Sorry for the EXTREMELY slow reply - very busy time of year!
First, man, this is such a cool setup! The idea of dispatching a custom event so that you can then listen to it and reload this frame is pretty interesting. So, some notes:
I did also try updating the html directly in the stimulus controller, by using $('#scan-stats-stream').html(response.data), and that doesn't work, either. I can see it in the console, but it's a grayed-out "document-fragment."
Probably because the stream contains a <template>
which is a special element that is almost a fake element. Anyway, this isn't the right approach (as you guessed), but just to clarify, it's likely the <template>
element that causes that document fragment thing.
Overall, there are 2 solutions here:
A) When you get the response
back (that contains the stream), you need to process it through Turbo. You do that by calling the renderStreamMessage()
- example is here - https://symfonycasts.com/screencast/turbo/manual-stream#codeblock-ac3a378c42
B) Unless I've missed something, I think (A) is all you need. However, there may be another possibility. Your col-4 is already inside of a turbo-frame. Could we put a form inside and simply submit that form? Here's my thinking:
1) Right now, on initial page load, I'm guessing that your col-4 is already set up to read some sort of ?date=
on the URL to build the chart.
2) So, what if you put a <form method="get">
inside of your <turbo-frame id="scan-stats">
? Give it an <input type="hidden" name="date">
. Naturally, if the user somehow chose a date (they won't, but stay with me) and submitted this form, it would submit back to this same page, which would (just like an initial page load) read the ?date=
from the URL and render the correct chart inside of a <turbo-frame id="scan-stats">
.
3) To get the date
to update and the form to submit, I would still use your drawer--base
controller and event trick. But instead of making an Ajax call, you would grab the date
from the event, set it onto the hidden input, then find and submit the form. Then let the Turbo frame handle the rest.
Let me know what you think1
Cheers!
Hi, me again, with another question. Sorry, but I swear I'm scouring the internet first before turning to asking here.
How do I redirect to a turbo frame?
I know this has something to do with request/response headers, and "Turbo-Frame" being set to the id of the <turbo-frame> so Symfony knows where to put what, but I can't get this working when there's a redirect.
So... I have a read controller, that essentially reads an Entity. It's a get request, pretty straight-forward. Links to open this are simply:<a href="/modal/scan-batch-details/read/%s" data-turbo="true" data-turbo-frame="modal-scan-batch-details">
Thanks to turbo-frames, wherever this link is (and it's in a few places), it'll connect to that turbo frame, which is inside a modal with a stimulus controller that has a turboFrameContainerTargetConnected() function to pop the modal open once it detects that new content has been placed inside (i.e. once the new turbo frame arrives).
This modal has a pretty standard form to edit the entity details, and a submit button, and all that works just fine. Heck, I even have a separate "read" and "edit" routes, since this is potentially a modal users may have access to view but NOT edit.
I have one use case where this isn't working, though. The entity in question is a parent, and this modal has options to essentially delete / undelete the entity's children, which is does by getting a list of ids to act on. This list of child ids could anywhere from 1 to who-knows-how-many long, so I'm POSTing it using axios.
I can reach the server-side controller with the posted data just fine, and after doing the db work I tell it to redirectToRoute to go back to the '/modal/scan-batch-details/read' route. But it doesn't reload the frame, and it must be because the Turbo-Frame header isn't passed. In fact, I'm pretty sure that's the case, because I have a check in each controller:if (!$request->headers->get('Turbo-Frame')) throw new \LogicException('Invalid controller access.');
This is to ensure nobody just types in a url to get to the route, it should only be accessed as a turbo-frame, since it's always intended to show in the modal. Anyway, when I redirect, this exception triggers, because there's no Turbo-Frame header.
If I remove the check, it just does nothing at all. Well, it does the server-side work, but it won't reload the modal. Console logging shows the correct template being returned, it's just not rendering because the header is missing the Turbo-Frame id.
I've tried setting the headers, several ways in several places (in axios, in PHP), but it doesn't ever seem to stick. The only way I've been able to get it working is having the PHP route return an empty response (instead of redirecting), and having Axios get the turbo-frame and call .reload() on it. This works, but it feels over-engineered.
Is there some other obvious step I'm missing, here?
Hey @Ryan-L!
Haha, just replied to your other message then saw this one :).
Ok, so in this case, by making the Ajax call manually (i.e. POSTing via Axios), you're going outside of the Turbo Frames system. That's ok - it just means that "you're in charge" of making any updates or handling the response. There are a few options I can think of:
A) Try to stay inside the frames system. This is basically what you want: you want this delete Ajax endpoint to go through the frame system and then have the response frame update things like normal. To do that, you would need to remove your custom JavaScript that sends the Ajax request via Axios. Instead, you would need to have a <form>
element that performs the request inside of the frame. Imagine a
<form action="/.../question/delete" method="POST">
<input type="hidden" name="question" value="{{ question.id }}">
<button>Delete Question {{ question.id }}</button>
</form>
If you put that inside of your modal turbo frame and pressed the button, everything would work: the form would submit in the frame and, after the redirect, it would find the matching frame on the next Ajax response and load it in your frame. The tricky part about this is that a <form>
can't live inside of a <form>
, so if you already have a <form>
for editing the parent question... and these children "deletes" are inside of that, it could be trouble. But, this is what I would try doing: try keeping things as boring forms. If you need a way to delete multiple questions at once - you could add a small Stimulus controller that, as you "check" a question to for delete, adds a <input type="hidden" name="questions[]" value="5" />
to a form somewhere. Then, when they click "delete" it submits that form.
B) If you need to stick with your approach of sending the Ajax request itself, then when the Ajax request finishes, you could ignore the response entirely. Then, to reload the form, you can actually find the <turbo-frame>
element (e.g. you could set it as a Stimulus target), then call this.turboFrameTarget.reload()
(ref - https://github.com/hotwired/turbo/pull/206). That effectively causes the turbo frame to "reload" its current URL, which would cause the deleted questions to be removed from the UI.
Let me know of either of these ideas helps.
Cheers!
Hey, thanks for the reply!
That final renderStreamMessage() was definitely the piece I was missing. I was able to get it working that way, but your second suggestion of just using a form, is so much easier to work with.
Using a turbo-stream required hooking up additional stimulus controllers, and functions, and actions/listeners. In many cases, the only thing the controller did was POST the data and handle the render the turbo stream. And nothing else. There was also one use case where the "drawer" had a search form, and that wasn't playing nice with the turbo-stream because of the mixed GET/POST methods involved.
Using a turbo-frame with a form just works, and it also works more nicely with external links, since all you have to do is drop the path in the link's href tag, and add the data-turbo="true" and data-turbo-frame="frameId" tags. Frames are much more simple. With streams, I had to chain a bunch of action and dispatches together...
Anyway, thanks for the suggestion!
Hey @Ryan-L!
Fantastic! I've heard it said that there's a bit of a gradient with Turbo:
A) Turbo Drive: maximum dev happiness + nice UI
B) Turbo Frames: medium dev happiness (due to more complex than Drive) = nicer UI
C) Turbo Drive: least dev happiness (due to MORE complex than frames) = nicest UI
So if you can ever solve the UI problem you have with a simpler solution, big win 👍. I think frames are underrated - I'll talk a bout them a lot in our LAST stack tutorial - https://symfonycasts.com/screencast/last-stack
Cheers!
"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": "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.21.6
"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
}
}
Okay, I need help with this.
Here's the use case: Two column page.
Col-8 on the left is a table, with a toolbar with things like a date input, and filters that apply to the table. The table is meant to pull in a single day's data from the db, and said filters can be applied to the results.
Col-4 on the left is a reporting section. It displays a totals table, and a chart. The filters don't apply to this section, but the date input does.
I want this col-4 section to load separately, because it doesn't always need to update, and I also want to break up a complicated page. I thought "hey, turbo frame or streams, right?"
Here's the stream template (I removed the template code, there is something there in the actual file).
That action listens for date changes. The stimulus controller looks like this, and this is the part I'm not sure at all about:
This is the PHP controller:
The stimulus controller definitely logs the html from the stream template on connect, AND when the reload is triggered. But the html on the page doesn't change. Is this the wrong approach? Or am I missing some obvious step or something?
I did also try updating the html directly in the stimulus controller, by using
$('#scan-stats-stream').html(response.data)
, and that doesn't work, either. I can see it in the console, but it's a grayed-out "document-fragment." It won't display. I can even dig into it, and I definitely see the table and the chart's code. So it's definitely returning the template, and even putting in in the right location, but it won't actually display.The only solution I came up with is to remove the <template> tags around the stream. That makes it work, but I think it's because I'm working around how it's supposed to work. As in, the action="replace" isn't doing anything at all, since I'm just updating it in stimulus.
How do I get this to work as intended, with an on-demand post request?