© Fu Cheng 2018
Fu ChengBuild Mobile Apps with Ionic 4 and Firebasehttps://doi.org/10.1007/978-1-4842-3775-5_8

8. View Comments

Fu Cheng1 
(1)
Sandringham, Auckland, New Zealand
 

So far, we have focused on the top stories page, which is also the index page of the app. Now we need to add new pages to show comments of a story and replies to a comment. Before adding those pages, we need to talk about page navigation in Ionic with Angular Router. In this chapter, we’ll first discuss Angular Router and then implement the page to show comments and replies.

Angular Router

Ionic apps are Single Page Applications, or SPAs, so there is no page reload after the initial page loading. To archive the similar effect as normal page navigation, a router watches the changes in the URL and updates the view. Angular Router is the built-in routing solution for Angular applications.

We first create a new module and a new component for the new page using the command below.
$ ng g module comments --flat false --routing true
$ ng g component comments-list -m comments
When creating the module, we pass the option --routing true to let Angular CLI create a new routing module. The file of the routing module is comments-routing.module.ts. In Listing 8-1, the variable routes defines the routes for the comments module. This module only has one component CommentsListComponent, so it’s mapped to the empty path. The function RouterModule.forChild creates the router module to import.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { CommentsListComponent } from './comments-list.component';
const routes: Routes = [
  { path: ", component: CommentsListComponent },
];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class CommentsRoutingModule { }
Listing 8-1

Routing module for comments

After defining the routing for the comments module, we need to add the routing to the root module. In the root routing module of the file app-routing.module.ts in Listing 8-2, three routing mappings are defined. The first one with path top-stories is mapped to the TopStoriesModule, while the second one with path comments/:id is mapped to CommentsModule. :id in the path is a routing parameter, so it matches URLs like comments/123 or comments/456. The parameter’s value can be extracted to be used in the component. The property loadChildren means this module is lazily loaded. The module is only loaded when the route path is accessed for the first time. Lazy loading of routes can reduce the initial bundle size and improve performance. If the user only views the top stories page, then the comments module is never loaded. The last empty path is matched when the user accesses the root URL, which is redirected to path /top-stories. The function RouterModule.forRoot accepts extra options to configure the router. The property useHash means using a URL hash like http://localhost:4200/#/top-stories or http://localhost:4200/#/comments/123.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
  { path: 'top-stories', loadChildren: 'app/top-stories/top-stories.module#TopStoriesModule' },
  { path: 'comments/:id', loadChildren: 'app/comments/comments.module#CommentsModule' },
  { path: ", redirectTo: '/top-stories', pathMatch: 'full' },
];
@NgModule({
  imports: [RouterModule.forRoot(routes, {
    useHash: true,
  })],
  exports: [RouterModule]
})
export class AppRoutingModule { }
Listing 8-2

Root routing module

We can also configure Angular Router to work with NgRx. By using @ngrx/router-store to connect Angular Router with the NgRx store, we can access a router state in the store. The store dispatches different kinds of actions when navigation changes. NgRx already provides the reducer function to work with Angular Router. We just need to integrate it with the store. Listing 8-3 shows the file app/reducers/index.ts for the root module.

In Listing 8-3, RouterStateUrl is the state for routing. The property url is the current URL, params is the routing parameters; queryParams is the query parameters. CustomRouterStateSerializer is a custom implementation of RouterStateSerializer that serializes RouterStateSnapshot objects to custom format. This is because RouterStateSnapshot contains a lot of information about the current routing state. Generally, we don’t need all of these data, and large data may have performance issues. CustomRouterStateSerializer only extracts RouterStateUrl objects. fromRouter.RouterReducerState<RouterStateUrl> defines the state of the router, while fromRouter.routerReducer is the provided reducer function from @ngrx/router-store.
import { ActionReducerMap } from '@ngrx/store';
import { Params, RouterStateSnapshot } from '@angular/router';
import { RouterStateSerializer } from '@ngrx/router-store';
import * as fromItems from './items';
import * as fromRouter from '@ngrx/router-store';
export interface RouterStateUrl {
  url: string;
  params: Params;
  queryParams: Params;
}
export class CustomRouterStateSerializer
  implements RouterStateSerializer<RouterStateUrl> {
  serialize(routerState: RouterStateSnapshot): RouterStateUrl {
    let route = routerState.root;
    while (route.firstChild) {
      route = route.firstChild;
    }
    const { url, root: { queryParams } } = routerState;
    const { params } = route;
    return { url, params, queryParams };
  }
}
export interface State {
  router: fromRouter.RouterReducerState<RouterStateUrl>;
  items: fromItems.State;
}
export const reducers: ActionReducerMap<State> = {
  router: fromRouter.routerReducer,
  items: fromItems.reducer,
};
Listing 8-3

Connect Angular Router to NgRx store

We need to update the root module to import NgRx modules for the router. In Listing 8-4, StoreRouterConnectingModule.forRoot connects the router to the store. The property stateKey specifies the key for route data in the state. We also need to declare CustomRouterStateSerializer as the provider of RouterStateSerializer.
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
@NgModule({
  declarations: [
    MyApp,
  ],
  imports: [
    StoreModule.forRoot(reducers),
    StoreRouterConnectingModule.forRoot({
      stateKey: 'router',
    }),
  ],
  providers: [
    { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer },
  ]
})
export class AppModule {}
Listing 8-4

Import NgRx router module

Comment Model

Now we are going to implement the page to display comments for each top story. For each comment, we also want to show its replies. We’ll create a new comments page. Clicking the comments icon of an item in the top stories page triggers the navigation to the comments page. In the comments page, clicking the replies icon navigates to the comments page again but shows the replies to this comment.

Stories and comments are all items in Hacker News API. They can both have children items, which can be comments or replies. The data structure is recursive, so we can use the same comments page to show both comments and replies.

For the model Item, we add three new properties related to comments.
  • text – The text of the comment.

  • descendants – The number of descendants of this item. For stories, this is the number of comments; for comments, this is the number of replies.

  • kids – The array of ids of descendants of this item. For stories, this is the array of comments ids; for comments, this is the array of replies ids.

View Comments

Even though stories and comments are both items, the components to display them are different. The comment component needs to show the text instead of titles and links. We have separate components to render comments and lists of comments.

We use the Angular CLI to generate stub code of the two components. These components are put into the same components module as ItemsComponent and ItemComponent.
$ ng g component components/comment
$ ng g component components/comments

Comment Component

Listing 8-5 shows the template of the CommentComponent. We use binding of the property innerHTML to display the content of item.text because item.text contains HTML markups. We check the property item.kids for replies to this comment. If a comment has replies, the number of replies is displayed. The property routerLink of the ion-button specifies the link to navigate. Because the comments link requires the id, ['/comments', item.id] is the way to provide the id as the parameter.
<div *ngIf="item">
  <div [innerHTML]="item.text"></div>
  <div>
    <span>
      <ion-icon name="person"></ion-icon>
      {{ item.by }}
    </span>
    <span>
      <ion-icon name="time"></ion-icon>
      {{ item.time | timeAgo }}
    </span>
    <ion-button *ngIf="item.kids" [fill]="'clear'" [routerLink]="['/comments', item.id]">
      <ion-icon slot="icon-only" name="chatboxes"></ion-icon>
      {{ item.kids.length }}
    </ion-button>
  </div>
</div>
<div *ngIf="!item">
  Loading...
</div>
Listing 8-5

Template of CommentComponent

The CommentComponent in Listing 8-6 only has one Input binding of type Item.
import { Component, Input } from '@angular/core';
import { Item } from '../../models/item';
@Component({
  selector: 'app-comment',
  templateUrl: './comment.component.html',
  styleUrls: ['./comment.component.scss']
})
export class CommentComponent {
  @Input() item: Item;
}
Listing 8-6

CommentComponent

Comments Component

The template of CommentsComponent is also like the ItemsComponent; see Listing 8-7.
<ion-list *ngIf="items && items.length > 0">
  <ion-item *ngFor="let item of items">
    <app-comment [item]="item"></app-comment>
  </ion-item>
</ion-list>
<p *ngIf="items && items.length === 0">
  No items.
</p>
<p *ngIf="!items">
  Loading...
</p>
Listing 8-7

Template of CommentsComponent

The code of CommentsComponent is also like the ItemsComponent;see Listing 8-8.
import { Component, Input } from '@angular/core';
import { Items } from '../../models/items';
@Component({
  selector: 'app-comments',
  templateUrl: './comments.component.html',
  styleUrls: ['./comments.component.scss']
})
export class CommentsComponent {
  @Input() items: Items;
}
Listing 8-8

CommentsComponent

ItemComponent Changes

We also need to update the ItemComponent to display the number of comments and allows users to view the comments; see Listing 8-9. The same property routerLink is used to navigate to the comments page.
<ion-button *ngIf="item.kids" [fill]="'clear'" [routerLink]="['/comments', item.id]">
  <ion-icon slot="icon-only" name="chatboxes"></ion-icon>
  {{ item.kids.length }}
</ion-button>
Listing 8-9

Updated template of ItemComponent

State Management

Now we start adding the logic to show comments and replies. We still use NgRx to manage the state of comments page. Since comments and replies are all items, we can reuse the state for the feature items to store comments and replies.

Actions

Listing 8-10 shows the actions for the comments page. The action Select means selecting the comment to view. The payload is the item id. The action LoadMore means loading more comments or replies. The action LoadSuccess means the loading of an item is successful. This action is required because the user may access the comments page directly, so it’s possible that the comment has not been loaded yet, so we need the loading action. Payload types of these actions are straightforward.
import { Action } from '@ngrx/store';
import { Item } from '../../models/item';
export enum CommentsActionTypes {
  Select = '[Comments] Select',
  LoadMore = '[Comments] Load More',
  LoadSuccess = '[Comments] Load Success',
}
export class Select implements Action {
  readonly type = CommentsActionTypes.Select;
  constructor(public payload: number) {}
}
export class LoadMore implements Action {
  readonly type = CommentsActionTypes.LoadMore;
}
export class LoadSuccess implements Action {
  readonly type = CommentsActionTypes.LoadSuccess;
  constructor(public payload: Item) {}
}
export type CommentsActions = Select | LoadMore | LoadSuccess;
Listing 8-10

Comments actions

Reducers

The first reducer function is for selected comments; see Listing 8-11. The state only contains one property selectedItemId to contain the id of selected item. This property is updated when the action LoadSuccess is dispatched.
import { CommentsActions, CommentsActionTypes } from '../actions/comments';
export interface State {
  selectedItemId: number;
}
const initialState: State = {
  selectedItemId: null,
};
export function reducer(
  state = initialState,
  action: CommentsActions,
) {
  switch (action.type) {
    case CommentsActionTypes.LoadSuccess:
      return {
        ...state,
        selectedItemId: action.payload.id,
      };
    default: {
      return state;
    }
  }
}
export const getSelectedItemId = (state: State) => state.selectedItemId;
Listing 8-11

Reducer function for selected comments

Because there may be many comments or replies, the pagination is still required. In Listing 8-12, the state and reducer functions are similar to those ones in the top stories page. The difference is that the total number of items is determined by the length of property kids.
import { CommentsActions, CommentsActionTypes } from '../actions/comments';
export interface State {
  offset: number;
  limit: number;
  total: number;
}
export const pageSize = 20;
const initialState: State = {
  offset: 0,
  limit: pageSize,
  total: 0,
};
export function reducer(
  state = initialState,
  action: CommentsActions,
): State {
  switch (action.type) {
    case CommentsActionTypes.LoadMore:
      const offset = state.offset + state.limit;
      return {
        ...state,
        offset: offset < state.total ? offset : state.offset,
      };
    case CommentsActionTypes.LoadSuccess: {
      return {
        ...state,
        total: (action.payload.kids && action.payload.kids.length) || 0,
      };
    }
    default: {
      return state;
    }
  }
}
Listing 8-12

Reducer for pagination

The final reducer map and selectors are listed in Listing 8-13. The most important selector is getSelectedItemChildren, which will be used by the comments component to select the items to display. Here we use the selector getItemEntities from the feature items to select loaded items.
import * as fromRoot from '../../reducers';
import * as fromComments from './comments';
import * as fromPagination from './pagination';
import { ActionReducerMap, createFeatureSelector, createSelector } from '@ngrx/store';
import { getItemEntities } from '../../reducers/items';
export interface CommentsState {
  comments: fromComments.State;
  pagination: fromPagination.State;
}
export interface State extends fromRoot.State {
  comments: CommentsState;
}
export const reducers: ActionReducerMap<CommentsState> = {
  comments: fromComments.reducer,
  pagination: fromPagination.reducer,
};
export const getCommentsFeatureState = createFeatureSelector<CommentsState>('comments');
export const getCommentsState = createSelector(
  getCommentsFeatureState,
  state => state.comments,
);
export const getPaginationState = createSelector(
  getCommentsFeatureState,
  state => state.pagination,
);
export const getSelectedItemId = createSelector(
  getCommentsState,
  fromComments.getSelectedItemId,
);
export const getSelectedItem = createSelector(
  getItemEntities,
  getSelectedItemId,
  (entities, id) => entities[id],
);
export const getSelectedItemChildren = createSelector(
  getSelectedItem,
  getItemEntities,
  getPaginationState,
  (item, entities, pagination) => {
    return item ? (item.kids || []).slice(0, pagination.offset + pagination.limit)
      .map(id => entities[id]) : [];
  }
);
Listing 8-13

Reducer map and selectors

Effects

NgRx effects are also required for loading the comments. In Listing 8-14, the effect loadComment$ handles the action Select. After the item is loaded, it dispatches three actions as the result. The first action itemActions.LoadSuccess updates the items store with the loaded item; the second action commentsActions.LoadSuccess selects the item to view; the third action itemActions.Load triggers the loading of comments or replies. The effect loadMore$ handles the action LoadMore. It gets the state from the store and dispatches the action itemActions.Load with the ids from the selected item’s property kids.
@Injectable()
export class CommentsEffects {
  constructor(private actions$: Actions,
              private store: Store<fromComments.State>,
              private db: AngularFireDatabase) {
  }
  @Effect()
  loadComment$: Observable<Action> = this.actions$.pipe(
    ofType(CommentsActionTypes.Select),
    switchMap((action: commentsActions.Select) =>
      this.db.object(`/v0/item/${action.payload}`).valueChanges()
        .pipe(
          take(1),
          mergeMap((item: Item) => of<Action>(
            new itemActions.LoadSuccess([item]),
            new commentsActions.LoadSuccess(item),
            new itemActions.Load((item.kids || []).slice(0, pageSize))))
        )
    )
  );
  @Effect()
  loadMore$: Observable<Action> = this.actions$.pipe(
    ofType(CommentsActionTypes.LoadMore),
    withLatestFrom(this.store),
    map(([action, state]) => {
      const {
        items: { entities },
        comments: {pagination: { offset, limit },
        comments: { selectedItemId }}
      } = state;
      const ids = entities[selectedItemId].kids || [];
      return new itemActions.Load(ids.slice(offset, offset + limit));
    })
  );
}
Listing 8-14

Effects

Connect Component to the Store

After we finish the store-related code, the last step is to connect the component with the store. The comments component uses the same infinite scrolling as in the top stories page. Listing 8-15 shows the template of the component. The component ion-button renders a back arrow that can be used to go back to the previous URL .
<ion-header>
  <ion-toolbar>
    <ion-buttons slot="start">
      <ion-button (click)="goBack()">
        <ion-icon slot="icon-only" name="arrow-back"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>Comments</ion-title>
  </ion-toolbar>
</ion-header>
<ion-content padding>
  <app-comments [items]="items$ | async"></app-comments>
  <ion-infinite-scroll (ionInfinite)="load($event)">
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>
</ion-content>
Listing 8-15

Template of the comments component

Listing 8-16 shows the code of the component. The constructor parameter of type ActivatedRoute is provided by Angular Router to access the current activated route. We can get the route parameters from ActivatedRoute. The return value of this.route.params is an Observable<Params> object. When the parameters change, a new Params object is emitted. In this case, we need to dispatch an action Select to trigger the loading of comments or replies. The method goBack uses the Location object to go back to the previous URL.
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import * as fromItems from '../reducers/items';
import * as fromComments from './reducers';
import * as commentsActions from './actions/comments';
import { select, Store } from '@ngrx/store';
import { Observable, Subscription } from 'rxjs';
import { Items } from '../models/items';
import { map } from 'rxjs/operators';
@Component({
  selector: 'app-comments-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './comments-list.component.html',
  styleUrls: ['./comments-list.component.scss']
})
export class CommentsListComponent implements OnInit, OnDestroy {
  items$: Observable<Items>;
  private itemsLoading$: Observable<boolean>;
  private infiniteScrollComponent: any;
  private subscriptions: Subscription[];
  constructor(private route: ActivatedRoute,
              private store: Store<fromComments.State>,
              private location: Location) {
    this.items$ = store.pipe(select(fromComments.getSelectedItemChildren));
    this.itemsLoading$ = store.pipe(select(fromItems.isItemsLoading));
    this.subscriptions = [];
  }
  ngOnInit() {
    this.subscriptions.push(this.itemsLoading$.subscribe(loading => {
      if (!loading) {
        this.notifyScrollComplete();
      }
    }));
    this.subscriptions.push(this.route.params.pipe(
      map(params => new commentsActions.Select(parseInt(params.id, 10)))
    ).subscribe(this.store));
  }
  ngOnDestroy(): void {
    this.subscriptions.forEach(subscription => subscription.unsubscribe());
  }
  load(event) {
    this.infiniteScrollComponent = event.target;
    this.store.dispatch(new commentsActions.LoadMore());
  }
  goBack(): void {
    this.location.back();
  }
  private notifyScrollComplete(): void {
    if (this.infiniteScrollComponent) {
      this.infiniteScrollComponent.complete();
    }
  }
}
Listing 8-16

CommentsListComponent

Figure 8-1 shows the screenshot of the comments page.
../images/436854_2_En_8_Chapter/436854_2_En_8_Fig1_HTML.jpg
Figure 8-1

Comments page

Summary

In this chapter, we implemented the user story to view comments of stories. We used Angular Router to implement the transition from the top stories page to the comments page. The comments page also uses NgRx to manage the state. In the next chapter, we’ll discuss user management with Firebase.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.221.53.5