Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

A Great Place to Hide Things! The data- Attributes

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

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:

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

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

... 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'):

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

... 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'):

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

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

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

... 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'):

... 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'):

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

... 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!

12
Login or Register to join the conversation
Alessandro V. Avatar
Alessandro V. Avatar Alessandro V. | posted 2 years ago

Hi, i'm trying to make the function in controller but symfony doesn't find the right class. How can i resolve?

Reply

Hey Alessandro,

This sounds like you have a different problem not related to the function you're going to create. Please, double check your syntax, usually PhpStorm gives you hints about syntax errors and show the affected line. If you still need help - please, share the exact error, otherwise it's difficult to guess what's the problem exactly :)

Cheers!

Reply
MolloKhan Avatar MolloKhan | SFCASTS | posted 4 years ago | edited

Here is your problem


// base,html.twig

$.ajax({
url: 'deleteUrl', # Here is the problem. You need to pass the real URL to your AJAX request
...
Reply
Yurniel L. Avatar
Yurniel L. Avatar Yurniel L. | posted 4 years ago

So glad about this excellent class!
I'd like to know how to solve this problem when clicking on the item to be deleted.
"DELETE http://127.0.0.1:8000/deleteUrl 404 (Not Found)"

Reply

Hey Yurniel L.

How did you generate the delete URL? You are hitting /deleteUrl instead of /reps/{ID-of-the-rep}

Cheers!

Reply
Default user avatar
Default user avatar Javier Mendez | posted 5 years ago

Magnificent class!

Reply

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

Reply

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/screencast/symfony2-ep2/twig-security-is-authenticated.

So, that's the basic answer: all security functions ultimately call the same core function, and that function supports ROLE_ stuff and these 3 ISAUTHENTICATED items as well. But, to go deeper, the real answer is voters :). https://knpuniversity.com/screencast/new-in-symfony3/voter. 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 ISAUTHENTICATED. 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!

Reply
Default user avatar

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

Reply

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

Reply
Default user avatar

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

Reply

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony... but since it's a JavaScript tutorial, the concepts are still ? valid!

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice