Lift Stuff! The js- Prefix

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

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.

The Project: Pump Up!

As always, please, please, please, do the heavy-lifting and code along with me. By the way, in 30 seconds, I promise you'll understand why I'm making all these amazing weight-lifting puns. I know, you just can't... weight.

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!

But, from a JavaScript standpoint, this is all incredibly boring, I mean traditional! Our first job - in case I fall over my keyboard while eating a donut and mess up - is to add a delete icon to each row. When we click that, it should send an AJAX request to delete that from the database, remove the row entirely from the page, and update the total at the bottom.

Right now, this entire page is rendered on the server, and the template lives at 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>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for repLog in repLogs %}
<tr>
<td>{{ repLog.itemLabel|trans }}</td>
<td>{{ repLog.reps }}</td>
<td>{{ repLog.totalWeightLifted }}</td>
<td>
&nbsp;
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Get liftin'!</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td>&nbsp;</td>
<th>Total</th>
<th>{{ totalWeight }}</th>
<td>&nbsp;</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:

... 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>
&nbsp;
</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:

... lines 1 - 8
/**
* RepLog
*
* @ORM\Table(name="rep_log")
* @ORM\Entity(repositoryClass="AppBundle\Repository\RepLogRepository")
*/
class RepLog
{
... lines 17 - 27
/**
* @var integer
*
* @Serializer\Groups({"Default"})
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var integer
*
* @Serializer\Groups({"Default"})
* @ORM\Column(name="reps", type="integer")
* @Assert\NotBlank(message="How many times did you lift this?")
* @Assert\GreaterThan(value=0, message="You can certainly life more than just 0!")
*/
private $reps;
/**
* @var string
*
* @Serializer\Groups({"Default"})
* @ORM\Column(name="item", type="string", length=50)
* @Assert\NotBlank(message="What did you lift?")
*/
private $item;
/**
* @var float
*
* @Serializer\Groups({"Default"})
* @ORM\Column(name="totalWeightLifted", type="float")
*/
private $totalWeightLifted;
... lines 64 - 198
}

To add the delete link, inside the last <td> add a new anchor tag. Set the href to #, since we plan to let JavaScript do the work. And then, give it a class: js-delete-rep-log:

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

Inside, add our cute little delete icon:

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

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

... lines 1 - 62
{% block javascripts %}
{{ parent() }}
<script>
$('.js-delete-rep-log').on('click', function() {
console.log('todo delete!');
});
</script>
{% endblock %}

Resolving External JS in PHPStorm

But hmm, PhpStorm says that $ is an unresolved function or method. Come on! I do have jQuery on the page. Open the base layout file - base.html.twig - and scroll to the bottom:

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

Using .on() versus .click()

Oh, and I want you to notice one other thing: we're using .on('click') instead of the .click() function. Why? Well, they both do the same thing. But, there are an infinite number of events you could listen to on any element: click, change, keyup, mouseover or even custom, invented events. By using .on(), we have one consistent way to add a listener to any event.

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.

Leave a comment!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.0",
        "symfony/symfony": "3.1.*", // v3.1.10
        "twig/twig": "2.10.*", // v2.10.0
        "doctrine/orm": "^2.5", // v2.7.1
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.2
        "symfony/swiftmailer-bundle": "^2.3", // v2.4.0
        "symfony/monolog-bundle": "^2.8", // 2.12.0
        "symfony/polyfill-apcu": "^1.0", // v1.2.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "~2.0@dev", // dev-master
        "doctrine/doctrine-fixtures-bundle": "~2.3", // v2.4.1
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.2.1
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "friendsofsymfony/jsrouting-bundle": "^1.6" // 1.6.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.1
        "symfony/phpunit-bridge": "^3.0" // v3.1.6
    }
}