If you have ever built an Angular 2 application before, you’ll know that setting up and bootstrapping an application can take a significant amount of time. Thankfully, the Angular team have rolled out Angular CLI, a command line interface that makes creating and scaffolding an application significantly easier.
In this post, we’ll build an entire Hacker News client using Angular CLI, RxJS Observables and Webpack as our module loader.
We’ll go through building the entire application step by step. Throughout this post, I’ll try my best to explain my thought process as well as some of the mistakes I’ve made and what I did to fix them.
Here’s a rundown of what we’ll be doing.
This visual tutorial should make you feel a little more comfortable building an Angular 2 application from small modular parts as well as creating an app from scratch all the way to completion. We’ll also briefly go over some important topics and understand how they apply to an actual application, which includes:
NgModule
decoratorOnce you have the required Node and NPM versions, you can install the CLI through your terminal.
npm install -g @angular/cli
You can then create and start your application.
ng new angular2-hn
cd angular2-hn
ng serve
If you now open https://localhost:4200/
, you’ll see the application running.
Pretty cool huh? Angular CLI used to use SystemJS as the module bundler and loader. Using SystemJS had a few quirks including long loading times and a lengthy process just to add third party libraries. So to make things simpler and faster, the Angular CLI team have moved from SystemJS to Webpack!
Bootstrapping an application with CLI uses Angular’s latest release version, but let’s go over one of the biggest changes that happened with the release of RC5, the @NgModule
decorator. We can see it being used in the app.module.ts
file.
// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
So what exactly is happening here? The @NgModule
decorator specifies all declarations (components, directives and pipes), library imports (such as FormsModule
and HttpModule
) and providers (a single-instance service for example) that we’ll be using in our application.
You can probably already see how much more organized it is to not need to specify all our module-level components, directives, pipes and so forth in each of our components.
Let’s set up Sass as our CSS preprocessor. The CLI makes it simple for a project that’s already been started.
ng set defaults.styleExt scss
Note: If you’re seeing a weird error here, you can just set the default style extension to scss
in angular-cli.json
instead and restart the server. Take a look at this issue.
Now that we have everything set up, we can create our first few components. To start things off, we’ll create a HeaderComponent
.
ng generate component Header
You’ll notice that a header
folder is immediately created and scaffolded with the following files created.
header.component.scss
header.component.html
header.component.ts
header.component.spec.ts
I’m only joking, unit testing is always important for apps that go to production. We won’t be doing them for this tutorial however so feel free to delete/comment out the spec
files.
Take a look at app.module.ts
once again and you’ll notice that our component is now declared there as well.
// app.module.ts
// ...
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
@NgModule({
declarations: [
AppComponent,
HeaderComponent
],
//...
If you take a look at header.component.ts
, you can see that its component selector is app-header
. Let’s add this in our root component, app.component.ts
.
<!-- app.component.html -->
<app-header></app-header>
Running the application will show you that the header component loads successfully.
Sweet, now let’s add some markup and styling.
<!-- app.component.html -->
<div id="wrapper">
<app-header></app-header>
</div>
The styling in app.component.scss
can be found here. Now let’s work on the header.
<!-- header.component.html -->
<header id="header">
<a class="home-link" href="/">
<img class="logo" src="https://i.imgur.com/J303pQ4.png">
</a>
<div class="header-text">
<div class="left">
<h1 class="name">
<a href="/">Angular 2 HN</a>
</h1>
<span class="header-nav">
<a href="">new</a>
<span class="divider">
|
</span>
<a href="">show</a>
<span class="divider">
|
</span>
<a href="">ask</a>
<span class="divider">
|
</span>
<a href="">jobs</a>
</span>
</div>
<div class="info">
Built with <a href="https://cli.angular.io/" target="_blank">Angular CLI</a>
</div>
</div>
</header>
And similarly, you can find the styling for this component here. Running the application gives us the following result.
Since we want this application to be as responsive as possible, it’s important to check how it looks with different screen sizes regularly. Let’s adjust our viewport to see how it would look on a mobile device.
As you can see, there seems to be an offset from the edge of the page. This is because the body
element has a bult-in offset (through margin
) that shows in almost all modern browsers.
But if you take a look at app.component.scss
, we explicity set margin: 0
for screen sizes less then 768px.
$mobile-only: "only screen and (max-width : 768px)";
body {
margin-bottom: 0;
@media #{$mobile-only} {
margin: 0;
}
}
So why isn’t this rendering the way it should? This is because of the way Angular encapsulates CSS styles onto a component. I won’t be going into too much detail here, but there are three different ways Angular does this.
None
: Angular doesn’t do anything. No encapsulation and no Shadow DOM, this is just like adding styles regularly. Adding a style will apply to the entire document.Emulated
: Angular emulates Shadow DOM behaviour. This is the default.Native
: Angular uses the browser’s native Shadow DOM completely (make sure you’re using a browser that supports this.In our root component, we’re trying to add styles to the body
element which really doesn’t make sense if you think about it. Our root component is within body
, not the other way around, hence why it’s styles will not be affected. We can work around this by telling Angular to not do any view encapsulation in this component whatsoever.
// app.component.ts
import { Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'app-root',
encapsulation: ViewEncapsulation.None,
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
}
Take a look at our application once more and you’ll notice that the styles have now been applied to body
. This is because all of the styles in this component now affect the entire document.
But wait a minute, was all of this really necessary? I see a styles.css
file in our src
folder. Isn’t this for global styles? Can’t we just add a class here to style body
?
Yes you can, but hey at least we learned something here.
Let’s create two more components, Stories
and Footer
. Stories represent posts in Hacker News, and we’ll start out with a skeleton just to get an ordered list in place.
ng g component Stories
// stories.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-stories',
templateUrl: './stories.component.html',
styleUrls: ['./stories.component.scss']
})
export class StoriesComponent implements OnInit {
items: number[];
constructor() {
this.items = Array(30);
}
ngOnInit() {
}
}
<!-- stories.component.html -->
<div class="main-content">
<ol>
<li *ngFor="let item of items; let i = index" class="post">
Story #{{i}}
</li>
</ol>
<div class="nav">
<a href="" class="prev">
‹ Prev
</a>
<a href="" class="more">
More ›
</a>
</div>
</div>
Click here to take a look at the styles for Stories
. Our footer is straightfoward (and it’s styling can be found here).
ng g component Footer
<!-- footer.component.html -->
<div id="footer">
<p>Show this project some ❤ on
<a href="https://github.com/housseindjirdeh/angular2-hn" target="_blank">
GitHub
</a>
</p>
</div>
We’ll also need to update our root component to show these components.
<!-- app.component.html -->
<div id="wrapper">
<app-header></app-header>
<app-stories></app-stories>
<app-footer></app-footer>
</div>
Let’s see what our page is looking like.
Since each post, or item, will have its own attributes, it makes sense to create a component for this as well.
ng g component Item
Once we start getting real data, we’ll need to pass down the item identifier from the story component to it’s child item component. In the meantime, let’s just pass down the list index as itemID
.
<!-- stories.component.html -->
<div class="main-content">
<ol>
<li *ngFor="let item of items; let i = index" class="post">
<item class="item-block" itemID="{{ i + 1 }}"></item>
</li>
</ol>
<div class="nav">
<a href="" class="prev">
‹ Prev
</a>
<a href="" class="more">
More ›
</a>
</div>
</div>
// item.component.ts
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'item',
templateUrl: './item.component.html',
styleUrls: ['./item.component.scss']
})
export class ItemComponent implements OnInit {
@Input() itemID: number;
constructor() { }
ngOnInit() {
}
}
<!-- item.component.html -->
<p>Story #{{itemID}}<p>
Refreshing the application will give you the same result, showing that the index parameter is successfully being passed down using the @Input
decoration.
We have a basic skeleton of the home page done and that’s a good start. Here’s the link for the source code for this step.
Before we start fetching real data, let’s briefly go over the concept of RxJS and observables.
Angular’s HTTP client allows you to communicate with a server, and you need it to fetch data from anywhere. To fetch data from a server, the first thing you would most likely do is pass the resource URL through an http.get
call. But what gets returned exactly?
In Angular 2, we use the RxJS library to return an Observable
of data, or an asynchronous stream of data. You may already be familiar with the concept of Promises and how you can use them to retrieve data asynchronously. Observables obtain data just like promises do, but they allow us to subscribe to the stream of data and respond to specific data changes that it emits.
Source: The introduction to Reactive Programming you’ve been missing
The diagram above depicts the events that occur when a user clicks on a button. Notice how this stream emits values (which represent the click events), an error as well as a completed event.
The entire concept of using Observables in your application is known as Reactive Programming.
Okay, now it’s time to start retrieving some real data. To do this, we’re going to be creating an Observable Data Service and injecting it into our components.
ng g service hackernews-api
This creates and sets up a service file for us. Now before we dive in, let’s try to understand how the official Hacker News API works. If you take a look at the documentation, you’ll notice that everything (polls, comments, stories, jobs) is just an item distinguishable though an id
parameter. This means any item’s information can be obtained from their specific URL.
// https://hacker-news.firebaseio.com/v0/item/2.json?print=pretty
{
"by" : "phyllis",
"descendants" : 0,
"id" : 2,
"kids" : [ 454411 ],
"score" : 16,
"time" : 1160418628,
"title" : "A Student's Guide to Startups",
"type" : "story",
"url" : "https://www.paulgraham.com/mit.html"
}
Now if we want to obtain information like front page ranking, we’ll need to use another endpoint specific to the type of stories. For example, top stories can be retrieved like this.
// https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty
[ 12426766, 12426315, 12424656, 12425725, 12426064, 12427341, 12425692, 12425776, 12425324, 12425750, 12425135, 12427073, 12425632, 12423733, 12425720, 12427135, 12425683, 12423794, 12424987, 12423809, 12424738, 12425119, 12426759, 12425711, 12422891, 12424731, 12423742, 12424131, 12424184, 12422833, 12424421, 12426729, 12423373, 12421687, 12427437 ...]
So to show the top stories on the front page, we’ll need to first subscribe to this endpoint and then subsequently subscribe to each item. Let’s dive in.
Since we would want a single instance of the service throughout the entire app, let’s include it in the provider
metadata of NgModule
.
// app.module.ts
//...
import { HackerNewsAPIService } from './hackernews-api.service';
@NgModule({
declarations: [
...
],
imports: [
...
],
providers: [HackerNewsAPIService],
bootstrap: [AppComponent]
})
export class AppModule { }
Now let’s add the request method to our service.
// hackernews-api.service.ts
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
@Injectable()
export class HackerNewsAPIService {
baseUrl: string;
constructor(private http: Http) {
this.baseUrl = 'https://hacker-news.firebaseio.com/v0';
}
fetchStories(): Observable<any> {
return this.http.get(`${this.baseUrl}/topstories.json`)
.map(response => response.json());
}
}
As we mentioned previously, the http.get
call returns an Observable of data. If you look at fetchStories
, we take in the Observable and then map
it to a JSON format. Let’s see how we handle this Observable in our component.
// stories.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { HackerNewsAPIService } from '../hackernews-api.service';
@Component({
selector: 'app-stories',
templateUrl: './stories.component.html',
styleUrls: ['./stories.component.scss']
})
export class StoriesComponent implements OnInit {
items;
constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}
ngOnInit() {
this._hackerNewsAPIService.fetchStories()
.subscribe(
items => this.items = items,
error => console.log('Error fetching stories'));
}
}
In the ngOnInit
hook, which fires when the component is initialized, we subscribe
to the data stream and set the items
attribute to what gets returned. In our view, the only thing we’re going to add is a SlicePipe
to show 30 list items instead of all 500 which gets returned.
<!-- stories.component.html -->
<div class="main-content">
<ol>
<li *ngFor="let item of items | slice:0:30" class="post">
<item class="item-block" itemID="{{ item }}"></item>
</li>
</ol>
<!-- ... -->
</div>
Now if you run the application, you’ll see a list of item ids populated.
Since we have the item id’s being passed down successfully to each of the item
components, let’s set up another Observable subscription for each item to show their details. To do this, let’s start by adding a new method to our service.
// hackernews-api.service.ts
//...
fetchItem(id: number): Observable<any> {
return this.http.get(`${this.baseUrl}/item/${id}.json`)
.map(response => response.json());
}
And now we need to modify our item
component a bit.
// item.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { HackerNewsAPIService } from '../hackernews-api.service';
@Component({
selector: 'item',
templateUrl: './item.component.html',
styleUrls: ['./item.component.scss']
})
export class ItemComponent implements OnInit {
@Input() itemID: number;
item;
constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}
ngOnInit() {
this._hackerNewsAPIService.fetchItem(this.itemID).subscribe(data => {
this.item = data;
}, error => console.log('Could not load item' + this.itemID));
}
}
<!-- item.component.html -->
<div *ngIf="!item" class="loading-section">
<!-- You can add a loading indicator here if you want to :) </i> -->
</div>
<div *ngIf="item">
<div class="item-laptop">
<p>
<a class="title" href="{{item.url}}">
{{item.title}}
</a>
<span class="domain">{{item.url | domain}}</span>
</p>
<div class="subtext-laptop">
{{item.score}} points by
<a href="">{{item.by}}</a>
{{ (item.time | amFromUnix) | amTimeAgo }}
<a href="">
<span *ngIf="item.descendants !== 0">
{{item.descendants}}
<span *ngIf="item.descendants === 1">comment</span>
<span *ngIf="item.descendants > 1">comments</span>
</span>
<span *ngIf="item.descendants === 0">discuss</span>
</a>
</div>
</div>
<div class="item-mobile">
<!-- Markup that shows only on mobile (to give the app a
responsive mobile feel). Same attributes as above
nothing really new here (but refer to the source
file if you're interested) -->
</div>
</div>
Nice and straightforward. For each item, we’re subscribing to their respective stream. In the markup, we can see that when the response hasn’t been received yet, we have a loading section where we can show a loading indicator of some sort. Once the item loads from the Observable, it’s details will show. Click here to see all the files for this component including styling.
Note: You may be wondering where the amFromUnix
and amTimeAgo
pipes came from. The time parameter for each item is in Unix format. To convert this into something we can understand, I use moment.js pipes by importing the angular2-moment library.
Note 2: For each item with a link, the entire URL is passed through it’s url
attribute. To only show the link domain, I created a pipe called domain
. Take a look here for the code.
Now if you run the application, you’ll see the first page of Hacker News! Click here for the full source code until this step.
Let’s take a look at the requests transferred when we load the front page of our application.
Woah, 31 requests and 20.8KB transferred in 546ms. This takes almost five times as long loading the front page of Hacker News and more then twice as much data to just load the posts. This is pretty darn slow, and maybe it’s kind of tolerable when you’re loading the list of posts on the front page but this is a serious problem if we try loading a large number of comments for a single post.
I built the entire application with each component using this method, including each post and their comments. You can take a look at what happens when I try to load a post with almost 2000 comments here. But to save you time from watching that entire gif, it takes 741 requests, 1.5MB and 90s to load roughly 700 of the comments (I wasn’t patient enough to wait for every comment to load).
Just for reference’s sake, I still have this version of the app up on my GitHub pages. At your own caution, you can take a look at how long it takes to load this many comments here.
Okay, now we can see why having multiple network connections to fetch a parent item and it’s content isn’t the nicest experience. After a little bit of searching, I found this awesome unofficial API which returns an item and it’s details through a single request.
For example, the response for the list of top stories looks like this.
// https://node-hnapi.herokuapp.com/news?page=1
[
{
"id": 12469856,
"title": "Owl Lisp – A purely functional Scheme that compiles to C",
"points": 57,
"user": "rcarmo",
"time": 1473524669,
"time_ago": "2 hours ago",
"comments_count": 9,
"type": "link",
"url": "https://github.com/aoh/owl-lisp",
"domain": "github.com"
},
{
"id": 12469823,
"title": "How to Write Articles and Essays Quickly and Expertly",
"points": 52,
"user": "bemmu",
"time": 1473524142,
"time_ago": "2 hours ago",
"comments_count": 6,
"type": "link",
"url": "https://www.downes.ca/post/38526",
"domain": "downes.ca"
},
...
]
Notice that there is a domain
as well as a time_ago
attribute which is pretty cool. This means we can ditch the domain.pipe.ts
file I created earlier as well as uninstall the angular2-moment
library. Let’s take a look at what we need to change in our data service.
// hackernews-api.service.ts
export class HackerNewsAPIService {
baseUrl: string;
constructor(private http: Http) {
this.baseUrl = 'https://node-hnapi.herokuapp.com';
}
fetchStories(storyType: string, page: number): Observable<any> {
return this.http.get(`${this.baseUrl}/${storyType}?page=${page}`)
.map(response => response.json());
}
}
Since the API doesn’t load all 500 top stories, we need to add page number as an argument. Notice how we’re also passing storyType
as well. This will allow us to show different types of stories depending on where the user navigates to.
Let’s take a look at how we can change the stories component. We can start with just passing in 'news'
and page number 1
into our service call to get our top stories.
// stories.component.ts
export class StoriesComponent implements OnInit {
items;
constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}
ngOnInit() {
this._hackerNewsAPIService.fetchStories('news', 1)
.subscribe(
items => this.items = items,
error => console.log('Error fetching stories'));
}
}
The correpsonding markup is as follows.
<!-- stories.component.html -->
<div class="loading-section" *ngIf="!items">
<!-- You can add a loading indicator here if you want to :) -->
</div>
<div *ngIf="items">
<ol>
<li *ngFor="let item of items" class="post">
<item class="item-block" [item]="item"></item>
</li>
</ol>
<div class="nav">
<a class="prev">
‹ Prev
</a>
<a class="more">
More ›
</a>
</div>
</div>
Since all our item components are not loading individually async anymore, we set up the loading section (where we can have a loading indicator) here. Moreover, we just pass in the item object of each post to the child item component.
This means we should be able to clean things up in ItemComponent
. In item.component.ts
, we don’t need to inject HackerNewsService
anymore and our component is now simply a conduit to take in the item object from it’s parent.
// item.component.ts
export class ItemComponent implements OnInit {
@Input() item;
constructor() {}
ngOnInit() {
}
}
The markup (item.component.html
) is very similar, but we now don’t need to conditionally check if the item object is present anymore (we do that in the parent component). Moreover, each parameter now refers to the properties of our new API.
<!-- item.component.html -->
<div class="item-laptop">
<p>
<a class="title" href="">
{{item.title}}
</a>
<span *ngIf="item.domain" class="domain">({{item.domain}})</span>
</p>
<div class="subtext-laptop">
<span>
{{item.points}} points by
<a href="">{{item.user}}</a>
</span>
<span>
{{item.time_ago}}
<span> |
<a href="">
<span *ngIf="item.comments_count !== 0">
{{item.comments_count}}
<span *ngIf="item.comments_count === 1">comment</span>
<span *ngIf="item.comments_count > 1">comments</span>
</span>
<span *ngIf="item.comments_count === 0">discuss</span>
</a>
</span>
</span>
</div>
</div>
<div class="item-mobile">
<!-- Markup that shows only on mobile (to give the app a
responsive mobile feel). Same attributes as above
nothing really new here (but refer to the source
file if you're interested) -->
</div>
Now let’s see what happens when we run this bad boy.
And now everything loads much faster. The source code for this step can be found here.
We’ve come quite a long way, but before we continue let’s map out the entire component structure of the application. Please excuse my funky Powerpoint skills.
Let’s start with what we’ve built so far.
Let’s also map out the components that show when we navigate to the comments page.
To allow the user to navigate between these pages, we’re going to have to include some basic routing in our application. Before we begin, let’s create our next component.
ng g component ItemComments
Now let’s create an app.routes.ts
file in our app
folder.
// app.routes.ts
import { Routes, RouterModule } from '@angular/router';
import { StoriesComponent } from './stories/stories.component';
import { ItemCommentsComponent } from './item-comments/item-comments.component';
const routes: Routes = [
{path: '', redirectTo: 'news/1', pathMatch : 'full'},
{path: 'news/:page', component: StoriesComponent, data: {storiesType: 'news'}},
{path: 'newest/:page', component: StoriesComponent, data: {storiesType: 'newest'}},
{path: 'show/:page', component: StoriesComponent, data: {storiesType: 'show'}},
{path: 'ask/:page', component: StoriesComponent, data: {storiesType: 'ask'}},
{path: 'jobs/:page', component: StoriesComponent, data: {storiesType: 'jobs'}},
{path: 'item/:id', component: ItemCommentsComponent}
];
export const routing = RouterModule.forRoot(routes);
An overview of what we’re doing here:
news
, newest
, show
, ask
and jobs
. All these paths will map to our StoriesComponent
news
which should return the list of top storiesStoriesComponent
, we pass down storiesType
as a parameter through the data
property. This lets us have a story type associated for each route (we’ll need this when we use our data service to fetch the list of stories):page
is used as a token so that StoriesComponent
can fetch the list of stories for a specific page:id
is similarly used so that ItemCommentsComponent
can obtain all the comments for a specific itemThere’s a lot more you can do with routing, but this basic setup should be everything we need. Now let’s open app.module.ts
to register our routing.
// app.module.ts
// ...
import { routing } from './app.routes';
@NgModule({
declarations: [
//...
],
imports: [
//...
routing
],
providers: [HackerNewsAPIService],
bootstrap: [AppComponent]
})
export class AppModule { }
To tell Angular where to load the component to route to, we need to use RouterOutlet
.
<!-- app.component.html -->
<div id="wrapper">
<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>
</div>
Let’s bind our navigation links in HeaderComponent
to their respective routes.
<!-- header.component.html -->
<header>
<div id="header">
<a class="home-link" routerLink="/news/1">
<img class="logo" src="https://i.imgur.com/J303pQ4.png">
</a>
<div class="header-text">
<div class="left">
<h1 class="name">
<a routerLink="/news/1" class="app-title">Angular 2 HN</a>
</h1>
<span class="header-nav">
<a routerLink="/newest/1">new</a>
<span class="divider">
|
</span>
<a routerLink="/show/1">show</a>
<span class="divider">
|
</span>
<a routerLink="/ask/1">ask</a>
<span class="divider">
|
</span>
<a routerLink="/jobs/1">jobs</a>
</span>
</div>
<div class="info">
Built with <a href="https://cli.angular.io/" target="_blank">Angular CLI</a>
</div>
</div>
</div>
</header>
The RouterLink
directive is responsible for binding a specific element to a route. Let’s update the StoriesComponent
now.
// stories.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ActivatedRoute } from '@angular/router';
import { HackerNewsAPIService } from '../hackernews-api.service';
@Component({
selector: 'app-stories',
templateUrl: './stories.component.html',
styleUrls: ['./stories.component.scss']
})
export class StoriesComponent implements OnInit {
typeSub: any;
pageSub: any;
items;
storiesType;
pageNum: number;
listStart: number;
constructor(
private _hackerNewsAPIService: HackerNewsAPIService,
private route: ActivatedRoute
) {}
ngOnInit() {
this.typeSub = this.route
.data
.subscribe(data => this.storiesType = (data as any).storiesType);
this.pageSub = this.route.params.subscribe(params => {
this.pageNum = +params['page'] ? +params['page'] : 1;
this._hackerNewsAPIService.fetchStories(this.storiesType, this.pageNum)
.subscribe(
items => this.items = items,
error => console.log('Error fetching' + this.storiesType + 'stories'),
() => this.listStart = ((this.pageNum - 1) * 30) + 1);
});
}
}
Let’s parse out what we’ve added. First of all, we imported ActivatedRoute
which is a service that allows us to access information present in the route.
import { ActivatedRoute } from '@angular/router';
@Component({
//...
})
export class StoriesComponent implements OnInit {
//..
constructor(
private route: ActivatedRoute
) {}
//...
}
We then subscribe to the route data property and store storiesType
into a component variable in the ngOnInit
hook. Notice how we assign any type to the response object. This is just a quick and simple way to opt-out of type checking. Otherwise you may see an error that states property storiesType
does not exist.
ngOnInit() {
this.typeSub = this.route
.data
.subscribe(data => this.storiesType = (data as any).storiesType);
// ...
}
And finally, we subscribe to the route parameters and obtain the page number. We then fetch the list of stories using our data service.
ngOnInit() {
// ...
this.pageSub = this.route.params.subscribe(params => {
this.pageNum = +params['page'] ? +params['page'] : 1;
this._hackerNewsAPIService.fetchStories(this.storiesType, this.pageNum)
.subscribe(
items => this.items = items,
error => console.log('Error fetching' + this.storiesType + 'stories'),
() => {
this.listStart = ((this.pageNum - 1) * 30) + 1;
window.scrollTo(0, 0);
});
});
}
To signal completion, we use onCompleted()
to update a listStart
variable which is used as the starting value of our ordered list (which you can see in the markup below). We also scroll to the top of the window so the user is not stuck at the bottom of the page when he/she tries to switch pages.
<!-- stories.component.html -->
<div class="main-content">
<div class="loading-section" *ngIf="!items">
<!-- You can add a loading indicator here if you want to :) -->
</div>
<div *ngIf="items">
<ol start="{{ listStart }}">
<li *ngFor="let item of items" class="post">
<item class="item-block" [item]="item"></item>
</li>
</ol>
<div class="nav">
<a *ngIf="listStart !== 1" [routerLink]="['/' + storiesType, pageNum - 1]" class="prev">
‹ Prev
</a>
<a *ngIf="items.length === 30" [routerLink]="['/' + storiesType, pageNum + 1]" class="more">
More ›
</a>
</div>
</div>
</div>
We now have the front page complete with navigation and pagination. Run the application to see the good stuff.
We’re almost done! Before we start adding our other comment page components, let’s update the links in ItemComponent
to include routing.
<!-- item.component.html -->
<div class="item-laptop">
<p>
<a class="title" href="{{item.url}}">
{{item.title}}
</a>
<span *ngIf="item.domain" class="domain">({{item.domain}})</span>
</p>
<div class="subtext-laptop">
<span>
{{item.points}} points by
<a href="">{{item.user}}</a>
</span>
<span>
{{item.time_ago}}
<span> |
<a [routerLink]="['/item', item.id]">
<span *ngIf="item.comments_count !== 0">
{{item.comments_count}}
<span *ngIf="item.comments_count === 1">comment</span>
<span *ngIf="item.comments_count > 1">comments</span>
</span>
<span *ngIf="item.comments_count === 0">discuss</span>
</a>
</span>
</span>
</div>
</div>
<div class="item-mobile">
<!-- Markup that shows only on mobile (to give the app a
responsive mobile feel). Same attributes as above,
nothing really new here (but refer to the source
file if you're interested) -->
</div>
Run the application and click on an item’s comments.
Beauty. We can see that it’s routing to ItemCommentsComponent
. Now let’s create our additional components.
ng g component CommentTree
ng g component Comment
We should add a new GET
request to our data service to fetch comments, so let’s do that before we start filling our components in.
// hackernews.api.service.ts
//...
fetchComments(id: number): Observable<any> {
return this.http.get(`${this.baseUrl}/item/${id}`)
.map(response => response.json());
}
And now we can fill out our components.
// item-comments.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { HackerNewsAPIService } from '../hackernews-api.service';
@Component({
selector: 'app-item-comments',
templateUrl: './item-comments.component.html',
styleUrls: ['./item-comments.component.scss']
})
export class ItemCommentsComponent implements OnInit {
sub: any;
item;
constructor(
private _hackerNewsAPIService: HackerNewsAPIService,
private route: ActivatedRoute
) {}
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
let itemID = +params['id'];
this._hackerNewsAPIService.fetchComments(itemID).subscribe(data => {
this.item = data;
}, error => console.log('Could not load item' + itemID));
});
}
}
Similar to what we did in StoriesComponent
, we subscribe to our route parameters, obtain the item id and use that to fetch our comments.
<!-- item-comments.component.html -->
<div class="main-content">
<div class="loading-section" *ngIf="!item">
<!-- You can add a loading indicator here if you want to :) -->
</div>
<div *ngIf="item" class="item">
<div class="mobile item-header">
<!-- Markup that shows only on mobile (to give the app a
responsive mobile feel). Same attributes as below,
nothing really new here (but refer to the source
file if you're interested) -->
</div>
<div class="laptop" [class.item-header]="item.comments_count > 0 || item.type === 'job'" [class.head-margin]="item.text">
<p>
<a class="title" href="{{item.url}}">
{{item.title}}
</a>
<span *ngIf="item.domain" class="domain">({{item.domain}})</span>
</p>
<div class="subtext">
<span>
{{item.points}} points by
<a href="">{{item.user}}</a>
</span>
<span>
{{item.time_ago}}
<span> |
<a [routerLink]="['/item', item.id]">
<span *ngIf="item.comments_count !== 0">
{{item.comments_count}}
<span *ngIf="item.comments_count === 1">comment</span>
<span *ngIf="item.comments_count > 1">comments</span>
</span>
<span *ngIf="item.comments_count === 0">discuss</span>
</a>
</span>
</span>
</div>
</div>
<p class="subject" [innerHTML]="item.content"></p>
<app-comment-tree [commentTree]="item.comments"></app-comment-tree>
</div>
</div>
At the top of the component, we’re going to display the item details, followed by it’s description (item.content
). We then input the entire comments object (item.comments
) to app-comment-tree
, the selector for CommentTreeComponent
. The styling for this component can be found here.
Next, set up the CommentTreeComponent
.
// comment-tree.component.ts
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-comment-tree',
templateUrl: './comment-tree.component.html',
styleUrls: ['./comment-tree.component.scss']
})
export class CommentTreeComponent implements OnInit {
@Input() commentTree;
constructor() {}
ngOnInit() {
}
}
<!-- comment-tree.component.html -->
<ul class="comment-list">
<li *ngFor="let comment of commentTree" >
<app-comment [comment]="comment"></app-comment>
</li>
</ul>
Nice and simple, we list all the comments using the ngFor
directive. Click here to see it’s SCSS file.
Let’s fill out CommentComponent
, the component responsible for each specific comment.
// comment.component.ts
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-comment',
templateUrl: './comment.component.html',
styleUrls: ['./comment.component.scss']
})
export class CommentComponent implements OnInit {
@Input() comment;
collapse: boolean;
constructor() {}
ngOnInit() {
this.collapse = false;
}
}
<!-- comment.component.html -->
<div *ngIf="!comment.deleted">
<div class="meta" [class.meta-collapse]="collapse">
<span class="collapse" (click)="collapse = !collapse">[{{collapse ? '+' : '-'}}]</span>
<a [routerLink]="['/user', comment.user]" routerLinkActive="active">{{comment.user}}</a>
<span class="time">{{comment.time_ago}}</span>
</div>
<div class="comment-tree">
<div [hidden]="collapse">
<p class="comment-text" [innerHTML]="comment.content"></p>
<ul class="subtree">
<li *ngFor="let subComment of comment.comments">
<app-comment [comment]="subComment"></app-comment>
</li>
</ul>
</div>
</div>
</div>
<div *ngIf="comment.deleted">
<div class="deleted-meta">
<span class="collapse">[deleted]</span> | Comment Deleted
</div>
</div>
Notice how we’re recursively referencing app-comment
inside of it’s own component. This is because each comment object in the array has it’s own array of comments, and we’re using recursion to show all of them.
Click here to see the styling for this component. If you now run the application, you can see all the comments for each item!
The entire source code for this step can be found here.
All we have left is user profiles. Since the concept is pretty much the same, I won’t go through this in detail. All you need to do is:
And that’s it! Take a look here if you want to see the whole user component setup.
We’re done! To kick off a production build, you can run ng build --prod
or ng serve --prod
which will make use of uglifying and tree-shaking.
I hope you found this tutorial useful. If you did, please tweet it forward and/or star the repo! I would also love to hear any type of feedback whatsoever.
If you happen to be interested enough to work on this app further, take a look at the issue list and feel free to put up a feature request or a PR! There have been some structural and feature changes since I’ve written this post and this version of the app lives in a separate branch from master, so you’ll need to go there to see the latest version .
If you’re interested, please take a look at my post on how I made this a Progressive Web App that works offline and can be installed to your mobile homescreen!
If you have any questions or suggestions, feel free to open an issue!