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 13.78 KiB
// 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;
};