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 SubscribeI'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.
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.
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 %} |
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.
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!
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)!.
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
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!
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?
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!
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
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?
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 :)
With
var $form = $(e.currentTarget).attr('action');
console.log($form);
I get undefined. How is the url path generated?
Thx!
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!
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.
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!
// 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
}
}
fast-forward 5 years. stimulus and hotwire are a thing. boy, what a spiral. although worth to mention pjax along the way