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 14.28 KiB
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 } from '@ngrx/store';
import { MatSnackBar } from '@angular/material';
import { DegreePlannerApiService } from '@app/degree-planner/services/api.service';
import * as selectors from '@app/degree-planner/store/selectors';
import {
  InitialLoadSuccess,
  SwitchPlan,
  SwitchPlanSuccess,
  PlanActionTypes,
  PlanError,
  MakePlanPrimary,
  MakePlanPrimarySuccess,
  MakePlanPrimaryFailure,
  ChangePlanName,
  ChangePlanNameSuccess,
  ChangePlanNameFailure,
  CreatePlan,
  CreatePlanSuccess,
  DeletePlan,
  DeletePlanSuccess,
  ChangeGradeVisibility,
} from '@app/degree-planner/store/actions/plan.actions';
import * as utils from '@app/degree-planner/shared/utils';
import { DegreePlan } from '@app/core/models/degree-plan';
import { PlannedTerm, PlannedTermNote } from '@app/core/models/planned-term';
import { INITIAL_DEGREE_PLANNER_STATE } from '@app/degree-planner/store/state';
import { YearMapping, MutableYearMapping } from '@app/core/models/year';
import { UserPreferences } from '@app/core/models/user-preferences';
import { Note } from '@app/core/models/note';
import { CourseBase, Course } from '@app/core/models/course';
import { pickTermEra } from '@app/degree-planner/shared/utils';
import { TermCode, YearCode } from '@app/core/models/termcode';
import { ConstantsService } from '@app/degree-planner/services/constants.service';
import { Alert } from '@app/core/models/alert';

@Injectable()
export class DegreePlanEffects {
  constructor(
    private actions$: Actions,
    private api: DegreePlannerApiService,
    private store$: Store<GlobalState>,
    private snackBar: MatSnackBar,
    private constants: ConstantsService,
  ) {}

  @Effect()
  init$ = this.actions$.pipe(
    ofType(ROOT_EFFECTS_INIT),
    // Load the list of degree plans and data used by all degree plans.
    flatMap(() => {
      console.log('loading all degree plans');
      return forkJoinWithKeys({
        allDegreePlans: this.api.getAllDegreePlans(),
        userPreferences: this.api.getUserPreferences(),
      });
    }),
    // Load data specific to the primary degree plan.
    flatMap(({ allDegreePlans, userPreferences }) => {
      const savedForLaterCourses = this.api.getSavedForLaterCourses();
      const visibleDegreePlan = userPreferences.degreePlannerSelectedPlan
        ? pickDegreePlanById(
            userPreferences.degreePlannerSelectedPlan,
            allDegreePlans,
          )
        : pickPrimaryDegreePlan(allDegreePlans);
      const visibleYears = loadPlanYears(
        this.api,
        visibleDegreePlan.roadmapId,
        this.constants,
      );

      const alerts: Alert[] = [];

      if (userPreferences.degreePlannerHasDismissedDisclaimer !== true) {
        const key = 'disclaimerAlert';
        alerts.push({
          key,
          title: 'This is a planning tool.',
          message:
            'If you have questions about your plan or degree, please contact your advisor.',
          callback: () => {
            this.api
              .getUserPreferences()
              .toPromise()
              .then(prefs =>
                this.api
                  .updateUserPreferences({
                    ...prefs,
                    degreePlannerHasDismissedDisclaimer: true,
                  })
                  .toPromise(),
              );
          },
        });
      }

      const showGrades =
        userPreferences.degreePlannerGradesVisibility !== undefined
          ? userPreferences.degreePlannerGradesVisibility
          : true;

      return forkJoinWithKeys({
        showGrades: of(showGrades),
        visibleDegreePlan: of(visibleDegreePlan),
        visibleYears,
        savedForLaterCourses,
        allDegreePlans: of(allDegreePlans),
        alerts: of(alerts),
      });
    }),
    map(payload => {
      return new InitialLoadSuccess({
        ...INITIAL_DEGREE_PLANNER_STATE,
        ...payload,
        activeTermCodes: this.constants.activeTermCodes(),
        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)),
    flatMap(([action, allDegreePlans]) => {
      const visibleDegreePlan = allDegreePlans.find(plan => {
        return plan.roadmapId === action.payload.newVisibleRoadmapId;
      }) as DegreePlan;

      const visibleYears = loadPlanYears(
        this.api,
        visibleDegreePlan.roadmapId,
        this.constants,
      );

      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, {});

      // Get the users current preferences and update the selected roadmapId
      this.setUserPreferences({
        degreePlannerSelectedPlan: state.payload.visibleDegreePlan.roadmapId,
      });
    }),
    catchError(error => {
      return of(
        new PlanError({
          message: 'Unable to switch plan',
          duration: 2000,
          error,
        }),
      );
    }),
  );

  @Effect({ dispatch: false })
  gradeVisibility$ = this.actions$.pipe(
    ofType<ChangeGradeVisibility>(PlanActionTypes.ChangeGradeVisibility),
    withLatestFrom(this.store$),
    map(([change, state]) => {
      this.setUserPreferences({
        degreePlannerGradesVisibility: change.visibility,
      });
      return state;
    }),
    catchError(error => {
      return of(
        new PlanError({
          message: 'Unable to change grade visibility',
          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, {});
    }),
    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, {});
          }),
          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(
        flatMap(newPlan => {
          const newYears = loadPlanYears(
            this.api,
            newPlan.roadmapId,
            this.constants,
          );

          return forkJoinWithKeys({
            newPlan: of(newPlan),
            newYears,
          });
        }),
        map(({ newPlan, newYears }) => {
          this.setUserPreferences({
            degreePlannerSelectedPlan: newPlan.roadmapId,
          });
          return new CreatePlanSuccess({ newPlan, newYears });
        }),
        tap(() => {
          const message = `New plan has been created`;
          this.snackBar.open(message, undefined, {});
        }),
        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 })),
        tap(() => {
          const message = `Deleting selected plan`;
          this.snackBar.open(message, undefined, { duration: 10000 });
        }),
        catchError(error => {
          return of(
            new PlanError({
              message: 'Unable to delete plan',
              duration: 2000,
              error,
            }),
          );
        }),
      );
    }),
  );

  private setUserPreferences(changes: UserPreferences) {
    // Get the users current preferences and update the selected roadmapId

    this.api
      .getUserPreferences()
      .toPromise()
      .then(prefs => {
        this.api
          .updateUserPreferences({
            ...prefs,
            ...changes,
          })
          .toPromise();
        // We have to .toPromise this to actually fire the API call
      });
  }
}

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: TermCode) => (thing: {
  termCode: string;
}) => {
  return thing.termCode === termCode.toString();
};

const toYearCode = (termCode: string) => {
  return termCode.substr(0, 3);
};

const buildTerm = (
  roadmapId: number,
  termCode: TermCode,
  notes: ReadonlyArray<Note>,
  courses: ReadonlyArray<{
    termCode: string;
    courses: ReadonlyArray<CourseBase>;
  }>,
  constants: ConstantsService,
): PlannedTerm => {
  const baseNote = notes.find(matchesTermCode(termCode));
  const note: PlannedTermNote | undefined = baseNote
    ? { isLoaded: true, text: baseNote.note, id: baseNote.id }
    : undefined;
  const group = courses.find(matchesTermCode(termCode));
  const formattedCourses = (group ? group.courses : []).map(course => {
    return { ...course, termCode: termCode.toString() };
  });

  const plannedCourses: Course[] = [];
  const enrolledCourses: Course[] = [];

  formattedCourses.forEach(course => {
    if (course.studentEnrollmentStatus === 'Enrolled') {
      enrolledCourses.push(course);
      return;
    }
    plannedCourses.push(course);
  });

  const era = pickTermEra(termCode, constants.activeTermCodes());
  return {
    roadmapId,
    termCode,
    era,
    note,
    plannedCourses,
    enrolledCourses,
  };
};

const loadPlanYears = (
  api: DegreePlannerApiService,
  roadmapId: number,
  constants: ConstantsService,
): 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,
        ...constants.activeTermCodes().map(tc => tc.toString()),
      ].map(TermCode.fromString);
      const uniqueYearCodes = unique(
        allTermCodes.map(tc => tc.yearCode.toString()),
      ).map(yearCodeStr => new YearCode(yearCodeStr));
      return {
        uniqueYearCodes,
        notes,
        courses,
      };
    }),
  );

  const visibleYears$ = uniqueYearCodes$.pipe(
    map(({ uniqueYearCodes, notes, courses }) => {
      const mapping: MutableYearMapping = {};
      uniqueYearCodes.forEach(yearCode => {
        mapping[yearCode.toString()] = {
          yearCode,
          isExpanded:
            utils.pickYearEra(yearCode, constants.activeTermCodes()) !== 'past',
          fall: buildTerm(roadmapId, yearCode.fall, notes, courses, constants),
          spring: buildTerm(
            roadmapId,
            yearCode.spring,
            notes,
            courses,
            constants,
          ),
          summer: buildTerm(
            roadmapId,
            yearCode.summer,
            notes,
            courses,
            constants,
          ),
        };
      });

      return mapping as YearMapping;
    }),
  );

  return visibleYears$;
};

const pickPrimaryDegreePlan = (plans: DegreePlan[]): DegreePlan => {
  const primary = plans.find(plan => plan.primary);
  return primary ? primary : plans[0];
};
const pickDegreePlanById = (
  roadmapId: number,
  plans: DegreePlan[],
): DegreePlan => {
  const plan = plans.find(plan => plan.roadmapId === roadmapId);
  return plan ? plan : plans[0];
};