import { pickTermEra } from '@app/degree-planner/shared/utils'; import { UIActionTypes } from './actions/ui.actions'; import { DegreePlannerState, INITIAL_DEGREE_PLANNER_STATE, } from '@app/degree-planner/store/state'; import { PlanActionTypes, InitialLoadSuccess, SwitchPlan, SwitchPlanSuccess, MakePlanPrimary, MakePlanPrimarySuccess, MakePlanPrimaryFailure, ChangePlanNameSuccess, ChangePlanNameFailure, CreatePlan, CreatePlanSuccess, DeletePlanSuccess, PlanError, } from '@app/degree-planner/store/actions/plan.actions'; import { MoveCourseInsideTerm, CourseActionTypes, RemoveCourse, MoveCourseBetweenTerms, AddCourse, AddCourseSuccess, RemoveSaveForLater, AddSaveForLater, CourseError, } from '@app/degree-planner/store/actions/course.actions'; import { NoteActionTypes, WriteNote, WriteNoteSuccess, DeleteNote, } from '@app/degree-planner/store/actions/note.actions'; import { AddAcademicYearActionTypes, AddAcademicYearRequest, } from '@app/degree-planner/store/actions/addAcademicYear.actions'; import { ExpandAcademicYear, CollapseAcademicYear, OpenCourseSearch, CloseCourseSearch, ToggleCourseSearch, UpdateSearchTermCode, } from '@app/degree-planner/store/actions/ui.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 { Note } from '@app/core/models/note'; import { PlannedTerm, PlannedTermEra, PlannedTermNote, } from '@app/core/models/planned-term'; import * as utils from '@app/degree-planner/shared/utils'; import { TermCode, YearCode } from '@app/core/models/termcode'; type SupportedActions = | PlanError | InitialLoadSuccess | SwitchPlan | SwitchPlanSuccess | WriteNote | WriteNoteSuccess | DeleteNote | MoveCourseInsideTerm | MoveCourseBetweenTerms | RemoveCourse | AddCourse | AddCourseSuccess | RemoveSaveForLater | AddSaveForLater | AddAcademicYearRequest | CreatePlan | CreatePlanSuccess | MakePlanPrimary | MakePlanPrimarySuccess | MakePlanPrimaryFailure | ChangePlanNameSuccess | ChangePlanNameFailure | DeletePlanSuccess | ExpandAcademicYear | CollapseAcademicYear | OpenCourseSearch | CloseCourseSearch | ToggleCourseSearch | UpdateSearchTermCode; export function degreePlannerReducer( state = INITIAL_DEGREE_PLANNER_STATE, action: SupportedActions, ): DegreePlannerState { switch (action.type) { case PlanActionTypes.PlanError: { return { ...state, isLoadingPlan: false }; } case 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 PlanActionTypes.InitialLoadSuccess: { return { ...action.payload }; } case 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 PlanActionTypes.SwitchPlanSuccess: { return { ...state, ...action.payload, isLoadingPlan: false }; } /** * The `AddAcademicYearRequest` action is triggered after `addAcademicYear()` * function runs. A new academic year container with three terms will be created. */ case AddAcademicYearActionTypes.AddAcademicYearRequest: { const currentYearCodes = Object.keys(state.visibleYears); const largestYearCode = Math.max( ...currentYearCodes.map(yearCode => { return parseInt(yearCode, 10); }), ); const nextYearCode = new YearCode(`${largestYearCode + 1}`); const nextYear = emptyYear(nextYearCode, state.activeTermCodes); const visibleYears: YearMapping = { ...state.visibleYears, [nextYearCode.toString()]: nextYear, }; return { ...state, visibleYears }; } case UIActionTypes.ExpandAcademicYear: { const yearCode = action.payload.yearCode; return { ...state, visibleYears: { ...state.visibleYears, [yearCode.toString()]: { ...state.visibleYears[yearCode.toString()], isExpanded: true, }, }, }; } case UIActionTypes.CollapseAcademicYear: { const yearCode = action.payload.yearCode; return { ...state, visibleYears: { ...state.visibleYears, [yearCode.toString()]: { ...state.visibleYears[yearCode.toString()], isExpanded: false, }, }, }; } /** * The `ToggleCourseSearch` action toggles the open and close state of the course search side nav */ case 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 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 UIActionTypes.CloseCourseSearch: { return { ...state, search: { ...state.search, visible: false }, }; } /** * The `UpdateSearchTermCode` action changes the active seach term code. */ case UIActionTypes.UpdateSearchTermCode: { return { ...state, search: { ...state.search, selectedTerm: action.termCode }, }; } case 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( termCode, newNote, state.activeTermCodes, 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 NoteActionTypes.WriteNoteSuccess: { const { updatedNote } = action.payload; const termCode = new TermCode(updatedNote.termCode); const { yearCode } = termCode; const visibleYears: YearMapping = { ...state.visibleYears, [yearCode.toString()]: createYearWithNote( termCode, { isLoaded: true, text: updatedNote.note, id: updatedNote.id }, state.activeTermCodes, 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 NoteActionTypes.DeleteNote: { const { termCode } = action.payload; const { yearCode } = termCode; const visibleYears: YearMapping = { ...state.visibleYears, [yearCode.toString()]: createYearWithoutNote( termCode, state.activeTermCodes, state.visibleYears[yearCode.toString()], ), }; return { ...state, visibleYears }; } case 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 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) { const fromYear = createYearWithoutCourse( fromTermCode, course.id, state.activeTermCodes, state.visibleYears[fromYearCode.toString()], ); const toYear = createYearWithCourse( toTermCode, { ...course, termCode: toTermCode.toString() }, state.activeTermCodes, 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 CourseActionTypes.AddCourse: { const { termCode, subjectCode, newIndex } = action.payload; const course = { ...action.payload, id: null, termCode: action.payload.termCode.toString(), subject: state.subjects[subjectCode], } as Course; const year: Year = createYearWithCourse( termCode, course, state.activeTermCodes, state.visibleYears[termCode.yearCode.toString()], newIndex, ); const visibleYears: YearMapping = { ...state.visibleYears, [termCode.yearCode.toString()]: year, }; return { ...state, visibleYears }; } case CourseActionTypes.AddCourseSuccess: { const { course, newIndex } = action.payload; const termCode = new TermCode(course.termCode); const { yearCode } = termCode; const year: Year = createYearWithCourse( termCode, course, state.activeTermCodes, state.visibleYears[yearCode.toString()], newIndex, ); const visibleYears = { ...state.visibleYears, [yearCode.toString()]: year, }; return { ...state, visibleYears }; } case CourseActionTypes.RemoveCourse: { const { recordId, fromTermCode } = action.payload; const { yearCode } = fromTermCode; const year: Year = createYearWithoutCourse( fromTermCode, recordId, state.activeTermCodes, state.visibleYears[yearCode.toString()], ); const visibleYears = { ...state.visibleYears, [yearCode.toString()]: year, }; return { ...state, visibleYears }; } case CourseActionTypes.RemoveSaveForLater: { const { courseId, subjectCode } = action.payload; // // Create new saved for later array const newSavedForLater = state.savedForLaterCourses.filter( course => course.subjectCode !== subjectCode && course.courseId !== courseId, ); return { ...state, savedForLaterCourses: newSavedForLater }; } case CourseActionTypes.AddSaveForLater: { const { newIndex } = action.payload; const newSavedCourse: SavedForLaterCourse = { id: null, courseId: action.payload.courseId, termCode: '0000', topicId: 0, subjectCode: action.payload.subjectCode, title: action.payload.title, catalogNumber: action.payload.catalogNumber, courseOrder: 0, subject: state.subjects[action.payload.subjectCode], }; const savedForLaterCoursesArr = state.savedForLaterCourses.slice(); savedForLaterCoursesArr.splice(newIndex, 0, newSavedCourse); return { ...state, savedForLaterCourses: savedForLaterCoursesArr }; } case PlanActionTypes.CreatePlanSuccess: { const { newPlan, newYears } = action.payload; return { ...state, visibleDegreePlan: newPlan, visibleYears: newYears, allDegreePlans: state.allDegreePlans.concat(newPlan), isLoadingPlan: false, }; } case PlanActionTypes.MakePlanPrimary: { // TODO add global loading state return state; } case 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 PlanActionTypes.MakePlanPrimaryFailure: { // TODO add error message return state; } case 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 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 PlanActionTypes.DeletePlanSuccess: { const allDegreePlans = state.allDegreePlans.filter(plan => { return plan.roadmapId !== action.payload.roadmapId; }); return { ...state, allDegreePlans }; } /** * 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 = (termCode: TermCode, era: PlannedTermEra): PlannedTerm => { return { termCode, era, plannedCourses: [], enrolledCourses: [] }; }; const emptyYear = (yearCode: YearCode, activeTermCodes: TermCode[]): Year => { return { yearCode, isExpanded: utils.pickYearEra(yearCode, activeTermCodes) !== 'past', fall: emptyTerm(yearCode.fall, pickTermEra(yearCode.fall, activeTermCodes)), spring: emptyTerm( yearCode.spring, pickTermEra(yearCode.spring, activeTermCodes), ), summer: emptyTerm( yearCode.summer, pickTermEra(yearCode.summer, activeTermCodes), ), }; }; const generateYearForTermCode = ( termCode: TermCode, activeTermCodes: TermCode[], ): Year => { return emptyYear(termCode.yearCode, activeTermCodes); }; const createYearWithNote = ( termCode: TermCode, note: PlannedTermNote | undefined, activeTermCodes: TermCode[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { const term = year[termCode.termName]; return { ...year, [termCode.termName]: { ...term, note } }; }; const createYearWithoutNote = ( termCode: TermCode, activeTermCodes: TermCode[], year?: Year, ) => { return createYearWithNote(termCode, undefined, activeTermCodes, 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 = ( termCode: TermCode, course: Course, activeTermCodes: TermCode[], year = generateYearForTermCode(termCode, activeTermCodes), 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 = ( termCode: TermCode, recordId: number, activeTermCodes: TermCode[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { const term = year[termCode.termName]; const courses = term.plannedCourses.filter(course => course.id !== recordId); return { ...year, [termCode.termName]: { ...term, plannedCourses: courses }, }; };