AJAX Form Submit: The Lazy Way

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:

77 lines app/Resources/views/lift/index.html.twig
... 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:

22 lines app/Resources/views/lift/_form.html.twig
{{ 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:

22 lines app/Resources/views/lift/_form.html.twig
{{ 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:

77 lines app/Resources/views/lift/index.html.twig
... 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:

77 lines app/Resources/views/lift/index.html.twig
... 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:

77 lines app/Resources/views/lift/index.html.twig
... 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):

83 lines web/assets/js/RepLogApp.js
... 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'):

83 lines web/assets/js/RepLogApp.js
... 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):

89 lines web/assets/js/RepLogApp.js
... 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():

89 lines web/assets/js/RepLogApp.js
... 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!

  • 2017-02-17 Victor Bocharsky

    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/s...

    Cheers!

  • 2017-02-17 Max

    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.

  • 2017-02-13 Victor Bocharsky

    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!

  • 2017-02-11 Max

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

    I get undefined. How is the url path generated?

    Thx!