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!

12
Login or Register to join the conversation
Jony-T Avatar
Jony-T Avatar Jony-T | posted 6 months ago | edited

If does not work for anyone under symfony6 add to the DashboardController

    public function configureAssets(): Assets
    {
        return parent::configureAssets()
            ->addWebpackEncoreEntry('admin');
    }
1 Reply

Hey Jony,

Thanks for this hint! Yeah, you should have this to make it work... we did it in previous chapters: https://symfonycasts.com/screencast/easyadminbundle/assets#codeblock-9cb529831f - so if you follow this course from the beginning - you should have this.

Cheers!

1 Reply
Jony-T Avatar
Jony-T Avatar Jony-T | Victor | posted 6 months ago | edited

Yes, Victor.
In my case was that I called the CSS with another name than admin so: If you follow the tutorial there is no problem when admin.js include the admin.css due to the name being the same in the ->addWebpackEncoreEntry('admin'); but in my case, I called adminstyles so I had ->addWebpackEncoreEntry('adminstyles'); .
When I created an admin.js that includes adminstyles.css in the tutorial we updated the webpack.config.js but no reference to taking care of the configureAsset() method of DashboardController, obviously because it is the same name.

So the advice here is: "if you are taking a look at a tutorial, follow the tutorial! " XDD.

Reply

Hey Jony,

Ah, I see! Yes... that's a good practice to name the JS and CSS files with the same exact name :) I think it's enough, because you have the file extension anyway, so it's clear that admin.css file is for "styles" for admin.js :)

Cheers!

Reply
Coding010 Avatar
Coding010 Avatar Coding010 | posted 8 months ago

Hello, I have a question if it's possible setting a data-controller attribute to a panel in stead of a field. To create a controller surrounding multiple forms at the same time. I'm not able to get this working.

The following code works to add an extra class value to the panel:

yield FormField::addPanel('User Details')->addCssClass('foobar');

But the following code doesn't do anything:

yield FormField::addPanel('User Details')->setFormTypeOptions([
    'row_attr' => [
        'data-controller' => 'foo',
    ]
]);

The goal is to group a couple of fields and create a single controller. Then adding the fields as a target.

Best regards

Reply

Hey Coding010,

Yeah, setFormTypeOptions() won't work for that addPanel() because in the source code EA just does not render any additional things like this. There's another method called setCssClass() but it will help to set a CSS class to that whole panel wrapper and that's it. But if you want to add some data attributes - I think you should override a template for this, in specific vendor/easycorp/easyadmin-bundle/src/Resources/views/crud/form_theme.html.twig one - you will find a <div class="form-panel"> tag there, add your data controller there.

I hope this helps!

Cheers!

Reply
Coding010 Avatar

Hi Victor, thanks for your reply.

I browsed around a bit on EasyAdmin's Github page and found this newly added feature:
https://github.com/EasyCorp/EasyAdminBundle/pull/5488.
It was added in Easy Admin 4.4.3.

This helped me out, as now I can set a Stimulus controller data attribute on the <body> element of a crud.
It's not specifically around a group of field as I first wanted, but this should do the trick.

Here a quick code example:

Override the templates via the CrudController.

// src/Controller/Admin/Crud/FooCrudController.php

public function configureCrud(Crud $crud): Crud
{
    return $crud
         ->overrideTemplates([
            'crud/new' => 'admin/crud/new.html.twig',
            'crud/edit' => 'admin/crud/edit.html.twig',
        ])
    ;
}

Note that 'crud/layout' doesn't work, even though the examples on the Symfony documentation page say it should.

And then create the template to override:

{# templates/admin/crud/new.html.twig #}

{% extends '@EasyAdmin/crud/new.html.twig' %}

{% block body_attr %}
  {{ stimulus_controller('admin/foo') }}
{% endblock body_attr %}

This adds the data-controller attribute to the body.

<body data-controller="admin--foo"> 
 ...

Hope this helps out others with this "problem" as well.

Reply
Sergey-P Avatar

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 11 months 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.1.0",
        "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
    }
}
userVoice