diff --git a/backend/courses/migrations/0069_userprofile_has_been_onboarded.py b/backend/courses/migrations/0069_userprofile_has_been_onboarded.py new file mode 100644 index 000000000..debbd6d20 --- /dev/null +++ b/backend/courses/migrations/0069_userprofile_has_been_onboarded.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.2 on 2025-12-04 22:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("courses", "0068_merge_20250819_1758"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="has_been_onboarded", + field=models.BooleanField( + default=False, + help_text="Defaults to False, changed to True once the user completes onboarding.", + ), + ), + ] diff --git a/backend/courses/models.py b/backend/courses/models.py index a7c25153b..67953c9f3 100644 --- a/backend/courses/models.py +++ b/backend/courses/models.py @@ -1436,6 +1436,11 @@ class UserProfile(models.Model): ) # phone field defined underneath validate_phone function below + has_been_onboarded = models.BooleanField( + default=False, + help_text="Defaults to False, changed to True once the user completes onboarding.", + ) + def validate_phone(value): """ Validator to check that a phone number is in the proper form. The number must be in a diff --git a/backend/courses/serializers.py b/backend/courses/serializers.py index 313397cab..99621273d 100644 --- a/backend/courses/serializers.py +++ b/backend/courses/serializers.py @@ -438,7 +438,7 @@ class Meta: class UserProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ["email", "phone", "push_notifications"] + fields = ["email", "phone", "push_notifications", "has_been_onboarded"] class UserSerializer(serializers.ModelSerializer): @@ -455,7 +455,7 @@ def update(self, instance, validated_data): if key in validated_data: setattr(instance, key, validated_data[key]) if prof_data is not None: - for key in ["phone", "email", "push_notifications"]: + for key in ["phone", "email", "push_notifications", "has_been_onboarded"]: if key in prof_data: setattr(prof, key, prof_data[key]) prof.save() diff --git a/backend/courses/views.py b/backend/courses/views.py index ce241ed09..32148353d 100644 --- a/backend/courses/views.py +++ b/backend/courses/views.py @@ -415,6 +415,12 @@ def get_queryset(self): def get_object(self): return self.request.user + def post(self, request, *args, **kwargs): + if request.data.get("has_seen_onboarding") is True: + request.user.has_seen_onboarding = True + request.user.save() + return self.partial_update(request, *args, **kwargs) + class StatusUpdateView(generics.ListAPIView): """ diff --git a/backend/degree/migrations/0003_alter_fulfillment_legal_and_more.py b/backend/degree/migrations/0003_alter_fulfillment_legal_and_more.py new file mode 100644 index 000000000..df05fd45e --- /dev/null +++ b/backend/degree/migrations/0003_alter_fulfillment_legal_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.2 on 2025-12-04 22:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("degree", "0002_fulfillment_legal_fulfillment_unselected_rules_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="fulfillment", + name="legal", + field=models.BooleanField( + default=True, + help_text="\nTrue if course associated with this fulfillment isn't illegally double counted anywhere,\nfalse otherwise.\n", + ), + ), + migrations.AlterField( + model_name="fulfillment", + name="unselected_rules", + field=models.ManyToManyField( + blank=True, + help_text="\nThe rules this course fulfills that should be shown in the open-ended rule box\n(as opposed to the expandable box). Blank if this course should not be included in\nany open-ended rule boxes.\n", + related_name="unselected", + to="degree.rule", + ), + ), + migrations.AlterField( + model_name="rule", + name="can_double_count_with", + field=models.ManyToManyField( + blank=True, + help_text="\nParent rules that can double count with this rule.\n(i.e. if this rule is Quantitative Data Analysis (a College Foundations req),\nthen this field would contain the General Educations: Sector rule as well as\nthe Major in ___ rule.)\n", + to="degree.rule", + ), + ), + ] diff --git a/frontend/degree-plan/components/Course/Course.tsx b/frontend/degree-plan/components/Course/Course.tsx index e63d89ba5..cc7ff801f 100644 --- a/frontend/degree-plan/components/Course/Course.tsx +++ b/frontend/degree-plan/components/Course/Course.tsx @@ -10,228 +10,245 @@ import { TRANSFER_CREDIT_SEMESTER_KEY } from "@/constants"; import { Tooltip } from "react-tooltip"; const DOUBLE_COUNT_ERROR_MESSAGE = - "This course is being illegally double counted in your plan!"; + "This course is being illegally double counted in your plan!"; const COURSE_BORDER_RADIUS = "9px"; export const BaseCourseContainer = styled.div<{ - $isDragging?: boolean; - $isUsed: boolean; - $isDisabled: boolean; + $isDragging?: boolean; + $isUsed: boolean; + $isDisabled: boolean; }>` - display: flex; - justify-content: center; - align-items: center; - min-width: 70px; - min-height: 35px; - border-radius: ${COURSE_BORDER_RADIUS}; - padding: 0.75rem; - text-wrap: nowrap; - cursor: ${(props) => - props.$isDisabled || props.$isUsed ? "not-allowed" : "grab"}; - opacity: ${(props) => (props.$isDisabled || props.$isDragging ? 0.7 : 1)}; - background-color: ${(props) => - props.$isDragging ? "#4B9AE7" : "var(--background-grey)"}; - box-shadow: rgba(0, 0, 0, 0.01) 0px 6px 5px 0px, - rgba(0, 0, 0, 0.04) 0px 0px 0px 1px; + display: flex; + justify-content: center; + align-items: center; + min-width: 70px; + min-height: 35px; + border-radius: ${COURSE_BORDER_RADIUS}; + padding: 0.75rem; + text-wrap: nowrap; + cursor: ${(props) => + props.$isDisabled || props.$isUsed ? "not-allowed" : "grab"}; + opacity: ${(props) => (props.$isDisabled || props.$isDragging ? 0.7 : 1)}; + background-color: ${(props) => + props.$isDragging ? "#4B9AE7" : "var(--background-grey)"}; + box-shadow: rgba(0, 0, 0, 0.01) 0px 6px 5px 0px, + rgba(0, 0, 0, 0.04) 0px 0px 0px 1px; `; export const PlannedCourseContainer = styled(BaseCourseContainer)` - width: 100%; - position: relative; - opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; - - .close-button { - padding-left: 1rem; - padding-right: 10px; - margin-top: auto; - margin-bottom: auto; - height: 100%; - align-items: center; - opacity: 0.6; - } + width: 100%; + position: relative; + opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; - .illegal-icon { - padding-right: 0.5rem; - margin-top: auto; - margin-bottom: auto; - } - - &:hover { .close-button { - opacity: unset; + padding-left: 1rem; + padding-right: 10px; + margin-top: auto; + margin-bottom: auto; + height: 100%; + align-items: center; + opacity: 0.6; + } + + .illegal-icon { + padding-right: 0.5rem; + margin-top: auto; + margin-bottom: auto; + } + + &:hover { + .close-button { + opacity: unset; + } } - } `; const CloseIcon = styled(GrayIcon)<{ $hidden: boolean }>` - visibility: ${(props) => (props.$hidden ? "hidden" : "visible")}; + visibility: ${(props) => (props.$hidden ? "hidden" : "visible")}; `; export const CourseXButton = ({ - onClick, - hidden, + onClick, + hidden, }: { - onClick?: (e: React.MouseEvent) => void; - hidden: boolean; + onClick?: (e: React.MouseEvent) => void; + hidden: boolean; }) => ( - - - + + + ); interface DraggableComponentProps { - courseType: string; - course: DnDCourse; - removeCourse: (course: Course["id"]) => void; - semester?: Course["semester"]; - isUsed: boolean; - isDisabled: boolean; - isDragging: boolean; - fulfillment?: Fulfillment; - className?: string; - onClick?: (arg0: React.MouseEvent) => void; - dragRef: ConnectDragSource; + courseType: string; + course: DnDCourse; + removeCourse: (course: Course["id"]) => void; + semester?: Course["semester"]; + isUsed: boolean; + isDisabled: boolean; + isDragging: boolean; + fulfillment?: Fulfillment; + className?: string; + onClick?: (arg0: React.MouseEvent) => void; + dragRef: ConnectDragSource; } export const SkeletonCourse = () => ( - - - + + + ); const CourseBadge = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 0.25rem; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; `; const IconBadge = styled.div` - padding: 0.2rem; - border-radius: 5px; - display: flex; - flex-direction: row; - align-items: center; - gap: 0.07rem; - background-color: #ebebeb; - - p { - font-size: 14px; - font-weight: bold; - } + padding: 0.2rem; + border-radius: 5px; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.07rem; + background-color: #ebebeb; + + p { + font-size: 14px; + font-weight: bold; + } `; const ExclamationIcon = ({ color }: { color: string }) => { - const Exclamation = styled(Icon)` - width: 1rem; - height: 1rem; - display: flex; - fill: ${color}; - `; - - return ( - - - - - - ); + const Exclamation = styled(Icon)` + width: 1rem; + height: 1rem; + display: flex; + fill: ${color}; + `; + + return ( + + + + + + ); }; const SemesterIcon = ({ semester }: { semester: string | null }) => { - if (!semester) return
; - const year = - semester === TRANSFER_CREDIT_SEMESTER_KEY ? "AP" : semester.substring(2, 4); - const sem = semester.substring(4); - - return ( - -

{year}

- {sem === "A" && } - {sem === "B" && } - {sem === "C" && } -
- ); + if (!semester) return
; + const year = + semester === TRANSFER_CREDIT_SEMESTER_KEY + ? "AP" + : semester.substring(2, 4); + const sem = semester.substring(4); + + return ( + +

{year}

+ {sem === "A" && } + {sem === "B" && } + {sem === "C" && } +
+ ); }; const CourseComponent = ({ - courseType, - course, - fulfillment, - removeCourse, - isUsed = false, - isDisabled = false, - className, - onClick, - isDragging, - dragRef, + courseType, + course, + fulfillment, + removeCourse, + isUsed = false, + isDisabled = false, + className, + onClick, + isDragging, + dragRef, }: DraggableComponentProps) => { - if (!!fulfillment) { + if (!!fulfillment) { + return ( + + + + {!fulfillment?.legal && ( +
+ + + + +
+ )} + + {course.full_code.replace("-", " ")} + {fulfillment.semester !== "" && ( + + )} + + {isUsed && ( + { + removeCourse(course.full_code); + e.stopPropagation(); + }} + hidden={false} + /> + )} +
+
+
+ ); + } return ( - - - - {!fulfillment?.legal && ( -
- + + - - - -
- )} - - {course.full_code.replace("-", " ")} - {fulfillment.semester !== "" && ( - - )} - - {isUsed && ( - { - removeCourse(course.full_code); - e.stopPropagation(); - }} - hidden={false} - /> - )} -
-
-
+ + {course.full_code.replace("-", " ")} + + {isUsed && ( + { + removeCourse(course.full_code); + e.stopPropagation(); + }} + hidden={false} + /> + )} + + + ); - } - return ( - - - - {course.full_code.replace("-", " ")} - {isUsed && ( - { - removeCourse(course.full_code); - e.stopPropagation(); - }} - hidden={false} - /> - )} - - - - ); }; export default CourseComponent; diff --git a/frontend/degree-plan/components/CreateDegreePlanModal.tsx b/frontend/degree-plan/components/CreateDegreePlanModal.tsx index 753447630..100c4ee58 100644 --- a/frontend/degree-plan/components/CreateDegreePlanModal.tsx +++ b/frontend/degree-plan/components/CreateDegreePlanModal.tsx @@ -1,152 +1,155 @@ import { Degree, DegreePlan } from "@/types"; import { - Autocomplete, - Box, - Button, - FormControl, - Modal, - Paper, - TextField, + Autocomplete, + Box, + Button, + FormControl, + Modal, + Paper, + TextField, } from "@mui/material"; import { useEffect, useState } from "react"; interface SelectDegreePlanModalProps { - open: boolean; - setOpen: React.Dispatch>; - addDegreePlan: (degreePlan: DegreePlan) => void; - force: boolean; // whether to keep this modal open no matter what + open: boolean; + setOpen: React.Dispatch>; + addDegreePlan: (degreePlan: DegreePlan) => void; + force: boolean; // whether to keep this modal open no matter what } interface DegreeOption { - label: string; - value: Degree; + label: string; + value: Degree; } const CreateDegreePlanModal = ({ - open, - setOpen, - addDegreePlan, - force = false, + open, + setOpen, + addDegreePlan, + force = false, }: SelectDegreePlanModalProps) => { - const [loading, setLoading] = useState(true); - const [degreeOptions, setDegreeOptions] = useState>([]); - const [selectedDegree, setSelectedDegree] = useState(); - const [name, setName] = useState(""); + const [loading, setLoading] = useState(true); + const [degreeOptions, setDegreeOptions] = useState>([]); + const [selectedDegree, setSelectedDegree] = useState(); + const [name, setName] = useState(""); - // close the modal - const handleClose = () => { - if (force) return; - setOpen(false); - }; + // close the modal + const handleClose = () => { + if (force) return; + setOpen(false); + }; - // create a new degree plan - const createDegreePlan = () => { - if (!selectedDegree || name === "") return; - async () => { - const res = await fetch("/api/degree/degreeplans", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - degreePlanId: selectedDegree.value.id, - name: name, - }), - }); + // create a new degree plan + const createDegreePlan = () => { + if (!selectedDegree || name === "") return; + async () => { + const res = await fetch("/api/degree/degreeplans", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + degreePlanId: selectedDegree.value.id, + name: name, + }), + }); - if (!res.ok) { - console.error(res); - } + if (!res.ok) { + console.error(res); + } - const data = await res.json(); - if (data) { - addDegreePlan(data); - setOpen(false); - } else { - console.error(data); - } + const data = await res.json(); + if (data) { + addDegreePlan(data); + setOpen(false); + } else { + console.error(data); + } + }; }; - }; - // get all degrees - useEffect(() => { - fetch("/api/degree/degrees") - .then((res) => res.json()) - .then((res) => { - setDegreeOptions( - res.map((degree: Degree) => { - return { - label: - degree.year + - ": " + - degree.program + - " " + - degree.major + - " " + - (degree.concentration || ""), - value: degree, - }; - }) - ); - setLoading(false); - }) - .catch((err) => { - console.error(err); - setLoading(false); - }); - }, [open]); + // get all degrees + useEffect(() => { + fetch("/api/degree/degrees") + .then((res) => res.json()) + .then((res) => { + setDegreeOptions( + res.map((degree: Degree) => { + return { + label: + degree.year + + ": " + + degree.program + + " " + + degree.major + + " " + + (degree.concentration || ""), + value: degree, + }; + }), + ); + setLoading(false); + }) + .catch((err) => { + console.error(err); + setLoading(false); + }); + }, [open]); - return ( - - -

Create degree plan

- - setName(e.target.value)} - /> - - value && setSelectedDegree(value) - } - renderInput={(params) => ( - - )} - loading={loading} - /> - - -
-
- ); + +

Create degree plan

+ + setName(e.target.value)} + /> + + value && setSelectedDegree(value) + } + renderInput={(params) => ( + + )} + loading={loading} + /> + + +
+ + ); }; export default CreateDegreePlanModal; diff --git a/frontend/degree-plan/components/Dock/CourseInDock.tsx b/frontend/degree-plan/components/Dock/CourseInDock.tsx index f146d2b1d..e042bd233 100644 --- a/frontend/degree-plan/components/Dock/CourseInDock.tsx +++ b/frontend/degree-plan/components/Dock/CourseInDock.tsx @@ -9,28 +9,43 @@ interface CourseInDockProps { isDisabled: boolean; className?: string; onClick?: () => void; - } +} -const CourseInDock = (props : CourseInDockProps) => { +const CourseInDock = (props: CourseInDockProps) => { const { course } = props; - const { remove } = useSWRCrud(`/api/degree/docked`, { idKey: 'full_code' }); + const { remove } = useSWRCrud(`/api/degree/docked`, { + idKey: "full_code", + }); const handleRemoveCourse = (full_code: string) => { - remove(full_code); - } + remove(full_code); + }; + + const [{ isDragging }, drag] = useDrag< + DnDCourse, + never, + { isDragging: boolean } + >( + () => ({ + type: ItemTypes.COURSE_IN_DOCK, + item: course, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + }), + [course], + ); - const [{ isDragging }, drag] = useDrag(() => ({ - type: ItemTypes.COURSE_IN_DOCK, - item: course, - collect: (monitor) => ({ - isDragging: !!monitor.isDragging() - }) - }), [course]) - return ( - - ) - } - - - export default CourseInDock; \ No newline at end of file + + ); +}; + +export default CourseInDock; diff --git a/frontend/degree-plan/components/Dock/Dock.tsx b/frontend/degree-plan/components/Dock/Dock.tsx index 5cfdcf13b..d8bb9ec16 100644 --- a/frontend/degree-plan/components/Dock/Dock.tsx +++ b/frontend/degree-plan/components/Dock/Dock.tsx @@ -1,20 +1,20 @@ - -import styled from '@emotion/styled'; -import { DarkBlueIcon } from '../Requirements/QObject'; -import React, { useContext, useEffect } from "react"; +import styled from "@emotion/styled"; +import { DarkBlueIcon } from "../Requirements/QObject"; +import React, { useContext, useEffect, useRef } from "react"; import { useDrop } from "react-dnd"; import { DegreePlan, DnDCourse, DockedCourse, User } from "@/types"; import { ItemTypes } from "./dnd/constants"; -import { SearchPanelContext } from '../Search/SearchPanel'; -import { useSWRCrud } from '@/hooks/swrcrud'; -import useSWR from 'swr'; +import { SearchPanelContext } from "../Search/SearchPanel"; +import { useSWRCrud } from "@/hooks/swrcrud"; +import useSWR from "swr"; import { DarkBlueBackgroundSkeleton } from "../FourYearPlan/PanelCommon"; // TODO: Move shared components to typescript // @ts-ignore import AccountIndicator from "pcx-shared-components/src/accounts/AccountIndicator"; -import _ from 'lodash'; -import CourseInDock from './CourseInDock'; -import { useRouter } from 'next/router'; +import _ from "lodash"; +import CourseInDock from "./CourseInDock"; +import { useRouter } from "next/router"; +import { TutorialModalContext } from "../FourYearPlan/OnboardingTutorial"; const DockWrapper = styled.div` z-index: 1; @@ -22,21 +22,26 @@ const DockWrapper = styled.div` display: flex; justify-content: center; flex-grow: 0; -` +`; -const DockContainer = styled.div<{$isDroppable:boolean, $isOver: boolean}>` +const DockContainer = styled.div<{ $isDroppable: boolean; $isOver: boolean }>` border-radius: 0px; - box-shadow: 0px 0px 4px 2px ${props => props.$isOver ? 'var(--selected-color);' : props.$isDroppable ? 'var(--primary-color-dark);' : 'rgba(0, 0, 0, 0.05);'} + box-shadow: 0px 0px 4px 2px ${(props) => + props.$isOver + ? "var(--selected-color);" + : props.$isDroppable + ? "var(--primary-color-dark);" + : "rgba(0, 0, 0, 0.05);"} background-color: var(--primary-color); width: 100%; display: flex; align-items: center; padding: 1rem 1rem; gap: 1rem; -` +`; const SearchIconContainer = styled.div` - padding: .25rem 2rem; + padding: 0.25rem 2rem; padding-left: 0; border-color: var(--primary-color-xx-dark); color: var(--primary-color-extra-dark); @@ -46,7 +51,7 @@ const SearchIconContainer = styled.div` flex-shrink: 0; display: flex; gap: 1rem; -` +`; const DockedCoursesWrapper = styled.div` height: 100%; @@ -59,8 +64,8 @@ const DockedCoursesWrapper = styled.div` overflow-x: auto; /* Hide scrollbar */ - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ /* Hide scrollbar for Chrome, Safari and Opera */ &::-webkit-scrollbar { @@ -68,7 +73,7 @@ const DockedCoursesWrapper = styled.div` height: 100px; width: 100px; } -` +`; const DockedCourses = styled.div` height: 100%; @@ -77,26 +82,26 @@ const DockedCourses = styled.div` gap: 1rem; padding: 0.1rem; overflow: auto; -` +`; const CenteringCourseDock = styled.div` color: var(--primary-color-extra-dark); margin-left: auto; margin-right: auto; -` +`; const Logo = styled.img` flex-shrink: 0; -` +`; const AnimatedDockedCourseItem = styled(CourseInDock)` z-index: 1000; background: var(--background-grey); animation-name: jump; - animation-duration: 1.5s; - animation-iteration-count: 1; - animation-timing-function: linear; -` + animation-duration: 1.5s; + animation-iteration-count: 1; + animation-timing-function: linear; +`; interface DockProps { login: (u: User) => void; @@ -105,86 +110,120 @@ interface DockProps { activeDegreeplanId: DegreePlan["id"] | null; } -const Dock = ({ user, login, logout, activeDegreeplanId }: DockProps) => { - const { searchPanelOpen, setSearchPanelOpen, setSearchRuleQuery, setSearchRuleId } = useContext(SearchPanelContext) - const { createOrUpdate } = useSWRCrud(`/api/degree/docked`, { idKey: 'full_code' }); - const { data: dockedCourses = [], isLoading } = useSWR(user ? `/api/degree/docked` : null); +const Dock = ({ user, login, logout, activeDegreeplanId }: DockProps) => { + const { + searchPanelOpen, + setSearchPanelOpen, + setSearchRuleQuery, + setSearchRuleId, + } = useContext(SearchPanelContext); + const { createOrUpdate } = useSWRCrud(`/api/degree/docked`, { + idKey: "full_code", + }); + const { data: dockedCourses = [], isLoading } = useSWR( + user ? `/api/degree/docked` : null, + ); // Returns a boolean that indiates whether this is the first render const useIsMount = () => { const isMountRef = React.useRef(true); useEffect(() => { - isMountRef.current = false; + isMountRef.current = false; }, []); return isMountRef.current; - }; - - const [{ isOver, canDrop }, drop] = useDrop(() => ({ - accept: [ItemTypes.COURSE_IN_PLAN, ItemTypes.COURSE_IN_REQ, ItemTypes.COURSE_IN_SEARCH], - drop: (course: DnDCourse) => { - createOrUpdate({"full_code": course.full_code}, course.full_code); - }, - collect: monitor => ({ - isOver: !!monitor.isOver(), - canDrop: !!monitor.canDrop() + }; + + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: [ + ItemTypes.COURSE_IN_PLAN, + ItemTypes.COURSE_IN_REQ, + ItemTypes.COURSE_IN_SEARCH, + ], + drop: (course: DnDCourse) => { + createOrUpdate( + { full_code: course.full_code }, + course.full_code, + ); + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), }), - }), []); + [], + ); const { asPath } = useRouter(); - return ( - - - - { - setSearchRuleQuery(""); - setSearchRuleId(null); - setSearchPanelOpen(!searchPanelOpen); - }}> - - - -
- Add Course -
-
- - {isLoading ? - - - - : - !dockedCourses.length ? Drop courses in the dock for later. : - // courseAdded ? - // - // {dockedCourses.map((course, i) => { - // if (i == dockedCourses.length - 1) { - // return - // } - // return - // } - // )} - // - // : - - {dockedCourses.map((course) => - - )} - } - - -
-
- ) -} + const { + tutorialModalKey, + highlightedComponentRef, + componentRefs, + } = useContext(TutorialModalContext); + const dockRef = React.useRef(null); + const isDockStep = + tutorialModalKey === "courses-dock" || + tutorialModalKey === "general-search"; -export default Dock; \ No newline at end of file + useEffect(() => { + if (!componentRefs?.current || !dockRef.current) return; + + componentRefs.current["dock"] = dockRef.current; + dockRef.current.style.zIndex = isDockStep ? "20" : "0"; + }, [componentRefs, isDockStep]); + + return ( +
+ + + + { + setSearchRuleQuery(""); + setSearchRuleId(null); + setSearchPanelOpen(!searchPanelOpen); + }} + > + + + +
Add Course
+
+ + {isLoading ? ( + + + + ) : !dockedCourses.length ? ( + + Drop courses in the dock for later. + + ) : ( + + {dockedCourses.map((course) => ( + + ))} + + )} + + +
+
+
+ ); +}; + +export default Dock; diff --git a/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanel.tsx b/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanel.tsx index c9286541b..ac88b1171 100644 --- a/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanel.tsx +++ b/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanel.tsx @@ -6,49 +6,49 @@ import { useSWRCrud } from "@/hooks/swrcrud"; import { DropTargetMonitor, useDrop } from "react-dnd"; import { ItemTypes } from "../Dock/dnd/constants"; import { - ExpandedCoursesPanelContext, - ExpandedCoursesPanelContextType, + ExpandedCoursesPanelContext, + ExpandedCoursesPanelContextType, } from "@/components/ExpandedBox/ExpandedCoursesPanelTrigger"; const useOutsideAlerter = ( - ref: MutableRefObject, - retract: () => void, - setOpen: (arg0: boolean) => void + ref: MutableRefObject, + retract: () => void, + setOpen: (arg0: boolean) => void, ) => { - const { setCourses, setRuleId, searchRef } = useContext( - ExpandedCoursesPanelContext - ); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - ref.current && - !ref.current.contains(event.target) && - !searchRef?.current.contains(event.target) - ) { - setCourses(null); - setRuleId(null); - retract(); - setOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [ref]); + const { setCourses, setRuleId, searchRef } = useContext( + ExpandedCoursesPanelContext, + ); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + ref.current && + !ref.current.contains(event.target) && + !searchRef?.current.contains(event.target) + ) { + setCourses(null); + setRuleId(null); + retract(); + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [ref]); }; interface ExpandedCoursesPanelProps extends ExpandedCoursesPanelContextType { - currentSemester?: string; + currentSemester?: string; } const ExpandedCoursesPanelWrapper = styled.div<{ - $left?: number; - $right?: number; - $top?: number; - $bottom?: number; - $isDroppable: boolean; - $isOver: boolean; + $left?: number; + $right?: number; + $top?: number; + $bottom?: number; + $isDroppable: boolean; + $isOver: boolean; }>` position: absolute; z-index: 100; @@ -61,166 +61,168 @@ const ExpandedCoursesPanelWrapper = styled.div<{ ${(props) => (props.$top ? `top: ${props.$top}px;` : "")} ${(props) => (props.$bottom ? `bottom: ${props.$bottom}px;` : "")} box-shadow: ${(props) => - props.$isOver - ? "0px 0px 4px 2px var(--selected-color);" - : props.$isDroppable - ? "0px 0px 4px 2px var(--primary-color-dark);" - : "rgba(0, 0, 0, 0.05);"}; + props.$isOver + ? "0px 0px 4px 2px var(--selected-color);" + : props.$isDroppable + ? "0px 0px 4px 2px var(--primary-color-dark);" + : "rgba(0, 0, 0, 0.05);"}; // box-shadow: 0 0 0 max(100vh, 100vw) rgba(0, 0, 0, .2); `; const ExpandedCoursesPanelContainer = styled.div` - background-color: var(--primary-color-light); - overflow: auto; - height: 100%; + background-color: var(--primary-color-light); + overflow: auto; + height: 100%; `; const ExpandedCourses = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; - cursor: pointer; - padding: 0.5rem 0.75rem; - height: 100%; - gap: 0.5rem; - padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + height: 100%; + gap: 0.5rem; + padding: 1rem; `; const EmptyExpandedText = styled.div` - display: flex; - height: 5rem; - padding: 0 2rem; - align-items: center; - justify-content: center; - text-align: center; - color: var(--primary-color-extra-dark); + display: flex; + height: 5rem; + padding: 0 2rem; + align-items: center; + justify-content: center; + text-align: center; + color: var(--primary-color-extra-dark); `; const ExpandedCoursesHeader = styled.div` - font-weight: bold; + font-weight: bold; `; const CourseList = styled.div` - // flex-grow: 1; - gap: 0.5rem; - display: grid; - grid-template-columns: auto auto; + // flex-grow: 1; + gap: 0.5rem; + display: grid; + grid-template-columns: auto auto; `; const ExpandedCoursesPanel = ({ - courses, - setCourses, - position, - setPosition, - currentSemester, - retract, - setOpen, - ruleId, - searchRef, - setSearchRef, - degreePlanId, + courses, + setCourses, + position, + setPosition, + currentSemester, + retract, + setOpen, + ruleId, + searchRef, + setSearchRef, + degreePlanId, }: ExpandedCoursesPanelProps) => { - let { left, right, top, bottom } = position; - - // Set default offsets just in case left and right or top and bottom values aren't set. - if (!left && !right) left = 0; - if (!top && !bottom) top = 0; - right = left === undefined ? right : undefined; - bottom = top === undefined ? bottom : undefined; - const wrapperRef = useRef(null); - useOutsideAlerter(wrapperRef, retract, setOpen); - - // hooks for LEAFs - const { createOrUpdate } = useSWRCrud( - `/api/degree/degreeplans/${degreePlanId}/fulfillments`, - { - idKey: "full_code", - createDefaultOptimisticData: { semester: null, rules: [] }, - } - ); - - const handleDrop = (course: DnDCourse) => { - if (ruleId) { - course.rules?.splice(course.rules?.indexOf(ruleId), 1); - createOrUpdate( + let { left, right, top, bottom } = position; + + // Set default offsets just in case left and right or top and bottom values aren't set. + if (!left && !right) left = 0; + if (!top && !bottom) top = 0; + right = left === undefined ? right : undefined; + bottom = top === undefined ? bottom : undefined; + const wrapperRef = useRef(null); + useOutsideAlerter(wrapperRef, retract, setOpen); + + // hooks for LEAFs + const { createOrUpdate } = useSWRCrud( + `/api/degree/degreeplans/${degreePlanId}/fulfillments`, { - rules: course.rules !== undefined ? course.rules : [], - unselected_rules: - course.unselected_rules !== undefined - ? [...course.unselected_rules, ruleId] - : [ruleId], + idKey: "full_code", + createDefaultOptimisticData: { semester: null, rules: [] }, }, - course.full_code - ); - } - - if (courses && course.fulfillment) { - setCourses([...courses, course.fulfillment]); - } - - return undefined; - }; // TODO: this doesn't handle fulfillments that already have a rule - - const handleCanDrop = (course: DnDCourse) => { - return ruleId === course.rule_id; - }; + ); + + const handleDrop = (course: DnDCourse) => { + if (ruleId) { + course.rules?.splice(course.rules?.indexOf(ruleId), 1); + createOrUpdate( + { + rules: course.rules !== undefined ? course.rules : [], + unselected_rules: + course.unselected_rules !== undefined + ? [...course.unselected_rules, ruleId] + : [ruleId], + }, + course.full_code, + ); + } + + if (courses && course.fulfillment) { + setCourses([...courses, course.fulfillment]); + } + + return undefined; + }; // TODO: this doesn't handle fulfillments that already have a rule + + const handleCanDrop = (course: DnDCourse) => { + return ruleId === course.rule_id; + }; - const handleCollect = (monitor: DropTargetMonitor) => { - return { - isOver: !!monitor.isOver(), - canDrop: !!monitor.canDrop(), + const handleCollect = (monitor: DropTargetMonitor) => { + return { + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }; }; - }; - - const [{ isOver, canDrop }, drop] = useDrop< - DnDCourse, - never, - { isOver: boolean; canDrop: boolean } - >( - { - accept: [ItemTypes.COURSE_IN_REQ], - drop: handleDrop, - canDrop: handleCanDrop, - collect: handleCollect, - }, - [createOrUpdate] - ); - - return ( - - - - Also satisfied by... - {courses && courses?.length > 0 ? ( - - {courses?.map((fulfillment) => ( - - ))} - - ) : ( - - Drag any extra courses here that you don't want to use for this - requirement. - - )} - - - - ); + + const [{ isOver, canDrop }, drop] = useDrop< + DnDCourse, + never, + { isOver: boolean; canDrop: boolean } + >( + { + accept: [ItemTypes.COURSE_IN_REQ], + drop: handleDrop, + canDrop: handleCanDrop, + collect: handleCollect, + }, + [createOrUpdate], + ); + + return ( + + + + + Also satisfied by... + + {courses && courses?.length > 0 ? ( + + {courses?.map((fulfillment) => ( + + ))} + + ) : ( + + Drag any extra courses here that you don't want to + use for this requirement. + + )} + + + + ); }; export default ExpandedCoursesPanel; diff --git a/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanelTrigger.tsx b/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanelTrigger.tsx index a3b14946b..e66911c05 100644 --- a/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanelTrigger.tsx +++ b/frontend/degree-plan/components/ExpandedBox/ExpandedCoursesPanelTrigger.tsx @@ -1,119 +1,119 @@ import { Fulfillment } from "@/types"; import { - createContext, - MutableRefObject, - PropsWithChildren, - useContext, - useRef, + createContext, + MutableRefObject, + PropsWithChildren, + useContext, + useRef, } from "react"; export interface ExpandedCoursesPanelContextType { - position: { top?: number; bottom?: number; left?: number; right?: number }; - setPosition: (arg0: ExpandedCoursesPanelContextType["position"]) => void; - courses: Fulfillment[] | null | undefined; - setCourses: (arg0: Fulfillment[] | null | undefined) => void; - retract: () => void; - set_retract: (arg0: () => void) => void; - open: boolean; - setOpen: (arg0: boolean) => void; - ruleId: number | null; - setRuleId: (arg0: number | null) => void; - searchRef: MutableRefObject | null; - setSearchRef: (arg0: MutableRefObject | null) => void; - degreePlanId: number | undefined; + position: { top?: number; bottom?: number; left?: number; right?: number }; + setPosition: (arg0: ExpandedCoursesPanelContextType["position"]) => void; + courses: Fulfillment[] | null | undefined; + setCourses: (arg0: Fulfillment[] | null | undefined) => void; + retract: () => void; + set_retract: (arg0: () => void) => void; + open: boolean; + setOpen: (arg0: boolean) => void; + ruleId: number | null; + setRuleId: (arg0: number | null) => void; + searchRef: MutableRefObject | null; + setSearchRef: (arg0: MutableRefObject | null) => void; + degreePlanId: number | undefined; } export const ExpandedCoursesPanelContext = createContext< - ExpandedCoursesPanelContextType + ExpandedCoursesPanelContextType >({ - position: { top: 0, left: 0 }, - setPosition: (arg0) => {}, // placeholder - courses: null, - setCourses: (courses) => {}, // placeholder - retract: () => {}, // placehoder - set_retract: (arg0) => {}, // placeholder - open: false, - setOpen: (arg0) => {}, // placeholder - ruleId: null, - setRuleId: (arg0) => {}, - searchRef: null, - setSearchRef: (arg0) => {}, - degreePlanId: undefined, + position: { top: 0, left: 0 }, + setPosition: (arg0) => {}, // placeholder + courses: null, + setCourses: (courses) => {}, // placeholder + retract: () => {}, // placehoder + set_retract: (arg0) => {}, // placeholder + open: false, + setOpen: (arg0) => {}, // placeholder + ruleId: null, + setRuleId: (arg0) => {}, + searchRef: null, + setSearchRef: (arg0) => {}, + degreePlanId: undefined, }); export const ExpandedCoursesPanelTrigger = ({ - courses, - triggerType, - changeExpandIcon, - ruleId, - searchRef, - children, + courses, + triggerType, + changeExpandIcon, + ruleId, + searchRef, + children, }: PropsWithChildren<{ - courses: Fulfillment[]; - triggerType: "click" | "hover" | undefined; - changeExpandIcon: () => void; - ruleId: number; - searchRef: MutableRefObject; + courses: Fulfillment[]; + triggerType: "click" | "hover" | undefined; + changeExpandIcon: () => void; + ruleId: number; + searchRef: MutableRefObject; }>) => { - const ref = useRef(null); - const { - setPosition, - setCourses, - retract, - set_retract, - open, - setOpen, - setRuleId, - setSearchRef, - } = useContext(ExpandedCoursesPanelContext); + const ref = useRef(null); + const { + setPosition, + setCourses, + retract, + set_retract, + open, + setOpen, + setRuleId, + setSearchRef, + } = useContext(ExpandedCoursesPanelContext); - const showExpandedCourses = () => { - if (open) { - setCourses(null); - setOpen(false); - setRuleId(null); - retract(); - } else { - setOpen(!open); - setCourses(courses); - set_retract(() => changeExpandIcon); - setRuleId(ruleId); - setSearchRef(searchRef); - if (!ref.current) return; - const position: ExpandedCoursesPanelContextType["position"] = {}; - const { - left, - top, - right, - bottom, - } = searchRef.current.getBoundingClientRect(); + const showExpandedCourses = () => { + if (open) { + setCourses(null); + setOpen(false); + setRuleId(null); + retract(); + } else { + setOpen(!open); + setCourses(courses); + set_retract(() => changeExpandIcon); + setRuleId(ruleId); + setSearchRef(searchRef); + if (!ref.current) return; + const position: ExpandedCoursesPanelContextType["position"] = {}; + const { + left, + top, + right, + bottom, + } = searchRef.current.getBoundingClientRect(); - // calculate the optimal position - let vw = Math.max( - document.documentElement.clientWidth || 0, - window.innerWidth || 0 - ); - let vh = Math.max( - document.documentElement.clientHeight || 0, - window.innerHeight || 0 - ); - if (left > vw - right) position["right"] = vw - left + 5; - // set the right edge of the ExpandedCourses panel to left edge of trigger - else position["left"] = right - 5; - if (top > vh - bottom) position["bottom"] = vh - bottom; - else position["top"] = top; + // calculate the optimal position + let vw = Math.max( + document.documentElement.clientWidth || 0, + window.innerWidth || 0, + ); + let vh = Math.max( + document.documentElement.clientHeight || 0, + window.innerHeight || 0, + ); + if (left > vw - right) position["right"] = vw - left + 5; + // set the right edge of the ExpandedCourses panel to left edge of trigger + else position["left"] = right - 5; + if (top > vh - bottom) position["bottom"] = vh - bottom; + else position["top"] = top; - setPosition(position); - } - }; + setPosition(position); + } + }; - return ( -
- {children} -
- ); + return ( +
+ {children} +
+ ); }; diff --git a/frontend/degree-plan/components/Footer.tsx b/frontend/degree-plan/components/Footer.tsx index f60d03f89..9056874e6 100644 --- a/frontend/degree-plan/components/Footer.tsx +++ b/frontend/degree-plan/components/Footer.tsx @@ -12,7 +12,7 @@ const Wrapper = styled.div` const Link = styled.a` color: rgb(50, 115, 220); -` +`; const Footer = () => ( @@ -21,15 +21,21 @@ const Footer = () => ( {" "} by{" "} - + Penn Labs - . - Have feedback about Penn Degree Plan? Let us know {" "} - {// contact@penncourses.org + . Have feedback about Penn Degree Plan? Let us know{" "} + { + // contact@penncourses.org } - here! + + here! + ); -export default Footer; \ No newline at end of file +export default Footer; diff --git a/frontend/degree-plan/components/FourYearPlan/CourseInExpanded.tsx b/frontend/degree-plan/components/FourYearPlan/CourseInExpanded.tsx index 2303967f8..8153a44a2 100644 --- a/frontend/degree-plan/components/FourYearPlan/CourseInExpanded.tsx +++ b/frontend/degree-plan/components/FourYearPlan/CourseInExpanded.tsx @@ -6,60 +6,80 @@ import { useSWRCrud } from "@/hooks/swrcrud"; import styled from "styled-components"; interface CourseInReqProps { - course: DnDCourse; - isDisabled: boolean; - rule_id: number; - fulfillment?: Fulfillment; - className?: string; - activeDegreePlanId: number; - - onClick?: () => void; + course: DnDCourse; + isDisabled: boolean; + rule_id: number; + fulfillment?: Fulfillment; + className?: string; + activeDegreePlanId: number; + + onClick?: () => void; } const CourseComponentContainer = styled.div` - display: inline-block; - width: 100%; -` + display: inline-block; + width: 100%; +`; -const CourseInExpanded = ( { course, isDisabled, rule_id, fulfillment, activeDegreePlanId } : CourseInReqProps) => { - const { remove: removeFulfillment, createOrUpdate: updateFulfillment } = useSWRCrud( - `/api/degree/degreeplans/${activeDegreePlanId}/fulfillments`, { idKey: "full_code" }); - const { createOrUpdate } = useSWRCrud(`/api/degree/docked`, { idKey: 'full_code' }); +const CourseInExpanded = ({ + course, + isDisabled, + rule_id, + fulfillment, + activeDegreePlanId, +}: CourseInReqProps) => { + const { + remove: removeFulfillment, + createOrUpdate: updateFulfillment, + } = useSWRCrud( + `/api/degree/degreeplans/${activeDegreePlanId}/fulfillments`, + { idKey: "full_code" }, + ); + const { createOrUpdate } = useSWRCrud(`/api/degree/docked`, { + idKey: "full_code", + }); const handleRemoveCourse = async (full_code: string) => { - const updatedRules = course.rules?.filter(rule => rule != rule_id); - /** If the current rule about to be removed is the only rule - * the course satisfied, then we delete the fulfillment */ + const updatedRules = course.rules?.filter((rule) => rule != rule_id); + /** If the current rule about to be removed is the only rule + * the course satisfied, then we delete the fulfillment */ if (updatedRules && updatedRules.length == 0) { - removeFulfillment(full_code); + removeFulfillment(full_code); } else { - updateFulfillment({rules: updatedRules}, full_code); + updateFulfillment({ rules: updatedRules }, full_code); } - createOrUpdate({"full_code": full_code}, full_code); - } + createOrUpdate({ full_code: full_code }, full_code); + }; + + const [{ isDragging }, drag] = useDrag< + DnDCourse, + never, + { isDragging: boolean } + >( + () => ({ + type: ItemTypes.COURSE_IN_EXPAND, + item: { ...course, rule_id: rule_id }, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + }), + [course], + ); - const [{ isDragging }, drag] = useDrag(() => ({ - type: ItemTypes.COURSE_IN_EXPAND, - item: {...course, rule_id: rule_id}, - collect: (monitor) => ({ - isDragging: !!monitor.isDragging() - }) - }), [course]) - return ( - - - - ) -} - -export default CourseInExpanded; \ No newline at end of file + + + + ); +}; + +export default CourseInExpanded; diff --git a/frontend/degree-plan/components/FourYearPlan/CourseInPlan.tsx b/frontend/degree-plan/components/FourYearPlan/CourseInPlan.tsx index 2aded5a1a..662c070f5 100644 --- a/frontend/degree-plan/components/FourYearPlan/CourseInPlan.tsx +++ b/frontend/degree-plan/components/FourYearPlan/CourseInPlan.tsx @@ -1,32 +1,46 @@ import { useDrag } from "react-dnd"; import { ItemTypes } from "../Dock/dnd/constants"; import { Course, DnDCourse, Fulfillment } from "@/types"; -import 'react-loading-skeleton/dist/skeleton.css' +import "react-loading-skeleton/dist/skeleton.css"; import CourseComponent from "../Course/Course"; interface CoursePlannedProps { - course: Fulfillment; - removeCourse: (course: Course["id"]) => void; - semester: Course["semester"]; - isDisabled: boolean; - className?: string; - onClick?: (arg0: React.MouseEvent) => void; + course: Fulfillment; + removeCourse: (course: Course["id"]) => void; + semester: Course["semester"]; + isDisabled: boolean; + className?: string; + onClick?: (arg0: React.MouseEvent) => void; } -const CourseInPlan = (props : CoursePlannedProps) => { - const { course } = props; - - const [{ isDragging }, drag] = useDrag(() => ({ - type: ItemTypes.COURSE_IN_PLAN, - item: course, - collect: (monitor) => ({ - isDragging: !!monitor.isDragging() - }) - }), [course]) +const CourseInPlan = (props: CoursePlannedProps) => { + const { course } = props; - return ( - - ) -} + const [{ isDragging }, drag] = useDrag< + DnDCourse, + never, + { isDragging: boolean } + >( + () => ({ + type: ItemTypes.COURSE_IN_PLAN, + item: course, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + }), + [course], + ); + + return ( + + ); +}; -export default CourseInPlan; \ No newline at end of file +export default CourseInPlan; diff --git a/frontend/degree-plan/components/FourYearPlan/CoursesPlanned.tsx b/frontend/degree-plan/components/FourYearPlan/CoursesPlanned.tsx index 253a1c648..34496e0d1 100644 --- a/frontend/degree-plan/components/FourYearPlan/CoursesPlanned.tsx +++ b/frontend/degree-plan/components/FourYearPlan/CoursesPlanned.tsx @@ -1,5 +1,3 @@ - - import { Ref } from "react"; import CourseInPlan from "./CourseInPlan"; import { SkeletonCourse } from "../Course/Course"; @@ -10,7 +8,7 @@ const PlannedCoursesContainer = styled.div` flex-grow: 1; display: flex; flex-direction: column; - gap: .5rem; + gap: 0.5rem; `; export const SkeletonCoursesPlanned = () => ( @@ -18,24 +16,36 @@ export const SkeletonCoursesPlanned = () => ( -) +); interface CoursesPlannedProps { fulfillments: Fulfillment[]; removeCourse: (course: Course["id"]) => void; - semester: Course["id"], + semester: Course["id"]; className?: string; isLoading?: boolean; } -const CoursesPlanned = ({fulfillments, removeCourse, className, semester, isLoading = false}: CoursesPlannedProps) => { +const CoursesPlanned = ({ + fulfillments, + removeCourse, + className, + semester, + isLoading = false, +}: CoursesPlannedProps) => { return ( - {fulfillments.map(fulfillment => - - )} + {fulfillments.map((fulfillment) => ( + + ))} - ) -} + ); +}; -export default CoursesPlanned; \ No newline at end of file +export default CoursesPlanned; diff --git a/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx b/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx index 40a47b6f2..02b9ba642 100644 --- a/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx +++ b/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx @@ -1,10 +1,10 @@ import styled from "@emotion/styled"; import type { - Degree, - DegreeListing, - DegreePlan, - MajorOption, - SchoolOption, + Degree, + DegreeListing, + DegreePlan, + MajorOption, + SchoolOption, } from "@/types"; import React, { useState, useEffect } from "react"; import { deleteFetcher, postFetcher, useSWRCrud } from "@/hooks/swrcrud"; @@ -14,417 +14,440 @@ import Select from "react-select"; import { schoolOptions } from "@/components/OnboardingPanels/SharedComponents"; export type ModalKey = - | "plan-create" - | "plan-rename" - | "plan-remove" - | "degree-add" - | "degree-remove" - | "semester-remove" - | null; // null is closed + | "plan-create" + | "plan-rename" + | "plan-remove" + | "degree-add" + | "degree-remove" + | "semester-remove" + | null; // null is closed const getModalTitle = (modalState: ModalKey) => { - switch (modalState) { - case "plan-create": - return "Create a new degree plan"; - case "plan-rename": - return "Rename degree plan"; - case "plan-remove": - return "Remove degree plan"; - case "degree-add": - return "Add degree"; - case "degree-remove": - return "Remove degree"; - case "semester-remove": - return "Remove semester"; - case null: - return ""; - default: - throw Error("Invalid modal key: "); - } + switch (modalState) { + case "plan-create": + return "Create a new degree plan"; + case "plan-rename": + return "Rename degree plan"; + case "plan-remove": + return "Remove degree plan"; + case "degree-add": + return "Add degree"; + case "degree-remove": + return "Remove degree"; + case "semester-remove": + return "Remove semester"; + case null: + return ""; + default: + throw Error("Invalid modal key: "); + } }; const DELETE_CONFIRMATION_MESSAGE = (subject: string) => - `Are you sure you want to remove this ${subject}? All of your planning for this ${subject} will be lost.`; + `Are you sure you want to remove this ${subject}? All of your planning for this ${subject} will be lost.`; const ModalInteriorWrapper = styled.div<{ $row?: boolean }>` - display: flex; - flex-direction: ${(props) => (props.$row ? "row" : "column")}; - align-items: center; - gap: 1.2rem; - text-align: center; + display: flex; + flex-direction: ${(props) => (props.$row ? "row" : "column")}; + align-items: center; + gap: 1.2rem; + text-align: center; `; const ModalInput = styled.input` - background-color: #fff; - color: black; - height: 32px; + background-color: #fff; + color: black; + height: 32px; `; const ModalTextWrapper = styled.div` - text-align: start; - width: 100%; + text-align: start; + width: 100%; `; const ModalText = styled.div` - color: var(--modal-text-color); - font-size: 0.87rem; + color: var(--modal-text-color); + font-size: 0.87rem; `; const ModalButton = styled.button` - margin: 0px 0px 0px 0px; - height: 32px; - width: 5rem; - background-color: var(--modal-button-color); - border-radius: 0.25rem; - padding: 0.25rem 0.5rem; - color: white; - border: none; - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } + margin: 0px 0px 0px 0px; + height: 32px; + width: 5rem; + background-color: var(--modal-button-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + color: white; + border: none; + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } `; const ButtonRow = styled.div<{ $center?: boolean }>` - display: flex; - width: 100%; - flex-direction: row; - justify-content: ${(props) => (props.$center ? "center" : "flex-end")}; - gap: 0.5rem; + display: flex; + width: 100%; + flex-direction: row; + justify-content: ${(props) => (props.$center ? "center" : "flex-end")}; + gap: 0.5rem; `; const CancelButton = styled.button` - margin: 0px 0px 0px 0px; - height: 29px; - width: 4rem; - background-color: transparent; - border-radius: 0.25rem; - padding: 0.25rem 0.5rem; - color: var(--modal-button-color); - border: none; + margin: 0px 0px 0px 0px; + height: 29px; + width: 4rem; + background-color: transparent; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + color: var(--modal-button-color); + border: none; `; const SelectList = styled.div` - display: flex; - flex-direction: column; - gap: 1.5rem; - align-items: left; - width: 100%; + display: flex; + flex-direction: column; + gap: 1.5rem; + align-items: left; + width: 100%; `; const DegreeAddInterior = styled.div` - display: flex; - flex-direction: column; - gap: 2rem; - width: 100%; - padding: 1.2rem 2rem; + display: flex; + flex-direction: column; + gap: 2rem; + width: 100%; + padding: 1.2rem 2rem; `; /** Create label for major listings */ export const createMajorLabel = (degree: DegreeListing) => { - const concentration = - degree.concentration && degree.concentration !== "NONE" - ? ` - ${degree.concentration_name}` - : ""; - return `${degree.major_name}${concentration} (${degree.year})`; + const concentration = + degree.concentration && degree.concentration !== "NONE" + ? ` - ${degree.concentration_name}` + : ""; + return `${degree.major_name}${concentration} (${degree.year})`; }; interface RemoveDegreeProps { - degreeplanId: number; - degreeId: number; + degreeplanId: number; + degreeId: number; } interface RemoveSemesterProps { - helper: () => void; + helper: () => void; } interface ModalInteriorProps { - modalKey: ModalKey; - modalObject: - | DegreePlan - | null - | RemoveSemesterProps - | RemoveDegreeProps - | Degree; - activeDegreePlan: DegreePlan | null; - setActiveDegreeplan: (arg0: DegreePlan | null) => void; - close: () => void; - modalRef: React.RefObject; + modalKey: ModalKey; + modalObject: + | DegreePlan + | null + | RemoveSemesterProps + | RemoveDegreeProps + | Degree; + activeDegreePlan: DegreePlan | null; + setActiveDegreeplan: (arg0: DegreePlan | null) => void; + close: () => void; + modalRef: React.RefObject; } const ModalInterior = ({ - modalObject, - modalKey, - activeDegreePlan, - setActiveDegreeplan, - close, - modalRef, + modalObject, + modalKey, + activeDegreePlan, + setActiveDegreeplan, + close, + modalRef, }: ModalInteriorProps) => { - const { - create: createDegreeplan, - update: updateDegreeplan, - remove: deleteDegreeplan, - } = useSWRCrud("/api/degree/degreeplans"); + const { + create: createDegreeplan, + update: updateDegreeplan, + remove: deleteDegreeplan, + } = useSWRCrud("/api/degree/degreeplans"); - const { mutate } = useSWRConfig(); + const { mutate } = useSWRConfig(); - const [ - modalRefCurrent, - setModalRefCurrent, - ] = useState(null); + const [ + modalRefCurrent, + setModalRefCurrent, + ] = useState(null); - useEffect(() => { - setModalRefCurrent(modalRef.current); - }, [modalRef]); + useEffect(() => { + setModalRefCurrent(modalRef.current); + }, [modalRef]); - const add_degreeplan = async (name: string) => { - const _new = await postFetcher("/api/degree/degreeplans", { name: name }); - await mutate("/api/degree/degreeplans"); // use updated degree plan returned - setActiveDegreeplan(_new); - }; + const add_degreeplan = async (name: string) => { + const _new = await postFetcher("/api/degree/degreeplans", { + name: name, + }); + await mutate("/api/degree/degreeplans"); // use updated degree plan returned + setActiveDegreeplan(_new); + }; - const delete_degreeplan = async (id: number) => { - await deleteFetcher(`/api/degree/degreeplans/${id}`); - await mutate("/api/degree/degreeplans"); // use updated degree plan returned - }; + const delete_degreeplan = async (id: number) => { + await deleteFetcher(`/api/degree/degreeplans/${id}`); + await mutate("/api/degree/degreeplans"); // use updated degree plan returned + }; - const [school, setSchool] = useState(); - const [major, setMajor] = useState(); - const [name, setName] = useState(""); + const [school, setSchool] = useState(); + const [major, setMajor] = useState(); + const [name, setName] = useState(""); - const { data: degrees, isLoading: isLoadingDegrees } = useSWR< - DegreeListing[] - >(`/api/degree/degrees`); + const { data: degrees, isLoading: isLoadingDegrees } = useSWR< + DegreeListing[] + >(`/api/degree/degrees`); - const getMajorOptions = React.useCallback(() => { - /** Filter major options based on selected schools/degrees */ - const majorOptions = - degrees - ?.filter((d) => school?.value === d.degree) - .map((degree) => ({ - value: degree, - label: createMajorLabel(degree), - })) || []; - return majorOptions; - }, [school]); + const getMajorOptions = React.useCallback(() => { + /** Filter major options based on selected schools/degrees */ + const majorOptions = + degrees + ?.filter((d) => school?.value === d.degree) + .map((degree) => ({ + value: degree, + label: createMajorLabel(degree), + })) || []; + return majorOptions; + }, [school]); - if (!modalKey && !modalObject) return
; + if (!modalKey && !modalObject) return
; - const [isAddingDegree, setIsAddingDegree] = useState(false); - const add_degree = async (degreeplanId: number, degreeId: number) => { - setIsAddingDegree(true); - try { - const updated = await postFetcher( - `/api/degree/degreeplans/${degreeplanId}/degrees`, - { degree_ids: [degreeId] } - ); - await mutate(`/api/degree/degreeplans/${degreeplanId}`); // use updated degree plan returned - await mutate(`/api/degree/degreeplans/${degreeplanId}/fulfillments`); - } catch (error) { - console.error(error); - } finally { - setIsAddingDegree(false); - } - }; + const [isAddingDegree, setIsAddingDegree] = useState(false); + const add_degree = async (degreeplanId: number, degreeId: number) => { + setIsAddingDegree(true); + try { + const updated = await postFetcher( + `/api/degree/degreeplans/${degreeplanId}/degrees`, + { degree_ids: [degreeId] }, + ); + await mutate(`/api/degree/degreeplans/${degreeplanId}`); // use updated degree plan returned + await mutate( + `/api/degree/degreeplans/${degreeplanId}/fulfillments`, + ); + } catch (error) { + console.error(error); + } finally { + setIsAddingDegree(false); + } + }; - const remove_degree = async (degreeplanId: number, degreeId: number) => { - await deleteFetcher(`/api/degree/degreeplans/${degreeplanId}/degrees`, { - degree_ids: [degreeId], - }); // remove degree - await mutate(`/api/degree/degreeplans/${degreeplanId}`); // use updated degree plan returned - }; + const remove_degree = async (degreeplanId: number, degreeId: number) => { + await deleteFetcher(`/api/degree/degreeplans/${degreeplanId}/degrees`, { + degree_ids: [degreeId], + }); // remove degree + await mutate(`/api/degree/degreeplans/${degreeplanId}`); // use updated degree plan returned + }; - switch (modalKey) { - case "plan-create": - return ( - - setName(e.target.value)} - /> - - Cancel - { - add_degreeplan(name); + switch (modalKey) { + case "plan-create": + return ( + + setName(e.target.value)} + /> + + Cancel + { + add_degreeplan(name); - close(); - }} - > - Create - - - - ); - case "plan-rename": - return ( - - setName(e.target.value)} - /> - { - if ( - modalObject && - "id" in modalObject && - "name" in modalObject - ) { - updateDegreeplan({ name }, modalObject.id); - if (modalObject.id == activeDegreePlan?.id) { - let newNameDegPlan = modalObject; - newNameDegPlan.name = name; - setActiveDegreeplan(newNameDegPlan); - } - } - close(); - }} - > - Rename - - - ); - case "plan-remove": - return ( - - - - Are you sure you want to remove this degree plan? - - - - { - // TODO: these are not great type casts - delete_degreeplan((modalObject as DegreePlan).id); - close(); - }} - > - Remove - - - - ); - case "degree-add": - return ( - - - - - setMajor(selectedOption || undefined) - } - styles={{ menuPortal: (base) => ({ ...base, zIndex: 999 }) }} - menuPortalTarget={modalRefCurrent} - isClearable - isDisabled={!school} - placeholder={ - isLoadingDegrees - ? "loading programs..." - : "Major - Concentration (Starting Year)" - } - isLoading={isLoadingDegrees} - /> - - - { - if (!major?.value.id) return; - await add_degree((modalObject as Degree).id, major?.value.id); - close(); - }} - > - {isAddingDegree ? "Adding..." : "Add"} - - - - - ); - case "degree-remove": - return ( - - - {DELETE_CONFIRMATION_MESSAGE("degree")} - - { - remove_degree( - (modalObject as RemoveDegreeProps).degreeplanId, - (modalObject as RemoveDegreeProps).degreeId - ); - close(); - }} - > - Remove - - - ); - case "semester-remove": - return ( - - - {DELETE_CONFIRMATION_MESSAGE("semester")} - - { - (modalObject as RemoveSemesterProps).helper(); - close(); - }} - > - Remove - - - ); - } - return
; + close(); + }} + > + Create +
+
+
+ ); + case "plan-rename": + return ( + + setName(e.target.value)} + /> + { + if ( + modalObject && + "id" in modalObject && + "name" in modalObject + ) { + updateDegreeplan({ name }, modalObject.id); + if (modalObject.id == activeDegreePlan?.id) { + let newNameDegPlan = modalObject; + newNameDegPlan.name = name; + setActiveDegreeplan(newNameDegPlan); + } + } + close(); + }} + > + Rename + + + ); + case "plan-remove": + return ( + + + + Are you sure you want to remove this degree plan? + + + + { + // TODO: these are not great type casts + delete_degreeplan( + (modalObject as DegreePlan).id, + ); + close(); + }} + > + Remove + + + + ); + case "degree-add": + return ( + + + + + setMajor(selectedOption || undefined) + } + styles={{ + menuPortal: (base) => ({ + ...base, + zIndex: 999, + }), + }} + menuPortalTarget={modalRefCurrent} + isClearable + isDisabled={!school} + placeholder={ + isLoadingDegrees + ? "loading programs..." + : "Major - Concentration (Starting Year)" + } + isLoading={isLoadingDegrees} + /> + + + { + if (!major?.value.id) return; + await add_degree( + (modalObject as Degree).id, + major?.value.id, + ); + close(); + }} + > + {isAddingDegree ? "Adding..." : "Add"} + + + + + ); + case "degree-remove": + return ( + + + + {DELETE_CONFIRMATION_MESSAGE("degree")} + + + { + remove_degree( + (modalObject as RemoveDegreeProps).degreeplanId, + (modalObject as RemoveDegreeProps).degreeId, + ); + close(); + }} + > + Remove + + + ); + case "semester-remove": + return ( + + + + {DELETE_CONFIRMATION_MESSAGE("semester")} + + + { + (modalObject as RemoveSemesterProps).helper(); + close(); + }} + > + Remove + + + ); + } + return
; }; interface DegreeModalProps { - setModalKey: (arg0: ModalKey) => void; - modalKey: ModalKey; - modalObject: DegreePlan | null; - activeDegreePlan: DegreePlan | null; - setActiveDegreeplan: (arg0: DegreePlan | null) => void; + setModalKey: (arg0: ModalKey) => void; + modalKey: ModalKey; + modalObject: DegreePlan | null; + activeDegreePlan: DegreePlan | null; + setActiveDegreeplan: (arg0: DegreePlan | null) => void; } const DegreeModal = ({ - setModalKey, - modalKey, - modalObject, - activeDegreePlan, - setActiveDegreeplan, + setModalKey, + modalKey, + modalObject, + activeDegreePlan, + setActiveDegreeplan, }: DegreeModalProps) => ( - setModalKey(null)} - modalKey={modalKey} - > - {/* + setModalKey(null)} + modalKey={modalKey} + > + {/* // @ts-ignore */} - setModalKey(null)} - /> - + setModalKey(null)} + /> + ); export default DegreeModal; diff --git a/frontend/degree-plan/components/FourYearPlan/EditButton.tsx b/frontend/degree-plan/components/FourYearPlan/EditButton.tsx index 2d8c6acfb..102361b62 100644 --- a/frontend/degree-plan/components/FourYearPlan/EditButton.tsx +++ b/frontend/degree-plan/components/FourYearPlan/EditButton.tsx @@ -1,21 +1,30 @@ import React from "react"; -import styled from '@emotion/styled'; -import { PanelTopBarButton, PanelTopBarIcon, PanelTopBarString } from "./PanelCommon"; +import styled from "@emotion/styled"; +import { + PanelTopBarButton, + PanelTopBarIcon, + PanelTopBarString, +} from "./PanelCommon"; const EditButtonWrapper = styled(PanelTopBarButton)` min-width: 5.5rem; /* Specify width so size does not change */ -` +`; -export const EditButton = ({ editMode, setEditMode }: { editMode: boolean; setEditMode: (arg0: boolean) => void; }) => ( +export const EditButton = ({ + editMode, + setEditMode, +}: { + editMode: boolean; + setEditMode: (arg0: boolean) => void; +}) => ( setEditMode(!editMode)}> - {editMode ? - : - - } + {editMode ? ( + + ) : ( + + )} - - {editMode ? "Done" : "Edit" } - + {editMode ? "Done" : "Edit"} ); diff --git a/frontend/degree-plan/components/FourYearPlan/OnboardingTutorial.tsx b/frontend/degree-plan/components/FourYearPlan/OnboardingTutorial.tsx new file mode 100644 index 000000000..a67513ae6 --- /dev/null +++ b/frontend/degree-plan/components/FourYearPlan/OnboardingTutorial.tsx @@ -0,0 +1,479 @@ +import styled from "@emotion/styled"; +import React, { + useState, + useEffect, + useContext, + createContext, + MutableRefObject, +} from "react"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +export type TutorialModalKey = + | "welcome" + | "requirements-panel-1" + // | "requirements-panel-2" + | "edit-requirements" + | "calendar-panel" + | "past-semesters" + | "current-semester" + | "future-semesters" + | "edit-mode" + | "show-stats" + | "courses-dock" + | "general-search" + | null; + +// Tutorial steps in order +const TUTORIAL_STEPS: TutorialModalKey[] = [ + "welcome", + "requirements-panel-1", + // "requirements-panel-2", + "edit-requirements", + "calendar-panel", + "past-semesters", + "current-semester", + "future-semesters", + "edit-mode", + "show-stats", + "courses-dock", + "general-search", + null, +]; + +const MODAL_CONTENT: Record< + NonNullable, + { title: string; description: string } +> = { + welcome: { + title: "Welcome to Penn Degree Plan!", + description: + "Our newest four-year degree planning website, brought to you by Penn Labs.", + }, + "requirements-panel-1": { + title: "Requirements Panel", + description: + "Requirements for your degree are listed here, organized by majors, school requirements, and electives. Use the dropdown to expand a section and view specific requirements.", + }, + // "requirements-panel-2": { + // title: "Requirements Panel", + // description: "Requirements for your degree are listed here, organized by majors, school requirements, and electives. Use the dropdown to expand a section and view specific requirements." + // }, + "edit-requirements": { + title: "Edit Requirements", + description: "Add or delete majors by entering edit mode.", + }, + "calendar-panel": { + title: "Calendar Panel", + description: + "This is an overview of your degree plan by semester. Drag and drop between here and the requirements panel, the courses dock, or between semesters.", + }, + "past-semesters": { + title: "Past Semesters", + description: "Gray represents past semesters.", + }, + "current-semester": { + title: "Current Semester", + description: "Blue represents the current semester.", + }, + "future-semesters": { + title: "Future Semesters", + description: "White represents future semesters.", + }, + "edit-mode": { + title: "Edit Mode", + description: "Enter edit mode to add or remove semesters.", + }, + "show-stats": { + title: "Show Stats", + description: + "Show or hide the course statistics, Course Quality, Instructor Quality, Difficulty, and Work Required.", + }, + "courses-dock": { + title: "Courses Dock", + description: + "Drag any courses here from the general search, requirements panel, or the schedule panel to view later.", + }, + "general-search": { + title: "General Search", + description: + "Search for any other courses you would like to be added to electives or to keep on standby.", + }, +}; + +// Component reference mapping +const COMPONENT_REF_MAP: Partial, + string +>> = { + "requirements-panel-1": "reqPanel", + // "requirements-panel-2": "reqPanel", + "edit-requirements": "editReqs", + "calendar-panel": "planPanel", + "past-semesters": "planPanel", + "current-semester": "planPanel", + "future-semesters": "planPanel", + "edit-mode": "editSemesterButton", + "show-stats": "showStatsButton", + "courses-dock": "dock", + "general-search": "dock", +}; + +const ModalBackground = styled.div` + background-color: #707070; + opacity: 0.75; + position: fixed; + inset: 0; + z-index: 10; +`; + +const ModalContainer = styled.div` + position: fixed; + z-index: 40; + pointer-events: none; +`; + +const ModalCard = styled.div` + max-width: 400px; + max-height: 400px; + border-radius: 8px; + box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3); + background-color: #fff; + margin: 0 20px; + pointer-events: auto; + position: fixed; + top: ${(props) => props.top}; + left: ${(props) => props.left}; + transform: ${(props) => props.transform}; +`; + +const ModalCardHead = styled.header` + font-weight: 700; + font-size: 1.4rem; + padding: 1.5rem 2rem 0.5rem; + position: relative; + color: #4a4a4a; +`; + +const ModalCardBody = styled.div` + padding: 0.5rem 2rem 1.5rem; + overflow: auto; +`; + +const ModalInteriorWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 1.2rem; + text-align: center; +`; + +const ModalText = styled.div` + color: var(--modal-text-color); + font-size: 0.87rem; + text-align: start; + width: 100%; +`; + +const ButtonRow = styled.div` + display: flex; + width: 100%; + justify-content: flex-end; + gap: 0.5rem; +`; + +const ModalButton = styled.button` + height: 32px; + width: 5rem; + background-color: var(--modal-button-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + color: white; + border: none; + cursor: pointer; +`; + +const CloseButton = styled.button` + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + font-size: 1.2rem; + font-weight: bold; + color: #4a4a4a; + cursor: pointer; + + &:hover { + color: #000; + } +`; + +type ModalPosition = { top: string; left: string; transform: string }; + +const MODAL_WIDTH = 400; +const MODAL_HEIGHT = 200; +const SCREEN_PADDING = 20; +const DEFAULT_POSITION: ModalPosition = { + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", +}; + +const getComponentRect = ( + modalKey: TutorialModalKey, + componentRefs: MutableRefObject> | null, +): DOMRect | null => { + if (!modalKey || modalKey === "welcome" || !componentRefs) return null; + + const componentKey = COMPONENT_REF_MAP[modalKey]; + if (!componentKey || !componentRefs.current[componentKey]) return null; + + return componentRefs.current[componentKey]!.getBoundingClientRect(); +}; + +const calculatePositionForKey = ( + modalKey: NonNullable, + rect: DOMRect, +): ModalPosition => { + const positions: Partial, + ModalPosition + >> = { + "requirements-panel-1": { + top: "50%", + left: `${rect.left - MODAL_WIDTH - 30}px`, + transform: "translateY(-50%)", + }, + // "requirements-panel-2": { + // top: "50%", + // left: `${rect.left - MODAL_WIDTH - 30}px`, + // transform: "translateY(-50%)" + // }, + "edit-requirements": { + top: `${rect.top + rect.height + 20}px`, + left: `${rect.left + rect.width / 2}px`, + transform: "translateX(-50%)", + }, + "edit-mode": { + top: `${rect.bottom + 10}px`, + left: `${rect.left + rect.width}px`, + transform: "translateX(-50%)", + }, + "show-stats": { + top: `${rect.bottom + 10}px`, + left: `${rect.left + rect.width / 2}px`, + transform: "translateX(-50%)", + }, + "courses-dock": { + top: `${rect.top - MODAL_HEIGHT + 20}px`, + left: `${rect.left}px`, + transform: "translateX(-50%)", + }, + "general-search": { + top: `${rect.top - MODAL_HEIGHT + 20}px`, + left: `${rect.left}px`, + transform: "translateX(-50%)", + }, + }; + + const calendarPanelKeys = [ + "calendar-panel", + "past-semesters", + "current-semester", + "future-semesters", + ]; + if (calendarPanelKeys.includes(modalKey)) { + return { + top: `${rect.top + rect.height / 2}px`, + left: `${rect.right + 10}px`, + transform: "translateY(-50%)", + }; + } + + return positions[modalKey] || DEFAULT_POSITION; +}; + +const constrainToViewport = (position: ModalPosition): ModalPosition => { + const { innerWidth: windowWidth, innerHeight: windowHeight } = window; + + let { top, left, transform } = position; + let leftValue = parseInt(left); + let topValue = parseInt(top); + + if (leftValue + MODAL_WIDTH > windowWidth) { + left = `${windowWidth - MODAL_WIDTH - SCREEN_PADDING}px`; + transform = transform + .replace("translateX(-50%)", "") + .replace("translate(-100%, -50%)", "translateY(-50%)"); + } else if (leftValue < SCREEN_PADDING) { + left = `${SCREEN_PADDING}px`; + transform = transform + .replace("translateX(-50%)", "") + .replace("translate(-100%, -50%)", "translateY(-50%)"); + } + + if (topValue < SCREEN_PADDING) { + top = `${SCREEN_PADDING}px`; + transform = transform + .replace("translateY(-50%)", "") + .replace("translateX(-50%)", ""); + } else if (topValue + MODAL_HEIGHT > windowHeight) { + top = `${windowHeight - MODAL_HEIGHT - SCREEN_PADDING}px`; + transform = transform + .replace("translateY(-50%)", "") + .replace("translateX(-50%)", ""); + } + + return { top, left, transform }; +}; + +const calculateModalPosition = ( + modalKey: TutorialModalKey, + componentRefs: MutableRefObject> | null, +): ModalPosition => { + const rect = getComponentRect(modalKey, componentRefs); + if (!rect) return DEFAULT_POSITION; + + const position = calculatePositionForKey( + modalKey as NonNullable, + rect, + ); + return constrainToViewport(position); +}; + +interface TutorialModalContextProps { + tutorialModalKey: TutorialModalKey; + setTutorialModalKey: (key: TutorialModalKey) => void; + highlightedComponentRef: any; + componentRefs: React.MutableRefObject< + Record + > | null; +} + +export const TutorialModalContext = createContext({ + tutorialModalKey: null, + setTutorialModalKey: () => {}, + highlightedComponentRef: null, + componentRefs: null, +}); + +interface TutorialModalProps { + updateOnboardingFlag: () => void; +} + +const TutorialModal = ({ updateOnboardingFlag }: TutorialModalProps) => { + const { tutorialModalKey, setTutorialModalKey, componentRefs } = useContext( + TutorialModalContext, + ); + const [position, setPosition] = useState(DEFAULT_POSITION); + const [displayedModalKey, setDisplayedModalKey] = useState< + TutorialModalKey + >(tutorialModalKey); + + const handleClose = () => { + updateOnboardingFlag(); + setTutorialModalKey(null); + }; + + const navigateStep = (forward: boolean) => { + if (!tutorialModalKey) return; + + const currentIndex = TUTORIAL_STEPS.indexOf(tutorialModalKey); + const nextIndex = forward ? currentIndex + 1 : currentIndex - 1; + + if (nextIndex >= 0 && nextIndex < TUTORIAL_STEPS.length) { + setTutorialModalKey(TUTORIAL_STEPS[nextIndex]); + } + }; + + // Update position for next step + useEffect(() => { + if (!tutorialModalKey) return; + + const timer = setTimeout(() => { + const newPosition = calculateModalPosition( + tutorialModalKey, + componentRefs, + ); + setPosition(newPosition); + setDisplayedModalKey(tutorialModalKey); + }, 20); + + return () => clearTimeout(timer); + }, [tutorialModalKey, componentRefs]); + + // Handle window resize + useEffect(() => { + if (!tutorialModalKey) return; + + const handleResize = () => { + const newPosition = calculateModalPosition( + tutorialModalKey, + componentRefs, + ); + setPosition(newPosition); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [tutorialModalKey, componentRefs]); + + // Reached last step + if (!tutorialModalKey) return null; + + const modalContent = displayedModalKey + ? MODAL_CONTENT[displayedModalKey] + : { title: "", description: "" }; + const isFirstStep = displayedModalKey === "welcome"; + const isLastStep = displayedModalKey === "general-search"; + + return ( + <> + + + + + {modalContent.title} + + + + + + + {isFirstStep && ( + Porcupine + )} + {modalContent.description} + + {!isFirstStep && ( + navigateStep(false)} + > + Back + + )} + {isLastStep ? ( + + Close + + ) : ( + navigateStep(true)} + > + Next + + )} + + + + + + + ); +}; + +export default TutorialModal; diff --git a/frontend/degree-plan/components/FourYearPlan/PanelCommon.tsx b/frontend/degree-plan/components/FourYearPlan/PanelCommon.tsx index 65e508524..73cb63c99 100644 --- a/frontend/degree-plan/components/FourYearPlan/PanelCommon.tsx +++ b/frontend/degree-plan/components/FourYearPlan/PanelCommon.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import React from "react"; -import 'react-loading-skeleton/dist/skeleton.css' +import "react-loading-skeleton/dist/skeleton.css"; import Skeleton from "react-loading-skeleton"; import { Icon } from "../common/bulma_derived_components"; @@ -12,7 +12,7 @@ export const PanelTopBarIcon = styled(Icon)` export const PanelTopBarString = styled.div` flex-shrink: 1; -` +`; export const PanelTopBarButton = styled.button` border: none; @@ -20,8 +20,8 @@ export const PanelTopBarButton = styled.button` flex-direction: row; justify-content: start; align-items: center; - padding: .5rem 1rem; - gap: .5rem; + padding: 0.5rem 1rem; + gap: 0.5rem; min-height: 1.5rem; font-family: inherit; @@ -30,11 +30,9 @@ export const PanelTopBarButton = styled.button` border-radius: 5px; color: var(--primary-color-xxx-dark); `; -export const DarkBlueBackgroundSkeleton: React.FC<{ width?: string; }> = (props) => ( - -); +export const DarkBlueBackgroundSkeleton: React.FC<{ width?: string }> = ( + props, +) => ; export const PanelHeader = styled.div` display: flex; @@ -56,13 +54,13 @@ export const PanelBody = styled.div` flex-grow: 1; display: flex; flex-direction: column; - gap: .5rem; + gap: 0.5rem; `; export const PanelContainer = styled.div` border-radius: 10px; box-shadow: 0px 0px 10px 6px rgba(0, 0, 0, 0.05); - background-color: #FFFFFF; + background-color: #ffffff; display: flex; flex-direction: column; width: 100%; @@ -75,4 +73,3 @@ export const PanelTopBarIconList = styled.div` flex-direction: row; gap: 0.8rem; `; - diff --git a/frontend/degree-plan/components/FourYearPlan/PlanPanel.tsx b/frontend/degree-plan/components/FourYearPlan/PlanPanel.tsx index e114d5c92..89063bceb 100644 --- a/frontend/degree-plan/components/FourYearPlan/PlanPanel.tsx +++ b/frontend/degree-plan/components/FourYearPlan/PlanPanel.tsx @@ -2,25 +2,47 @@ import SelectListDropdown from "./SelectListDropdown"; import Semesters from "./Semesters"; import styled from "@emotion/styled"; import type { DegreePlan } from "@/types"; -import React, { useState } from "react"; -import { useSWRCrud } from '@/hooks/swrcrud'; -import { EditButton } from './EditButton'; +import React, { useState, useEffect, useContext, useMemo } from "react"; +import { useSWRCrud } from "@/hooks/swrcrud"; +import { EditButton } from "./EditButton"; import { PanelTopBarButton, PanelTopBarIcon } from "./PanelCommon"; -import { PanelContainer, PanelHeader, PanelTopBarIconList, PanelBody } from "./PanelCommon"; +import { + PanelContainer, + PanelHeader, + PanelTopBarIconList, + PanelBody, +} from "./PanelCommon"; import { ModalKey } from "./DegreeModal"; +import { TutorialModalContext } from "./OnboardingTutorial"; +import { SemestersContext } from "./Semesters"; + +const TutorialHighlight = styled.div<{ $active: boolean }>` + position: relative; + border-radius: 6px; + outline: ${(p) => (p.$active ? "2px solid var(--selected-color)" : "none")}; + outline-offset: 2px; +`; const ShowStatsText = styled.div` min-width: 6rem; -` +`; -const ShowStatsButton = ({ showStats, setShowStats }: { showStats: boolean, setShowStats: (arg0: boolean) => void }) => ( +const ShowStatsButton = ({ + showStats, + setShowStats, +}: { + showStats: boolean; + setShowStats: (arg0: boolean) => void; +}) => ( setShowStats(!showStats)}> - + - - {showStats ? "Hide Stats" : "Show Stats"} - + {showStats ? "Hide Stats" : "Show Stats"} ); @@ -36,7 +58,7 @@ interface PlanPanelProps { setShowOnboardingModal: (arg0: boolean) => void; } -const PlanPanel = ({ +const PlanPanel = ({ setModalKey, modalKey, setModalObject, @@ -46,50 +68,130 @@ const PlanPanel = ({ degreeplans, isLoading, currentSemester, -} : PlanPanelProps) => { - - - const { copy: copyDegreeplan } = useSWRCrud('/api/degree/degreeplans'); +}: PlanPanelProps) => { + const { copy: copyDegreeplan } = useSWRCrud( + "/api/degree/degreeplans", + ); const [showStats, setShowStats] = useState(true); const [editMode, setEditMode] = useState(false); + const { tutorialModalKey, componentRefs } = useContext( + TutorialModalContext, + ); + const { semesterRefs } = useContext(SemestersContext); + const planPanelRef = React.useRef(null); + const showStatsRef = React.useRef(null); + const editSemesterRef = React.useRef(null); + + useEffect(() => { + if (!componentRefs?.current) return; + + const planPanelKeys = [ + "calendar-panel", + "current-semester", + "future-semesters", + "past-semesters", + "edit-mode", + "show-stats", + ]; + + const isPlanPanelActive = planPanelKeys.includes( + tutorialModalKey || "", + ); + componentRefs.current["planPanel"] = planPanelRef.current; + if (planPanelRef.current) { + planPanelRef.current.style.zIndex = isPlanPanelActive ? "20" : ""; + } + + componentRefs.current["showStatsButton"] = showStatsRef.current; + componentRefs.current["editSemesterButton"] = editSemesterRef.current; + }, [tutorialModalKey, componentRefs]); + + useEffect(() => { + if (tutorialModalKey !== "current-semester") return; + if (!currentSemester) return; + + const attemptScroll = () => { + console.log(semesterRefs); + console.log(semesterRefs?.current); + const target = semesterRefs?.current?.[currentSemester]; + console.log("target", target); + if (target) { + console.log("scrolling to", target); + target.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + } + }; + // slight delay to allow modal and semesters to render + const startTimer = setTimeout(attemptScroll, 50); + return () => clearTimeout(startTimer); + }, [tutorialModalKey, currentSemester, semesterRefs]); + return ( - - - item.name} - allItems={degreeplans || []} - selectItem={(id: DegreePlan["id"]) => setActiveDegreeplan(degreeplans?.filter(d => d.id === id)[0] || null)} - mutators={{ - copy: (item: DegreePlan) => { - (copyDegreeplan({...item, name: `${item.name} (copy)`}, item.id) as Promise) - .then((copied) => copied && setActiveDegreeplan(copied)) - }, - remove: (item: DegreePlan) => { - setModalKey("plan-remove") - setModalObject(item) - }, - rename: (item: DegreePlan) => { - setModalKey("plan-rename") - setModalObject(item) - }, - create: () => { - setShowOnboardingModal(true); - } - }} - isLoading={isLoading} - /> - - - - - - {/** map to semesters */} - - + + item.name} + allItems={degreeplans || []} + selectItem={(id: DegreePlan["id"]) => + setActiveDegreeplan( + degreeplans?.filter((d) => d.id === id)[0] || null, + ) + } + mutators={{ + copy: (item: DegreePlan) => { + (copyDegreeplan( + { ...item, name: `${item.name} (copy)` }, + item.id, + ) as Promise).then( + (copied) => + copied && setActiveDegreeplan(copied), + ); + }, + remove: (item: DegreePlan) => { + setModalKey("plan-remove"); + setModalObject(item); + }, + rename: (item: DegreePlan) => { + setModalKey("plan-rename"); + setModalObject(item); + }, + create: () => { + setShowOnboardingModal(true); + }, + }} + isLoading={isLoading} + /> + + + + + + + + + + {/** map to semesters */} + + - - + /> + + ); -} +}; -export default PlanPanel; \ No newline at end of file +export default PlanPanel; diff --git a/frontend/degree-plan/components/FourYearPlan/SatisfiedCheck.tsx b/frontend/degree-plan/components/FourYearPlan/SatisfiedCheck.tsx index 1c1994420..84f7bacde 100644 --- a/frontend/degree-plan/components/FourYearPlan/SatisfiedCheck.tsx +++ b/frontend/degree-plan/components/FourYearPlan/SatisfiedCheck.tsx @@ -1,4 +1,4 @@ const SatisfiedCheck = () => ( - -) -export default SatisfiedCheck; \ No newline at end of file + +); +export default SatisfiedCheck; diff --git a/frontend/degree-plan/components/FourYearPlan/SelectListDropdown.tsx b/frontend/degree-plan/components/FourYearPlan/SelectListDropdown.tsx index 882aa3e54..498cbdd16 100644 --- a/frontend/degree-plan/components/FourYearPlan/SelectListDropdown.tsx +++ b/frontend/degree-plan/components/FourYearPlan/SelectListDropdown.tsx @@ -4,7 +4,7 @@ import { GrayIcon } from "../common/bulma_derived_components"; import { DBObject, DegreePlan } from "../../types"; import { DarkBlueBackgroundSkeleton } from "./PanelCommon"; -const ButtonContainer = styled.div<{ $isActive: boolean; }>` +const ButtonContainer = styled.div<{ $isActive: boolean }>` line-height: 1.5; position: relative; border-radius: 0 !important; @@ -96,12 +96,12 @@ const DropdownRenameButton = ({ rename }: { rename: () => void }) => ( >