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 SubscribeThanks to the useDispatch
behavior, after the delete form submits via Ajax and finishes, our submit-confirm
controller dispatches a custom event called submit-confirm:async:submitted
. Copy that event name: we'll need it in a few minutes.
This is awesome because we can listen to that event from our other controller so that we can run code whenever an item is removed. At the top, we can remove that debug
flag now that we know the event is dispatching correctly.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 13 | |
connect() { | |
useDispatch(this); | |
} | |
... lines 17 - 51 | |
} |
So: how can we listen to the new event from inside cart-list
controller? Well, think about how a normal event works. If I click a link, for example, this checkout link, my browser first calls any click
listeners attached to the thing I actually clicked. So, the <a>
tag. Then, the event bubbles up. That's a fancy way of saying that the browser next calls any click
listeners on the element above this: this <div>
. Then... it calls any click
listeners on the element above that... and then above that... and then above that... all the way until it gets to the body
.
Whelp, our custom event is no different. When you call dispatch()
, it dispatches the event on the element
attached to the controller, though that's configurable.
For us, it means that the event is being dispatched on the form
element. When that happens, our browser first checks to see if there are any listeners to our submit-confirm:async:submitted
event on the form
element. Then it bubbles up: checking each element up the tree to see if each has any listeners to our custom event.
This means two things for us. First, our custom event is no different than a click
event. So to listen to it, we can use a Stimulus action. And second, we can attach that action to the form
element or to any of its ancestors.
So... where should we add the action? Over in the template, find the div that's around the row for a single item: it's this cart-item
element.
Add the action here. I'll pop things onto multiple lines... and then say data-action=""
, the name of our event - paste submit-confirm:async:submitted
- an ->
, the name of our controller - cart-list
- a #
sign, and finally the name of the method to call when that event happens. How about removeItem
.
... lines 1 - 2 | |
{% block body %} | |
... lines 4 - 30 | |
{% for item in cart.items %} | |
<div | |
class="cart-item row p-3" | |
data-action="submit-confirm:async:submitted->cart-list#removeItem" | |
> | |
... lines 36 - 71 | |
</div> | |
... lines 73 - 76 | |
{% endfor %} | |
... lines 78 - 96 | |
{% endblock %} | |
... lines 98 - 99 |
Why am I adding the action to this exact div? Well, it won't matter at first. But in a few minutes, it'll give us the ability to access this div
and add extra logic to make it fade out.
Over in the controller, rename connect()
to removeItem()
, give it an event argument, and let's console.log()
our very favorite event.currentTarget
.
... lines 1 - 2 | |
export default class extends Controller { | |
removeItem(event) { | |
console.log(event.currentTarget); | |
} | |
} |
Ok team: let's find out if our controllers are communicating. Refresh, hit remove and confirm. Over in the console... yes! It hit our new log and the currentTarget
is the div
around the removed row.
What we really want to do in this method is make an Ajax call to an endpoint that will return the new HTML for the entire cart area. We can create that endpoint with some clever organization.
Start in the template. Copy all of the HTML that's inside of our cart area. So everything that's inside of the cart-list
controller element: this div
... all the way down to the end. Yep, that looks right.
Now create a new template in templates/cart/
called, how about _cartList.html.twig
, and paste.
<div> | |
<div class="row p-3"> | |
... lines 3 - 13 | |
{% for item in cart.items %} | |
<div | |
class="cart-item row p-3" | |
data-action="submit-confirm:async:submitted->cart-list#removeItem" | |
> | |
... lines 19 - 54 | |
</div> | |
... lines 56 - 59 | |
{% endfor %} | |
... lines 61 - 66 | |
</div> | |
... lines 68 - 76 |
Back in the original template, include that with {{ include('cart/_cartList.html.twig') }}
.
... lines 1 - 2 | |
{% block body %} | |
... lines 4 - 13 | |
<div | |
class="component-light p-3" | |
{{ stimulus_controller('cart-list') }} | |
> | |
{{ include('cart/_cartList.html.twig') }} | |
</div> | |
... lines 20 - 22 | |
{% endblock %} | |
... lines 24 - 25 |
This won't change anything yet: our page still works like it did before.
But now we can add a route & controller that returns just this template partial.
Open src/Controller/CartController.php
. This is the controller that renders the shopping cart page. Right below that method, add another one: public function _shoppingCartList()
.
I'm keeping with the convention of prefixing my template partials - or even controllers that return a "fragment" of HTML - with an underscore. Above this, add @Route()
and set the URL to be /cart
- to match what's above - and then /_list
. Name the route _app_cart_list
.
... lines 1 - 18 | |
class CartController extends AbstractController | |
{ | |
... lines 21 - 37 | |
/** | |
* @Route("/cart/_list", name="_app_cart_list") | |
*/ | |
public function _shoppingCartList(CartStorage $cartStorage) | |
{ | |
... lines 43 - 45 | |
} | |
... lines 47 - 105 | |
} |
Beautiful! To render the new template, we need one variable: cart
... which we get via this CartStorage
service that's custom to our project. Copy that argument, paste it down here, and return $this->render('cart/_cartList.html.twig')
passing a cart
variable set to $cartStorage->getOrCreateCart()
.
... lines 1 - 40 | |
public function _shoppingCartList(CartStorage $cartStorage) | |
{ | |
return $this->render('cart/_cartList.html.twig', [ | |
'cart' => $cartStorage->getOrCreateCart(), | |
]); | |
} | |
... lines 47 - 107 |
We're done! Go try it in the browser by going directly here. So /cart/_list
and... got it! Hit back.
Next: let's update our controller to make an Ajax call to this endpoint and replace the entire cart area with fresh HTML. After we do that, will we need to, somehow, re-initialize our Stimulus controllers on the new HTML elements?
And, as a bonus, we'll add a basic CSS transition to really make things shine.
Hey @Alcides!
Wow, that's super weird! It honestly smells like a bug - Stimulus is normally smart enough to not care if new content is loaded via Ajax later onto the page. So, I believe you - but this is very strange indeed. Good workaround though :).
Cheers!
// 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.11.1
"doctrine/doctrine-bundle": "^2.2", // 2.2.3
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
"doctrine/orm": "^2.8", // 2.8.1
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"sensio/framework-extra-bundle": "^5.6", // v5.6.1
"symfony/asset": "5.2.*", // v5.2.3
"symfony/console": "5.2.*", // v5.2.3
"symfony/dotenv": "5.2.*", // v5.2.3
"symfony/flex": "^1.3.1", // v1.18.5
"symfony/form": "5.2.*", // v5.2.3
"symfony/framework-bundle": "5.2.*", // v5.2.3
"symfony/property-access": "5.2.*", // v5.2.3
"symfony/property-info": "5.2.*", // v5.2.3
"symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
"symfony/security-bundle": "5.2.*", // v5.2.3
"symfony/serializer": "5.2.*", // v5.2.3
"symfony/twig-bundle": "5.2.*", // v5.2.3
"symfony/ux-chartjs": "^1.1", // v1.2.0
"symfony/validator": "5.2.*", // v5.2.3
"symfony/webpack-encore-bundle": "^1.9", // v1.11.1
"symfony/yaml": "5.2.*", // v5.2.3
"twig/extra-bundle": "^2.12|^3.0", // v3.2.1
"twig/intl-extra": "^3.2", // v3.2.1
"twig/twig": "^2.12|^3.0" // v3.2.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
"symfony/debug-bundle": "^5.2", // v5.2.3
"symfony/maker-bundle": "^1.27", // v1.30.0
"symfony/monolog-bundle": "^3.0", // v3.6.0
"symfony/stopwatch": "^5.2", // v5.2.3
"symfony/var-dumper": "^5.2", // v5.2.3
"symfony/web-profiler-bundle": "^5.2" // v5.2.3
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.12.13
"@popperjs/core": "^2.9.1", // 2.9.1
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.0.4
"bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
"core-js": "^3.0.0", // 3.8.3
"jquery": "^3.6.0", // 3.6.0
"react": "^17.0.1", // 17.0.1
"react-dom": "^17.0.1", // 17.0.1
"regenerator-runtime": "^0.13.2", // 0.13.7
"stimulus": "^2.0.0", // 2.0.0
"stimulus-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
"stimulus-use": "^0.24.0-1", // 0.24.0-1
"sweetalert2": "^10.13.0", // 10.14.0
"webpack-bundle-analyzer": "^4.4.0", // 4.4.0
"webpack-notifier": "^1.6.0" // 1.13.0
}
}
Hi Ryan, nice work!!
Just a note. When I have nested stimulus controllers and the child one inside a turbo frame like this:
The method_name in controller_2 is executed only when the frame is loaded for the first time, but if the same content is reloaded in the frame through Turbo frame mechanism, the event is no longer propagated correctly when the button is clicked. For it to work I have to put the div that captures the event as the parent of the frame like this:
Or this way using @window:
Cheers.