Skip to content

Reactive Workshop Steps

Lukas Ruebbelke edited this page Oct 24, 2018 · 2 revisions

Step Zero: Helper Methods

import { Project } from './../../projects/project.model';

const initialProjects: Project[] = [
  {
    id: '1',
    title: 'Project One',
    details: 'This is a sample project',
    percentComplete: 20,
    approved: false,
    customerId: null
  },
  {
    id: '2',
    title: 'Project Two',
    details: 'This is a sample project',
    percentComplete: 40,
    approved: false,
    customerId: null
  },
  {
    id: '3',
    title: 'Project Three',
    details: 'This is a sample project',
    percentComplete: 100,
    approved: true,
    customerId: null
  }
];

const createProject = (projects, project) => [...projects, project];
const updateProject = (projects, project) => projects.map(p => {
  return p.id === project.id ? Object.assign({}, project) : p;
});
const deleteProject = (projects, project) => projects.filter(w => project.id !== w.id);

Step One: Groundwork

// Reducer
export interface ProjectsState {
  selectedProjectId: string | null;
  projects: Project[];
}

export const initialState: ProjectsState = {
  selectedProjectId: null,
  projects: initialProjects
};

export function projectsReducer(
  state = initialState,
  action: Action
): ProjectsState {
  switch (action.type) {
    default:
      return state;
  }
}
// state/index.ts
import * as fromCustomers from './customers/customers.reducer';
import * as fromProjects from './projects/projects.reducer';

export interface AppState {
  customers: fromCustomers.CustomersState,
  projects: fromProjects.ProjectsState
}

export const reducers: ActionReducerMap<AppState> = {
  customers: fromCustomers.customersReducer,
  projects: fromProjects.projectsReducer
};
// common-data/index.ts
export { ProjectsState } from './lib/state/projects/projects.reducer';
// Component
import { Component, OnInit } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { Project, ProjectsService, ProjectsState } from '@workshop/core-data';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export class ProjectsComponent implements OnInit {
  projects$: Observable<Project[]>;
  projects: Project[];
  currentProject: Project;

  constructor(
    private projectsService: ProjectsService,
    private store: Store<ProjectsState>
  ) {
    this.projects$ = store.pipe(
      select('projects'),
      map((state: ProjectsState) => state.projects)
    );
  }
}

Step Two: Reducers

// Reducer
export function projectsReducer(
  state = initialState,
  action: Action
): ProjectsState {
  switch (action.type) {
    case 'select':
      return {
        selectedProjectId: action.payload.id,
        projects: state.projects
      }
    case 'create':
      return {
        selectedProjectId: state.selectedProjectId,
        projects: createProject(state.projects, action.payload)
      }
    case 'update':
      return {
        selectedProjectId: state.selectedProjectId,
        projects: updateProject(state.projects, action.payload)
      }
    case 'delete':
      return {
        selectedProjectId: state.selectedProjectId,
        projects: deleteProject(state.projects, action.payload)
      }
    default:
      return state;
  }
}
// Component
export class ProjectsComponent implements OnInit {
  projects$: Observable<Project[]>;
  currentProject: Project;

  constructor(
    private store: Store<ProjectsState>
  ) {
    this.projects$ = store.pipe(
      select('projects'),
      map((state: ProjectsState) => state.projects)
    );
  }

  ngOnInit() {
    this.resetCurrentProject();
  }

  resetCurrentProject() {
    this.currentProject = { id: null, name: '', price: 0, description: '' };
  }

  selectProject(project) {
    this.currentProject = project;
  }

  reset(project) {
    this.resetCurrentProject();
  }

  getProjects() {
    // Pending
  }

  saveProject(project) {
    if (!project.id) {
      this.createProject(project);
    } else {
      this.updateProject(project);
    }
  }

  createProject(project) {
    this.store.dispatch({type: 'create', payload: project});
    this.resetCurrentProject();
  }

  updateProject(project) {
    this.store.dispatch({type: 'update', payload: project});
    this.resetCurrentProject();
  }

  deleteProject(project) {
    this.store.dispatch({type: 'delete', payload: project});
    this.resetCurrentProject();
  }
}

Step Three: Actions

// Actions
import { Project } from '@workshop/core-data';
import { Action } from '@ngrx/store';

export enum ProjectsActionTypes {
  ProjectSelected = '[Projects] Selected',
  AddProject = '[Projects] Add Data',
  UpdateProject = '[Projects] Update Data',
  DeleteProject = '[Projects] Delete Data'
}

export class SelectProject implements Action {
  readonly type = ProjectsActionTypes.ProjectSelected;
  constructor(public payload) {}
}

export class AddProject implements Action {
  readonly type = ProjectsActionTypes.AddProject;
  constructor(public payload: Project) {}
}

export class UpdateProject implements Action {
  readonly type = ProjectsActionTypes.UpdateProject;
  constructor(public payload: Project) {}
}

export class DeleteProject implements Action {
  readonly type = ProjectsActionTypes.DeleteProject;
  constructor(public payload: Project) {}
}

export type ProjectsActions = SelectProject
  | AddProject
  | UpdateProject
  | DeleteProject
;
// common-data/index.ts
export { SelectProject, AddProject, UpdateProject, DeleteProject } from './lib/state/projects/projects.actions';
// Reducer
export function projectsReducer(
  state = initialState,
  action: ProjectsActions
): ProjectsState {
  switch (action.type) {
    case ProjectsActionTypes.ProjectSelected:
      return {
        selectedProjectId: action.payload,
        projects: state.projects
      }
    case ProjectsActionTypes.AddProject:
      return {
        selectedProjectId: state.selectedProjectId,
        projects: createProject(state.projects, action.payload)
      }
    case ProjectsActionTypes.UpdateProject:
      return {
        selectedProjectId: state.selectedProjectId,
        projects: updateProject(state.projects, action.payload)
      }
    case ProjectsActionTypes.DeleteProject:
      return {
        selectedProjectId: state.selectedProjectId,
        projects: deleteProject(state.projects, action.payload)
      }
    default:
      return state;
  }
}
// Component
export class ProjectsComponent implements OnInit {
  // ...

  createProject(project) {
    this.store.dispatch(new AddProject(project));
    this.resetCurrentProject();
  }

  updateProject(project) {
    this.store.dispatch(new UpdateProject(project));
    this.resetCurrentProject();
  }

  deleteProject(project) {
    this.store.dispatch(new DeleteProject(project));
    this.resetCurrentProject();
  }
}

Step Four: Entity

// Actions
export enum ProjectsActionTypes {
  ProjectSelected = '[Projects] Selected',
  LoadProjects = '[Projects] Load Data',
  AddProject = '[Projects] Add Data',
  UpdateProject = '[Projects] Update Data',
  DeleteProject = '[Projects] Delete Data'
}

export class SelectProject implements Action {
  readonly type = ProjectsActionTypes.ProjectSelected;
  constructor(public payload) {}
}

export type ProjectsActions = SelectProject
| LoadProjects
| AddProject
| UpdateProject
| DeleteProject;
// Reducer
export interface ProjectsState extends EntityState<Project> {
  selectedProjectId: string | null;
}
export const adapter: EntityAdapter<Project> = createEntityAdapter<Project>();
export const initialState: ProjectsState = adapter.getInitialState({
  selectedProjectId: null
});

export function projectsReducer(
  state = initialState,
  action: ProjectsActions
): ProjectsState {
  switch (action.type) {
    case ProjectsActionTypes.ProjectSelected:
      return Object.assign({}, state, { selectedProjectId: action.payload});
    case ProjectsActionTypes.LoadProjects:
      return adapter.addAll(action.payload, state);
    case ProjectsActionTypes.AddProject:
      return adapter.addOne(action.payload, state);
    case ProjectsActionTypes.UpdateProject:
      return adapter.upsertOne(action.payload, state);
    case ProjectsActionTypes.DeleteProject:
      return adapter.removeOne(action.payload.id, state);
    default:
      return state;
  }
}
// common-data/index.ts
export { initialProjects, ProjectsState } from './lib/state/projects/projects.reducer';
// Component
export class ProjectsComponent implements OnInit {
  projects$: Observable<Project[]>;
  currentProject: Project;

  constructor(
    private store: Store<ProjectsState>
  ) {
    this.projects$ = store.pipe(
      select('projects'),
      map(data => data.entities),
      map(data => Object.keys(data).map(k => data[k]))
    );
  }

  ngOnInit() {
    this.getProjects();
    this.resetCurrentProject();
  }

  //...

  getProjects() {
    this.store.dispatch(new LoadProjects(initialProjects));
  }

  //...
}

Step Five: Selectors

// Reducer 
export const getSelectedProjectId = (state: ProjectsState) => state.selectedProjectId;

// get the selectors
const { selectIds, selectEntities, selectAll } = adapter.getSelectors();

export const selectProjectIds = selectIds;
export const selectProjectEntities = selectEntities;
export const selectAllProjects = selectAll;
// state/index.ts
export const selectProjectsState = createFeatureSelector<fromProjects.ProjectsState>('projects');

export const selectProjectIds = createSelector(
  selectProjectsState,
  fromProjects.selectProjectIds
)

export const selectProjectEntities = createSelector(
  selectProjectsState,
  fromProjects.selectProjectEntities
)

export const selectAllProjects = createSelector(
  selectProjectsState,
  fromProjects.selectAllProjects
)
// common-data/index.ts
export { selectAllProjects } from './lib/state';
// Component
export class ProjectsComponent implements OnInit {

  constructor(
    private store: Store<ProjectsState>
  ) {
    this.projects$ = store.pipe(select(selectAllProjects));
  }

}

Step Six: Effects

// HELPFUL SNIPPET
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { DataPersistence } from '@nrwl/nx';
import { map } from 'rxjs/operators';

import { Project } from './../../core/projects/project.model';
import { ProjectsService } from './../../core/projects/projects.service';
import { ProjectsActionTypes } from './projects.actions';
import { ProjectsState } from './projects.reducer';

@Injectable({providedIn: 'root'})
export class ProjectsEffects {

  constructor(
    private actions$: Actions,
    private dataPersistence: DataPersistence<ProjectsState>,
    private projectsService: ProjectsService
  ) {}
}
// Actions
export enum ProjectsActionTypes {
  ProjectSelected = '[Projects] Selected',
  LoadProjects = '[Projects] Load Data',
  ProjectsLoaded = '[Projects] Data Loaded',
  AddProject = '[Projects] Add Data',
  ProjectAdded = '[Projects] Data Added',
  UpdateProject = '[Projects] Update Data',
  DeleteProject = '[Projects] Delete Data'
}

export class ProjectsLoaded implements Action {
  readonly type = ProjectsActionTypes.ProjectsLoaded;
  constructor(public payload: Project[]) {}
}

export class ProjectAdded implements Action {
  readonly type = ProjectsActionTypes.ProjectAdded;
  constructor(public payload: Project) {}
}

export type ProjectsActions = SelectProject
  | LoadProjects
  | ProjectsLoaded
  | AddProject
  | ProjectAdded
  | UpdateProject
  | DeleteProject;
// Effects
export class ProjectsEffects {
  @Effect()
  loadProjects$ = this.dataPersistence.fetch(ProjectsActionTypes.LoadProjects, {
    run: (action: LoadProjects, state: ProjectsState) => {
      return this.projectsService.all().pipe(map((res: Project[]) => new ProjectsLoaded(res)))
    },
    onError: (action: LoadProjects, error) => {
      console.error('Error', error);
    }
  });

  @Effect()
  addProject$ = this.dataPersistence.pessimisticUpdate(ProjectsActionTypes.AddProject, {
    run: (action: AddProject, state: ProjectsState) => {
      return this.projectsService.create(action.payload).pipe(map((res: Project) => new ProjectAdded(res)))
    },
    onError: (action: AddProject, error) => {
      console.error('Error', error);
    }
  });

  constructor(
    private actions$: Actions,
    private dataPersistence: DataPersistence<ProjectsState>,
    private projectsService: ProjectsService
  ) {}
}  
// Module
@NgModule({
  imports: [
    CommonModule,
    NxModule.forRoot(),
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument({ maxAge: 5 }),
    EffectsModule.forRoot([CustomersEffects, ProjectsEffects]),
  ],
  declarations: []
})
// Reducer
export function projectsReducer(
  state = initialState,
  action: ProjectsActions
): ProjectsState {
  switch (action.type) {
    //...
    case ProjectsActionTypes.ProjectsLoaded:
      return adapter.addAll(action.payload, state);
    case ProjectsActionTypes.ProjectAdded:
      return adapter.addOne(action.payload, state);
  //...
    default:
      return state;
  }
}
// Components
getProjects() {
  this.store.dispatch(new LoadProjects());
}

Completed Actions

// Actions
import { Project } from '@workshop/core-data';
import { Action } from '@ngrx/store';

export enum ProjectsActionTypes {
  ProjectSelected = '[Projects] Selected',
  LoadProjects = '[Projects] Load Data',
  ProjectsLoaded = '[Projects] Data Loaded',
  AddProject = '[Projects] Add Data',
  ProjectAdded = '[Projects] Data Added',
  UpdateProject = '[Projects] Update Data',
  ProjectUpdated = '[Projects] Data Updated',
  DeleteProject = '[Projects] Delete Data',
  ProjectDeleted = '[Projects] Delete Data'
}

export class SelectProject implements Action {
  readonly type = ProjectsActionTypes.ProjectSelected;
  constructor(public payload) { }
}

export class LoadProjects implements Action {
  readonly type = ProjectsActionTypes.LoadProjects;
}

export class ProjectsLoaded implements Action {
  readonly type = ProjectsActionTypes.ProjectsLoaded;
  constructor(public payload: Project[]) { }
}

export class AddProject implements Action {
  readonly type = ProjectsActionTypes.AddProject;
  constructor(public payload: Project) { }
}

export class ProjectAdded implements Action {
  readonly type = ProjectsActionTypes.ProjectAdded;
  constructor(public payload: Project) { }
}

export class UpdateProject implements Action {
  readonly type = ProjectsActionTypes.UpdateProject;
  constructor(public payload: Project) { }
}

export class ProjectUpdated implements Action {
  readonly type = ProjectsActionTypes.ProjectUpdated;
  constructor(public payload: Project) { }
}

export class DeleteProject implements Action {
  readonly type = ProjectsActionTypes.DeleteProject;
  constructor(public payload: Project) { }
}

export class ProjectDeleted implements Action {
  readonly type = ProjectsActionTypes.ProjectDeleted;
  constructor(public payload: Project) { }
}

export type ProjectsActions = SelectProject
  | LoadProjects
  | ProjectsLoaded
  | AddProject
  | ProjectAdded
  | UpdateProject
  | ProjectUpdated
  | DeleteProject
  | ProjectDeleted
;

Completed Effects

// Effects
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { DataPersistence } from '@nrwl/nx';
import { map } from 'rxjs/operators';

import { Project } from './../../core/projects/project.model';
import { ProjectsService } from './../../core/projects/projects.service';
import {
  AddProject,
  DeleteProject,
  LoadProjects,
  UpdateProject,
  ProjectAdded,
  ProjectDeleted,
  ProjectsActionTypes,
  ProjectsLoaded,
  ProjectUpdated,
} from './projects.actions';
import { ProjectsState } from './projects.reducer';

@Injectable({providedIn: 'root'})
export class ProjectsEffects {
  @Effect()
  loadProjects$ = this.dataPersistence.fetch(ProjectsActionTypes.LoadProjects, {
    run: (action: LoadProjects, state: ProjectsState) => {
      return this.projectsService.all().pipe(map((res: Project[]) => new ProjectsLoaded(res)))
    },
    onError: (action: LoadProjects, error) => {
      console.error('Error', error);
    }
  });

  @Effect()
  addProject$ = this.dataPersistence.pessimisticUpdate(ProjectsActionTypes.AddProject, {
    run: (action: AddProject, state: ProjectsState) => {
      return this.projectsService.create(action.payload).pipe(map((res: Project) => new ProjectAdded(res)))
    },
    onError: (action: AddProject, error) => {
      console.error('Error', error);
    }
  });

  @Effect()
  updateProject$ = this.dataPersistence.pessimisticUpdate(ProjectsActionTypes.UpdateProject, {
    run: (action: UpdateProject, state: ProjectsState) => {
      return this.projectsService.update(action.payload).pipe(map((res: Project) => new ProjectUpdated(res)))
    },
    onError: (action: UpdateProject, error) => {
      console.error('Error', error);
    }
  });

  @Effect()
  deleteProject$ = this.dataPersistence.pessimisticUpdate(ProjectsActionTypes.DeleteProject, {
    run: (action: DeleteProject, state: ProjectsState) => {
      return this.projectsService.delete(action.payload).pipe(map((res: Project) => new ProjectDeleted(res)))
    },
    onError: (action: DeleteProject, error) => {
      console.error('Error', error);
    }
  });

  constructor(
    private actions$: Actions,
    private dataPersistence: DataPersistence<ProjectsState>,
    private projectsService: ProjectsService
  ) {}
}

Solution Seven: Computed Data

// state/index.ts
export const selectCurrentProjectId = createSelector(
  selectProjectsState,
  fromProjects.getSelectedProjectId
);

export const selectCurrentProject = createSelector(
  selectProjectEntities,
  selectCurrentProjectId,
  (projectEntities, projectId) => {
    return projectId ? projectEntities[projectId] : emptyProject;
  }
);
// common-date/index.ts
export { selectAllProjects, selectCurrentProject } from './lib/state';
// Component
export class ProjectsComponent implements OnInit {
  projects$: Observable<Project[]>;
  currentProject$: Observable<Project>;

  constructor(
    private store: Store<ProjectsState>
  ) {
    this.projects$ = store.pipe(select(selectAllProjects));
    this.currentProject$ = store.pipe(select(selectCurrentProject));
  }

  ngOnInit() {
    this.getProjects();
    this.resetCurrentProject();
  }

  resetCurrentProject() {
    this.selectProject(null);
  }

  selectProject(project) {
    this.store.dispatch(new SelectProject(project.id));
  }

  //...
}

Solution Eight: Facades

// HELPFUL SNIPPET
import { Injectable } from '@angular/core';
import { ActionsSubject, select, Store } from '@ngrx/store';
import { filter } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class ProjectsFacade {

}
// Facade

import { Injectable } from '@angular/core';
import { select, Store } from '@ngrx/store';

import { selectAllProjects, selectCurrentProject } from './..';
import * as ProjectsActions from './projects.actions';
import { ProjectsState } from './projects.reducer';

export class ProjectsFacade {
  allProjects$ = this.store.pipe(select(selectAllProjects));
  currentProject$ = this.store.pipe(select(selectCurrentProject));

  constructor(private store: Store<ProjectsState>) { }

  selectProject(projectId) {
    this.store.dispatch(new ProjectsActions.SelectProject(projectId));
  }

  loadAll() {
    this.store.dispatch(new ProjectsActions.LoadProjects());
  }

  createProject(project) {
    this.store.dispatch(new ProjectsActions.AddProject(project));
  }

  updateProject(project) {
    this.store.dispatch(new ProjectsActions.UpdateProject(project));
  }

  deleteProject(project) {
    this.store.dispatch(new ProjectsActions.DeleteProject(project));
  }
}
// Component
export class ProjectsComponent implements OnInit {
  projects$: Observable<Project[]>;
  currentProject$: Observable<Project>;

  constructor(
    private facade: ProjectsFacade
  ) {
    this.projects$ = facade.allProjects$
    this.currentProject$ = facade.currentProject$
  }

  ngOnInit() {
    this.getProjects();
    this.resetCurrentProject();
  }

  resetCurrentProject() {
    this.selectProject({id: null});
  }

  selectProject(project) {
    this.facade.selectProject(project.id);
  }

  reset(project) {
    this.resetCurrentProject();
  }

  getProjects() {
    this.facade.loadAll();
  }

  saveProject(project) {
    if (!project.id) {
      this.createProject(project);
    } else {
      this.updateProject(project);
    }
  }

  createProject(project) {
    this.facade.createProject(project);
  }

  updateProject(project) {
    this.facade.updateProject(project);
  }

  deleteProject(project) {
    this.facade.deleteProject(project.id);
  }
}

Reset on Actions

// Facade
export class ProjectsFacade {
  allProjects$ = this.store.pipe(select(selectAllProjects));
  currentProject$ = this.store.pipe(select(selectCurrentProject));
  mutations$ = this.actions$.pipe(
    filter(action =>
      action.type === ProjectsActionTypes.AddProject
      || action.type === ProjectsActionTypes.UpdateProject
      || action.type === ProjectsActionTypes.DeleteProject
    )
  );

  constructor(private store: Store<ProjectsState>, private actions$: ActionsSubject) { }
}
// Component
@Component({
  selector: 'app-projects',
  templateUrl: './projects.component.html',
  styleUrls: ['./projects.component.scss']
})
export class ProjectsComponent implements OnInit {
  customers$: Observable<Customer[]> = this.customersFacade.allCustomers$;
  projects$: Observable<Project[]> = this.projectsFacade.allProjects$;
  currentProject$: Observable<Project> = this.projectsFacade.currentProject$;

  constructor(
    private projectsFacade: ProjectsFacade,
    private customersFacade: CustomersFacade
  ) { }

  ngOnInit() {
    this.customersFacade.loadCustomers();
    this.projectsFacade.loadProjects();
    this.projectsFacade.mutations$.subscribe(_ => this.resetCurrentProject());
    this.resetCurrentProject();
  }

  resetCurrentProject() {
    this.selectProject({id: null});
  }

  selectProject(project) {
    this.projectsFacade.selectProject(project.id);
  }

  saveProject(project) {
    if (!project.id) {
      this.projectsFacade.addProject(project);
    } else {
      this.projectsFacade.updateProject(project);
    }
  }

  deleteProject(project) {
    this.projectsFacade.deleteProject(project);
  }
}