feat: course-catalog-injected-popup (#98)

* some work

* some work on course popup

update the stories and create the header component

* use chip component in header

* complete CourseHeaderAndActions Component

added course buttons, using proper subcomponents now.

* Change test course to 314

* Add rmp callback

* some unocss updates

* add course button onclick handlers

* add todo for calendar button

* Rename CoursePopup

Old one to "Old", remove "2" from new one

* description stuff done

* Modify story to use proper course info

* Add Grade Distribution Stuff

* Minor tweaks

change style in header

* Add TODO

replace current grade colors with a tailwind palette

* Fix syllabi url

Remove unused variable and unnecessary args to url

* Bunch of renaming

* Kinda complete the handlers

* change grade distribution colors to match updated figma

* change from reducer pattern to state variables, remove chartData from state

* add additional story

* disabled add when course is not open

* use array fill

* Some changes with the instructor names

* trying to get the CES stuff to work

* CES button is working

* remove a todo

* add actual color for dminus

* fix description, start no distribution state

* post merge fixes

* small fixes

* fix: import as type

* fix: some better typescript stuff i think

* fix: manifest.ts

* fix: pr feedback

* Apply suggestions from code review

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
Abhinav Chadaga
2024-03-02 14:04:01 -06:00
committed by doprz
parent 0c5bec8002
commit 89d03f4244
11 changed files with 284 additions and 67 deletions

View File

@@ -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, '<all_urls>'] : HOST_PERMISSIONS,
action: {
default_popup: 'src/pages/popup/index.html',

View File

@@ -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<BACKGROUND_MESSAGES>({
...browserActionHandler,
...tabManagementHandler,
...userScheduleHandler,
...CESHandler,
});
messageListener.listen();

View File

@@ -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&regl=en-US';
const CESHandler: MessageHandler<CESMessage> = {
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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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',

View File

@@ -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 mascots 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<typeof meta>;
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,
},
};

View File

@@ -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<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
}

View File

@@ -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<string[]> {
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,20 +34,44 @@ 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<DescriptionProps> = ({ lines }: DescriptionProps) => {
const Description: React.FC<DescriptionProps> = ({ course }: DescriptionProps) => {
const [description, setDescription] = React.useState<string[]>([]);
const [status, setStatus] = React.useState<LoadStatusType>(LoadStatus.LOADING);
React.useEffect(() => {
fetchDescription(course)
.then(description => {
setStatus(LoadStatus.DONE);
setDescription(description);
})
.catch(() => {
setStatus(LoadStatus.ERROR);
});
}, [course]);
const keywords = ['prerequisite', 'restricted'];
return (
<>
{status === LoadStatus.ERROR && (
<Text color='theme-red'>Please refresh the page and log back in using your UT EID and password</Text>
)}
{/* TODO (achadaga): would be nice to have a new spinner here */}
{status === LoadStatus.LOADING && <Spinner />}
{status === LoadStatus.DONE && (
<ul className='my-[5px] space-y-1.5 children:marker:text-ut-burntorange'>
{lines.map(line => {
{description.map(line => {
const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword));
return (
<div className='flex gap-2'>
<div key={line} className='flex gap-2'>
<span className='text-ut-burntorange'></span>
<li key={line}>
<Text variant='p' className={clsx({ 'font-bold text-ut-burntorange': isKeywordPresent })}>
<Text
variant='p'
className={clsx({ 'font-bold text-ut-burntorange': isKeywordPresent })}
>
{line}
</Text>
</li>
@@ -32,6 +79,8 @@ const Description: React.FC<DescriptionProps> = ({ lines }: DescriptionProps) =>
);
})}
</ul>
)}
</>
);
};

View File

@@ -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<GradeDistributionProps> = ({ 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<GradeDistributionProps> = ({ course }) => {
return (
<div className='pb-[25px] pt-[12px]'>
{/* TODO (achadaga): again would be nice to have an updated spinner */}
{status === DataStatus.LOADING && <Spinner />}
{status === DataStatus.NOT_FOUND && <Text variant='p'>No grade distribution data found</Text>}
{status === DataStatus.NOT_FOUND && (
<HighchartsReact
ref={ref}
highcharts={Highcharts}
options={{
...chartOptions,
title: {
text: `There is currently no grade distribution data for ${course.department} ${course.number}`,
},
tooltip: { enabled: false },
}}
/>
)}
{status === DataStatus.ERROR && <Text variant='p'>Error fetching grade distribution data</Text>}
{status === DataStatus.FOUND && (
<>

View File

@@ -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<void>} A promise that resolves when the tab is opened.
*/
export const handleOpenCalendar = async () => {
export const handleOpenCalendar = async (): Promise<void> => {
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<HeadingAndActionProps> = ({ course, onClose, activeSchedule }) => {
const HeadingAndActions: React.FC<HeadingAndActionProps> = ({
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<boolean>(
activeSchedule !== undefined ? activeSchedule.courses.some(course => course.uniqueId === uniqueId) : false
);
const instructorString = instructors
.map(instructor => {
const getInstructorFullName = (instructor: Instructor) => {
const { firstName, lastName } = instructor;
if (firstName === '') return lastName;
return `${firstName} ${lastName}`;
})
.join(', ');
if (firstName === '') return capitalizeString(lastName);
return `${capitalizeString(firstName)} ${capitalizeString(lastName)}`;
};
const instructorString = instructors.map(getInstructorFullName).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&regl=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 (
<div className='w-full pb-3 pt-6'>
<div className='flex flex-col gap-1'>
<div className='flex flex-col'>
<div className='flex items-center gap-1'>
<Text variant='h1' className='truncate'>
{courseName}
@@ -99,36 +128,35 @@ const HeadingAndActions: React.FC<HeadingAndActionProps> = ({ course, onClose, a
<CloseIcon className='h-7 w-7' />
</button>
</div>
<div className='flex gap-2.5 flex-content-center'>
<div className='flex gap-2 flex-content-center'>
{instructorString.length > 0 && (
<Text variant='h4' className='inline-flex items-center justify-center'>
with {instructorString}
</Text>
)}
<div className='flex-content-centr flex gap-1'>
{flags.map(flag => (
<Chip label={flagMap[flag]} />
<Chip key={flagMap[flag]} label={flagMap[flag]} />
))}
</div>
</div>
<div className='flex flex-col'>
{schedule.meetings.map(meeting => (
<Text variant='h4'>
{meeting.getDaysString({ format: 'long', separator: 'long' })}{' '}
{meeting.getTimeString({ separator: ' to ', capitalize: false })}
{meeting.location && (
<>
{` in `}
<Text variant='h4' className='text-ut-burntorange underline'>
{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 (
<Text key={daysString + timeString + locationString} variant='h4'>
{daysString} {timeString}
{locationString}
</Text>
</>
)}
</Text>
))}
);
})}
</div>
</div>
<div className='my-3 flex flex-wrap items-center gap-[15px]'>
<Button variant='filled' color='ut-burntorange' icon={CalendarMonth} onClick={handleOpenCalendar} />
<Divider orientation='vertical' size='28px' />
<Divider size='1.75rem' orientation='vertical' />
<Button variant='outline' color='ut-blue' icon={Reviews} onClick={handleOpenRateMyProf}>
RateMyProf
</Button>
@@ -140,6 +168,7 @@ const HeadingAndActions: React.FC<HeadingAndActionProps> = ({ course, onClose, a
</Button>
<Button
variant='filled'
disabled={course.status !== Status.OPEN}
color={!courseAdded ? 'ut-green' : 'ut-red'}
icon={!courseAdded ? Add : Remove}
onClick={handleAddOrRemoveCourse}