Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Coursebag #480

Merged
merged 13 commits into from
Oct 16, 2024
8 changes: 6 additions & 2 deletions api/src/controllers/roadmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ router.post('/', async function (req: Request<never, unknown, Record<string, unk
return;
}
console.log(`Adding Roadmap: ${JSON.stringify(req.body)}`);

try {
if (await Roadmap.exists({ userID: req.body._id })) {
await Roadmap.replaceOne({ userID: req.body._id }, { roadmap: req.body.roadmap, userID: req.body._id });
await Roadmap.replaceOne(
{ userID: req.body._id },
{ roadmap: req.body.roadmap, userID: req.body._id, coursebag: req.body.coursebag },
);
} else {
// add roadmap to mongo
await new Roadmap({ roadmap: req.body.roadmap, userID: req.body._id }).save();
await new Roadmap({ roadmap: req.body.roadmap, userID: req.body._id, coursebag: req.body.coursebag }).save();
}
res.json({});
} catch {
Expand Down
4 changes: 4 additions & 0 deletions api/src/models/roadmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const roadmapSchema = new mongoose.Schema({
type: String,
required: true,
},
coursebag: {
type: [String],
required: true,
},
});

const Roadmap = mongoose.model('Roadmap', roadmapSchema);
Expand Down
14 changes: 13 additions & 1 deletion site/src/component/SearchModule/SearchModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import './SearchModule.scss';
import wfs from 'websoc-fuzzy-search';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';
import { Search } from 'react-bootstrap-icons';
import { Bag, Search } from 'react-bootstrap-icons';

import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { setHasFullResults, setLastQuery, setNames, setPageNumber, setResults } from '../../store/slices/searchSlice';
import { searchAPIResults } from '../../helpers/util';
import { SearchIndex } from '../../types/types';
import { NUM_RESULTS_PER_PAGE } from '../../helpers/constants';
import { setShowCourseBag } from '../../store/slices/roadmapSlice';

const SEARCH_TIMEOUT_MS = 300;
const FULL_RESULT_THRESHOLD = 3;
Expand All @@ -22,6 +23,7 @@ interface SearchModuleProps {
const SearchModule: FC<SearchModuleProps> = ({ index }) => {
const dispatch = useAppDispatch();
const search = useAppSelector((state) => state.search[index]);
const showCourseBag = useAppSelector((state) => state.roadmap.showCourseBag);
const [pendingRequest, setPendingRequest] = useState<number | null>(null);
const [prevIndex, setPrevIndex] = useState<SearchIndex | null>(null);

Expand Down Expand Up @@ -135,6 +137,16 @@ const SearchModule: FC<SearchModuleProps> = ({ index }) => {
placeholder={placeholder}
onChange={(e) => searchNamesAfterTimeout(e.target.value)}
/>
{
// only show course bag icon on roadmap page
location.pathname === '/roadmap' && (
<InputGroup.Append>
<InputGroup.Text onClick={() => dispatch(setShowCourseBag(!showCourseBag))}>
<Bag style={{ color: showCourseBag ? 'var(--primary)' : 'var(--text-color)', cursor: 'pointer' }} />
</InputGroup.Text>
</InputGroup.Append>
)
}
</InputGroup>
</Form.Group>
</div>
Expand Down
12 changes: 9 additions & 3 deletions site/src/helpers/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { searchAPIResults } from './util';
import { RoadmapPlan, defaultPlan } from '../store/slices/roadmapSlice';
import {
BatchCourseData,
Coursebag,
InvalidCourseData,
MongoRoadmap,
PlannerData,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions site/src/pages/RoadmapPage/Course.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +13,9 @@ interface CourseProps extends CourseGQLData {
requiredCourses?: string[];
unmatchedPrerequisites?: string[];
onDelete?: () => void;
onAddToBag?: () => void;
isInBag?: boolean;
removeFromBag?: () => void;
}

const Course: FC<CourseProps> = (props) => {
Expand All @@ -29,6 +32,9 @@ const Course: FC<CourseProps> = (props) => {
requiredCourses,
terms,
onDelete,
onAddToBag,
isInBag,
removeFromBag,
} = props;
const CoursePopover = (
<Popover id={'course-popover-' + id}>
Expand Down Expand Up @@ -107,14 +113,18 @@ const Course: FC<CourseProps> = (props) => {
)}
</div>
<div className="title">{title}</div>
{/* <div className="course-footer">
<div className="course-footer">
{onAddToBag && !isInBag && <BagPlus onClick={onAddToBag}></BagPlus>}
{isInBag && <BagFill onClick={removeFromBag}></BagFill>}
{/* <div className="course-footer">
{requiredCourses && (
<OverlayTrigger trigger={['hover', 'focus']} placement="right" overlay={WarningPopover} delay={100}>
<ExclamationTriangle />
</OverlayTrigger>
)}
{/* <div className="units">{minUnits === maxUnits ? minUnits : `${minUnits}-${maxUnits}`} units</div> * /}
</div> */}
</div>
</div>
);
};
Expand Down
45 changes: 45 additions & 0 deletions site/src/pages/RoadmapPage/CourseBag.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="coursebag-container">
<h3 className="coursebag-title">Course Bag</h3>
<div style={{ height: '100%' }}>
{coursebag.map((course, index) => {
return (
<Draggable draggableId={`coursebag-${course.id}-${index}`} key={`coursebag-${index}`} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
// use inline style here so dnd can calculate size
margin: ' 0rem 2rem 1rem 2rem',
cursor: 'grab',
...provided.draggableProps.style,
}}
>
<Course
{...course}
onDelete={() => {
dispatch(removeCourseFromBag(course));
}}
/>
</div>
)}
</Draggable>
);
})}
</div>
</div>
);
};

export default CourseBag;
23 changes: 20 additions & 3 deletions site/src/pages/RoadmapPage/CourseHitItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +19,8 @@ interface CourseHitItemProps extends CourseGQLData {
const CourseHitItem: FC<CourseHitItemProps> = (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));
Expand All @@ -25,6 +32,16 @@ const CourseHitItem: FC<CourseHitItemProps> = (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 (
<div
Expand Down Expand Up @@ -60,7 +77,7 @@ const CourseHitItem: FC<CourseHitItemProps> = (props: CourseHitItemProps) => {
...provided.draggableProps.style,
}}
>
<Course {...props} />
<Course {...props} onAddToBag={onAddToBag} isInBag={isInBag} removeFromBag={removeFromBag} />
</div>
);
}}
Expand Down
29 changes: 29 additions & 0 deletions site/src/pages/RoadmapPage/Coursebag.scss
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 7 additions & 6 deletions site/src/pages/RoadmapPage/Planner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
selectAllPlans,
setAllPlans,
defaultPlan,
setCoursebag,
} from '../../store/slices/roadmapSlice';
import { useFirstRender } from '../../hooks/firstRenderer';
import { SavedRoadmap, MongoRoadmap } from '../../types/types';
Expand All @@ -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<string>());
Expand Down Expand Up @@ -56,16 +58,16 @@ 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
dispatch(setUnsavedChanges(false));

// 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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading