Skip to content
Snippets Groups Projects
Forked from an inaccessible project.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
plan.effects.ts 10.36 KiB
// 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];
};