// 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 * as selectors 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'; import * as utils from '@app/degree-planner/shared/utils'; // Models import { DegreePlan } from '@app/core/models/degree-plan'; import { PlannedTerm, PlannedTermEra } 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'; import { YearMapping } from '@app/core/models/year'; import { Note } from '@app/core/models/note'; import { CourseBase } from '@app/core/models/course'; import { pickTermEra } from '@app/degree-planner/shared/utils'; @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(), subjectDescriptions: this.api.getAllSubjectDescriptions(), activeTermCodes, }); }), // Load data specific to the primary degree plan. flatMap( ({ allDegreePlans, subjects, subjectDescriptions, activeTermCodes }) => { const savedForLaterCourses = this.loadSavedForLaterCourses(subjects); const visibleDegreePlan = pickPrimaryDegreePlan(allDegreePlans); const visibleYears = loadPlanYears( this.api, visibleDegreePlan.roadmapId, subjects, activeTermCodes, ); const descriptions = {}; subjectDescriptions['0000'].map(subject => { descriptions[subject.subjectCode] = subject.formalDescription; }); return forkJoinWithKeys({ visibleDegreePlan: of(visibleDegreePlan), visibleYears, savedForLaterCourses, activeTermCodes: of(activeTermCodes), allDegreePlans: of(allDegreePlans), subjects: of(subjects), expandedYears: of([] as string[]), subjectDescriptions: of(descriptions), }); }, ), // map(payload => { // const allTerms = payload.visibleYears.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 => { return new InitialLoadSuccess({ ...INITIAL_DEGREE_PLANNER_STATE, ...payload, isLoadingPlan: false, }); }), 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(selectors.selectAllDegreePlans)), withLatestFrom(this.store$.select(selectors.selectSubjects)), withLatestFrom(this.store$.select(selectors.selectActiveTermCodes)), flatMap(([[[action, allDegreePlans], subjects], activeTermCodes]) => { const visibleDegreePlan = allDegreePlans.find(plan => { return plan.roadmapId === action.payload.newVisibleRoadmapId; }) as DegreePlan; const visibleYears = loadPlanYears( this.api, visibleDegreePlan.roadmapId, subjects, activeTermCodes, ); return forkJoinWithKeys({ visibleDegreePlan: of(visibleDegreePlan), visibleYears, }); }), 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(selectors.selectVisibleDegreePlan)), filter(([_, visibleDegreePlan]) => visibleDegreePlan !== undefined), // Get term data for the degree plan specified by the roadmap ID. flatMap(([_action, visibleDegreePlan]) => { const { roadmapId, name } = 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(() => { 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(selectors.selectAllDegreePlans)), flatMap(([action, allDegreePlans]) => { const { roadmapId, newName } = action.payload; const oldDegreePlan = 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 toYearCode = (termCode: string) => { return termCode.substr(0, 3); }; const buildTerm = ( yearCode: string, offset: string, notes: Note[], subjects: SubjectMapping, courses: { termCode: string; courses: CourseBase[] }[], activeTermCodes: string[], ): PlannedTerm => { const termCode = yearCode + offset; const note = notes.find(matchesTermCode(termCode)); const group = courses.find(matchesTermCode(termCode)); const era = pickTermEra(termCode, activeTermCodes); return { termCode, era, note, courses: (group ? group.courses : []).map(course => { return { ...course, termCode, subject: subjects[course.subjectCode] }; }), }; }; const loadPlanYears = ( api: DegreePlannerApiService, roadmapId: number, subjects: SubjectMapping, activeTermCodes: string[], ): Observable<YearMapping> => { const notesAndCourses$ = forkJoinWithKeys({ notes: api.getAllNotes(roadmapId), courses: api.getAllTermCourses(roadmapId), }); const uniqueYearCodes$ = notesAndCourses$.pipe( map(({ notes, courses }) => { const noteTermCodes = notes.map(note => note.termCode); const courseTermCodes = courses.map(course => course.termCode); const allTermCodes = [ ...noteTermCodes, ...courseTermCodes, ...activeTermCodes, ]; const uniqueYearCodes = unique(allTermCodes.map(toYearCode)).sort(); return { uniqueYearCodes, notes, courses, }; }), ); const visibleYears$ = uniqueYearCodes$.pipe( map(({ uniqueYearCodes, notes, courses }) => { const mapping: YearMapping = {}; uniqueYearCodes.forEach(yearCode => { mapping[yearCode] = { yearCode, isExpanded: utils.pickYearEra(yearCode, activeTermCodes) !== 'past', fall: buildTerm( yearCode, '2', notes, subjects, courses, activeTermCodes, ), spring: buildTerm( yearCode, '4', notes, subjects, courses, activeTermCodes, ), summer: buildTerm( yearCode, '6', notes, subjects, courses, activeTermCodes, ), }; }); return mapping; }), ); return visibleYears$; }; 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; };