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!

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.12.1
        "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
    }
}