If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe 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.
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.
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.
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.
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.
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!
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.
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!
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.
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!
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!
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!
hey, silly question if you don't mind,
what makes "Server" label on your developer toolbar (bottom right ) green?
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!
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!
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!
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,
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!
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!
// 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
}
}
// 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
}
}
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!