Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

9
Login or Register to join the conversation
discipolat Avatar
discipolat Avatar discipolat | posted 3 months ago | edited

Hi, I'm having a trouble in symfony 6.2.6. Form errors does not show on submit. From an Ajax (with modal) or directly form the normal call.
From a post form Stack overflow a got the code below, but looking for a "native" solution.

$form = $this->createForm(SomeSortOfType::class, $entity, [
    'attr' => [
        // set this if you want to force server-side validation
        'novalidate' => 'novalidate',
    ],
]);

$form->handleRequest($request);

if ($form->isSubmitted()) {
    if ($form->isValid()) {
        $entity = $form->getData();

        // do stuff with the submitted data

        $this->addFlash('success', 'You did it!');
    } else {
        $errors = [];

        foreach ($form->getErrors(true) as $error) {
            $path = $error->getCause()->getPropertyPath();

            if (empty($path)) {
                break;
            }

            $path = \preg_replace('/^(data.)|(.data)|(\\])|(\\[)|children/', '', $path);
            $message = $error->getCause()->getMessage();
            $errors[$path] = $message;
        }

        foreach ($errors as $path => $message) {
            $form->get($path)->addError(new FormError($message));
        }

        $this->addFlash('error', 'You failed!');
    }
}

$response = $this->renderStructure(
    $structure,
    [
        'form' => $form->createView(),
    ],
    $preview,
    $partial,
);

return $response;
Reply

Hey Discipolat,

Yes, nice discover! That's probably something you have to do when you work with JSON responses, i.e. fetch the form errors, return them in your JSON response to the JS endpoint which will then parse them and attach to specific fields in your form... that might lead to complex and big JS code. The easiest solution with Stimulus would be to render the whole HTML form on your server-side, and then return the HTML response (not JSON response) to your JS endpoint (i.e. Stimulus endpoint) and just replace the whole HTML form with the new form HTML code where you have form errors already highlighted. I.e. you basically replace your HTML form code with the new one from the server with highlighted error fields. And that's exactly what makes Stimulus so cool - it will automatically work on your page even after you replaced the part of your old HTML code with the new one.

Another official and out-of-the-box solution would be to use Live Components from Symfony UX, in specific you're looking for Auto-Validating Form. You can learn more on this page: https://ux.symfony.com/live-component/demos/auto-validating-form - it has a nice demo showing how it works... and also useful links to the actual Symfony docs about this that makes it work.

I hope this helps!

Cheers!

1 Reply
discipolat Avatar
discipolat Avatar discipolat | Victor | posted 3 months ago | edited

Got it ! Thank's @Victor .

Reply

I have a bootstrap-datetime picker in my form. When i load the form via the "new" page the picker works, but when the form is loaded via the modal (with stimulus) the picker doesn't work. Am i doing something wrong or did i forget something? I now what the reason is: i init the picker via jquery whith document.ready. But the picker get's loaded after it. Maybe there is a best-practice with stimulus (i think there is, but not that i'm aware of).

Reply

Hey Lex,

Hm, looks like that's how that datetime picker works, I suppose it's somehow initialized when the DOM is ready, i.e. when the page is fully loaded by browser, but then you create a modal with AJAX request but that code never initialized because the initialization already happened before.

So, I think you have 2 possible options - either look for a different datetime picker (probably one that even does not need that jQuery - thanks to Stimulus and Webpack Encore we can finally completely get rid of it, but it's up to you of course :) ). Or you can try to read their (this datetime picker) docs and try to find how to initialize the datetime picker on request, and do this after you showed that modal. I think their docs should cover something like this.

I hope this helps!

Cheers!

Reply

Hi SymfonyCasts,

I have the same problems with date picker and with ckeditor.
Is it best practise (and available) to use a datepicker and html wysiwyg of other libraries (stimulus use)?

Thank you for your help !!

Reply

Hey @Annemieke-B ,

Well, ideally find a library that works well with Stimulus. Also, it might depend on your specific implementation. For example, instead of trying to activate Bootstrap datetime picker once on the page load - do it manually in the Stimulus controller, right after it brings a new code to the HTML page. IIRC Bootstrap should have some programmatic activating on request - this way you will be sure that when you're trying to activate it all the necessary HTML code is already on the page. The same for CKEditor, try to activate it after Stimulus did all the necessary changes to the HTML to make sure the needed elements are present on the page already.

I hope this helps!

Cheers!

Reply

Thanks to your reply i found out that my picker doesn't need jquery (after reading the docs; that's why i should read them). Pff... sorry for that but thanks again!

Reply

Hey Lex,

Haha, that is awesome! :) No problem, I'm glad to hear the docs helped ;)

Cheers!

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