Course Javascript
Course Javascript
Course Javascript
Guys, get ready to pump up... on your JavaScript skills! No, no, I'm not talking about the basics. Look, I get it: you know how
to write JavaScript, you're a ninja and a rock star all at once with jQuery. That's awesome! In fact, it's exactly where I want to
start. Because in this tutorial, we're going to flex our muscles and start asking questions about how things - that we've used
for years - actually work.
And this will make us more dangerous right away. But, but but! It's also going to lead us to our real goal: building a foundation
so we can learn about ridiculously cool things in future tutorials, like module loaders and front-end frameworks like ReactJS.
Yep, in a few short courses, we're going to take a traditional HTML website and transform it into a modern, hipster,
JavaScript-driven front-end. So buckle up.
Anyways, download the course code from any page and unzip it to find a start/ directory. That will have the same code that
you see here. Follow the details in the README.md file to get your project set up.
The last step will be to open a terminal, move into your project and do 50 pushups. I mean, run:
./bin/console server:run
to start the built-in PHP web server. Now, this is a Symfony project but we're not going to talk a lot about Symfony: we'll focus
on JavaScript. Pull up the site by going to http://localhost:8000.
Welcome... to Lift Stuff: an application for programmers, like us, who spend all of their time on a computer. With Lift Stuff, they
can stay in shape and record the things that they lift while working.
Let me show you: login as ron_furgandy, password pumpup. This is the only important page on the site. On the left, we have
a history of the things that we've lifted, like our cat. We can lift many different things, like a fat cat, our laptop, or our coffee
cup. Let's get in shape and lift our coffee cup 10 times. I lifted it! Our progress is saved, and we're even moving up the super-
retro leaderboard on the right! I'm coming for you Meowly Cyrus!
Right now, this entire page is rendered on the server, and the template lives at app/Resources/views/lift/index.html.twig:
60 lines app/Resources/views/lift/index.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<div class="row">
<div class="col-md-7">
<h2>
Lift History
<a href="#list-stuff-form" class="btn btn-md btn-success pull-right">
<span class="fa fa-plus"></span> Add
</a>
</h2>
<table class="table table-striped">
<thead>
<tr>
<th>What</th>
<th>How many times?</th>
<th>Weight</th>
<th> </th>
</tr>
</thead>
<tbody>
{% for repLog in repLogs %}
<tr>
<td>{{ repLog.itemLabel|trans }}</td>
<td>{{ repLog.reps }}</td>
<td>{{ repLog.totalWeightLifted }}</td>
<td>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Get liftin'!</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td> </td>
<th>Total</th>
<th>{{ totalWeight }}</th>
<td> </td>
</tr>
</tfoot>
</table>
{{ include('lift/_form.html.twig') }}
</div>
<div class="col-md-5">
<div class="leaderboard">
<h2 class="text-center"><img class="dumbbell" src="{{ asset('assets/images/dumbbell.png') }}">Leaderboard</h2>
{{ include('lift/_leaderboard.html.twig') }}
</div>
</div>
</div>
{% endblock %}
Inside, we're looping over something I call a repLog to build the table:
60 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">
... lines 14 - 22
{% for repLog in repLogs %}
<tr>
<td>{{ repLog.itemLabel|trans }}</td>
<td>{{ repLog.reps }}</td>
<td>{{ repLog.totalWeightLifted }}</td>
<td>
</td>
</tr>
... lines 32 - 35
{% endfor %}
... lines 37 - 45
</table>
... lines 47 - 49
</div>
... lines 51 - 57
</div>
{% endblock %}
Each repLog represents one item we've lifted, and it's the only important table in the database. It has an id, the number of
reps that we lifted and the total weight:
72 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">
... lines 14 - 22
{% for repLog in repLogs %}
<tr>
... lines 25 - 27
<td>
<a href="#" class="js-delete-rep-log">
... line 30
</a>
</td>
</tr>
... lines 34 - 37
{% endfor %}
... lines 39 - 47
</table>
... lines 49 - 51
</div>
... lines 53 - 59
</div>
{% endblock %}
... lines 62 - 72
72 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">
... lines 14 - 22
{% for repLog in repLogs %}
<tr>
... lines 25 - 27
<td>
<a href="#" class="js-delete-rep-log">
<span class="fa fa-trash"></span>
</a>
</td>
</tr>
... lines 34 - 37
{% endfor %}
... lines 39 - 47
</table>
... lines 49 - 51
</div>
... lines 53 - 59
</div>
{% endblock %}
... lines 62 - 72
Adorable! Ok, first! Why did we add this js-delete-rep-log class? Well, there are only ever two reasons to add a class: to style
that element, or because you want to find it in JavaScript.
Our goal is the second, and by prefixing the class with js-, it makes that crystal clear. This is a fairly popular standard: when
you add a class for JavaScript, give it a js- prefix so that future you doesn't need to wonder which classes are for styling and
which are for JavaScript. Future you will... thank you.
Copy that class and head to the bottom of the template. Add a block javascripts, endblock and call the parent() function:
72 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
{{ parent() }}
... lines 65 - 70
{% endblock %}
This is Symfony's way of adding JavaScript to a page. Inside, add a <script> tag and then, use jQuery to find all .js-delete-
rep-log elements, and then .on('click'), call this function. For now, just console.log('todo delete!'):
72 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
{{ parent() }}
<script>
$('.js-delete-rep-log').on('click', function() {
console.log('todo delete!');
});
</script>
{% endblock %}
97 lines app/Resources/views/base.html.twig
<!DOCTYPE html>
<html lang="en">
... lines 3 - 19
<body>
... lines 21 - 90
{% block javascripts %}
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-
hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-
Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>
Both jQuery and Bootstrap should be coming in from a CDN. Oh, but this note says that there is no locally stored library for
the http link. Aha! Tell PhpStorm to download and learn all about the library by pressing Option+Enter on a Mac - or Alt+Enter
on Linux or Windows - and choosing "Download Library". Do the same thing for Bootstrap.
Et voilà! The error is gone, and we'll start getting at least some auto-completion.
It's a small start, but already when we refresh, open the console, and click delete, it works! Now, let's follow the rabbit hole
deeper.
Chapter 2: (document).ready() & Ordering
72 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
{{ parent() }}
<script>
$('.js-delete-rep-log').on('click', function() {
console.log('todo delete!');
});
</script>
{% endblock %}
It adds our new JavaScript code right after the main script tags in the base layout:
97 lines app/Resources/views/base.html.twig
<!DOCTYPE html>
<html lang="en">
... lines 3 - 19
<body>
... lines 21 - 90
{% block javascripts %}
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-
hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-
Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>
View the HTML source and scroll to the bottom see that in action. Yep, jQuery and then our stuff.
Tip
In new Symfony projects, the <script> elements are added up inside <head> but with a defer attribute. This causes the
JavaScript to be executed in the same order (and at the same time) as what we will see here.
Our JavaScript lives at the bottom of the page for a reason: performance. Unless you add an async attribute, when your
browser sees a script tag, it stops, waits while that file is downloaded, executes it, and then continues.
But not everyone agrees that putting JS in the footer is the best thing since Chuck Norris. After all, if your page is heavily
dependent on JS, your user might see a blank page for a second before your JavaScript has the chance to execute and put
cool stuff there, like a photo of Chuck Norris.
So, there might be some performance differences between putting JavaScript in the header versus the footer. But, our code
should work equally well in either place, right? If I move the block javascripts up into my header, this should probably still
work?
98 lines app/Resources/views/base.html.twig
<!DOCTYPE html>
<html lang="en">
<head>
... lines 4 - 16
{% block javascripts %}
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-
hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-
Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
crossorigin="anonymous"></script>
{% endblock %}
... lines 21 - 22
</head>
... lines 24 - 96
</html>
We still have 3 script tags, in the same order, just in a different spot.
Well... let's find out! Refresh! Then click delete. Ah we broke it! What happened?!
This is the reason why you probably already always use the famous $(document).ready() block. Move our code inside of it,
and refresh again:
74 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
$('.js-delete-rep-log').on('click', function () {
console.log('todo delete!');
});
});
</script>
{% endblock %}
Yes!
Very simply, jQuery calls your $(document).ready() function once the DOM has fully loaded. But it's nothing fancy: it's
approximately equal to putting your JavaScript code at the absolute bottom of the page. It's nice because it makes our code
portable: it will work no matter where it lives.
We could even take the script tag, delete it from the block, and put it right in the middle of the page:
74 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">
... lines 14 - 47
</table>
<script>
$(document).ready(function() {
$('.js-delete-rep-log').on('click', function () {
console.log('todo delete!');
});
});
</script>
{{ include('lift/_form.html.twig') }}
</div>
... lines 60 - 66
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% endblock %}
Now in the HTML, the external script tags are still on top, but our JavaScript lives right, smack in the middle of the page. And
when we refresh, it still works super well.
Hey, you know what? We should really put our JavaScript in the footer! Chuck Norris told me it's better for
performance.
98 lines app/Resources/views/base.html.twig
<!DOCTYPE html>
<html lang="en">
... lines 3 - 19
<body>
... lines 21 - 90
{% block javascripts %}
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-
hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-
Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>
Now, we have a different problem. In the source, jQuery once again lives at the absolute bottom. But when we refresh the
page, error! Our browser immediately tells us that $ is not defined.
This comes from our code, which still lives in the middle of the page. And yea, it makes sense: as our browser loads the
page, it sees the $, but has not yet downloaded jQuery: that script tag lives further down.
So there are two things we need to worry about. First, any JavaScript that I depend on needs to be included on the page
before me. And actually, this will stop being true when we talk about module loaders in a future tutorial.
Second, before I try to select any elements with jQuery, I better make sure the DOM has loaded, which we can always
guarantee with a $(document).ready() block.
Let's put our JavaScript back into the block so that it's always included after jQuery, whether that's in the header of footer:
73 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
{{ parent() }}
<script>
$(document).ready(function() {
$('.js-delete-rep-log').on('click', function () {
console.log('todo delete!');
});
});
</script>
{% endblock %}
I'm feeling so good about our first click listener, let's add another! When I click anywhere on a row, I also want to log a
message.
Back in the template, give the entire table a js class so we can select it. How about js-rep-log-table:
77 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 - 47
</table>
... lines 49 - 50
</div>
... lines 52 - 58
</div>
{% endblock %}
... lines 61 - 77
Down below, find that and look inside for the tbody tr elements. Then, .on('click') add a function that prints some fascinating
text: console.log('row clicked'):
77 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
{{ parent() }}
<script>
$(document).ready(function() {
... lines 67 - 70
$('.js-rep-log-table tbody tr').on('click', function() {
console.log('row clicked!');
});
});
</script>
{% endblock %}
Beautiful! Refresh and click the row. No surprises: we see "row clicked". But check this out: click the delete link. Hot diggity -
two log messages! Of course it would do this! I clicked the delete link, but the delete link is inside of the row. Both things got
clicked!
Here it goes: when we click, we cause a click event. Now technically, when I click the delete icon, the element that I'm
actually clicking is the span that holds the icon. Cool! So, your browser goes to that span element and says:
Top of the morning! I'd like to trigger a click event on you!
Then, if there are any listener functions attached on click, those are called. Next, your browser goes up one level to the
anchor and says:
And the same thing happens again: if there are any click listener functions attached to that element, those are executed. This
includes our listener function. From here, it just keeps going: bubbling all the way up the tree: to the td, the tr, tbody, table,
and eventually, to the <body> tag itself.
And that is why we see "todo delete" first: the event bubbling process notifies the link element and then bubles up and
notifies the tr.
79 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
{{ parent() }}
<script>
$(document).ready(function() {
var $table = $('.js-rep-log-table');
$table.find('.js-delete-rep-log').on('click', function () {
console.log('todo delete!');
});
... lines 72 - 75
});
</script>
{% endblock %}
Do the same below: $table.find() and look for the tbody tr elements in that:
79 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
{{ parent() }}
<script>
$(document).ready(function() {
var $table = $('.js-rep-log-table');
... lines 68 - 72
$table.find('tbody tr').on('click', function() {
console.log('row clicked!');
});
});
</script>
{% endblock %}
If you refresh now, it still works great. But some of you might be wondering about my variable name: $table? For PHP
developers, that looks weird... because, ya know, $ means something important in PHP. But in JavaScript, $ is not a special
character. In fact, it's so not special that - if you want - you can even start a variable name with it. Madness! So the $ in $table
isn't doing anything special, but it is a fairly common convention to denote a variable that is a jQuery object.
It's nice because when I see $table, I think:
Oh! This starts with a $! Good show! I bet it's a jQuery object, and I can call find() or any other fancy jQuery
method on it. Jolly good!
Now that we understand event bubbling, let's mess with it! Yes, we can actually stop the bubbling process... which is
probably not something you want to do... but you might already be doing it accidentally.
Chapter 4: The Event Argument & stopPropagation
Back to our mission: when I click a delete link, it works... but I hate that it puts that annoying # in my URL and scrolls me up to
the top of the page. You guys have probably seen and fixed that a million times. The easiest way is by finding your listener
function and - at the bottom - returning false:
81 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
var $table = $('.js-rep-log-table');
$table.find('.js-delete-rep-log').on('click', function () {
console.log('todo delete!');
return false;
});
... lines 74 - 77
});
</script>
{% endblock %}
Go back, remove that pound sign, refresh, and click! Haha! Get outta here pound sign!
But woh, something else changed: we're also not getting the "row clicked" text anymore. If I click just the row, I get it, but if I
click the delete icon, it only triggers the event on that element. What the heck just happened?
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 73
});
... lines 75 - 78
});
</script>
{% endblock %}
This e variable is packed with information and some functions. The most important is e.preventDefault():
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
... lines 71 - 73
});
... lines 75 - 78
});
</script>
{% endblock %}
Another is e.stopPropagation():
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
... lines 72 - 73
});
... lines 75 - 78
});
</script>
{% endblock %}
It turns out that when you return false from a listener function, it is equivalent to calling e.preventDefault() and
e.stopPropagation(). To prove it, remove the return false and refresh:
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
console.log('todo delete!');
});
... lines 75 - 78
});
</script>
{% endblock %}
Yep, same behavior: no # sign, but still no "row clicked" when we click the delete icon.
81 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
console.log('todo delete!');
});
... lines 74 - 77
});
</script>
{% endblock %}
You should use e.preventDefault() in most cases, but not always. Sometimes, like with a keyup event, if you call
preventDefault(), that'll prevent whatever the user just typed from actually going into the text box.
Now, what else can this magical event argument help us with?
Chapter 5: The DOM Element Object
New goal! Eventually, when we click the trash icon, it will make an AJAX call. But before that, let's just see if we can turn the
icon red. In our JavaScript code, we need to figure out exactly which js-delete-rep-log element was clicked.
How? I bet you've done it before... a bunch of times... by using the this variable. But don't! Wait on that - we'll talk about the
infamous this variable later.
Using e.target
Because there's another way to find out which element was clicked... a better way, and it involves our magical e event
argument. Just say $(e.target). target is a property on the event object that points to the actual element that was clicked. Then,
.addClass('text-danger'):
81 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
});
... lines 74 - 77
});
</script>
{% endblock %}
So what is this e.target thing exactly? I mean, is it a string? Or an object? And what else can we do with it?
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
console.log(e.target);
});
... lines 75 - 78
});
</script>
{% endblock %}
And then, refresh! Ok, click on some delete links. Huh... it just prints out the HTML itself. So, it's a string?
Actually, no... our browser is kinda lying to us: e.target is a DOM Element object. Google for that and find the W3Schools
page all about it. You see, every element on the page is represented by a JavaScript object, a DOM Element object. My
debugger is printing it like a string, but that's just for convenience... or inconvenience in this case. Nope, it's actually an
object, with properties and methods that we can call. The W3Schools page shows all of this.
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
console.dir(e.target);
});
... lines 75 - 78
});
</script>
{% endblock %}
Now refresh. Click a link and check this out! It still gives you some information about what the element is, but now you can
expand it to find a huge list of its properties and methods. Nice! One of the properties is called className, which we will use
in a second.
If you're not familiar with console.dir(), it's bananas cool. Sometimes, console.log() gives you a string representation of
something. But console.dir() tries to give you a tree of what that thing actually is. It's like programmer X-Ray vision!
Not exactly. Whenever you have a jQuery object like $table, or $(e.target), that actually represents an array of elements, even
though there may only be one element. Let me show you: use console.log() to print out e.target, and also, $(e.target)[0] ===
e.target:
85 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
console.log(
e.target,
$(e.target)[0] === e.target
);
});
... lines 78 - 81
});
</script>
{% endblock %}
Go back, refresh, and click one of the links. It prints true! The jQuery object is an object, but it holds an array of DOM
elements. And you can actually access the underlying DOM element objects by using the indexes, 0, 1, 2, 3 and so on. The
jQuery object is just a fancy wrapper around them.
Try this example: search for all .fa-trash elements, find the third DOM element, which is index 2, and see if it's the same as
the element that was just clicked: e.target:
86 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
console.log(
e.target,
$(e.target)[0] === e.target,
$('.fa-trash')[5] === e.target
);
});
... lines 79 - 82
});
</script>
{% endblock %}
In theory, this should return true only when we click on the third trash icon.
So refresh and try it! Click the icons: false, false and then true! This is all an elaborate way of explaining that - under
everything - we have these cool DOM Element objects. jQuery? That's just a fancy wrapper object that holds an array of
these guys.
Of course, that fancy wrapper allows us to add a class by simply calling... addClass():
86 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
$(e.target).addClass('text-danger');
... lines 73 - 77
});
... lines 79 - 82
});
</script>
{% endblock %}
But now, we know that if we wanted to, we could do this directly on the DOM Element object. Try it: e.target.className =
e.target.className + ' text-danger':
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
... lines 70 - 71
//$(e.target).addClass('text-danger');
e.target.className = e.target.className+' text-danger';
});
... lines 75 - 78
});
</script>
{% endblock %}
It's not as elegant as using jQuery... and jQuery also helps handle browser incompatibilities, but feel empowered! Go tell a
co-worker that you just learned how the Internet works!
Then come back, remove that new code and go back to using jQuery:
81 lines app/Resources/views/lift/index.html.twig
... lines 1 - 61
{% block javascripts %}
... lines 63 - 64
<script>
$(document).ready(function() {
... lines 67 - 68
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(e.target).addClass('text-danger');
});
... lines 74 - 77
});
</script>
{% endblock %}
Chapter 6: The Magical this Variable & currentTarget
Turning the icon red is jolly good and all, but since we'll soon make an AJAX call, it would be way jollier if we could turn that
icon into a spinning loader icon. But, there's a problem.
83 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">
<span class="fa fa-trash"></span>
Delete
</a>
</td>
</tr>
... lines 35 - 38
{% endfor %}
... lines 40 - 48
</table>
... lines 50 - 51
</div>
... lines 53 - 59
</div>
{% endblock %}
... lines 62 - 83
Now we have a trash icon with the word delete next to it. Back in our JavaScript, once again, console.log() the actual element
that was clicked: e.target:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
... lines 68 - 69
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(e.target).addClass('text-danger');
console.log(e.target);
});
... lines 76 - 79
});
</script>
{% endblock %}
True to what I said, e.target will be the exact one element that originally received the event, so click in this case. And that's a
problem for us! Why? Well, I want to be able to find the fa span element and change it to a spinning icon. Doing that is going
to be annoying, because if we click on the trash icon, e.target is that element. But if we click on the word delete, then we need
to look inside of e.target to find the span.
Hello e.currentTarget
It would be WAY more hipster if we could retrieve the element that the listener was attached to. In other words, which js-
delete-rep-log was clicked? That would make it super easy to look for the fa span inside of it and make the changes we need.
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
... lines 68 - 69
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(e.target).addClass('text-danger');
console.log(e.currentTarget);
});
... lines 76 - 79
});
</script>
{% endblock %}
Yep, this ends up being much more useful than e.target. Now when we refresh and click the trash icon, it's the anchor tag.
Click the delete icon, it's still the anchor tag. No matter which element we actually click, e.currentTarget returns the original
element that we attached the listener to.
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
... lines 68 - 69
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(e.target).addClass('text-danger');
console.log(e.currentTarget === this);
});
... lines 76 - 79
});
</script>
{% endblock %}
Refresh! And click anywhere on the delete link. It's always true.
There's a good chance that you've been using the this variable for years inside of your listener functions to find the element
that was clicked. And now we know the true and dramatic story behind it! this is equivalent to e.currentTarget, the DOM
Element that we originally attached our listener to.
82 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
... lines 68 - 69
$table.find('.js-delete-rep-log').on('click', function (e) {
e.preventDefault();
$(this).addClass('text-danger');
});
... lines 75 - 78
});
</script>
{% endblock %}
That will always add the text-danger link to the anchor tag.
And finally, we can easily change our icon to a spinner! Just use $(this).find('.fa') to find the icon inside of the anchor. Then,
.removeClass('fa-trash'), .addClass('fa-spinner') and .addClass('fa-spin'):
86 lines app/Resources/views/lift/index.html.twig
... lines 1 - 62
{% block javascripts %}
... lines 64 - 65
<script>
$(document).ready(function() {
... lines 68 - 69
$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');
});
... lines 79 - 82
});
</script>
{% endblock %}
Refresh! Show me a spinner! There it is! It doesn't matter if we click the "Delete" text or the trash icon itself.
So, use the this variable, it's your friend. But realize what's going on: this is just a shortcut to e.currentTarget. That fact is
going to become critically important in just a little while.
Now that we've learned this, remove the "delete" text... it's kinda ugly:
85 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">
<span class="fa fa-trash"></span>
</a>
</td>
</tr>
... lines 34 - 37
{% endfor %}
... lines 39 - 47
</table>
... lines 49 - 50
</div>
... lines 52 - 58
</div>
{% endblock %}
... lines 61 - 85
Chapter 7: 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:
... 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.
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
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:
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!
... 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');
var newWeight = $totalWeightContainer.html() - $row.data('weight');
... lines 85 - 92
});
... 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!
Chapter 8: Organizing with Objects!
Ok, this all looks pretty good... except that our code is just a bunch of functions and callback functions! Come on people, if
this were PHP code, we would be using classes and objects. Let's hold our JavaScript to that same standard: let's use
objects.
Creating an Object
How do you create an object? There are a few ways, but for now, it's as simple as var RepLogApp = {}:
Yep, that's an object. Yea, I know, it's just an associative array but an associative array is an object in JavaScript. And its
keys become the properties and methods on the object. See, JavaScript doesn't have classes like PHP, only objects. Well,
that's not entirely true, but we'll save that for a future tutorial.
Adding a Method
Anyways, let's give our object a new method: an initialize key set to a function(). We'll call this when the page loads, and its
job will be to attach all the event handlers for all the events that we need on our table. Give it a $wrapper argument:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
... lines 71 - 80
},
... lines 82 - 108
};
... lines 110 - 114
</script>
{% endblock %}
Setting a Property
Before we do anything else, set that $wrapper argument onto a property: this.$wrapper = $wrapper:
Yep, we just dynamically added a new property. This is the second time we've seen the this variable in JavaScript. And this
time, it's more familiar: it refers to this object.
Next, copy our first listener registration code, but change $table to this.$wrapper. And instead of using a big ugly anonymous
function, let's make this event call a new method on our object: this.handleRepLogDelete:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
this.$wrapper = $wrapper;
this.$wrapper.find('.js-delete-rep-log').on(
'click',
this.handleRepLogDelete
);
... lines 77 - 80
},
... lines 82 - 108
};
... lines 110 - 114
</script>
{% endblock %}
Repeat this for the other event listener: copy the registration line, change $table to this.$wrapper, and then on click, call
this.handleRowClick:
After initialize, create these methods! Add a key called, handleRepLogDelete set to a new function:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
handleRepLogDelete: function(e) {
... lines 84 - 103
},
... lines 105 - 108
};
... lines 110 - 114
</script>
{% endblock %}
Then go copy all of our original handler code, delete it, and put it here:
Do the same thing for our other method: handleRowClick set to a function() {}:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 105
handleRowClick: function() {
... line 107
}
};
... lines 110 - 114
</script>
{% endblock %}
I'm not using the, e argument, so I don't need to add it. Copy the console.log() line, delete it, and put it here:
117 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 105
handleRowClick: function() {
console.log('row clicked!');
}
};
... lines 110 - 114
</script>
{% endblock %}
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
... lines 71 - 72
this.$wrapper.find('.js-delete-rep-log').on(
... line 74
this.handleRepLogDelete
);
this.$wrapper.find('tbody tr').on(
... line 78
this.handleRowClick
);
},
... lines 82 - 108
};
... lines 110 - 114
</script>
{% endblock %}
I mean, there are no () on the end of it. Nope, we're simply passing the function as a reference to the on() function. If you
forget and add (), things will get crazy.
The cool thing about this approach is that now we have an entire object who's job is to work inside of this.$wrapper.
Ok, let's try this! Go back and refresh! Hit delete! Ah, it fails!
The problem is inside of handleRepLogDelete. Ah, cool, this makes total sense. Before, we had a $table variable defined
above the function. That's gone, but no problem! Just use this.$wrapper:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
handleRepLogDelete: function(e) {
... lines 84 - 93
var $totalWeightContainer = this.$wrapper.find('.js-total-weight');
... lines 95 - 103
},
... lines 105 - 108
};
... lines 110 - 114
</script>
{% endblock %}
Ok, go back and refresh again. Open up the console, click delete and... whoa! That doesn't work either! The errors is on the
exact same line. What's going on here? It says:
We just found out that, somehow, this.$wrapper is not our jQuery object, it's undefined!
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
handleRepLogDelete: function(e) {
... lines 84 - 93
var $totalWeightContainer = this.$wrapper.find('.js-total-weight');
... lines 95 - 103
},
... lines 105 - 108
};
... lines 110 - 114
</script>
{% endblock %}
Rude! How is that even possible! The answer! Because JavaScript is weird, especially when it comes to the crazy this
variable!
If this is hard to wrap your head around, don't worry! Coming from PHP, objects in JavaScript are weird... and they'll get
stranger before we're done. But, most things you can do in PHP you can also do in JavaScript... it just looks different. The
stuff inside the object may not have some special static keyword on them, but this is what static properties and methods look
like in JavaScript.
And like static properties and methods in PHP, you can reference them by their class name. Well, in JavaScript, that mean,
by their object name - RepLogApp:
Ok, go back and refresh now. Hit delete. It actually works! Sorry, I shouldn't sound so surprised!
Now that we have a fancy object, we can use it to get even more organized, by breaking big functions into smaller ones.
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
updateTotalWeightLifted: function() {
... lines 84 - 89
},
... lines 91 - 117
};
... lines 119 - 123
</script>
{% endblock %}
Instead of figuring out the total weight lifted here and doing the update down in the success callback:
We'll just call this method and have it do all that heavy lifting.
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
updateTotalWeightLifted: function() {
var totalWeight = 0;
... lines 85 - 89
},
... lines 91 - 117
};
... lines 119 - 123
</script>
{% endblock %}
Then I'll say, this.$wrapper, which I can do because we're not in a callback function: this is our object. Then, .find to look for
all tbody tr elements, and .each() to loop over them:
But stop! Notice that when you use .each(), you pass it a callback function! So guess what? Inside, this is no longer our
RepLogApp object, it's something different. In this case, this is the individual tr DOM Element object that we're looping over in
this moment.
Inside, add up all the total weights with totalWeight += $(this).data() and read the data-weight attribute:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 82
updateTotalWeightLifted: function() {
var totalWeight = 0;
this.$wrapper.find('tbody tr').each(function() {
totalWeight += $(this).data('weight');
});
... lines 88 - 89
},
... lines 91 - 117
};
... lines 119 - 123
</script>
{% endblock %}
Finally use this.$wrapper.find() to look for our js-total-weight element and set its HTML to totalWeight:
Cool!
Down in handleRepLogDelete, we don't need any of this logic anymore, nor this logic. We just need to call our new function.
The only gotcha is that the fadeOut() function doesn't actually remove the row from the DOM, so our new weight-totaling
function would still count its weight.
Fix it by telling fadeOut() to use normal speed, pass it a function to be called when it finishes fading, and then say
$row.remove() to fully remove it from the DOM:
But check this out: we're actually inside of another callback function, which is inside of a callback function, inside of our entire
function which is itself a callback! So, this is definitely not our RepLogApp object.
That's the equivalent in PHP of calling a static method by using its class name.
Ok, try it out! Refresh the page. We're at 765. Now delete a row... 657! Nice! Let's finally figure out what's really going on with
the this variable... and how to make it act better!
Chapter 10: Getting to the bottom of the this Variable
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 91
handleRepLogDelete: function(e) {
... lines 93 - 102
$.ajax({
... lines 104 - 105
success: function() {
$row.fadeOut('normal', function() {
... line 108
RepLogApp.updateTotalWeightLifted();
});
}
});
},
... lines 114 - 117
};
... lines 119 - 123
</script>
{% endblock %}
We expect the this variable inside of that function to be whatever object we're inside of right now. In that case, it is. But in so
many other cases, this is something different! Like inside handleRowClick and handleRepLogDelete:
What's going on? And more importantly, how can we fix it? When I'm inside a method in an object, I want this to act normal: I
want it to point to my object.
Now, in reality, it's not that bad. But we do need to remember one rule of thumb: whenever you have a callback function -
meaning someone else is calling a function after something happens - this will have changed. We've already seen this a lot:
in the click functions, inside of .each(), inside of success and even inside of $row.fadeOut():
So what is this inside of these functions? It depends on the situation, so you need to read the docs for the success function,
the fadeOut() function or the .each() function to be sure. For fadeOut(), this ends up being the DOM Element that just finished
fading out. So, we can actually call $(this).remove():
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 84
whatIsThis: function(greeting) {
console.log(this, greeting);
},
... lines 88 - 123
};
... lines 125 - 129
</script>
{% endblock %}
Simple enough! And since we're calling this function directly - not as a callback - I would expect this to actually be what we
expect: our RepLogApp object. Let's find out. Refresh! Expand the logged object. Yea, it's RepLogApp! Cool!
But now, let's get tricky! Create a new variable called newThis and set it to an object with important stuff like cat set to meow
and dog set to woof:
To force newThis to be this inside our function, call the function indirectly with this.whatIsThis.call() and pass it newThis and
the greeting, hello:
Oh, and quick note: this.whatIsThis is, obviously, a function. But in JavaScript, functions are actually objects themselves! And
there are a number of different methods that you can call on them, like .call(). The first argument to call() is the variable that
should be used for this, followed by any arguments that should be passed to the function itself.
Refresh now and check this out! this is now our thoughtful cat, meow, dog, woof object. That is what is happening behind the
scenes with your callback functions.
Now that we understand the magic behind this, how can we fix it? How can we guarantee that this is always our RepLogApp
object when we're inside of it?
Chapter 11: Fixing "this" with bind()
So how can we fix this? If we're going to be fancy and use objects in JavaScript, I don't want to have to worry about whether
or not this is actually this in each function! That's no way to live! Nope, I want to know confidently that inside of my whatIsThis
function, this is my RepLogApp object... not a random array of pets and their noises.
More importantly, I want that same guarantee down in each callback function: I want to be absolutely sure that this is this
object, exactly how we'd expect our methods to work.
And yes! This is possible: we can take back control! Create a new variable: var boundWhatIsThis = this.whatIsThis.bind(this):
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
... lines 71 - 81
var newThis = {cat: 'meow', dog: 'woof'};
var boundWhatIsThis = this.whatIsThis.bind(this);
... line 84
},
... lines 86 - 125
};
... lines 127 - 131
</script>
{% endblock %}
Just like call(), bind() is a method you can call on functions. You pass it what you want this to be - in this case our
RepLogApp object - and it returns a new function that, when called, will always have this set to whatever you passed to
bind(). Now, when we say boundWhatIsThis.call() and try to pass it an alternative this object, that will be ignored:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
... lines 71 - 81
var newThis = {cat: 'meow', dog: 'woof'};
var boundWhatIsThis = this.whatIsThis.bind(this);
boundWhatIsThis.call(newThis, 'hello');
},
... lines 86 - 125
};
... lines 127 - 131
</script>
{% endblock %}
Try it out: refresh! Yes! Now this is this again!
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
initialize: function($wrapper) {
... lines 71 - 72
this.$wrapper.find('.js-delete-rep-log').on(
... line 74
this.handleRepLogDelete.bind(this)
);
this.$wrapper.find('tbody tr').on(
... line 78
this.handleRowClick.bind(this)
);
},
... lines 82 - 118
};
... lines 120 - 124
</script>
{% endblock %}
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 90
handleRepLogDelete: function(e) {
... lines 92 - 93
var $link = $(e.currentTarget);
$link.addClass('text-danger');
$link.find('.fa')
... lines 98 - 101
var deleteUrl = $link.data('url');
var $row = $link.closest('tr');
... lines 104 - 113
},
... lines 115 - 118
};
... lines 120 - 124
</script>
{% endblock %}
Finally, we can fix something that's been bothering me. Instead of saying RepLogApp, I want to use this. We talked earlier
about how RepLogApp is kind of like a static object, and just like in PHP, when something is static, you can reference it by its
object name, or really, class name in PHP.
Of course, the problem is that inside of the callback, this won't be our RepLogApp object anymore. How could we fix this?
There are two options. First, we could bind our success function to this. Then, now that this is our RepLogApp object inside
of success, we could also bind our fadeOut callback to this. Finally, that would let us call this.updateTotalWeightLifted().
But wow, that's a lot of work, and it'll be a bit ugly! Instead, there's a simpler way. First, realize that whenever you have an
anonymous function, you could refactor it into an individual method on your object. If we did that, then I would recommend
binding that function so that this is the RepLogApp object inside.
But if that feels like overkill and you want to keep using anonymous functions, then simply go above the callback and add var
self = this:
... lines 1 - 64
{% block javascripts %}
... lines 66 - 67
<script>
var RepLogApp = {
... lines 70 - 90
handleRepLogDelete: function(e) {
... lines 92 - 103
var self = this;
$.ajax({
... lines 106 - 113
});
},
... lines 116 - 119
};
... lines 121 - 125
</script>
{% endblock %}
The variable self is not important in any way - I just made that up. So, it doesn't change inside of callback functions, which
means we can say self.updateTotalWeightLifted():
1. Use bind() to make sure that this is always this inside any methods in your object.
2. Make sure to reference your object with this, instead of your object's name. This isn't an absolute rule, but unless you
know what you're doing, this will give you more flexibility in the long-run.
Chapter 12: Immediately Invoked Function Expression!
Our code is growing up! And to keep going, it's really time to move our RepLogApp into its own external JavaScript file. For
now, let's keep this real simple: inside the web/ directory - which is the public document root for the project - and in assets/, I'll
create a new js/ directory. Then, create a new file: RepLogApp.js. Copy all of our RepLogApp object and paste it here:
53 lines web/assets/js/RepLogApp.js
var RepLogApp = {
initialize: function ($wrapper) {
this.$wrapper = $wrapper;
this.$wrapper.find('.js-delete-rep-log').on(
'click',
this.handleRepLogDelete.bind(this)
);
this.$wrapper.find('tbody tr').on(
'click',
this.handleRowClick.bind(this)
);
},
updateTotalWeightLifted: function () {
var totalWeight = 0;
this.$wrapper.find('tbody tr').each(function () {
totalWeight += $(this).data('weight');
});
this.$wrapper.find('.js-total-weight').html(totalWeight);
},
handleRepLogDelete: function (e) {
e.preventDefault();
var $link = $(e.currentTarget);
$link.addClass('text-danger');
$link.find('.fa')
.removeClass('fa-trash')
.addClass('fa-spinner')
.addClass('fa-spin');
var deleteUrl = $link.data('url');
var $row = $link.closest('tr');
var self = this;
$.ajax({
url: deleteUrl,
method: 'DELETE',
success: function () {
$row.fadeOut('normal', function () {
$(this).remove();
self.updateTotalWeightLifted();
});
}
});
},
handleRowClick: function () {
console.log('row clicked!');
}
};
77 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('assets/js/RepLogApp.js') }}"></script>
<script>
$(document).ready(function() {
var $table = $('.js-rep-log-table');
RepLogApp.initialize($table);
});
</script>
{% endblock %}
If you don't normally use Symfony, ignore the asset() function: it doesn't do anything special.
To make sure we didn't mess anything up, refresh! Let's add a few items to our list. Then, delete one. It works!
That's not the end of the world, but it is a bummer! Fortunately, by being clever, we can create private functions and variables.
You just need to think differently than you would in PHP.
59 lines web/assets/js/RepLogApp.js
var RepLogApp = {
... lines 2 - 49
_calculateTotalWeight: function() {
... lines 51 - 56
}
};
Its job will be to handle the total weight calculation logic that's currently inside updateTotalWeightLifted:
59 lines web/assets/js/RepLogApp.js
var RepLogApp = {
... lines 2 - 49
_calculateTotalWeight: function() {
var totalWeight = 0;
this.$wrapper.find('tbody tr').each(function () {
totalWeight += $(this).data('weight');
});
return totalWeight;
}
};
We're making this change purely for organization: my intention is that we will only use this method from inside of this object.
In other words, ideally, calculateTotalWeight would be private!
But since everything is public in JavaScript, a common standard is to prefix methods that should be treated as private with an
underscore. It's a nice convention, but it doesn't enforce anything. Anybody could still call this from outside of the object.
59 lines web/assets/js/RepLogApp.js
var RepLogApp = {
... lines 2 - 13
updateTotalWeightLifted: function () {
this.$wrapper.find('.js-total-weight').html(
this._calculateTotalWeight()
);
},
... lines 19 - 57
};
At the bottom of this file, create another object called: var Helper = {}:
69 lines web/assets/js/RepLogApp.js
... lines 1 - 54
var Helper = {
... lines 56 - 67
};
Commonly, we'll organize our code so that each file has just one object, like in PHP. But eventually, this variable won't be
public - it's just a helper meant to be used only inside of this file.
I'll even add some documentation: this is private, not meant to be called from outside!
69 lines web/assets/js/RepLogApp.js
... lines 1 - 51
/**
* A "private" object
*/
var Helper = {
... lines 56 - 67
};
Just like before, give this an initialize, function with a $wrapper argument. And then say: this.$wrapper = $wrapper:
69 lines web/assets/js/RepLogApp.js
... lines 1 - 54
var Helper = {
initialize: function ($wrapper) {
this.$wrapper = $wrapper;
},
... lines 59 - 67
};
Move the calculateTotalWeight() function into this object, but take off the underscore:
69 lines web/assets/js/RepLogApp.js
... lines 1 - 54
var Helper = {
... lines 56 - 59
calculateTotalWeight: function() {
var totalWeight = 0;
this.$wrapper.find('tbody tr').each(function () {
totalWeight += $(this).data('weight');
});
return totalWeight;
}
};
Technically, if you have access to the Helper variable, then you're allowed to call calculateTotalWeight. Again, that whole _
thing is just a convention.
Back in our original object, let's set this up: call Helper.initialize() and pass it $wrapper:
69 lines web/assets/js/RepLogApp.js
var RepLogApp = {
initialize: function ($wrapper) {
this.$wrapper = $wrapper;
Helper.initialize(this.$wrapper);
... lines 5 - 13
},
... lines 15 - 49
};
... lines 51 - 69
69 lines web/assets/js/RepLogApp.js
var RepLogApp = {
initialize: function ($wrapper) {
this.$wrapper = $wrapper;
Helper.initialize(this.$wrapper);
... lines 5 - 13
},
updateTotalWeightLifted: function () {
this.$wrapper.find('.js-total-weight').html(
Helper.calculateTotalWeight()
);
},
... lines 20 - 49
};
... lines 51 - 69
But, this Helper object is still public. What I mean is, we still have access to it outside of this file. If we try to
console.log(Helper) from our template, it works just fine:
78 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
console.log(Helper);
... lines 72 - 75
</script>
{% endblock %}
What I really want is the ability for me to choose which variables I want to make available to the outside world - like
RepLogApp - and which I don't, like Helper.
71 lines web/assets/js/RepLogApp.js
(function() {
var RepLogApp = {
... lines 3 - 50
};
... lines 52 - 55
var Helper = {
... lines 57 - 68
};
})();
What?
There are two things to check out. First, all we're doing is creating a function: it starts on top, and ends at the bottom with the
}. But by adding the (), we are immediately executing that function. We're creating a function and then calling it!
Why on earth would we do this? Because! Variable scope in JavaScript is function based. When you create a variable with
var, it's only accessible from inside of the function where you created it. If you have functions inside of that function, they have
access to it too, but ultimately, that function is its home.
Before, when we weren't inside of any function, our two variables effectively became global: we could access them from
anywhere. But now that we're inside of a function, the RepLogApp and Helper variables are only accessible from inside of
this self-executing function.
This means that when we refresh, we get Helper is not defined. We just made the Helper variable private!
Unfortunately... we also made our RepLogApp variable private, which means the code in our template will not work. We still
need to somehow make RepLogApp available publicly, but not Helper. How? By taking advantage of the magical window
object.
Chapter 13: The window Object & Global Variables
Now that we're using this fancy self-executing function, we don't have access to RepLogApp anymore:
71 lines web/assets/js/RepLogApp.js
(function() {
var RepLogApp = {
... lines 3 - 50
};
... lines 52 - 69
})();
How can we fix that? Very simple. Instead of var RepLogApp, say window.RepLogApp:
71 lines web/assets/js/RepLogApp.js
(function() {
window.RepLogApp = {
... lines 3 - 50
};
... lines 52 - 69
})();
78 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
console.log(Helper);
... lines 72 - 75
</script>
{% endblock %}
And then go back and refresh. It works! No error in the console, and delete does its job!
Right now, inside of our self-executing function, we're using two global variables: window and $, for $.ajax, for example:
71 lines web/assets/js/RepLogApp.js
(function() {
window.RepLogApp = {
... lines 3 - 21
handleRepLogDelete: function (e) {
... lines 23 - 35
$.ajax({
... lines 37 - 44
});
},
... lines 47 - 50
};
... lines 52 - 69
})();
At the bottom of the file, between the parentheses, reference the global window and jQuery variables and pass them as
arguments to our function. On top, add those arguments: window and $:
71 lines web/assets/js/RepLogApp.js
(function(window, $) {
window.RepLogApp = {
... lines 3 - 69
})(window, jQuery);
Now, when we reference window and $ in our code, we're no longer referencing the global objects directly, we're referencing
those arguments.
Why the heck would you do this? There are two reasons, and neither are huge. First, you can alias global variables. At the
bottom, we reference the jQuery global variable, which is even better than referencing $ because sometimes people setup
jQuery in no conflict mode, where it does not create a $ variable. But then above, we alias this to $, meaning it's safe inside
for us to use that shortcut. You probably don't have this problem, but you'll see stuff like this in third-party libraries.
Second, when you pass in a global variable as an argument, it protects you from making a really silly mistake in your code,
like accidentally setting $ = null. If you do that now, it'll set $ to null only inside this function. But before, you would have
overwritten that variable globally. It's yet another way that self-executing blocks help to sandbox us.
78 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
console.log(window);
... lines 72 - 75
</script>
{% endblock %}
This is pretty cool, because it will show us all global variables that are available.
And Boom! This is a huge object, and includes the $ variable, jQuery, and eventually, RepLogApp.
71 lines web/assets/js/RepLogApp.js
(function(window, $) {
... lines 2 - 55
Helper = {
... lines 57 - 68
};
})(window, jQuery);
You've probably been taught to never do this. And that's right! But you may not realize exactly what happens if you do.
Refresh again and open the window variable. Check this out! It's a little hard to find, but all of a sudden, there is a global
Helper variable! So if you forget to say var - which you shouldn't - it makes that variable a global object, which means it's set
on window.
There's one other curious thing about window: if you're in a global context where there is no this variable... then this is
actually equal to window:
78 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
console.log(window === this);
... lines 72 - 75
</script>
{% endblock %}
To tell JavaScript to stop being such a pushover, at the top of the RepLogApp.js file, inside quotes, say 'use strict':
73 lines web/assets/js/RepLogApp.js
'use strict';
(function(window, $) {
... lines 4 - 57
Helper = {
... lines 59 - 70
};
})(window, jQuery);
Tip
Even better! Put 'use strict' inside the self-executing function. Adding 'use strict' applies to the function its inside of and any
functions inside of that (just like creating a variable with var). If you add it outside of a function (like we did), it affects the
entire file. In this case, both locations are effectively identical. But, if you use a tool that concatenates your JS files into a
single file, it's safer to place 'use strict' inside the self-executing function, to ensure it doesn't affect those other concatenated
files!
I know, weird. This is a special JavaScript directive that tells your browser to activate a more strict parsing mode. Now,
certain things that were allowed before, will cause legit errors. And sure enough, when we refresh, we get:
73 lines web/assets/js/RepLogApp.js
'use strict';
(function(window, $) {
... lines 4 - 57
var Helper = {
... lines 59 - 70
};
})(window, jQuery);
Chapter 14: Instantiatable Objects & Constructors
Ok ok, it's finally time to talk about the JavaScript elephant in the room: prototypical inheritance. This means, real JavaScript
objects that we can instantiate!
But first, let's do just a little bit of reorganization on Helper - it'll make our next step easier to understand.
Instead of putting all of my functions directly inside my object immediately, I'll just say var Helper = {}:
73 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 54
/**
* A "private" object
*/
var Helper = {};
... lines 59 - 71
})(window, jQuery);
Then set the Helper.initialize key to a function, and Helper.calculateTotalWeight equal to its function:
73 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 54
/**
* A "private" object
*/
var Helper = {};
Helper.initialize = function ($wrapper) {
... line 61
};
Helper.calculateTotalWeight = function() {
... lines 64 - 69
};
})(window, jQuery);
This didn't change anything: it's just a different way of putting keys onto an object.
Heck, even strings are objects: we'll see that in a moment. The only downside with our Helper or RepLogApp objects so far
is that they are effectively static.
71 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 54
/**
* A "private" object
*/
var Helper = function ($wrapper) {
this.$wrapper = $wrapper;
};
... lines 61 - 69
})(window, jQuery);
Huh. So now, Helper is a function... But remember that functions are objects, so it's totally valid to add properties or methods
to it.
Why would set our object to a function? Because now we are allowed to say this.helper = new Helper($wrapper):
71 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
this.$wrapper = $wrapper;
this.helper = new Helper(this.$wrapper);
... lines 8 - 16
},
... lines 18 - 52
};
... lines 54 - 69
})(window, jQuery);
JavaScript does have the new keyword just like PHP! And you can use it once Helper is actually a function. This returns a
new instance of Helper, which we set on a property.
In PHP, when you say new Helper(), PHP calls the constructor on your object, if you have one. The same happens here, the
function is the constructor. At this point, we could create multiple Helper instances, each with their own $wrapper.
Now, instead of using Helper in a static kind of way, we use its instance: this.helper:
71 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
... lines 5 - 17
updateTotalWeightLifted: function () {
this.$wrapper.find('.js-total-weight').html(
this.helper.calculateTotalWeight()
);
},
... lines 23 - 52
};
... lines 54 - 69
})(window, jQuery);
Before we keep celebrating, let's try this. Go back, refresh, and delete one of our items! Huh, it worked... but the total didn't
update. And, we have an error:
That's odd! Why does it think our Helper doesn't have that key? The answer is all about the prototype.
Chapter 15: The Object prototype!
In RepLogApp, when we try to call this.helper.calculateTotalWeight, for some reason, it doesn't think this is a function!
71 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
... lines 5 - 17
updateTotalWeightLifted: function () {
this.$wrapper.find('.js-total-weight').html(
this.helper.calculateTotalWeight()
);
},
... lines 23 - 52
};
... lines 54 - 69
})(window, jQuery);
But down below, we can plainly see: calculateTotalWeight is a function! What the heck is going on?
To find out, in initialize, let's log a few things: console.log(this.helper) and then Object.keys(this.helper):
73 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... line 6
this.helper = new Helper(this.$wrapper);
console.log(this.helper, Object.keys(this.helper));
... lines 9 - 18
},
... lines 20 - 54
};
... lines 56 - 71
})(window, jQuery);
The Object.keys method is an easy way to print the properties and methods inside an object.
73 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... line 6
this.helper = new Helper(this.$wrapper);
console.log(this.helper, Object.keys(this.helper));
console.log(Helper, Object.keys(Helper));
... lines 10 - 18
},
... lines 20 - 54
};
... lines 56 - 71
})(window, jQuery);
Let's look at what the difference is between our instance of the Helper object and the Helper object itself.
Ok, find your browser, refresh, and check this out! There's the helper instance object, but check out the methods and
properties on it: it has $wrapper. Wait, so when we create a new Helper(), that instance object does have the $wrapper
property... but somehow it does not have a calculateTotalWeight method!
That's why we're getting the error. The question is why? Below, where we printed the upper-case "H" Helper object, it prints
out as a function, but in its keys, it does have one called calculateTotalWeight. Oooh, mystery!
This can be very confusing. So follow this next part closely and all the way to the end.
At this point, the calculateTotalWeight function is effectively still static. The only way that we can call that method is by saying
Helper.calculateTotalWeight - by calling the method on the original, static object. We cannot call this method on the
instantiated instance: we can't say this.helper.calculateTotalWeight(). It just doesn't work!
74 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 63
Helper.prototype.calculateTotalWeight = function() {
... lines 65 - 70
};
})(window, jQuery);
That weird little trick fixes everything. To test it easily, back up in initialize(), let's try calling this.helper.calculateTotalWeight():
74 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... line 6
this.helper = new Helper(this.$wrapper);
console.log(this.helper, Object.keys(this.helper));
console.log(Helper, Object.keys(Helper));
console.log(this.helper.calculateTotalWeight());
... lines 11 - 19
},
... lines 21 - 55
};
... lines 57 - 72
})(window, jQuery);
This did not work before, but refresh! 157.5 - it works now!
The short explanation is that when you create objects that need to be instantiated, you need to add its properties and
methods to this special prototype key.
Once you've done that and create a new Helper, magically, anything on the prototype, like calculateTotalWeight, becomes
part of that object.
But, that superficial explanation is crap! Let's find out how this really works!
Chapter 16: prototype Versus __proto__
Suddenly, after adding calculateTotalWeight to some strange prototype key, we can call this method on any new instance of
the Helper object. But go back to your browser and check out the first log. Huh, our helper instance still only has one key:
$wrapper. I don't see calculateTotalWeight here... so how the heck is that working? I mean, I don't see the method we're
calling!
Hello proto
Check out that __proto__ property. Every object has a magic property called __proto__. And if you open it, it holds the
calculateTotalWeight function. Here's the deal: when you call a method or access a property on an object, JavaScript first
looks for it on the object itself. But if it doesn't find it there, it looks at the __proto__ property to see if it exists on that object. If it
does, JavaScript uses it. If it does not exist, it actually keeps going to the next __proto__ property inside of the original
__proto__ and tries to look for it there. It repeats that until it gets to the top level. What you are seeing here is the top-level
__proto__ that every object shares. In other words, these methods and properties exist on every object in JavaScript.
Boy, if you think about it, this is a lot like class inheritance, where each __proto__ acts like a class we extend. And this last
__proto__ is like some base class that everything extends.
74 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 63
Helper.prototype.calculateTotalWeight = function() {
... lines 65 - 70
};
})(window, jQuery);
Whenever you use the new keyword, anything on the prototype key of that object becomes the __proto__ of the newly
instantiated object.
Create a new variable called playObject set to an object with a lift key set to stuff:
80 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... lines 6 - 11
var playObject = {
lift: 'stuff'
};
... lines 15 - 25
},
... lines 27 - 61
};
... lines 63 - 78
})(window, jQuery);
80 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... lines 6 - 11
var playObject = {
lift: 'stuff'
};
playObject.__proto__.cat = 'meow';
... lines 16 - 25
},
... lines 27 - 61
};
... lines 63 - 78
})(window, jQuery);
You shouldn't normally access or set the __proto__ property directly, but for playing around now, it's great. Finally,
console.log(playObject.lift), which we know will work, but also playObject.cat:
80 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... lines 6 - 11
var playObject = {
lift: 'stuff'
};
playObject.__proto__.cat = 'meow';
console.log(playObject.lift, playObject.cat);
... lines 17 - 25
},
... lines 27 - 61
};
... lines 63 - 78
})(window, jQuery);
Ok, try it. Refresh! Hey, stuff and meow! That's the __proto__ property in action!
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... lines 6 - 7
console.log(
'foo'.__proto__,
[].__proto__,
(new Date()).__proto__
);
... lines 13 - 21
},
... lines 23 - 57
};
... lines 59 - 74
})(window, jQuery);
Let's see what happens! Refresh! Nice! Each is a big list of things that we can call on each type of object. Apparently strings
have an indexOf() method, a match() method, normalize(), search(), slice() and a lot more. The Array has its own big list. If
you have a DateTime instance, you'll be able to call getHours(), getMilliseconds() and getMinutes(), to name a few.
To compare, let's Google for "JavaScript string methods". Check out the W3Schools result. This basically gives you the exact
same information we just found ourselves: these are the methods you can call on a string. The cool part is that we now
understand how this works: these are all stored on the __proto__ of each string object.
To prove it all works, add var helper2 = new Helper() and pass it a different $wrapper, like the footer on our page:
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... line 6
this.helper = new Helper(this.$wrapper);
var helper2 = new Helper($('footer'));
... lines 9 - 21
},
... lines 23 - 57
};
... lines 59 - 74
})(window, jQuery);
Since the footer doesn't have any rows that have weight on it, this should return zero. Log that:
this.helper.calculateTotalWeight() and helper2.calculateTotalWeight():
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = {
initialize: function ($wrapper) {
... line 6
this.helper = new Helper(this.$wrapper);
var helper2 = new Helper($('footer'));
console.log(
this.helper.calculateTotalWeight(),
helper2.calculateTotalWeight()
);
... lines 13 - 21
},
... lines 23 - 57
};
... lines 59 - 74
})(window, jQuery);
Here's the point of all of this: you do want to setup your objects so that they can be instantiated. And now we know how to do
this. First, set your variable to a function: this will become the constructor:
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 59
/**
* A "private" object
*/
var Helper = function ($wrapper) {
... line 64
};
... lines 66 - 74
})(window, jQuery);
And second, add any methods or properties you need under the prototype key:
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 65
Helper.prototype.calculateTotalWeight = function() {
... lines 67 - 72
};
})(window, jQuery);
You can still add keys directly to Helper, and these are basically the equivalent of static methods: you can only call them by
using the original object name, like Helper.foo or Helper.bar.
Let's keep going: we can organize all of this a bit better. And once we have, we'll be able to make RepLogApp object a
proper, instantiatable object... with almost no work.
Chapter 17: Extending the Prototype
From now on, we'll pretty much be adding everything to the prototype key. But, it does get a little bit annoying to always need
to say Helper.prototype.something = for every method:
76 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 65
Helper.prototype.calculateTotalWeight = function() {
... lines 67 - 72
};
})(window, jQuery);
No worries! We can shorten this with a shortcut that's similar to PHP's array_merge() function. Use $.extend() and pass it
Helper.prototype and then a second object containing all of the properties you want to merge into that object. In other words,
move our calculateTotalWeight() function into this and update it to be calculateTotalWeight: function:
73 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 60
$.extend(Helper.prototype, {
calculateTotalWeight: function() {
var totalWeight = 0;
this.$wrapper.find('tbody tr').each(function () {
totalWeight += $(this).data('weight');
});
return totalWeight;
}
});
})(window, jQuery);
At the bottom, we don't need the semicolon anymore. If we had more properties, we'd add them right below
calculateTotalWeight: no need to worry about writing prototype every time.
There's nothing special about $.extend, it's just a handy array_merge-esque function that we happen to have handy. You
may see other functions from other libraries that do the same thing.
74 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
this.$wrapper = $wrapper;
this.helper = new Helper(this.$wrapper);
this.$wrapper.find('.js-delete-rep-log').on(
'click',
this.handleRepLogDelete.bind(this)
);
this.$wrapper.find('tbody tr').on(
'click',
this.handleRowClick.bind(this)
);
};
... lines 17 - 72
})(window, jQuery);
Constructor done!
Next, add $.extend() with window.RepLogApp.prototype and {. The existing keys fit right into this perfectly! Winning! At the
end, add an extra ):
74 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 17
$.extend(window.RepLogApp.prototype, {
updateTotalWeightLifted: function () {
this.$wrapper.find('.js-total-weight').html(
this.helper.calculateTotalWeight()
);
},
handleRepLogDelete: function (e) {
e.preventDefault();
var $link = $(e.currentTarget);
$link.addClass('text-danger');
$link.find('.fa')
.removeClass('fa-trash')
.addClass('fa-spinner')
.addClass('fa-spin');
var deleteUrl = $link.data('url');
var $row = $link.closest('tr');
var self = this;
$.ajax({
url: deleteUrl,
method: 'DELETE',
success: function () {
$row.fadeOut('normal', function () {
$(this).remove();
self.updateTotalWeightLifted();
});
}
});
},
handleRowClick: function () {
console.log('row clicked!');
}
});
... lines 55 - 72
})(window, jQuery);
Yes! In our template, we won't use RepLogApp like this anymore. Instead, say var repLogApp = new RepLogApp($table):
77 lines app/Resources/views/lift/index.html.twig
... lines 1 - 64
{% block javascripts %}
... lines 66 - 69
<script>
$(document).ready(function() {
var $table = $('.js-rep-log-table');
var repLogApp = new RepLogApp($table);
});
</script>
{% endblock %}
We won't call any methods on that new repLogApp variable, but we could if we wanted to. We could also create multiple
RepLogApp objects if we had multiple tables on the page, or if we loaded a table via AJAX. Our JavaScript is starting to be
awesome!
Chapter 18: AJAX Form Submit: The Lazy Way
I'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.
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.
77 lines app/Resources/views/lift/index.html.twig
... 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:
22 lines app/Resources/views/lift/_form.html.twig
{{ 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:
22 lines app/Resources/views/lift/_form.html.twig
{{ 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:
77 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 - 50
</table>
{{ include('lift/_form.html.twig') }}
</div>
... lines 55 - 61
</div>
{% endblock %}
... lines 64 - 77
Ok, no problem: let's move the js-rep-log-table class from the table itself to the div that surrounds everything:
77 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 - 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:
77 lines app/Resources/views/lift/index.html.twig
... 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 %}
83 lines web/assets/js/RepLogApp.js
... 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'):
83 lines web/assets/js/RepLogApp.js
... 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.
Adding AJAX
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.
89 lines web/assets/js/RepLogApp.js
... 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():
89 lines web/assets/js/RepLogApp.js
... 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!
Chapter 19: 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.
... 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.
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>
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>.
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);
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);
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);
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);
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!
Chapter 20: Delegate Selectors FTW!
So dang. Each time we submit, it adds a new row to the table, but its delete button doesn't work until we refresh. What's going
on here?
Well, let's think about it. In RepLogApp, the constructor function is called when we instantiate it. So, inside
$(document).ready():
67 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 59
<script>
$(document).ready(function() {
var $wrapper = $('.js-rep-log-table');
var repLogApp = new RepLogApp($wrapper);
});
</script>
{% endblock %}
That means it's executed after the entire page has loaded.
Then, at that exact moment, our code finds all elements with a js-delete-rep-log class in the HTML, and attaches the listener
to each DOM Element:
99 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.find('.js-delete-rep-log').on(
'click',
this.handleRepLogDelete.bind(this)
);
... lines 12 - 19
};
... lines 21 - 97
})(window, jQuery);
So if we have 10 delete links on the page initially, it attaches this listener to those 10 individual DOM Elements. If we add a
new js-delete-rep-log element later, there will be no listener attached to it. So when we click delete, nothing happens! So,
what's the fix?
If you're like me, you've probably fixed this in a really crappy way before. Back then, after dynamically adding something to
my page, I would manually try to attach whatever listeners it needed. This is SUPER error prone and annoying!
Here's how it looks: instead of saying this.$wrapper.find(), use this.$wrapper.on() to attach the listener to the wrapper:
102 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.on(
'click',
... line 10
this.handleRepLogDelete.bind(this)
);
... lines 13 - 22
};
... lines 24 - 100
})(window, jQuery);
Then, add an extra second argument, which is the selector for the element that you truly want to react to:
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.on(
'click',
'.js-delete-rep-log',
this.handleRepLogDelete.bind(this)
);
... lines 13 - 22
};
... lines 24 - 100
})(window, jQuery);
That's it! This works exactly the same as before. It just says:
Whenever a click event bubbles up to $wrapper, please check to see if any elements inside of it with a js-delete-
rep-log were also clicked. If they were, fire this function! And have a great day!
You know what else! When it calls handleRepLogDelete, the e.currentTarget is still the same as before: it will be the js-
delete-rep-log link element. So all our code still works!
Ah, this is sweet! So let's use delegate selectors everywhere. Get rid of the .find() and add the selector as the second
argument:
To make sure this isn't one big elaborate lie, head back and refresh! Add a new rep log to the page... and delete it! It works!
And we can also submit the form again without refreshing!
So always use delegate selectors: they just make your life easy. And since we designed our RepLogApp object around a
$wrapper element, there was no work to get this rocking.
Chapter 21: Proper JSON API Endpoint Setup
It's time to graduate from this old-school AJAX approach where the server sends us HTML, to one where the server sends us
ice cream! I mean, JSON!
First, in LiftController::indexAction(), let's remove the two AJAX if statements from before: we won't use them anymore:
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 - 30
// return a blank form after success
if ($request->isXmlHttpRequest()) {
return $this->render('lift/_repRow.html.twig', [
'repLog' => $repLog
]);
}
... lines 37 - 40
}
... lines 42 - 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
}
In fact, we're not going to use this endpoint at all. So, close this file.
Next, head to your browser, refresh, and view the source. Find the <form> element and copy the entire thing. Then back in
your editor, find _form.html.twig and completely replace this file with that:
29 lines app/Resources/views/lift/_form.html.twig
<form class="form-inline js-new-rep-log-form" novalidate>
<div class="form-group">
<label class="sr-only control-label required" for="rep_log_item">
What did you lift?
</label>
<select id="rep_log_item"
name="rep_log[item]"
required="required"
class="form-control">
<option value="" selected="selected">What did you lift?</option>
<option value="cat">Cat</option>
<option value="fat_cat">Big Fat Cat</option>
<option value="laptop">My Laptop</option>
<option value="coffee_cup">Coffee Cup</option>
</select></div>
<div class="form-group">
<label class="sr-only control-label required" for="rep_log_reps">
How many times?
</label>
<input type="number" id="rep_log_reps"
name="rep_log[reps]" required="required"
placeholder="How many times?"
class="form-control"/>
</div>
<button type="submit" class="btn btn-primary">I Lifted it!</button>
</form>
We need to make two adjustments. First, get rid of the CSRF _token field. Protecting your API against CSRF attacks is a little
more complicated, and a topic for another time. Second, when you use the Symfony form component, it creates name
attributes that are namespaced. Simplify each name to just item and reps:
29 lines app/Resources/views/lift/_form.html.twig
<form class="form-inline js-new-rep-log-form" novalidate>
<div class="form-group">
... lines 3 - 5
<select id="rep_log_item"
name="item"
required="required"
class="form-control">
... lines 10 - 14
</select></div>
<div class="form-group">
... lines 18 - 20
<input type="number" id="rep_log_reps"
name="reps" required="required"
placeholder="How many times?"
class="form-control"/>
</div>
... lines 26 - 27
</form>
By the way, if you did want to use Symfony's form component to render the form, be sure to override the getBlockPrefix()
method in your form class and return an empty string:
That will tell the form to render simple names like this.
In src/AppBundle/Controller, open another file: RepLogController. This contains a set of API endpoints for working with
RepLogs: one endpoint returns a collection, another returns one RepLog, another deletes a RepLog, and one -
newRepLogAction() - can be used to create a new RepLog:
I want you to notice a few things. First, the server expects us to send it the data as JSON:
131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 9
use Symfony\Component\HttpFoundation\Request;
... line 11
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class RepLogController extends BaseController
{
... lines 16 - 64
public function newRepLogAction(Request $request)
{
... line 67
$data = json_decode($request->getContent(), true);
if ($data === null) {
throw new BadRequestHttpException('Invalid JSON');
}
... lines 72 - 101
}
... lines 103 - 129
}
Next, if you are a Symfony user, you'll notice that I'm still handling the data through Symfony's form system like normal:
The createApiResponse() method uses Symfony's serializer, which is a fancy way of returning JSON:
57 lines src/AppBundle/Controller/BaseController.php
... lines 1 - 8
class BaseController extends Controller
{
/**
* @param mixed $data Usually an object you want to serialize
* @param int $statusCode
* @return JsonResponse
*/
protected function createApiResponse($data, $statusCode = 200)
{
$json = $this->get('serializer')
->serialize($data, 'json');
return new JsonResponse($json, $statusCode, [], true);
}
... lines 23 - 56
}
On success, it does the same thing: returns JSON containing the new RepLog's data:
No problem! Find the form and add a fancy new data-url attribute set to path(), then the name of that route: rep_log_new:
29 lines app/Resources/views/lift/_form.html.twig
Bam! Now, back in RepLogApp, before we use that, let's clear out all the code that actually updates our DOM: all the stuff
related to updating the form with the form errors or adding the new row. That's all a todo for later:
But, do add a console.log('success') and console.log('error') so we can see if this stuff is working!
Next, our data format needs to change - I'll show you exactly how.
Chapter 22: POSTing to the API Endpoint
Before we keep going, I want to go back and look at what it used to look like when we submitted the form. I have not
refreshed yet, and this AJAX call is an example of what the POST request looked like using our old code.
Click that AJAX call and move to the "Headers" tab. When we sent the AJAX call before, what did our request look like? At
the bottom, you'll see "Form Data". But more interestingly, if you click "View Source", it shows you the raw request body that
we sent. It's this weird-looking, almost query-string format, with & and = between fields.
This is the traditional form submit format for the web, a data format called application/x-www-form-urlencoded, if you want to
get dorky about it. When you submit a normal HTML form, the data is sent like this. In PHP, that data is parsed into the
familiar $_POST variable. We don't realize that it originally looked like this, because PHP gives us that nice associative
array.
I wanted to show this because we are not going to send data in this format. Remember, our endpoint expects pure JSON. So
$form.serialize() is not going to work anymore.
Instead, above the AJAX call, create a new formData variable set to an associative array, or an object:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
... lines 63 - 64
var $form = $(e.currentTarget);
var formData = {};
... lines 67 - 82
}
});
... lines 85 - 102
})(window, jQuery);
If you Google for that function - jQuery serializeArray() - you'll see that it finds all the fields in a form and returns a big array
with keys name and value for each field.
This is not exactly what we want: we want an array where the name is the array key and that field's value is its value. No
problem, because we can loop over this and turn it into that format. Add a function with key and fieldData arguments. Then
inside, simply say, formData[fieldData.name] = fieldData.value:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
... lines 63 - 65
var formData = {};
$.each($form.serializeArray(), function(key, fieldData) {
formData[fieldData.name] = fieldData.value
});
... lines 70 - 82
}
});
... lines 85 - 102
})(window, jQuery);
Now that formData has the right format, turn it into JSON with JSON.stringify(formData):
Remember, we're doing this because that's what our endpoint expects: it will json_decode() the request body.
Ok, moment of truth. Refresh! Let's lift our laptop 10 times. Submit! Of course, nothing on the page changes, but we do have a
successful POST request! Check out the response: id, item, label, reps and totalWeightLifted. Cool!
Also check out the "Headers" section again and find the request body at the bottom. It's now pure JSON: you can see the
difference between our old request format and this new one.
Ok! It's time to get to work on our UI: we need to start processing the JSON response to add errors to our form and
dynamically add a new row on success.
Chapter 23: Handling JSON Validation Errors
Our first goal is to read the JSON validation errors and add them visually to the form. A moment ago, when I filled out the form
with no rep number, the endpoint sent back an error structure that looked like this: with an errors key and a key-value array of
errors below that.
That's the raw JSON that's sent back from the server.
To actually map the errorData onto our fields, let's create a new function below called _mapErrorsToForm with an errorData
argument. To start, just log that:
Above, to call this, we know we can't use this because we're in a callback. So add the classic var self = this;, and then call
self._mapErrorsToForm(errorData.errors):
109 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
... lines 63 - 69
var self = this;
$.ajax({
... lines 72 - 78
error: function(jqXHR) {
var errorData = JSON.parse(jqXHR.responseText);
self._mapErrorsToForm(errorData.errors);
}
});
},
... lines 85 - 88
});
... lines 90 - 107
})(window, jQuery);
All the important stuff is under the errors key, so we'll pass just that.
Ok, refresh that! Leave the form empty, and submit! Hey, beautiful error data!
And actually, there's a third way: which is to use a full front-end framework like ReactJS. We'll save that for a future tutorial.
But wait! Way up in our constructor, we're already referencing this selector:
It's no big deal, but I would like to not duplicate that class name in multiple places. Instead, add an _selectors property to our
object. Give it a newRepForm key that's set to its selector:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
_selectors: {
newRepForm: '.js-new-rep-log-form'
},
... lines 29 - 109
});
... lines 111 - 128
})(window, jQuery);
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 89
_mapErrorsToForm: function(errorData) {
... line 91
var $form = this.$wrapper.find(this._selectors.newRepForm);
... lines 93 - 95
$form.find(':input').each(function() {
... lines 97 - 107
});
}
});
... lines 111 - 128
})(window, jQuery);
Inside, we know that this is actually the form element. So we can say var fieldName = $(this).attr('name'):
I'm also going to find the wrapper that's around the entire form field. What I mean is, each field is surrounded by a .form-group
element. Since we're using Bootstrap, we also need to add a class to this. Find it with var $wrapper = $(this).closest('.form-
group'):
Perfect!
Then, if there is not any data[fieldName], the field doesn't have an error. Just continue:
If there is an error, we need to add some HTML to the page. The easy way to do that is by creating a new jQuery element. Set
var $error to $() and then the HTML you want: a span with a js-field-error class and a help-block class:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 89
_mapErrorsToForm: function(errorData) {
... lines 91 - 95
$form.find(':input').each(function() {
var fieldName = $(this).attr('name');
var $wrapper = $(this).closest('.form-group');
if (!errorData[fieldName]) {
// no error!
return;
}
var $error = $('<span class="js-field-error help-block"></span>');
... lines 105 - 107
});
}
});
... lines 111 - 128
})(window, jQuery);
I left the span blank because it's cleaner to add the text on the next line: $error.html(errorsData[fieldName]):
This jQuery object is now done! But it's not on the page yet. Add it with $wrapper.append($error). Also call
$wrapper.addClass('has-error'):
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 89
_mapErrorsToForm: function(errorData) {
... lines 91 - 95
$form.find(':input').each(function() {
var fieldName = $(this).attr('name');
var $wrapper = $(this).closest('.form-group');
if (!errorData[fieldName]) {
// no error!
return;
}
var $error = $('<span class="js-field-error help-block"></span>');
$error.html(errorData[fieldName]);
$wrapper.append($error);
$wrapper.addClass('has-error');
});
}
});
... lines 111 - 128
})(window, jQuery);
No problem: at the top, use $form.find() to find all the .js-field-error elements. And, remove those. Next, find all the form-group
elements and remove the has-error class:
And if we fill in both fields, the AJAX call is successful, but nothing updates. Time to tackle that.
Chapter 24: Clearing the Form, Prepping for a Template
Let's do the easy thing first: when we submit the form successfully, these errors need to disappear!
We already have code for that, so copy it, and isolate it into its own new method called _removeFormErrors:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 88
_mapErrorsToForm: function(errorData) {
this._removeFormErrors();
var $form = this.$wrapper.find(this._selectors.newRepForm);
... lines 92 - 105
},
... lines 107 - 119
});
... lines 121 - 138
})(window, jQuery);
The other thing we should do is empty, or reset the fields after submit. Let's create another function that does that and
removes the form's errors. Call it _clearForm. First call this._removeFormErrors():
To "reset" the form, get the DOM Element itself - there will be only one - by using [0] and calling reset() on it:
Ok, test this baby out! Submit it empty, then fill it out for real and submit. Boom!
Client-Side Templating??
Ok, back to the main task: on success, we need to add a new row to the table. We could do this the easy way: by manually
parsing the JSON and building the table. But there's one big problem: I do not want to duplicate the row markup in Twig AND
in JavaScript. Instead, we're going to use client-side templates.
Let's start off simple: at the bottom of our object, add a new function: _addRow that has a repLog argument. For now just log
that: this will be the RepLog data that the AJAX call sends back:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 121
_addRow: function(repLog) {
console.log(repLog);
}
});
... lines 126 - 143
})(window, jQuery);
Let's make sure things are working so far: refresh and add a new element. Yes! The data has id, itemLabel and even a links
key with a URL for this RepLog. We are ready to template!
In a nutshell, a client-side, or JavaScript templating engine is like having Twig, but in JavaScript. There are a lot of different
JavaScript templating libraries, but they all work the same: write a template - a mixture of HTML and dynamic code - and then
render it, passing in variables that are used inside. Again, it's just like using Twig... but in JavaScript!
One simple templating engine comes from a library called Underscore.js. This is basically a bunch of nice, utility functions for
arrays, strings and other things. It also happens to have a templating engine.
Google for Underscore CDN so we can be lazy and include it externally. Copy the minified version and then go back and
open app/Resources/views/base.html.twig. Add the new script tag at the bottom:
99 lines app/Resources/views/base.html.twig
... lines 1 - 90
{% block javascripts %}
... lines 92 - 93
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
{% endblock %}
... lines 96 - 99
Here's the goal: use a JavaScript template to render a new RepLog <tr> after we successfully submit the form. The first step
is to, well, create the template - a big string with a mix of HTML and dynamic code. If you look at the Underscore.js docs,
you'll see how their templates are supposed to look.
Now, we don't want to actually put our templates right inside JavaScript like they show, that would get messy fast. Instead,
one great method is to add a new script tag with a special type="text/template" attribute. Give this an id, like js-rep-log-row-
template, so we can find it later:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 66
<script type="text/template" id="js-rep-log-row-template">
... lines 68 - 80
</script>
{% endblock %}
Tip
The text/template part doesn't do anything special at all: it's just a standard to indicate that what's inside is not actually
JavaScript, but something else.
This is one of the few places where I use ids in my code. Inside, we basically want to duplicate the _repRow.html.twig
template, but update it to be written for Underscore.js.
So temporarily, we are totally going to have duplication between our Twig, server-side template and our Underscore.js,
client-side template. Copy all the <tr> code, then paste it into the new script tag.
Now, update things to use the Underscore.js templating format. So, <%= totalWeightLifted %>:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 66
<script type="text/template" id="js-rep-log-row-template">
<tr data-weight="<%= totalWeightLifted %>">
... lines 69 - 79
</tr>
</script>
{% endblock %}
This is the print syntax, and I'm using a totalWeightLifted variable because eventually we're going to pass these keys to the
template as variables: totalWeightLifted, reps, id, itemLabel and links.
Do the same thing to print out itemLabel. Keep going: the next line will be reps. And then use totalWeightLifted again... but
make sure you use the right syntax!
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 66
<script type="text/template" id="js-rep-log-row-template">
<tr data-weight="<%= totalWeightLifted %>">
<td><%= itemLabel %></td>
<td><%= reps %></td>
<td><%= totalWeightLifted %></td>
... lines 72 - 79
</tr>
</script>
{% endblock %}
But what about this data-url? We can't use the Twig path function anymore. But we can use this links._self key! That's
supposed to be the link to where we can GET info about this RepLog, but because our API is well-built, it's also the URL to
use for a DELETE request.
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 66
<script type="text/template" id="js-rep-log-row-template">
<tr data-weight="<%= totalWeightLifted %>">
<td><%= itemLabel %></td>
<td><%= reps %></td>
<td><%= totalWeightLifted %></td>
<td>
<a href="#"
class="js-delete-rep-log"
data-url="<%= links._self %>"
>
<span class="fa fa-trash"></span>
</a>
</td>
</tr>
</script>
{% endblock %}
Done! Our script tag trick is an easy way to store a template, but we could have also loaded it via AJAX. Winning!
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 121
_addRow: function(repLog) {
var tplText = $('#js-rep-log-row-template').html();
var tpl = _.template(tplText);
... lines 125 - 129
}
});
... lines 132 - 149
})(window, jQuery);
That doesn't render the template, it just prepares it. Oh, and like before, my editor doesn't know what _ is... so I'll switch back
to base.html.twig, press option+enter or alt+enter, and download that library. Much happier!
To finally render the template, add var html = tpl(repLog), where repLog is an array of all of the variables that should be
available in the template:
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 121
_addRow: function(repLog) {
var tplText = $('#js-rep-log-row-template').html();
var tpl = _.template(tplText);
var html = tpl(repLog);
... lines 127 - 129
}
});
... lines 132 - 149
})(window, jQuery);
Finally, celebrate by adding the new markup to the table: this.$wrapper.find('tbody') and then .append($.parseHTML(html)):
And since we have a new row, we also need to update the total weight. Easy! this.updateTotalWeightLifted():
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 121
_addRow: function(repLog) {
var tplText = $('#js-rep-log-row-template').html();
var tpl = _.template(tplText);
var html = tpl(repLog);
this.$wrapper.find('tbody').append($.parseHTML(html));
this.updateTotalWeightLifted();
}
});
... lines 132 - 149
})(window, jQuery);
Deep breath. Let's give this a shot. Refresh the page. I think we should lift our coffee cup ten times to stay in shape. Bah,
error! Oh, that was Ryan being lazy: our endpoint returns a links key, not link. Let's fix that:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 66
<script type="text/template" id="js-rep-log-row-template">
<tr data-weight="<%= totalWeightLifted %>">
... lines 69 - 71
<td>
<a href="#"
class="js-delete-rep-log"
data-url="<%= links._self %>"
>
... line 77
</a>
</td>
</tr>
</script>
{% endblock %}
Ok, refresh and try it gain! This time, let's lift our coffee cup 20 times! It's alive!!!
If you watch closely, it's even updating the total weight at the bottom.
I love it! Except for the massive duplication: it's a real bummer to have the row template in two places. Let me show you one
way to fix this.
Chapter 26: Full-JavaScript Rendering &
FOSJsRoutingBundle
When you try to render some things on the server, but then also want to update them dynamically in JavaScript, you're going
to run into our new problem: template duplication. There are kind of two ways to fix it. First, if you use Twig like I do, there is a
library called twig.js for JavaScript. In theory, you can write one Twig template and then use it on your server, and also in
JavaScript. I've done this before and know of other companies that do it also.
My only warning is to keep these shared templates very simple: render simple variables - like categoryName instead of
product.category.name - and try to avoid using many filters, because some won't work in JavaScript. But if you keep your
templates simple, it works great.
The second, and more universal way is to stop rendering things on your server. As soon as I decide I need a JavaScript
template, the only true way to remove duplication is to remove the duplicated server-side template and render everything via
JavaScript.
... lines 1 - 2
(function(window, $, Routing) {
window.RepLogApp = function ($wrapper) {
this.$wrapper = $wrapper;
this.helper = new Helper(this.$wrapper);
this.loadRepLogs();
... lines 9 - 24
};
... lines 26 - 160
})(window, jQuery, Routing);
Because here's the goal: when our object is created, I want to make an AJAX call to and endpoint that returns all of my
current RepLogs. We'll then use that to build all of the rows by using our template.
1. We could add a data- attribute to something, like on the $wrapper element in index.html.twig.
2. We could pass the URL into our RepLogApp object via a second argument to the constructor, just like we're doing with
$wrapper.
3. If you're in Symfony, you could cheat and use a cool library called FOSJsRoutingBundle.
Using FOSJsRoutingBundle
Google for that, and click the link on the Symfony.com documentation. This allows you to expose some of your URLs in
JavaScript. Copy the composer require line, open up a new tab, paste that and hit enter:
While Jordi is wrapping our package with a bow, let's finish the install instructions. Copy the new bundle line, and add that to
app/AppKernel.php:
57 lines app/AppKernel.php
... lines 1 - 5
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = [
... lines 11 - 21
new FOS\JsRoutingBundle\FOSJsRoutingBundle(),
... lines 23 - 24
];
... lines 26 - 34
}
... lines 36 - 55
}
16 lines app/config/routing.yml
... lines 1 - 13
fos_js_routing:
resource: "@FOSJsRoutingBundle/Resources/config/routing/routing.xml"
Finally, we need to add two script tags to our page. Open base.html.twig and paste them at the bottom:
... lines 1 - 90
{% block javascripts %}
... lines 92 - 94
<script src="{{ asset('bundles/fosjsrouting/js/router.js') }}"></script>
<script src="{{ path('fos_js_routing_js', { callback: 'fos.Router.setData' }) }}"></script>
{% endblock %}
... lines 98 - 101
This bundle exposes a global variable called Routing. And you can use that Routing variable to generate links in the same
way that we use the path function in Twig templates: just pass it the route name and parameters.
Tip
If you have a JavaScript error where Routing is not defined, you may need to run:
Now, head to RepLogController. In order to make this route available to that Routing JavaScript variable, we need to add
options={"expose" = true}:
Back in RepLogApp, remember that this library gives us a global Routing object. And of course, inside of our self-executing
function, we do have access to global variables. But as a best practice, we prefer to pass ourselves any global variables that
we end up using. So at the bottom, pass in the global Routing object, and then add Routing as an argument on top:
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 31
loadRepLogs: function() {
$.ajax({
url: Routing.generate('rep_log_list'),
success: function(data) {
console.log(data);
}
});
},
... lines 40 - 141
});
... lines 143 - 160
})(window, jQuery, Routing);
Ok, go check it out! Refresh! You can see the GET AJAX call made immediately. And adding a new row of course still works.
But look at the data sent back from the server: it has an items key with 24 entries. Inside, each has the exact same keys that
the server sends us after creating a new RepLog. This is huge: these are all the variables we need to pass into our template!
Let's keep celebrating: inside of LiftController - which renders index.html.twig - we don't need to pass in the repLogs or
totalWeight variables to Twig: these will be filled in via JavaScript. Delete the totalWeight variable from Twig:
71 lines src/AppBundle/Controller/LiftController.php
... lines 1 - 10
class LiftController extends BaseController
{
... lines 13 - 15
public function indexAction(Request $request)
{
... lines 18 - 35
return $this->render('lift/index.html.twig', array(
'form' => $form->createView(),
'leaderboard' => $this->getLeaders(),
));
}
... lines 41 - 69
}
If you refresh the page now, we've got a totally empty table. Perfect. Back in loadRepLogs, use $.each() to loop over
data.items. Give the function key and repLog arguments:
Finally, above the AJAX call, add var self = this. And inside, say self._addRow(repLog):
And that should do it! Refresh the page! Slight delay... boom! All the rows load dynamically: we can delete them and add
more. Mission accomplished!
Chapter 27: All About Promises!
Ok, let's talk promises: JavaScript promises. These are a hugely important concept in modern JavaScript, and if you haven't
seen them yet, you will soon.
We all know that in JavaScript, a lot of things can happen asynchronously. For example, Ajax calls happen asynchronously
and even fading out an element happens asynchronously: we call the fadeOut() function, but it doesn't finish until later. This
is so common that JavaScript has created an interface to standardize how this is handled. If you understand how it works,
you will have a huge advantage.
Hello Promise
Google for "JavaScript promise" and click into the Mozilla.org article. To handle asynchronous operations, JavaScript has an
object called a Promise. Yep, it's literally an object in plain, normal JavaScript - there are no libraries being used. There are
some browser compatibility issues, especially with Internet Explorer... like always... but it's easy to fix, and we'll talk about it
later.
This article describes the two sides to a Promise. First, if you need to execute some asynchronous code and then notify
someone later, then you will create a Promise object. That's basically what jQuery does internally when we tell it to execute
an AJAX call. This isn't very common to do in our code, but we'll see an example later.
The second side is what we do all the time: this is when someone else is doing the asynchronous work for us, and we need
to do something when it finishes. We're already doing stuff like this in at least 5 places in our code!
Here's the basic idea: if something happens asynchronously - like an AJAX call - that code should return a Promise object. If
it does, we can call .then() on it, and pass it the function that should be executed when the operation finishes successfully.
Now that we know that, Google for "jQuery Ajax" to find the $.ajax() documentation. Check this out: normally when we call
$.ajax(), we don't think about what this function returns. In fact, we're not assigning it to anything in our code.
But apparently, it returns something called a jqXHR object. Search for jqXHR object on this page - you'll find a header that
talks about it. First, it gives a bunch of basic details about this object. Fine. But look below the code block:
The jqXHR object implements the Promise interface, giving it all the properties, methods, and behavior of a
Promise.
Woh! In other words, what we get back from $.ajax() is an object that has all the functionality of a Promise! An easy, and
mostly-accurate way of thinking about this is: the jqXHR object is a sub-class of Promise.
Below, it shows you all of the different methods you can call on the jqXHR object. You can call .done(), which is an
alternative to the success option, or .fail() as an alternative to the failure option. AND, check this out, you can call .then(),
because .then() exists on the Promise object.
And guess what? We can just chain more handlers off of this one: add another .done() that looks the same. Print a message -
another handler - and also console.log(data) again:
The story here is that jQuery implemented this functionality before the Promise object was a standard. You could use any of
these methods, but instead, I want to focus on treating what we get back from jQuery as a pure Promise object. I want to
pretend that these other methods don't exist, and only rely on .then() and .catch():
Don't rely on .done(), just use .then(), which is the method you would use with any other library that implements
Promises.
If you look back at the Promise documentation, this makes sense. It says:
.then() appends a fulfillment handler on the Promise and returns a new Promise resolving to the return value of
the called handler.
Ah, so when we add the second .then(), that's not being attached to the original Promise, that's being attached to a new
Promise that's returned from the first .then(). And according to the rules, the value for that new Promise is equal to whatever
we return from the first.
Back in the browser, it works! Both handlers are passed the same data.
What about handling failures? As you can see in the Promise documentation, the .then() function has an optional second
argument: a function that will be called on failure. In other words, we can go to the end of .then() and add a function. We know
that the value passed to jQuery failures is the jqXHR. Let's console.log('failed') and also log jqXHR.responseText:
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 79
handleNewFormSubmit: function(e) {
... lines 81 - 100
}).then(function(data) {
... lines 102 - 105
}, function(jqXHR) {
console.log('failed!');
console.log(jqXHR.responseText);
}).then(function(data) {
... lines 110 - 111
})
},
... lines 114 - 155
});
... lines 157 - 174
})(window, jQuery, Routing);
Ok, refresh! Keep the form blank and submit. Ok cool! It did call our failure handler and it did print the responseText correctly.
Here's the deal: catch is named catch for a reason: you really need to think about it in the same way as a try-catch block in
PHP. It will catch the failed Promise above and return a new Promise that resolves successfully. That means that any
handlers attached to it - like our second .then() - will execute as if everything was fine.
We're going to talk more about this, but obviously, this is probably not what we want. Instead, move the .catch() to the end:
Now, the second .then() will only be executed if the first .then() is executed. The .catch() will catch any failed Promises - or
errors - at the bottom. More on the error catching later.
I'm not worried about returning anything because we're not chaining our "then"s. Remove the second .then() and move the
error callback code into .catch():
With any luck, that will work exactly like before. Yea! The error looks good. And adding a new one works too.
Let's find our two other $.ajax() spots. Do the same thing there: Move the success function to .then(), and move the other
success also to .then():
Awesome!
Now, move our AJAX code here, and return it. Set the data key to JSON.stringify(data). And for the url, we can replace this
with Routing.generate('rep_log_new'):
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 96
_saveRepLog: function(data) {
return $.ajax({
url: Routing.generate('rep_log_new'),
method: 'POST',
data: JSON.stringify(data)
});
},
... lines 104 - 145
});
... lines 147 - 164
})(window, jQuery, Routing);
Here's the point: above, replace the AJAX call with simply this._saveRepLog() and pass it formData:
Isolating asynchronous code like this wasn't possible before because, in this function, we couldn't add any success or failure
options to the AJAX call. But now, since we know _saveRepLog() returns a Promise, and since we also know that Promises
have .then() and .catch() methods, we're super dangerous. If we ever needed to save a RepLog from somewhere else in our
code, we could call _saveRepLog() to do that... and even attach new handlers in that case.
Our AJAX call works really well, because when we make an AJAX call to create a new RepLog, our server returns all the
data for that new RepLog. That means that when we call .then() on the AJAX promise, we have all the data we need to call
_addRow() and get that new row inserted!
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 77
handleNewFormSubmit: function(e) {
... lines 79 - 86
this._saveRepLog(formData)
.then(function(data) {
self._clearForm();
self._addRow(data);
... lines 91 - 93
});
},
... lines 96 - 145
});
... lines 147 - 164
})(window, jQuery, Routing);
Now head over and fill out the form successfully. Whoa!
Yep, it blew up - that's not too surprising: we get an error that says:
And if you look closely, that's coming from underscore.js. This is almost definitely an error in our template. We pass the
response data - which is now empty - into ._addRow():
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 77
handleNewFormSubmit: function(e) {
... lines 79 - 87
.then(function(data) {
... line 89
self._addRow(data);
... lines 91 - 93
});
},
... lines 96 - 145
});
... lines 147 - 164
})(window, jQuery, Routing);
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 136
_addRow: function(repLog) {
var tplText = $('#js-rep-log-row-template').html();
var tpl = _.template(tplText);
var html = tpl(repLog);
this.$wrapper.find('tbody').append($.parseHTML(html));
this.updateTotalWeightLifted();
}
});
... lines 147 - 164
})(window, jQuery, Routing);
An empty response means that no variables are being passed. Hence, totalWeightLifted is not defined.
Now, as we understand it, our catch should only be called when our Promise fails, in other words, when we have an AJAX
error. But in this case, the server returns a 204 status code - that is a successful status code. So why is our catch being
called?
Here's the deal: in reality, .catch() will be called if your Promise is rejected, or if a handler above it throws an error. Since our
.then() calls _addRow() and that throws an exception, this ultimately triggers the .catch(). Again, this works a lot like the try-
catch block in PHP!
Tip
There are some subtle cases when throwing an exception inside asynchronous code won't trigger your .catch(). The Mozilla
Promise Docs discuss this!
Let's console.log(jqXHR):
Ok, refresh and fill out our form. There it is! Thanks to the error, it logs a "ReferenceError".
We've just found out that .catch() will catch anything that went wrong... and that the value passed to your handler will depend
on what went wrong. This means that, if you want, you can code for this: if (jqXHR instanceof ReferenceError), then
console.log('wow!'):
Let's see if that hits! Refresh, lift some laptops and, there it is!
What JavaScript doesn't have is the ability to do more intelligent try-catch block, where you catch only certain types of errors.
Instead, .catch() handles all errors, but then, you can write your code to be a bit smarter.
Since we really only want to catch jqXHR errors, we could check to see if the jqXHR value is what we're expecting. One way
is to check if jqXHR.responseText === 'undefined'. If this is undefined, this is not the error we intended to handle. To not
handle it, and make that error uncaught, just throw jqXHR:
Now, if you wanted to, you could add another .catch() on the bottom, and inside its function, log the e value:
You see, because the first catch throws the error, the second one will catch it.
And when we try it now, the error prints two times - jQuery's Promise logs a warning each time an error is thrown inside a
Promise. And then at the bottom, there's our log.
Why? Well, I'm not going to code defensively unless I'm coding against a situation that might possibly happen. In this case, it
was developer error: my code just isn't written correctly for the server. Instead of trying to code around that, we just need to fix
things!
We do the same thing in PHP: most of the time, we let exceptions happen... because it means we messed up!
Ok, we understand more about .catch(), but we still need to fix this whole situation! To do that, we'll need to create our own
Promise.
Chapter 30: Making (and Keeping) a Promise
Ignore the error for a second and go down to the AJAX call. We know that this method returns a Promise, and then we call
.then() on it:
But, our handler expects that the Promise's value will be the RepLog data. But now, it's null because that's what the server is
returning!
Somehow, I want to fix this method so that it once again returns a Promise whose value is the RepLog data.
How? Well first, we're going to read the Location header that's sent back in the response - which is the URL we can use to
fetch that RepLog's data:
We'll use that to make a second AJAX call to get the data we need.
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 96
_saveRepLog: function(data) {
return $.ajax({
... lines 99 - 101
}).then(function(data, textStatus, jqXHR) {
... line 103
});
},
... lines 106 - 147
});
... lines 149 - 166
})(window, jQuery, Routing);
Normally, promise handlers are only passed 1 argument, but in this case jQuery cheats and passes us 3. To fetch the
Location header, say console.log(jqXHR.getResponseHeader('Location')):
Go see if that works: we still get the errors, but hey! It prints /reps/76! Cool! Let's make an AJAX call to that: copy the jqXHR
line. Then, add our favorite $.ajax() and set the URL to that header. Add a .then() to this Promise with a data argument:
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 96
_saveRepLog: function(data) {
return $.ajax({
... lines 99 - 101
}).then(function(data, textStatus, jqXHR) {
$.ajax({
url: jqXHR.getResponseHeader('Location')
}).then(function(data) {
... lines 106 - 107
});
});
},
... lines 111 - 152
});
... lines 154 - 171
})(window, jQuery, Routing);
To check things, add console.log('now we are REALLY done') and also console.log(data) to make sure it looks right:
Ok, refresh and fill out the form. Ignore the errors, because there's our message and the correct data!
Ok, now we can just return this somehow, right? Wait, that's not going to work... When we return the main $.ajax(), that
Promise is resolved - meaning finished - the moment that the first AJAX call is made. You can see that because the errors
from the handlers happen first, and then the second AJAX call finishes.
Somehow, we need to return a Promise that isn't resolved until that second AJAX call finishes.
There are two ways to do this - we'll do the harder way... because it's a lot more interesting - but I'll mention the other way at
the end.
If you look at the Promise documentation, you'll find an example of how to do this: new Promise() with one argument: a
function that has resolve and reject arguments. I know, it looks a little weird.
Inside of that function, you'll put your asynchronous code. And as soon as it's done, you'll call the resolve() function and pass
it whatever value should be passed to the handlers. If something goes wrong, call the reject() function. This is effectively what
jQuery is doing right now inside of its $.ajax() function.
A polyfill is a library that gives you functionality that's normally only available in a newer version of your language, JavaScript
in this case. PHP also has polyfills: small PHP libraries that backport newer PHP functionality.
This polyfill guarantees that the Promise object will exist in JavaScript. If it's already supported by the browser it uses that.
But if not, it adds it.
Copy the es6-promise.auto.min.js path. In the next tutorial, we'll talk all about what that es6 part means. Next, go into
app/Resources/views/base.html.twig and add a script tag with src="" and this path:
102 lines app/Resources/views/base.html.twig
... lines 1 - 90
{% block javascripts %}
... lines 92 - 96
<script src="https://cdnjs.cloudflare.com/ajax/libs/es6-promise/4.0.5/es6-promise.auto.min.js"></script>
{% endblock %}
... lines 99 - 102
Creating a Promise
In _saveRepLog, create and return a new Promise, passing it the 1 argument it needs: a function with resolve and reject
arguments:
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 96
_saveRepLog: function(data) {
return new Promise(function(resolve, reject) {
... lines 99 - 112
});
},
... lines 115 - 156
});
... lines 158 - 175
})(window, jQuery, Routing);
Now, all we need to do is call resolve() when our asynchronous work is finally resolved. This happens after the second AJAX
call. Great! Just call resolve() and pass it data:
Finally, the RepLog data should once again be passed to the success handlers!
Go back now and refresh. Watch the total at the bottom: lift the big fat cat 10 times and... boom! The new row was added and
the total was updated. It worked!
This is huge! Our _saveRepLog function previously returned a jqXHR object, which implements the Promise interface. Now,
we've changed that to a real Promise, and our code that calls this function didn't need to change at all. The .then() and
.catch() work exactly like before. Ultimately, before and after this change, _saveRepLog() returns a promise whose value is
the RepLog data.
No problem: after .then(), add a .catch() to handle the AJAX failure. Inside that, call reject() and pass it jqXHR: the value that
our other .catch() expects:
We could also add a .catch() to the second AJAX call, but this should never fail under normal circumstances, so I think that's
overkill.
Refresh again! And try the form blank. Perfect! But, we can get a little bit fancier.
Chapter 31: Promise Chaining
Oh, but now we can get even cooler! The .catch() handler above reads the responseText off of the jqXHR object and uses its
error data:
If we want, we could simplify the code in the handler by doing that before we reject our Promise.
As soon as we do that, any .catch() handlers will be passed the nice, clean errorData:
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 77
handleNewFormSubmit: function(e) {
... lines 79 - 86
this._saveRepLog(formData)
.then(function(data) {
... lines 89 - 90
}).catch(function(errorData) {
self._mapErrorsToForm(errorData.errors);
});
},
... lines 95 - 157
});
... lines 159 - 176
})(window, jQuery, Routing);
Refresh! And submit the form. Yes! Now, if we ever need to call _saveRepLog() from somewhere else, attaching a .catch()
handler will be easier: we're passed the most relevant error data.
Creating your own Promise objects is not that common, but it's super powerful, giving you the ability to perform multiple
asynchronous actions and allow other functions to do something once they all finish.
... lines 1 - 2
(function(window, $, Routing) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 95
_saveRepLog: function(data) {
return new Promise(function(resolve, reject) {
$.ajax({
... lines 99 - 101
}).then(function(data, textStatus, jqXHR) {
$.ajax({
... lines 104 - 107
});
... lines 109 - 112
});
});
},
... lines 116 - 157
});
... lines 159 - 176
})(window, jQuery, Routing);
That's exactly what's happening in _saveRepLog(). In this case, you can actually return a Promise from your handler.
Here's a simpler version of how our code could have looked to solve this same problem. Well, simpler at least in terms of the
number of lines.
The first $.ajax() returns a Promise, and we immediately attach a .then() listener to it. From inside of that .then(), we return
another Promise. When you do this, any other chained handlers will not be called until that Promise, meaning, the second
AJAX call, has completed.
Let me say it a different way. First, because we're chaining .then() onto the $.ajax(), the return value of _saveRepLog() is
actually whatever the .then() function returns. And what is that? Both .then() and .catch() always return a Promise object.
And, up until now, the value used by the Promise returned by .then() or .catch() would be whatever value the function inside
returned. But! If that function returns a Promise, then effectively, that Promise is what is ultimately returned by .then() or
.catch().
Tip
Technically, .then() should return a new Promise that mimics that Promise returned by the function inside of it. But it's easier
to imagine that it directly returns the Promise that was returned inside of it.
That's a long way of saying that other chained listeners, will wait until that internal Promise is resolved. In our example, it
means that any .then() handlers attached to _saveRepLog() will wait until the inner AJAX call is finished. In fact, that's the
whole point of Promises: to allow us to perform multiple asynchronous actions by chaining a few .then() calls, instead of
doing the old, ugly, nested handler functions.
Phew! Ok! Let's move on to one last, real-world example of using a Promise: inside an external library.
Chapter 32: SweetAlert: Killing it with Promises
For our last trick, Google for a library called SweetAlert2. Very simply, this library give us sweet alert boxes, like this. And you
can customize it in a lot of ways, like having a "Yes" and "Cancel" button.
We're going to use SweetAlert so that when we click the delete icon, an alert opens so the user can confirm the delete before
we actually do it.
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 53
{% block javascripts %}
{{ parent() }}
... line 56
<script src="https://cdn.jsdelivr.net/sweetalert2/6.1.0/sweetalert2.min.js"></script>
... lines 58 - 81
{% endblock %}
This also comes with a CSS file: copy that too. Back in index.html.twig, override a block called stylesheets and add the
endblock. Call parent() to include the normal stylesheets, and then add the link tag with this path:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 47
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/sweetalert2/6.1.0/sweetalert2.min.css" />
{% endblock %}
... lines 53 - 83
Perfect!
This library exposes a global swal() function. Copy the timer example - it's somewhat similar to what we want. Then, open
RepLogApp.js. Remember, whenever we reference a global object, we like to pass it into our self-executing function. You
don't need to do this, but it's super hipster. Pass swal at the bottom and also swal on top:
... lines 1 - 2
(function(window, $, Routing, swal) {
... lines 4 - 192
})(window, jQuery, Routing, swal);
If you want some auto-completion on that library, you can of course select it and hit option+enter or alt+enter to tell PhpStorm
to download it.
Down in handleRepLogDelete, here's the plan. First, we'll open the alert. And then, when the user clicks "OK", we'll run all of
the code below that actually deletes the RepLog. To prepare for that, isolate all of that into its own new method:
_deleteRepLog with a $link argument:
This doesn't change anything: we could still just call this function directly from above. But instead, paste the SweetAlert code
and update the title - "Delete this log" - and the text - "Did you not actually lift this?". And remove the timer option. Instead,
add showCancelButton: true:
Go Deeper!
In version 7, when you click "Cancel", the reject handler is not called anymore. Instead, the success handler is called, but
you can use the promise argument to check which button was clicked! See
https://github.com/sweetalert2/sweetalert2/releases/tag/v7.0.0 for details!
Of course! I need be more careful with my ordering. Right now, we still need to manually make sure that we include the
libraries in the correct order: including SweetAlert first, so that it's available to RepLogApp:
83 lines app/Resources/views/lift/index.html.twig
... lines 1 - 53
{% block javascripts %}
... lines 55 - 56
<script src="https://cdn.jsdelivr.net/sweetalert2/6.1.0/sweetalert2.min.js"></script>
<script src="{{ asset('assets/js/RepLogApp.js') }}"></script>
... lines 59 - 81
{% endblock %}
Ok, try it again. Things look happy! Now, click the little trash icon. Boom! We have "OK" and "Cancel".
Specifically, for SweetAlert, the success, or resolved handler is called if we click "OK", and the reject handler is called if we
click "Cancel". Easy! Above the swal() call, add var self = this. Then, inside the success handler, use
self._deleteRepLog($link):
... lines 1 - 2
(function(window, $, Routing, swal) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 48
handleRepLogDelete: function (e) {
... lines 50 - 53
var self = this;
swal({
... lines 56 - 58
}).then(
function () {
self._deleteRepLog($link);
},
function () {
... line 64
}
);
},
... lines 69 - 174
});
... lines 176 - 192
})(window, jQuery, Routing, swal);
Down in the reject function, we don't need to do anything. Just call console.log('canceled'):
Let's try it! Refresh, click the trash icon and hit "Cancel". Yea, there's the log! Now hit "OK". It deletes it! Guys, this is why
understanding promises is so important.
And we also know that instead of passing two arguments to .then(), we could instead chain a .catch() onto this:
And we also also know that both functions are passed a value, and what that value is depends on the library. Add an arg to
.catch() and log it:
... lines 1 - 2
(function(window, $, Routing, swal) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 48
handleRepLogDelete: function (e) {
... lines 50 - 54
swal({
... lines 56 - 60
}).catch(function(arg) {
console.log('canceled', arg);
});
},
... lines 65 - 170
});
... lines 172 - 188
})(window, jQuery, Routing, swal);
Ok, refresh, hit delete and hit cancel. Oh, it's a string: "cancel". Try it again, but hit escape this time to close the alert. Now it's
esc. Interesting! If you search for "Promise" on its docs, you'll find a spot called "Handling Dismissals". Ah, it basically says:
When an alert is dismissed by the user, the reject function is passed one of these strings, documenting the reason
it was dismissed.
That's pretty cool. And more importantly, it was easy for us to understand.
That will show a little loading icon after the user clicks "OK". Next, add the preConfirm option set to a function. Inside, return a
new Promise with the familiar resolve and reject arguments:
... lines 1 - 2
(function(window, $, Routing, swal) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 48
handleRepLogDelete: function (e) {
... lines 50 - 54
swal({
... lines 56 - 59
preConfirm: function() {
return new Promise(function(resolve, reject) {
... lines 62 - 64
});
}
... lines 67 - 70
});
},
... lines 73 - 178
});
... lines 180 - 196
})(window, jQuery, Routing, swal);
Just to fake it, let's pretend we need to do some work before we can actually delete the RepLog, and that work will take about
a second. Use setTimeout() to fake this: pass that a function and set it to wait for one second. After the second, we'll call
resolve():
Try it! Refresh and click delete. After I hit ok, you should see a loading icon for one second, before the alert finally closes. Do
it! There it was! Viva promises!
More realistically, sometimes - instead of doing my work after the alert closes, I like to do my work, my AJAX call, inside of
preConfirm. After all, SweetAlert shows the user a pretty fancy loading icon while they're waiting. Let's do that here - it's super
easy!
Move the self._deleteRepLog() call up into the preConfirm function and return it. Then get rid of the .then() entirely:
This is totally legal, as long as the _deleteRepLog() function returns a Promise. In other words, as long as we return $.ajax(),
SweetAlert will be happy:
... lines 1 - 2
(function(window, $, Routing, swal) {
... lines 4 - 26
$.extend(window.RepLogApp.prototype, {
... lines 28 - 67
_deleteRepLog: function($link) {
... lines 69 - 78
return $.ajax({
... lines 80 - 86
})
},
... lines 89 - 173
});
... lines 175 - 191
})(window, jQuery, Routing, swal);
We can still keep the catch here, because if you hit cancel, that will still reject the promise and call .catch(). Head back,
refresh, and click delete. You should see the loading icon for just a moment, while our AJAX call finishes. Hit "Ok"! Beautiful!
Cleanup My Mistakes
Oh, and by the way, if you noticed that I was still using .done() in a few places, that was an accident! Let's change this to
.then(), and do the same thing in loadRepLogs:
Now we're using the true Promise functions, not the .done() function that only lives in jQuery.
Woh, we're done! I hope you guys thoroughly enjoyed this weird dive into some of the neglected parts of JavaScript! In the
next tutorial in this series, we're going to talk about ES6, a new version of JavaScript, which has a lot of new features and
new syntaxes that you probably haven't seen yet. But, they're critical to writing modern JavaScript.