Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Stimulus JavaScript 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

We just created a Stimulus controller. Now we need to apply this controller to the "row" that's around each field. Let me make things a bit smaller. So we're going to apply the controller to this row. The code in the controller will watch the textarea for changes and render a preview.

The whole flow looks like this. When that row first appears on the page, the initialize() method will add a preview div. Then, whenever we type into the field, Stimulus will call render()... which will render the HTML preview. We're not going to talk more about the Stimulus code, but if you have any questions, let us know in the comments.

Thanks to the fact that admin.js is importing bootstrap.js, which initializes all of the controllers in the controllers/ directory, our new snarkdown_controller is already available in the admin section. So, we can get to work!

On the field, call setFormTypeOptions() and pass this an array. We need to set a few attributes. The first is row_attr: the attributes that you want to add to the form "row". This is not an Easy Admin thing... it's a normal option inside Symfony's form system. Add a data-controller attribute set to snarkdown. I did just typo that, which is going to totally confuse future me.

Next pass an attr option: the attributes that should be added the textarea itself. Add one called data-snarkdown-target set to input. In Stimulus language, this makes the textarea a "target"... so that it's easy for us to find. Also add data-action set to snarkdown#render.

... lines 1 - 14
class QuestionCrudController extends AbstractCrudController
{
... lines 17 - 30
public function configureFields(string $pageName): iterable
{
... lines 33 - 43
yield TextareaField::new('question')
->hideOnIndex()
->setFormTypeOptions([
'row_attr' => [
'data-controller' => 'snarkdown',
],
'attr' => [
'data-snarkdown-target' => 'input',
'data-action' => 'snarkdown#render',
],
]);
... lines 55 - 74
}
}

This says: whenever the textarea changes, call the render() method on our snarkdown controller.

Let's try this! Move over and refresh... and type a little... hmm. No preview. And no errors in the console either. Debugging time! Inspect the element. Bah! A typo on the controller name... so the controller was never initialized.

Fix that - snarkdown - and now when we refresh, there it is! It starts with a preview... and when we type... it instantly updates to show that as bold. Awesome!

Though, we could style this a bit better... and fortunately we know how to add CSS to our admin area. In admin.css, add a .markdown-preview selector. This is the class that the preview div has when we add it. Let's give this some margin, a border and some padding.

... lines 1 - 6
.markdown-preview {
margin-top: 10px;
border: 2px dashed #da3735;
padding: 5px;
}

And now... neato! And to make this even cooler, in QuestionCrudController, on the field, call ->setHelp('Preview').

... lines 1 - 14
class QuestionCrudController extends AbstractCrudController
{
... lines 17 - 30
public function configureFields(string $pageName): iterable
{
... lines 33 - 43
yield TextareaField::new('question')
... lines 45 - 54
->setHelp('Preview:');
... lines 56 - 75
}
}

Help messages render below the field... so... ah. This gives the preview a little header.

Making Admin Controllers Lazy

So with the combination of Stimulus and an admin.js file that imports bootstrap.js, we can add custom JavaScript to our admin section simply by dropping a new controller into the controllers/ directory.

This does create one small problem. Every file in the controllers/ directory is also registered and packaged into the built app.js file for the frontend. This means that users that visit our frontend are downloading snarkdown_controller and snarkdown itself. That's probably not a security problem... but it is wasteful and will slow down the frontend experience.

My favorite way to fix this is to go into the controller and add a superpower that's special to Stimulus inside of Symfony. Put a comment directly above the controller with stimulusFetch colon then inside single quotes lazy.

... lines 1 - 4
/* stimulusFetch: 'lazy' */
export default class extends Controller {
... lines 7 - 26
}

What does that do? It tells Encore to not download this controller code - or anything it imports - until the moment that an element appears on the page that matches this controller. In other words, the code won't be downloaded immediately. But then, the moment a data-controller="snarkdown" element appears on the page, it'll be downloaded via Ajax and executed. Pretty perfect for admin stuff.

Check it out. On your browser, go back to the admin section. Pull up your network tools and go to the Questions section. I'll make the tools bigger... then go edit a question. On the network tools filter, click "JS".

Check out this last entry: assets_controllers_snarkdown_controller_js.js. That is the file that contains our snarkdown_controller code. And notice the "initiator" is "load_script". That's a Webpack function that tells me that this was downloaded after the page was loaded. Specifically, once the textarea appeared on the page.

And if we visit any different page... yep! That file was not downloaded at all because there is no data-controller="snarkdown" element on the page.

Next, it's finally time to do something with our dashboard! Let's render a chart and talk about what other things you can do with your admin section's landing page.

Leave a comment!

5
Login or Register to join the conversation
Sergey-P Avatar
Sergey-P Avatar Sergey-P | posted 23 days ago

Hello! If I start a stimulus application, this way

public function configureAssets(Assets $assets): Assets
 {
     return parent::configureAssets($assets)->addWebpackEncoreEntry('app');
 }

or this way

{{ encore_entry_script_tags('admin') }}

then actions menu(3 dorts) stops working. It doesn't expand if i click it.

Reply
Sergey-P Avatar
Sergey-P Avatar Sergey-P | Sergey-P | posted 23 days ago | edited

I found the problem it's related to the bootstrap import
import { Modal } from 'bootstrap';

Reply

Oh yea, that pesky thing! Good job debugging that!

Reply

I know it is out of scope, but what if we do not want admin related (stimulus) controllers to load at all on non-admin pages? Let's say someone manipulate the DOM.

Reply

Hey julien_bonnier

Great question. I had the same when developed some internal stuff. And there is pretty easy question. IIRC Stimulus controller loader is configured in assets/bootstrap.js so you can duplicate it and configure to load a separate folder for admin, so you will have everything separated.

Cheers!

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}