Loading a Form into the 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

We're going to load the new product form into the body of this modal. But the header and buttons will still come from _modal.html.twig.

Making _modal.html.twig Customizable

Let's customize those to make more sense. Up on the header, we can say: "add a new product".

Wait, don't do that. I want to try to make this template as reusable as possible for other modals. Instead, let's say {{ modalTitle }}:

<div
... lines 2 - 5
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTitle }}</h5>
... lines 11 - 13
</div>
... lines 15 - 25
</div>
</div>
</div>

In index.html.twig, add a second argument to include() and pass modalTitle set to "Add a new product":

... 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', {
modalTitle: 'Add a new Product',
}) }}
</div>
</div>
... lines 21 - 60
</div>
... lines 62 - 63

Very nice! For the body, use {{ modalContent }}. That's a new variable I'm inventing. But pipe this into the default filter and say "loading...":

<div
... lines 2 - 5
>
<div class="modal-dialog">
<div class="modal-content">
... lines 9 - 14
<div class="modal-body">
{{ modalContent|default('Loading...') }}
</div>
... lines 18 - 25
</div>
</div>
</div>

In this case, we are not going to pass any modal content, but you could in other situations. We'll replace the loading... in a minute after we make the Ajax call.

For the buttons, hard-code those to some new text: "Cancel" and "Save". We can always make them dynamic later.

<div
... lines 2 - 5
>
<div class="modal-dialog">
<div class="modal-content">
... lines 9 - 17
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Cancel
</button>
<button type="button" class="btn btn-primary">
Save
</button>
</div>
</div>
</div>
</div>

Let's make sure we didn't break anything. When I click the button, very nice!

Passing the Form AJAX URL to the Stimulus Controller

To get the new product form HTML, when the modal opens, we're going to make an Ajax call to an endpoint that will return that HTML.

Head over to src/Controller/ProductAdminController.php and find the new action. This is the endpoint that we're going to make our Ajax request to: we're going to customize this so that it's able to return the full HTML page or just the form partial under a specific condition. We'll do that in a minute.

Copy the route name. As you know, I don't like to hard-code Ajax URLs in my Stimulus controllers... and I really don't want to do that in this case because I want the controller to be reusable for other forms on our site.

And so, we'll do what we've done several times before: pass the URL into the controller as a value. Add static values = {} and create a value called, how about formUrl, which will be a String.

... lines 1 - 3
export default class extends Controller {
... line 5
static values = {
formUrl: String,
}
... lines 9 - 14
}

Then, down in openModal, console.log(this.formUrlValue).

... lines 1 - 3
export default class extends Controller {
... line 5
static values = {
formUrl: String,
}
... line 9
openModal(event) {
console.log(this.formUrlValue);
... lines 12 - 13
}
}

In the template, on stimulus_controller, add a second argument so that we can pass the formUrl value set to path() and the route name: product_admin_new.

... lines 1 - 5
<div class="container-fluid mt-4">
<div class="d-flex flex-row">
... lines 8 - 9
<div {{ stimulus_controller('modal-form', {
formUrl: path('product_admin_new')
}) }}>
... lines 13 - 20
</div>
</div>
... lines 23 - 62
</div>
... lines 64 - 65

Try it: refresh, click and... got it! There's the URL.

Installing & Importing jQuery

So far, we've been using fetch() to make Ajax calls, which I really like. I also really like Axios. But I've gotten some questions about how it would look to use jQuery inside of Stimulus. So instead of showing another example of using fetch(), let's install and use jQuery.

At your terminal, install it with:

yarn add jquery --dev

Once that finishes, we can import that into our controller with: import $ from 'jquery'.

import { Controller } from 'stimulus';
import { Modal } from 'bootstrap';
import $ from 'jquery';
... lines 4 - 18

Making the Ajax Call

Now, down in the method, remove the console.log() and make the Ajax call with $.ajax() and pass it this.formUrlValue.

That will make the Ajax call... but will do absolutely nothing with the result. What we need to do is take the HTML from the Ajax call and, if you look at _modal.html.twig, put it inside the modal-body element. That means we need a new target.

Right here, add data-modal-form-target= and let's call this one modalBody.

<div
... lines 2 - 5
>
<div class="modal-dialog">
<div class="modal-content">
... lines 9 - 14
<div class="modal-body" data-modal-form-target="modalBody">
{{ modalContent|default('Loading...') }}
</div>
... lines 18 - 25
</div>
</div>
</div>

Copy that, go back to the controller, and set this up as a second target.

... lines 1 - 4
export default class extends Controller {
static targets = ['modal', 'modalBody'];
... lines 7 - 16
}

In openModal() use that: this.modalBodyTarget.innerHTML equals, await $.ajax()... because jQuery's Ajax function returns a Promise. And, of course, my Webpack build is mad because we need to make openModal() async.

... lines 1 - 4
export default class extends Controller {
static targets = ['modal', 'modalBody'];
... lines 7 - 16
}

Our Ajax call is still going to return the HTML for the entire page... but let's at least see if it works.

Move over, refresh and... awesome! It looks totally wrong because the endpoint returns the full page, but it is working!

Before we fix that, I want to handle one small detail. In our Stimulus controller, at the very top of openModal, add this.modalBodyTarget.innerHTML equals Loading....

... lines 1 - 4
export default class extends Controller {
... lines 6 - 10
async openModal(event) {
this.modalBodyTarget.innerHTML = 'Loading...';
... lines 13 - 16
}
}

That's a minor thing: if we open the modal twice, this will clear the contents before we start the AJAX call... so that we don't temporarily see an old form.

The Form HTML Endpoint

Ok: our last job is to return only the form HTML instead of the entire page from the Ajax endpoint.

Over in ProductAdminController, inside of the new action, to return the full page, we render new.html.twig. To return only the form, we can actually just render _form.html.twig: this renders the form element. Yea! make:crud already generated the exact template partial we need!

Inside new(), we can say $template = and then, to figure out if this is an Ajax request, use $request->isXmHttpRequest(). If it is, use _form.html.twig. Else, use new.html.twig. Now, render product_admin/ and then $template.

... lines 1 - 15
class ProductAdminController extends AbstractController
{
... lines 18 - 30
public function new(Request $request): Response
{
... lines 33 - 44
$template = $request->isXmlHttpRequest() ? '_form.html.twig' : 'new.html.twig';
... line 46
return $this->render('product_admin/' . $template, [
... lines 48 - 49
]);
}
... lines 52 - 95
}

That's it! But I do have one warning. When I make an Ajax call for a partial, I usually append a query parameter like ?form=1 or ?ajax=1... or add some special header. I do not usually rely on isXmlHttpRequest(). Why? Two reasons. First, relying on a query parameter makes it really easy to try the URL in your browser. And second, some Ajax clients - like fetch() - don't send the headers that are needed for the isXMLHttpRequest() method to detect it. If we were using fetch(), this would return false.

So, it's up to you: this works with jQuery's Ajax client and is easy. If you're using fetch() you'll probably want to add a query parameter when you make the Ajax call, which you can do pretty easily inside of the Stimulus controller. We did that earlier with URLSearchParams.

Anyways, head back to the page, refresh, click and... oh, look at that! It's beautiful!

Oh, but there are two sets of buttons. It's hard to see because it's unstyled, but there's a save button down here.

We probably want to keep the buttons in the modal footer and hide the one that's coming from the form partial. A really easy way to do this is with CSS. Over in your editor, open assets/styles/app.css. All the way at the bottom, we're going to hide any buttons that are inside of the modal body... which has this modal-body class. Do that with .modal-body button and display: none.

... lines 1 - 150
.modal-body button {
display: none;
}

This will hide all the buttons for all of the modals on your site. If that's a problem, add a custom class on your modal HTML so you can be more targeted.

When I refresh now... and click the button... it looks perfect!

Okay: we've got our form into the modal. Now we need to make it submit via Ajax inside the modal. Lets do that next!

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