Building a Hacker News Clone in AngularJS Part 4 – Angular Filters

tutorial

Hi Everyone! In this post, we’ll continue our journey through building a Hacker News Clone in AngularJS. The main focus of this post will be on building a new state for individual stories. To do that, we’re going to build a custom Angular component and refactor some of our earlier formatting code into Angular Filters. That way, we can reuse our earlier code in our new component. Our goal is to build a page that will display the comments related to a given story.

Be sure to check out the previous posts in the series if you haven’t already:
Part 1: Creating a Service to connect to the Hacker News API
Part 2: Creating a Custom Directive to display stories
Part 3: Adding Pagination

In Part 4 we’ll be covering the following tasks:

  1. Adding a new state for individual posts
  2. Adding content to the Post Show Page
  3. Creating a custom Angular Component to display individual comments
  4. Refactoring our Story Directive’s formatting functions into Angular Filters

Adding Dynamic URLs for show pages of individual stories with comments

So now we need to add a new state and view to our application where we can look at individual stories and their comments. First, let’s take a look at what we’re trying to build as it appears on Hacker News:

Hacker News Comments View

This is the page that comes up when you click on the link to comments on a story.

Okay, so let’s get started. To build this page into our application, we need to create a new state. Now, let’s open up js/app.js and add this state to our Angular app:

// js/app.js
angular
  .module('app', ['ui.router', 'angularUtils.directives.dirPagination'])
  .config(function($stateProvider, $urlRouterProvider) {
    $stateProvider
      .state('top', {
        url: '/top?page',
        templateUrl: 'views/top-stories.html',
        controller: 'TopStoriesController as vm'
      }) 
      .state('post', {
        url: '/post?id', 
        templateUrl: 'views/story-comments.html', 
        controller: 'StoryController as vm'
      })
    $urlRouterProvider.otherwise('/top');
  });

Also, we need to add files for the view template and the controller. First, let’s create views/story-comments.html and js/app/controllers/StoryController.js:

touch js/app/controllers/StoryController.js 
touch views/story-comments.html

Now, let’s open up views/story-comments.html and add an h2 tag:

<!-- views/story-comments.html -->
<h2>These are the comments</h2>

And, we need to add the StoryController into js/app/controllers/StoryController.js. This controller will need to have access to the TopStoriesService. The service will allow our controller to have access to our story data from the Hacker News API:

// js/app/controllers/StoryController.js
(function() {
'use strict';

  angular
    .module('app')
    .controller('StoryController', StoryController);

  StoryController.$inject = ['TopStoriesService'];
  function StoryController(TopStoriesService) {
    var vm = this;
    

    activate();

    ////////////////

    function activate() { 
    
    }
  }
})();

Finally, let’s add a link to this new Controller file in our index.html document:

<script src="js/app/controllers/StoryController.js"></script>

Okay, so now when we load up http://localhost:8080/#/post?id=1 in our browser, this is what we get:

AngularJS Hacker News Clone Story Comments View after Controller Hookup

This is the story-comments.html view displayed in the browser (notice no errors in the console)

Sweet, so the view is working. Let’s add and commit our changes and then we can fill in some content.

git add . 
git commit -m "adds post state to application with controller and view"

Adding Content to the Story Comments View

First, consulting the Hacker News screenshot above, we need to add in the individual story. To do that, we can use the story directive that we created in Part 2 of this series. All we need to do is pass in the id of the story we want to display. But, wait a minute. How do we access that story id in the view? Well, it comes from the url, so we need to get it from the $stateParams object. To use $stateParams, we need to inject that $stateParams object into our StoryController as a dependency. So, let’s add a line in our controller that we can use to pass the id from the $stateParams into our vm property on our Controller. That way, we can access the value of the id from within the view template:

// js/app/controllers/StoryController.js
...
StoryController.$inject = ['TopStoriesService', '$stateParams'];
function StoryController(TopStoriesService, $stateParams) {
  var vm = this;
  

  activate();

  vm.id = $stateParams.id;

  function activate() { }
}

Okay, so let’s test this out to make sure that it’s working. Remember, we’re using the controllerAs syntax defined in our app.js file. Because we defined controller: StoryController as vm we use {{ vm.id }} within the view to access the value of the id property. So, let’s add a line to our story-comments.html view to make sure that we have access to the id:

<!-- views/story-comments.html -->
<h2>These are the comments</h2>
ID: {{ vm.id }}

Now, when we open up this page in our browser, here’s what we see:

Hacker News Clone in AngularJS Story Comments View with ID

Notice that the ID is now displayed on the page

Great! So, let’s see what happens if we pass vm.id into our custom <story> directive element:

<!-- views/story-comments.html -->
<h2>These are the comments</h2>
<story id="vm.id"></story>

When we make this change and reload our browser, here’s what we end up with:

AngularJS Hacker News Clone Story Comments View Passing ID to the Story Directive

Here’s what happens when we pass the id into our custom story directive.

All right, so let’s add these changes and commit them so we can move on to the next part of the page.

git add .
git commit -m "adds story info to post show page"

Adding a Form to Submit Comments

In this section, we’ll add a form that can be used to add comments to the story. To do this, we’ll want to open up the story-comments.html view and add in the form:

<!-- views/story-comments.html --> 
<h2>These are the comments</h2>
<story id="vm.id"></story>

<form>
  <p>
    <textarea name="comment" id="comment" cols="50" rows="6"></textarea>
  </p>
  <p>
    <button>Add Comment</button>
  </p>
</form>

Now, when we reload the page in the browser, here’s what we get:

Hacker News Clone Story Comments view with Add Comment Form

Here’s the Story Comments View with the Add Comment form

So let’s add this to our repo, commit and move on:

git add . 
git commit -m "adds comment form to post show page"

Okay, now we can move on to adding the comments themselves.

Adding Comments to the Story Comments View

In this section, we’ll be adding comments to the page. At this point, we’re going to need a new component to display the information. Because, we need to get more information from the TopStoriesService. First, let’s open up our StoryController and use the TopStoriesService to fetch the particular story we’re interested in:

// js/app/controllers/StoryController.js 
(function() {
'use strict';

  angular
    .module('app')
    .controller('StoryController', StoryController);

  StoryController.$inject = ['TopStoriesService', '$stateParams'];
  function StoryController(TopStoriesService, $stateParams) {
    var vm = this;
    vm.id;
    vm.story;
    
    activate();

    function activate() { 
      vm.id = $stateParams.id;
      
      TopStoriesService
        .getStory(vm.id)
        .then(function(res) {
          vm.story = res.data;
        });  
    
    }
  }
})();

Now, let’s display vm.story within the view:

<!-- views/story-comments.html -->
<h2>These are the comments</h2>
<story id="vm.id"></story>

<form>
  <p>
    <textarea name="comment" id="comment" cols="50" rows="6"></textarea>
  </p>
  <p>
    <button>Add Comment</button>
  </p>
</form>

{{ vm.story }}

Now, when we open up http://localhost:8080/#/post?id=1 in our browser, this is what we’ll see:

Hacker News Clone Story Comments View with Story Data

Here’s the Story data displayed on the Page in JSON format.

Okay, so now we can see what we’re working with. Within the JSON object that’s returned, note the "kids" property that points to an array value. That array is full of ids that contain comments related to this story. In order to display these, I’m not quite sure at first what we’re going to do. For each of those Ids, we need to get the information about those comments by making calls to the Hacker News API.

After messing around with it for a bit, I think that the best way for us to move forward is to make a custom component to display each comment. This way, we can pass each of these ids into the comment element as an attribute, creating a call to the Hacker News API to return the appropriate data.

Before we move on, though, let’s add our changes and commit them to version control:

git add . 
git commit -m "gets story data from hacker news api and binds it to vm.story"

Creating a Custom Component to Display Comments

Let’s build our comment component! First, we can create a new directory under our app directory and call it components. Then we can create a file for our component:

mkdir js/app/components
touch js/app/components/StoryComment.js

Next, before we do anything else, let’s add a link to this new file at the bottom of our index.html file. That way, all of our code will be loaded when we run the application.

<!-- index.html -->
<script src="js/app/components/StoryComment.js"></script>

Now, we’ll want to open up our StoryComment.js file and start building our component.

Adding a Code Snippet for Angular Components

Before we add the component, here’s a little aside. To build this Angular App, I’ve been using the Angular 1 Snippets based on John Papa’s Style Guide for Angular. It just so happens that this guide doesn’t include a snippet for Angular 1 Components (introduced in v.1.5.0), so I’ve written my own for Visual Studio Code. To add it to your project, you’ll want to open up Code > Preferences > User Snippets, type in Javascript and select it. After you’ve selected JavaScript, you should see a file called javascript.json open in your editor. Next, open up the attached terminal and run the following command:

curl https://gist.githubusercontent.com/DakotaLMartinez/d5ce20712ac5bc792ee3d1694427e3fc/raw/bcc05362e242dffff5b88b580cd460d21c4a288e/javascript.json | pbcopy

The above command copies the gist containing the snippet from my GitHub account to your clipboard by piping (|) to results of the curl command to pbcopy. Now, click below the comment in the javascript.json file you have open and paste in the contents of your clipboard with Command (or ctrl) + V. Save the file and you’ll have access to the snippet for building Angular 1 Components.

Back to Building our Angular 1 Component

Okay, so now that we have our code snippet ready to go, let’s open up our StoryComment.js file and start building our component. First, we’ll want to build the configuration object that we’re going to pass to Angular’s .component() method. This object will specify the bindings we’ll be using, the component’s template, and the component’s controller:

// js/app/components/storyComment.js
(function() {
  'use strict';
  // Usage:
  //
  // Creates:
  //

  var StoryComment = {
    bindings: {
      id: '='
    },
    templateUrl: 'views/story-comment.html',
    controller: StoryCommentController,
    controllerAs: 'vm'
  };
  
  StoryCommentController.$inject = ['TopStoriesService']
  function StoryCommentController (TopStoriesService) {
    
  }

  angular
    .module('app')
    .component('storyComment', StoryComment);

})();

A Few Things to Note About AngularJS Components

  • Within the bindings property of the StoryComment configuration object, we are setting the id property to '='. This creates two way data binding on the id property between the controller and the view. This way, we can access the value of the id passed into the element in our view layer, allowing us to connect to the Hacker News API to retrieve the appropriate data. (Angular Components will automatically set bindToController to true)
  • When we set the templateUrl property, we’re specifying the path (from the root of the project) to the html template we’re using for our component.
  • When we set the controllerAs property to 'vm', we’re specifying that we’ll be using vm within the view to display properties. Angular Components will automatically use controllerAs syntax, but they default to $ctrl. (If you don’t set the value of controllerAs in this configuration object, you would access the id in the view using {{ $ctrl.id }} instead of {{ vm.id }})
  • We’ve injected the TopStoriesService into the StoryCommentController because we’ll be using it to fetch the comment data from the Hacker News API.
  • Finally, this one can be super annoying, so pay close attention. Just like directives, the name passed as the first parameter to the .component() function must be camelCased! If we named this component StoryComment on line 22, we’d get an error. In our case, a component named storyComment will be matched in the view to an element < story-comment >.

All right, so now that we’ve got the bones of our Component in place, let’s go about adding the code we’ll need to fetch the comment data from the Hacker News API. We’re going to use the getStory function defined in the TopStoriesService and we’ll pass it the id property that we created in our component bindings above. Finally, we’ll set a new property on our vm controller object and call it vm.comment so we can display some data in the view in the next step:

// js/app/components/storyComment.js 
...
StoryCommentController.$inject = ['TopStoriesService']
function StoryCommentController (TopStoriesService) {
  var vm = this;
  TopStoriesService 
    .getStory(vm.id)
    .then(function(res){
      vm.comment = res.data;
    });
}

Before we check this out in the browser, let’s add the html template that our component will use. To do that, we’ll create a file called story-comment.html within our views directory:

touch views/story-comment.html

Now, let’s open up that file in our editor and add in a single line, to make sure it’s working:

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

Okay, so now that we have all the pieces of our component defined, let’s create one and see what happens. To do that, let’s open up the story-comments.html view that corresponds to the our post state that we created in the first step. This is the view template that is displayed when we visit http://localhost:8080/#/post?id=1. Within this template, let’s create a <story-comment> element and pass in the id of one of the comments on the 1st story:

<!-- views/story-comments.html -->
<h2>These are the comments</h2>
<story id="vm.id"></story>

<form>
  <p>
    <textarea name="comment" id="comment" cols="50" rows="6"></textarea>
  </p>
  <p>
    <button>Add Comment</button>
  </p>
</form>

{{ vm.story }}
<h3>Comments</h3>
<story-comment id="15"></story-comment>

Now, let’s load up http://localhost:8080/#/post?id=1 in the browser and see what we’ve got:

Hacker News Clone in AngularJS Story Comments View showing Data from one Story Comment

This screenshot shows the value of {{ vm.comment }} for a single story comment.

Great, so we’ve got the data for one story comment. With this data, let’s try to display the comment. Notice that this object has a 'text' property that contains some strange characters. The Hacker News API actually delivers html strings via this 'text' property, so we’ll need to sanitize that text before Angular will let us display it properly. To do that, we’ll take advantage of Angular’s ngSanitize module. In order to get this module working, there are a few things we need to do:

  • Install angular-sanitize locally
  • Link to the file from within index.html
  • Add 'ngSantitize' to our array of dependencies in app.js

But, as you may recall from the first post, we’ve already created a local copy of angular-sanitize.js and linked to it from within index.html. So, all we need to do now is add 'ngSanitize' to our array of dependencies specified in js/app.js:

// js/app.js
angular
  .module('app', ['ui.router', 'angularUtils.directives.dirPagination', 'ngSanitize'])
  .config(function($stateProvider) {
    ...

For a bit more information on how ngSanitize works, check out the AngularJS official documentation on $sanitize. In our case, what it boils down to is that injecting ngSanitize as a dependency into our app allows us to use ng-bind-html within our html. To illustrate, let’s go through an example. In our example, we’ll be replacing the {{ vm.comment }} in our story-comment.html view:

<!-- views/story-comment.html -->
<div ng-bind-html="vm.comment.text"></div>

And now, let’s take a look at http://localhost:8080/#/post?id=1 in the browser and see what we’ve got:

Hacker News Clone Angular ngSanitize Single Comment with Sanitized Text

Here we can see that the quotes have been rendered safely using ng-bind-html

Awesome! This is what we want. Now, let’s think about what other information from Hacker News we’ll want to display for each comment. Most importantly, we’ll want to have the author and how long ago the comment was posted. To pass this information from the API to the view, let’s create a few properties on our component’s Controller object that we can pass to the view:

// js/app/components/storyComment.js 
... 
  StoryCommentController.$inject = ['TopStoriesService']
  function StoryCommentController (TopStoriesService) {
    var vm = this;
    vm.commentText;
    vm.commentAuthor;
    vm.commentTime;
    
    TopStoriesService
      .getStory(vm.id)
      .then(function(res){
        vm.commentText = res.data.text;
        vm.commentAuthor = res.data.by; 
        vm.commentTime = res.data.time;
      });
  }
...

Now, we can add in references to those properties within out story-comment.html view:

<!-- views/story-comment.html -->
<div>{{ vm.commentAuthor }} {{ vm.commentTime }}</div>
<div ng-bind-html="vm.commentText"></div>

Now, let’s reload our page in the browser and see what we’ve got:

Hacker News Clone Story Comment View with Author and Time Added in

Shows a single comment with the author and time. Notice the unformatted time.

Okay, so now that we’ve create our Story Comment component. Let’s add our changes and commit them to the repository

git add . 
git commit -m "adds story-comment component to our post state"

Refactoring our Story Directive Formatting Functions into Filters

See that big number by the author’s name? You may recall earlier when we wrote a function getHoursAgo to parse this number into something human readable. The problem is, we put that function in the controller of our custom Story directive in js/app/directives/Story.js. This is a great example of the importance of separation of concerns. At this point, we don’t want to have to reimplement this formatting function, we want to reuse the code we already wrote! Okay, so how do we do that?

Well, it doesn’t make sense for this formatting code to be in the Story directive, because we need to use it within our storyComment component. Where could we put this code so that both the Story directive and the storyComment component can access it? If you’re thinking TopStoriesService, so was I! In fact, the first thing I did was move these functions into the TopStoriesService. But why there? Well, both the Story directive and the storyComment component depend upon the TopStoriesService in order to get information from the Hacker News API. So, why wouldn’t we move the code to the TopStoriesService? Check out Angular’s Conceptual Overview for Services and you’ll see that Services are best suited for View-Independent business logic. Thus, not the place to put display formatting.

Actually, Angular has a module designed specifically for creating and storing this kind of code. In Angular, Filters are designed specifically for formatting data to be viewed by users. So, let’s go about refactoring those functions by creating two new filters:

mkdir js/app/filters
touch js/app/filters/getHoursAgo.js
touch js/app/filters/getDomainFromUrl.js

Before we do anything else, let’s add these new filters to our index.html file:

<!-- index.html -->
<script src="js/app/filters/getHoursAgo.js"></script>
<script src="js/app/filters/getDomainFromUrl.js"></script>

I found again that there was no snippet available for Angular Filters, so I made my own. If you’d like to add it to your visual studio code project, open up Code > Preferences > User Snippets, type javascript and select it. The javascript.json file should open again in your editor. Next, open up your attached terminal and run the following command:

curl https://gist.githubusercontent.com/DakotaLMartinez/1840f35d0c0cc6dc9f7d2465fb17ceea/raw/80a6044634238b416cea544ea6c486c8d78bca44/javascript.json | pbcopy

Then paste the contents of your clipboard into the javascript.json file, make sure you have commas between each snippet object, and save the file. You should now have access to my Angular Filter snippet.

Adding our Angular Filters: getHoursAgo

Now that we’ve got our snippets installed, let’s go about adding our filters. We’ll start with the getHoursAgo filter. First, we’ll need to name our filter. Next we’ll need to define a function that returns a function that will operate on the filter’s input. I’ve included a conditional check in my snippet so we can throw an error if the filter receives the wrong kind of input. In this case we want to check that the filter is receiving a number, so I’m going by an article I found on how to check if a value is a number :

// js/app/filters/getHoursAgo.js
angular
  .module('app')
  .filter('getHoursAgo', getHoursAgo);
  
  function getHoursAgo() {
    return function(input) {
      if (!isNaN(parseFloat(input)) && isFinite(input)) {
        var secondDifference = (Date.now()/1000) - input;

        if (secondDifference < 3600) {
          var minutesAgo = secondDifference/60;
          return Math.floor(minutesAgo) + ' minutes ago';
        } else {
          var hoursAgo = secondDifference/3600;
          return Math.floor(hoursAgo) + ' hours ago';
        }
      } else {
        throw new Error('getHoursAgoFilter must be given a number as an input.');
      }
    };
  };

The snippet makes it pretty easy for us to fill in the parts of this that we need to change regularly. Namely, the filterName, the expected type of input and the error message. You just need to type the name once and the error message will contain the name of the Filter.

Now, if we want to use this getHoursAgo Filter, we could do this in our html: {{ vm.commentTime | getHoursAgo }}. If we do this, the filter will work. However, the error is also thrown. If anyone knows why this is the case, please let me know in the comments.

In our case, we actually want to use the filter within the controller. To do that, we can add getHoursAgoFilter(res.data.time) to the Component’s controller and that works fine. We also need to make sure that we include the getHoursAgoFilter in our StoryCommentController‘s array of dependencies:

// js/app/components/StoryComment.js
  ... 
  StoryCommentController.$inject = ['TopStoriesService', 'getHoursAgoFilter']
  function StoryCommentController (TopStoriesService, getHoursAgoFilter) {
    var vm = this;
    vm.comment;
    vm.commentText;
    vm.commentAuthor;
    vm.commentTime;

    TopStoriesService
      .getStory(vm.id)
      .then(function(res){
        vm.commentText = res.data.text;
        vm.commentAuthor = res.data.by;
        vm.commentTime = getHoursAgoFilter(res.data.time);
      });
  }

After we add the filter to our controller, we can reload the page in our browser and we’ll see that the filter is working:

Hacker News Clone getHoursAgoFilter working in Controller

When we load http://localhost:8080/#/post?id=1 in the browser we see the getHoursAgo filter is working

If we invoke this filter within a controller, it works without throwing an error. Now that we’re using this filter within our Story Comment Component, let’s also use it from within our Story Directive. To do that, we’ll need to open up that file and inject the filter as a dependency and call it on the time:

// js/app/directives/Story.js 
  ...
  story.$inject = ['TopStoriesService', 'getHoursAgoFilter'];
  function story(TopStoriesService, getHoursAgoFilter) {
    // Usage:
    //
    // Creates:
    //
    var directive = {
        bindToController: true,
        controller: StoryDirectiveController,
        controllerAs: 'vm',
        link: link,
        templateUrl: 'views/story.html',
        restrict: 'E',
        scope: {
          id: '=id'
        }
    };
    return directive;
    
    function link(scope, element, attrs) {
      var hideLink = element[0].querySelector('.hide');

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

      scope.$on('$destroy', function(){
        element.off();
      });
    }
  }
  /* @ngInject */
  function StoryDirectiveController (TopStoriesService, getHoursAgoFilter) {
    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(vm.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 = getHoursAgoFilter(vm.story.time);
        vm.numComments = vm.story.descendants;
      })
  }

Now, we can reload the page in the browser to double check that everything is still working. After we see that it is, we can delete the getHoursAgo function definition from inside the Story directive. After we’ve deleted that method, we can commit our changes and move on to refactoring the other filters:

git add js/app/components/StoryComment.js
git add js/app/directives/Story.js 
git add index.html 
git add js/app/filters/getHoursAgo.js
git commit -m "adds getHoursAgoFilter to project and uses within directive and component"

Adding our Angular Filters: getDomainFromUrl

In this section, we’ll be refactoring the getDomainFromUrl function out of our Story directive and into an Angular Filter. For this filter, we’ll want to make sure that it receives a url string as an input. I found a blog post by Matthew O’Riordan containing a URL regular expression for links with or without a protocol. I’ve used that regular expression within the condition block here. To create the filter, let’s open up the file we created for it and use the Angular Filter snippet:

// js/app/filters/getDomainFromUrl.js
angular
  .module('app')
  .filter('getDomainFromUrl', getDomainFromUrl);
  
  function getDomainFromUrl() {
    return function(input) {
      if (input.match(/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/
)) {
        var a = document.createElement('a');
        a.setAttribute('href', input);
        if (a.hostname === 'news.ycombinator.com') {
          return '';
        } else {
          return '(' + a.hostname + ')';
        }
      } else {
        throw new Error('getDomainFromUrlFilter must be given a URL as an input.');
      }
    };
  };

Now, we want to refactor our Story directive to use the filter instead of the function defined on the controller’s scope. Remember that we have to inject our new filter as a dependency to the directive:

// js/app/directives/Story.js 
  ... 
  story.$inject = ['TopStoriesService', 'getHoursAgoFilter', 'getDomainFromUrlFilter'];
  function story(TopStoriesService, getHoursAgoFilter, getDomainFromUrlFilter) {
    // Usage:
    //
    // Creates:
    //
    var directive = {
        bindToController: true,
        controller: StoryDirectiveController,
        controllerAs: 'vm',
        link: link,
        templateUrl: 'views/story.html',
        restrict: 'E',
        scope: {
          id: '=id'
        }
    };
    return directive;
    
    function link(scope, element, attrs) {
      var hideLink = element[0].querySelector('.hide');

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

      scope.$on('$destroy', function(){
        element.off();
      });
    }
  }
  /* @ngInject */
  function StoryDirectiveController (TopStoriesService, getHoursAgoFilter, getDomainFromUrlFilter) {
    var vm = this;

    TopStoriesService
      .getStory(vm.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 = getDomainFromUrlFilter(vm.url);
        vm.score = vm.story.score;
        vm.author = vm.story.by;
        vm.time = getHoursAgoFilter(vm.story.time);
        vm.numComments = vm.story.descendants
      });
  }

When we reload the top stories view in the browser, we should still see the domain in parentheses. So, let’s add these changes and commit them to our repository:

git add js/app/directives/Story.js
git add js/app/filters/getDomainFromurl.js
git commit -m "adds getDomainFromUrl filter and uses it in Story directive"

Adding Extra Time Options to our getHoursAgo Filter

Our getHoursAgo filter works perfectly, but there’s still room for improvement. When we take a look at the very first post on Hacker News, we see a really large number of hours ago!

Hacker News Clone in AngularJS really large number of hours ago on show page

Notice the really large number of hours ago referring to the post and the comments

I think it would be better to be able to display a more readable result here. For instance, if the post was 120 hours ago, I think it should say 5 days ago! So, to add this to our getHoursAgoFilter, we need to add a few more conditions to our if/else statement. Also, we also want to be able to handle 1 day ago (we don’t want to say 1 days ago). Unfortunately, there isn’t a good way to use Angular’s ngPluralize directive within a custom filter. Instead, because all of our time units are pluralized by simply adding an ‘s’, I’ve written a custom function to pluralize the time:

function pluralizeTime(time, timeUnit) {
  if (time === 1) {
    return time + ' ' + timeUnit + ' ago';
  } else {
    return time + ' ' + timeUnit + 's ago';
  }
}

Later, we’ll be able to use this function within our new conditions to display the proper text from our filter. To finish adding these options, we’re going to need to break out the calculator. Because we’re dealing with a secondDifference variable that refers to the amount of seconds passed since the post was created, we need to create windows of seconds that correspond to the values of the timeUnit that we want to display.

For this case, let’s handle minutes, hours, days, weeks, months and years. Let’s create a window of time, in seconds, for each of these time units:

  • Hours for posts that were created between 1 hour (3,600 seconds) and 24 hours (86,400 seconds) ago.
  • Days for posts that were created between 1 day (86,400 seconds) and 7 days (604,800 seconds) ago.
  • Weeks for posts that were created between 7 days (604,800 seconds) and 31 days (2,678,400 seconds) ago.
  • Months for posts that were created between 31 days (2,678,400 seconds) and 365 days (31,536,000 seconds) ago.
  • Years for posts that were created more than 365 days (31,536,000 seconds) ago.

For each of these windows, we’ll need to divide the secondDifference by the number of seconds in the corresponding timeUnit. For example, within our days window, we’ll set the value of daysAgo equal to the value of secondDifference divided by 86400 (the number of seconds in a day). Here’s the code we’ll use to add these new options and display them with the proper plurality using our pluralizeTime function:

// js/app/filters/getHoursAgo.js
angular
  .module('app')
  .filter('getHoursAgo', getHoursAgo);
  
  function getHoursAgo() {
    return function(input) {
      if (!isNaN(parseFloat(input)) && isFinite(input)) {
        var secondDifference = (Date.now()/1000) - input;

        if (secondDifference < 3600) {
          var minutesAgo = secondDifference/60;
          return pluralizeTime(Math.floor(minutesAgo), 'minute');
        } else if (3600 <= secondDifference && secondDifference < 86400) {
          var hoursAgo = secondDifference/3600;
          return pluralizeTime(Math.floor(hoursAgo), 'hour');
        } else if (86400 <= secondDifference && secondDifference < 604800) {
          var daysAgo = secondDifference/86400;
          return pluralizeTime(Math.floor(daysAgo), 'day');
        } else if (604800 <= secondDifference && secondDifference < 2678400) {
          var weeksAgo = secondDifference/604800;
          return pluralizeTime(Math.floor(weeksAgo), 'week');
        } else if (2678400 <= secondDifference && secondDifference < 31536000) {
          var monthsAgo = secondDifference/2678400;
          return pluralizeTime(Math.floor(monthsAgo), 'month');
        } else {
          var yearsAgo = secondDifference/31536000;
          return pluralizeTime(Math.floor(yearsAgo), 'year');
        }
      } else {
        throw new Error('getHoursAgoFilter must be given a number as an input');
      }
    };
    function pluralizeTime(time, timeUnit) {
      if(time === 1) {
        return time + ' ' + timeUnit + ' ago';
      } else {
        return time + ' ' + timeUnit + 's ago';
      }
    }
  };

After we make these changes and save them, we can reload the first post at http://localhost:8080/#/post?id=1 and take a look at the result:

Hacker News Clone in AngularJS, filter adjusted to account for additional time units

The First post is now marked 9 years ago instead of 86000+ hours ago.

Now, we see that the first post on Hacker News was created 9 years ago. Much easier to read! If we wanted to rename this filter to reflect the updated options. We could rename getHoursAgo here to getTimeAgo and then do a global find and replace on the project changing getHoursAgoFilter to getTimeAgoFilter and everything would work the same way.

Final Thoughts

In this post, we’ve added a custom component to display comments. We’ve also refactored some earlier code from our Story directive into filters for reuse in our new component. In the next post, we’ll be going over how to display all of the comments within the post view, including how to display nested comments using our custom story-comment component.

Leave a Reply

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