Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Extending a UX Controller

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

When we use the chartjs controller from the UX package, we build all the data for that library in PHP then pass it directly into chart.js. The Chart object we create is turned into JSON... rendered on this data-view attribute... which is ultimately read from the core controller and passed right into chart.js.

So, for the most part, if you need to customize your chart, you can do that by playing with the data in PHP. For example, click into the Chart.js library and go to "Get Started".

Configuring a Controller via data- Attributes

If you scroll down a bit, you can see that each chart has keys for type, data and options. In our controller, we're setting the data key via a nice setData() method. We can set the options in a similar way.

For example, there is apparently a scales, yAxes, ticks, beginAtZero option. Let's set that in PHP. Do that with $chart->setOptions()... and then we just need to match the options structure: scales set to an array, then yAxes, this is set to two arrays - you can see that in their JavaScript version - then ticks set to another array and beginAtZero set to true.

... lines 1 - 9
class AdminController extends AbstractController
{
... lines 12 - 14
public function dashboard(ChartBuilderInterface $chartBuilder)
{
... lines 17 - 28
$chart->setOptions([
'scales' => [
'yAxes' => [[
'ticks' => [
'beginAtZero' => true
]
]]
]
]);
... lines 38 - 41
}
}

Cool! Go back to our site. Our chart's y axis already starts at zero... so we won't see any difference. Oh, bah! A syntax error. I forgot my semicolon. Refresh now and... got it! It doesn't look any different, but if you look at the JSON on the data-view attribute, it now has our options data... which we know is eventually passed into chart.js.

So this is great! We can do many things right inside of PHP.

Adding a Second Controller to Extend the First

But... this can't handle every option. Go back to their docs, scroll down to "Developers" and click on "Updating Charts". You might have a situation where you need to update the data after it's rendered onto the page. This is easy in JavaScript: as long as you have that Chart object: change its data and call chart.update().

But how could we do that in our situation? In the core controller, it does create this Chart object... but we have no way to hook into the process and access that. Or do we?

In the connect() method of the core Stimulus controller, it does something very interesting: it dispatches an event! Actually two events: chartjs:pre-connect - where it passes the options on the event - and chartjs:connect - where it passes the Chart object itself!

How can we hook into these events? By creating a second controller that listens to them. Open assets/controllers/. Create a new file called, how about, admin-chartjs_controller.js. We'll start the same way as always. In fact, let's cheat: copy the inside of counter_controller.js and paste. Then add our normal connect() method with console.log() a chart.

import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
console.log('📈');
}
}

Multiple Controllers on an Element

Next, in the template, add a second controller to the element. But, hmm. The render_chart() function is responsible for rendering this <canvas> element. Now we need to pass a second data-controller to this. How can we do that?

The answer is that render_chart() has an optional second argument: an array of additional attributes for the element. Pass data-controller set to the name of our controller, which is admin-chartjs. Oh, but I should probably write Twig code here... not PHP.

... lines 1 - 2
{% block body %}
... lines 4 - 101
<div class="col-10 mt-4">
<h1>Admin Dashboard</h1>
{{ render_chart(chart, {'data-controller': 'admin-chartjs'}) }}
</div>
... lines 107 - 108
{% endblock %}

Okay! Move over and hit refresh. The graph is still there and... yes! It looks like our new controller is connected!

Inspect the chart again. Interesting. You can't have two data-controller attributes on the same element. Fortunately, Stimulus does allow us to have 2 controllers on the same element by having one data-controller attribute with each controller separated by a space. The render_chart() function took care of doing that for us.

Hooking into the Core Controller via JavaScript

So here's the goal. Let's pretend that we need our new Stimulus controller to change some of the data on this chart and then re-render it. Maybe... we make an Ajax call every minute for fresh data.

This means we need the Chart object that's in the core controller. And that means we need to listen to the chartjs:connect event.

How do we do that? We already know that custom events are no different than normal events. And in this case, the event is being dispatched on this.element: the canvas element. We can add an action to that.

Over on render_chart(), I'll break this onto multiple lines. Add another attribute: data-action set to the name of the event - I'll go copy that from the core controller, chartjs:connect, an arrow, the name of our custom controller - admin-chartjs - a pound sign and then the name of the method to call when this event happens. How about onChartConnect?

... lines 1 - 101
<div class="col-10 mt-4">
<h1>Admin Dashboard</h1>
... line 104
{{ render_chart(chart, {
'data-controller': 'admin-chartjs',
'data-action': 'chartjs:connect->admin-chartjs#onChartConnect'
}) }}
</div>
... lines 110 - 113

Copy that and head into our custom controller. Rename connect() to onChartConnect(), give it an event object, and console.log(event).

... lines 1 - 2
export default class extends Controller {
onChartConnect(event) {
console.log(event);
}
}

Alright! Let's see if it works! Refresh, check the console and... we got it! There's the custom event! Expand it. I love this: it has a detail property, with the chart object inside.

Back in our controller, we now have access to the Chart object! And so we are infinitely dangerous. To test this out, let's see if we can let the chart load, wait 5 seconds, then update some data.

Start by assigning the chart to a property so we can use it anywhere: this.chart = event.detail.chart.

... lines 1 - 2
export default class extends Controller {
onChartConnect(event) {
this.chart = event.detail.chart;
... lines 6 - 9
}
... lines 11 - 15
}

Then, at the bottom, and a new method that will, sort of, fake making an Ajax request for the new data and updating the chart. I'll call it setNewData(). Inside, say this.chart.data.datasets[0].data[2] = 30 and then this.chart.update().

... lines 1 - 11
setNewData() {
this.chart.data.datasets[0].data[2] = 30;
this.chart.update();
}
... lines 16 - 17

This first line might look a little crazy... but if you look at their docs, this is how you can access your datasets. Let me go to the data we created in our PHP controller: we have a single "dataset". So we're finding the 0 index to get this dataset, which is this stuff, finding the data key, finding the element with index 2, and changing it to 30. So that should change the 5 up to 30.

Back in the Stimulus controller, up in onChartConnect() call setTimeout(), pass that an arrow function, wait 5 seconds and then call this.setNewData().

... lines 1 - 3
onChartConnect(event) {
... lines 5 - 6
setTimeout(() => {
this.setNewData();
}, 5000)
}
... lines 11 - 17

Moment of truth. Head over, go back to our site and reload the page. Here's the chart. Waiting... ha! It updated! March jumped up to 30!

This was all possible thanks to the fact that the chartjs core Stimulus controller dispatches these events. That gives us 100% control over its behavior.

And this isn't something unique to this one controller. This is a pattern that many of the UX libraries follow.

Next: if you've been wondering how things like React or Vue.js fit into Stimulus, wait no longer! The answer is that, while you might choose to use them less, if you do want to build something in React or Vue, they work beautifully with Stimulus.

Leave a comment!

11
Login or Register to join the conversation
Klaus-M Avatar
Klaus-M Avatar Klaus-M | posted 25 days ago | edited

Hello there,

I want to update a chart with a select field value. Therefore, I'd like to dispatch a custom event.
But unfortunately the event isn't recognized by the controller.

Here is some code:

{{ render_chart(chart, {
    'data-controller': 'dashboard-chart',
    'data-action': 'chart:update->dashboard-chart#updateChart'
}) }}

The controller that fires the event doesn't surround the chart controller, but I guess this isn't needed.
Can you please help me? Thanks in advance.

Reply

Hey @Klaus-M!

Hmm. So, when a chart:update event is dispatched, this will execute the updateChart() method on your custom dashboard-chart controller. Question: who/what dispatches this chart:update event? It sounds like it's a different custom controller?

The controller that fires the event doesn't surround the chart controller, but I guess this isn't needed

Whatever fires the chart:update event must be inside the chart element... which actually isn't possible (because it's not possible to put anything inside of the element that render_chart() renders). This is because events "bubble up": you trigger an event (e.g. chart:update) on some element, and it goes up the element tree one-by-one looking for listeners. In this case, when it bubbles "up", this chart element is not a parent/ancestor, so that event never reaches this element.

Here's what I would do:

A) Remove the data-controller stuff from render_chart(). Instead, find some HTML element that surrounds both your chart and wherever your chart:update is fired from. Add the dashboard-chart controller to that element via {{ stimulus_controller() }} (and also move the data-action there to via stimulus_action().

B) And... that should be it! My guess is that you are already listening to the chartjs:connect event from inside of dashboard-chart controller so that you can gain access to the Chart instance. Even though your dashboard-chart controller is now a parent/ancestor of the "chart" element, your chartjs:connect listener will still be called on dashboad-chart (the core chartjs UX controller dispatches this event, then it will "bubble up" eventually to the element that holds your custom controller).

Let me know if this helps!

Cheers!

Reply
Peter L. Avatar
Peter L. Avatar Peter L. | posted 1 year ago | edited

Not sure what changed in the configuration of Chart.js or SymfonyUX/ChartJS, but 'yAxes' params are now simple array only. Otherwise they are not working and throwing error 'Invalid scale configuration for scale: yAxes' in console . But the chart itself will still work.
For example to see if it is working ['yAxes' => ['ticks' => ['color' => 'blue']]]

Reply

Hey Peter L.!

Sorry for the slow reply. Yes, that changed in chart.js 3... and you're right that it looks like I completely forgot to add a note about that. I'll do that!

Thanks!

Reply

Do you have example how we can add Chart to Modal and this chart will have datetime setting - range ?

Reply

Hey Mepcuk!

Hmm. Let me answer that in 2 parts:

and this chart will have datetime setting - range

For this part, you need to find the exact Chart.js config that you need to tweak from their docs... then update that data when you create the chart (similar to what we do here): https://symfonycasts.com/screencast/stimulus/extend-chartjs#codeblock-f48ad9bdb1

how we can add Chart to Modal

For this part, later in this tutorial, we discuss opening a modal and AJAX-loading in an HTML form - https://symfonycasts.com/screencast/stimulus/modal-form

In your situation, you would do much of the same thing. The difference would be that instead of returning an HTML form from your AJAX endpoint, you would return a template that basically has this in it:


{{ render_chart(chart) }}

The magic of Stimulus takes over from there: as soon as this HTML appears on the modal, the chart UX controller would take care of the rest.

If you wanted the "datetime" from the first part of your question to be dynamic (e.g. maybe the user selects a datetime from a field and then click "show chart"), then when you make the AJAX call to the "chart" endpoint, you would send that up as an extra field - e.g. to an endpoint like /sales/chart?startDate=2020-01-01 (were the 2020-01-01 is a value you read from some date field).

Let me know if that helps!

Cheers!

Reply
Dimitri Avatar

Hi Ryan!
I'm very interested in this point (dynamic date range on a chart) but still struggle to do it. I built a stimulus controller that handles the chart, I'm abble to get the dates set by the user from this controller but still have to get datas from my database in ordrer to rebuilt the chart. In first place from my symfony controller, I call a service that get back datas but from my stimulus controller I'm definitively lost!
Could you give some help please?

Cheers!

Reply

Hey Dimitri,

I think you have two options:

A) Pass the dates to your Stimulus controller in the traditional way. Fetch the data from the database from a Symfony controller action, and pass it to a Twig template, and the template will pass the dates to Stimulus as a value https://stimulus.hotwired.dev/reference/values

B) Fetch the dates from your Stimulus controller by making an API call

I hope it helps. Cheers!

Reply
Dimitri Avatar

Hi MolloKhan!
Thanks very much for your time and your tips.
I'm gonna dig around the API call!
Cheers!

Reply

I think I found an easter egg for those lazy copy&pasters...
In the video you set the new value to 30 while in the code block below it is set to 3000 making the transition somewhat "shocking" ;)

Reply

Hey elkuku

Lol - that's hilarious! That's why you should not copy-paste blindly... but seriously, that's our fault. I'll fix that
Thanks for letting us know. Cheers!

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": ">=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
    }
}

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