diff --git a/api/src/controllers/roadmap.ts b/api/src/controllers/roadmap.ts index af0fc404..dab31f01 100644 --- a/api/src/controllers/roadmap.ts +++ b/api/src/controllers/roadmap.ts @@ -29,12 +29,16 @@ router.post('/', async function (req: Request = ({ index }) => { const dispatch = useAppDispatch(); const search = useAppSelector((state) => state.search[index]); + const showCourseBag = useAppSelector((state) => state.roadmap.showCourseBag); const [pendingRequest, setPendingRequest] = useState(null); const [prevIndex, setPrevIndex] = useState(null); @@ -135,6 +137,16 @@ const SearchModule: FC = ({ index }) => { placeholder={placeholder} onChange={(e) => searchNamesAfterTimeout(e.target.value)} /> + { + // only show course bag icon on roadmap page + location.pathname === '/roadmap' && ( + + dispatch(setShowCourseBag(!showCourseBag))}> + + + + ) + } diff --git a/site/src/helpers/planner.ts b/site/src/helpers/planner.ts index bd7c3f91..d3c31b20 100644 --- a/site/src/helpers/planner.ts +++ b/site/src/helpers/planner.ts @@ -3,6 +3,7 @@ import { searchAPIResults } from './util'; import { RoadmapPlan, defaultPlan } from '../store/slices/roadmapSlice'; import { BatchCourseData, + Coursebag, InvalidCourseData, MongoRoadmap, PlannerData, @@ -124,9 +125,10 @@ interface RoadmapCookies { export const loadRoadmap = async ( cookies: RoadmapCookies, - loadHandler: (r: RoadmapPlan[], s: SavedRoadmap, isLocalNewer: boolean) => void, + loadHandler: (r: RoadmapPlan[], s: SavedRoadmap, coursebag: Coursebag, isLocalNewer: boolean) => void, ) => { let roadmap: SavedRoadmap = null!; + let coursebagStrings: string[] = []; const localRoadmap: SavedRoadmap = JSON.parse(localStorage.getItem('roadmap') ?? 'null'); // if logged in if (cookies.user !== undefined) { @@ -136,6 +138,9 @@ export const loadRoadmap = async ( if (request.data.roadmap !== undefined) { roadmap = request.data.roadmap; } + if (request.data.coursebag !== undefined) { + coursebagStrings = request.data.coursebag; + } } let isLocalNewer = false; @@ -154,10 +159,11 @@ export const loadRoadmap = async ( 'planners' in roadmap ? roadmap.planners : [{ name: defaultPlan.name, content: (roadmap as { planner: SavedPlannerYearData[] }).planner }]; - // expand planner and set the state const planners = await expandAllPlanners(loadedData); - loadHandler(planners, roadmap, isLocalNewer); + const coursesObj: BatchCourseData = (await searchAPIResults('courses', coursebagStrings)) as BatchCourseData; + const coursebag = coursebagStrings.map((id) => coursesObj[id]); + loadHandler(planners, roadmap, coursebag, isLocalNewer); }; type PrerequisiteNode = Prerequisite | PrerequisiteTree; diff --git a/site/src/pages/RoadmapPage/Course.tsx b/site/src/pages/RoadmapPage/Course.tsx index 7bc5142a..81bf12dd 100644 --- a/site/src/pages/RoadmapPage/Course.tsx +++ b/site/src/pages/RoadmapPage/Course.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import './Course.scss'; import { Button } from 'react-bootstrap'; -import { InfoCircle, ExclamationTriangle, Trash } from 'react-bootstrap-icons'; +import { InfoCircle, ExclamationTriangle, Trash, BagPlus, BagFill } from 'react-bootstrap-icons'; import CourseQuarterIndicator from '../../component/QuarterTooltip/CourseQuarterIndicator'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import Popover from 'react-bootstrap/Popover'; @@ -13,6 +13,9 @@ interface CourseProps extends CourseGQLData { requiredCourses?: string[]; unmatchedPrerequisites?: string[]; onDelete?: () => void; + onAddToBag?: () => void; + isInBag?: boolean; + removeFromBag?: () => void; } const Course: FC = (props) => { @@ -29,6 +32,9 @@ const Course: FC = (props) => { requiredCourses, terms, onDelete, + onAddToBag, + isInBag, + removeFromBag, } = props; const CoursePopover = ( @@ -107,7 +113,10 @@ const Course: FC = (props) => { )}
{title}
- {/*
+
+ {onAddToBag && !isInBag && } + {isInBag && } + {/*
{requiredCourses && ( @@ -115,6 +124,7 @@ const Course: FC = (props) => { )} {/*
{minUnits === maxUnits ? minUnits : `${minUnits}-${maxUnits}`} units
* /}
*/} +
); }; diff --git a/site/src/pages/RoadmapPage/CourseBag.tsx b/site/src/pages/RoadmapPage/CourseBag.tsx new file mode 100644 index 00000000..7209894b --- /dev/null +++ b/site/src/pages/RoadmapPage/CourseBag.tsx @@ -0,0 +1,45 @@ +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { removeCourseFromBag } from '../../store/slices/roadmapSlice'; +import Course from './Course'; +import './Coursebag.scss'; +import { Draggable } from 'react-beautiful-dnd'; +const CourseBag = () => { + const { coursebag } = useAppSelector((state) => state.roadmap); + const dispatch = useAppDispatch(); + + return ( +
+

Course Bag

+
+ {coursebag.map((course, index) => { + return ( + + {(provided) => ( +
+ { + dispatch(removeCourseFromBag(course)); + }} + /> +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default CourseBag; diff --git a/site/src/pages/RoadmapPage/CourseHitItem.tsx b/site/src/pages/RoadmapPage/CourseHitItem.tsx index 01cf182f..1371655e 100644 --- a/site/src/pages/RoadmapPage/CourseHitItem.tsx +++ b/site/src/pages/RoadmapPage/CourseHitItem.tsx @@ -1,7 +1,12 @@ import { FC } from 'react'; import { Draggable } from 'react-beautiful-dnd'; -import { useAppDispatch } from '../../store/hooks'; -import { setActiveCourse, setShowAddCourse } from '../../store/slices/roadmapSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + addCourseToBag, + removeCourseFromBag, + setActiveCourse, + setShowAddCourse, +} from '../../store/slices/roadmapSlice'; import Course from './Course'; import { useIsMobile } from '../../helpers/util'; @@ -14,6 +19,8 @@ interface CourseHitItemProps extends CourseGQLData { const CourseHitItem: FC = (props: CourseHitItemProps) => { const dispatch = useAppDispatch(); const isMobile = useIsMobile(); + const coursebag = useAppSelector((state) => state.roadmap.coursebag); + const isInBag = coursebag.some((course) => course.id === props.id); // do not make course draggable on mobile const onMobileMouseDown = () => { dispatch(setActiveCourse(props)); @@ -25,6 +32,16 @@ const CourseHitItem: FC = (props: CourseHitItemProps) => { onMobileMouseDown(); } }; + const onAddToBag = () => { + if (!props) return; + if (props.id === undefined) return; + if (coursebag.some((course) => course.id === props.id)) return; + dispatch(addCourseToBag(props)); + }; + const removeFromBag = () => { + dispatch(removeCourseFromBag(props)); + }; + if (isMobile) { return (
= (props: CourseHitItemProps) => { ...provided.draggableProps.style, }} > - +
); }} diff --git a/site/src/pages/RoadmapPage/Coursebag.scss b/site/src/pages/RoadmapPage/Coursebag.scss new file mode 100644 index 00000000..7737f66e --- /dev/null +++ b/site/src/pages/RoadmapPage/Coursebag.scss @@ -0,0 +1,29 @@ +.coursebag-container { + padding-top: 2vh; + overflow-y: auto; + height: 100%; +} +.coursebag-title { + font-size: 2rem; + font-weight: 500; + padding: 0 1.8rem; +} +.no-results { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + font-size: 1.5rem; + padding: 2rem; + text-align: center; + + img { + width: 400px; + max-width: 100%; + } +} + +.search-pagination { + display: flex; + justify-content: center; +} diff --git a/site/src/pages/RoadmapPage/Planner.tsx b/site/src/pages/RoadmapPage/Planner.tsx index a3f42501..793724a1 100644 --- a/site/src/pages/RoadmapPage/Planner.tsx +++ b/site/src/pages/RoadmapPage/Planner.tsx @@ -14,6 +14,7 @@ import { selectAllPlans, setAllPlans, defaultPlan, + setCoursebag, } from '../../store/slices/roadmapSlice'; import { useFirstRender } from '../../hooks/firstRenderer'; import { SavedRoadmap, MongoRoadmap } from '../../types/types'; @@ -29,6 +30,7 @@ const Planner: FC = () => { const currentPlanData = useAppSelector(selectYearPlans); const allPlanData = useAppSelector(selectAllPlans); const transfers = useAppSelector((state) => state.roadmap.transfers); + const coursebag = useAppSelector((state) => state.roadmap.coursebag); const [showSyncModal, setShowSyncModal] = useState(false); const [missingPrerequisites, setMissingPrerequisites] = useState(new Set()); @@ -56,8 +58,8 @@ const Planner: FC = () => { planners: collapseAllPlanners(allPlanData), transfers: transfers, }; + const coursebagStrings = coursebag.map((course) => course.id); - // save to local storage as well localStorage.setItem('roadmap', JSON.stringify(roadmap)); // mark changes as saved to bypass alert on page leave @@ -65,7 +67,7 @@ const Planner: FC = () => { // if logged in, save data to account if (cookies.user !== undefined) { - const mongoRoadmap: MongoRoadmap = { _id: cookies.user.id, roadmap: roadmap }; + const mongoRoadmap: MongoRoadmap = { _id: cookies.user.id, roadmap: roadmap, coursebag: coursebagStrings }; axios.post('/api/roadmap', mongoRoadmap).then((res) => { // error saving to account, saved locally if (res.data.error) { @@ -113,16 +115,15 @@ const Planner: FC = () => { // if first render and current roadmap is empty, load from local storage if (isFirstRenderer && roadmapStr === emptyRoadmap) { - loadRoadmap(cookies, (planners, roadmap, isLocalNewer) => { + loadRoadmap(cookies, (planners, roadmap, coursebag, isLocalNewer) => { dispatch(setAllPlans(planners)); dispatch(setTransfers(roadmap.transfers)); + dispatch(setCoursebag(coursebag)); if (isLocalNewer) { setShowSyncModal(true); } }); - } - // validate planner every time something changes - else { + } else { validatePlanner(transfers, currentPlanData, (missing, invalid) => { // set missing courses setMissingPrerequisites(missing); diff --git a/site/src/pages/RoadmapPage/SearchSidebar.tsx b/site/src/pages/RoadmapPage/SearchSidebar.tsx index 883ef666..54e3ef7f 100644 --- a/site/src/pages/RoadmapPage/SearchSidebar.tsx +++ b/site/src/pages/RoadmapPage/SearchSidebar.tsx @@ -6,14 +6,15 @@ import SearchModule from '../../component/SearchModule/SearchModule'; import CourseHitItem from './CourseHitItem'; import { useIsMobile } from '../../helpers/util'; -import { useAppDispatch } from '../../store/hooks'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { setShowSearch } from '../../store/slices/roadmapSlice'; import { StrictModeDroppable } from './StrictModeDroppable'; +import CourseBag from './CourseBag'; const SearchSidebar = () => { const dispatch = useAppDispatch(); const isMobile = useIsMobile(); - + const { showCourseBag } = useAppSelector((state) => state.roadmap); return (
{isMobile && ( @@ -27,21 +28,36 @@ const SearchSidebar = () => {
)}
- - {(provided) => { - return ( -
-
-
- +
+ +
+ {!showCourseBag ? ( + + {(provided) => { + return ( +
+
+ +
+ {provided.placeholder} +
+ ); + }} +
+ ) : ( + + {(provided) => { + return ( +
+
+
- + {provided.placeholder}
- {provided.placeholder} -
- ); - }} - + ); + }} + + )}
); diff --git a/site/src/pages/RoadmapPage/Transfer.tsx b/site/src/pages/RoadmapPage/Transfer.tsx index c736c1a8..594e418b 100644 --- a/site/src/pages/RoadmapPage/Transfer.tsx +++ b/site/src/pages/RoadmapPage/Transfer.tsx @@ -41,7 +41,7 @@ const TransferEntry: FC = (props) => { transfer: { name, units }, }), ); - }, [name, units]); + }, [dispatch, name, props.index, units]); return ( diff --git a/site/src/pages/RoadmapPage/index.tsx b/site/src/pages/RoadmapPage/index.tsx index 2f8fdd4a..60532f8b 100644 --- a/site/src/pages/RoadmapPage/index.tsx +++ b/site/src/pages/RoadmapPage/index.tsx @@ -4,7 +4,13 @@ import Planner from './Planner'; import SearchSidebar from './SearchSidebar'; import { DragDropContext, DropResult, DragStart } from 'react-beautiful-dnd'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { moveCourse, deleteCourse, setActiveCourse } from '../../store/slices/roadmapSlice'; +import { + moveCourse, + deleteCourse, + setActiveCourse, + addCourseToBag, + removeCourseFromBag, +} from '../../store/slices/roadmapSlice'; import AddCoursePopup from './AddCoursePopup'; import { CourseGQLData } from '../../types/types'; import { useIsMobile } from '../../helpers/util'; @@ -13,19 +19,22 @@ const RoadmapPage: FC = () => { const dispatch = useAppDispatch(); const showSearch = useAppSelector((state) => state.roadmap.showSearch); const searchResults = useAppSelector((state) => state.search.courses.results) as CourseGQLData[]; + const courseBag = useAppSelector((state) => state.roadmap.coursebag); const isMobile = useIsMobile(); + const roadmaps = useAppSelector((state) => state.roadmap.plans); + const roadmap = roadmaps[useAppSelector((state) => state.roadmap.currentPlanIndex)].content.yearPlans; + const onDragEnd = useCallback( + (result: DropResult) => { + if (result.reason === 'DROP') { + // no destination + if (!result.destination) { + return; + } - const onDragEnd = useCallback((result: DropResult) => { - if (result.reason === 'DROP') { - // no destination - if (!result.destination) { - return; - } + // dragging to search bar + if (result.destination.droppableId === 'search' && result.source.droppableId != 'search') { + // removing from quarter - // dragging to search bar - if (result.destination.droppableId === 'search') { - // removing from quarter - if (result.source.droppableId != 'search') { const [yearIndex, quarterIndex] = result.source.droppableId.split('-'); dispatch( deleteCourse({ @@ -34,41 +43,63 @@ const RoadmapPage: FC = () => { courseIndex: result.source.index, }), ); + return; } - return; - } + //move from planner to coursebag + if (result.destination.droppableId === 'coursebag' && result.source.droppableId != 'coursebag') { + const [yearIndex, quarterIndex]: string[] = result.source.droppableId.split('-'); + const course = roadmap[parseInt(yearIndex)].quarters[parseInt(quarterIndex)].courses[result.source.index]; + dispatch(addCourseToBag(course)); + dispatch( + deleteCourse({ + yearIndex: parseInt(yearIndex), + quarterIndex: parseInt(quarterIndex), + courseIndex: result.source.index, + }), + ); - const movePayload = { - from: { - yearIndex: -1, - quarterIndex: -1, - courseIndex: -1, - }, - to: { - yearIndex: -1, - quarterIndex: -1, - courseIndex: -1, - }, - }; + return; + } - // roadmap to roadmap has source - if (result.source.droppableId != 'search') { - const [yearIndex, quarterIndex] = result.source.droppableId.split('-'); - movePayload.from.yearIndex = parseInt(yearIndex); - movePayload.from.quarterIndex = parseInt(quarterIndex); - movePayload.from.courseIndex = result.source.index; - } - // search to roadmap has no source (use activeCourse in global state) + if (result.source.droppableId === 'coursebag' && result.destination.droppableId != 'coursebag') { + const course = courseBag[result.source.index]; - // both have destination - const [yearIndex, quarterIndex] = result.destination.droppableId.split('-'); - movePayload.to.yearIndex = parseInt(yearIndex); - movePayload.to.quarterIndex = parseInt(quarterIndex); - movePayload.to.courseIndex = result.destination.index; + dispatch(removeCourseFromBag(course)); + } + + const movePayload = { + from: { + yearIndex: -1, + quarterIndex: -1, + courseIndex: -1, + }, + to: { + yearIndex: -1, + quarterIndex: -1, + courseIndex: -1, + }, + }; + + // roadmap to roadmap has source + if (result.source.droppableId != 'search' && result.source.droppableId != 'coursebag') { + const [yearIndex, quarterIndex] = result.source.droppableId.split('-'); + movePayload.from.yearIndex = parseInt(yearIndex); + movePayload.from.quarterIndex = parseInt(quarterIndex); + movePayload.from.courseIndex = result.source.index; + } + // search to roadmap has no source (use activeCourse in global state) - dispatch(moveCourse(movePayload)); - } - }, []); + // both have destination + const [yearIndex, quarterIndex] = result.destination.droppableId.split('-'); + movePayload.to.yearIndex = parseInt(yearIndex); + movePayload.to.quarterIndex = parseInt(quarterIndex); + movePayload.to.courseIndex = result.destination.index; + + dispatch(moveCourse(movePayload)); + } + }, + [courseBag, dispatch, roadmap], + ); const onDragStart = useCallback( (start: DragStart) => { @@ -76,8 +107,12 @@ const RoadmapPage: FC = () => { const activeCourse = searchResults[start.source.index]; dispatch(setActiveCourse(activeCourse)); } + if (start.source.droppableId === 'coursebag') { + const activeCourse = courseBag[start.source.index]; + dispatch(setActiveCourse(activeCourse)); + } }, - [dispatch, searchResults], + [dispatch, searchResults, courseBag], ); // do not conditionally renderer because it would remount planner which would discard unsaved changes diff --git a/site/src/pages/SearchPage/CourseHitItem.tsx b/site/src/pages/SearchPage/CourseHitItem.tsx index 39b43398..f332bd37 100644 --- a/site/src/pages/SearchPage/CourseHitItem.tsx +++ b/site/src/pages/SearchPage/CourseHitItem.tsx @@ -8,7 +8,8 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { setCourse } from '../../store/slices/popupSlice'; import { CourseGQLData } from '../../types/types'; import { getCourseTags, useIsMobile } from '../../helpers/util'; - +import { BagFill, BagPlus } from 'react-bootstrap-icons'; +import { addCourseToBag, removeCourseFromBag } from '../../store/slices/roadmapSlice'; interface CourseHitItemProps extends CourseGQLData {} const CourseHitItem: FC = (props) => { @@ -16,6 +17,8 @@ const CourseHitItem: FC = (props) => { const navigate = useNavigate(); const activeCourse = useAppSelector((state) => state.popup.course); const isMobile = useIsMobile(); + const coursebag = useAppSelector((state) => state.roadmap.coursebag); + const isInBag = coursebag.some((course) => course.id === props.id); // data to be displayed in pills const pillData = getCourseTags(props); @@ -37,6 +40,19 @@ const CourseHitItem: FC = (props) => { } }; + const onAddToBag = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!props) return; + if (props.id === undefined) return; + if (coursebag.some((course) => course.id === props.id)) return; + dispatch(addCourseToBag(props)); + }; + + const removeFromBag = (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(removeCourseFromBag(props)); + }; + return (
@@ -48,17 +64,21 @@ const CourseHitItem: FC = (props) => {
-
+

{props.school}

-

{props.description}

- -
- {pillData.map((pill, i) => ( - - {pill} - - ))} +
+
+ {pillData.map((pill, i) => ( + + {pill} + + ))} +
+
+ {onAddToBag && !isInBag && onAddToBag(e)} size={24}>} + {isInBag && removeFromBag(e)}>} +
diff --git a/site/src/pages/SearchPage/HitItem.scss b/site/src/pages/SearchPage/HitItem.scss index ee9cd445..028f8042 100644 --- a/site/src/pages/SearchPage/HitItem.scss +++ b/site/src/pages/SearchPage/HitItem.scss @@ -13,3 +13,9 @@ .course-hit-id { display: flex; } + +.hit-lower { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/site/src/store/slices/roadmapSlice.ts b/site/src/store/slices/roadmapSlice.ts index 109ba87a..9116ab48 100644 --- a/site/src/store/slices/roadmapSlice.ts +++ b/site/src/store/slices/roadmapSlice.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { defaultYear, quarterDisplayNames } from '../../helpers/planner'; import { + Coursebag, CourseGQLData, CourseIdentifier, InvalidCourseData, @@ -57,6 +58,9 @@ interface RoadmapSliceState { unsavedChanges: boolean; // Selected quarter and year for adding a course on mobile currentYearAndQuarter: { year: number; quarter: number } | null; + // Store the course data of the active dragging item + coursebag: Coursebag; + showCourseBag: boolean; // Whether or not to show the search bar on mobile showSearch: boolean; // Whether or not to show the add course modal on mobile @@ -79,6 +83,8 @@ const initialSliceState: RoadmapSliceState = { showAddCourse: false, showTransfer: false, transfers: [], + coursebag: [], + showCourseBag: false, }; /** added for multiple planner */ @@ -340,6 +346,18 @@ export const roadmapSlice = createSlice({ state.plans[index].name = action.payload.name; }, /** added for multiple plans */ + setCoursebag(state, action: PayloadAction) { + state.coursebag = action.payload; + }, + addCourseToBag: (state, action: PayloadAction) => { + state.coursebag.push(action.payload); + }, + removeCourseFromBag: (state, action: PayloadAction) => { + state.coursebag = state.coursebag.filter((course) => course.id !== action.payload.id); + }, + setShowCourseBag: (state, action: PayloadAction) => { + state.showCourseBag = action.payload; + }, setUnsavedChanges: (state, action: PayloadAction) => { state.unsavedChanges = action.payload; @@ -383,6 +401,10 @@ export const { setPlanIndex, setPlanName, setUnsavedChanges, + addCourseToBag, + removeCourseFromBag, + setShowCourseBag, + setCoursebag, } = roadmapSlice.actions; // Other code such as selectors can use the imported `RootState` type diff --git a/site/src/types/types.ts b/site/src/types/types.ts index de8987fc..cc8346ef 100644 --- a/site/src/types/types.ts +++ b/site/src/types/types.ts @@ -146,10 +146,12 @@ export interface SavedRoadmap { transfers: TransferData[]; } +export type Coursebag = CourseGQLData[]; // Structure stored in mongo for accounts export interface MongoRoadmap { _id: string; roadmap: SavedRoadmap; + coursebag: string[]; } /**