Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Multiple Updates in one Stream

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 goal is to be able to update the quick stats area and the reviews area all at once. We can't do that by redirecting or returning a normal HTML page... because that would only affect the reviews frame. So let's continue to return a stream... but a stream where we update the quick stats area and the reviews.

The entire content of _reviews.html.twig lives inside of an element with a product-review id. So, in reviews.stream.html.twig, add a second <turbo-stream>. Yup, we can include as many instructions as we want in a stream. Set the action="" to replace and the target to product-review, the id of the element that surrounds the reviews area. Inside, include the reviews template. Oh, but don't forget to include the <template> element - I'll remember that in a minute.

<turbo-stream action="update" target="product-quick-stats">
<template>
{{ include('product/_quickStats.html.twig') }}
</template>
</turbo-stream>
<turbo-stream action="replace" target="product-review">
<template>
{{ include('product/_reviews.html.twig') }}
</template>
</turbo-stream>

We're using replace instead of update because _reviews.html.twig contains the target. So we want to replace the existing product-review element with the new one... instead of just updating its innerHTML.

Before we try this, I'll go back to reviews.stream.html.twig and add the <template> element. If you do forget this, you'll get a clear error that says that a template element was expected.

Ok: move over and refresh. Let's add another glowing review... and submit. Yes! It worked! I see my new review! But... the form is gone.

Adding a Success Message

As so often happens... this makes total sense. Before, the frame was being redirected to the reviews page. So it was being redirected to this page here... and this page contains a fresh form. So, naturally, the fresh form showed up at the bottom of the reviews frame after successfully submitting a review.

But now, over in reviews.stream.html.twig, when we render _reviews.html.twig, if you look at that template, we are not passing in a reviewForm variable. And I already have logic here that checks to see if that variable exists and conditionally renders the form. So, in our case, it renders nothing.

We could create a reviewForm object in the controller and pass it into here. But, I kind of like this... except that having a success message would help a lot.

So let's see: we check for reviewForm and we also check to see if the user is not logged in. Add an else on the bottom with a success alert. In our situation, the only way to get here is if the form was just submitted successfully. But you could also pass a success variable to the template to be more explicit.

<turbo-frame id="product-review">
... lines 2 - 18
{% if reviewForm|default(false) %}
... lines 20 - 28
{% elseif not is_granted('ROLE_USER') %}
<p><a href="{{ path('app_login') }}" data-turbo-frame="_top">Log in</a> to post your review</p>
{% else %}
<div class="alert alert-success">
Thanks for your "real" review you "human" ?!
</div>
{% endif %}
</turbo-frame>

Anyways, let's test this thing out with another glowing review. When we submit... that's lovely.

I'm having too much fun so here's a challenge. Imagine we want to add a link below this success message to "Add another review". When we click it, it should load a fresh form right into the frame. How could we do that?

Well... that's almost disappointingly easy! Remember: we're inside of a turbo-frame... so all we need to do is add a link in the frame that navigates us to the review page... because the review page renders this frame with a fresh form!

Check it out: right after the success message, add an anchor tag with {{ path() }} to generate a URL to the app_product_reviews route. This needs an id wildcard set to product.id. Put some text inside.

<turbo-frame id="product-review">
... lines 2 - 18
{% if reviewForm|default(false) %}
... lines 20 - 28
{% elseif not is_granted('ROLE_USER') %}
<p><a href="{{ path('app_login') }}" data-turbo-frame="_top">Log in</a> to post your review</p>
{% else %}
<div class="alert alert-success">
Thanks for your "real" review you "human" ?!
</div>
<a href="{{ path('app_product_reviews', {
id: product.id
}) }}">Love the product *that* much? Add another review!</a>
{% endif %}
</turbo-frame>

Move back over, refresh... and, once again, profess your love - or maybe disgust - for this product: your call. Submit. There's our success message. When we click this normal link... yes! That was awesome! Go team streams and frames!

Checking for the Stream "Accept" Request Header

Finally, there's one last detail I want to handle... and it's minor. Imagine if, for some reason, this review form were submitted without JavaScript. And so it performs a normal full page submit, not a submit through Turbo.

Until now, that was totally okay! Our controller saves the new review and then redirected to a legitimate page. But now we're returning this bizarre stream HTML... which our browser wouldn't know what to do with... it would probably just render it onto the page... which is not great!

Fortunately, whenever Turbo makes an Ajax request, it adds an Accept header to the request that advertises that it supports Turbo streams. We can check for that in the controller.

Here's how it looks: wrap our stream render with if TurboStreamResponse::STREAM_FORMAT equals $request->getPreferredFormat().

Tip

In symfony/ux-turbo 2.1 and higher, this code has changed:

if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
    $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

    return $this->render('product/reviews.stream.html.twig', [
        'product' => $product,
    ]);
}

... lines 1 - 18
class ProductController extends AbstractController
... lines 20 - 69
/**
* @Route("/product/{id}/reviews", name="app_product_reviews")
*/
public function productReviews(Product $product, CategoryRepository $categoryRepository, Request $request, EntityManagerInterface $entityManager)
{
... lines 75 - 80
if ($request->isMethod('POST')) {
... lines 82 - 85
if ($reviewForm->isSubmitted() && $reviewForm->isValid()) {
... lines 87 - 89
if (TurboStreamResponse::STREAM_FORMAT === $request->getPreferredFormat()) {
return $this->render('product/reviews.stream.html.twig', [
'product' => $product,
], new TurboStreamResponse());
}
$this->addFlash('review_success', 'Thanks for your review! I like you!');
return $this->redirectToRoute('app_product_reviews', [
'id' => $product->getId(),
]);
}
}
... lines 103 - 109
}
... lines 111 - 120

That's it. This preferred format thing basically looks at the Accept request header to see if the request supports turbo streams. All Ajax requests made through Turbo send this header.

If the request does support streams, then... we return a stream! If it doesn't, we do our normal behavior: redirect the page. So once again, this will work fine without JavaScript. Also, even though I've not done any work with it yet, Turbo can also be used to build Native iOS or Android apps: you can read about it in their docs. Streams don't really make sense in that context, so coding like this also makes sure your code supports native apps... if you ever choose to go in that direction.

Next: let's have some fun with Turbo Streams! I want to see if we can create and process them manually in JavaScript. Apart from being cool, this will give us a better understanding of how streams work and a better appreciation for the next big part of streams that we'll discuss after.

Leave a comment!

10
Login or Register to join the conversation

It seems that now it's no longer

$request->setFormat(TurboBundle::STREAM_FORMAT);

but

$request->setRequestFormat(TurboBundle::STREAM_FORMAT);

related to <a href="https://github.com/symfony/ux-turbo/commit/3f59eadd9e5b6d282031c4ab0fa699b201027511&quot;&gt;this commit </a>

Reply

Thanks jgrasp - it's on my list to add a note about this! I asked for the latest tag to be created for that library... but haven't had a chance to add this note yet!

EDIT: Nevermind, I DID have a note, but you're correct that I still had this mistake in my updated code. This was also just fixed on the main repo. Thanks!

Reply
Jarrett Avatar
Jarrett Avatar Jarrett | posted 1 year ago

Another way to skin the cat.
In the real world you're only allowing one review per product per user. So, move the addFlash to before returning the TurboStreamResponse (I have a print 'review_success' flash in my _product_reviews twig, so it will show up in the stream response). The _reviews template more-or-less stays the same except I add a messages saying "you have already reviewed this product" or you could have a link to EDIT your review if the "form" variable isn't passed. There are some other "handle yourself" coding here, like determining in twig one review per product per user (no good performance / reusable way to do this, unless perhaps you create a whole new entity to keep track of product/review/user but that seems like a mess, you have to query for if this product has been reviewed by this user, put it in a service if you use it more than twice). I'm sandboxing mix/match code for how I would use this code for my projects as I move along here. So far I haven't thought of a situation where I wouldn't just ternary this in the render options array, or if/else for readability depending on how long the line is. Your results may vary.

Reply

Hey @Jay Gee!

Thanks for the post! I think Turbo Streams (and frames) are still so new that there are undoubtedly better ways to do certain things - I was pushing things pretty far, but mostly thinking of the "best ways that *I* could think of doing something"... and I almost definitely didn't always end up with the best way. I'm glad you're thinking critically to challenge what I came up with :).

Cheers!

Reply
Nick-F Avatar
Nick-F Avatar Nick-F | posted 1 year ago | edited

Instead of adding another else statement to the template you can also add an "append" turbo stream to the reviews frame to add a button within the turbo frame tags:


<turbo-stream action="append" target="reviews-frame">
	<template>
		<div>
			&lt;a href="{{ path('app_product_reviews', {id: product.id}) }}" class="btn btn-success">
				Review Again!
			&lt;/a>
		</div>
        </template>
</turbo-stream>
Reply

That's very nice! 😀

Reply
Gabs Avatar

"Turbo can also be used to build Native iOS or Android apps". Now that has me convinced. Does this mean one could take a mostly PHP powered website and with some JS/Turbo sprincles make a mobile App? I's love to see an example of that even if it's just a Hello World or To Do List App.

Reply

Hey Matthias K.!

That's.... an interesting idea! It's a bit outside of my wheelhouse... but it could be kind of fun to try. We'll add it to our idea list - but it wouldn't be something that would happen too soon :).

Cheers!

1 Reply
Michael S. Avatar
Michael S. Avatar Michael S. | posted 2 years ago

I wonder if this would also be a good use to move the logic for a faceted search completely to the backend or might there be a limit where it might be too many templates in a stream response?

Reply

Hi, Michael!

To my knowledge, there is no limit as to how many templates you can return from a request. This might be a matter of practice: When would a response become too large, or the number of templates so big that it would take noticeable time for the JavaScript to parse it. In a real world scenario, I can't imagine this becoming an issue!

1 Reply
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": "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
    }
}

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