import { parseTermCode, 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, CreatePlanSuccess, DeletePlanSuccess, PlanError, } from '@app/degree-planner/store/actions/plan.actions'; import { MoveCourseInsideTerm, CourseActionTypes, RemoveCourse, MoveCourseBetweenTerms, AddCourseSuccess, RemoveSaveForLater, AddSaveForLater, CourseError, } from '@app/degree-planner/store/actions/course.actions'; import { NoteActionTypes, WriteNoteSuccess, DeleteNoteSuccess, } 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 } from '@app/core/models/planned-term'; import * as utils from '@app/degree-planner/shared/utils'; type SupportedActions = | PlanError | InitialLoadSuccess | SwitchPlan | SwitchPlanSuccess | WriteNoteSuccess | DeleteNoteSuccess | MoveCourseInsideTerm | MoveCourseBetweenTerms | RemoveCourse | AddCourseSuccess | RemoveSaveForLater | AddSaveForLater | AddAcademicYearRequest | 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 }; } /** * 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 = `${largestYearCode + 1}`; const nextYear = emptyYear(nextYearCode, state.activeTermCodes); const visibleYears: YearMapping = { ...state.visibleYears, [nextYearCode]: nextYear, }; return { ...state, visibleYears }; } case UIActionTypes.ExpandAcademicYear: { const yearCode = action.payload.yearCode; return { ...state, visibleYears: { ...state.visibleYears, [yearCode]: { ...state.visibleYears[yearCode], isExpanded: true }, }, }; } case UIActionTypes.CollapseAcademicYear: { const yearCode = action.payload.yearCode; return { ...state, visibleYears: { ...state.visibleYears, [yearCode]: { ...state.visibleYears[yearCode], 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, selectedTerm: '0000' }, }; } /** * The `UpdateSearchTermCode` action changes the active seach term code. */ case UIActionTypes.UpdateSearchTermCode: { return { ...state, search: { ...state.search, selectedTerm: action.termCode }, }; } /** * 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 } = updatedNote; const { yearCode } = parseTermCode(termCode); const visibleYears: YearMapping = { ...state.visibleYears, [yearCode]: createYearWithNote( termCode, updatedNote, state.activeTermCodes, state.visibleYears[yearCode], ), }; 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.DeleteNoteSuccess: { const { termCode } = action.payload; const { yearCode } = parseTermCode(termCode); const visibleYears: YearMapping = { ...state.visibleYears, [yearCode]: createYearWithoutNote( termCode, state.activeTermCodes, state.visibleYears[yearCode], ), }; return { ...state, visibleYears }; } case CourseActionTypes.MoveCourseInsideTerm: { const { termCode, recordId, newIndex } = action.payload; const { yearCode, termName } = parseTermCode(termCode); const year = state.visibleYears[yearCode]; if (year) { const courses = year[termName].courses; 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]: { ...state.visibleYears[yearCode], [termName]: { ...state.visibleYears[yearCode][termName], courses: newCourses, }, }, }; return { ...state, visibleYears }; } } return state; } case CourseActionTypes.MoveCourseBetweenTerms: { const { to: toTermCode, from: fromTermCode, id, newIndex, } = action.payload; const { yearCode: fromYearCode } = parseTermCode(fromTermCode); const { yearCode: toYearCode } = parseTermCode(toTermCode); const course = findCourse(state.visibleYears, fromTermCode, id); if (course && course.id !== null) { const fromYear = createYearWithoutCourse( fromTermCode, course.id, state.activeTermCodes, state.visibleYears[fromYearCode], ); const toYear = createYearWithCourse( toTermCode, course, newIndex, state.activeTermCodes, fromYearCode === toYearCode ? fromYear : state.visibleYears[toYearCode], ); const visibleYears = { ...state.visibleYears, [fromYearCode]: fromYear, [toYearCode]: toYear, }; return { ...state, visibleYears }; } return state; } case CourseActionTypes.AddCourseSuccess: { const { course, newIndex } = action.payload; const { termCode } = course; const { yearCode } = parseTermCode(termCode); const year: Year = createYearWithCourse( termCode, course, newIndex, state.activeTermCodes, state.visibleYears[yearCode], ); const visibleYears = { ...state.visibleYears, [yearCode]: year, }; return { ...state, visibleYears }; } case CourseActionTypes.RemoveCourse: { const { recordId, fromTermCode } = action.payload; const { yearCode } = parseTermCode(fromTermCode); const year: Year = createYearWithoutCourse( fromTermCode, recordId, state.activeTermCodes, state.visibleYears[yearCode], ); const visibleYears = { ...state.visibleYears, [yearCode]: 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 } = action.payload; return { ...state, allDegreePlans: state.allDegreePlans.concat(newPlan), }; } 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 termCodeExists = (termCode: string, things: { termCode: string }[]) => { return things.some(thing => thing.termCode === termCode); }; const emptyTerm = (termCode: string, era: PlannedTermEra): PlannedTerm => { return { termCode, era, courses: [] }; }; const emptyYear = (yearCode: string, activeTermCodes: string[]): Year => { const fall = `${yearCode}2`; const spring = `${yearCode}4`; const summer = `${yearCode}6`; return { yearCode, isExpanded: utils.pickYearEra(yearCode, activeTermCodes) !== 'past', fall: emptyTerm(fall, pickTermEra(fall, activeTermCodes)), spring: emptyTerm(spring, pickTermEra(spring, activeTermCodes)), summer: emptyTerm(summer, pickTermEra(summer, activeTermCodes)), }; }; const generateYearForTermCode = ( termCode: string, activeTermCodes: string[], ): Year => { const { yearCode } = parseTermCode(termCode); return emptyYear(yearCode, activeTermCodes); }; const createYearWithNote = ( termCode: string, note: Note | undefined, activeTermCodes: string[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { const { termName } = parseTermCode(termCode); const term = year[termName]; return { ...year, [termName]: { ...term, note } }; }; const createYearWithoutNote = ( termCode: string, activeTermCodes: string[], year?: Year, ) => { return createYearWithNote(termCode, undefined, activeTermCodes, year); }; const findCourse = (years: YearMapping, termCode: string, recordId: number) => { const { yearCode, termName } = parseTermCode(termCode); const year = years[yearCode]; if (year) { const term = year[termName]; return term.courses.find(course => course.id === recordId); } }; const createYearWithCourse = ( termCode: string, course: Course, newIndex: number, activeTermCodes: string[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { const { termName } = parseTermCode(termCode); const term = year[termName]; const courses = term.courses.slice(); courses.splice(newIndex, 0, course); return { ...year, [termName]: { ...term, courses } }; }; const createYearWithoutCourse = ( termCode: string, recordId: number, activeTermCodes: string[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { const { termName } = parseTermCode(termCode); const term = year[termName]; const courses = term.courses.filter(course => course.id !== recordId); return { ...year, [termName]: { ...term, courses } }; };