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];
};