Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Opening a Modal

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

Our current goal is to be able to add a new product - completely without leaving or reloading this page. To do that, we need an "Add" button!

Creating the modal-form Controller

Open the template for this page - templates/product_admin/index.html.twig - and wrap the h1 in a div with class="d-flex flex-row". Also give the h1 an me-3 so it has some margin between it and the button:

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
<h1 class="me-3">Product index</h1>
... lines 9 - 14
</div>
... lines 16 - 55
</div>
... lines 57 - 58

Add that button, text "Add" with classes btn, btn-primary and btn-sm.

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
<h1 class="me-3">Product index</h1>
... lines 9 - 10
<button
class="btn btn-primary btn-sm"
>Add+</button>
... line 14
</div>
... lines 16 - 55
</div>
... lines 57 - 58

Cool! Because clicking the button will open a modal via JavaScript, let's immediately attach a Stimulus data-controller attribute. But instead of adding it to the button directly, wrap the button in a div and add it there: {{ stimulus_controller() }} and let's call it modal-form... because we're going to make this controller able to open any form we want in a modal.

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
<h1 class="me-3">Product index</h1>
<div {{ stimulus_controller('modal-form') }}>
<button
class="btn btn-primary btn-sm"
>Add+</button>
</div>
</div>
... lines 16 - 55
</div>
... lines 57 - 58

Why are we attaching the controller to the div instead of the button? It won't make any difference right now... but it will come in handy in a few minutes when we need to add a modal template that we can access from our controller.

Speaking of the controller, let's go add that! In assets/controllers/, create a new file called modal-form_controller.js. Go steal the starting code from another controller... paste, and do our usual connect() method with console.log() coffee.

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

Ok! Refresh the page to make sure everything is connected and... it is! There's our tiny, delicious beverage.

Attaching the Button Action

Step two: on click, we want to open a modal. This means we need to add an action to the button.

Over in our template, add data-action= the name of our controller - modal-form - pound sign, and let's call the method openModal.

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
... lines 8 - 9
<div {{ stimulus_controller('modal-form') }}>
<button
class="btn btn-primary btn-sm"
data-action="modal-form#openModal"
>Add+</button>
</div>
</div>
... lines 17 - 56
</div>
... lines 58 - 59

Copy that, head into the controller, rename connect() to openModal() and add the event argument in case we need it. Inside, console.log(event).

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

If we refresh now... and click. We're on a roll!

Importing the Modal Object

So how do we open the modal? One of the nice things about Bootstrap is that it has standalone JavaScript utilities, including one that opens a modal. In Bootstrap 5, we can import it by saying import { Modal } from 'bootstrap'.

import { Controller } from 'stimulus';
import { Modal } from 'bootstrap';
... lines 3 - 9

Fixing the Missing @popperjs/core Peer Dependency

But... oh! As soon as we do that, we have a failing build! Head over to your terminal and go to the tab that's running Encore:

Bootstrap contains a reference to the file @popperjs/core. This file cannot be found.

Ah! Earlier, we talked about peer dependencies: we saw a warning about them when we installed Bootstrap. Many of Bootstrap's JavaScript tools depend on another library called popperjs. For good, but somewhat technical reasons, instead of Bootstrap listing popperjs as its own dependency so it's downloaded automatically, it's listed as a "peer dependency"... which means that it's our responsibility to install it directly.

No problem! Copy that @popperjs/core string, head to our other terminal and install it with:

yarn add @popperjs/core --dev

When this finishes... beautiful! Our build is instantly happy.

Adding the Modal Template

Okay: we've imported Modal. Now what?

The modal system works like this: we create a bunch of HTML that represents our modal, put it on the page, but hide it by default. When we want to open that modal, we point Bootstrap's Modal object at that element and say, "show that modal"!

This means that we need to add some modal HTML onto our page. To do that, and to hopefully make this HTML reusable for other modals, in the templates/ directory, create a new file called _modal.html.twig.

Inside, I'll paste a basic modal structure. There's no magic here: you can find and copy a bunch of different modal examples from the Bootstrap docs. This has a header, a body, which is basically empty, and a footer with some buttons.

<div
class="modal fade"
tabindex="-1"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modal title</h5>
<button type="button" class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Close
</button>
<button type="button" class="btn btn-primary">
Understood
</button>
</div>
</div>
</div>
</div>

Now go back to index.html.twig. Right after the button, include the modal: include('_modal.html.twig').

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
... lines 8 - 9
<div {{ stimulus_controller('modal-form') }}>
... lines 11 - 15
{{ include('_modal.html.twig') }}
</div>
</div>
... lines 19 - 58
</div>
... lines 60 - 61

Why are we including it right there? You'll see why in a minute. But first, go refresh the page... and inspect element on the button. Ok good: the modal HTML is on the page but, as you can see, it's hidden. This element basically serves as a template for what we want our modal to look like.

Opening the Modal

Head back to our controller and remove the console.log(). Now say: const modal = new Modal().

What we need to pass here is the DOM element that holds the modal template. In other words, this element right here. How can we find that from inside of our controller? By using a target of course!

Back in _modal.html.twig, all the way up on the top level element, add a target: data- - the name of our controller - modal-form - -target= and call the new target modal.

<div
class="modal fade"
... lines 3 - 4
data-modal-form-target="modal"
>
... lines 7 - 27
</div>

This does make the modal template a bit specific to this one Stimulus controller. But I'm okay with that. If we need to make this same element a target for a different controller in the future... we can totally do that! We can add as many target attributes as we need.

Copy the target name and head back to the controller. Declare it with static targets = [] an array with modal inside. Careful with the typing on targets: I'll regret that mistake.

... lines 1 - 3
export default class extends Controller {
static targets = ['modal'];
... lines 6 - 10
}

Anyways, now we can say new Modal(this.modalTarget).

... lines 1 - 3
export default class extends Controller {
static targets = ['modal'];
openModal(event) {
const modal = new Modal(this.modalTarget);
... line 9
}
}

That creates a new Modal object... but doesn't actually open it yet. To do that, say modal.show().

... lines 1 - 3
export default class extends Controller {
static targets = ['modal'];
openModal(event) {
const modal = new Modal(this.modalTarget);
modal.show();
}
}

Time to take it for a test drive! Move over, refresh and click. Ah! An error!

Cannot read property classList of undefined.

It's coming from Bootstrap. It's not perfectly clear what's happening... but the "undefined" is very telling. It makes me wonder if my target isn't being seen correctly.

Ah, yup! My bad: static targets. Misspelling this doesn't cause an error directly from Stimulus... because having a property called target is legal! But, of course, that caused the target to not work, which meant it was undefined.

Move over and try it again. This time... got it! And the "close" and X buttons already work!

But... there's no form inside yet. So next, let's make an Ajax call to load the new product form right into the modal. When we do that, we're going to be careful to make sure that our new modal system can be re-used for any form on our site, not just this one.

Leave a comment!

13
Login or Register to join the conversation
Tac-Tacelosky Avatar
Tac-Tacelosky Avatar Tac-Tacelosky | posted 1 year ago

The modal import has some very quirky issue where it kills dropdowns elsewhere on the page. See https://stackoverflow.com/q....

The fix is to change the import to

import Modal from 'bootstrap/js/dist/modal';

1 Reply
Scott-1 Avatar

He Tac,

how did you get it to work? When i change import {Modal} from 'bootstrap'; to import {Modal} from 'bootstrap/js/dist/modal'; i get i error:
bootstrap_js_dist_modal__WEBPACK_IMPORTED_MODULE_15__.Modal is not a constructor
i have this in my controller:
let modal = new Modal(modalObject);

1 Reply

Hey Scott S.!

I still don't understand why the different import behaves differently, but I believe if you do this, you need to import Modal instead of {Modal}:


import Modal from 'bootstrap/js/dist/modal';

Cheers!

1 Reply

Hey Tac,

Thank you for sharing a possible fix with others!

Cheers!

Reply

Is there a more universal way to handle modals? This tutorial is perfect but can it apply to all the forms that I will have to manage in a project or will I have to make as many stimulus controllers as many modals I will have to manage? I found this on the net https://github.com/Sideclick/BootstrapModalBundle too bad it's not compatible with the bs5.
Thanks ;)

Reply

Hi @pasquale_pellicani,

All you need is 1 universal stimulus controller, which can be re-used everywhere and apply it to all forms you need, of course you will need to add some more twig code to use it.

Cheers!

1 Reply
ikerib Avatar
ikerib Avatar ikerib | posted 2 months ago | edited

Hi!
For me everything was working great until I opened a form on a modal with select2 or CKEeditor fields... they fail to load with no error... so I have a form but no select2, CKEditor, datatable functionality....

does anyone know how to make it work?

I got the select2 elements to work with the following code from after the
this.modalBodyTarget.innerHTML = await response.text(); line:

const $else2 = document.querySelectorAll('.nireselect2');
$else2.forEach(el => {
    $(el).select2({
        placeholder: 'Aukeratu bat',
        width: '100%',
        theme: 'classic'
    });
})

const $els = document.querySelectorAll('.js-datepicker');
$els.forEach(el => {
        new AirDatepicker(el, {
            locale: localeEs,
            dateFormat: 'yyyy-MM-dd'
        });
    })
    

but I don't know if it is the right way and I don't know how to run CKEditor either (using FOSCKEditor)

Reply

Hey @ikerib!

Ok! Let's see if we can fix this :).

First, about the code above. It's more or less valid, but we can improve :). The problem with code like this is that it will only affect elements that are ON the page the moment you run this. So, for example, if you moved this into assets/app.js (as an example), it would correctly transform all of the elements... that CURRENTLY exist on the page. But if any new elements are added later, nothing would happen with those. That's why you added this after the this.modalBodyTarget.innerHTML = await response.text(); line: you're basically re-initializing your JavaScript now that you have new content on the page. This works, but it's a bummer to need to do this whenever new content is loaded onto the page via AJAX.

Fortunately, this is where Stimulus shines. I'll use the .nireselect2 as an example:

A) Instead (or in addition to) the nireselect2 class, you should render a new stimulus controller onto the select element. I'll assume you're using the form component:

{{ form_row(form.someSelectField, {
    attr: stimulus_controller('select2')
}) }}

B) Create a new assets/controllers/select2-controller.js file:

import { Controller } from '@hotwired/stimulus';
import $ form 'jquery';

export default class extends Controller {
    connect() {
        $(this.elelement).select2({
            placeholder: 'Aukeratu bat',
            width: '100%',
            theme: 'classic'
        });
    }
}

That's it! The key is that, as soon as the data-controller="select2" pops onto the page - no matter HOW it's loaded onto the page - the connect() method in your controller will trigger and it will initialize select2 on that element (this.element).

For the dater picker, it would be a date-picker controller that does the same thing.

For FOSCKEditor, I'm not really sure, as IT, I believe tries to initialize CKEditor on your behalf. Personally, I would skip that bundle and create a small Stimulus controller (like above) to initialize CKEditor. Or use something like https://github.com/basecamp/trix - where all you need to do is render a <trix-editor input="x"></trix-editor> element and it "just works".

Let me know if this helps!

Cheers!

1 Reply
ikerib Avatar

Wow! Ok! I undestand what you say, thanks a lot! I will try all tomorrow ;)

Reply
ikerib Avatar

Everything working!! Thanks!!

Reply
It O. Avatar

Hey Guys;
We don't have a tutorial today?

Reply

Sorry It O.! That's totally my fault - I got behind yesterday! This tutorial especially (because I've been improving Stimulus with Symfony while making the tutorial!) has been a particularly hard one to get consistently out. But, I *did* just record the 2nd to last video... and so I'll be getting the audio recorded for those ASAP. The last video is, of course, waiting on a pull request to get merged (I hope!) that I just created today - https://github.com/afcapel/... - it's open source meets tutorial building :).

Anyways, more to come soon - with any luck, the last few will come faster than one every day.

Cheers!

Reply
It O. Avatar

great!!! thanks for answering

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