From ad04b642a39c436fe917c8235b1336d998b44960 Mon Sep 17 00:00:00 2001 From: Daniel Rafailov Date: Sat, 24 Oct 2026 14:31:25 -0400 Subject: [PATCH 1/2] Refactored react_modal.js.jsx into modular functional TypeScript components --- js/components/common/CampusMap.tsx | 162 +++ js/components/common/CourseModal.tsx | 260 +++++ js/components/common/DayBox.tsx | 117 ++ js/components/common/Description.tsx | 152 +++ js/components/common/FocusModal.tsx | 65 ++ js/components/common/MapModal.tsx | 212 ++++ js/components/common/MapSidebar.tsx | 60 + .../common/__tests__/ConvertToLink.test.js | 9 +- .../__tests__/CourseModalUpdate.test.js | 2 +- .../common/__tests__/GetTable.test.js | 13 +- .../common/__tests__/InfoModalFields.test.js | 2 +- .../common/__tests__/TimetableLoading.test.js | 2 +- js/components/common/react_modal.js.jsx | 1009 ----------------- js/components/common/types.ts | 136 +++ js/components/common/utils.ts | 24 +- js/components/graph/FocusTab.tsx | 2 +- js/components/graph/Graph.js | 2 +- js/components/grid/calendar.js.jsx | 2 +- js/components/grid/course_panel.js.jsx | 2 +- package.json | 1 + pnpm-lock.yaml | 15 + 21 files changed, 1208 insertions(+), 1041 deletions(-) create mode 100644 js/components/common/CampusMap.tsx create mode 100644 js/components/common/CourseModal.tsx create mode 100644 js/components/common/DayBox.tsx create mode 100644 js/components/common/Description.tsx create mode 100644 js/components/common/FocusModal.tsx create mode 100644 js/components/common/MapModal.tsx create mode 100644 js/components/common/MapSidebar.tsx delete mode 100644 js/components/common/react_modal.js.jsx create mode 100644 js/components/common/types.ts diff --git a/js/components/common/CampusMap.tsx b/js/components/common/CampusMap.tsx new file mode 100644 index 000000000..1a4020408 --- /dev/null +++ b/js/components/common/CampusMap.tsx @@ -0,0 +1,162 @@ +import { JSX, useRef } from "react" +import L from "leaflet" +import { FeatureGroup, MapContainer, Marker, Popup, TileLayer } from "react-leaflet" +import { convertTimeToString } from "./utils" +import { BuildingGroup, LectureTimeframe } from "./types" + +export interface CampusMapProps { + lecturesByBuilding: BuildingGroup[] + selectedLecTimeframes: LectureTimeframe[] +} + +/** + * Interactive Leaflet campus map displaying markers for lecture locations. + * + * Plots building markers based on scheduled lecture coordinates, highlights selected + * buildings with custom icons, displays popups listing class meeting times, and provides + * a "Zoom to Fit" control to dynamically adjust map bounds to encompass all active markers. + * + * @param lecturesByBuilding Array of buildings with their coordinates and scheduled classes. + * @param selectedLecTimeframes Currently selected lectures to highlight on the map. + */ +export function CampusMap({ + lecturesByBuilding, + selectedLecTimeframes, +}: CampusMapProps): JSX.Element { + const mapRef = useRef(null) + const groupRef = useRef(null) + + const zoomToFit = (): void => { + const map = mapRef.current + const mapGroup = groupRef.current + + if (!map || !mapGroup) return + + // markerCount is the number of markers on the map. + // Only adjust the map bounds if there are multiple markers because the zoom will + // be strange for 1 marker. Note that the map will be centered on the single marker + const markerCount = mapGroup.getLayers().length + + if (markerCount > 1) { + map.fitBounds(mapGroup.getBounds(), { padding: [30, 30] }) + } + } + + const blueMarker = new L.Icon({ + iconUrl: "/static/res/ico/blue-marker.png", + shadowUrl: "/static/res/ico/shadow.png", + iconAnchor: [5, 35], + popupAnchor: [13, -35], + iconSize: [34, 35], + shadowSize: [50, 85], + shadowAnchor: [2, 90], + }) + + const redMarker = new L.Icon({ + iconUrl: "/static/res/ico/red-marker.png", + shadowUrl: "/static/res/ico/shadow.png", + iconAnchor: [5, 35], + popupAnchor: [13, -35], + iconSize: [34, 35], + shadowSize: [50, 85], + shadowAnchor: [2, 90], + }) + + const locationMarkers = lecturesByBuilding.map(building => { + if (building.lat === undefined || building.lng === undefined) { + return null + } + + const description = building.days + .sort((dayA, dayB) => { + return dayA.dayOrder - dayB.dayOrder + }) + .map(day => { + const dayTimes = day.timeframes + .sort((timeA, timeB) => { + return timeA.startTime - timeB.startTime + }) + .map(time => ( +
  • + {convertTimeToString(time.startTime) + + "-" + + convertTimeToString(time.endTime) + + ", " + + time.room + + ": " + + time.courseCode} +
  • + )) + + return ( +
    +

    {day.dayString}

    + {dayTimes} +
    + ) + }) + + let colouredMarker + const buildingInd = selectedLecTimeframes.findIndex( + lec => lec.location && lec.location.buildingCode === building.buildingCode + ) + + colouredMarker = buildingInd == -1 ? blueMarker : redMarker + + return ( + + +

    + {building.buildingName + " | " + building.buildingCode} +

    +

    + {building.address} +

    + {description} +
    +
    + ) + }) + + const mapBounds = L.latLngBounds([]) + + // If there are selected lectures, center the map based on the locations of those lectures, otherwise, center the map + // on Bahen + let center: L.LatLngExpression = [43.6596426, -79.3976676] + + if (lecturesByBuilding.length) { + lecturesByBuilding.forEach(building => { + if (building.lat !== undefined && building.lng !== undefined) { + mapBounds.extend(L.latLng(building.lat, building.lng)) + } + }) + + center = mapBounds.getCenter() + } + + return ( +
    + + + {locationMarkers} + + +
    + ) +} diff --git a/js/components/common/CourseModal.tsx b/js/components/common/CourseModal.tsx new file mode 100644 index 000000000..98e18745a --- /dev/null +++ b/js/components/common/CourseModal.tsx @@ -0,0 +1,260 @@ +import { JSX, useEffect, useState } from "react" +import ReactModal from "react-modal" +import { Description } from "./Description" +import { getCourse } from "./utils" +import { + CourseDetails, + MeetingItem, + RawCourseData, + Sessions, + SessionRowData, + TimeData, +} from "./types" + +const INT_TO_DAY: Record = { + 0: "Monday", + 1: "Tuesday", + 2: "Wednesday", + 3: "Thursday", + 4: "Friday", +} + +const COURSE_CODE_REGEX = /([A-Z]{3}[0-9]{3}[H|Y]1)/ + +interface CourseModalProps { + showCourseModal?: boolean + courseId?: string + onClose?: () => void +} + +/** + * Convert the course names to tags and return a list of strings and tags. + */ +export const convertToLink = ( + content?: string | null, + onNavigate?: (courseId: string) => void +): (string | JSX.Element)[] => { + if (!content) { + return [] + } + + const result: (string | JSX.Element)[] = content.split(COURSE_CODE_REGEX) + for (let i = 0; i < result.length; i++) { + let word = result[i] + if (typeof word === "string" && word.match(COURSE_CODE_REGEX)) { + result[i] = ( + onNavigate?.(word)}> + {word} + + ) + } + } + return result +} + +/** + * Helper function to format a Time data object for display in the course modal. + * @param {time} time A Time data object. + * @returns {string} The time formatted as a string (e.g. "Tuesday 11 - 13"). + * */ +const formatTime = (time: TimeData): string => { + return INT_TO_DAY[time.weekDay] + " " + time.startHour + " - " + time.endHour +} + +/** + * Generate the data needed for the course modal table based on the meeting times corresponding + * to a course in a given session. + * @param {MeetingItem[]} allMeetTimes An array of MeetingData objects corresponding to a particular course. + * @param {string} session The session (F, S, Y) to query. + * @returns {SessionRowData[]} An array of row data objects that will appear in the course modal table. + */ +export const getTable = ( + allMeetTimes: MeetingItem[], + session: string +): SessionRowData[] => { + // Filter and sort the lecture sections in the specified session by their section code + const filteredMeetTimes = allMeetTimes.filter( + meetTime => meetTime.meetData.session === session + ) + + const sortedMeetTimes = filteredMeetTimes.sort((firstMeetTime, secondMeetTime) => + firstMeetTime.meetData.section > secondMeetTime.meetData.section ? 1 : -1 + ) + + return sortedMeetTimes.map(meetTime => { + // Sort each section's meeting time blocks and corresponding locations and store them in arrays. + // times2, locations2 are used for the extra columns that appear in the table when viewing a Y course. + const occurrences = { + times1: [] as string[], + locations1: [] as string[], + times2: [] as string[], + locations2: [] as string[], + } + const sortedTimeData = meetTime.timeData.sort((time1, time2) => + time1.weekDay > time2.weekDay ? 1 : -1 + ) + sortedTimeData.map(time => { + const location = time.timeLocation?.buildingCode ?? " " + if (session === "Y" && time.timeSession?.endsWith("1")) { + occurrences.locations2.push(location) + occurrences.times2.push(formatTime(time)) + } else { + occurrences.locations1.push(location) + occurrences.times1.push(formatTime(time)) + } + }) + const rowData = { + activity: meetTime.meetData.section, + instructor: meetTime.meetData.instructor, + availability: + meetTime.meetData.cap - + meetTime.meetData.enrol + + " of " + + meetTime.meetData.cap + + " available", + waitList: meetTime.meetData.wait + " students", + time1: occurrences.times1, + location1: occurrences.locations1, + time2: occurrences.times2, + location2: occurrences.locations2, + } + + return rowData + }) +} + +/** + * Modal popup that displays comprehensive information about a selected course. + * + * Fetches course data asynchronously and renders course descriptions, prerequisites, + * exclusions, and a timetable grid for Fall, Spring, and Full Year sessions. + * Manages an internal browser-like history stack allowing users to navigate + * forward and backward between courses clicked within prerequisite text. + * + * @param showCourseModal Whether the modal popup is currently visible. + * @param courseId The initial course code to display (e.g., "CSC110Y1"). + * @param onClose Callback invoked when the modal is requested to close. + */ +export function CourseModal({ + showCourseModal = false, + courseId, + onClose, +}: CourseModalProps): JSX.Element { + const [course, setCourse] = useState(null) + const [sessions, setSessions] = useState({}) + const [courseTitle, setCourseTitle] = useState("") + + const [visitedCourses, setVisitedCourses] = useState( + courseId ? [courseId] : [] + ) + const [currVisitedIndex, setCurrVisitedIndex] = useState(0) + const currentCourseId = visitedCourses[currVisitedIndex] ?? "" + + /** + * Change the courseId state, whenever a new course link is clicked. + * Additionally, add the new courseId to the list of visited courses. + */ + const navigateToCourse = (newCourseId: string): void => { + if (newCourseId === currentCourseId) return + // Truncate any forward history and append the new course + setVisitedCourses(prev => [...prev.slice(0, currVisitedIndex + 1), newCourseId]) + setCurrVisitedIndex(prev => prev + 1) + } + + /** + * Manages state for currVisitedIndex when we click the back button to + * return to the previous course that we visited. + */ + const navigateBack = (): void => { + if (currVisitedIndex > 0) { + setCurrVisitedIndex(prev => prev - 1) + } + } + + /** + * Manages state for currVisitedIndex when we click the forward button to + * gp to the next course in visitedCourses + */ + const navigateForward = (): void => { + if (currVisitedIndex < visitedCourses.length - 1) { + setCurrVisitedIndex(prev => prev + 1) + } + } + + useEffect(() => { + if (showCourseModal && courseId) { + setVisitedCourses([courseId]) + setCurrVisitedIndex(0) + } + }, [showCourseModal, courseId]) + + useEffect(() => { + if (!currentCourseId) return + getCourse(currentCourseId) + .then(data => { + const course = data as RawCourseData + const newCourse = { + ...course, + description: convertToLink(course.description, navigateToCourse), + prereqString: convertToLink(course.prereqString, navigateToCourse), + coreqs: convertToLink(course.coreqs, navigateToCourse), + prep: convertToLink(course.prep, navigateToCourse), + exclusions: convertToLink(course.exclusions, navigateToCourse), + } + + const sessions = { + F: getTable(course.allMeetingTimes, "F"), + S: getTable(course.allMeetingTimes, "S"), + Y: getTable(course.allMeetingTimes, "Y"), + } + + setCourse(newCourse) + setSessions(sessions) + setCourseTitle(`${course.name} ${course.title}`) + }) + .catch(() => { + console.error(`Course with code ${currentCourseId} not found`) + setCourse(null) + setSessions({}) + setCourseTitle("Course Not Found") + }) + }, [currentCourseId]) + + return ( + +
    + {courseTitle} + +
    + + + +
    +
    + +
    + {course && } +
    +
    + ) +} diff --git a/js/components/common/DayBox.tsx b/js/components/common/DayBox.tsx new file mode 100644 index 000000000..4f54c218e --- /dev/null +++ b/js/components/common/DayBox.tsx @@ -0,0 +1,117 @@ +import { JSX, useState } from "react" +import { convertTimeToString } from "./utils" +import { BuildingLocation, LectureTimeframe } from "./types" + +interface DayBoxProps { + day: string + dayLectures: LectureTimeframe[] + selectLecTimeframe: (lecture: LectureTimeframe) => void + selectDayTimeframes: (lecture: LectureTimeframe[]) => void + isDaySelected: (lectures: LectureTimeframe[]) => boolean + findSelectedLecTimeframe: ( + lecture: LectureTimeframe, + selectedLectures: LectureTimeframe[] + ) => number + selectedLecTimeframes: LectureTimeframe[] +} + +/** + * Accordion day container in the map modal sidebar. + * + * Displays the day header with selection indicators and expansion arrows. When expanded, + * lists each lecture occurring on that day with its time, room, and building location, + * allowing individual lectures to be clicked to highlight their corresponding pin on the map. + * + * @param day The name of the weekday (e.g., "Monday"). + * @param dayLectures Array of lecture timeframes occurring on this day. + * @param selectLecTimeframe Callback to toggle selection of a specific lecture timeframe. + * @param selectDayTimeframes Callback to select or deselect all lectures for this day. + * @param isDaySelected Function checking if all lectures on this day are currently selected. + * @param findSelectedLecTimeframe Helper function checking if a lecture is selected. + * @param selectedLecTimeframes List of currently selected lecture timeframes. + */ +export function DayBox({ + day, + dayLectures, + selectLecTimeframe, + selectDayTimeframes, + isDaySelected, + findSelectedLecTimeframe, + selectedLecTimeframes, +}: DayBoxProps): JSX.Element { + const [expanded, setExpanded] = useState(false) + + const toggleExpand = (): void => { + setExpanded(prev => !prev) + } + + const formatLocation = (location: BuildingLocation): string => { + return "Location: " + location.buildingName + " (" + location.buildingCode + ")" + } + + const isSelected = isDaySelected(dayLectures) + + const arrowColour = isSelected ? "lightpurple" : "darkpurple" + const direction = expanded ? "doubleup-" : "doubledown-" + + return ( +
    +
    +
    +

    selectDayTimeframes(dayLectures)} + style={{ display: "inline-block" }} + > + {" "} + {day}{" "} +

    +
    + + +
    + + {expanded && ( +
      + {dayLectures.map(lec => { + let locationStr = "" + if (lec.location) { + locationStr += formatLocation(lec.location) + } + + return ( +
    • selectLecTimeframe(lec)} + > +

      {lec.courseCode}

      +
      + {" "} + {convertTimeToString(lec.startTime) + + "-" + + convertTimeToString(lec.endTime)}{" "} +
      +
      {locationStr}
      +
    • + ) + })} +
    + )} +
    + ) +} diff --git a/js/components/common/Description.tsx b/js/components/common/Description.tsx new file mode 100644 index 000000000..cf362f705 --- /dev/null +++ b/js/components/common/Description.tsx @@ -0,0 +1,152 @@ +import { JSX } from "react" +import { AgGridReact } from "ag-grid-react" +import { CourseDetails, Sessions } from "./types" + +interface DescriptionProps { + course: CourseDetails + sessions: Sessions +} + +/** + * Renders the descriptive content and timetable grid of a course within CourseModal. + * + * Dynamically displays all populated course requirement fields (prerequisites, + * corequisites, exclusions, breadth, etc.) with course codes linked to navigation, + * alongside an AG Grid table displaying lecture meeting times and locations per session. + * + * @param course Formatted course details object containing description and requirement lists. + * @param sessions Meeting times data partitioned by academic session (F, S, Y). + */ +export function Description({ course, sessions }: DescriptionProps): JSX.Element { + const timetableUnavailable = + Object.keys(sessions).length !== 0 && + sessions.F?.length === 0 && + sessions.S?.length === 0 && + sessions.Y?.length === 0 + + let timetableDisplay + if (timetableUnavailable) { + timetableDisplay = ( +
    No timetable information available
    + ) + } else { + timetableDisplay = Object.keys(sessions).map(session => + sessions[session]?.length !== 0 ? ( +
    + + {session === "F" ? "Fall" : session === "S" ? "Winter" : "Full Year"} + +
    + col.data?.time1.join("\n") ?? "", + width: session === "Y" ? 155 : 205, + autoHeight: true, + }, + { + field: "location1", + headerName: session === "Y" ? "Fall Location" : "Location", + wrapHeaderText: true, + cellStyle: { + whiteSpace: "pre", + lineHeight: "1.8", + paddingTop: "7px", + paddingBottom: "6px", + }, + valueFormatter: col => col.data?.location1.join("\n") ?? "", + width: session === "Y" ? 90 : 153, + autoHeight: true, + }, + { + field: "time2", + headerName: "Winter Time", + wrapHeaderText: true, + cellStyle: { + whiteSpace: "pre", + lineHeight: "1.8", + paddingTop: "7px", + paddingBottom: "6px", + }, + valueFormatter: col => col.data?.time2.join("\n") ?? "", + width: 155, + autoHeight: true, + hide: session !== "Y", + }, + { + field: "location2", + headerName: "Winter Location", + wrapHeaderText: true, + cellStyle: { + whiteSpace: "pre", + lineHeight: "1.8", + paddingTop: "7px", + paddingBottom: "6px", + }, + valueFormatter: col => col.data?.location2.join("\n") ?? "", + width: 90, + autoHeight: true, + hide: session !== "Y", + }, + ]} + domLayout="autoHeight" + > +
    +
    + ) : ( +
    + ) + ) + } + + const COURSE_DETAILS_FIELDS = [ + { key: "prereqString", label: "Prerequisite: " }, + { key: "coreqs", label: "Corequisite: " }, + { key: "prep", label: "Recommended Preparation: " }, + { key: "exclusions", label: "Exclusion: " }, + { key: "distribution", label: "Distribution Requirement Status: " }, + { key: "breadth", label: "Breadth Requirement: " }, + ] as const + + //We want to use the Timetable component, but that component needs to be independent before using it here + return ( +
    + {/* Description does not have a strong label prefix */} + {(course.description?.length ?? 0) > 0 &&

    {course.description}

    } + + {/* Dynamically render all labeled course fields */} + {COURSE_DETAILS_FIELDS.map(({ key, label }) => { + const content = course[key] + if (!content || content.length === 0) return null + + return ( +

    + {label} + {content} +

    + ) + })} + +

    + Timetable: +

    + + {timetableDisplay} +
    + ) +} diff --git a/js/components/common/FocusModal.tsx b/js/components/common/FocusModal.tsx new file mode 100644 index 000000000..83e94b672 --- /dev/null +++ b/js/components/common/FocusModal.tsx @@ -0,0 +1,65 @@ +import { JSX, useEffect, useState } from "react" +import ReactModal from "react-modal" +import { getProgram } from "./utils" +import { ProgramData, ProgramNotModified } from "./types" + +interface FocusModalProps { + showFocusModal: boolean + focusId: string + onClose: () => void +} + +/** + * Modal popup that displays the description and requirements of a computer science focus. + * + * Fetches focus data asynchronously from the server using conditional caching + * (`If-Modified-Since`) and displays the department title, focus description, + * and program completion requirements. + * + * @param showFocusModal Whether the focus modal popup is currently visible. + * @param focusId The unique program code identifier of the focus. + * @param onClose Callback invoked when the modal is closed. + */ +export function FocusModal({ + showFocusModal, + focusId, + onClose, +}: FocusModalProps): JSX.Element { + const [focusTitle, setFocusTitle] = useState("") + const [focusModifiedTime, setFocusModifiedTime] = useState( + new Date(0).toUTCString() + ) + const [focusDescription, setFocusDescription] = useState("") + const [focusRequirements, setFocusRequirements] = useState("") + + useEffect(() => { + if (showFocusModal) { + getProgram(focusId, focusModifiedTime).then( + (focusData: ProgramData | ProgramNotModified) => { + if (!focusData.modified) return + + setFocusTitle(focusData.title) + setFocusDescription(focusData.description) + setFocusRequirements(focusData.requirements) + setFocusModifiedTime(focusData.modifiedTime ?? "") + } + ) + } + }, [showFocusModal, focusId]) + + return ( + +
    {focusTitle}
    +
    +

    +

    +
    +
    + ) +} diff --git a/js/components/common/MapModal.tsx b/js/components/common/MapModal.tsx new file mode 100644 index 000000000..45dc50c41 --- /dev/null +++ b/js/components/common/MapModal.tsx @@ -0,0 +1,212 @@ +import { JSX, useImperativeHandle, useState } from "react" +import ReactModal from "react-modal" +import { CampusMap } from "./CampusMap" +import { MapSidebar } from "./MapSidebar" +import { BuildingGroup, LectureTimeframe } from "./types" + +export interface MapModalHandle { + openModal: () => void +} + +interface MapModalProps { + ref?: React.Ref + lectures: LectureTimeframe[] +} + +/** + * Modal popup that displays an interactive campus map alongside a weekly lecture schedule. + * + * Groups the user's lectures by building and day, allowing users to visualize where + * their classes take place on campus and filter map markers by day or specific lecture sections. + * Exposes an imperative `openModal` handle via ref so external components (e.g. TimetableHeader) + * can trigger the modal to open. + * + * @param lectures List of lecture timeframes scheduled in the current timetable. + * @param ref Optional ref exposing the `MapModalHandle` (`openModal`). + */ +export function MapModal({ ref, lectures }: MapModalProps): JSX.Element { + const [modalIsOpen, setModalIsOpen] = useState(false) + const [selectedLecTimeframes, setSelectedLecTimeframes] = useState< + LectureTimeframe[] + >([]) + + useImperativeHandle(ref, () => ({ + openModal: () => { + setModalIsOpen(true) + }, + })) + + const closeModal = (): void => { + setModalIsOpen(false) + } + + // Group each lecture time by the building they occur in. If the lecture has 2 rooms in different + // buildings, it will appear under both of the buildings. Lectures for each building are grouped by + // their course codes + const groupLecturesByBuilding = ( + lecsByBuilding: BuildingGroup[], + lecture: LectureTimeframe + ): void => { + const loc = lecture.location + if (!loc) return + + const foundBuilding = lecsByBuilding.find(b => b.buildingName === loc.buildingName) + + const timeframeData = { + courseCode: lecture.courseCode, + session: lecture.session, + startTime: lecture.startTime, + endTime: lecture.endTime, + room: loc.room, + } + + const dayData = { + dayString: lecture.dayString, + dayOrder: lecture.day, + timeframes: [timeframeData], + } + + // if the building is not in the lecsByBuilding yet, add it into the array + if (!foundBuilding) { + lecsByBuilding.push({ + buildingName: loc.buildingName, + buildingCode: loc.buildingCode, + address: loc.buildingAddress, + lat: loc.buildingLat, + lng: loc.buildingLng, + postalCode: loc.buildingPostalCode, + days: [dayData], + }) + } + + // the building is already in lecsByBuilding, so add the timeframe to the course if the course already + // exists. Otherwise, add the course with the timeframe + else { + const foundDay = foundBuilding.days.find(c => c.dayOrder === lecture.day) + + if (!foundDay) { + foundBuilding.days.push(dayData) + } else { + foundDay.timeframes.push(timeframeData) + } + } + } + + // Add lecture to selectedLecTimeframes if lecture does not already exist in selectedLecTimeframes. + // Otherwise, remove it from selectedLecTimeframes + const selectLecTimeframe = (lecture: LectureTimeframe): void => { + const updatedLecTimeframes = selectedLecTimeframes.slice() + + const lecTimeframeInd = findSelectedLecTimeframe(lecture, updatedLecTimeframes) + + if (lecTimeframeInd == -1) { + updatedLecTimeframes.push(lecture) + } else { + updatedLecTimeframes.splice(lecTimeframeInd, 1) + } + + setSelectedLecTimeframes(updatedLecTimeframes) + } + + // Add each element of lectures that does not already exist in selectedLecTimeframes to selectedLecTimeframes + const addDayTimeframes = (lectures: LectureTimeframe[]): void => { + const updatedLecTimeframes = selectedLecTimeframes.slice() + + for (const lec of lectures) { + if (findSelectedLecTimeframe(lec, updatedLecTimeframes) == -1) { + updatedLecTimeframes.push(lec) + } + } + + setSelectedLecTimeframes(updatedLecTimeframes) + } + + // Remove each element of lectures from selectedLecTimeframes + const removeDayTimeframes = (lectures: LectureTimeframe[]): void => { + const updatedLecTimeframes = selectedLecTimeframes.slice() + + for (const lec of lectures) { + const lecTimeframeInd = findSelectedLecTimeframe(lec, updatedLecTimeframes) + + if (lecTimeframeInd != -1) { + updatedLecTimeframes.splice(lecTimeframeInd, 1) + } + } + + setSelectedLecTimeframes(updatedLecTimeframes) + } + + // If all elements of lectures are in selectedLecTimeframes, remove all elements of lectures + // from selectedLecTimeframes. Otherwise, add all elements of lectures to selectedLecTimeframes + const selectDayTimeframes = (lectures: LectureTimeframe[]): void => { + if (isDaySelected(lectures)) { + removeDayTimeframes(lectures) + } else { + addDayTimeframes(lectures) + } + } + + // Returns True if all elements of lectures are in selectedLecTimeframes + const isDaySelected = (lectures: LectureTimeframe[]): boolean => { + const updatedLecTimeframes = selectedLecTimeframes.slice() + return lectures.every( + lec => findSelectedLecTimeframe(lec, updatedLecTimeframes) != -1 + ) + } + + // Returns the index of lecture in selectedLecTimeframes if it exists. Otherwise, return -1 + const findSelectedLecTimeframe = ( + lecture: LectureTimeframe, + selectedLectures: LectureTimeframe[] + ): number => { + return selectedLectures.findIndex( + lec => + lec.courseCode === lecture.courseCode && + lec.session === lecture.session && + lec.day == lecture.day && + lec.startTime == lecture.startTime && + lec.endTime == lecture.endTime + ) + } + + const lecturesByBuilding: BuildingGroup[] = [] + + const lecturesByDay: Record = {} + + lectures.forEach(lecture => { + if (lecture.location) { + groupLecturesByBuilding(lecturesByBuilding, lecture) + } + + if (lecturesByDay[lecture.dayString]) { + lecturesByDay[lecture.dayString].push(lecture) + } else { + lecturesByDay[lecture.dayString] = [lecture] + } + }) + + return ( + +
    + + +
    +
    + ) +} diff --git a/js/components/common/MapSidebar.tsx b/js/components/common/MapSidebar.tsx new file mode 100644 index 000000000..ff5a7b94b --- /dev/null +++ b/js/components/common/MapSidebar.tsx @@ -0,0 +1,60 @@ +import { JSX } from "react" +import { DayBox } from "./DayBox" +import { LectureTimeframe } from "./types" + +interface MapSidebarProps { + lecturesByDay: Record + selectLecTimeframe: (lecture: LectureTimeframe) => void + selectDayTimeframes: (lecture: LectureTimeframe[]) => void + isDaySelected: (lectures: LectureTimeframe[]) => boolean + findSelectedLecTimeframe: ( + lecture: LectureTimeframe, + selectedLectures: LectureTimeframe[] + ) => number + selectedLecTimeframes: LectureTimeframe[] +} + +/** + * Collapsible weekly sidebar for the campus map modal. + * + * Iterates through the weekdays (Monday through Friday) and renders a `DayBox` + * for each day that contains scheduled lectures, allowing users to expand days + * and toggle selection of specific lecture timeframes. + * + * @param lecturesByDay Dictionary mapping day names to their array of lecture timeframes. + * @param selectLecTimeframe Callback to toggle selection of a single lecture timeframe. + * @param selectDayTimeframes Callback to select or deselect all lectures on a given day. + * @param isDaySelected Function returning whether all lectures on a day are currently selected. + * @param findSelectedLecTimeframe Helper function finding the index of a lecture in selected lectures. + * @param selectedLecTimeframes List of currently selected lecture timeframes. + */ +export function MapSidebar({ + lecturesByDay, + selectLecTimeframe, + selectDayTimeframes, + isDaySelected, + findSelectedLecTimeframe, + selectedLecTimeframes, +}: MapSidebarProps): JSX.Element { + const dayLectures = [] + const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + + for (const day of days) { + if (lecturesByDay[day]) { + dayLectures.push( + + ) + } + } + + return
    {dayLectures}
    +} diff --git a/js/components/common/__tests__/ConvertToLink.test.js b/js/components/common/__tests__/ConvertToLink.test.js index 341cc85d5..90b054305 100644 --- a/js/components/common/__tests__/ConvertToLink.test.js +++ b/js/components/common/__tests__/ConvertToLink.test.js @@ -1,5 +1,5 @@ import React from "react" -import { CourseModal } from "../react_modal.js.jsx" +import { convertToLink } from "../CourseModal" /** * Test for convertToLink. @@ -8,14 +8,13 @@ describe("ConvertToLink", () => { let actual let content let expected - const wrapper = new CourseModal({}) describe("The content has no course names", () => { beforeEach(() => { content = "The application of logic and proof techniques." }) test("The content is returned as all strings", () => { - actual = wrapper.convertToLink(content) + actual = convertToLink(content) expected = [content] expect(actual).toEqual(expected) }) @@ -26,7 +25,7 @@ describe("ConvertToLink", () => { content = "Certain topics briefly mentioned in CSC165H1 may be covered." }) test("The content is returned as one link tag and several strings", () => { - actual = wrapper.convertToLink(content) + actual = convertToLink(content) expected = [ "Certain topics briefly mentioned in ", { }) test("The content is returned as one link tag and several strings \ (including the symbol after the course name", () => { - actual = wrapper.convertToLink(content) + actual = convertToLink(content) expected = [ "(60% or higher in ", { diff --git a/js/components/common/__tests__/GetTable.test.js b/js/components/common/__tests__/GetTable.test.js index 449069e2e..770d0f02f 100644 --- a/js/components/common/__tests__/GetTable.test.js +++ b/js/components/common/__tests__/GetTable.test.js @@ -1,4 +1,4 @@ -import { CourseModal } from "../react_modal.js.jsx" +import { getTable } from "../CourseModal" /** * Test for getTable. */ @@ -6,7 +6,6 @@ describe("getTable", () => { let actual let meetingTime let expected - const wrapper = new CourseModal({}) describe("The meeting time has only one lecture and the lecture has one occurence", () => { beforeEach(() => { @@ -42,7 +41,7 @@ describe("getTable", () => { ] }) test("The information of the lecture is formatted correctly", () => { - actual = wrapper.getTable(meetingTime, "F") + actual = getTable(meetingTime, "F") expected = [ { activity: "LEC0101", @@ -107,7 +106,7 @@ describe("getTable", () => { ] }) test("The occurences of the lecture are sorted by day", () => { - actual = wrapper.getTable(meetingTime, "F") + actual = getTable(meetingTime, "F") expected = [ { activity: "LEC0101", @@ -173,7 +172,7 @@ describe("getTable", () => { ] }) test("Both of the first and second location of the occurence are shown", () => { - actual = wrapper.getTable(meetingTime, "Y") + actual = getTable(meetingTime, "Y") expected = [ { activity: "LEC0101", @@ -252,7 +251,7 @@ describe("getTable", () => { ] }) test("The lectures are sorted by the section", () => { - actual = wrapper.getTable(meetingTime, "F") + actual = getTable(meetingTime, "F") expected = [ { activity: "LEC0101", @@ -334,7 +333,7 @@ describe("getTable", () => { ] }) test("The lecture is shown before the tutorial", () => { - actual = wrapper.getTable(meetingTime, "F") + actual = getTable(meetingTime, "F") expected = [ { activity: "LEC0101", diff --git a/js/components/common/__tests__/InfoModalFields.test.js b/js/components/common/__tests__/InfoModalFields.test.js index 0ee625431..174980c8f 100644 --- a/js/components/common/__tests__/InfoModalFields.test.js +++ b/js/components/common/__tests__/InfoModalFields.test.js @@ -1,6 +1,6 @@ import React from "react" import { render, screen, cleanup } from "@testing-library/react" -import { Description } from "../react_modal.js.jsx" +import { Description } from "../Description" describe("Displays course information fields iff they are non-empty.", () => { beforeEach(() => cleanup()) diff --git a/js/components/common/__tests__/TimetableLoading.test.js b/js/components/common/__tests__/TimetableLoading.test.js index 238c63f10..95119a2ef 100644 --- a/js/components/common/__tests__/TimetableLoading.test.js +++ b/js/components/common/__tests__/TimetableLoading.test.js @@ -1,6 +1,6 @@ import React from "react" import { render, screen, cleanup } from "@testing-library/react" -import { Description } from "../react_modal.js.jsx" +import { Description } from "../Description" describe("Displays correct content based on timetable availability", () => { beforeEach(() => cleanup()) diff --git a/js/components/common/react_modal.js.jsx b/js/components/common/react_modal.js.jsx deleted file mode 100644 index dd2e82c27..000000000 --- a/js/components/common/react_modal.js.jsx +++ /dev/null @@ -1,1009 +0,0 @@ -import React, { createRef } from "react" -import ReactModal from "react-modal" -import { FeatureGroup, MapContainer, Marker, Popup, TileLayer } from "react-leaflet" -import L from "leaflet" -import { getCourse, getProgram } from "../common/utils" -import { AgGridReact } from "ag-grid-react" - -const DAY_TO_INT = { - 0: "Monday", - 1: "Tuesday", - 2: "Wednesday", - 3: "Thursday", - 4: "Friday", -} - -const COURSE_CODE_REGEX = /([A-Z]{3}[0-9]{3}[H|Y]1)/ - -class ModalContent extends React.Component { - render() { - return ( -
    - -
    - ) - } -} - -class CourseModal extends React.Component { - constructor(props) { - super(props) - this.state = { - courseId: "", - course: [], - sessions: {}, - courseTitle: "", - visitedCourses: [], - currVisitedIndex: 0, - } - } - - /** - * Change the courseId state, whenever a course link is clicked. - * Additionally, add the courseId to the list of visited courses. - */ - linkStateChange = (courseLink, index) => { - if (this.state.courseId === courseLink) { - // No state is required to update. Do nothing. - } else if (index === undefined) { - // In this case, a course link was clicked. - const newVisitedCourses = this.state.visitedCourses.slice( - 0, - this.state.currVisitedIndex + 1 - ) - - this.setState({ - courseId: courseLink, - visitedCourses: newVisitedCourses.concat(courseLink), - currVisitedIndex: this.state.currVisitedIndex + 1, - }) - } else { - // In this case, a button was clicked. - this.setState({ - courseId: courseLink, - currVisitedIndex: index, - }) - } - } - - /** - * Convert the course names to
    tags and return a list of strings and tags. - */ - convertToLink = content => { - if (!content) { - return [] - } - - const result = content.split(COURSE_CODE_REGEX) - for (let i = 0; i < result.length; i++) { - let word = result[i] - if (word.match(COURSE_CODE_REGEX)) { - result[i] = ( - this.linkStateChange(word)} - > - {word} - - ) - } - } - return result - } - - componentDidUpdate(prevProps, prevState) { - if (this.props.showCourseModal !== prevProps.showCourseModal) { - this.setState({ - courseId: this.props.courseId, - visitedCourses: [this.props.courseId], - currVisitedIndex: 0, - }) - } else if (prevState.courseId !== this.state.courseId) { - getCourse(this.state.courseId) - .then(course => { - const newCourse = { - ...course, - description: this.convertToLink(course.description), - prereqString: this.convertToLink(course.prereqString), - coreqs: this.convertToLink(course.coreqs), - prep: this.convertToLink(course.prep), - exclusions: this.convertToLink(course.exclusions), - } - - const sessions = { - F: this.getTable(course.allMeetingTimes, "F"), - S: this.getTable(course.allMeetingTimes, "S"), - Y: this.getTable(course.allMeetingTimes, "Y"), - } - - this.setState({ - course: newCourse, - sessions: sessions, - courseTitle: `${course.name} ${course.title}`, - }) - }) - .catch(error => { - console.error(`Course with code ${this.state.courseId} not found`) - this.setState({ - course: {}, - sessions: {}, - courseTitle: "Course Not Found", - }) - }) - } - } - - /** - * Helper function to format a Time data object for display in the course modal. - * @param {object} time A Time data object. - * @returns {string} The time formatted as a string (e.g. "Tuesday 11 - 13"). - * */ - formatTime(time) { - return DAY_TO_INT[time.weekDay] + " " + time.startHour + " - " + time.endHour - } - - /** - * Generate the data needed for the course modal table based on the meeting times corresponding - * to a course in a given session. - * @param {object[]} allMeetTimes An array of MeetingData objects corresponding to a particular course. - * @param {string} session The session (F, S, Y) to query. - * @returns {object[]} An array of row data objects that will appear in the course modal table. - */ - getTable(allMeetTimes, session) { - // Filter and sort the lecture sections in the specified session by their section code - const filteredMeetTimes = allMeetTimes.filter( - meetTime => meetTime.meetData.session === session - ) - const sortedMeetTimes = filteredMeetTimes.sort((firstMeetTime, secondMeetTime) => - firstMeetTime.meetData.section > secondMeetTime.meetData.section ? 1 : -1 - ) - - return sortedMeetTimes.map(meetTime => { - // Sort each section's meeting time blocks and corresponding locations and store them in arrays. - // times2, locations2 are used for the extra columns that appear in the table when viewing a Y course. - const occurrences = { times1: [], locations1: [], times2: [], locations2: [] } - const sortedTimeData = meetTime.timeData.sort((time1, time2) => - time1.weekDay > time2.weekDay ? 1 : -1 - ) - sortedTimeData.map(time => { - let location = " " - if (time.timeLocation !== null && time.timeLocation !== undefined) { - location = time.timeLocation.buildingCode - } - - if (session === "Y" && time.timeSession.endsWith("1")) { - occurrences.locations2.push(location) - occurrences.times2.push(this.formatTime(time)) - } else { - occurrences.locations1.push(location) - occurrences.times1.push(this.formatTime(time)) - } - }) - const rowData = { - activity: meetTime.meetData.section, - instructor: meetTime.meetData.instructor, - availability: - meetTime.meetData.cap - - meetTime.meetData.enrol + - " of " + - meetTime.meetData.cap + - " available", - waitList: meetTime.meetData.wait + " students", - time1: occurrences.times1, - location1: occurrences.locations1, - time2: occurrences.times2, - location2: occurrences.locations2, - } - - return rowData - }) - } - - /** - * Handles the click event when the back button is clicked in the info modal. - * Updates the state to navigate back to the previous visited course and updates the current visited index. - */ - infoModalBackClick = () => { - this.linkStateChange( - this.state.visitedCourses[this.state.currVisitedIndex - 1], - this.state.currVisitedIndex - 1 - ) - } - - /** - * Handles the click event when the forward button is clicked in the info modal. - * Updates the state to navigate forward to the next visited course and updates the current visited index. - */ - infoModalForwardClick = () => { - this.linkStateChange( - this.state.visitedCourses[this.state.currVisitedIndex + 1], - this.state.currVisitedIndex + 1 - ) - } - - render() { - return ( - -
    - {this.state.courseTitle} - -
    - - - -
    -
    - -
    - -
    -
    - ) - } -} - -//Use React component from search.js -class Description extends React.Component { - render() { - const timetableUnavailable = - Object.keys(this.props.sessions).length !== 0 && - this.props.sessions.F.length === 0 && - this.props.sessions.S.length === 0 && - this.props.sessions.Y.length === 0 - - let timetableDisplay - if (timetableUnavailable) { - timetableDisplay = ( -
    No timetable information available
    - ) - } else { - timetableDisplay = Object.keys(this.props.sessions).map(session => - this.props.sessions[session].length !== 0 ? ( -
    - - {session === "F" ? "Fall" : session === "S" ? "Winter" : "Full Year"} - -
    - col.data.time1.join("\n"), - width: session === "Y" ? 155 : 205, - autoHeight: true, - }, - { - field: "location1", - headerName: session === "Y" ? "Fall Location" : "Location", - wrapHeaderText: true, - cellStyle: { - whiteSpace: "pre", - lineHeight: "1.8", - paddingTop: "7px", - paddingBottom: "6px", - }, - valueFormatter: col => col.data.location1.join("\n"), - width: session === "Y" ? 90 : 153, - autoHeight: true, - }, - - { - field: "time2", - headerName: "Winter Time", - wrapHeaderText: true, - cellStyle: { - whiteSpace: "pre", - lineHeight: "1.8", - paddingTop: "7px", - paddingBottom: "6px", - }, - valueFormatter: col => col.data.time2.join("\n"), - width: 155, - autoHeight: true, - hide: session !== "Y", - }, - { - field: "location2", - headerName: "Winter Location", - wrapHeaderText: true, - cellStyle: { - whiteSpace: "pre", - lineHeight: "1.8", - paddingTop: "7px", - paddingBottom: "6px", - }, - valueFormatter: col => col.data.location2.join("\n"), - width: 90, - autoHeight: true, - hide: session !== "Y", - }, - ]} - domLayout="autoHeight" - > -
    -
    - ) : ( -
    - ) - ) - } - - //We want to use the Timetable component, but that component needs to be independent before using it here - return ( -
    - {/* Render individual fields iff they are non-empty */} - {this.props.course.description?.length > 0 && ( -

    {this.props.course.description}

    - )} - {this.props.course.prereqString?.length > 0 && ( -

    - Prerequisite: - {this.props.course.prereqString} -

    - )} - {this.props.course.coreqs?.length > 0 && ( -

    - Corequisite: - {this.props.course.coreqs} -

    - )} - {this.props.course.prep?.length > 0 && ( -

    - Recommended Preparation: - {this.props.course.prep} -

    - )} - {this.props.course.exclusions?.length > 0 && ( -

    - Exclusion: - {this.props.course.exclusions} -

    - )} - {this.props.course.distribution?.length > 0 && ( -

    - Distribution Requirement Status: - {this.props.course.distribution} -

    - )} - {this.props.course.breadth?.length > 0 && ( -

    - Breadth Requirement: - {this.props.course.breadth} -

    - )} -

    - Timetable: -

    - - {timetableDisplay} - - {/*
    - ) - } -} - -/** - * This component handles the modal popup that appears when viewing the description of a focus - */ -class FocusModal extends React.Component { - constructor(props) { - super(props) - this.state = { - focusTitle: "", - focusInfo: { - description: "", - requiredCourses: [], - relatedCourses: [], - }, - focusModifiedTime: new Date(0).toUTCString(), - } - } - - componentDidUpdate(prevProps) { - if (this.props.showFocusModal && !prevProps.showFocusModal) { - getProgram(this.props.focusId, this.state.focusModifiedTime).then(focusData => { - if (!focusData.modified) return - - this.setState({ - focusTitle: focusData.title, - focusDescription: focusData.description, - focusRequirements: focusData.requirements, - focusModifiedTime: focusData.modifiedTime, - }) - }) - } - } - - render() { - return ( - -
    {this.state.focusTitle}
    -
    -

    -

    -
    -
    - ) - } -} - -class Video extends React.Component { - // TODO: put this back in. - // static defaultProps = { - // urls: [] - // } - - render() { - if (this.props.urls.length > 0) { - return ( -
    - -
    - ) - } else { - return
    - } - } -} - -/** - * Modal containing a map with markers corresponding to the locations of the courses currently on the - * time table - */ -class MapModal extends React.Component { - constructor(props) { - super(props) - this.state = { - modalIsOpen: false, - selectedLecTimeframes: [], - } - this.openModal = this.openModal.bind(this) - this.closeModal = this.closeModal.bind(this) - this.groupLecturesByBuilding = this.groupLecturesByBuilding.bind(this) - this.selectLecTimeframe = this.selectLecTimeframe.bind(this) - this.addDayTimeframes = this.addDayTimeframes.bind(this) - this.removeDayTimeframes = this.removeDayTimeframes.bind(this) - this.isDaySelected = this.isDaySelected.bind(this) - this.selectDayTimeframes = this.selectDayTimeframes.bind(this) - } - - openModal() { - this.setState({ modalIsOpen: true }) - } - - closeModal() { - this.setState({ modalIsOpen: false }) - } - - // Group each lecture time by the building they occur in. If the lecture has 2 rooms in different - // buildings, it will appear under both of the buildings. Lectures for each building are grouped by - // their course codes - groupLecturesByBuilding(lecsByBuilding, lecture, roomNum) { - const foundBuilding = lecsByBuilding.find( - b => b.buildingName === lecture[roomNum].buildingName - ) - - const timeframeData = { - courseCode: lecture.courseCode, - session: lecture.session, - startTime: lecture.startTime, - endTime: lecture.endTime, - room: lecture[roomNum].room, - } - - const dayData = { - dayString: lecture.dayString, - dayOrder: lecture.day, - timeframes: [timeframeData], - } - - // if the building is not in the lecsByBuilding yet, add it into the array - if (!foundBuilding) { - lecsByBuilding.push({ - buildingName: lecture[roomNum].buildingName, - buildingCode: lecture[roomNum].buildingCode, - address: lecture[roomNum].buildingAddress, - lat: lecture[roomNum].buildingLat, - lng: lecture[roomNum].buildingLng, - postalCode: lecture[roomNum].buildingPostalCode, - days: [dayData], - }) - } - // the building is already in lecsByBuilding, so add the timeframe to the course if the course already - // exists. Otherwise, add the course with the timeframe - else { - const foundDay = foundBuilding.days.find(c => c.dayOrder === lecture.day) - - if (!foundDay) { - foundBuilding.days.push(dayData) - } else { - foundDay.timeframes.push(timeframeData) - } - } - } - - // Add lecture to selectedLecTimeframes if lecture does not already exist in selectedLecTimeframes. - // Otherwise, remove it from selectedLecTimeframes - selectLecTimeframe(lecture) { - const updatedLecTimeframes = this.state.selectedLecTimeframes.slice() - - const lecTimeframeInd = this.findSelectedLecTimeframe(lecture, updatedLecTimeframes) - - if (lecTimeframeInd == -1) { - updatedLecTimeframes.push(lecture) - } else { - updatedLecTimeframes.splice(lecTimeframeInd, 1) - } - - this.setState({ selectedLecTimeframes: updatedLecTimeframes }) - } - - // Add each element of lectures that does not already exist in selectedLecTimeframes to selectedLecTimeframes - addDayTimeframes(lectures) { - const updatedLecTimeframes = this.state.selectedLecTimeframes.slice() - - for (const lec of lectures) { - if (this.findSelectedLecTimeframe(lec, updatedLecTimeframes) == -1) { - updatedLecTimeframes.push(lec) - } - } - - this.setState({ selectedLecTimeframes: updatedLecTimeframes }) - } - - // Remove each element of lectures from selectedLecTimeframes - removeDayTimeframes(lectures) { - const updatedLecTimeframes = this.state.selectedLecTimeframes.slice() - - for (const lec of lectures) { - const lecTimeframeInd = this.findSelectedLecTimeframe(lec, updatedLecTimeframes) - - if (lecTimeframeInd != -1) { - updatedLecTimeframes.splice(lecTimeframeInd, 1) - } - } - - this.setState({ selectedLecTimeframes: updatedLecTimeframes }) - } - - // If all elements of lectures are in selectedLecTimeframes, remove all elements of lectures - // from selectedLecTimeframes. Otherwise, add all elements of lectures to selectedLecTimeframes - selectDayTimeframes(lectures) { - if (this.isDaySelected(lectures)) { - this.removeDayTimeframes(lectures) - } else { - this.addDayTimeframes(lectures) - } - } - - // Returns True if all elements of lectures are in selectedLecTimeframes - isDaySelected(lectures) { - const updatedLecTimeframes = this.state.selectedLecTimeframes.slice() - return lectures.every( - lec => this.findSelectedLecTimeframe(lec, updatedLecTimeframes) != -1 - ) - } - - // Returns the index of lecture in selectedLecTimeframes if it exists. Otherwise, return -1 - findSelectedLecTimeframe(lecture, selectedLectures) { - return selectedLectures.findIndex( - lec => - lec.courseCode === lecture.courseCode && - lec.session === lecture.session && - lec.day == lecture.day && - lec.startTime == lecture.startTime && - lec.endTime == lecture.endTime - ) - } - - render() { - const lecturesByBuilding = [] - - const lecturesByDay = {} - - this.props.lectures.forEach(lecture => { - if (lecture.location) { - this.groupLecturesByBuilding(lecturesByBuilding, lecture, "location") - } - - if (lecturesByDay[lecture.dayString]) { - lecturesByDay[lecture.dayString].push(lecture) - } else { - lecturesByDay[lecture.dayString] = [lecture] - } - }) - - return ( - -
    - - -
    -
    - ) - } -} - -/** - * The sidebar for the Map modal - */ -class MapSidebar extends React.Component { - constructor(props) { - super(props) - } - - render() { - const dayLectures = [] - - const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] - - for (const day of days) { - if (this.props.lecturesByDay[day]) { - dayLectures.push( - - ) - } - } - - return
    {dayLectures}
    - } -} - -/** - * An expandable weekday box with its corresponding lectures - */ -class DayBox extends React.Component { - constructor(props) { - super(props) - this.state = { - expanded: false, - } - - this.toggleExpand = this.toggleExpand.bind(this) - this.formatLocation = this.formatLocation.bind(this) - } - - toggleExpand() { - this.setState({ expanded: !this.state.expanded }) - } - - formatLocation(location) { - return "Location: " + location.buildingName + " (" + location.buildingCode + ")" - } - - render() { - const isSelected = this.props.isDaySelected(this.props.dayLectures) - - const arrowColour = isSelected ? "lightpurple" : "darkpurple" - - return ( -
    -
    -
    -

    this.props.selectDayTimeframes(this.props.dayLectures)} - style={{ display: "inline-block" }} - > - {" "} - {this.props.day}{" "} -

    -
    - - -
    - - {this.state.expanded && ( -
      - {this.props.dayLectures.map(lec => { - let locationStr = "" - if (lec.location) { - locationStr += this.formatLocation(lec.location) - } - - return ( -
    • this.props.selectLecTimeframe(lec)} - > -

      {lec.courseCode}

      -
      - {" "} - {convertTimeToString(lec.startTime) + - "-" + - convertTimeToString(lec.endTime)}{" "} -
      -
      {locationStr}
      -
    • - ) - })} -
    - )} -
    - ) - } -} - -/** - * The map of the St.George campus used in the map modal - */ -class CampusMap extends React.Component { - constructor(props) { - super(props) - - this.zoomToFit = this.zoomToFit.bind(this) - this.mapRef = createRef() - this.groupRef = createRef() - } - - zoomToFit() { - const map = this.mapRef.current.leafletElement - const mapGroup = this.groupRef.current.leafletElement - - // markerCount is the number of markers on the map. - // Only adjust the map bounds if there are multiple markers because the zoom will - // be strange for 1 marker. Note that the map will be centered on the single marker - const markerCount = mapGroup.getLayers().length - - if (markerCount > 1) { - map.fitBounds(mapGroup.getBounds(), { padding: [30, 30] }) - } - } - - render() { - const blueMarker = new L.Icon({ - iconUrl: "/static/res/ico/blue-marker.png", - shadowUrl: "/static/res/ico/shadow.png", - iconAnchor: [5, 35], - popupAnchor: [13, -35], - iconSize: [34, 35], - shadowSize: [50, 85], - shadowAnchor: [2, 90], - }) - - const redMarker = new L.Icon({ - iconUrl: "/static/res/ico/red-marker.png", - shadowUrl: "/static/res/ico/shadow.png", - iconAnchor: [5, 35], - popupAnchor: [13, -35], - iconSize: [34, 35], - shadowSize: [50, 85], - shadowAnchor: [2, 90], - }) - - const locationMarkers = this.props.lecturesByBuilding.map(building => { - const description = building.days - .sort((dayA, dayB) => { - return dayA.dayOrder - dayB.dayOrder - }) - .map(day => { - const dayTimes = day.timeframes - .sort((timeA, timeB) => { - return timeA.startTime - timeB.startTime - }) - .map(time => ( -
  • - {convertTimeToString(time.startTime) + - "-" + - convertTimeToString(time.endTime) + - ", " + - time.room + - ": " + - time.courseCode} -
  • - )) - - return ( -
    -

    {day.dayString}

    - {dayTimes} -
    - ) - }) - - let colouredMarker - const buildingInd = this.props.selectedLecTimeframes.findIndex( - lec => lec.location && lec.location.buildingCode === building.buildingCode - ) - - colouredMarker = buildingInd == -1 ? blueMarker : redMarker - - return ( - - -

    - {building.buildingName + " | " + building.buildingCode} -

    -

    - {building.address} -

    - {description} -
    -
    - ) - }) - - const mapBounds = L.latLngBounds() - - // If there are selected lectures, center the map based on the locations of those lectures, otherwise, center the map - // on Bahen - let center = [43.6596426, -79.3976676] - - if (this.props.lecturesByBuilding.length) { - this.props.lecturesByBuilding.forEach(building => - mapBounds.extend(L.latLng(building.lat, building.lng)) - ) - - center = mapBounds.getCenter() - } - - return ( -
    - - - {locationMarkers} - - -
    - ) - } -} - -/** - * Returns and formats all course codes in id. - * @param {string} id The Node's ID. - * @returns {string[]} All formatted course codes. - * TODO: Change function name - */ -function formatCourseName(id) { - let names - - if (id === "CSC200") { - names = id + "Y1" - } else if (id === "Calc1") { - names = "MAT135H1" + " " + "MAT136H1" + " " + "MAT137Y1" + " " + "MAT157Y1" - } else if (id === "Alg1") { - names = "MAT221H1" + " " + "MAT223H1" + " " + "MAT240H1" - } else if (id === "Sta1") { - names = "STA247H1" + " " + "STA255H1" - } else if (id === "Sta2") { - names = "STA248H1" + " " + "STA261H1" - } else if (id.indexOf("H1", id.length - 2) !== -1) { - names = id - } else { - names = id + "H1" - } - - names = names.split(" ") - return names -} - -// Helper function to get the string representation of the time -function convertTimeToString(time) { - const meridiem = time < 12 ? "AM" : "PM" - - if (time % 1 === 0) { - return (time === 12 ? 12 : time % 12) + ":00" + meridiem - } - return ((time === 12.5 ? 12 : Math.floor(time)) % 12) + ":30" + meridiem -} - -export { CourseModal, Description, MapModal, FocusModal } diff --git a/js/components/common/types.ts b/js/components/common/types.ts new file mode 100644 index 000000000..b31539acc --- /dev/null +++ b/js/components/common/types.ts @@ -0,0 +1,136 @@ +import { JSX } from "react" + +interface Building { + buildingCode: string + buildingName?: string + buildingAddress?: string + buildingPostalCode?: string + buildingLat?: number + buildingLng?: number +} + +export interface TimeData { + weekDay: 0 | 1 | 2 | 3 | 4 + startHour?: number + endHour?: number + timeLocation?: Building | null + timeSession?: string +} + +export interface MeetingItem { + meetData: { + session: string + section: string + instructor: string + cap: number + enrol: number + wait: number + } + timeData: TimeData[] +} + +export interface CourseDetails { + breadth?: string | null + description?: (string | JSX.Element)[] + title?: string | null + prereqString?: (string | JSX.Element)[] + allMeetingTimes: MeetingItem[] + name?: string + prep?: (string | JSX.Element)[] + exclusions?: (string | JSX.Element)[] + distribution?: string | null + coreqs?: (string | JSX.Element)[] +} + +export interface SessionRowData { + activity: string + instructor: string + availability: string + waitList: string + time1: string[] + location1: string[] + time2: string[] + location2: string[] +} + +export interface Sessions { + F?: SessionRowData[] + S?: SessionRowData[] + Y?: SessionRowData[] + [key: string]: SessionRowData[] | undefined +} + +export interface LectureTimeframe { + courseCode: string + session: string + timeSession?: string | null + day: number + dayString: string + startTime: number + endTime: number + location?: BuildingLocation | null + inConflict?: boolean + width?: number + dataSatisfied?: boolean +} + +export interface TimeframeData { + courseCode: string + session: string + startTime: number + endTime: number + room?: string +} + +export interface DayData { + dayString: string + dayOrder: number + timeframes: TimeframeData[] +} + +export interface BuildingLocation { + buildingCode?: string + buildingName?: string + buildingAddress?: string + buildingPostalCode?: string + buildingLat?: number + buildingLng?: number + room?: string +} + +export interface RawCourseData { + breadth?: string | null + description?: string | null + title?: string | null + prereqString?: string | null + allMeetingTimes: MeetingItem[] + name?: string + prep?: string | null + exclusions?: string | null + distribution?: string | null + coreqs?: string | null + videoUrls?: string[] +} + +export interface BuildingGroup { + buildingName?: string + buildingCode?: string + address?: string + lat?: number + lng?: number + postalCode?: string + days: DayData[] +} + +export interface ProgramData { + modified: true + title: string + description: string + requirements: string + courseList: string[] + modifiedTime: string | null +} + +export interface ProgramNotModified { + modified: false +} diff --git a/js/components/common/utils.ts b/js/components/common/utils.ts index 78049bb19..eda3ce581 100644 --- a/js/components/common/utils.ts +++ b/js/components/common/utils.ts @@ -1,16 +1,4 @@ -interface ProgramData { - modified: true - title: string - description: string - requirements: string - courseList: string[] - modifiedTime: string | null -} - -interface ProgramNotModified { - modified: false -} - +import { ProgramData, ProgramNotModified } from "./types" /** * Retrieves a course from file. * @param courseName The course code. This + '.txt' is the name of the file. @@ -76,3 +64,13 @@ function getCourseList(requirements: string): string[] { const courseList = requirements.match(courseCodeRegex) || [] return courseList.map(course => course.toLowerCase()) } + +// Helper function to get the string representation of the time +export function convertTimeToString(time: number): string { + const meridiem = time < 12 ? "AM" : "PM" + + if (time % 1 === 0) { + return (time === 12 ? 12 : time % 12) + ":00" + meridiem + } + return ((time === 12.5 ? 12 : Math.floor(time)) % 12) + ":30" + meridiem +} diff --git a/js/components/graph/FocusTab.tsx b/js/components/graph/FocusTab.tsx index 5b3729385..a6a1854ef 100644 --- a/js/components/graph/FocusTab.tsx +++ b/js/components/graph/FocusTab.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { FocusModal } from "../common/react_modal.js.jsx" +import { FocusModal } from "../common/FocusModal" interface FocusTabProps { focusName: string diff --git a/js/components/graph/Graph.js b/js/components/graph/Graph.js index 2262ea6d4..6f9f17b6d 100644 --- a/js/components/graph/Graph.js +++ b/js/components/graph/Graph.js @@ -1,6 +1,6 @@ import React from "react" import PropTypes from "prop-types" -import { CourseModal } from "../common/react_modal.js.jsx" +import { CourseModal } from "../common/CourseModal" import { ExportModal } from "../common/export" import { getProgram } from "../common/utils" import Bool from "./Bool" diff --git a/js/components/grid/calendar.js.jsx b/js/components/grid/calendar.js.jsx index 5f3ca1e04..0878c78ad 100644 --- a/js/components/grid/calendar.js.jsx +++ b/js/components/grid/calendar.js.jsx @@ -1,5 +1,5 @@ import { createRef, useCallback } from "react" -import { MapModal } from "../common/react_modal.js.jsx" +import { MapModal } from "../common/MapModal" /* * Holds the containers of the Fall and Spring timetables, diff --git a/js/components/grid/course_panel.js.jsx b/js/components/grid/course_panel.js.jsx index 84035a6ee..bf7b42f2a 100644 --- a/js/components/grid/course_panel.js.jsx +++ b/js/components/grid/course_panel.js.jsx @@ -1,5 +1,5 @@ import { useCallback, useState, useEffect } from "react" -import { CourseModal } from "../common/react_modal.js.jsx" +import { CourseModal } from "../common/CourseModal" import { getCourse } from "../common/utils" /** diff --git a/package.json b/package.json index 34284c564..c6d8a7873 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/jquery": "^4.0.1", + "@types/leaflet": "^1.9.22", "@types/node": "^22.20.2", "@types/react-dom": "^19.3.0", "@types/react-modal": "^3.16.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d7182e2..9269d4e4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@types/jquery': specifier: ^4.0.1 version: 4.0.1 + '@types/leaflet': + specifier: ^1.9.22 + version: 1.9.22 '@types/node': specifier: ^22.20.2 version: 22.20.2 @@ -1678,6 +1681,9 @@ packages: '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hoist-non-react-statics@3.3.7': resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} peerDependencies: @@ -1713,6 +1719,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/leaflet@1.9.22': + resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -7475,6 +7484,8 @@ snapshots: '@types/gensync@1.0.5': {} + '@types/geojson@7946.0.16': {} + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -7513,6 +7524,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/leaflet@1.9.22': + dependencies: + '@types/geojson': 7946.0.16 + '@types/mime@1.3.5': {} '@types/node@22.20.2': From c3c7ed806df9e0d593549ff067401b9927f4e519 Mon Sep 17 00:00:00 2001 From: Daniel Rafailov Date: Sat, 24 Oct 2026 15:16:05 -0400 Subject: [PATCH 2/2] Added changes to Changelog file --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 127278024..13352ac1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### 🔧 Internal changes +- Refactored `react_modal.js.jsx` into modular functional TypeScript components (`CampusMap.tsx`, `CourseModal.tsx`, `DayBox.tsx`, `Description.tsx`, `FocusModal.tsx`, `MapModal.tsx`, `MapSidebar.tsx`), added shared `types.ts`, and removed unused dead code - Migrated `Sidebar.js`, `FocusBar.js`, and `NavBar.js.jsx` to TypeScript - Removed `Time'` data type in favour of `(MeetingId -> Times)` function type in `Database.Tables` - Refactored `Database.Tables.MeetTime'` into `Models.Meeting` and renamed it to `MeetingData`