Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Ajax-Powered HTML Updates & a CSS Transition

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

Time to make the Ajax call. In our new stimulus controller - cart-list - add a value called cartRefreshUrl, which will be a String. We're doing this, like we've done before, to avoid hardcoding the URL to the endpoint.

... lines 1 - 2
export default class extends Controller {
static values = {
cartRefreshUrl: String,
}
... lines 7 - 11
}

Fetching the Fresh HTML

Copy cartRefreshUrl, go to cart.html.twig, add a second argument to stimulus_controller() and set cartRefreshUrl to path() and the name of our route: _app_cart_list.

... lines 1 - 2
{% block body %}
... lines 4 - 13
<div
... line 15
{{ stimulus_controller('cart-list', {
cartRefreshUrl: path('_app_cart_list')
}) }}
>
... line 20
</div>
... lines 22 - 24
{% endblock %}
... lines 26 - 27

Making the Ajax call is probably the easiest part. Down in removeItem(), say const response = await fetch(this.cartRefreshUrlValue). And, of course, as soon as we use await, we need to make the method async. Finish by replacing the entire HTML of this element with the response text: this.element.innerHTML = await response.text().

... lines 1 - 2
export default class extends Controller {
... lines 4 - 7
async removeItem(event) {
const response = await fetch(this.cartRefreshUrlValue);
this.element.innerHTML = await response.text();
}
}

We're done! Testing time. Oh, but an empty cart is no fun... let's add a few more items. And... excellent! Remove the red sofa, confirm and... oh! That was awesome! We get the entire, no-full-page-refresh experience with zero duplication and minimal JavaScript. I mean, check out how big the controller is! It's teenie tiny!

Stimulus Re-Initialized on the new HTML

And a super important, amazing thing just happened automatically. We add new HTML to the page. In fact, all of the HTML inside of this element is brand new. Normally, with JavaScript, that's a problem: any event listeners that we need on the elements - like a submit listener that opens a dialog - need to be manually reattached to the new elements.

But with Stimulus, it all... just works! We talked about this earlier. As soon as Stimulus saw these two new data-controller="submit-confirm" elements on the page, it instantiated two fresh new submit-confirm controllers. And everything behaves perfectly. Watch: if we click remove... that still works! We don't need to think about anything.

A Simple CSS Transition

I'm so excited about this that I want to add one last tiny extra detail to make it really smooth. I want to make the row fade out before it disappears. We can do this with a CSS transition.

Open up assets/styles/app.css and scroll down a bit: I'm looking for cart-item. Here it is. This is the class that's around each cart row. Add transition: opacity 500ms

... lines 1 - 86
.cart .cart-item {
... line 88
transition: opacity 500ms;
}
... lines 91 - 150

That doesn't actually make it transition. This just says: if the opacity ever changes... for any reason, I want you to change the opacity gradually to the new value over 500 milliseconds.

Below this, add another .cart .cart-item with .removing and set opacity: 0.

... lines 1 - 91
.cart .cart-item.removing {
opacity: 0;
}
... lines 95 - 150

This says, if the cart-item element also has a removing class, change the opacity to zero. Thanks to the transition, that change will happen gradually.

And where does this removing class come from? Good question! We are going to add it.

Back in the controller, right at the beginning, add event.currentTarget. That will get us the element that's around the entire row: this element here... which has the cart-item class on it. Then .classList.add('removing').

... lines 1 - 2
export default class extends Controller {
... lines 4 - 7
async removeItem(event) {
event.currentTarget.classList.add('removing');
... lines 10 - 12
}
}

Try it! Refresh. Let's delete the blue sofa. Watch closely. Yes! It was quick, but it faded out before it was replaced. Remove the last one. That's so cool.

If your server is super fast, the fading out might not finish before the HTML reloads. If you care enough, you could delay the Ajax call a few milliseconds with setTimeout() or get super fancy with some extra promises.

Later, we'll talk about how to add CSS transitions in a different, more robust way. But this was easy and works nicely!

Next, we've talked a lot about Stimulus. But isn't this also a tutorial about Symfony UX? What is that? And how does it fit in? Let's find out by adding a JavaScript-powered chart to our page... by only writing PHP code.

Leave a comment!

2
Login or Register to join the conversation
Peter M. Avatar
Peter M. Avatar Peter M. | posted 1 year ago

Hey Ryan,

First of, loving it great series!

I noticed that the `cart-item` counter holds the number of items in the cart on top of the page. This however does not update since it is not within the extracted new twig template.

To achieve this, with my limited knowledge I see three options:

1) Make a second controller, that just fetches the number and replaces it. This would be two API calls when removing an item.
2) Return the entire page, but that seems a bit overkill.
3) Somehow extend the '_app_cart_list` to not only return the html, but also the new cart number. After we could dispatch in cart-list_controller an event to another controller to update the cart-item-number

What would be the recommended way to achieve this?

Reply

Hi Peter!

This is one of the situations in which we wish that two Stimulus controllers could talk to each other. You can try to achieve this using the Stimulus Use package and send events from one controller so that another one can update. You would have to have one "main" controller (possibly spanning the entire page) to "catch" an event sent by the cart-list_controller and then send down an action onto the new controller in the header.

This however, in my personal experience, has been buggy or not 100% reliable. Another, in my opinion, easier way to achieve this is to just make the cart-list_controller into a more general "cart_controller" that spans the entirety of the DOM structure in the page. The set 2 targets, the one for the cart list and the one for the header. On changes to the cart, the one controller can choose to change the list (if present) or just the header.

I agree having one controller and 2 API calls for just updating the header might be a bit overkill, but it's not a bad solution either.

I hope this helped! Let me know if you have more questions!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works perfectly with Stimulus 3!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "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
    }
}

What JavaScript libraries does this tutorial use?

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