Old-School AJAX HTML

When 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:

80 lines src/AppBundle/Controller/LiftController.php
... 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:

87 lines src/AppBundle/Controller/LiftController.php
... 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():

87 lines src/AppBundle/Controller/LiftController.php
... 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.

Adding AJAX Success

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:

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

79 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 - 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:

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

Handling Form Success

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:

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

67 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 - 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:

97 lines src/AppBundle/Controller/LiftController.php
... 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>.

Distinguishing Between Success and Error

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():

97 lines src/AppBundle/Controller/LiftController.php
... 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:

97 lines src/AppBundle/Controller/LiftController.php
... 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:

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

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

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

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

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

Leave a comment!

  • 2017-07-04 Victor Bocharsky

    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!

  • 2017-06-30 Yan Yong

    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());
    })
    }