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 globalActions from '@app/core/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 | globalActions.DismissAlert | uiActions.OpenCourseSearch | uiActions.CloseCourseSearch | uiActions.ToggleCourseSearch | uiActions.OpenSidenav | uiActions.CloseSidenav | uiActions.UpdateSearchTermCode | planActions.ChangeGradeVisibility; 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 globalActions.NotificationActionTypes.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 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 }, }; };