// Libraries import { Injectable } from '@angular/core'; import { ROOT_EFFECTS_INIT, Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, forkJoin, of } from 'rxjs'; import { map, flatMap, withLatestFrom, filter } from 'rxjs/operators'; import { GlobalState } from '@app/core/state'; import { Store } from '@ngrx/store'; // Services import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; import { getDegreePlannerState } from '@app/degree-planner/store/selectors'; // Actions import { InitialPlanLoadResponse, ChangeVisiblePlanRequest, ChangeVisiblePlanResponse, PlanActionTypes, ChangeCourseTermResponse, AddCourseResponse, RemoveCourseResponse, RemoveSavedForLaterResponse, AddSavedForLaterResponse, AddCourseRequest, RemoveCourseRequest, } 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 { Course, SubjectMapping, CourseBase } from '@app/core/models/course'; import { SavedForLaterCourse } from '@app/core/models/saved-for-later-course'; @Injectable() export class DegreePlanEffects { constructor( private actions$: Actions, private api: DegreePlannerApiService, private store$: Store<GlobalState>, ) {} @Effect() init$: Observable<InitialPlanLoadResponse> = 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 => new InitialPlanLoadResponse(payload)), ); @Effect() switch$: Observable<ChangeVisiblePlanResponse> = this.actions$.pipe( ofType<ChangeVisiblePlanRequest>(PlanActionTypes.ChangeVisiblePlanRequest), 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 ChangeVisiblePlanResponse(payload)), ); @Effect() MoveCourseBetweenTerms$ = this.actions$.pipe( ofType<any>(PlanActionTypes.ChangeCourseTermRequest), withLatestFrom(this.store$.select(getDegreePlannerState)), filter(([_, state]) => typeof state.visibleDegreePlan !== undefined), // Get term data for the degree plan specified by the roadmap ID. flatMap(([action, state]) => { // TODO error handle the API calls return this.api .updateCourseTerm( (state.visibleDegreePlan as DegreePlan).roadmapId, action.payload.id, action.payload.to, ) .pipe( map(response => { return { response, action, }; }), ); }), // // Wrap data in an Action for dispatch map(({ response, action }) => { if (response === 1) { return new ChangeCourseTermResponse({ id: action.payload.id, from: action.payload.from, to: action.payload.to, }); } return; }), ); @Effect() AddCourse$ = this.actions$.pipe( ofType<AddCourseRequest>(PlanActionTypes.AddCourseRequest), 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]) => { // TODO error handle the API calls const roadmapId = (state.visibleDegreePlan as DegreePlan).roadmapId; const { subjectCode, termCode, courseId } = action.payload; const addCourse$ = this.api.addCourse( roadmapId, subjectCode, courseId, termCode, ); const courseBaseToCourse$ = addCourse$.pipe( map<CourseBase, Course>(courseBase => ({ ...courseBase, subject: state.subjects[courseBase.subjectCode], })), ); const toSuccessAction$ = courseBaseToCourse$.pipe( map(course => new AddCourseResponse({ course })), ); return toSuccessAction$; }), ); @Effect() RemoveCourse$ = this.actions$.pipe( ofType<RemoveCourseRequest>(PlanActionTypes.RemoveCourseRequest), 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 = (state.visibleDegreePlan as DegreePlan).roadmapId; const recordId = action.payload.recordId; const removeCourse$ = this.api.removeCourse(roadmapId, recordId); const toSuccessAction$ = removeCourse$.pipe( map(() => new RemoveCourseResponse({ recordId })), ); return toSuccessAction$; }), ); @Effect() RemoveSavedForLater$ = this.actions$.pipe( ofType<any>(PlanActionTypes.RemoveSavedForLaterRequest), 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]) => { // TODO error handle the API calls return this.api .removeSavedForLater( action.payload.subjectCode, action.payload.courseId, ) .pipe( map(response => { return { response, action, }; }), ); }), // // Wrap data in an Action for dispatch map(({ response, action }) => { if (response === null) { const { courseId, subjectCode } = action.payload; return new RemoveSavedForLaterResponse({ courseId, subjectCode }); // TODO Update UI and remove saved response } return; }), ); @Effect() SaveForLater$ = this.actions$.pipe( ofType<any>(PlanActionTypes.AddSavedForLaterRequest), 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]) => { // TODO error handle the API calls return this.api .saveForLater(action.payload.subjectCode, action.payload.courseId) .pipe( map(response => { return { response, action, }; }), ); }), // // // Wrap data in an Action for dispatch map(({ response, action }) => { if (response === null) { return new AddSavedForLaterResponse(action.payload); } // return; return; }), ); 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]; };