Forked from an inaccessible project.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
reducer.ts 18.61 KiB
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,
state.activeTermCodes,
fromYearCode === 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 },
};
};