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

10. Manage Favorites

Fu Cheng1 
(1)
Sandringham, Auckland, New Zealand
 

After adding user management, we can now move to the next user story that allows users to add stories to their favorites. Favorites data is stored in the Firebase Cloud Firestore. We’ll use AngularFire2 to interact with Firestore. The favorites page is only accessible to authenticated users, and we’ll use authentication guard to protect this page. After reading this chapter, you should know how to interact with Firebase Cloud Firestore.

Favorites Service

Favorites data is stored in the Firebase Cloud Firestore. Even though Cloud Firestore is still in beta at the time of writing, it’s still recommended for you to give it a try. If you have used MongoDB or other document-oriented databases before, you may find the concepts in Cloud Firestore are quite familiar. Cloud Firestore stores data in documents, while documents are stored in collections. A document is a lightweight record that contains fields, which map to values. Collections can be nested to create a hierarchical data structure.

The favorites data is stored in the collection favorites. Each document in this collection has the ID matching a user’s ID. These user documents have a subcollection items that contains documents for favorite items of the current user. Each document of the favorite items collection has the ID matching the item’s ID and a field timestamp representing the time when the favorite item is added. The timestamp is used to sort the favorite items to make sure newly added items appear first in the list. The path of a favorite item looks like /favorites/<user_id>/items/<item_id>.

AngularFire2 already has support for Cloud Firestore. We only need to import the module AngularFirestoreModule, then we can use the injected service AngularFirestore to interact with Cloud Firestore.

We start from the FavoritesService in Listing 10-1. The method collection returns the items collection for a user with a given userId. The method add adds a new item to the collection with a current timestamp. The method remove deletes an item from the collection. The method list returns the list of favorite items in the collection. orderBy('timestamp', 'desc') specifies that the items are sorted in descending order by timestamp.
import { Injectable } from '@angular/core';
import { AngularFirestore } from '@angular/fire/firestore';
import { Favorite } from '../../models/favorite';
@Injectable()
export class FavoritesService {
  constructor(private afStore: AngularFirestore) {}
  add(userId: string, itemId: number): Promise<Favorite> {
    const timestamp = new Date().getTime();
    return this.collection(userId)
      .doc(`${itemId}`)
      .set({
        timestamp,
      }).then(() => ({
        itemId,
        timestamp,
      }));
  }
  remove(userId: string, itemId: number): Promise<void> {
    return this.collection(userId)
      .doc(`${itemId}`)
      .delete();
  }
  list(userId: string): Promise<Favorite[]> {
    return this.collection(userId)
      .orderBy('timestamp', 'desc')
      .get().then(snapshot => snapshot.docs.map(doc => ({
        itemId: parseInt(doc.id, 10),
        timestamp: doc.data()['timestamp'],
      })));
  }
  private collection(userId: string) {
    return this.afStore.firestore.collection('favorites')
      .doc(userId)
      .collection('items');
  }
}
Listing 10-1

FavoritesService

Favorite in Listing 10-2 is the type to describe a favorite item. It contains two properties: itemId and timestamp.
export interface Favorite {
  itemId: number;
  timestamp: number;
}
Listing 10-2

Favorite

State Management

NgRx is used to manage the state related to favorites. Listing 10-3 shows the actions that can change state. The action Load means loading the current user’s favorite items. The action Add means adding an item to the favorite. The action Remove means removing an item from the favorite. These three actions all have two other actions to represent the results. The payload of these actions is straightforward.
import { Action } from '@ngrx/store';
import { Favorite } from '../../models/favorite';
export enum FavoritesActionTypes {
  Load = '[Favorites] Load',
  LoadSuccess = '[Favorites] Load Success',
  LoadFailure = '[Favorites] Load Failure',
  Add = '[Favorites] Add',
  AddSuccess = '[Favorites] Add Success',
  AddFailure = '[Favorites] Add Failure',
  Remove = '[Favorites] Remove',
  RemoveSuccess = '[Favorites] Remove Success',
  RemoveFailure = '[Favorites] Remove Failure',dt2fr56uk
}
export class Load implements Action {
  readonly type = FavoritesActionTypes.Load;
}
export class LoadSuccess implements Action {
  readonly type = FavoritesActionTypes.LoadSuccess;
  constructor(public payload: Favorite[]) {}
}
export class LoadFailure implements Action {
  readonly type = FavoritesActionTypes.LoadFailure;
  constructor(public payload: any) {}
}
export class Add implements Action {
  readonly type = FavoritesActionTypes.Add;
  constructor(public payload: number) {}
}
export class AddSuccess implements Action {
  readonly type = FavoritesActionTypes.AddSuccess;
  constructor(public payload: Favorite) {}
}
export class AddFailure implements Action {
  readonly type = FavoritesActionTypes.AddFailure;
  constructor(public payload: number) {}
}
export class Remove implements Action {
  readonly type = FavoritesActionTypes.Remove;
  constructor(public payload: number) {}
}
export class RemoveSuccess implements Action {
  readonly type = FavoritesActionTypes.RemoveSuccess;
  constructor(public payload: number) {}
}
export class RemoveFailure implements Action {
  readonly type = FavoritesActionTypes.RemoveFailure;
  constructor(public payload: number) {}
}
export type FavoritesActions = Load | LoadSuccess | LoadFailure
  | Add | AddSuccess | AddFailure
  | Remove | RemoveSuccess | RemoveFailure;
Listing 10-3

Actions

The state, reducer function, and selectors are shown in Listing 10-4. The interface FavoritesItem describes the favorite items. The property loading specifies whether the action to modify it is still in progress. For example, when removing a favorite item, the property loading is updated to true. This property is useful for UI to display the loading indicators. The state for favorites also uses EntityState from @ngrx/entity, just like the feature items. When creating the EntityAdapter, we provide the sortComparer to sort by the timestamp.

In the reducer function, we use different functions of the adapter to update the state. For the action LoadSuccess, loaded favorite items are added to the entities store; for the actions Add and Remove, the property loading is set to true; for the action AddSuccess, the added favorite item is saved and the property loading is set to false; for the action RemoveFailure, only the property loading is set to false; for the actions RemoveSuccess and AddFailure, the favorite item is removed.

The function inFavorite returns a selector to check whether an item is in the favorite. The function getLoading returns a selector to check whether an item in in the loading status. The selector getFavoriteItems returns all favorite items.
import { FavoritesActions, FavoritesActionTypes } from '../actions/favorites';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromAuth from '../../auth/reducers';
import { getItemEntities } from '../../reducers/items';
import { FavoritesService } from '../services/favorites.service';
export interface FavoritesItem {
  itemId: number;
  timestamp?: number;
  loading: boolean;
}
export const adapter: EntityAdapter<FavoritesItem> = createEntityAdapter<FavoritesItem>({
  selectId: (item: FavoritesItem) => item.itemId,
  sortComparer: (item1, item2) => item2.timestamp - item1.timestamp,
});
export type State = EntityState<FavoritesItem>;
export interface FavoritesState {
  auth: fromAuth.State;
  favorites: State;
}
export const initialState: State = adapter.getInitialState();
export function reducer(state = initialState, action: FavoritesActions) {
  switch (action.type) {
    case FavoritesActionTypes.LoadSuccess: {
      return adapter.upsertMany(action.payload.map(item => ({
          ...item,
          loading: false,
      })), state);
    }
    case FavoritesActionTypes.Add: {
      return adapter.addOne({
        itemId: action.payload,
        loading: true,
      }, state);
    }
    case FavoritesActionTypes.Remove: {
      return adapter.updateOne({
        id: action.payload,
        changes: {
          loading: true,
        },
      }, state);
    }
    case FavoritesActionTypes.AddSuccess:
      const favorite = action.payload;
      return adapter.updateOne({
        id: favorite.itemId,
        changes: {
          ...favorite,
          loading: false,
        },
      }, state);
    case FavoritesActionTypes.RemoveFailure: {
      return adapter.updateOne({
        id: action.payload,
        changes: {
          loading: false,
        },
      }, state);
    }
    case FavoritesActionTypes.RemoveSuccess:
    case FavoritesActionTypes.AddFailure: {
      return adapter.removeOne(action.payload, state);
    }
    default: {
      return state;
    }
  }
}
export const getFavoritesState = createFeatureSelector<State>('favorites');
export const {
  selectEntities: selectFavoriteEntities,
  selectIds: selectFavorites,
} = adapter.getSelectors(getFavoritesState);
export const inFavorite = (itemId) => createSelector(
  selectFavoriteEntities,
  entities => entities[itemId] && !entities[itemId].loading
);
export const getLoading = (itemId) => createSelector(
  selectFavoriteEntities,
  entities => entities[itemId] && entities[itemId].loading
);
export const getFavoriteItems = createSelector(
  selectFavorites,
  selectFavoriteEntities,
  getItemEntities,
  (ids: number[], favorites, entities) =>
    ids.filter(id => favorites[id] && !favorites[id].loading)
      .map(id => entities[id])
);
Listing 10-4

State, reducer function, and selectors

The FavoritesEffects in Listing 10-5 uses FavoritesService to perform different actions. Because favorites data is stored for each user, we need to access the store to get the information of the current logged-in user. The effect load$ uses the method list to load all favorite items. Here we also need to dispatch an action itemsActions.Load to trigger the loading of those items. The effects add$ and remove$ use corresponding methods of FavoritesService and dispatch actions based on the results.
@Injectable()
export class FavoritesEffects {
  constructor(private action$: Actions,
              private store: Store<fromAuth.State>,
              private favoritesService: FavoritesService) {
  }
  @Effect()
  load$ = this.action$.pipe(
    ofType(FavoritesActionTypes.Load),
    withLatestFrom(this.store),
    mergeMap(([action, state]) => {
      const {auth: {status: {user}}} = state;
      return from(this.favoritesService.list(user.uid)).pipe(
        mergeMap(favorites => of<Action>(
          new LoadSuccess(favorites),
          new itemsActions.Load(favorites.map(f => f.itemId))
        ))
      );
    })
  );
  @Effect()
  add$ = this.action$.pipe(
    ofType(FavoritesActionTypes.Add),
    withLatestFrom(this.store),
    mergeMap(([action, state]) => {
      const {auth: {status: {user}}} = state;
      const itemId = (action as Add).payload;
      return from(this.favoritesService.add(user.uid, itemId)).pipe(
        map(result => new AddSuccess(result)),
        catchError((error) => of(new AddFailure(itemId)))
      );
    })
  );
  @Effect()
  remove$ = this.action$.pipe(
    ofType(FavoritesActionTypes.Remove),
    withLatestFrom(this.store),
    mergeMap(([action, state]) => {
      const {auth: {status: {user}}} = state;
      const itemId = (action as Remove).payload;
      return from(this.favoritesService.remove(user.uid, itemId)).pipe(
        map(() => new RemoveSuccess(itemId)),
        catchError((error) => of(new RemoveFailure(itemId)))
      );
    })
  );
}
Listing 10-5

FavoritesEffects

Favorite Toggle

In the top stories page, we need to add a control for each item to allow a user to add a story to the favorite or remove a story from the favorite. The component FavoriteToggleComponent in Listing 10-6 displays different icons based on the item’s status in the favorites. The input property itemId is the id of the item to check. The property inFavorite specifies whether the item is in the favorite. The property loading specifies whether the action is still in progress. The EventEmitters toAdd and toRemove emit the item id when the user clicks the icon to toggle the favorite status.
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
@Component({
  selector: 'app-favorite-toggle',
  templateUrl: './favorite-toggle.component.html',
  styleUrls: ['./favorite-toggle.component.scss']
})
export class FavoriteToggleComponent {
  @Input() itemId: number;
  @Input() inFavorite: boolean;
  @Input() loading: boolean;
  @Output() toAdd = new EventEmitter<number>();
  @Output() toRemove = new EventEmitter<number>();
  constructor() { }
  add() {
    this.toAdd.emit(this.itemId);
  }
  remove() {
    this.toRemove.emit(this.itemId);
  }
}
Listing 10-6

FavoriteToggleComponent

Listing 10-7 shows the template of the component FavoriteToggleComponent . The component displays different elements based on the values of input properties loading and inFavorite.
<span>
  <ion-spinner *ngIf="loading"></ion-spinner>
  <ion-button fill="clear" *ngIf="!loading && inFavorite" (click)="remove()">
    <ion-icon slot="icon-only" name="heart" color="danger"></ion-icon>
  </ion-button>
  <ion-button fill="clear" class="btnLike" *ngIf="!loading && !inFavorite" (click)="add()">
    <ion-icon slot="icon-only" name="heart-empty" color="danger"></ion-icon>
  </ion-button>
</span>
Listing 10-7

Template of FavoriteToggleComponent

The component FavoriteToggleComponent is very simple as it only has plain input properties. The actual logic to deal with the store is encapsulated in the component FavoriteToggleContainerComponent in Listing 10-8. FavoriteToggleContainerComponent only has one input property itemId. isLoggedIn$ is the observable of the user’s login status using the selector fromAuth.getLoggedIn; inFavorite$ is the observable of the item’s favorite status using the function fromFavorites.inFavorite to create the selector; loading$ is the observable of the item’s loading status using the function fromFavorites.getLoading to create the selector. The methods add and remove dispatch actions with the item’s id.
import { Component, Input, OnInit } from '@angular/core';
import * as fromFavorites from '../../reducers';
import * as fromAuth from '../../../auth/reducers';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { Add, Remove } from '../../actions/favorites';
@Component({
  selector: 'app-favorite-toggle-container',
  templateUrl: './favorite-toggle-container.component.html',
  styleUrls: ['./favorite-toggle-container.component.scss']
})
export class FavoriteToggleContainerComponent implements OnInit {
  @Input() itemId: number;
  isLoggedIn$: Observable<boolean>;
  inFavorite$: Observable<boolean>;
  loading$: Observable<boolean>;
  constructor(private store: Store<fromFavorites.State>) {}
  ngOnInit() {
    this.isLoggedIn$ = this.store.pipe(select(fromAuth.getLoggedIn));
    this.inFavorite$ = this.store.pipe(select(fromFavorites.inFavorite(this.itemId)));
    this.loading$ = this.store.pipe(select(fromFavorites.getLoading(this.itemId)));
  }
  add() {
    this.store.dispatch(new Add(this.itemId));
  }
  remove() {
    this.store.dispatch(new Remove(this.itemId));
  }
}
Listing 10-8

FavoriteToggleContainerComponent

Listing 10-9 shows the template of the component FavoriteToggleComponent. We simply use the pipe async to extract the values as properties for FavoriteToggleComponent.
<app-favorite-toggle *ngIf="isLoggedIn$ | async"
                     [itemId]="itemId"
                     [inFavorite]="inFavorite$ | async"
                     [loading]="loading$ | async"
                     (toAdd)="add()"
                     (toRemove)="remove()"></app-favorite-toggle>
Listing 10-9

Template of FavoriteToggleContainerComponent

Favorites Page

The page to show favorite items is very simple. In Listing 10-10, we use the selector fromFavorites.getFavoriteItems to get all favorite items. In the method ngOnInit, we dispatch the action Load to load all favorite items.
@Component({
  selector: 'app-favorites-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './favorites-list.component.html',
  styleUrls: ['./favorites-list.component.scss']
})
export class FavoritesListComponent implements OnInit {
  items$: Observable<Items>;
  constructor(private store: Store<fromFavorites.State>) {
    this.items$ = store.pipe(select(fromFavorites.getFavoriteItems));
  }
  ngOnInit() {
    this.store.dispatch(new Load());
  }
}
Listing 10-10

Favorites page

The template of the favorites page is also very simple; see Listing 10-11.
<ion-header>
  <ion-toolbar>
    <ion-buttons slot="start">
      <ion-button routerLink="/top-stories">
        <ion-icon slot="icon-only" name="home"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>Favorites</ion-title>
  </ion-toolbar>
</ion-header>
<ion-content padding>
  <app-items [items]="items$ | async"></app-items>
</ion-content>
Listing 10-11

Template of favorites page

The template of ItemComponent needs to be updated to include the component FavoriteToggleContainerComponent, see below.
<app-favorite-toggle-container [itemId]="item.id"></app-favorite-toggle-container>

Authentication Guards

The favorites page can only work when the user is logged in, so we can get the user id to retrieve the favorites data. If the user accesses the favorites page directly, we should redirect to the login page. This check can be done using authentication guards in Angular Router.

Listing 10-12 shows the AuthGuard to check for user login. AuthGuard implements the interface CanActivate from Angular Router. In the method canActivate, we use the selector fromAuth.getLoggedIn to get the login state. If the user is not logged in, we dispatch an action authActions.LoginRedirect to redirect the user to the login page.
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import * as authActions from '../actions/auth';
import * as fromAuth from '../reducers';
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private store: Store<fromAuth.State>) {}
  canActivate(): Observable<boolean> {
    return this.store.pipe(
      select(fromAuth.getLoggedIn),
      map(isAuthed => {
        if (!isAuthed) {
          this.store.dispatch(new authActions.LoginRedirect());
          return false;
        }
        return true;
      }),
      take(1)
    );
  }
}
Listing 10-12

AuthGuard

The action LoginRedirect is handled in the AuthEffects; see Listing 10-13. We use the method navigate of Router to navigate to the URL /login.
@Effect({ dispatch: false })
loginRedirect$ = this.action$.pipe(
  ofType(AuthActionTypes.LoginRedirect, AuthActionTypes.Logout),
  tap(() => {
    this.router.navigate(['/login']);
  })
);
Listing 10-13

Handle login redirects

In the root routing module, we use the property canActivate to specify the AuthGuard to protect this routing; see Listing 10-14.
{ path: 'favorites',
  loadChildren: 'app/favorites-list/favorites-list.module#FavoritesListModule',
  canActivate: [AuthGuard]
}
Listing 10-14

Use AuthGuard

Integration with Authentication

After finishing the state management of favorites data, we still need to deal with the integration with authentication. When a user is logged in, we need to load the favorites data. This is done by dispatching the action Load in effect login$ of AuthEffects. When a user is logged out, we need to clear the favorites data. This is done by adding a new action Clear with the reducer function to clear all entities in the favorites; see the updated reducer function in Listing 10-15. The action Clear is dispatched in the effect logout$ of AuthEffects.
export function reducer(state = initialState, action: FavoritesActions) {
  switch (action.type) {
    case FavoritesActionTypes.Clear: {
      return adapter.removeAll(state);
    }
  }
}
Listing 10-15

Remove all favorites data

Figure 10-1 shows the screenshot of the top stories page with the icon for favorites.
../images/436854_2_En_10_Chapter/436854_2_En_10_Fig1_HTML.jpg
Figure 10-1

Top stories page

Summary

In this chapter, we implemented the user story to manage favorites, including adding stories into the favorites and removing stories from the favorites. Favorites data is stored in the Firebase Cloud Firestore. We implemented the service to interact with Firebase. We also created the page to list stories in the favorites. Since the favorites feature is only enabled when the user is logged in, we also discussed the integration with the authentication in this chapter. In the next chapter, we’ll implement the user story to share stories.

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

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