diff --git a/src/manifest.ts b/src/manifest.ts index 40a50f7f..8efde8fa 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -17,6 +17,7 @@ const HOST_PERMISSIONS: string[] = [ '*://*.catalog.utexas.edu/ribbit/', '*://*.registrar.utexas.edu/schedules/*', '*://*.login.utexas.edu/login/*', + 'https://utexas.bluera.com/*', ]; const manifest = defineManifest(async () => ({ @@ -26,7 +27,7 @@ const manifest = defineManifest(async () => ({ description: packageJson.description, options_page: 'src/pages/options/index.html', background: { service_worker: 'src/pages/background/background.ts' }, - permissions: ['storage', 'unlimitedStorage', 'background'], + permissions: ['storage', 'unlimitedStorage', 'background', 'scripting'], host_permissions: process.env.MODE === 'development' ? [...HOST_PERMISSIONS, ''] : HOST_PERMISSIONS, action: { default_popup: 'src/pages/popup/index.html', diff --git a/src/pages/background/background.ts b/src/pages/background/background.ts index de601f71..156dd8a3 100644 --- a/src/pages/background/background.ts +++ b/src/pages/background/background.ts @@ -4,6 +4,7 @@ import { MessageListener } from 'chrome-extension-toolkit'; import onInstall from './events/onInstall'; import onServiceWorkerAlive from './events/onServiceWorkerAlive'; import onUpdate from './events/onUpdate'; +import CESHandler from './handler/CESHandler'; import browserActionHandler from './handler/browserActionHandler'; import tabManagementHandler from './handler/tabManagementHandler'; import userScheduleHandler from './handler/userScheduleHandler'; @@ -32,6 +33,7 @@ const messageListener = new MessageListener({ ...browserActionHandler, ...tabManagementHandler, ...userScheduleHandler, + ...CESHandler, }); messageListener.listen(); diff --git a/src/pages/background/handler/CESHandler.ts b/src/pages/background/handler/CESHandler.ts new file mode 100644 index 00000000..a9e4f927 --- /dev/null +++ b/src/pages/background/handler/CESHandler.ts @@ -0,0 +1,39 @@ +import openNewTab from '@background/util/openNewTab'; +import type CESMessage from '@shared/messages/CESMessage'; +import type { MessageHandler } from 'chrome-extension-toolkit'; + +const CESFall2023Url = 'https://utexas.bluera.com/utexas/rpvl.aspx?rid=d3db767b-049f-46c5-9a67-29c21c29c580®l=en-US'; + +const CESHandler: MessageHandler = { + openCESPage({ data, sendResponse }) { + const { instructorFirstName, instructorLastName } = data; + openNewTab(CESFall2023Url).then(tab => { + const instructorFirstAndLastName = [instructorFirstName, instructorLastName]; + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (...instructorFirstAndLastName: String[]) => { + const inputElement = document.getElementById( + 'ctl00_ContentPlaceHolder1_ViewList_tbxValue' + ) as HTMLInputElement | null; + const [instructorFirstName, instructorLastName] = instructorFirstAndLastName; + if (inputElement) { + inputElement.value = `${instructorFirstName} ${instructorLastName}`; + inputElement.focus(); + const enterKeyEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + }); + inputElement.dispatchEvent(enterKeyEvent); + } + }, + args: instructorFirstAndLastName, + }); + sendResponse(tab); + }); + }, +}; + +export default CESHandler; diff --git a/src/shared/messages/CESMessage.ts b/src/shared/messages/CESMessage.ts new file mode 100644 index 00000000..dd45354d --- /dev/null +++ b/src/shared/messages/CESMessage.ts @@ -0,0 +1,10 @@ +interface CESMessage { + /** + * Opens the CES page for the specified instructor + * + * @param data first and last name of the instructor + */ + openCESPage: (data: { instructorFirstName: string; instructorLastName: string }) => chrome.tabs.Tab; +} + +export default CESMessage; diff --git a/src/shared/messages/index.ts b/src/shared/messages/index.ts index 806d4a5e..12b1c99d 100644 --- a/src/shared/messages/index.ts +++ b/src/shared/messages/index.ts @@ -1,6 +1,7 @@ import { createMessenger } from 'chrome-extension-toolkit'; import type BrowserActionMessages from './BrowserActionMessages'; +import type CESMessage from './CESMessage'; import type TabManagementMessages from './TabManagementMessages'; import type TAB_MESSAGES from './TabMessages'; import type { UserScheduleMessages } from './UserScheduleMessages'; @@ -8,7 +9,7 @@ import type { UserScheduleMessages } from './UserScheduleMessages'; /** * This is a type with all the message definitions that can be sent TO the background script */ -export type BACKGROUND_MESSAGES = BrowserActionMessages & TabManagementMessages & UserScheduleMessages; +export type BACKGROUND_MESSAGES = BrowserActionMessages & TabManagementMessages & UserScheduleMessages & CESMessage; /** * A utility object that can be used to send type-safe messages to the background script diff --git a/src/shared/util/themeColors.ts b/src/shared/util/themeColors.ts index c20775e5..840a2e2e 100644 --- a/src/shared/util/themeColors.ts +++ b/src/shared/util/themeColors.ts @@ -26,7 +26,7 @@ export const colors = { cplus: '#F59E0B', c: '#FB923C', cminus: '#F97316', - dplus: '#EA580C', // TODO (achadaga): copilot generated, get actual color from Isaiah + dplus: '#EF4444', d: '#DC2626', dminus: '#B91C1C', f: '#B91C1C', diff --git a/src/stories/injected/CourseCatalogInjectedPopup.stories.ts b/src/stories/injected/CourseCatalogInjectedPopup.stories.ts index bb063565..a9fa0069 100644 --- a/src/stories/injected/CourseCatalogInjectedPopup.stories.ts +++ b/src/stories/injected/CourseCatalogInjectedPopup.stories.ts @@ -9,6 +9,51 @@ const exampleSchedule: UserSchedule = new UserSchedule({ name: 'Example Schedule', hours: 0, }); +// TODO (achadaga): import this after +// https://github.com/Longhorn-Developers/UT-Registration-Plus/pull/106 is merged +const bevoCourse: Course = new Course({ + uniqueId: 47280, + number: '311C', + fullName: "BVO 311C BEVO'S SEMINAR LONGHORN CARE", + courseName: "BEVO'S SEMINAR LONGHORN CARE", + department: 'BVO', + creditHours: 3, + status: Status.OPEN, + instructors: [new Instructor({ fullName: 'BEVO', firstName: '', lastName: 'BEVO', middleInitial: '' })], + isReserved: false, + description: [ + 'Restricted to Students in the School of Longhorn Enthusiasts', + 'Immerse yourself in the daily routine of a longhorn—sunrise pasture walks and the best shady spots for a midday siesta. Understand the behavioral science behind our mascot’s stoic demeanor during games.', + 'BVO 311C and 312H may not both be counted.', + 'Prerequisite: Grazing 311 or 311H.', + 'May be counted toward the Independent Inquiry flag requirement. May be counted toward the Writing flag requirement', + 'Offered on the letter-grade basis only.', + ], + schedule: new CourseSchedule({ + meetings: [ + new CourseMeeting({ + days: ['Tuesday', 'Thursday'], + startTime: 480, + endTime: 570, + location: { building: 'UTC', room: '123' }, + }), + new CourseMeeting({ + days: ['Thursday'], + startTime: 570, + endTime: 630, + location: { building: 'JES', room: '123' }, + }), + ], + }), + url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/', + flags: ['Independent Inquiry', 'Writing'], + instructionMode: 'In Person', + semester: { + code: '12345', + year: 2024, + season: 'Spring', + }, +}); const meta = { title: 'Components/Injected/CourseCatalogInjectedPopup', @@ -40,10 +85,25 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = { +export const OpenCourse: Story = { args: { course: exampleCourse, activeSchedule: exampleSchedule, onClose: () => {}, }, }; + +export const ClosedCourse: Story = { + args: { + course: { + ...exampleCourse, + status: Status.CLOSED, + } satisfies Course, + }, +}; + +export const CourseWithNoData: Story = { + args: { + course: bevoCourse, + }, +}; diff --git a/src/views/components/CourseCatalogMain.tsx b/src/views/components/CourseCatalogMain.tsx index 2a2558d2..54cef399 100644 --- a/src/views/components/CourseCatalogMain.tsx +++ b/src/views/components/CourseCatalogMain.tsx @@ -14,6 +14,20 @@ import type { SiteSupportType } from '@views/lib/getSiteSupport'; import { populateSearchInputs } from '@views/lib/populateSearchInputs'; import React, { useEffect, useState } from 'react'; +import { useKeyPress } from '../hooks/useKeyPress'; +import useSchedules from '../hooks/useSchedules'; +import { CourseCatalogScraper } from '../lib/CourseCatalogScraper'; +import getCourseTableRows from '../lib/getCourseTableRows'; +import type { SiteSupport } from '../lib/getSiteSupport'; +import { populateSearchInputs } from '../lib/populateSearchInputs'; +import ExtensionRoot from './common/ExtensionRoot/ExtensionRoot'; +import AutoLoad from './injected/AutoLoad/AutoLoad'; +import CourseCatalogInjectedPopup from './injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup'; +import RecruitmentBanner from './injected/RecruitmentBanner/RecruitmentBanner'; +import TableHead from './injected/TableHead'; +import TableRow from './injected/TableRow/TableRow'; +import TableSubheading from './injected/TableSubheading/TableSubheading'; + interface Props { support: Extract; } diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx index c61600e5..9876e7f0 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx @@ -1,9 +1,32 @@ +import type { Course } from '@shared/types/Course'; +import Spinner from '@views/components/common/Spinner/Spinner'; import Text from '@views/components/common/Text/Text'; +import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper'; +import { SiteSupport } from '@views/lib/getSiteSupport'; import clsx from 'clsx'; import React from 'react'; interface DescriptionProps { - lines: string[]; + course: Course; +} + +const LoadStatus = { + LOADING: 'LOADING', + DONE: 'DONE', + ERROR: 'ERROR', +} as const; +type LoadStatusType = (typeof LoadStatus)[keyof typeof LoadStatus]; + +async function fetchDescription(course: Course): Promise { + if (!course.description?.length) { + const response = await fetch(course.url); + const text = await response.text(); + const doc = new DOMParser().parseFromString(text, 'text/html'); + + const scraper = new CourseCatalogScraper(SiteSupport.COURSE_CATALOG_DETAILS); + course.description = scraper.getDescription(doc); + } + return course.description; } /** @@ -11,27 +34,53 @@ interface DescriptionProps { * * @component * @param {DescriptionProps} props - The component props. - * @param {string[]} props.lines - The lines of text to render. + * @param {Course} props.course - The course for which to display the description. * @returns {JSX.Element} The rendered description component. */ -const Description: React.FC = ({ lines }: DescriptionProps) => { +const Description: React.FC = ({ course }: DescriptionProps) => { + const [description, setDescription] = React.useState([]); + const [status, setStatus] = React.useState(LoadStatus.LOADING); + + React.useEffect(() => { + fetchDescription(course) + .then(description => { + setStatus(LoadStatus.DONE); + setDescription(description); + }) + .catch(() => { + setStatus(LoadStatus.ERROR); + }); + }, [course]); + const keywords = ['prerequisite', 'restricted']; return ( -
    - {lines.map(line => { - const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword)); - return ( -
    - -
  • - - {line} - -
  • -
    - ); - })} -
+ <> + {status === LoadStatus.ERROR && ( + Please refresh the page and log back in using your UT EID and password + )} + {/* TODO (achadaga): would be nice to have a new spinner here */} + {status === LoadStatus.LOADING && } + {status === LoadStatus.DONE && ( +
    + {description.map(line => { + const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword)); + return ( +
    + +
  • + + {line} + +
  • +
    + ); + })} +
+ )} + ); }; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx index 11646298..68019bc9 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx @@ -22,7 +22,6 @@ const DataStatus = { NOT_FOUND: 'NOT_FOUND', ERROR: 'ERROR', } as const; - type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus]; const GRADE_COLORS = { @@ -61,7 +60,7 @@ const GradeDistribution: React.FC = ({ course }) => { color: GRADE_COLORS[grade as LetterGrade], })); } - return []; + return Array(12).fill(0); }, [distributions, semester, status]); React.useEffect(() => { @@ -137,8 +136,21 @@ const GradeDistribution: React.FC = ({ course }) => { return (
+ {/* TODO (achadaga): again would be nice to have an updated spinner */} {status === DataStatus.LOADING && } - {status === DataStatus.NOT_FOUND && No grade distribution data found} + {status === DataStatus.NOT_FOUND && ( + + )} {status === DataStatus.ERROR && Error fetching grade distribution data} {status === DataStatus.FOUND && ( <> diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx index 09296f66..72910d2c 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx @@ -1,3 +1,6 @@ +import { background } from '@shared/messages' +import { Status } from '@shared/types/Course'; +import type Instructor from '@shared/types/Instructor'; import addCourse from '@pages/background/lib/addCourse'; import removeCourse from '@pages/background/lib/removeCourse'; import type { Course } from '@shared/types/Course'; @@ -18,11 +21,13 @@ import Mood from '~icons/material-symbols/mood'; import Remove from '~icons/material-symbols/remove'; import Reviews from '~icons/material-symbols/reviews'; +const { openNewTab, addCourse, removeCourse, openCESPage } = background; + interface HeadingAndActionProps { /* The course to display */ course: Course; /* The active schedule */ - activeSchedule: UserSchedule; + activeSchedule?: UserSchedule; /* The function to call when the popup should be closed */ onClose: () => void; } @@ -31,59 +36,83 @@ interface HeadingAndActionProps { * Opens the calendar in a new tab. * @returns {Promise} A promise that resolves when the tab is opened. */ -export const handleOpenCalendar = async () => { +export const handleOpenCalendar = async (): Promise => { const url = chrome.runtime.getURL('calendar.html'); - await openTabFromContentScript(url); + openNewTab({ url }); }; +const capitalizeString = (str: string) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + /** * Renders the heading component for the CoursePopup component. * * @param {HeadingAndActionProps} props - The component props. + * @param {Course} props.course - The course object containing course details. + * @param {Schedule} props.activeSchedule - The active schedule object. + * @param {Function} props.onClose - The function to close the popup. * @returns {JSX.Element} The rendered component. */ -const HeadingAndActions: React.FC = ({ course, onClose, activeSchedule }) => { +const HeadingAndActions: React.FC = ({ + course, + activeSchedule, + onClose, +}: HeadingAndActionProps): JSX.Element => { const { courseName, department, number: courseNumber, uniqueId, instructors, flags, schedule } = course; - const courseAdded = activeSchedule.courses.some(ourCourse => ourCourse.uniqueId === uniqueId); + const [courseAdded, setCourseAdded] = useState( + activeSchedule !== undefined ? activeSchedule.courses.some(course => course.uniqueId === uniqueId) : false + ); + + const getInstructorFullName = (instructor: Instructor) => { + const { firstName, lastName } = instructor; + if (firstName === '') return capitalizeString(lastName); + return `${capitalizeString(firstName)} ${capitalizeString(lastName)}`; + }; + + const instructorString = instructors.map(getInstructorFullName).join(', '); - const instructorString = instructors - .map(instructor => { - const { firstName, lastName } = instructor; - if (firstName === '') return lastName; - return `${firstName} ${lastName}`; - }) - .join(', '); const handleCopy = () => { navigator.clipboard.writeText(uniqueId.toString()); }; + const handleOpenRateMyProf = async () => { const openTabs = instructors.map(instructor => { - const { fullName } = instructor; - const url = `https://www.ratemyprofessors.com/search/professors/1255?q=${fullName}`; - return openTabFromContentScript(url); + const instructorSearchTerm = getInstructorFullName(instructor); + instructorSearchTerm.replace(' ', '+'); + const url = `https://www.ratemyprofessors.com/search/professors/1255?q=${instructorSearchTerm}`; + return openNewTab({ url }); }); await Promise.all(openTabs); }; + const handleOpenCES = async () => { - // TODO: does not look up the professor just takes you to the page - const cisUrl = 'https://utexas.bluera.com/utexas/rpvl.aspx?rid=d3db767b-049f-46c5-9a67-29c21c29c580®l=en-US'; - await openTabFromContentScript(cisUrl); + const openTabs = instructors.map(instructor => { + let { firstName, lastName } = instructor; + firstName = capitalizeString(firstName); + lastName = capitalizeString(lastName); + return openCESPage({ instructorFirstName: firstName, instructorLastName: lastName }); + }); + await Promise.all(openTabs); }; + const handleOpenPastSyllabi = async () => { // not specific to professor const url = `https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&department=${department}&course_number=${courseNumber}&course_title=${courseName}&unique=&instructor_first=&instructor_last=&course_type=In+Residence&search=Search`; - await openTabFromContentScript(url); + openNewTab({ url }); }; + const handleAddOrRemoveCourse = async () => { + if (!activeSchedule) return; if (!courseAdded) { - await addCourse(activeSchedule.name, course); + addCourse({ course, scheduleName: activeSchedule.name }); } else { - await removeCourse(activeSchedule.name, course); + removeCourse({ course, scheduleName: activeSchedule.name }); } + setCourseAdded(prev => !prev); }; + return (
-
+
{courseName} @@ -99,36 +128,35 @@ const HeadingAndActions: React.FC = ({ course, onClose, a
-
- - with {instructorString} - +
+ {instructorString.length > 0 && ( + + with {instructorString} + + )}
{flags.map(flag => ( - + ))}
- {schedule.meetings.map(meeting => ( - - {meeting.getDaysString({ format: 'long', separator: 'long' })}{' '} - {meeting.getTimeString({ separator: ' to ', capitalize: false })} - {meeting.location && ( - <> - {` in `} - - {meeting.location.building} - - - )} - - ))} + {schedule.meetings.map(meeting => { + const daysString = meeting.getDaysString({ format: 'long', separator: 'long' }); + const timeString = meeting.getTimeString({ separator: ' to ', capitalize: false }); + const locationString = meeting.location ? ` in ${meeting.location.building}` : ''; + return ( + + {daysString} {timeString} + {locationString} + + ); + })}
@@ -140,6 +168,7 @@ const HeadingAndActions: React.FC = ({ course, onClose, a