Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

AJAX Form Submit: The Lazy Way

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

I'm feeling pretty awesome about all our new skills. So let's turn to a new goal and some new challenges. Below the RepLog table, we have a very traditional form. When we fill it out, it submits to the server: no AJAX, no fanciness.

And no fun! Let's update this to submit via AJAX. Of course, that comes with a few other challenges, like needing to dynamically add a new row to the table afterwards.

AJAXify the Form

In general, there are two ways to AJAXify this form submit. First, there's the simple, traditional, easy, and lazy way! That is, we submit the form via AJAX and the server returns HTML. For example, if we forget to select an item to lift, the AJAX would return the form HTML with the error in it so we can render it on the page. Or, if it's successful, it would probably return the new <tr> HTML so we can put it into the table. This is easier... because you don't need to do all that much in JavaScript. But, this approach is also a bit outdated.

The second approach, the more modern approach, is to actually treat your backend like an API. This means that we'll only send JSON back and forth. But this also means that we'll need to do more work in JavaScript! Like, we need to actually build the new <tr> HTML row by hand from the JSON data!

Obviously, that is where we need to get to! But we'll start with the old-school way first, and then refactor to the modern approach as we learn more and more cool stuff.

Making $wrapper Wrap Everything

In both situations, step one is the same: we need attach a listener on submit of the form. Head over to our template:

... lines 1 - 2
{% block body %}
<div class="row">
<div class="col-md-7">
... lines 6 - 52
{{ include('lift/_form.html.twig') }}
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 77

The form itself lives in another template that's included here: _form.html.twig inside app/Resources/views/lift:

{{ form_start(form, {
'attr': {
'class': 'form-inline',
'novalidate': 'novalidate'
}
}) }}
{{ form_errors(form) }}
{{ form_row(form.item, {
'label': 'What did you lift?',
'label_attr': {'class': 'sr-only'}
}) }}
{{ form_row(form.reps, {
'label': 'How many times?',
'label_attr': {'class': 'sr-only'},
'attr': {'placeholder': 'How many times?'}
}) }}
<button type="submit" class="btn btn-primary">I Lifted it!</button>
{{ form_end(form) }}

This is a Symfony form, but all this fanciness ultimately renders a good, old-fashioned form tag. Give the form another class: js-new-rep-log-form:

{{ form_start(form, {
'attr': {
'class': 'form-inline js-new-rep-log-form',
'novalidate': 'novalidate'
}
}) }}
... lines 7 - 20
{{ form_end(form) }}

Copy that and head into RepLogApp so we can attach a new listener. But wait... there is one problem: the $wrapper is actually the <table> element:

... lines 1 - 2
{% block body %}
<div class="row">
<div class="col-md-7">
... lines 6 - 12
<table class="table table-striped js-rep-log-table">
... lines 14 - 50
</table>
{{ include('lift/_form.html.twig') }}
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 77

And the form does not live inside of the <table>!

When you create little JavaScript applications like RepLogApp, you want the wrapper to be an element that goes around everything you need to manipulate.

Ok, no problem: let's move the js-rep-log-table class from the table itself to the div that surrounds everything:

... lines 1 - 2
{% block body %}
<div class="row">
<div class="col-md-7 js-rep-log-table">
... lines 6 - 12
<table class="table table-striped">
... lines 14 - 50
</table>
... lines 52 - 53
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 77

Down below, I don't need to change anything here, but let's rename $table to $wrapper for clarity:

... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
$(document).ready(function() {
var $wrapper = $('.js-rep-log-table');
var repLogApp = new RepLogApp($wrapper);
});
</script>
{% endblock %}

The Form Submit Listener

Now adding our listener is simple: this.$wrapper.find() and look for .js-new-rep-log-form. Then, .on('submit'), have this call a new method: this.handleNewFormSubmit. And don't forget the all-important .bind(this):

... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 15
this.$wrapper.find('.js-new-rep-log-form').on(
'submit',
this.handleNewFormSubmit.bind(this)
);
};
... lines 21 - 81
})(window, jQuery);

Down below, add that function - handleNewFormSubmit - and give it the event argument. This time, calling e.preventDefault() will prevent the form from actually submitting, which is good. For now, just console.log('submitting'):

... lines 1 - 2
(function(window, $) {
... lines 4 - 21
$.extend(window.RepLogApp.prototype, {
... lines 23 - 58
handleNewFormSubmit: function(e) {
e.preventDefault();
console.log('submitting!');
}
});
... lines 64 - 81
})(window, jQuery);

Ok, test time! Head back, refresh, and try the form. Yes! We get the log, but the form doesn't submit.

Adding AJAX

Turning this form into an AJAX call will be really easy... because we already know that this form works if we submit it in the traditional way. So let's just literally send that exact same request, but via AJAX.

First, get the form with $form = $(e.currentTarget):

... lines 1 - 2
(function(window, $) {
... lines 4 - 21
$.extend(window.RepLogApp.prototype, {
... lines 23 - 58
handleNewFormSubmit: function(e) {
e.preventDefault();
var $form = $(e.currentTarget);
... lines 63 - 67
}
});
... lines 70 - 87
})(window, jQuery);

Next, add $.ajax(), set the url to $form.attr('action') and the method to POST. For the data, use $form.serialize():

... lines 1 - 2
(function(window, $) {
... lines 4 - 21
$.extend(window.RepLogApp.prototype, {
... lines 23 - 58
handleNewFormSubmit: function(e) {
e.preventDefault();
var $form = $(e.currentTarget);
$.ajax({
url: $form.attr('action'),
method: 'POST',
data: $form.serialize()
});
}
});
... lines 70 - 87
})(window, jQuery);

That's a really lazy way to get all the values for all the fields in the form and put them in the exact format that the server is accustomed to seeing for a form submit.

That's already enough to work! Submit that form! Yea, you can see the AJAX calls in the console and web debug toolbar. Of course, we don't see any new rows until we manually refresh the page...

So that's where the real work starts: showing the validation errors on the form on error and dynamically inserting a new row on success. Let's do it!

Leave a comment!

16
Login or Register to join the conversation
thephilosoft Avatar
thephilosoft Avatar thephilosoft | posted 1 year ago
...that is, we submit the form via AJAX and the server returns HTML. ... This is easier... because you don't need to do all that much in JavaScript. But, this approach is also a bit outdated.

fast-forward 5 years. stimulus and hotwire are a thing. boy, what a spiral. although worth to mention pjax along the way

Reply

Hey, Alexandr!

It's truly amazing how things developed in the frontend world! It's funny because when I looked at this tutorial the first time, I had many reservations about this approach. My entire life I had built clients based on APIs and JSON data being transfered...

Then the whole world changed, but I think what made this change possible was Stimulus. Without stimulus, there's very little motivation to return HTML from an ajax call (except for utter simplification)!.

Reply

Hey thank you guys for the vedio. Trying to decouple the `createApiResetApi` and the `getErrorsFormForm` inside a service But I didnt find a cool name for the php class that represent the logic behind. Any idea ? Maybe `RestViewHandler'? or APIhandler ..but they does not really seems correct to me

Reply

Hey ahmedbhs!

Yea, both of those functions are related... but not that related- the first creates responses, while the second reformats some stuff so that it can be used to create a response later... So, honestly, if you want to put them into a service, maybe even ApiResponseUtil - something like that. Best idea I've got :).

Cheers!

Reply
Peter-K Avatar
Peter-K Avatar Peter-K | posted 5 years ago

this is the question regarding IDE, how did you manage to change $table to $wrapper on line 75 from line 73? What do I need to do?

Reply

Hey Peter K.

What Ryan did is called "Rename refactoring" and PhpStorm has a shortcut for it. If you are on windows, just type "shift + F6" (over a variable) and you will be able to do the same.
You can learn the most useful shortcuts here: https://www.jetbrains.com/h...
Or even better, watch our tutorial about developing with PhpStorm: https://knpuniversity.com/s...

Cheers!

2 Reply
Peter-K Avatar

thanks I will watch that one. I am on mac and I have short fingers cant even reach fn+shift+f6 with 1 hand :D lol

Reply

Lol, I agree, not an easy shortcut for 1 hand :)

Reply

haha, is like playing the piano!

Reply
Shaun T. Avatar
Shaun T. Avatar Shaun T. | posted 5 years ago

In RepLogController.php you have a function called newRepLogAction. I was wondering why you create a new form type rather than just creating and persisting a new Replog instance?

Reply

Hey Shaun T.

Most of the time is better to use a FormType instead of manually creating your entity objects because there is a lot of built-in functionality, like property constraints, form life cycles, CSRF protection, etc

I hope it answers your question :)

Reply
Shaun T. Avatar
Shaun T. Avatar Shaun T. | MolloKhan | posted 5 years ago | edited

Many thanks @MolloKhan :)

Reply
Max S. Avatar

With
var $form = $(e.currentTarget).attr('action');
console.log($form);

I get undefined. How is the url path generated?

Thx!

Reply
Victor Avatar Victor | SFCASTS | Max S. | posted 5 years ago | edited

Hey Max,

Could you double check you have the "action" attribute in your <form ... > tag? Also, do console.log($(e.currentTarget)) and ensure that the right form is selected.

Cheers!

Reply
Max S. Avatar
Max S. Avatar Max S. | Victor | posted 5 years ago | edited

Hey Victor,

well, the right form is selected, but there is no attribute 'action'. If I add


{{ form_start(form, {
    'attr': {
        'class': 'form-inline js-new-rep-log-form',
        'novalidate': 'novalidate',
        'action'    : 'asdf'
    }

to the twig file (I couldn't find a place in the video where this is added) I logically get 'asdf' back.
Interestingly although $form.attr('action') seems to be undefined an successful ajax-call to /lift is made... Setting the 'action': 'lift' produces an successful call as well.

Reply
Victor Avatar Victor | SFCASTS | Max S. | posted 5 years ago | edited

Yo Max,

Yeah, it's kind of interesting! Actually, we do specify "action" nowhere and $form.attr('action') returns "undefined" - it's exactly as in our code. But when you set "url" to undefined - jQuery sends AJAX call to the same page where you are on, i.e. "/lift". So you can specify action explicitly... or just leave form without action and jQuery will send AJAX calls to the same URL.

Further in this course we'll add data-url" attribute to this form and will use it for AJAX calls:
https://knpuniversity.com/screencast/javascript/post-proper-api-endpoint

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony... but since it's a JavaScript tutorial, the concepts are still ? valid!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.0",
        "symfony/symfony": "3.1.*", // v3.1.10
        "twig/twig": "2.10.*", // v2.10.0
        "doctrine/orm": "^2.5", // v2.7.1
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.2
        "symfony/swiftmailer-bundle": "^2.3", // v2.4.0
        "symfony/monolog-bundle": "^2.8", // 2.12.0
        "symfony/polyfill-apcu": "^1.0", // v1.2.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "~2.0@dev", // dev-master
        "doctrine/doctrine-fixtures-bundle": "~2.3", // v2.4.1
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.2.1
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "friendsofsymfony/jsrouting-bundle": "^1.6" // 1.6.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.1
        "symfony/phpunit-bridge": "^3.0" // v3.1.6
    }
}
userVoice