diff --git a/frontend/degree-plan/components/FourYearPlan/Stats.tsx b/frontend/degree-plan/components/FourYearPlan/Stats.tsx index b3559baaf..5f291dc6d 100644 --- a/frontend/degree-plan/components/FourYearPlan/Stats.tsx +++ b/frontend/degree-plan/components/FourYearPlan/Stats.tsx @@ -2,10 +2,11 @@ import React, { useState, useRef, useEffect } from "react"; import styled from '@emotion/styled'; import { CircularProgressbar } from 'react-circular-progressbar'; import 'react-circular-progressbar/dist/styles.css'; +import { Fulfillment } from "@/types"; const getColor = (num: number, reverse: boolean) => { if (isNaN(num)) { - return "#76bf96"; + return "#d6d6d6"; } num = Number(num.toFixed(1)); if (num < 2) { @@ -30,9 +31,9 @@ const ScoreRow = ({ score, label }: { score: number, label: string }) => { return ( <> { - if (courses.length == 0) return [0, 0, 0, 0]; - let courseQualitySum = 0; - let instructorQualitySum = 0; - let difficultySum = 0; - let workRequired = 0; - for (const course in courses) { - courseQualitySum += course.course_quality; - instructorQualitySum += course.instructor_quality; - difficultySum += course.difficulty; - workRequired += course.work_required; +type StatsType = "course_quality" | "instructor_quality" | "difficulty" | "work_required"; +const StatsKeys: StatsType[] = ["course_quality", "instructor_quality", "difficulty", "work_required"]; +const getAverages = (fulfillments: Fulfillment[]) => { + const counts = { course_quality: 0, instructor_quality: 0, difficulty: 0, work_required: 0 }; + const sums = { course_quality: 0, instructor_quality: 0, difficulty: 0, work_required: 0 }; + for (const f of fulfillments) { + for (const key of StatsKeys) { + sums[key] += f.course[key] || 0; + counts[key] += f.course[key] ? 1 : 0; + } } - return [courseQualitySum / courses.length, - instructorQualitySum / courses.length, - difficultySum / courses.length, - workRequired / courses.length]; + const avgs = {} as Record; + for (const key of StatsKeys) { + if (counts[key] == 0) avgs[key] = NaN; + else avgs[key] = sums[key] / counts[key]; + } + return avgs; } -const Stats = ({ courses, className } : { courses: any, className: string }) => { + +const Stats = ({ courses, className } : { courses: Fulfillment[], className: string }) => { + const { course_quality, instructor_quality, difficulty, work_required } = getAverages(courses) as Record; + return ( - - - - + + + + ) } diff --git a/frontend/degree-plan/components/Infobox/CourseInfo.js b/frontend/degree-plan/components/Infobox/CourseInfo.js index 35c93c6a4..759d25694 100644 --- a/frontend/degree-plan/components/Infobox/CourseInfo.js +++ b/frontend/degree-plan/components/Infobox/CourseInfo.js @@ -7,7 +7,7 @@ import { Popover, PopoverTitle } from "./common/Popover"; import { toNormalizedSemester } from "./util/helpers"; import ReactTooltip from "react-tooltip"; -import { GrayIcon } from "../bulma_derived_components"; +import { CloseButton } from "./common/CloseButton"; const activityMap = { REC: "Recitation", @@ -215,15 +215,6 @@ const Spacer = styled.div` height: 0.6rem; `; -const CloseIcon = styled(GrayIcon)` - pointer-events: auto; - margin-left: 0.5rem; - - & :hover { - color: #707070; - } -` - export const CourseHeader = ({ close, aliases, @@ -258,9 +249,7 @@ export const CourseHeader = ({ > - - - + {data.last_offered_sem_if_superceded && ( diff --git a/frontend/degree-plan/components/Infobox/ReviewPanel.tsx b/frontend/degree-plan/components/Infobox/ReviewPanel.tsx index b219991df..f309bdd54 100644 --- a/frontend/degree-plan/components/Infobox/ReviewPanel.tsx +++ b/frontend/degree-plan/components/Infobox/ReviewPanel.tsx @@ -3,21 +3,22 @@ import Draggable from 'react-draggable'; import useSWR from 'swr'; import styled from '@emotion/styled'; import InfoBox from './index' -import { PropsWithChildren, useContext, useEffect, useState } from 'react'; +import { PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'; import { createContext } from 'react'; export const ReviewPanelTrigger = ({ full_code, children }: PropsWithChildren<{full_code: Course["full_code"]}>) => { + const ref = useRef(null); const { setPosition, set_full_code, isPermanent, setIsPermanent } = useContext(ReviewPanelContext); - const [isHovered, setIsHovered] = useState(false); - useEffect(() => { - if (isHovered) set_full_code(full_code); - else setTimeout(() => { if (!isHovered) close()}, 300); - }, [isHovered]) return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + ref={ref} + onDoubleClick={() => { + set_full_code(full_code) + if (!ref.current) return; + const { x, y } = ref.current.getBoundingClientRect(); + if (!isPermanent) setPosition({ x, y}); + }} className="review-panel-trigger" > {children} @@ -35,7 +36,7 @@ interface ReviewPanelContextType { export const ReviewPanelContext = createContext({ position: {x: 0, y: 0}, - setPosition: ([x, y]) => {}, // placeholder + setPosition: ({x, y}) => {}, // placeholder full_code: null, set_full_code: (course) => {}, // placeholder isPermanent: false, diff --git a/frontend/degree-plan/components/Infobox/common/CloseButton.tsx b/frontend/degree-plan/components/Infobox/common/CloseButton.tsx new file mode 100644 index 000000000..9d7d62aeb --- /dev/null +++ b/frontend/degree-plan/components/Infobox/common/CloseButton.tsx @@ -0,0 +1,19 @@ +import { GrayIcon } from '@/components/bulma_derived_components'; +import styled from '@emotion/styled' + +const CloseIcon = styled(GrayIcon)` + pointer-events: auto; + margin-left: 0.5rem; + + & :hover { + color: #707070; + } +` + +const CloseButton = ({ close }) => ( + + + +) + +export default CloseButton; \ No newline at end of file diff --git a/frontend/degree-plan/components/Infobox/common/ErrorBox.tsx b/frontend/degree-plan/components/Infobox/common/ErrorBox.tsx new file mode 100644 index 000000000..b1e919692 --- /dev/null +++ b/frontend/degree-plan/components/Infobox/common/ErrorBox.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren } from "react"; +import { GrayIcon } from "@/components/bulma_derived_components"; + +interface ErrorBoxProps { + detail?: string; +} + +export const ErrorBox = ({ children, detail }: PropsWithChildren) => ( +
+ +

{children}

+ + {detail} If you think this is an error, contact Penn Labs at{" "} + contact@pennlabs.org. + +
+); \ No newline at end of file diff --git a/frontend/degree-plan/components/Infobox/index.js b/frontend/degree-plan/components/Infobox/index.js index 9430f0ba1..b041d0628 100644 --- a/frontend/degree-plan/components/Infobox/index.js +++ b/frontend/degree-plan/components/Infobox/index.js @@ -3,9 +3,12 @@ import styled from "styled-components"; import { lato } from "@/fonts"; import Ratings from "./InfoRatings"; import { CourseDescription, CourseHeader } from "./CourseInfo"; +import { ErrorBox } from "./common/ErrorBox"; const InfoBoxCSS = styled.div` +height: 100%; + .box { font-size: 15px; margin: 0px; @@ -227,6 +230,14 @@ const InfoBoxCSS = styled.div` } ` +const ErrorWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + flex-direction: column; +` + /** * Information box on the left most side, containing scores and descriptions * of course or professor. @@ -267,49 +278,56 @@ const InfoBox = ({ avgDifficulty != null || avgWorkRequired != null; + console.log("data", data) + if (!data) { return

Loading data...

; } return ( -
- - - {hasReviews && ( - + + {hasReviews && ( + + )} + +
+ : + Course not found + + }
); }; diff --git a/frontend/degree-plan/components/Requirements/QObject.tsx b/frontend/degree-plan/components/Requirements/QObject.tsx index 1785741d4..2d47b993d 100644 --- a/frontend/degree-plan/components/Requirements/QObject.tsx +++ b/frontend/degree-plan/components/Requirements/QObject.tsx @@ -8,6 +8,7 @@ import grammar from "@/util/q_object_grammar" import { Icon } from "../bulma_derived_components"; import { BaseCourseContainer } from "../FourYearPlan/CoursePlanned"; import assert from "assert"; +import { ReviewPanelTrigger } from "../Infobox/ReviewPanel"; type ConditionKey = "full_code" | "semester" | "attributes__code__in" | "department__code" | "full_code__startswith" | "code__gte" | "code__lte" | "department__code__in" interface Condition { @@ -39,9 +40,11 @@ const CourseOption = ({ course, chosenOptions, setChosenOptions, semester }: Cou })) return ( - - {course.split("-").join(" ")}{semester ? ` (${semester})` : ""} - + + + {course.split("-").join(" ")}{semester ? ` (${semester})` : ""} + + ) } diff --git a/frontend/degree-plan/components/Requirements/ReqPanel.tsx b/frontend/degree-plan/components/Requirements/ReqPanel.tsx index 0e8a6f930..f223eeb49 100644 --- a/frontend/degree-plan/components/Requirements/ReqPanel.tsx +++ b/frontend/degree-plan/components/Requirements/ReqPanel.tsx @@ -26,7 +26,7 @@ const EmptyPanelContainer = styled.div` `; const EmptyPanelImage = styled.img` - max-width: min(60%, 40vw); + max-width: min(60%, 20rem); `; const EmptyPanel = () => { @@ -78,9 +78,9 @@ const ReqPanel = ({activeDegreePlan, isLoading, highlightReqId, setSearchClosed, if (degree.id === activeDegreeId) setActiveDegreeId(undefined); }, create: () => updateDegreeplan( - {degree_ids: [...(activeDegreePlan?.degree_ids || []), 520]}, // TODO: this is a placeholder, we need to add a new degree + {degree_ids: [...(activeDegreePlan?.degree_ids || []), 1900 ]}, // TODO: this is a placeholder, we need to add a new degree activeDegreePlan?.id - )?.then(() => setActiveDegreeId(520)), + )?.then((res) => { if (res?.id) setActiveDegreeId(res.id); }), }} /> diff --git a/frontend/degree-plan/hooks/swrcrud.ts b/frontend/degree-plan/hooks/swrcrud.ts index 1a697712f..d8fa294c7 100644 --- a/frontend/degree-plan/hooks/swrcrud.ts +++ b/frontend/degree-plan/hooks/swrcrud.ts @@ -1,6 +1,12 @@ import { DBObject, assertValueType } from "@/types"; import { assert } from "console"; import useSWR, { useSWRConfig } from "swr"; +import { updateDecorator } from "typescript"; + +interface SWRCrudError extends Error { + info: any; + status: number; +} // TODO: this is copied from alert and plan, we should move it to a shared location /** @@ -24,49 +30,36 @@ const getCsrf = (): string | boolean => { return result; }; -export const getFetcher = (resource: string) => fetch(resource, { - method: "GET", - credentials: "include", - mode: "same-origin", - headers: { - "Accept": "application/json", - "X-CSRFToken": getCsrf(), - } as HeadersInit, // TODO: idk if this is a good cast -}).then(res => res.json()); - -export const postFetcher = (resource: string, body: any) => fetch(resource, { - method: "POST", - credentials: "include", - mode: "same-origin", - headers: { - "Accept": "application/json", - "X-CSRFToken": getCsrf(), - "Content-Type": "application/json" - } as HeadersInit, - body: JSON.stringify(body) -}).then(res => res.json()); - -export const patchFetcher = (resource: string, body: any) => fetch(resource, { - method: "PATCH", - credentials: "include", - mode: "same-origin", - headers: { - "Accept": "application/json", - "X-CSRFToken": getCsrf(), - "Content-Type": "application/json" - } as HeadersInit, - body: JSON.stringify(body) -}).then(res => res.json()); - -export const deleteFetcher = (resource: string) => fetch(resource, { - method: "DELETE", - credentials: "include", - mode: "same-origin", - headers: { - "Accept": "application/json", - "X-CSRFToken": getCsrf(), - } as HeadersInit, -}); +export const baseFetcher = (init: RequestInit) => async (resource: string, body?: any) => { + const res = await fetch(resource, { + credentials: "include", + mode: "same-origin", + headers: { + "Accept": "application/json", + "X-CSRFToken": getCsrf(), + "Content-Type": "application/json" + } as HeadersInit, + ...init, + body: body === undefined ? undefined : JSON.stringify(body) + }); + if (!res.ok) { + const error = new Error('An error occurred while fetching the data.') as SWRCrudError; + // Attach extra info to the error object. + error.info = await res.json() + error.status = res.status + // TODO: need to figure out how to catch these errors + throw Promise.reject(error); + } + return res.json() +} + +type FetcherWithoutBody = (resource: string) => Promise; +type FetcherWithBody = (resource: string, body: any) => Promise; + +export const getFetcher: FetcherWithoutBody = baseFetcher({ method: "GET" }) +export const postFetcher: FetcherWithBody = baseFetcher({ method: "POST" }) +export const patchFetcher: FetcherWithBody = baseFetcher({ method: "PATCH" }) +export const deleteFetcher: FetcherWithoutBody = baseFetcher({ method: "DELETE" }); const normalizeFinalSlash = (resource: string) => { if (!resource.endsWith("/")) resource += "/"; @@ -82,7 +75,7 @@ export const useSWRCrud = ( updateFetcher: patchFetcher, removeFetcher: deleteFetcher, createOrUpdateFetcher: postFetcher, - idKey: "id" as keyof T , + idKey: "id" as keyof T, ...config } @@ -110,7 +103,8 @@ export const useSWRCrud = ( optimistic[idKey] = id; return ({ id, ...data, ...updatedData} as T) }, - revalidate: false + revalidate: false, + throwOnError: false }) mutate(endpoint, updated, { @@ -125,7 +119,9 @@ export const useSWRCrud = ( return list; }, populateCache: (updated: T, list?: Array) => { + console.log("swrcrud: update: populateCache", updated, list) if (!list) return []; + if (!updated) return list; const index = list.findIndex((item: T) => item[idKey] === updated[idKey]); if (index === -1) { console.warn("swrcrud: update: updated element not found in list view"); @@ -136,6 +132,7 @@ export const useSWRCrud = ( return list }, revalidate: false, + throwOnError: false }) return updated; @@ -147,7 +144,11 @@ export const useSWRCrud = ( const removed = removeFetcher(key); mutate(endpoint, removed, { optimisticData: (list?: Array) => list ? list.filter((item: T) => String(item[idKey]) !== id) : [], - populateCache: (_, list?: Array) => list ? list.filter((item: any) => item[idKey] !== id) : [], + populateCache: (_, list?: Array) => { + if (!list) return [] + if (!removed) return list; + return list.filter((item: any) => item[idKey] !== id) + }, revalidate: false }) mutate(key, removed, { @@ -174,6 +175,7 @@ export const useSWRCrud = ( return [...list.filter((item: T) => item[idKey] !== id), optimistic] }, populateCache: (updated: T, list: Array | undefined) => { + if (!updated) return list || []; if (!list) return [updated]; return [...list.filter((item: T) => item[idKey] !== id), updated] }, diff --git a/frontend/degree-plan/pages/FourYearPlanPage.tsx b/frontend/degree-plan/pages/FourYearPlanPage.tsx index 1b9c954f4..8c03a2c1a 100644 --- a/frontend/degree-plan/pages/FourYearPlanPage.tsx +++ b/frontend/degree-plan/pages/FourYearPlanPage.tsx @@ -75,7 +75,7 @@ const FourYearPlanPage = () => { // review panel const { data: options } = useSWR('/api/options'); const [reviewPanelCoords, setReviewPanelCoords] = useState<{x: number, y: number}>({ x: 0, y: 0 }); - const [reviewPanelFullCode, setReviewPanelFullCode] = useState("CIS-120"); + const [reviewPanelFullCode, setReviewPanelFullCode] = useState(null); const [reviewPanelIsPermanent, setReviewPanelIsPermanent] = useState(false); const [searchClosed, setSearchClosed] = useState(true); diff --git a/frontend/degree-plan/types.ts b/frontend/degree-plan/types.ts index 242c20aa2..f32c71593 100644 --- a/frontend/degree-plan/types.ts +++ b/frontend/degree-plan/types.ts @@ -59,13 +59,17 @@ export interface Course { full_code: string; description: string; semester: string; + instructor_quality: number; + course_quality: number; + work_required: number; + difficulty: number; } export interface Fulfillment extends DBObject { id: number; degree_plan: number; // id full_code: string; - historical_course: Course; // id + course: Course; // id semester: string | null; rules: number[]; // ids }