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 use AJAX to submit this form, there are two possible responses: one if there was a form validation error and one if the submit was successful.
If we have an error response, for now, we need to return the HTML for this form, but with the validation error and styling messages included in it.
In our project, find the LiftController
in src/AppBundle/Controller
. The indexAction()
method is responsible for both initially rendering the form on page load, and for handling the form submit:
... lines 1 - 9 | |
class LiftController extends BaseController | |
{ | |
/** | |
* @Route("/lift", name="lift") | |
*/ | |
public function indexAction(Request $request) | |
{ | |
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); | |
$form = $this->createForm(RepLogType::class); | |
$form->handleRequest($request); | |
if ($form->isValid()) { | |
$em = $this->getDoctrine()->getManager(); | |
$repLog = $form->getData(); | |
$repLog->setUser($this->getUser()); | |
$em->persist($repLog); | |
$em->flush(); | |
$this->addFlash('notice', 'Reps crunched!'); | |
return $this->redirectToRoute('lift'); | |
} | |
$repLogs = $this->getDoctrine()->getRepository('AppBundle:RepLog') | |
->findBy(array('user' => $this->getUser())) | |
; | |
$totalWeight = 0; | |
foreach ($repLogs as $repLog) { | |
$totalWeight += $repLog->getTotalWeightLifted(); | |
} | |
return $this->render('lift/index.html.twig', array( | |
'form' => $form->createView(), | |
'repLogs' => $repLogs, | |
'leaderboard' => $this->getLeaders(), | |
'totalWeight' => $totalWeight, | |
)); | |
} | |
... lines 50 - 80 |
If you're not too familiar with Symfony, don't worry. But, at the bottom, add an if statement: if this is an AJAX request, then - at this point - we know we've failed form validation:
... lines 1 - 9 | |
class LiftController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function indexAction(Request $request) | |
{ | |
... lines 17 - 37 | |
$totalWeight = 0; | |
foreach ($repLogs as $repLog) { | |
$totalWeight += $repLog->getTotalWeightLifted(); | |
} | |
// render just the form for AJAX, there is a validation error | |
if ($request->isXmlHttpRequest()) { | |
... lines 45 - 47 | |
} | |
... lines 49 - 55 | |
} | |
... lines 57 - 85 | |
} |
Instead of returning the entire HTML page - which you can see it's doing right now - let's render just the form HTML. Do that with return $this->render('lift/_form.html.twig')
passing that a form
variable set to $form->createView()
:
... lines 1 - 9 | |
class LiftController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function indexAction(Request $request) | |
{ | |
... lines 17 - 42 | |
// render just the form for AJAX, there is a validation error | |
if ($request->isXmlHttpRequest()) { | |
return $this->render('lift/_form.html.twig', [ | |
'form' => $form->createView() | |
]); | |
} | |
... lines 49 - 55 | |
} | |
... lines 57 - 85 | |
} |
Remember, the _form.html.twig
template is included from index, and holds just the form.
And just like that! When we submit, we now get that HTML fragment.
Back in RepLogApp
, add a success
key to the AJAX call with a data
argument: that will be the HTML we want to put on the page:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 62 | |
$.ajax({ | |
... lines 64 - 66 | |
success: function(data) { | |
... line 68 | |
} | |
}); | |
} | |
}); | |
... lines 73 - 90 | |
})(window, jQuery); |
We need to replace all of this form code. I'll surround the form with a new element and give it a js-new-rep-log-form-wrapper
class:
... lines 1 - 2 | |
{% block body %} | |
<div class="row"> | |
<div class="col-md-7 js-rep-log-table"> | |
... lines 6 - 52 | |
<div class="js-new-rep-log-form-wrapper"> | |
{{ include('lift/_form.html.twig') }} | |
</div> | |
</div> | |
... lines 57 - 63 | |
</div> | |
{% endblock %} | |
... lines 66 - 79 |
Back in success
, use $form.closest()
to find that, then replace its HTML with data
:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 62 | |
$.ajax({ | |
... lines 64 - 66 | |
success: function(data) { | |
$form.closest('.js-new-rep-log-form-wrapper').html(data); | |
} | |
}); | |
} | |
}); | |
... lines 73 - 90 | |
})(window, jQuery); |
Tip
We could have also used the replaceWith()
jQuery function instead of targeting
a parent element.
Sweet! Let's enjoy our work! Refresh and submit! Nice! But if I put 5 into the box and hit enter to submit a second time... it doesn't work!? What the heck? We'll fix that in a minute.
What about when we don't fail validation? In that case, we'll want to dynamically add a new row to the table. In other words, the AJAX call should once again return an HTML fragment: this time for a single <tr>
row: this row right here.
To do that, we need to isolate it into its own template. Copy it, delete it, and create a new template: _repRow.html.twig
. Paste the contents here:
<tr data-weight="{{ repLog.totalWeightLifted }}"> | |
<td>{{ repLog.itemLabel|trans }}</td> | |
<td>{{ repLog.reps }}</td> | |
<td>{{ repLog.totalWeightLifted }}</td> | |
<td> | |
<a href="#" | |
class="js-delete-rep-log" | |
data-url="{{ path('rep_log_delete', {id: repLog.id}) }}" | |
> | |
<span class="fa fa-trash"></span> | |
</a> | |
</td> | |
</tr> |
Back in the main template, include this: lift/_repRow.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 - 22 | |
{% for repLog in repLogs %} | |
{{ include('lift/_repRow.html.twig') }} | |
{% else %} | |
... lines 26 - 28 | |
{% endfor %} | |
... lines 30 - 38 | |
</table> | |
... lines 40 - 43 | |
</div> | |
... lines 45 - 51 | |
</div> | |
{% endblock %} | |
... lines 54 - 67 |
Now that we've done this, we can render it directly in LiftController
. We know that the form was submitted successfully if the code inside the $form->isValid()
block is executed. Instead of redirecting to another page, if this is AJAX, then return $this->render('lift/_repRow.html.twig')
and pass it the one variable it needs: repLog
set to repLog
:
... lines 1 - 10 | |
class LiftController extends BaseController | |
{ | |
... lines 13 - 15 | |
public function indexAction(Request $request) | |
{ | |
... lines 18 - 22 | |
if ($form->isValid()) { | |
... lines 24 - 28 | |
$em->flush(); | |
// return a blank form after success | |
if ($request->isXmlHttpRequest()) { | |
return $this->render('lift/_repRow.html.twig', [ | |
'repLog' => $repLog | |
]); | |
} | |
... lines 37 - 40 | |
} | |
... lines 42 - 65 | |
} | |
... lines 67 - 95 | |
} |
And just by doing that, when we submit successfully, our AJAX endpoint returns the new <tr>
.
But, our JavaScript code is already confused! It thought the new <tr>
code was the error response, and replaced the form with it. Lame! Our JavaScript code needs to be able to distinguish between a successful request and one that failed with validation errors.
There's a perfectly standard way of doing this... and I was being lazy until now! On error, we should not return a 200 status code, and that's what the render()
function gives us by default. When you return a 200 status code, it activates jQuery's success
handler.
Instead, we should return a 400 status code, or really, anything that starts with a 4. To do that, add $html =
and then change render()
to renderView()
:
... lines 1 - 10 | |
class LiftController extends BaseController | |
{ | |
... lines 13 - 15 | |
public function indexAction(Request $request) | |
{ | |
... lines 18 - 50 | |
// render just the form for AJAX, there is a validation error | |
if ($request->isXmlHttpRequest()) { | |
$html = $this->renderView('lift/_form.html.twig', [ | |
'form' => $form->createView() | |
]); | |
... lines 56 - 57 | |
} | |
... lines 59 - 65 | |
} | |
... lines 67 - 95 | |
} |
This new method simply gives us the string HTML for the page. Next, return a new Response
manually and pass it the content - $html
- and status code - 400
:
... lines 1 - 10 | |
class LiftController extends BaseController | |
{ | |
... lines 13 - 15 | |
public function indexAction(Request $request) | |
{ | |
... lines 18 - 50 | |
// render just the form for AJAX, there is a validation error | |
if ($request->isXmlHttpRequest()) { | |
$html = $this->renderView('lift/_form.html.twig', [ | |
'form' => $form->createView() | |
]); | |
return new Response($html, 400); | |
} | |
... lines 59 - 65 | |
} | |
... lines 67 - 95 | |
} |
As soon as we do that, the success
function will not be called on errors. Instead, the error
function will be called. For an error
callback, the first argument is not the data from the response, it's a jqXHR
object:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 63 | |
$.ajax({ | |
... lines 65 - 70 | |
error: function(jqXHR) { | |
... lines 72 - 73 | |
} | |
}); | |
} | |
}); | |
... lines 78 - 95 | |
})(window, jQuery); |
That's fine, because the response content is stored on jqXHR.responseText
:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 63 | |
$.ajax({ | |
... lines 65 - 70 | |
error: function(jqXHR) { | |
$form.closest('.js-new-rep-log-form-wrapper') | |
.html(jqXHR.responseText); | |
} | |
}); | |
} | |
}); | |
... lines 78 - 95 | |
})(window, jQuery); |
Now we can use the success
function to add the new tr
to the table. Before the AJAX call - to avoid any problems with the this
variable - add $tbody = this.$wrapper.find('tbody')
:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 62 | |
var $tbody = this.$wrapper.find('tbody'); | |
$.ajax({ | |
... lines 65 - 74 | |
}); | |
} | |
}); | |
... lines 78 - 95 | |
})(window, jQuery); |
And in success
, add $tbody.append(data)
:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 62 | |
var $tbody = this.$wrapper.find('tbody'); | |
$.ajax({ | |
... lines 65 - 67 | |
success: function(data) { | |
$tbody.append(data); | |
}, | |
... lines 71 - 74 | |
}); | |
} | |
}); | |
... lines 78 - 95 | |
})(window, jQuery); |
That should do it!
Try it! Refresh the page! If we submit with errors, we get the errors! If we submit with something correct, a new row is added to the table. The only problem is that it doesn't update the total dynamically - that still requires a refresh.
But that's easy to fix! Above the AJAX call, add var self = this
. And then inside success
, call self.updateTotalWeightLifted()
:
... lines 1 - 2 | |
(function(window, $) { | |
... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
... lines 60 - 63 | |
var self = this; | |
$.ajax({ | |
... lines 66 - 68 | |
success: function(data) { | |
$tbody.append(data); | |
self.updateTotalWeightLifted(); | |
}, | |
... lines 73 - 76 | |
}); | |
} | |
}); | |
... lines 80 - 97 | |
})(window, jQuery); |
And now, it's all updating and working perfectly.
Except... if you try to submit the form twice in a row... it refreshes fully. It's like our JavaScript stops working after one submit. And you know what else? If you try to delete a row that was just added via JavaScript, it doesn't work either! Ok, let's find out why!
Hey Will T.!
Oh no! Let's see if we can figure this out :). The fact that you're getting a double post to both the delete and lift endpoints makes me think that, somehow, your JavaScript click and submit listeners are being executed multiple times. But to make sure I'm not mistaking things, when you click to delete, you see 2 AJAX requests in your network tools, correct? Oh, and if that is correct, in the network tools in your browser, one of the columns in the network tools next to the request should be "initiator" (it may say something like "someFile.js:6"). Are both of the "initiators" identical or are they different?
Anyways, let's do some debugging! At the top of RepLogApp.js - (if you go to https://symfonycasts.com/screencast/javascript/old-school-ajax-html#codeblock-94f1aed6fd - and expand that code block to full, I'm talking about lines 4-20) we initialize the listeners. I'd add a console.log('init')
to that function. In theory, it should only be called once. But it sounds almost like it's called twice in your case. And the answer will help us debug.
Let me know what you find!
Cheers!
When LiftController.php is getting changed shouldn't we leave in 'repLog' => $repLog
with the render?
Hey @Daniel
I don't fully get your question. Could you tell to which part of video you are talking about?
Cheers!
Will the following code make more sense to update the total weight after success ajax call? I feel really weird to use the calculateTotalWeight method in the ReplogApp object.
handleNewFormSubmit: function (e) {
...
var $totalWeight = this.$wrapper.find('.js-total-weight');
var helper = this.helper;
$.ajax({
...
success: function(data) {
$totalWeight.html(helper.calculateTotalWeight());
})
}
Hey Yan,
But we do the same in the screencast, just move this code you mentioned (finding ".js-total-weight" element and calling calculateTotalWeight() method) to the separate method which is called updateTotalWeightLifted() - we use it in a few places: handleNewFormSubmit() and handleRepLogDelete() methods, so we get rid of code duplication this way.
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
}
}
I'm getting a double post to the delete and lift end points.
Can someone help me fix this?