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 SubscribeWhen we click the "Save" button, we want to submit the form via Ajax. Take a look at the structure of the modal. The save button is actually outside of the form: the form lives inside of modal-body
. This means that we can't add a Stimulus submit action to the form... because clicking save won't actually submit the form! The button does nothing! Instead, over in _modal.html.twig
, we're going to add an action to the button
itself.
Add data-action=
- we can just use the default action for a button, which is click
- then our controller name - modal-form
- a #
sign and a new method name. How about submitForm
.
<div | |
... lines 2 - 5 | |
> | |
<div class="modal-dialog"> | |
<div class="modal-content"> | |
... lines 9 - 17 | |
<div class="modal-footer"> | |
... lines 19 - 21 | |
<button type="button" class="btn btn-primary" data-action="modal-form#submitForm"> | |
Save | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> |
Copy that name and go add it to our stimulus controller submitForm()
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
submitForm() { | |
... lines 20 - 21 | |
} | |
} |
Let's see: we need to find the form
element so we can read the data from all of its fields. Normally, when we need to find something, we add a target. Should we do that in this situation?
We could. But I'd like to make it as easy as possible to reuse our modal-form
controller on other forms. If we can avoid needing to add extra attributes to the form
element, which is rendered by this form_start()
function, that will make reusing all of this on other forms much easier.
Instead, in our controller, let's leverage the modalBodyTarget
- which is going to be this element right here - and look inside of it for a form
element. With jQuery, we can do that with: const $form = $(this.modalBodyTarget)
then .find('form')
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
submitForm() { | |
const $form = $(this.modalBodyTarget).find('form'); | |
... line 21 | |
} | |
} |
If you use jQuery in Stimulus, you will always use $()
and then some element so that we're looking inside that element... instead of inside the whole page. If we wanted to look for something inside of our entire controller element, we would use $(this.element)
. The point is: we always want our selecting to be looking inside our controller.
I also, as a convention, prefix my variable names with a $
when they are jQuery objects. There's nothing special about that variable name.
If you wanted to do this without jQuery, it would be really similar: this.modalBodyTarget
then .getElementsByTagName()
, pass it form
... and use the 0
index to get the first and only match.
Anyways, to make the Ajax call, we're going to need the data from all of the fields in the form. Without jQuery, if you look at our submit-confirm_controller
, and scroll down to submitForm()
, we learned that you can do this with a combination of URLSearchParams
and FormData
... passing it the form
element, which in this case was this.element
.
We could do that same thing here. But since we're using jQuery, there's a shortcut: console.log($form.serialize())
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
submitForm() { | |
const $form = $(this.modalBodyTarget).find('form'); | |
console.log($form.serialize()); | |
} | |
} |
Let's try that. Move over, refresh the page, open the modal and fill in at least one of the fields. Hit save. Nothing visually happened... but look at the log.
There it is! We get a long string, which is the format we can use in the Ajax call. If you look closely, the product name does contain "shoelaces".
To make the Ajax call, we need three things... and we already have the first. We need the form field data - we have that with $form.serialize()
and also the URL to submit to & method
to use. We can get those last two directly from the form
element.
Say $.ajax()
and I'll pass it the options format where even the URL is an option. Set that to $form.
. Now you might expect me to read the action
attribute off of the form. But instead, say .prop('action')
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
async submitForm() { | |
const $form = $(this.modalBodyTarget).find('form'); | |
this.modalBodyTarget.innerHTML = await $.ajax({ | |
url: $form.prop('action'), | |
... lines 23 - 24 | |
}); | |
} | |
} |
That's slightly different... and bit smarter: this will return the correct action URL even if there is no action
attribute... which means that a form should submit back to the current URL.
If you look back at submit-confirm_controller
, that time we used this.element.action
... which is a property that exists on all form
elements. In jQuery, we're asking it to give us that same property.
Repeat this for method
set to $form.prop('method')
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
async submitForm() { | |
const $form = $(this.modalBodyTarget).find('form'); | |
this.modalBodyTarget.innerHTML = await $.ajax({ | |
url: $form.prop('action'), | |
method: $form.prop('method'), | |
... line 24 | |
}); | |
} | |
} |
Finally, for the data, we can say data
set to $form.serialize()
.
... lines 1 - 4 | |
export default class extends Controller { | |
... lines 6 - 18 | |
async submitForm() { | |
const $form = $(this.modalBodyTarget).find('form'); | |
this.modalBodyTarget.innerHTML = await $.ajax({ | |
url: $form.prop('action'), | |
method: $form.prop('method'), | |
data: $form.serialize(), | |
}); | |
} | |
} |
So there you have it: a jQuery version of how you can submit a form via Ajax.
Now, this will submit the form... but we want to also do something with the response.
Look at ProductAdminController::new()
. If we submit to this URL and the form fails validation, it will automatically re-render our _form.html.twig
template. But this time the form will render with errors.
So... great! This means that after the Ajax call is finished, we want to put the returned HTML back onto this.modalBodyTarget
. Copy that and say this.modalBodyTarget.innerHTML
equals await $.ajax()
. Then make submitForm()
async.
Moment of truth! Spin over, refresh the page, click the button, but leave the form blank this time. Hit save and... ah! There's an error!
What happened? Let's find out next... and make sure that our form - no matter how or where it's submitted - will always work.
Hi, it seems that by rendering a partial view you lose all the stimulus controllers that normally work if you render by extending from the base structure. I have difficulty with ui-datepicker and google place autocomplete both used with webpack, any advice ? thanks
Yo @pasquale_pellicani!
Are you making an Ajax call that returns a "partial" page, then inserting that onto the page (like we do in this chapter)? If so, everything "should work" :p. What I mean is: Stimulus is smart: if you add some HTML that contains Stimulus logic into the page, it will "just work" no matter how it go onto the page (Stimulus is actively watching in the background for new stimulus controllers to get added to the page).
So, in your case, something else might be going on. Can you tell us a bit more what you're working on and what's going wrong and when? And any libraries you're using for datepicker or autocomplete.
Cheers!
Of course and thanks for asking, I'll start by saying that if I load the view with a normal call the controller for the autocomplete place and the.js file with the selector for the datepicker work normally. I got the stimulus controller to have the autocomplete effect from here while for the datepicker I created a ui-datepicker.js file and loaded it with the addEntry instruction in the webpack file and then loaded it into the page header
ui-datepicker.js content below:
import $ from 'jquery';
import datepickerFactory from 'jquery-datepicker';
import datepickerITFactory from 'jquery-datepicker/i18n/jquery.ui.datepicker-it';
// Just pass your jquery instance and you're done
datepickerFactory($);
datepickerITFactory($);
$(function() {
console.log('document (as function) is ready I load datepicker!!!');
$('.fc-datepicker').datepicker();
$.datepicker.regional['it'];
});
....I've been going crazy for 2 days, thanks in advance ^_^
Hey @pasquale_pellicani!
Ok, we can figure this out :). First, is the places autocomplete working after the Ajax call? I would expect yes, because it's properly using Stimulus (more explanation below), but I'm curious about that.
Ok, so the problem is... kind of a generic JavaScript problem, which Stimulus helps a lot. In your situation, the flow is like this:
A) The initial page loads
B) Your JavaScript in ui-datepicker.js
is called, which initializes the .datepicker()
on the .fc-datepicker
element (this probably includes adding a bunch of new elements around that element to support the date picker).
C) You reload part of the form, which replaces the entire form, including the .fc-datepicker
element and all of the fancy date picker stuff.
D) ... but your JavaScript to initialize the date picker never runs again... so it never reinitializes.
The fix is to wrap this logic inside of a Stimulus controller:
// assets/controllers/date-picker-controller.js
import $ from 'jquery';
import datepickerFactory from 'jquery-datepicker';
import datepickerITFactory from 'jquery-datepicker/i18n/jquery.ui.datepicker-it';
datepickerFactory($);
datepickerITFactory($);
export default class extends Controller {
connect() {
console.log('document (as function) is ready I load datepicker!!!');
$(this.element).datepicker();
$.datepicker.regional['it'];
}
});
Now, when you're rendering this field, we don't need an fc-datepicker
class anymore (you can add one for styling if you need it, but our JavaScript doesn't need it). Instead, we need a data-controller
attribute that points to our date-picker
controller:
{{ form_row(form.theDate, {
attr: stimulus_controller('date-picker')
}) }}
That's it! You should see a data-controller=""
attribute on your actual input
element on page reload. NOW here is the flow:
A) Page loads
B) Stimulus sees the data-controller="date-picker"
and calls your controller
C) Your controller's connect()
method is called. And this.element
points to your input
. So, you initialize the date picker fanciness.
D) You reload your form via Ajax. This removes the old date picker element entirely. You are, for a millisecond, back to a form without the fancy date picker!
E) But, Stimulus notices that the old data-controller
input element was removed and a new one was added. So, it calls connect()
again, and you initialize the data picker on the new element.
That's it! Let me know if this help :)
Cheers!
Hi all, sorry, I don't know whether to be ashamed or something, practically everything worked but the z-index of the modal won over the doms of the datepicker and the place autocomplete XD
p.s.
However, thanks Ryan I used your suggestion of the new controller for the ui-datepicker, I take this opportunity to ask another question, is the proliferation of controllers correct? Or is the ideal to consolidate everything into a single controller?
Woo! I'm always happy if it's something simle :).
I take this opportunity to ask another question, is the proliferation of controllers correct? Or is the ideal to consolidate everything into a single controller?
There's no perfect answer about where to "draw the line" between putting 2 things into 1 controller of having 2 controllers, but yes, in general, I think that more controllers is better... and usually just because it makes your life easier (so do whatever makes life easier!). One big benefit of a controller is that it is tied to an element and then you can reference it with this.element
. So, to give an example, if you, instead, had a controller that you associated with your form
element, you would then need to (in connect()
) find all of your date pickers - e.g. this.elemenet.querySelectorAll('.fc-daterpicker')
, loop over these, and initialize the date picker on them. That's just more work :P. And also, if some new HTML gets loaded into your form
element (but the form element itself remains), any new date picker element will not be "noticed" and reinitialized.
I hope that helps :)
Cheers!
Hey Krzysztof,
We have a separate screencast about uploads including Dropzone though that does not leverage Symfony UX: https://symfonycasts.com/screencast/symfony-uploads - I think you might be interested in that screencast too.
But if you're interested in Symfony UX Dropzone, please, take a look at the example in the official docs:
https://ux.symfony.com/dropzone
And more details are explained in https://symfony.com/bundles/ux-dropzone/current/index.html
I hope it helps!
Cheers!
Hi all, it would be ideal to add an example of this form with an image to upload to the server, we would need to review the form.serialize() instruction which does not deal with file field types, the ideal would be new FormData() but it doesn't seem like the solution definitive. What is the ideal solution when in addition to the form with data there would also be an image file to save ? thanks ;)
Hey @pasquale_pellicani!
My go to solution in general these days is actually FormData
and then applying that to fetch()
. And FormData
works fine with file uploads and will set the Content-Type
to multipart/form-data
for you. So yes, I would say that FormData
IS the definitive solution. Things like jQuery's serialize predate it, which is why they exist / existed. But these days, I go for FormData
.
Let me know if that works for you - or if not, what's going on :)
Cheers!
Hi, in reality I am taking up code from a project born about 10 years ago, therefore with sy3.4 where I intend to improve the code as I move on, but I must recognize that my predecessor knew a lot about js, to manage a form including an image to upload (optional) uses two separate forms in same view, the first is managed with the form.seralizeJSON fetch, the second in cascade with the new FormData() technique, really an excellent job ^_^ thanks for your replies
Hi!. Just a question for general knowledge... Is using jQuery still a thing in 2023? It makes sense to know that stimulus will support it, especially when refactoring older projects for example. But is it a good idea to use still use it in new projects?
Hey @escobarcampos
Yes, jQuery is still a thing, the team behind it seems active and working on the library. However, I don't recommend using it. With Stimulus + some vanilla JavaScript you can do wonders. Also, with Symfony UX and Live components, it's likely that you won't even need custom JavaScript
Cheers!
// 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
}
}
How to upload form with file input?
I have Symfony UX Dropzone for file input and I want to submit it in stimulus controller.