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 SubscribeIn Review.stream.html.twig
, we have the ability to publish turbo streams automatically whenever a review is created, updated or removed. That's pretty cool. Unrelated to this, I haven't mentioned it yet, but our site has a review admin area! You can get it by going to /admin/review
. Here we can create, update or delete reviews. Do you... see where this is going? Sometimes an admin user will "tweak" a review to make it... um... more encouraging. Wouldn't it be cool if, when an admin user did this, that review was instantly updated on the frontend for all users?
Uh, yea! That would be cool! So let's do it.
Start in _review.html.twig
. This is the template that renders a single Review
. Give this element an id so that we can target it from a turbo stream, how about id="product-review-{{ review.id }}"
.
<div | |
id="product-review-{{ review.id }}" | |
class="component-light my-3 p-3" | |
{% if isNew|default(false) %} | |
{{ stimulus_controller('streamed-item', { | |
className: 'streamed-new-item' | |
}) }} | |
{% endif %} | |
> | |
<p><i class="fas fa-user-circle me-2"></i>{{ review.owner.email }} <i class="fas fa-star ms-4"></i> {{ review.stars }}/5</p> | |
<div> | |
{{ review.content }} | |
</div> | |
</div> |
Copy that value and, in Review.stream.html.twig
, when the review is updated, let's add a new turbo stream: <turbo-stream>
with action="replace"
and target=""
set to product-review-{{ review.id }}
. Except that in this template, the variable is called entity
.
Inside, add the boring - but required - template
element and inside of that, include product/_review.html.twig
. This template needs a review
variable... so make sure to pass that in: review
set to entity
.
... lines 1 - 19 | |
{% block update %} | |
<turbo-stream action="replace" target="product-review-{{ entity.id }}"> | |
<template> | |
{{ include('product/_review.html.twig', { | |
review: entity | |
}) }} | |
</template> | |
</turbo-stream> | |
{% endblock %} | |
... lines 29 - 33 |
That's it! When a review is updated, it will replace this review element with the updated content.
Let's see it in action! Refresh the frontend. This is the review we'll update. Over in the admin area, add a very important dinosaur emoji... and save. Okay. I think that worked. Let me double check. Yep! Review updated.
Now check out the front end. That's amazing! This review just updated for every user in the world that is currently viewing a page where this is rendered. We could also update the quick stats area... but I'll leave that to you.
What about removing a review? In the admin area, we can actually delete a review. Could we automatically remove this element from the frontend when that happens? Absolutely!
Inside the remove
block, create a <turbo-stream>
. This will have a new action - action="remove"
- and will target
the same element as our update. Now, you might expect me to say entity.id
. But... by the time this template is rendered, the entity has already been deleted from the database. And so, entity.id
is empty.
Fortunately, the library also passes us an id
variable that we can use instead. Oh, and because we have action="remove"
, the turbo-stream
element won't have anything inside: it's just an instruction to find this element and remove it.
... lines 1 - 29 | |
{% block remove %} | |
<turbo-stream action="remove" target="product-review-{{ id }}"></turbo-stream> | |
{% endblock %} |
Ok: refresh the frontend just to be safe... and in the admin area, delete this. Now... deep breath... switch to the frontend. It's gone! Ok, this is getting fun.
So let's get fancier. What if, when a review is deleted, instead of instantly disappearing, the element turned red, then faded out. OoooOOOoo.
Start in styles/app.css
. Add a new streamed-removed-item
class that sets the background-color
to coral
.
... lines 1 - 184 | |
.streamed-removed-item { | |
background-color: lightcoral; | |
} | |
... lines 188 - 191 |
Back in Review.stream.html.twig
, this will be a bit trickier. We don't actually want to remove the element anymore... we want to keep it... but trigger some JavaScript that will fade it out.
To do this, change the action to replace
... and then copy the entire template
from update
. But this time, pass in a new variable: isRemoved
set to true
. We can use that in the template to do something special.
... lines 1 - 29 | |
{% block remove %} | |
<turbo-stream action="replace" target="product-review-{{ id }}"> | |
<template> | |
{{ include('product/_review.html.twig', { | |
review: entity, | |
isRemoved: true | |
}) }} | |
</template> | |
</turbo-stream> | |
{% endblock %} |
Go open it up: _review.html.twig
. If we pass in an isNew
variable, we already have code to activate a Stimulus controller that causes the item to get a green background that fades out. We're going to do something similar.
If isRemoved
, then initialize that same Stimulus controller. But this time pass className
set to streamed-removed-item
. This is why we made that controller dynamic. Also pass in another value called removeElement
set to true
.
<div | |
id="product-review-{{ review.id }}" | |
class="component-light my-3 p-3" | |
{% if isNew|default(false) %} | |
{{ stimulus_controller('streamed-item', { | |
className: 'streamed-new-item' | |
}) }} | |
{% endif %} | |
{% if isRemoved|default(false) %} | |
{{ stimulus_controller('streamed-item', { | |
className: 'streamed-removed-item', | |
removeElement: true | |
}) }} | |
{% endif %} | |
> | |
<p><i class="fas fa-user-circle me-2"></i>{{ review.owner.email }} <i class="fas fa-star ms-4"></i> {{ review.stars }}/5</p> | |
<div> | |
{{ review.content }} | |
</div> | |
</div> |
This will signal to the controller that we want to fade out the element entirely.
Let's get to work in that file: streamed-item_controller.js
.
Start by setting up the removeElement
value, which will be a Boolean
.
Then, import a helper function called addFadeTransition
. This is a utility that we created in the first tutorial to help us fade in or fade out an element.
To activate it, inside connect()
, call addFadeTransition()
and pass it this
object, this.element
- the element that we're going to fade - and also an options object with transitioned
set to true
. That's needed because our element will start visible and then we want it to fade out. If you want to know more about how this all works, check out our Stimulus tutorial.
Inside setTimeout()
, check to see if this.removeElementValue
is true
. If it is not, then keep the original code: this is where we fade out the background color. But if it is true, call this.leave()
. That will trigger the entire element to fade out.
import { Controller } from 'stimulus'; | |
import { addFadeTransition } from '../util/add-transition'; | |
export default class extends Controller { | |
static values = { | |
className: String, | |
removeElement: Boolean | |
} | |
connect() { | |
addFadeTransition(this, this.element, { | |
transitioned: true | |
}); | |
this.element.classList.add(this.classNameValue); | |
setTimeout(() => { | |
if (this.removeElementValue) { | |
this.leave(); | |
} else { | |
this.element.classList.add('fade-background'); | |
this.element.classList.remove(this.classNameValue); | |
} | |
}, 5000); | |
} | |
} |
Phew! Let's see this thing in action! Go back and find this review... here it is. Refresh the frontend to get the fresh CSS... delete the review... and go to the frontend! Yes! It's there but with a red background! And then... woohoo! It faded out!
The big takeaway here? By combining Turbo Streams with Stimulus, you can do much more than simply "update the HTML of an element". You can do... anything.
Okay team: there's just one more thing that I want to try: using Turbo Streams to pop up "toast" notifications on the frontend, like after we do something awesome. That's next.
"Houston: no signs of life"
Start the conversation!
// 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
}
}