Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Making a Configurable, Reusable 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 can already reuse this new controller on any form where we want the user to confirm before submitting. That's awesome. But to truly unlock its potential, we need to make it configurable, giving us the ability to change the title, text, icon and confirm button text.

Fortunately, the values API makes this easy. At the top of our controller, add a static values = {} and... let's make a few things customizable. I'll use the same keys that SweetAlert uses. So we'll say title: String, text: String, icon: String and confirmButtonText: String. We could configure more... but that's enough for me.

... lines 1 - 3
export default class extends Controller {
static values = {
title: String,
text: String,
icon: String,
confirmButtonText: String,
}
... lines 11 - 29
}

Below, use these. Set title to this.titleValue or null. There's no built-in way to give a value a default... so it's common to use this "or" syntax. This means use titleValue if it's set and "truthy", else use null.

Let's do the others: this.textValue or null, this.iconValue or null and, down here this.confirmButtonTextValue or yes... because if you have a confirm button with no text... it looks silly.

... lines 1 - 11
onSubmit(event) {
... lines 13 - 14
Swal.fire({
title: this.titleValue || null,
text: this.textValue || null,
icon: this.iconValue || null,
... lines 19 - 21
confirmButtonText: this.confirmButtonTextValue || 'Yes',
}).then((result) => {
... lines 24 - 26
})
... line 28
}
... lines 30 - 31

I like this! Let's see how it looks if we don't pass any of these values. Refresh and... yup! It works... but probably we should configure those.

Head to the template - cart.html.twig - to pass them in. Do that by adding a 2nd argument to stimulus_controller(). Let's see, pass title set to "remove this item?", icon set to warning - there are five built-in icon types you can choose from - and confirmButtonText set to "yes, remove it".

... lines 1 - 2
{% block body %}
... lines 4 - 27
{% for item in cart.items %}
... lines 29 - 44
<form
... lines 46 - 50
{{ stimulus_controller('submit-confirm', {
title: 'Remove this item?',
icon: 'warning',
confirmButtonText: 'Yes, remove it'
}) }}
... line 56
>
... lines 58 - 62
</form>
... lines 64 - 69
{% endfor %}
... lines 71 - 89
{% endblock %}
... lines 91 - 92

Let's check it! Refresh and remove. That looks awesome! And more importantly, we can now properly re-use this on any form.

Submitting via Ajax

While we're here, I want to add one more option to our controller: the ability to submit the form - after confirmation - via Ajax instead of a normal form submit. Let me tell you... my ultimate goal. After confirming, I want to submit the form via Ajax then remove that row from the cart table without any full page refresh.

Quick side note about this. Our next tutorial in this series - which will be about Stimulus's sister technology "Turbo" - will show an even easier way to submit any form via Ajax. So definitely check that out.

But doing this with Stimulus will be a good exercise and will give us more control and flexibility over the process... which you sometimes need.

Setting up SweetAlert for the Ajax Submit

Ok: to support submitting via Ajax, we need to tweak our SweetAlert config. Add a showLoaderOnConfirm key set to true. Then add a preConfirm option set to an arrow function. This is going to replace the .then().

And... actually let's organize things a bit more: add a method down here called submitForm(). For now, just console.log('submitting form'). Then up in preConfirm, call this.submitForm().

... lines 1 - 3
export default class extends Controller {
... lines 5 - 11
onSubmit(event) {
... lines 13 - 14
Swal.fire({
... lines 16 - 21
confirmButtonText: this.confirmButtonTextValue || 'Yes',
showLoaderOnConfirm: true,
preConfirm: () => {
this.submitForm();
}
});
}
... line 29
submitForm() {
console.log('submitting form!');
}
}

This deserves some explanation. When you use the preConfirm option in SweetAlert, its callback will be executed after the user confirms the dialog. The big difference between this and what we had before - with .then() - is that this allows us to do something asynchronous - like an Ajax call - and the SweetAlert modal will stay open and show a loading icon until that Ajax call finishes.

Let's make sure we've got it hooked up. Refresh, and... yes! There's the log.

Submitting a Form via Ajax

Now let's actually submit that form via Ajax. Replace the console.log() with return fetch(). For the URL, this.element is a form... so we can use this.element.action. Pass an object as the second argument. This needs two things: the method - set to this.element.method - and the request body, which will be the form fields.

How do we get those? It's awesome! new URLSearchParams() - that's the object we used earlier - then new FormData() - that's another core JavaScript object... that even works in IE 11! - and pass this the form: this.element.

... lines 1 - 29
submitForm() {
return fetch(this.element.action, {
method: this.element.method,
body: new URLSearchParams(new FormData(this.element)),
});
}
... lines 36 - 37

That's a really nice way to submit a form via Ajax and include all of its fields. Oh, and notice the return. We're returning the Promise from fetch()... so that we can return that same Promise from preConfirm. When you return a Promise from preConfirm, instead of closing the modal immediately after clicking the "Yes" button, SweetAlert will wait for that Promise to finish. So, it will wait for our Ajax call to finish before closing.

... lines 1 - 11
onSubmit(event) {
... lines 13 - 14
Swal.fire({
... lines 16 - 23
preConfirm: () => {
return this.submitForm();
}
});
}
... lines 29 - 37

And we can now see this in action! Refresh and click remove. Watch the confirm button: it should turn into a loading icon while the Ajax call finishes. And... go!

Gorgeous! I think that worked! It didn't remove the row from the page - we still need to work on that - but if we refresh... it is gone.

Making the Ajax Form Submit Configurable

But I don't want this Ajax submit to always happen on all the forms where I use this confirm submit controller... because it requires extra work to, sort of, "reset" the page after the Ajax call finishes. So let's make this behavior configurable.

Over in the controller, up on values, add one more called submitAsync which will be a Boolean.

... lines 1 - 3
export default class extends Controller {
static values = {
... lines 6 - 9
submitAsync: Boolean,
}
... lines 12 - 42
}

Down in submitForm(), use that: if not this.submitAsyncValue, then this.element.submit() and return.

... lines 1 - 30
submitForm() {
if (!this.submitAsyncValue) {
this.element.submit();
return;
}
... lines 37 - 41
}
... lines 43 - 44

Let's make sure the Ajax call is gone. Actually... let me add a few more items to my cart... because it's getting kind of empty. Add the sofa in all three colors... then go back to the cart. Let's remove this one and... beautiful. It's back to the full page refresh.

Now let's reactivate the Ajax submit on just this form by passing in the submitAsync value. In the template, set submitAsync to true.

... lines 1 - 2
{% block body %}
... lines 4 - 27
{% for item in cart.items %}
... lines 29 - 44
<form
... lines 46 - 50
{{ stimulus_controller('submit-confirm', {
... lines 52 - 54
submitAsync: true,
}) }}
... line 57
>
... lines 59 - 63
</form>
... lines 65 - 70
{% endfor %}
... lines 72 - 90
{% endblock %}
... lines 92 - 93

At this point, we have a clean submit confirm controller that can be reused on any form. As a bonus, you can even tell it to submit the form via Ajax.

But when we submit via Ajax, we need to somehow remove the row that was just deleted. To do that, we're going to create a second controller around the entire cart area and make the two controllers communicate to each other. Teamwork? Yup, that's next.

Leave a comment!

16
Login or Register to join the conversation
Sydney-A Avatar
Sydney-A Avatar Sydney-A | posted 8 months ago

Hi Guys,

Great tutorial! There is a part in the video that I did not quite understand, maybe someone can help?
What is the purpose of creating the submitAsync value and how does it help. I did no quite understand the explanation.

Thanks!

Reply

Hey Sydney!

Let's try to figure it out together! :) Well, first of all, the submitAsync value is just a boolean flag, I suppose you understand it well because it's pretty simple. It just says either we want to send this form "async" or "sync", and that's it. And we can control what kind of form sending we want for different controllers. What about actual sending the form async/sync - that's pretty simple too. We either send the form sync with "this.element.submit();", i.e. just literally ask the browser to submit that form as usual and reload the page. Or if we want to send it async - we intercept the normal browser's form sending and instead send an ajax request with all the form data via fetch() that will return us a response that we will handle later. I.e. no normal page refresh by the browser.

In short, we either let the browser to send the form as usual with the page refresh... or send an ajax request instead to avoid the page refresh, and that's it. I hope it's clearer now :)

Cheers!

Reply
Sydney-A Avatar

Thanks a lot Victor. Its clear now.

Reply
Stonerek Avatar
Stonerek Avatar Stonerek | posted 1 year ago | edited

Hi guys

Great tutorial, I liked stimulus.

I'm trying to use modal_form_controller to edit the data as well.
I have to prevent some fields from being overwritten.
I am using "disabled" in _form.html.twig:


     {% if garden.nr == ''%}
         {{form_widget (form)}}
     {% else%}
         {{form_widget (form.nr, {'disabled': true})}}
     {% endif%}

Unfortunately, when I approve such a form, I get an ugly exception:
Expected argument of type "string", "null" given at property path "nr".

If I edit without disabled in this field everything works fine ...

I would be grateful for suggestions on how I can fix this.

Reply

Hey Artur,

Hm, it sounds like you need to allow your "nr" field to be nullable, most probably in the setter and property declaration. Or, maybe try to use readonly instead, but IIRC it might be hacked in the HTML code by some tricky users.

I hope this helps!

Cheers!

Reply
Stonerek Avatar

Thanks Victor for the quick hint!
"readonly" works great for me.
Your burglary alert scared me a bit, but at least I can move on with my project.
Regards.

Reply

Hey Artur,

Great, I'm happy it works for you! About the alert, you may try to open Chrome inspector and remove that readonly attribute in the HTML code of the loaded page - then you will be allowed to edit the field and if you send the form - server will update the value of that field in theory. You can try do it yourself so that you know how some users may bypass this readonly attribute. If it's a critical thing for you - I'd recommend you to revert back to disabled and try to find the solution with allowing null for that field instead.

Cheers!

Reply
Justin Avatar
Justin Avatar Justin | posted 1 year ago | edited

Fun story. The way you used fetch() with body: new URLSearchParams(new FormData(this.element)) doesn't work with all form methods. With GET you get an error saying that GET or HEAD can't have a body.

If you want, you can use something like this to make the form submit work with any request method.
`
let url = this.element.action;
let init = {method: this.element.method};

if (['get', 'head'].includes(this.element.method)) {

url += '?' + new URLSearchParams(new FormData(this.element)).toString();

} else {

init.body = new URLSearchParams(new FormData(this.element));

}

const response = await fetch(url, init);
`

Thanks for the content Ryan!

Reply

Yo Justin!

Ya know, I had never really thought about that, but that makes sense! And I love your solution - just toString()'ing the UrlSearchParams - very nice. Thanks a bunch for sharing this :).

Cheers!

Reply
Jakub G. Avatar
Jakub G. Avatar Jakub G. | posted 2 years ago

hey, silly question if you don't mind,
what makes "Server" label on your developer toolbar (bottom right ) green?

Reply

Hey Jakub G.!

Actually, this caused some of the team internally to say "Hmm, that's a good question - what exactly *does* trigger that?" - so it is a good question ;). I believe it turns green based on one of two things:

A) In practice, it turns green if you're running the symfony binary AND docker-compose. It turns green because the symfony web server noticed docker compose, reads the running containers, and exposes some env vars.

B) OR, it may turn green if ANY of the items in the sub-menu are green. In practice, for me, my green items are because I'm running Docker Compose, so it's kind of the same answer as (A). But there are a few other items that could possibly be green also: https://imgur.com/a/FV9dkxB

So I'm not 100% sure - but that should give you the basic idea :).

Cheers!

Reply

Hi there!

First of, great course so far! Yay!

Next, I followed a couple of your JavaScript-related tutorials and I think one piece is missing. What about internationalization? I've been wondering for a while, what would be the best approach to use the same translation strings we used in the PHP side inside the JS. For example, the default 'Yes' from that lesson or even the cancel button from Sweetalert. So far I've been using some data to dump text in the dom that JS can read, I've been making some JSON endpoint that JS can query. But I was wondering, what would be the recommended way of doing that.

I live in Québec and almost every app I make has to be French and English...

Cheers!

Reply

Hey Julien,

Unfortunately, no any screencasts about translations so far, but we're going to create a tutorial about it in the future, it's on our TODO list. But I can't say when i might be released yet. But I can give you some hints :) Take a look at this bundle: https://github.com/willdura... - it helps to dump some translations to the frontend and use similar to how you use them in PHP files. But basically, your way sounds like a good workaround too, but the bundle is just helps to "standardize" it and has some more useful tools.

I hope this helps!

Cheers!

Reply
CDesign Avatar
CDesign Avatar CDesign | posted 2 years ago | edited

If I pass submitAsync: false, (rather than true), it still submits asynchronously. And if I console.log(this.submitAsyncValue); in the submitForm() method it does show 'true' even though 'false' was passed. What's going on there?

And I do have submitAsync registered in the values object as bool: submitAsync: Boolean,

Reply

Hey John christensen!

I need to check into this - but I think this might be a bug on my part. Or, probably more accurate, a bug in WebpackEncoreBundle's Stimulus controller. I noticed this just a few days ago: if you pass submitAsync: false, it STILL renders the attribute, just without any "=" part. But that's enough for Stimulus to see the presence of this attribute, and think that you want to set it to true.

Cheers!

Reply

Here's the issue tracking this - https://github.com/symfony/... - I'm going to have someone check into it and (assuming there IS a bug, which I think there is) fix it soon :).

Thanks for bringing this to my attention!

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