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 SubscribeWe have a new mission. But before we jump in, we need to log in. Use the delightful cheating links... then head over to a product page and scroll down.
Okay: every product has reviews and we can even post a review from right here. There is nothing fancy about this: this is a normal HTML form with no custom JavaScript and no turbo frame. And, mostly, it works great! Fill out the form... and submit. Ooh, that's smooth... just because Turbo Drive is awesome.
But notice that we are taken to a different page, a /reviews
page. This is on purpose: management wants to show the reviews below each product... but they also want a dedicated "reviews" page for each product. And so, we decided to make the review form submit to this page.
This is working great... but it could be even better if, when we submit a review from the product page, we stayed on the product page. This is a type of progressive enhancement: everything is cool right now, but we're going to choose to enhance things to a higher "coolness level". Doing this is going to require two lines of code.
The template for this page is templates/product/show.html.twig
. At the bottom, the reviews are rendered via this _reviews.html.twig
template partial. Open that and scroll down to the form. The reason all of this lives in its own partial is that this is also included from the reviews page template - reviews.html.twig
. That lets us show the same list of reviews and form on both pages without duplication.
So let's think: when the "new review" form submits, we want the page to not navigate away: we want everything to happen in this reviews area. Isn't that... exactly what Turbo Frames are for? If we wrapped this entire template in a <turbo-frame>
... wouldn't that do it? I think it would!
At the top of the template, add <turbo-frame id="">
, how about, product-review
. Take the closing tag and put it on the bottom.
<turbo-frame id="product-review"> | |
{% for review in product.reviews %} | |
<div class="component-light my-3 p-3"> | |
<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> | |
{% else %} | |
<p>This product has not been reviewed yet!</p> | |
{% endfor %} | |
<hr> | |
{% if reviewForm|default(false) %} | |
<h4>Post your own review</h4> | |
{{ form_start(reviewForm, { | |
'action': path('app_product_reviews', { id: product.id }) | |
}) }} | |
{{ form_row(reviewForm.stars) }} | |
{{ form_row(reviewForm.content) }} | |
<button class="btn btn-primary" formnovalidate>Add Review</button> | |
{{ form_end(reviewForm) }} | |
{% elseif not is_granted('ROLE_USER') %} | |
<p><a href="{{ path('app_login') }}">Log in</a> to post your review</p> | |
{% endif %} | |
</turbo-frame> |
Those are the 2 lines! Testing time. Refresh, scroll to the bottom of the product show page and submit the form empty. Yes! That was perfect! We see the validation errors but we are still on the product show page. This is my favorite example yet of the power of turbo frames. With two lines of code, the entire review system is now self-contained.
Behind the scenes, when we submit this form, it does submit to the /reviews
page. You can see this down in the network tools under the Ajax calls. Here it is: this was a POST request to /reviews
.
If you look closely at the "preview" for this, it did render the full reviews page - with header, footer and all. But our turbo-frame
is smart enough to find just the product-review
frame inside this response, grab it and use it.
I love this product so much that I think we should publish another another 5 star review. When we submit... gorgeous! Our new review even popped up right above the form!
Though, hmm. There's no success message anywhere on the page. There was one before... but now it's gone! What happened?
Look back at the network tools. There are two new requests.
The first is a POST request to /reviews
. That processed our form, was successful, and returned a 302 redirect back to the same URL. This caused a second Ajax request to be made to /reviews
and this is what was used to fill in the turbo-frame
.
Look at the preview for this request closely. Near the top - here! The page does have a success message! Then, way below, we see the reviews. Can you spot the problem? The success message is being printed outside of the turbo-frame. And so, we never see it.
Fortunately, we can fix this pretty easily. Open up the controller that handles the review form submit and renders the reviews page: src/Controller/ProductController.php
. Here it is: productReviews()
.
... lines 1 - 68 | |
/** | |
* @Route("/product/{id}/reviews", name="app_product_reviews") | |
*/ | |
public function productReviews(Product $product, CategoryRepository $categoryRepository, Request $request, EntityManagerInterface $entityManager) | |
{ | |
$reviewForm = null; | |
if ($this->getUser()) { | |
$reviewForm = $this->createForm(ReviewForm::class, new Review($this->getUser(), $product)); | |
} | |
if ($request->isMethod('POST')) { | |
$this->denyAccessUnlessGranted('ROLE_USER'); | |
$reviewForm->handleRequest($request); | |
if ($reviewForm->isSubmitted() && $reviewForm->isValid()) { | |
$entityManager->persist($reviewForm->getData()); | |
$entityManager->flush(); | |
$this->addFlash('success', 'Thanks for your review! I like you!'); | |
return $this->redirectToRoute('app_product_reviews', [ | |
'id' => $product->getId(), | |
]); | |
} | |
} | |
return $this->renderForm('product/reviews.html.twig', [ | |
'product' => $product, | |
'currentCategory' => $product->getCategory(), | |
'categories' => $categoryRepository->findAll(), | |
'reviewForm' => $reviewForm?: null, | |
]); | |
} | |
... lines 104 - 113 |
Let's see: if this is a POST
request and it's successful, then we set a success
flash message. Over in templates/base.html.twig
, we already have code that renders any success
flash messages near the top of the page.
Now that we're leveraging a frame, what we really want to do is render the success message inside that frame. Back in the controller, change the flash type from success
to, how about, review_success
.
... lines 1 - 84 | |
if ($reviewForm->isSubmitted() && $reviewForm->isValid()) { | |
$entityManager->persist($reviewForm->getData()); | |
$entityManager->flush(); | |
$this->addFlash('review_success', 'Thanks for your review! I like you!'); | |
return $this->redirectToRoute('app_product_reviews', [ | |
'id' => $product->getId(), | |
]); | |
} | |
... lines 95 - 113 |
Right now, nothing is rendering review_success
flash messages. But go into the template - _reviews.html.twig
- and, above the form, render it: for flash
in app.flashes('review_success')
. Inside, and an alert div with alert-success
and print the flash
variable.
... lines 1 - 13 | |
{% for flash in app.flashes('review_success') %} | |
<div class="alert alert-success">{{ flash }}</div> | |
{% endfor %} | |
... lines 19 - 33 |
If you want to be fancier, you could isolate the flash logic from base.html.twig
into its own template and include it from both the base layout and _reviews.html.twig
. That'd be pretty sweet!
Let's go review our product one more time. Do a full page refresh just to be safe, recommend this product to all your friends, submit and... that's lovely.
Back at the top of the page, click to log out... because there is one tiny little detail left. Go back to the product and scroll down to the reviews. You need to be logged in to post a review. But when we click the "log in" link... it's busted!
Check out the console, it's a familiar error:
Response has no matching
<turbo-frame id="product-review">
element.
Of course. Refresh the page to reset things. When we click the "log in" link, it's now inside of a turbo frame. And so, Turbo makes an Ajax call to the login page and looks for a product-review
frame on that page. That is... not what we want. We want this link to target the whole page. And we know how to do that!
Over in _reviews.html.twig
, all the way on the bottom, find the link and add data-turbo-frame="_top"
.
... lines 1 - 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> | |
{% endif %} | |
... lines 32 - 33 |
Now when we refresh... and click... we're good!
Next: let's add a bonus feature to our site! Whenever any form is submitted on our site for any reason, let's automatically disable the submit button to avoid double submits.
"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
}
}