A Great Place to Hide Things! The data- Attributes

Time to finally hook up the AJAX and delete one of these rows! Woohoo!

As an early birthday gift, I already took care of the server-side work for us. If you want to check it out, it's inside of the src/AppBundle/Controller directory: RepLogController:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 2
namespace AppBundle\Controller;
... lines 4 - 13
class RepLogController extends BaseController
{
... lines 16 - 129
}

I have a bunch of different RESTful API endpoints and one is called, deleteRepLogAction():

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 5
use AppBundle\Entity\RepLog;
... line 7
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
... line 10
use Symfony\Component\HttpFoundation\Response;
... lines 12 - 13
class RepLogController extends BaseController
{
... lines 16 - 46
/**
* @Route("/reps/{id}", name="rep_log_delete")
* @Method("DELETE")
*/
public function deleteRepLogAction(RepLog $repLog)
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$em = $this->getDoctrine()->getManager();
$em->remove($repLog);
$em->flush();
return new Response(null, 204);
}
... lines 60 - 129
}

As long as we make a DELETE request to /reps/ID-of-the-rep, it'll delete it and return a blank response. Happy birthday!

Back in index.html.twig, inside of our listener function, how can we figure out the DELETE URL for this row? Or, even more basic, what's the ID of this specific RepLog? I have no idea! Yay!

We know that this link is being clicked, but it doesn't give us any information about the RepLog itself, like its ID or delete URL.

Adding a data-url Attribute

This is a really common problem, and the solution is to somehow attach extra metadata to our DOM about the RepLog, so we can read it in JavaScript. And guess what! There's an official, standard, proper way to do this! It's via a data attribute. Yep, according to those silly "rules" of the web, you're not really supposed to invent new attributes for your elements. Well, unless the attribute starts with data-, followed by lowercase letters. That's totally allowed!

Go Deeper!

You can actually read the "data attributes" spec here: http://bit.ly/dry-spec-about-data-attributes

So, add an attribute called data-url and set it equal to the DELETE URL for this RepLog. The Symfony way of generating this is with path(), the name of the route - rep_log_delete - and the id: repLog.id:

98 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 - 22
{% for repLog in repLogs %}
<tr>
... lines 25 - 27
<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>
... lines 37 - 40
{% endfor %}
... lines 42 - 50
</table>
... lines 52 - 53
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 98

Reading data- Attributes

Sweet! To read that in JavaScript, simply say var deleteUrl = $(this), which we know is the link, .data('url'):

98 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(this).addClass('text-danger');
$(this).find('.fa')
.removeClass('fa-trash')
.addClass('fa-spinner')
.addClass('fa-spin');
var deleteUrl = $(this).data('url');
... lines 82 - 89
});
... lines 91 - 94
});
</script>
{% endblock %}

That's a little bit of jQuery magic: .data() is a shortcut to read a data attribute.

Tip

.data() is a wrapper around core JS functionality: the data-* attributes are also accessible directly on the DOM Element Object:

var deleteUrl = $(this)[0].dataset.url;

Finally, the AJAX call is really simple! I'll use $.ajax, set url to deleteUrl, method to DELETE, and ice_cream to yes please!. I mean, success, set to a function:

98 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 80
var deleteUrl = $(this).data('url');
... line 82
$.ajax({
url: deleteUrl,
method: 'DELETE',
success: function() {
... line 87
}
});
});
... lines 91 - 94
});
</script>
{% endblock %}

Hmm, so after this finishes, we probably want the entire row to disappear. Above the AJAX call, find the row with $row = $(this).closest('tr'):

98 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 80
var deleteUrl = $(this).data('url');
var $row = $(this).closest('tr');
$.ajax({
url: deleteUrl,
method: 'DELETE',
success: function() {
... line 87
}
});
});
... lines 91 - 94
});
</script>
{% endblock %}

In other words, start with the link, and go up the DOM tree until you find a tr element. Oh, and reminder, this is $row because this is a jQuery object! Inside success, say $row.fadeOut() for just a little bit of fancy:

98 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 80
var deleteUrl = $(this).data('url');
var $row = $(this).closest('tr');
$.ajax({
url: deleteUrl,
method: 'DELETE',
success: function() {
$row.fadeOut();
}
});
});
... lines 91 - 94
});
</script>
{% endblock %}

Ok, try that out! Refresh, delete my coffee cup and life is good. And if I refresh, it's truly gone. Oh, but dang, if I delete my cup of coffee record, the total weight at the bottom does not change. I need to refresh the page to do that. LAME! I'll re-add my coffee cup. Now, let's fix that!

Adding data-weight Metadata

If we somehow knew what the weight was for this specific row, we could read the total weight and just subtract it when it's deleted. So how can we figure out the weight for this row? Well, we could just read the HTML of the third column... but that's kinda shady. Instead, why not use another data- attribute?

On the <tr> element, add a data-weight attribute set to repLog.totalWeightLifted:

101 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 - 22
{% for repLog in repLogs %}
<tr data-weight="{{ repLog.totalWeightLifted }}">
... lines 25 - 35
</tr>
... lines 37 - 40
{% endfor %}
... lines 42 - 50
</table>
... lines 52 - 53
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 101

Also, so that we know which th to update, add a class: js-total-weight:

101 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 - 42
<tfoot>
<tr>
... lines 45 - 46
<th class="js-total-weight">{{ totalWeight }}</th>
... line 48
</tr>
</tfoot>
</table>
... lines 52 - 53
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 101

Let's hook this up! Before the AJAX call - that's important, we'll find out why soon - find the total weight container by saying $table.find('.js-total-weight'):

101 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 81
var $row = $(this).closest('tr');
var $totalWeightContainer = $table.find('.js-total-weight');
... lines 84 - 92
});
... lines 94 - 97
});
</script>
{% endblock %}

Next add var newWeight set to $totalWeightContainer.html() - $row.data('weight'):

101 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 81
var $row = $(this).closest('tr');
var $totalWeightContainer = $table.find('.js-total-weight');
var newWeight = $totalWeightContainer.html() - $row.data('weight');
... lines 85 - 92
});
... lines 94 - 97
});
</script>
{% endblock %}

Use that inside success: $totalWeightContainer.html(newWeight):

101 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
$(document).ready(function() {
... lines 70 - 71
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 73 - 81
var $row = $(this).closest('tr');
var $totalWeightContainer = $table.find('.js-total-weight');
var newWeight = $totalWeightContainer.html() - $row.data('weight');
$.ajax({
... lines 86 - 87
success: function() {
$row.fadeOut();
$totalWeightContainer.html(newWeight);
}
});
});
... lines 94 - 97
});
</script>
{% endblock %}

Let's give this fanciness a try. Go back refresh. 459? Hit delete, it's gone. 454.

Now, how about we get into trouble with some JavaScript objects!

Leave a comment!

  • 2017-05-26 Diego Aguiar

    Hey Chris!

    That's correct, you need to write your own Voter that checks if the user is the owner of that post. Checking for "IS_AUTHENTICATED_***" only tells you if the user is logged in via a form, cookie or anonymous

    Have a nice day :)

  • 2017-05-26 Chris

    Hello Ryan,

    so I assume that in this case I have to write a special Voter so that nobody else than the creator himself can delete his own RepRow post?

    Otherwhise, even with IS_AUTHENTICATED_REMEMBERED everybody else, who is logged in can delete his post?Am I right?
    Thank you :)

  • 2017-04-19 weaverryan

    Yo Ivan!

    Hahaha - actually, this is our fault! When I originally wrote the code for this line, I was using this. And so, putting it before the AJAX callback was important - since this is replaced. But, with the final code, we're not using this, so it is actually *not* important where this code lives. My mistake for not catching that! Thanks for bringing it up!

    Cheers!

  • 2017-04-17 Ivan

    "Before the AJAX call - that's important, we'll find out why soon - find the total weight container by saying $table.find('.js-total-weight')"

    Sorry but I haven't found a reason behind this. Shame on me :)

  • 2017-02-15 Javier Mendez

    Magnificent class!

  • 2017-01-21 weaverryan

    Hey Greg!

    Sure thing :). So, denyAccessUnlessGranted - and really, *any* way that you check security, including is_granted in Twig and even the roles you list for each access_control in security.yml - they all, ultimately call the `isGranted()` method on the security.authorization_checker service (this service was called security.context before 2.8). In other words, whatever you can pass to denyAccessUnlessGranted is the same thing you can pass in any of these other situations.

    Now, at first, the security system really only supports passing one type of thing to these functions: roles (e.g. anything starting with ROLE_). I'm guessing that makes good sense to you :). But, the security system *also* supports passing three other special things: IS_AUTHENTICATED_ANONYMOUSLY, IS_AUTHENTICATED_REMEMBERED and IS_AUTHENTICATED_FULLY. These strings correspond to the *level* of authentication of the user - e.g. "are they logged in?" or "are they logged in via a remember me cookie only?". We actually explain this best in an older Symfony 2 tutorial: https://knpuniversity.com/s....

    So, that's the basic answer: all security functions ultimately call the same core function, and that function supports ROLE_ stuff and these 3 IS_AUTHENTICATED_ items as well. But, to go deeper, the *real* answer is voters :). https://knpuniversity.com/s.... Symfony's core comes with 2 (this is not 100% true, but just pretend it is) voters: one that handles ROLE_ items and one that handles IS_AUTHENTICATED_. So actually, you can pass ANYTHING you want to denyAccessUnlessGranted (or the other functions), as long as you create a new voter that can handle that string.

    That was probably more info than you wanted - I hope some of it was what you were looking for :).

    Cheers!

  • 2017-01-21 Greg

    Hi Ryan,

    I have a little question about Symfony ;)
    In delete method you have a method denyAccessUnlessGranted, I know this is a new method since sf 2.8 or 2.7 but I didn't know that you can pass 'IS_AUTHENTICATED_REMEMBER'.
    Could you explain me a little this?

    Thanks

    Cheers

    Greg