Skip to content

Commit

Permalink
feat: course-catalog-injected-popup (#98)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
abhinavchadaga and doprz committed Mar 6, 2024
1 parent 0c5bec8 commit 89d03f4
Show file tree
Hide file tree
Showing 11 changed files with 284 additions and 67 deletions.
3 changes: 2 additions & 1 deletion src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/pages/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -32,6 +33,7 @@ const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
...browserActionHandler,
...tabManagementHandler,
...userScheduleHandler,
...CESHandler,
});

messageListener.listen();
39 changes: 39 additions & 0 deletions src/pages/background/handler/CESHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/shared/messages/CESMessage.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion src/shared/messages/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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';

/**
* 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
Expand Down
2 changes: 1 addition & 1 deletion src/shared/util/themeColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 61 additions & 1 deletion src/stories/injected/CourseCatalogInjectedPopup.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
};
14 changes: 14 additions & 0 deletions src/views/components/CourseCatalogMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,86 @@
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;
}

/**
* Renders the description component.
*
* @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 (
<ul className='my-[5px] space-y-1.5 children:marker:text-ut-burntorange'>
{lines.map(line => {
const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword));
return (
<div className='flex gap-2'>
<span className='text-ut-burntorange'></span>
<li key={line}>
<Text variant='p' className={clsx({ 'font-bold text-ut-burntorange': isKeywordPresent })}>
{line}
</Text>
</li>
</div>
);
})}
</ul>
<>
{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'>
{description.map(line => {
const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword));
return (
<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 })}
>
{line}
</Text>
</li>
</div>
);
})}
</ul>
)}
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const DataStatus = {
NOT_FOUND: 'NOT_FOUND',
ERROR: 'ERROR',
} as const;

type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus];

const GRADE_COLORS = {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 && (
<>
Expand Down
Loading

0 comments on commit 89d03f4

Please sign in to comment.