Building a Hacker News Clone in AngularJS – Part 2

tutorial

This is part 2 of my walkthrough on creating a Hacker News clone in AngularJS. This is one of the final projects in the Learn Verified Curriculum. I recommend reading through Part 1 of the series on Building a Hacker News Clone first.

In the first post, we bootstrapped our Angular app, created a nested view using Angular UI Router, created a TopStoriesService to connect to the Hacker News API, and used that service to display a list of story ids in the browser. In this post, we’ll cover the following tasks:

  • Adding a custom directive to display each story
  • Calling on our TopStoriesService within our directive’s controller to fetch information from the Hacker News API
  • Adding our custom directive to the top-stories view
  • Adding a view template for our new directive to use

Adding a Custom Directive to Display Each Story

First, we’re going to create a custom directive called story that will be in charge of displaying each story. To do that, I’m going to create a new folder for directives and a file for our new directive:

mkdir js/app/directives
touch js/app/directives/Story.js

Before we forget, let’s add a link to this new Story.js file to the bottom of our index.html file:

<!-- index.html -->
<script src="js/app/directives/Story.js"></script>

And within the file for our new directive, let’s create it. This is where things start to get a little tricky. How are we going to get the data that we need from Hacker News into the directive? Do we inherit it from the TopStoriesController that we already have defined? Do we add it in a link function inside the directive? I am reading a lot of Angular documentation and going through my notes from the course to decide which is the best way to do this.

To get started, I decided that using an isolate scope on the Story directive was probably the best way to go about pulling in information for each individual story to create my Hacker News Clone. Specifically, we want to pass each Story’s ID from the TopStoriesController Scope into its own Story directive scope via an attribute. Details will follow below.

NOTE

If you run into this really annoying error, look for something syntactically wrong. I ran into this error last week when I’d failed to add one of the closing script tags to the bottom of my index.html file. This time, I had capitalized 'Story' when I named my custom directive. Angular requires the names of custom directives to be camelCased! As a result, a directive named 'myDirective' would be called in html using <my-directive>. To clarify, I’ll highlight the line in the js/app/directives/Story.js file below that contains the name of the directive (‘story’ instead of ‘Story’).

Calling on our TopStoriesService within our Directive’s Controller to Fetch Information from the Hacker News API

Below, you will see the code for our custom directive. You’ll want to keep an eye on the highlighted lines to see:

  1. The correction I made to line 4 that allows the directive to work properly within the HTML later on.
  2. Lines 8-10, where we define an id property on our directive’s scope object. This allows us to pass the story’s id as an attribute to the directive element in our html (see AngularJS docs for more details).
  3. The directive’s controller, where we set up the connection between the story ID passed into our scope from the HTML and the method in our TopStoriesService that allows us to get the Story details

Also, notice in the directive’s controller we call the getStory method from our TopStoriesService and we have .then(function(res){}) afterwards. This is Angular’s way of implementing a promise. Check out this awesome cartoon explaining promises in AngularJS for more info.

// js/app/directives/Story.js
angular 
  .module('app')
  .directive('story', function(){
    return {
      restrict: 'E',
      replace: true,
      scope: {
        id: '=id'
      },
      templateUrl: 'views/story.html',
      controller: function($scope, TopStoriesService) {
        var vm = this;
        
        TopStoriesService
          .getStory($scope.id)
          .then(function(res){
            vm.story = res.data; 
          });
      },
      controllerAs: 'story',
      link: function(scope, elem, attrs, ctrl) {
        
      }
    };
  });

Now that we’ve created our custom Story directive, we can use the story element to get information from the Hacker News API and return it to the view.

Adding our Custom Directive to the Top-Stories View

Recall that the TopStoriesController that governs the top stories state has a property ctrl.stories that contains the ids of the 500 of the top stories on Hacker News. The way we get each story’s information into the page is that we loop through that array of Ids (within the top-stories view) and pass each one into our custom story directive as an attribute. As you can see in the highlighted lines above, the controller in our custom directive then calls on the TopStoriesService to get that story’s information and pass it to the view. In order to do that, we open up the top-stories.html view used by the TopStoriesController and we pass in the id of each post to our story directive as an attribute within the ng-repeat:

<!-- views/top-stories.html -->
<h2>Here are the top stories</h1>
<ul>
    <li ng-repeat="story in vm.stories">
        <story id="story"></story>
    </li>
</ul>

This is all we need to do here to get all the information we need into our Hacker News Clone. But, if we visit the page now, we won’t see anything. That’s because we haven’t filled in the story.html template for our custom directive yet.

Adding a View Template for our New Directive to Use

Now, all we need to do to add content to our top stories page is to fill in the template for our story directive (views/story.html). First, I’ll show you how we can work with the data that’s returned from the Hacker News API.

<!-- views/story.html -->
{{ vm.story }}

When we use this as our template, we get the following response:

Hacker News Clone Top Stories Data

Here we see the story objects, as JSON strings, returned in the unordered list. If we hit the URL: https://hacker-news.firebaseio.com/v0/item/12154137.json We get the following response:

{
  by: "juanplusjuan",
  descendants: 104,
  id: 12154137,
  kids: [
    12155547,
    12154168,
    12155531,
    12154196,
    12154514,
    12154387,
    12156482,
    12155946,
    12154913,
    12156415,
    12154640,
    12154814,
    12157094
  ],
  score: 408,
  time: 1469384291,
  title: "With Launch of AU Passport, Africa Is Now Borderless",
  type: "story",
  url: "http://venturesafrica.com/with-the-launch-of-the-au-passport-africa-is-now-borderless/"
}

So, we have the author, the id, the score, the timecode of the post, the title, the type, the url, and kids. The kids are comments that are related to the story. Now that we’ve got all of the pieces set up, let’s define some new properties for the data we need within our controller so that we can display them in the view. First, let’s take a look at one of the stories in Hacker News and see what data we’re going to need:

Hacker News Individual Story Item

Okay, so looking at this let’s make a list of all the information we’re going to need:

  • The number of the story (in an ordered list)
  • Title (linked to the story’s url)
  • The domain in parentheses
  • The score
  • The author
  • The time (formatted in hours ago)
  • A link to hide the story
  • A link to the comments displaying the number of comments

Adding the Number of the Story to our Hacker News Clone

Looking at this, right away I can see that we’d be better off using an ordered list than an unordered list. So, let’s open up our top-stories view and switch our <ul> to a <ol>:

<!-- views/top-stories.html -->
<h2>Here are the top stories</h1>
<ol>
    <li ng-repeat="story in vm.stories">
        <story id="story"></story>
    </li>
</ol>

Adding the Linked Title to our Hacker News Clone

Now, We can start defining the needed attributes to our custom directive’s controller object. We’ll do this within the call to the promise that results from our connection to the Hacker News API in the TopStoriesService. Let’s start off by adding the links to each story (link text will be the title of the story):

// js/app/directives/Story.js
TopStoriesService
  .getStory($scope.id)
  .then(function(res){
    vm.story = res.data;
    vm.title = vm.story.title;
    vm.url = vm.story.url;
  });

And now let’s display some of this information within the directive’s view template:

<!-- views/story.html -->
<div>
    <strong><a href="{{ vm.url}}">{{ vm.title }}</a></strong> 
</div>

Now, when we take a look at our Hacker News Clone in the browser, here’s what we’ll see:

Hacker News Clone Numbered List with Links to Stories

Great! Looking good so far.

Adding the Domain in Parentheses

Now let’s add the domain in parentheses after our link. To do this, we’re going to create a function that can read the url property, create an anchor element and assign the href property to that url, and then return the hostname of that anchor element.

I noticed a problem in our Hacker News clone with links to other items on the hacker news site. It turns out that those stories don’t have a url property, so both the links and this function wouldn’t work. On the Hacker News site, there is no domain displayed in parentheses for links to stories that are also on the Hacker News site. So, in the first highlighted portion of the example below, I’ve adjusted the getDomainFromUrl function to return an empty string if the url matches the hacker news site. To make sure all of the links work, I’ve added a check to see if the JSON returned from the Hacker News API has a url property defined. If not, I set the url property equal to a URL matching the way Hacker news handles these links. See the second highlighted portion below:

// js/app/directives/Story.js
controller: function($scope, TopStoriesService) {
  var vm = this;

  function getDomainFromUrl(url) {
    var a = document.createElement('a');
    a.setAttribute('href', url);
    if(a.hostname === 'news.ycombinator.com') {
      return '';
    } else {
      return '(' + a.hostname + ')';
    }
  }

  TopStoriesService
    .getStory($scope.id)
    .then(function(res){
      vm.story = res.data;
      vm.title = vm.story.title;
      vm.url = vm.story.url;
      if(!vm.url) {
        vm.url = 'https://news.ycombinator.com/item?id=' + vm.story.id;
      }
      vm.domain = getDomainFromUrl(vm.url);
    });
}

Now, we can add the domain into our custom directive’s template and we should be able to see it in our list:

<!-- views/story.html -->
<div>
    <strong><a href="{{ vm.url }}">{{ vm.title }}</a></strong> {{ vm.domain }}
</div>

Once we’ve made this adjustment to the template, we can load the Hacker News Clone on our local server and here’s what we’ll see:

Hacker News Clone Top Stories Page with Domain in Parentheses

You can see looking at #25 above that there is no domain in parentheses after the story link. This link also points to the proper place on the Hacker News site now.

Adding the Score

Now we’ll add the second line of information to each story. First, we’ll want to add the score property to our controller object within the story directive:

//js/app/directives/Story.js
TopStoriesService
  .getStory($scope.id)
  .then(function(res){
    vm.story = res.data;
    vm.title = vm.story.title;
    vm.url = vm.story.url;
    if(!vm.url) {
      vm.url = 'https://news.ycombinator.com/item?id=' + vm.story.id;
    }
    vm.domain = getDomainFromUrl(vm.url);
    vm.score = vm.story.score;
  });

Now, let’s add another div to divide the story into two lines. We’ll insert the story’s score at the beginning of the second line. We’ll want to make sure that we display the score with the proper plurality. To do that, we’ll use Angular’s ngPluralize directive:

<!-- views/story.html -->
<div>
  <div>
    <strong><a href="{{vm.url}}">{{ vm.title }}</a></strong> {{ vm.domain }}
  </div>
  <div>
    <ng-pluralize count="vm.score"
                  when="{
                    '0': '0 points', 
                    '1': '1 points', 
                    'other': '{} points'
                  }">
    </ng-pluralize>
  </div>
</div>

Adding the Author

Okay, so let’s add in the author after the score:

// js/app/directives/Story.js
TopStoriesService
  .getStory($scope.id)
  .then(function(res){
    vm.story = res.data;
    vm.title = vm.story.title;
    vm.url = vm.story.url;
    if(!vm.url) {
      vm.url = 'https://news.ycombinator.com/item?id=' + vm.story.id;
    }
    vm.domain = getDomainFromUrl(vm.url);
    vm.score = vm.story.score;
    vm.author = vm.story.by;
  });

And let’s add the author to our directive’s template:

<!-- views/story.html -->
<div>
  <div>
    <strong><a href="{{ vm.url }}">{{ vm.title }}</a></strong> {{ vm.domain }}
  </div>
  <div>
    <ng-pluralize count="vm.score"
                  when="{
                    '0': '0 points', 
                    '1': '1 points', 
                    'other': '{} points'
                  }"> 
     </ng-pluralize>
     by {{ vm.author }}
  </div>
</div>

As a result, our Hacker News Clone now looks like this:

Hacker News Clone Adds Score and Author to List of Stories

Great. Now that we’ve got those changes finished. Let’s commit them to version control and move on to the next step:

git add . 
git commit -m "adds title, domain, score and author to Story directive"

Adding the Time (hours ago)

This one is a little bit tricky. The Hacker News API returns a UNIX Timecode for each post (the number of seconds from January 1st, 1970) while JavaScript’s Date.now() function will return the number of milliseconds since January 1st, 1970. So we need to do the following:

  • Get both times in terms of seconds
  • Subtract the story time from the current time
  • Convert the second result to hours (rounding down)

Okay, so let’s build a function getHoursAgo that will take in the time from the Hacker News API and give us how many hours ago the story was posted.

//js/app/directives/Story.js
controller: function($scope, TopStoriesService) {
  var vm = this;

  function getDomainFromUrl(url) {
    var a = document.createElement('a');
    a.setAttribute('href', url);
    if(a.hostname === 'news.ycombinator.com') {
      return '';
    } else {
      return '(' + a.hostname + ')';
    }
  }

  function getHoursAgo(seconds) {
    var secondDifference = (Date.now()/1000) - seconds;
    var hoursAgo = secondDifference/3600;
    return Math.floor(hoursAgo);
  }

  TopStoriesService
    .getStory($scope.id)
    .then(function(res){
      vm.story = res.data;
      vm.title = vm.story.title;
      vm.url = vm.story.url;
      if(!vm.url) {
        vm.url = 'https://news.ycombinator.com/item?id=' + vm.story.id;
      }
      vm.domain = getDomainFromUrl(vm.url);
      vm.score = vm.story.score;
      vm.author = vm.story.by;
      vm.time = getHoursAgo(vm.story.time);
    });
}

And now we’ll update the directive’s template:

<!-- views/story.html -->
<div>
  <strong><a href="{{vm.url}}">{{ vm.title }}</a></strong> {{ vm.domain }}
</div>
<div>
  <ng-pluralize count="vm.score"
                when="{
                  '0': '0 points', 
                  '1': '1 points', 
                  'other': '{} points'
                }"> 
   </ng-pluralize>
   by {{ vm.author }} {{ vm.time }} hours ago
</div>

All right, so this is what that looks like when we load our local server:

Hacker New Clone with Title, Domain, score, author and time

Okay, looks great, but check out number 7. It says 0 hours ago. That’s not what we want. All right, let’s make a change to our getHoursAgo method so that it will return minutes ago if the article was posted within the last hour:

//js/app/directives/Story.js
function getHoursAgo(seconds) {
  var secondDifference = (Date.now()/1000) - seconds;
  
  if (secondDifference < 3600) {
    var minutesAgo = secondDifference/60;
    return Math.floor(minutesAgo) + ' minutes ago';
  } else {
    var hoursAgo = secondDifference/3600;
    return Math.floor(hoursAgo) + ' hours ago';
  }
}

And update our template to take off the ‘hours ago’ we had there previously:

<!-- views/story.html -->
<div>
  <strong><a href="{{vm.url}}">{{ vm.title }}</a></strong> {{ vm.domain }}
</div>
<div>
  <ng-pluralize count="vm.score"
                when="{
                  '0': '0 points', 
                  '1': '1 points', 
                  'other': '{} points'
                }"> 
   </ng-pluralize>
   by {{ vm.author }} {{ vm.time }}
</div>

And now we can take a look at our Hacker News Clone and see what’s going on:

Hacker News Clone adds Correct Time to Top Stories View

Great, now we’ve got the time of the story looking the way we expect. Let’s add our changes and commit them to git:

git add . 
git commit -m "adds time ago to Story Directive"

Adding a Link to Hide

When you click the hide link on HackerNews, you’re required to sign in before you can use the link. That functionality is beyond the scope of this tutorial, as it would require that we build our own authentication system, but let’s implement the hide functionality as if we were logged in. First, we can add the link to our directive’s template and give it a class of hide:

<!-- views/story.html -->
<div>
  <strong><a href="{{vm.url}}">{{ vm.title }}</a></strong> {{ vm.domain }}
</div>
<div>
  <ng-pluralize count="vm.score"
                when="{
                  '0': '0 points', 
                  '1': '1 points', 
                  'other': '{} points'
                }"> 
   </ng-pluralize>
   by {{ vm.author }} {{ vm.time }} | <a href="" class="hide">Hide</a>
</div>

This will allow us to attach a click event to this link and remove the story from the list if we click on it. To do this, we’ll add some code to the link function inside our custom directive:

// js/app/directives/Story.js
link: function(scope, element, attrs) {
  var hideLink = element[0].querySelector('.hide');

  hideLink.addEventListener('click', function(event){
    element.parent().remove();
  });
}

Now if we open up our server we can click the Hide links and remove the stories from the page.

Hacker News Clone Hide Story Link

We need to do one more thing, though. Because Angular is still keeping track of that event listener that we added to the story directive, even though we’ve removed it from the DOM. What we need to do is to make sure that we remove all event listeners when the directive is destroyed. To do this, we add the following code to our link function:

// js/app/directives/Story.js
link: function(scope, elem, attrs, ctrl) {
  var hideLink = elem[0].querySelector('.hide');

  hideLink.addEventListener('click', function(event){
    elem.parent().remove();
  });

  scope.$on('$destroy', function(){
    elem.off();
  });
}

All right, now we’re properly cleaning up after ourselves. Let’s commit our changes and move on to displaying the comments.

git add . 
git commit -m "adds link to hide to story directive template"

Adding a Link to the Comments Displaying the Number of Comments

To do this, I originally added a function getNumComments and passed in the array of kids we get from the HackerNews API. If you’re watching this tutorial on YouTube as well, you’ll see me do this at the end of this video: Hacker News Clone in AngularJS – Post 2: Creating Custom Directives to Fetch Stories. Later, I discovered that this is actually unnecessary. Instead, we can use the ‘descendants’ property given to us by the Hacker News API. This property will give us the actual number of comments related to a story because it also counts nested comments (which my original solution did not).

So, to do what we need to do, we first define the vm.numComments property in our Story directive’s controller, and then we use the ngPluralize directive within the view to display our desired link text. We’ll want to display 'discuss' if there are no comments yet and the properly pluralized number if there are comments:

// js/app/directives/Story.js
...
controller: function($scope, TopStoriesService) {
  var vm = this;

  ...

  TopStoriesService
    .getStory($scope.id)
    .then(function(res){
      vm.story = res.data;
      vm.title = vm.story.title;
      vm.url = vm.story.url;
      if(!vm.url) {
        vn.url = 'https://news.ycombinator.com/item?id=' + vm.story.id;
      }
      vm.domain = getDomainFromUrl(vm.url);
      vm.score = vm.story.score;
      vm.author = vm.story.by;
      vm.time = getHoursAgo(vm.story.time);
      vm.id = vm.story.id;
      vm.numComments = vm.story.descendants;
    });
}

After we’ve done that, let’s update the directive’s template to show the number of comments:

<!-- views/story.html -->
<div>
  <div>
    <strong><a href="{{vm.url}}">{{ vm.title }}</a></strong> {{ vm.domain }}
  </div>
  <div>
    Score: {{ vm.score }} by {{ vm.author }} {{ vm.time }} | <a href="" class="hide">Hide</a> | <ng-pluralize count="vm.numComments" when="{ '0': 'discuss', '1': '1 comment', 'other': '{} comments'}"></ng-pluralize>
  </div>
</div>

Now, when we open the page in our local server, this is what we’ll see:

Hacker News Clone - Top Stories Page with Number of Comments

What’s Next

All right! That’s all for now. In the next post, we’ll implement pagination to show just 30 stories per page and we’ll add in next and previous links. We’ll also add in a new state that will allow dynamic routes with views for the comments on each post using ngSanitize.

Leave a Reply

Your email address will not be published. Required fields are marked *