Skip to content
Snippets Groups Projects
plan.effects.ts 13.8 KiB
Newer Older
// Libraries
import { Injectable } from '@angular/core';
import { ROOT_EFFECTS_INIT, Actions, Effect, ofType } from '@ngrx/effects';
Isaac Evavold's avatar
Isaac Evavold committed
import { Observable, forkJoin, of } from 'rxjs';
Scott Berg's avatar
Scott Berg committed
import {
Paulina Nogal's avatar
Paulina Nogal committed
  tap,
Scott Berg's avatar
Scott Berg committed
  map,
  flatMap,
  withLatestFrom,
  catchError,
  filter,
} from 'rxjs/operators';
Isaac Evavold's avatar
Isaac Evavold committed
import { Store, Action } from '@ngrx/store';
Paulina Nogal's avatar
Paulina Nogal committed
import { MatSnackBar } from '@angular/material';

// Services
import { DegreePlannerApiService } from '@app/degree-planner/services/api.service';
Isaac Evavold's avatar
Isaac Evavold committed
import * as selectors from '@app/degree-planner/store/selectors';
Isaac Evavold's avatar
Isaac Evavold committed
  InitialLoadSuccess,
  SwitchPlan,
  SwitchPlanSuccess,
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
  PlanActionTypes,
Isaac Evavold's avatar
Isaac Evavold committed
  PlanError,
Isaac Evavold's avatar
Isaac Evavold committed
  MakePlanPrimary,
Scott Berg's avatar
Scott Berg committed
  MakePlanPrimarySuccess,
  MakePlanPrimaryFailure,
Isaac Evavold's avatar
Isaac Evavold committed
  ChangePlanName,
Scott Berg's avatar
Scott Berg committed
  ChangePlanNameSuccess,
  ChangePlanNameFailure,
Isaac Evavold's avatar
Isaac Evavold committed
  CreatePlan,
  CreatePlanSuccess,
  DeletePlan,
  DeletePlanSuccess,
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
} from '@app/degree-planner/store/actions/plan.actions';
Isaac Evavold's avatar
Isaac Evavold committed
import * as utils from '@app/degree-planner/shared/utils';
import { DegreePlan } from '@app/core/models/degree-plan';
Isaac Evavold's avatar
Isaac Evavold committed
import { PlannedTerm, PlannedTermEra } from '@app/core/models/planned-term';
Isaac Evavold's avatar
Isaac Evavold committed
import { SubjectMapping } from '@app/core/models/course';
Isaac Evavold's avatar
Isaac Evavold committed
import { SavedForLaterCourse } from '@app/core/models/saved-for-later-course';
Scott Berg's avatar
Scott Berg committed
import {
  DegreePlannerState,
  INITIAL_DEGREE_PLANNER_STATE,
} from '@app/degree-planner/store/state';
Isaac Evavold's avatar
Isaac Evavold committed
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 {
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
  constructor(
    private actions$: Actions,
    private api: DegreePlannerApiService,
    private store$: Store<GlobalState>,
Scott Berg's avatar
Scott Berg committed
    private snackBar: MatSnackBar,
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
  ) {}

  @Effect()
Isaac Evavold's avatar
Isaac Evavold committed
  init$ = this.actions$.pipe(
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
    ofType(ROOT_EFFECTS_INIT),
Isaac Evavold's avatar
Isaac Evavold committed
    // Load the list of degree plans and data used by all degree plans.
Isaac Evavold's avatar
Isaac Evavold committed
    flatMap(() => {
Isaac Evavold's avatar
Isaac Evavold committed
      const activeTermCodes = this.api
        .getActiveTerms()
        .pipe(map(terms => terms.map(term => term.termCode)));

      return forkJoinWithKeys({
        allDegreePlans: this.api.getAllDegreePlans(),
        subjects: this.api.getAllSubjects(),
Scott Berg's avatar
Scott Berg committed
        subjectDescriptions: this.api.getAllSubjectDescriptions(),
Isaac Evavold's avatar
Isaac Evavold committed
        activeTermCodes,
      });
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
    }),
Isaac Evavold's avatar
Isaac Evavold committed
    // Load data specific to the primary degree plan.
Scott Berg's avatar
Scott Berg committed
    flatMap(
      ({ allDegreePlans, subjects, subjectDescriptions, activeTermCodes }) => {
        const savedForLaterCourses = this.loadSavedForLaterCourses(subjects);
        const visibleDegreePlan = pickPrimaryDegreePlan(allDegreePlans);
        const visibleYears = loadPlanYears(
          this.api,
          visibleDegreePlan.roadmapId,
          subjects,
          activeTermCodes,
        );
Scott Berg's avatar
Scott Berg committed
        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[]),
          isCourseSearchVisible: of(false),
          subjectDescriptions: of(descriptions),
        });
      },
    ),
Isaac Evavold's avatar
Isaac Evavold committed
    // 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 };
    // }),
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
    map(payload => {
Isaac Evavold's avatar
Isaac Evavold committed
      return new InitialLoadSuccess({
        ...INITIAL_DEGREE_PLANNER_STATE,
        ...payload,
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
      });
    }),
Isaac Evavold's avatar
Isaac Evavold committed
    catchError(error => {
Scott Berg's avatar
Scott Berg committed
      return of(
        new PlanError({
          message: 'Something went wrong',
          duration: 2000,
          error,
        }),
      );
Isaac Evavold's avatar
Isaac Evavold committed
    }),
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed
  );

  @Effect()
Isaac Evavold's avatar
Isaac Evavold committed
  switch$ = this.actions$.pipe(
    ofType<SwitchPlan>(PlanActionTypes.SwitchPlan),
Isaac Evavold's avatar
Isaac Evavold committed
    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 => {
Isaac Evavold's avatar
Isaac Evavold committed
        return plan.roadmapId === action.payload.newVisibleRoadmapId;
      }) as DegreePlan;

Isaac Evavold's avatar
Isaac Evavold committed
      const visibleYears = loadPlanYears(
Isaac Evavold's avatar
Isaac Evavold committed
        this.api,
Isaac Evavold's avatar
Isaac Evavold committed
        visibleDegreePlan.roadmapId,
        subjects,
        activeTermCodes,
Isaac Evavold's avatar
Isaac Evavold committed
      );
Isaac Evavold's avatar
Isaac Evavold committed
      return forkJoinWithKeys({
        visibleDegreePlan: of(visibleDegreePlan),
Isaac Evavold's avatar
Isaac Evavold committed
        visibleYears,
Isaac Evavold's avatar
Isaac Evavold committed
      });
    }),
Isaac Evavold's avatar
Isaac Evavold committed
    map(payload => new SwitchPlanSuccess(payload)),
Scott Berg's avatar
Scott Berg committed
    tap(state => {
Paulina Nogal's avatar
Paulina Nogal committed
      const touchedPlan = state.payload.visibleDegreePlan.name;
      const message = `Switched to ${touchedPlan}`;
Scott Berg's avatar
Scott Berg committed
      this.snackBar.open(message, undefined, { duration: 2000 });
Paulina Nogal's avatar
Paulina Nogal committed
    }),
Isaac Evavold's avatar
Isaac Evavold committed
    catchError(error => {
Scott Berg's avatar
Scott Berg committed
      return of(
        new PlanError({
          message: 'Unable to switch plan',
          duration: 2000,
          error,
        }),
      );
Isaac Evavold's avatar
Isaac Evavold committed
    }),
Scott Berg's avatar
Scott Berg committed
  @Effect()
  MakePlanPrimary$ = this.actions$.pipe(
Isaac Evavold's avatar
Isaac Evavold committed
    ofType<MakePlanPrimary>(PlanActionTypes.MakePlanPrimary),
Isaac Evavold's avatar
Isaac Evavold committed
    withLatestFrom(this.store$.select(selectors.selectVisibleDegreePlan)),
    filter(([_, visibleDegreePlan]) => visibleDegreePlan !== undefined),
Scott Berg's avatar
Scott Berg committed
    // Get term data for the degree plan specified by the roadmap ID.
Isaac Evavold's avatar
Isaac Evavold committed
    flatMap(([_action, visibleDegreePlan]) => {
      const { roadmapId, name } = visibleDegreePlan as DegreePlan;
Isaac Evavold's avatar
Isaac Evavold committed
      return this.api.updatePlan(roadmapId, name, true);
Scott Berg's avatar
Scott Berg committed
    }),
    // // Wrap data in an Action for dispatch
Isaac Evavold's avatar
Isaac Evavold committed
    map(response => {
Scott Berg's avatar
Scott Berg committed
      if (response === 1) {
        return new MakePlanPrimarySuccess();
      } else {
        return new MakePlanPrimaryFailure();
      }
    }),
Isaac Evavold's avatar
Isaac Evavold committed
    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,
        }),
      );
    }),
Scott Berg's avatar
Scott Berg committed
  );

Scott Berg's avatar
Scott Berg committed
  @Effect()
  ChangePlanName$ = this.actions$.pipe(
Isaac Evavold's avatar
Isaac Evavold committed
    ofType<ChangePlanName>(PlanActionTypes.ChangePlanName),
Isaac Evavold's avatar
Isaac Evavold committed
    withLatestFrom(this.store$.select(selectors.selectAllDegreePlans)),
    flatMap(([action, allDegreePlans]) => {
Isaac Evavold's avatar
Isaac Evavold committed
      const { roadmapId, newName } = action.payload;
Isaac Evavold's avatar
Isaac Evavold committed
      const oldDegreePlan = allDegreePlans.find(plan => {
Isaac Evavold's avatar
Isaac Evavold committed
        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}`;
Scott Berg's avatar
Scott Berg committed
            this.snackBar.open(message, undefined, { duration: 2000 });
Isaac Evavold's avatar
Isaac Evavold committed
          catchError(() => {
            return of(new ChangePlanNameFailure({ roadmapId, oldName }));
          }),
        );
Scott Berg's avatar
Scott Berg committed
    }),
Isaac Evavold's avatar
Isaac Evavold committed
  );
Scott Berg's avatar
Scott Berg committed

Isaac Evavold's avatar
Isaac Evavold committed
  @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,
            }),
          );
        }),
      );
Isaac Evavold's avatar
Isaac Evavold committed
    }),
  );

  @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,
            }),
          );
        }),
      );
Scott Berg's avatar
Scott Berg committed
    }),
  );

Isaac Evavold's avatar
Isaac Evavold committed
  private loadSavedForLaterCourses(subjects: SubjectMapping) {
    return this.api.getSavedForLaterCourses().pipe(
      map(courseBases => {
        return courseBases.map<SavedForLaterCourse>(base => {
          return {
            ...base,
            subject: subjects[base.subjectCode] as string,
          };
        });
      }),
    );
  }
Isaac Evavold's avatar
Isaac Evavold committed

Isaac Evavold's avatar
Isaac Evavold committed
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();
    }),
  );
Isaac Evavold's avatar
Isaac Evavold committed
  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;
    }),
  );
};
Isaac Evavold's avatar
Isaac Evavold committed

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;
};
Isaac Evavold's avatar
Isaac Evavold committed
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,
      };
Isaac Evavold's avatar
Isaac Evavold committed
    }),
  );

  const visibleYears$ = uniqueYearCodes$.pipe(
    map(({ uniqueYearCodes, notes, courses }) => {
Isaac Evavold's avatar
Isaac Evavold committed
      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$;
};

Isaac Evavold's avatar
Isaac Evavold committed
const pickPrimaryDegreePlan = (plans: DegreePlan[]): DegreePlan => {
  const primary = plans.find(plan => plan.primary);
  return primary ? primary : plans[0];
};
jvanboxtel@wisc.edu's avatar
jvanboxtel@wisc.edu committed

const checkExpanded = (activeTermCodes, visibleTerms) => {
  console.log(visibleTerms);
};
Isaac Evavold's avatar
Isaac Evavold committed

const hasVisibleDegreePlan = <T extends Action>(
  pair: [T, DegreePlannerState],
): pair is [T, { visibleDegreePlan: DegreePlan } & DegreePlannerState] => {
  return pair[1].visibleDegreePlan !== undefined;
};