// Libraries import { Injectable } from '@angular/core'; import { ROOT_EFFECTS_INIT, Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, forkJoin, of } from 'rxjs'; import { tap, map, flatMap, withLatestFrom, catchError, filter, } from 'rxjs/operators'; import { GlobalState } from '@app/core/state'; import { Store, Action } from '@ngrx/store'; import { MatSnackBar } from '@angular/material'; // Services import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; import { getDegreePlannerState } from '@app/degree-planner/store/selectors'; // Actions import { InitialLoadSuccess, SwitchPlan, SwitchPlanSuccess, PlanActionTypes, PlanError, MakePlanPrimary, MakePlanPrimarySuccess, MakePlanPrimaryFailure, ChangePlanName, ChangePlanNameSuccess, ChangePlanNameFailure, CreatePlan, CreatePlanSuccess, DeletePlan, DeletePlanSuccess, } from '@app/degree-planner/store/actions/plan.actions'; // Models import { DegreePlan } from '@app/core/models/degree-plan'; import { PlannedTerm } from '@app/core/models/planned-term'; import { SubjectMapping } from '@app/core/models/course'; import { SavedForLaterCourse } from '@app/core/models/saved-for-later-course'; import { DegreePlannerState, INITIAL_DEGREE_PLANNER_STATE, } from '@app/degree-planner/store/state'; @Injectable() export class DegreePlanEffects { constructor( private actions$: Actions, private api: DegreePlannerApiService, private store$: Store<GlobalState>, private snackBar: MatSnackBar, ) {} @Effect() init$ = this.actions$.pipe( ofType(ROOT_EFFECTS_INIT), // Load the list of degree plans and data used by all degree plans. flatMap(() => { const activeTermCodes = this.api .getActiveTerms() .pipe(map(terms => terms.map(term => term.termCode))); return forkJoinWithKeys({ allDegreePlans: this.api.getAllDegreePlans(), subjects: this.api.getAllSubjects(), activeTermCodes, }); }), // Load data specific to the primary degree plan. flatMap(({ allDegreePlans, subjects, activeTermCodes }) => { const savedForLaterCourses = this.loadSavedForLaterCourses(subjects); const visibleDegreePlan = pickPrimaryDegreePlan(allDegreePlans); const visibleTerms = loadPlanTerms( this.api, visibleDegreePlan, subjects, activeTermCodes, ); return forkJoinWithKeys({ visibleDegreePlan: of(visibleDegreePlan), visibleTerms, savedForLaterCourses, activeTermCodes: of(activeTermCodes), allDegreePlans: of(allDegreePlans), subjects: of(subjects), }); }), map(payload => { const allTerms = payload.visibleTerms.map(term => term.termCode); const currentIndex = allTerms.indexOf(payload.activeTermCodes[0]); const expandedTerms = allTerms.slice(currentIndex - allTerms.length); const expandedYearsDups = expandedTerms.map(term => term.substr(1, 2)); const expandedYears = expandedYearsDups.filter(function(item, pos, self) { return self.indexOf(item) === pos; }); return { ...payload, expandedYears }; }), map( payload => new InitialLoadSuccess({ ...INITIAL_DEGREE_PLANNER_STATE, ...payload }), ), catchError(error => { return of( new PlanError({ message: 'Something went wrong', duration: 2000, error, }), ); }), ); @Effect() switch$ = this.actions$.pipe( ofType<SwitchPlan>(PlanActionTypes.SwitchPlan), withLatestFrom(this.store$.select(getDegreePlannerState)), flatMap(([action, state]) => { const visibleDegreePlan = state.allDegreePlans.find(plan => { return plan.roadmapId === action.payload.newVisibleRoadmapId; }) as DegreePlan; const visibleTerms = loadPlanTerms( this.api, visibleDegreePlan, state.subjects, state.activeTermCodes, ); return forkJoinWithKeys({ visibleDegreePlan: of(visibleDegreePlan), visibleTerms, }); }), map(payload => new SwitchPlanSuccess(payload)), tap(state => { const touchedPlan = state.payload.visibleDegreePlan.name; const message = `Switched to ${touchedPlan}`; this.snackBar.open(message, undefined, { duration: 2000 }); }), catchError(error => { return of( new PlanError({ message: 'Unable to switch plan', duration: 2000, error, }), ); }), ); @Effect() MakePlanPrimary$ = this.actions$.pipe( ofType<MakePlanPrimary>(PlanActionTypes.MakePlanPrimary), withLatestFrom(this.store$.select(getDegreePlannerState)), filter(([_, state]) => state.visibleDegreePlan !== undefined), // Get term data for the degree plan specified by the roadmap ID. flatMap(([_action, state]) => { const { roadmapId, name } = state.visibleDegreePlan as DegreePlan; return this.api.updatePlan(roadmapId, name, true); }), // // Wrap data in an Action for dispatch map(response => { if (response === 1) { return new MakePlanPrimarySuccess(); } else { return new MakePlanPrimaryFailure(); } }), tap(state => { const message = 'This plan has been set as the primary plan'; this.snackBar.open(message, undefined, { duration: 2000 }); }), catchError(error => { return of( new PlanError({ message: 'Unable to make this plan primary', duration: 2000, error, }), ); }), ); @Effect() ChangePlanName$ = this.actions$.pipe( ofType<ChangePlanName>(PlanActionTypes.ChangePlanName), withLatestFrom(this.store$.select(getDegreePlannerState)), flatMap(([action, state]) => { const { roadmapId, newName } = action.payload; const oldDegreePlan = state.allDegreePlans.find(plan => { return plan.roadmapId === roadmapId; }) as DegreePlan; const oldName = oldDegreePlan.name; return this.api .updatePlan(roadmapId, newName, oldDegreePlan.primary) .pipe( map(() => { return new ChangePlanNameSuccess({ roadmapId, newName }); }), tap(() => { const message = `Plan has been renamed to ${newName}`; this.snackBar.open(message, undefined, { duration: 2000 }); }), catchError(() => { return of(new ChangePlanNameFailure({ roadmapId, oldName })); }), ); }), ); @Effect() createPlan$ = this.actions$.pipe( ofType<CreatePlan>(PlanActionTypes.CreatePlan), flatMap(action => { const { name, primary } = action.payload; return this.api.createDegreePlan(name, primary).pipe( map(newPlan => new CreatePlanSuccess({ newPlan })), tap(() => { const message = `New plan has been created`; this.snackBar.open(message, undefined, { duration: 2000 }); }), catchError(error => { return of( new PlanError({ message: 'Unable to create new plan', duration: 2000, error, }), ); }), ); }), ); @Effect() deletePlan$ = this.actions$.pipe( ofType<DeletePlan>(PlanActionTypes.DeletePlan), flatMap(action => { const { roadmapId } = action.payload; return this.api.deleteDegreePlan(roadmapId).pipe( map(() => new DeletePlanSuccess({ roadmapId })), catchError(error => { return of( new PlanError({ message: 'Unable to delete plan', duration: 2000, error, }), ); }), ); }), ); private loadSavedForLaterCourses(subjects: SubjectMapping) { return this.api.getSavedForLaterCourses().pipe( map(courseBases => { return courseBases.map<SavedForLaterCourse>(base => { return { ...base, subject: subjects[base.subjectCode] as string, }; }); }), ); } } const loadPlanTerms = ( api: DegreePlannerApiService, visibleDegreePlan: DegreePlan, subjects: SubjectMapping, activeTermCodes: string[], ): Observable<PlannedTerm[]> => { const notesAndTerms$ = forkJoinWithKeys({ notes: api.getAllNotes(visibleDegreePlan.roadmapId), terms: api.getAllTermCourses(visibleDegreePlan.roadmapId), }); const uniqueTerms$ = notesAndTerms$.pipe( map(({ notes, terms }) => { const noteTermCodes = notes.map(note => note.termCode); const courseTermCodes = terms.map(term => term.termCode); const uniqueTermCodes = unique([ ...noteTermCodes, ...courseTermCodes, ...activeTermCodes, ]); return uniqueTermCodes.sort(); }), ); const visibleTerms$ = forkJoin(uniqueTerms$, notesAndTerms$).pipe( map(([uniqueTerms, { notes, terms }]) => { return uniqueTerms.map(termCode => { const note = notes.find(matchesTermCode(termCode)); const term = terms.find(matchesTermCode(termCode)); const courses = (term ? term.courses : []).map(course => ({ ...course, subject: subjects[course.subjectCode], })); return { termCode, note, courses } as PlannedTerm; }); }), ); return visibleTerms$; }; type SimpleMap = { [name: string]: any }; type ObservableMap<T = SimpleMap> = { [K in keyof T]: Observable<T[K]> }; const forkJoinWithKeys = <T = SimpleMap>(pairs: ObservableMap<T>) => { const keys = Object.keys(pairs); const observables = keys.map(key => pairs[key]); return forkJoin(observables).pipe( map<any[], T>(values => { const valueMapping = {} as T; keys.forEach((key, index) => { valueMapping[key] = values[index]; }); return valueMapping; }), ); }; const unique = <T>(things: T[]): T[] => { return things.filter((thing, index, all) => all.indexOf(thing) === index); }; const matchesTermCode = (termCode: string) => (thing: { termCode: string }) => { return thing.termCode === termCode; }; const pickPrimaryDegreePlan = (plans: DegreePlan[]): DegreePlan => { const primary = plans.find(plan => plan.primary); return primary ? primary : plans[0]; }; const checkExpanded = (activeTermCodes, visibleTerms) => { console.log(visibleTerms); }; const hasVisibleDegreePlan = <T extends Action>( pair: [T, DegreePlannerState], ): pair is [T, { visibleDegreePlan: DegreePlan } & DegreePlannerState] => { return pair[1].visibleDegreePlan !== undefined; };