Forked from an inaccessible project.
-
Isaac Evavold authoredIsaac Evavold authored
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
reducer.ts 20.52 KiB
import {
DegreePlannerState,
INITIAL_DEGREE_PLANNER_STATE,
} from '@app/degree-planner/store/state';
import * as planActions from '@app/degree-planner/store/actions/plan.actions';
import * as courseActions from '@app/degree-planner/store/actions/course.actions';
import * as noteActions from '@app/degree-planner/store/actions/note.actions';
import * as uiActions from '@app/degree-planner/store/actions/ui.actions';
import * as userPrefsActions from './actions/userPreferences.actions';
import { SavedForLaterCourse } from '@app/core/models/saved-for-later-course';
import { DegreePlan } from '@app/core/models/degree-plan';
import { Year, YearMapping } from '@app/core/models/year';
import { Course } from '@app/core/models/course';
import { PlannedTerm, PlannedTermNote } from '@app/core/models/planned-term';
import { YearCode } from '@app/degree-planner/shared/term-codes/yearcode';
import { TermCode, Era } from '@app/degree-planner/shared/term-codes/termcode';
type SupportedActions =
| planActions.PlanError
| planActions.InitialLoadSuccess
| planActions.SwitchPlan
| planActions.SwitchPlanSuccess
| noteActions.WriteNote
| noteActions.WriteNoteSuccess
| noteActions.DeleteNote
| courseActions.MoveCourseInsideTerm
| courseActions.MoveCourseInsideSFL
| courseActions.MoveCourseBetweenTerms
| courseActions.RemoveCourse
| courseActions.AddCourse
| courseActions.AddCourseSuccess
| courseActions.RemoveSaveForLater
| courseActions.AddSaveForLater
| uiActions.AddAcademicYear
| planActions.CreatePlan
| planActions.CreatePlanSuccess
| planActions.MakePlanPrimary
| planActions.MakePlanPrimarySuccess
| planActions.MakePlanPrimaryFailure
| planActions.ChangePlanNameSuccess
| planActions.ChangePlanNameFailure
| planActions.DeletePlanSuccess
| uiActions.ExpandAcademicYear
| uiActions.CollapseAcademicYear
| uiActions.DismissAlert
| uiActions.OpenCourseSearch
| uiActions.CloseCourseSearch
| uiActions.ToggleCourseSearch
| uiActions.OpenSidenav
| uiActions.CloseSidenav
| uiActions.UpdateSearchTermCode
| planActions.ChangeGradeVisibility
| userPrefsActions.UpdateUserPreferences;
export function degreePlannerReducer(
state = INITIAL_DEGREE_PLANNER_STATE,
action: SupportedActions,
): DegreePlannerState {
switch (action.type) {
case planActions.PlanActionTypes.PlanError: {
return { ...state, isLoadingPlan: false };
}
case planActions.PlanActionTypes.CreatePlan: {
return { ...state, isLoadingPlan: true };
}
/**
* The `InitialPlanLoadResponse` action is triggered on initial Degree
* Planner app load. It downloads a list of the user's degree plans and
* picks the primary plan from that list to load as the first visible plan.
*/
case planActions.PlanActionTypes.InitialLoadSuccess: {
return { ...action.payload };
}
case planActions.PlanActionTypes.SwitchPlan: {
return { ...state, isLoadingPlan: true };
}
/**
* The `SwitchPlanSuccess` action is triggered whenever the UI needs
* to switch which degree plan is being shown and load the data associated
* with that degree plan. The reducer extracts that downloaded data from the
* action payload and builds a new state using that data.
*/
case planActions.PlanActionTypes.SwitchPlanSuccess: {
return { ...state, ...action.payload, isLoadingPlan: false };
}
/**
* The `AddAcademicYear` action is triggered after `addAcademicYear()`
* function runs. A new academic year container with three terms will be created.
*/
case uiActions.UIActionTypes.AddAcademicYear: {
const currentYearCodes = Object.keys(state.visibleYears);
const largestYearCode = Math.max(
...currentYearCodes.map(yearCode => {
return parseInt(yearCode, 10);
}),
);
const nextYearCode = new YearCode(
`${largestYearCode + 1}`,
Era.Future,
Era.Future,
Era.Future,
);
const nextYear = emptyYear(
(state.visibleDegreePlan as DegreePlan).roadmapId,
nextYearCode,
);
const visibleYears: YearMapping = {
...state.visibleYears,
[nextYearCode.toString()]: nextYear,
};
return { ...state, visibleYears };
}
case uiActions.UIActionTypes.ExpandAcademicYear: {
const yearCode = action.payload ? action.payload.yearCode : undefined;
const newState = { ...state };
if (yearCode) {
newState.visibleYears[yearCode.toString()].isExpanded = true;
} else {
Object.entries(newState.visibleYears).forEach(([code, year]) => {
newState.visibleYears[code].isExpanded = true;
});
}
return newState;
}
case uiActions.UIActionTypes.CollapseAcademicYear: {
const yearCode = action.payload ? action.payload.yearCode : undefined;
const newState = { ...state };
if (yearCode) {
newState.visibleYears[yearCode.toString()].isExpanded = false;
} else {
Object.entries(newState.visibleYears).forEach(([code, year]) => {
newState.visibleYears[code].isExpanded = false;
});
}
return newState;
}
case uiActions.UIActionTypes.DismissAlert: {
const keyToRemove = action.payload.key;
const newAlerts = state.alerts.filter(({ key }) => key !== keyToRemove);
return { ...state, alerts: newAlerts };
}
/**
* The `ToggleCourseSearch` action toggles the open and close state of the course search side nav
*/
case uiActions.UIActionTypes.ToggleCourseSearch: {
const newSearchState = {
...state.search,
visible: !state.search.visible,
};
// If a term was passed into the action
if (action.termCode) {
newSearchState.selectedTerm = action.termCode;
}
return {
...state,
search: newSearchState,
};
}
/**
* The `ToggleCourseSearch` action opens the course search side nav
*/
case uiActions.UIActionTypes.OpenCourseSearch: {
const newSearchState = {
...state.search,
visible: true,
};
// If a term was passed into the action
if (action.termCode) {
newSearchState.selectedTerm = action.termCode;
}
return { ...state, search: newSearchState };
}
/**
* The `ToggleCourseSearch` action closes the course search side nav
*/
case uiActions.UIActionTypes.CloseCourseSearch: {
return {
...state,
search: { ...state.search, visible: false },
};
}
/**
* The `UpdateSearchTermCode` action changes the active seach term code.
*/
case uiActions.UIActionTypes.UpdateSearchTermCode: {
return {
...state,
search: { ...state.search, selectedTerm: action.termCode },
};
}
case uiActions.UIActionTypes.OpenSidenav: {
return {
...state,
isSidenavOpen: true,
};
}
case uiActions.UIActionTypes.CloseSidenav: {
return {
...state,
isSidenavOpen: false,
};
}
case userPrefsActions.UserPreferencesActionTypes.UpdateUserPreferences: {
const update = {
...state,
};
// Remove properties who's value is undefined
Object.entries(action.payload).forEach(([key, value]) => {
if (value === undefined && update.userPreferences[key] !== undefined) {
delete update.userPreferences[key];
} else {
update.userPreferences[key] = value;
}
});
return update;
}
case noteActions.NoteActionTypes.WriteNote: {
const { termCode, noteText } = action.payload;
const { yearCode, termName } = termCode;
const year = state.visibleYears[yearCode.toString()];
const existingNote = year ? year[termName].note : undefined;
const newNote: PlannedTermNote =
existingNote && existingNote.isLoaded
? { isLoaded: true, text: noteText, id: existingNote.id }
: { isLoaded: false, text: noteText };
const visibleYears: YearMapping = {
...state.visibleYears,
[yearCode.toString()]: createYearWithNote(
(state.visibleDegreePlan as DegreePlan).roadmapId,
termCode,
newNote,
state.visibleYears[yearCode.toString()],
),
};
return { ...state, visibleYears };
}
/**
* The `WriteNoteResponse` action is dispatched by the `Note.write$` effect
* upon a successful response from the `updateNote` or `createNote` API
* endpoints. The reducer in this case either:
*
* - Replaces a note on a term that already had a note.
* - *OR* adds a note to a term that didn't previously have a note.
* - *OR* adds a new term with the given note if no term exists with the note's termCode.
*/
case noteActions.NoteActionTypes.WriteNoteSuccess: {
const { termCode, updatedNote } = action.payload;
const { yearCode } = termCode;
const visibleYears: YearMapping = {
...state.visibleYears,
[yearCode.toString()]: createYearWithNote(
(state.visibleDegreePlan as DegreePlan).roadmapId,
termCode,
{ isLoaded: true, text: updatedNote.note, id: updatedNote.id },
state.visibleYears[yearCode.toString()],
),
};
return { ...state, visibleYears };
}
/**
* The `DeleteNoteResponse` action is dispatched after the `deleteNote` API
* has been called and it is okay to remote the note with the given
* termCode from the degree planner state.
*/
case noteActions.NoteActionTypes.DeleteNote: {
const { termCode } = action.payload;
const { yearCode } = termCode;
const visibleYears: YearMapping = {
...state.visibleYears,
[yearCode.toString()]: createYearWithoutNote(
(state.visibleDegreePlan as DegreePlan).roadmapId,
termCode,
state.visibleYears[yearCode.toString()],
),
};
return { ...state, visibleYears };
}
case courseActions.CourseActionTypes.MoveCourseInsideTerm: {
const { termCode, recordId, newIndex } = action.payload;
const { yearCode, termName } = termCode;
const year = state.visibleYears[yearCode.toString()];
if (year) {
const courses = year[termName].plannedCourses;
const course = courses.find(course => course.id === recordId);
const oldIndex = courses.findIndex(course => course.id === recordId);
if (course) {
const newCourses = courses.slice();
newCourses.splice(oldIndex, 1);
newCourses.splice(newIndex, 0, course);
const visibleYears = {
...state.visibleYears,
[yearCode.toString()]: {
...state.visibleYears[yearCode.toString()],
[termName]: {
...state.visibleYears[yearCode.toString()][termName],
plannedCourses: newCourses,
},
},
};
return { ...state, visibleYears };
}
}
return state;
}
case courseActions.CourseActionTypes.MoveCourseInsideSFL: {
const { courseId, newIndex } = action.payload;
const courses = state.savedForLaterCourses.slice();
const course = courses.find(c => c.courseId === courseId);
if (course) {
const oldIndex = courses.findIndex(c => c.courseId === courseId);
courses.splice(oldIndex, 1);
courses.splice(newIndex, 0, course);
return { ...state, savedForLaterCourses: courses };
}
return state;
}
case courseActions.CourseActionTypes.MoveCourseBetweenTerms: {
const {
to: toTermCode,
from: fromTermCode,
id,
newIndex,
} = action.payload;
const { yearCode: fromYearCode } = fromTermCode;
const { yearCode: toYearCode } = toTermCode;
const course = findCourse(state.visibleYears, fromTermCode, id);
if (course && course.id !== null) {
course.classNumber = null;
const fromYear = createYearWithoutCourse(
(state.visibleDegreePlan as DegreePlan).roadmapId,
fromTermCode,
course.id,
state.visibleYears[fromYearCode.toString()],
);
const toYear = createYearWithCourse(
(state.visibleDegreePlan as DegreePlan).roadmapId,
toTermCode,
{ ...course, termCode: toTermCode.toString() },
fromYearCode.equals(toYearCode)
? fromYear
: state.visibleYears[toYearCode.toString()],
newIndex,
);
const visibleYears = {
...state.visibleYears,
[fromYearCode.toString()]: fromYear,
[toYearCode.toString()]: toYear,
};
return { ...state, visibleYears };
}
return state;
}
case courseActions.CourseActionTypes.AddCourse: {
const { termCode, newIndex } = action.payload;
const course = {
...action.payload,
id: null,
termCode: action.payload.termCode.toString(),
} as Course;
const year: Year = createYearWithCourse(
(state.visibleDegreePlan as DegreePlan).roadmapId,
termCode,
course,
state.visibleYears[termCode.yearCode.toString()],
newIndex,
);
const visibleYears: YearMapping = {
...state.visibleYears,
[termCode.yearCode.toString()]: year,
};
return { ...state, visibleYears };
}
case courseActions.CourseActionTypes.AddCourseSuccess: {
const { termCode, course, newIndex } = action.payload;
const { yearCode } = termCode;
const year: Year = createYearWithCourse(
(state.visibleDegreePlan as DegreePlan).roadmapId,
termCode,
course,
state.visibleYears[yearCode.toString()],
newIndex,
);
const visibleYears = {
...state.visibleYears,
[yearCode.toString()]: year,
};
return { ...state, visibleYears };
}
case courseActions.CourseActionTypes.RemoveCourse: {
const { recordId, fromTermCode } = action.payload;
const { yearCode } = fromTermCode;
const year: Year = createYearWithoutCourse(
(state.visibleDegreePlan as DegreePlan).roadmapId,
fromTermCode,
recordId,
state.visibleYears[yearCode.toString()],
);
const visibleYears = {
...state.visibleYears,
[yearCode.toString()]: year,
};
return { ...state, visibleYears };
}
case courseActions.CourseActionTypes.RemoveSaveForLater: {
const { courseId, subjectCode } = action.payload;
const newSavedForLater = state.savedForLaterCourses.filter(course => {
return !(
course.subjectCode === subjectCode && course.courseId === courseId
);
});
return { ...state, savedForLaterCourses: newSavedForLater };
}
case courseActions.CourseActionTypes.AddSaveForLater: {
const { courseId, subjectCode, newIndex } = action.payload;
const savedForLaterCourses = state.savedForLaterCourses.filter(c => {
return !(c.courseId === courseId && c.subjectCode === subjectCode);
});
const newSavedCourse: SavedForLaterCourse = {
id: null,
courseId: courseId,
termCode: '0000',
topicId: 0,
subjectCode: subjectCode,
title: action.payload.title,
catalogNumber: action.payload.catalogNumber,
courseOrder: 0,
};
savedForLaterCourses.splice(newIndex, 0, newSavedCourse);
return { ...state, savedForLaterCourses };
}
case planActions.PlanActionTypes.CreatePlanSuccess: {
const { newPlan, newYears } = action.payload;
return {
...state,
visibleDegreePlan: newPlan,
visibleYears: newYears,
allDegreePlans: state.allDegreePlans.concat(newPlan),
isLoadingPlan: false,
};
}
case planActions.PlanActionTypes.MakePlanPrimary: {
// TODO add global loading state
return state;
}
case planActions.PlanActionTypes.MakePlanPrimarySuccess: {
// Get current roadmap ID
const roadmapId = (state.visibleDegreePlan as DegreePlan).roadmapId;
// Update the visible term object
const newVisibleDegreePlan = {
...(state.visibleDegreePlan as DegreePlan),
primary: true,
};
// Update all plans to only have the current one be primary
const newPlans = state.allDegreePlans.map(plan => {
if (plan.roadmapId !== roadmapId) {
return { ...plan, primary: false };
}
return { ...plan, primary: true };
});
// Return the new state
return {
...state,
visibleDegreePlan: newVisibleDegreePlan,
allDegreePlans: newPlans,
};
}
case planActions.PlanActionTypes.MakePlanPrimaryFailure: {
// TODO add error message
return state;
}
case planActions.PlanActionTypes.ChangePlanNameSuccess: {
const visibleDegreePlan = {
...(state.visibleDegreePlan as DegreePlan),
name: action.payload.newName,
};
return {
...state,
allDegreePlans: state.allDegreePlans.map(plan => {
if (plan.roadmapId === action.payload.roadmapId) {
return { ...plan, name: action.payload.newName };
} else {
return plan;
}
}),
visibleDegreePlan,
};
}
case planActions.PlanActionTypes.ChangePlanNameFailure: {
const visibleDegreePlan = {
...(state.visibleDegreePlan as DegreePlan),
name: action.payload.oldName,
};
return {
...state,
allDegreePlans: state.allDegreePlans.map(plan => {
if (plan.roadmapId === action.payload.roadmapId) {
return { ...plan, name: action.payload.oldName };
} else {
return plan;
}
}),
visibleDegreePlan,
};
}
case planActions.PlanActionTypes.DeletePlanSuccess: {
const allDegreePlans = state.allDegreePlans.filter(plan => {
return plan.roadmapId !== action.payload.roadmapId;
});
return { ...state, allDegreePlans };
}
case planActions.PlanActionTypes.ChangeGradeVisibility: {
return { ...state, showGrades: action.visibility };
}
/**
* It's okay if the action didn't match any of the cases above. If that's
* the case, just return the existing state object.
*/
default:
return state;
}
}
const emptyTerm = (roadmapId: number, termCode: TermCode): PlannedTerm => {
return { roadmapId, termCode, plannedCourses: [], enrolledCourses: [] };
};
const emptyYear = (roadmapId: number, yearCode: YearCode): Year => {
return {
yearCode,
isExpanded: !(
yearCode.fall().isPast() &&
yearCode.spring().isPast() &&
yearCode.summer().isPast()
),
fall: emptyTerm(roadmapId, yearCode.fall()),
spring: emptyTerm(roadmapId, yearCode.spring()),
summer: emptyTerm(roadmapId, yearCode.summer()),
};
};
const generateYearForTermCode = (
roadmapId: number,
termCode: TermCode,
): Year => {
return emptyYear(roadmapId, termCode.yearCode);
};
const createYearWithNote = (
roadmapId: number,
termCode: TermCode,
note: PlannedTermNote | undefined,
year = generateYearForTermCode(roadmapId, termCode),
): Year => {
const term = year[termCode.termName];
return { ...year, [termCode.termName]: { ...term, note } };
};
const createYearWithoutNote = (
roadmapId: number,
termCode: TermCode,
year?: Year,
) => {
return createYearWithNote(roadmapId, termCode, undefined, year);
};
const findCourse = (
years: YearMapping,
termCode: TermCode,
recordId: number,
) => {
const year = years[termCode.yearCode.toString()];
if (year) {
const term = year[termCode.termName];
return term.plannedCourses.find(course => course.id === recordId);
}
};
const createYearWithCourse = (
roadmapId: number,
termCode: TermCode,
course: Course,
year = generateYearForTermCode(roadmapId, termCode),
newIndex?: number,
): Year => {
const term = year[termCode.termName];
const courses = term.plannedCourses.filter(
c => c.courseId !== course.courseId,
);
newIndex = newIndex !== undefined ? newIndex : courses.length;
courses.splice(newIndex, 0, course);
return { ...year, [termCode.termName]: { ...term, plannedCourses: courses } };
};
const createYearWithoutCourse = (
roadmapId: number,
termCode: TermCode,
recordId: number,
year = generateYearForTermCode(roadmapId, termCode),
): Year => {
const term = year[termCode.termName];
const courses = term.plannedCourses.filter(course => course.id !== recordId);
return {
...year,
[termCode.termName]: { ...term, plannedCourses: courses },
};
};