Skip to content

Instantly share code, notes, and snippets.

@danierdev
Forked from ThomasBurleson/tickets.facade.md
Created September 7, 2019 19:54
Show Gist options
  • Save danierdev/eb7d40172833e956a5e9443887ac363c to your computer and use it in GitHub Desktop.
Save danierdev/eb7d40172833e956a5e9443887ac363c to your computer and use it in GitHub Desktop.
Using ngrx with Effects + Facades

NgRx State Management with TicketFacade

Facades are a programming pattern in which a simpler public interface is provided to mask a composition of internal, more-complex, component usages.

When writing a lot of NgRx code - as many enterprises do - developers quickly accumulate large collections of actions and selectors classes. These classes are used to dispatch and query [respectively] the NgRx Store.

Using a Facade - to wrap and blackbox NgRx - simplifies accessing and modifying your NgRx state by masking internal all interactions with the Store, actions, reducers, selectors, and effects.

For more introduction, see Better State Management with Ngrx Facades

Solution: API Facade

Using a Facade pattern to expose a clear, concise services API and encapsulate the use of Effects and NgRx is a wonderful solution:

import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { Store } from '@ngrx/store';
import { Actions, Effect, ROOT_EFFECTS_INIT } from '@ngrx/effects';

import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap, combineLatest, withLatestFrom, mergeMap, concat, merge } from 'rxjs/operators';

import { BackendService } from '../services';
import { NoopAction, ApplicationState } from '../app/+state';
import { LoadAllUsersAction, UsersFacade } from '../users/+state';
import { Ticket, User } from '../ticket';

import { TicketAction, TicketActionTypes, TicketSelectedAction } from './tickets.actions';
import { TicketsQuery } from './tickets.reducers';
import {
  FilterTicketsAction,
  LoadAllTicketsAction,
  SaveTicketAction,
  AssignUserAction,
  CompleteTicketAction,
  TicketsLoadedAction,
  TicketSavedAction
} from './tickets.actions';

@Injectable()
export class TicketsFacade {
  /**
   * Public properties to centralize and expose store queries
   */
  users$           = this.users.allUsers$;
  filteredTickets$ = this.store.select(TicketsQuery.getTickets);
  selectedTicket$  = this.store.select(TicketsQuery.getSelectedTicket);

  constructor(
    private actions$: Actions,
    private store: Store<ApplicationState>,
    private users: UsersFacade,
    private backend: BackendService,
    private route: ActivatedRoute) {

    makeTicketID$(route).subscribe(ticketId => {
       this.select(ticketId);
    });
  }

  // ***************************************************************
  // Public API (normally seen in `ticket.service.ts`)
  //
  // Except these do not return anything... this only dispatch store actions.
  // Use the public observables above to watch for results/changes
  //
  // ***************************************************************

  loadAll()               { this.store.dispatch(new LoadAllTicketsAction());          }
  select(ticketId:string) { this.store.dispatch(new TicketSelectedAction(ticketId));  }
  add(title:string)       { this.store.dispatch(new SaveTicketAction({title}));       }
  close(ticket:Ticket)    { this.store.dispatch(new CompleteTicketAction(ticket));    }
  save(ticket:Ticket)     { this.store.dispatch(new SaveTicketAction(ticket));        }
  assign(ticket:Ticket)   { this.store.dispatch(new AssignUserAction(ticket));        }

  // ***************************************************************
  // Public API
  // Only one that returns the filteredList (that will be async populated)
  // ***************************************************************

  getTickets(): Observable<Ticket[]> {
    this.loadAll();
    return this.filteredTickets$;
  }

  // ***************************************************************
  // Private Queries
  // ***************************************************************

  private loaded$ = this.store.select(TicketsQuery.getLoaded);
  private allTickets$ = this.store.select(TicketsQuery.getAllTickets);

  // ***************************************************************
  // Effect Models
  //
  // These are run in the 'background' and are never exposed/used
  // by the view components
  //
  // ***************************************************************

  @Effect()
  autoLoadAllEffect$ = this.actions$
    .ofType(ROOT_EFFECTS_INIT)
    .pipe(
      mergeMap(_ => [
        new LoadAllUsersAction(),
        new LoadAllTicketsAction()
      ])
    );

  @Effect()
  loadAllEffect$ = this.actions$
    .ofType(TicketActionTypes.LOADALL)
    .pipe(
      withLatestFrom(this.loaded$),
      switchMap(([_, loaded]) => {
        return loaded ? of(null) : this.backend.tickets();
      }),
      map((tickets: Ticket[] | null) => {
        if (tickets) {
          tickets = this.users.updateWithAvatars(tickets);
          return new TicketsLoadedAction(tickets);
        }
        return new NoopAction();
      })
    );

  @Effect()
  saveEffect$ = this.actions$
    .ofType(TicketActionTypes.SAVE)
    .pipe(
      map(toTicket),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  completeEffect$ = this.actions$
    .ofType(TicketActionTypes.COMPLETE)
    .pipe(
      map(toTicket),
      switchMap(ticket => this.backend.complete(ticket.id, true)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  addNewEffect$ = this.actions$
    .ofType(TicketActionTypes.CREATE)
    .pipe(
      map(toTicket),
      switchMap(ticket => this.backend.newTicket(ticket)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  assignEffect$ = this.actions$
    .ofType(TicketActionTypes.ASSIGN)
    .pipe(
      map(toTicket),
      switchMap(({ id, assigneeId }) => this.backend.assign(id, assigneeId)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

}

const toTicket = (action: TicketAction): Ticket => action.data as Ticket;
const ofType = (type: string) => (action): boolean => action.type == type;

/**
 * For the current route and for future route changes, prepare an Observable to the route
 * params ticket 'id'
 */
const makeTicketID$ = ( route: ActivatedRoute ):Observable<string> => {
  const current$ = of(route.snapshot.paramMap.get('id'));
  const future$ = route.params.pipe( map( params => params['id']));

  return current$.pipe(merge( future$ ));
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment