feat: abhinavchadaga/course-catalog-popup (#128)

* 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

* fix: remove old CoursePopup component

* fix: course catalog injected popup story should useScott's 314 class since it actually has data

* fix: build error in background.ts:

* chore: run eslint autofix on CourseCatalogInjectedPopup.stories.ts

* chore: run prettier on CourseCatalogInjectedPopup.stories.ts
This commit is contained in:
Abhinav Chadaga
2024-03-04 10:05:10 -06:00
committed by doprz
parent 471e55dcea
commit 745f9dd6fb
15 changed files with 162 additions and 948 deletions

View File

@@ -1,111 +1,59 @@
/* eslint-disable storybook/story-exports */ import type { Course } from '@shared/types/Course';
// import { UserSchedule } from '@shared/types/UserSchedule'; import { Status } from '@shared/types/Course';
// import type { Meta, StoryObj } from '@storybook/react'; import { UserSchedule } from '@shared/types/UserSchedule';
// import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup'; import type { Meta, StoryObj } from '@storybook/react';
import CourseCatalogInjectedPopup from '@views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup';
// import { exampleCourse } from './mocked'; import { bevoCourse, bevoScheule, MikeScottCS314Course, MikeScottCS314Schedule } from './mocked';
// const exampleSchedule: UserSchedule = new UserSchedule({ const meta = {
// courses: [exampleCourse], title: 'Components/Injected/CourseCatalogInjectedPopup',
// name: 'Example Schedule', component: CourseCatalogInjectedPopup,
// hours: 0, args: {
// }); onClose: () => {},
// TODO (achadaga): import this after },
// https://github.com/Longhorn-Developers/UT-Registration-Plus/pull/106 is merged argTypes: {
// const bevoCourse: Course = new Course({ course: {
// uniqueId: 47280, control: {
// number: '311C', type: 'object',
// fullName: "BVO 311C BEVO'S SEMINAR LONGHORN CARE", },
// courseName: "BEVO'S SEMINAR LONGHORN CARE", },
// department: 'BVO', activeSchedule: {
// creditHours: 3, control: {
// status: Status.OPEN, type: 'object',
// instructors: [new Instructor({ fullName: 'BEVO', firstName: '', lastName: 'BEVO', middleInitial: '' })], },
// isReserved: false, },
// description: [ onClose: {
// 'Restricted to Students in the School of Longhorn Enthusiasts', control: {
// '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.', type: 'function',
// '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.', } satisfies Meta<typeof CourseCatalogInjectedPopup>;
// ],
// 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 = { export default meta;
// title: 'Components/Injected/CourseCatalogInjectedPopup', type Story = StoryObj<typeof meta>;
// component: CourseCatalogInjectedPopup,
// args: {
// course: exampleCourse,
// activeSchedule: exampleSchedule,
// onClose: () => {},
// },
// argTypes: {
// course: {
// control: {
// type: 'object',
// },
// },
// activeSchedule: {
// control: {
// type: 'object',
// },
// },
// onClose: {
// control: {
// type: 'function',
// },
// },
// },
// } satisfies Meta<typeof CourseCatalogInjectedPopup>;
// export default meta; export const OpenCourse: Story = {
// type Story = StoryObj<typeof meta>; args: {
course: MikeScottCS314Course,
activeSchedule: MikeScottCS314Schedule,
},
};
// export const OpenCourse: Story = { export const ClosedCourse: Story = {
// args: { args: {
// course: exampleCourse, course: {
// activeSchedule: exampleSchedule, ...MikeScottCS314Course,
// onClose: () => {}, status: Status.CLOSED,
// }, } as Course,
// }; activeSchedule: new UserSchedule({ courses: [], name: '', hours: 0 }),
},
};
// export const ClosedCourse: Story = { export const CourseWithNoData: Story = {
// args: { args: {
// course: { course: bevoCourse,
// ...exampleCourse, activeSchedule: bevoScheule,
// status: Status.CLOSED, },
// } satisfies Course, };
// },
// };
// export const CourseWithNoData: Story = {
// args: {
// course: bevoCourse,
// },
// };
export default {};

View File

@@ -1,60 +0,0 @@
import { Course, Status } from '@shared/types/Course';
import { UserSchedule } from '@shared/types/UserSchedule';
import type { Meta, StoryObj } from '@storybook/react';
import CoursePopup from '@views/components/injected/CoursePopupOld/CoursePopup';
import { exampleCourse, exampleSchedule } from './mocked';
const meta = {
title: 'Components/Injected/CoursePopup',
component: CoursePopup,
// tags: ['autodocs'],
args: {
course: exampleCourse,
activeSchedule: exampleSchedule,
},
argTypes: {
course: {
control: {
type: 'other',
},
},
activeSchedule: {
control: {
type: 'other',
},
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/8tsCay2FRqctrdcZ3r9Ahw/UTRP?type=design&node-id=602-1879&mode=design&t=BoS5xBrpSsjgQXqv-11',
},
},
} satisfies Meta<typeof CoursePopup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
args: {
course: new Course({ ...exampleCourse, status: Status.OPEN }),
activeSchedule: new UserSchedule({
courses: [],
name: 'Example Schedule',
hours: 0,
}),
},
};
export const Closed: Story = {
args: {
course: new Course({ ...exampleCourse, status: Status.CLOSED }),
},
};
export const Cancelled: Story = {
args: {
course: new Course({ ...exampleCourse, status: Status.CANCELLED }),
},
};

View File

@@ -1,5 +1,5 @@
import { Course, Status } from '@shared/types/Course'; import { Course, Status } from '@shared/types/Course';
import { CourseMeeting } from '@shared/types/CourseMeeting'; import { CourseMeeting, DAY_MAP } from '@shared/types/CourseMeeting';
import Instructor from '@shared/types/Instructor'; import Instructor from '@shared/types/Instructor';
import { UserSchedule } from '@shared/types/UserSchedule'; import { UserSchedule } from '@shared/types/UserSchedule';
@@ -47,7 +47,7 @@ export const exampleCourse: Course = new Course({
season: 'Spring', season: 'Spring',
year: 2024, year: 2024,
}, },
status: Status.CANCELLED, status: Status.OPEN,
uniqueId: 12345, uniqueId: 12345,
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/', url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/',
}); });
@@ -55,5 +55,106 @@ export const exampleCourse: Course = new Course({
export const exampleSchedule: UserSchedule = new UserSchedule({ export const exampleSchedule: UserSchedule = new UserSchedule({
courses: [exampleCourse], courses: [exampleCourse],
name: 'Example Schedule', name: 'Example Schedule',
hours: 0, hours: 3,
});
export 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: {
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',
},
});
export const bevoScheule: UserSchedule = new UserSchedule({
courses: [bevoCourse],
name: 'Bevo Schedule',
hours: 3,
});
export const MikeScottCS314Course: Course = new Course({
uniqueId: 50805,
number: '314',
fullName: 'C S 314 DATA STRUCTURES',
courseName: 'DATA STRUCTURES',
department: 'C S',
creditHours: 3,
status: Status.OPEN,
instructors: [
new Instructor({ fullName: 'SCOTT, MICHAEL', firstName: 'MICHAEL', lastName: 'SCOTT', middleInitial: 'D' }),
],
isReserved: true,
description: [
'Second part of a two-part sequence in programming. Introduction to specifications, simple unit testing, and debugging; building and using canonical data structures; algorithm analysis and reasoning techniques such as assertions and invariants.',
'Computer Science 314 and 314H may not both be counted.',
'BVO 311C and 312H may not both be counted.',
'Prerequisite: Computer Science 312 or 312H with a grade of at least C-.',
'May be counted toward the Quantitative Reasoning flag requirement.',
],
schedule: {
meetings: [
new CourseMeeting({
days: [DAY_MAP.T, DAY_MAP.TH],
startTime: 480,
endTime: 570,
location: { building: 'UTC', room: '123' },
}),
new CourseMeeting({
days: [DAY_MAP.TH],
startTime: 570,
endTime: 630,
location: { building: 'JES', room: '123' },
}),
],
},
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/50825/',
flags: ['Writing', 'Independent Inquiry'],
instructionMode: 'In Person',
semester: {
code: '12345',
year: 2024,
season: 'Spring',
},
});
export const MikeScottCS314Schedule: UserSchedule = new UserSchedule({
courses: [MikeScottCS314Course],
name: 'Mike Scott CS314 Schedule',
hours: 3,
}); });

View File

@@ -9,7 +9,7 @@ import HeadingAndActions from './HeadingAndActions';
interface CourseCatalogInjectedPopupProps { interface CourseCatalogInjectedPopupProps {
course: Course; course: Course;
activeSchedule?: UserSchedule; activeSchedule: UserSchedule;
onClose: () => void; onClose: () => void;
} }
@@ -27,16 +27,12 @@ export default function CourseCatalogInjectedPopup({
course, course,
activeSchedule, activeSchedule,
onClose, onClose,
}: CourseCatalogInjectedPopupProps) { }: CourseCatalogInjectedPopupProps): JSX.Element {
return ( return (
<Popup overlay className='max-w-[780px] px-6' onClose={onClose}> <Popup overlay className='max-w-[780px] px-6' onClose={onClose}>
<div className='flex flex-col'> <div className='flex flex-col'>
<HeadingAndActions course={course} onClose={onClose} activeSchedule={activeSchedule} /> <HeadingAndActions course={course} onClose={onClose} activeSchedule={activeSchedule} />
<Description <Description course={course} />
course={
course
} /* lines={course.description} Looks like this was replaced. Description now set internally */
/>
<GradeDistribution course={course} /> <GradeDistribution course={course} />
</div> </div>
</Popup> </Popup>

View File

@@ -7,8 +7,7 @@ import { Button } from '@views/components/common/Button/Button';
import { Chip, flagMap } from '@views/components/common/Chip/Chip'; import { Chip, flagMap } from '@views/components/common/Chip/Chip';
import Divider from '@views/components/common/Divider/Divider'; import Divider from '@views/components/common/Divider/Divider';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript'; import React from 'react';
import React, { useState } from 'react';
import Add from '~icons/material-symbols/add'; import Add from '~icons/material-symbols/add';
import CalendarMonth from '~icons/material-symbols/calendar-month'; import CalendarMonth from '~icons/material-symbols/calendar-month';
@@ -25,7 +24,7 @@ interface HeadingAndActionProps {
/* The course to display */ /* The course to display */
course: Course; course: Course;
/* The active schedule */ /* The active schedule */
activeSchedule?: UserSchedule; activeSchedule: UserSchedule;
/* The function to call when the popup should be closed */ /* The function to call when the popup should be closed */
onClose: () => void; onClose: () => void;
} }

View File

@@ -1,29 +0,0 @@
@use 'src/views/styles/colors.module.scss';
.container {
margin: 20px;
padding: 12px;
.description {
list-style-type: disc;
margin: 0px;
padding-left: 20px;
max-height: 200px;
overflow-y: auto;
li {
padding: 0px 4px 4px;
.prerequisite {
font-weight: bold;
}
.onlyOne {
font-style: italic;
}
.restriction {
color: colors.$speedway_brick;
}
}
}
}

View File

@@ -1,94 +0,0 @@
import type { Course } from '@shared/types/Course';
import Card from '@views/components/common/Card/Card';
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, { useEffect, useState } from 'react';
import styles from './CourseDescription.module.scss';
type Props = {
course: Course;
};
const LoadStatus = {
LOADING: 'LOADING',
DONE: 'DONE',
ERROR: 'ERROR',
} as const;
type LoadStatusType = (typeof LoadStatus)[keyof typeof LoadStatus];
/**
* Renders the course description component.
*
* @param {Props} props - The component props.
* @param {Course} props.course - The course object.
* @returns {JSX.Element} The rendered course description component.
*/
export default function CourseDescription({ course }: Props) {
const [description, setDescription] = useState<string[]>([]);
const [status, setStatus] = useState<LoadStatusType>(LoadStatus.LOADING);
useEffect(() => {
fetchDescription(course)
.then(description => {
setStatus(LoadStatus.DONE);
setDescription(description);
})
.catch(() => {
setStatus(LoadStatus.ERROR);
});
}, [course]);
return (
<Card className={styles.container}>
{status === LoadStatus.ERROR && (
<Text color='speedway_brick' /* size='medium' weight='bold' align='center' */>
Please refresh the page and log back in using your UT EID and password
</Text>
)}
{status === LoadStatus.LOADING && <Spinner className={styles.spinner} />}
{status === LoadStatus.DONE && (
<ul className={styles.description}>
{description.map(paragraph => (
<li key={paragraph}>
<DescriptionLine line={paragraph} />
</li>
))}
</ul>
)}
</Card>
);
}
interface LineProps {
line: string;
}
function DescriptionLine({ line }: LineProps) {
const lowerCaseLine = line.toLowerCase();
const className = clsx({
[styles.prerequisite]: lowerCaseLine.includes('prerequisite'),
[styles.onlyOne]:
lowerCaseLine.includes('may be') || lowerCaseLine.includes('only one') || lowerCaseLine.includes('may not'),
[styles.restriction]: lowerCaseLine.includes('restrict'),
});
return <Text className={className} /* size='medium' */>{line}</Text>;
}
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;
}

View File

@@ -1,18 +0,0 @@
@import 'src/views/styles/base.module.scss';
.container {
margin: 12px 4px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
box-shadow: none;
.button {
flex: 1;
}
.icon {
margin: 4px;
}
}

View File

@@ -1,128 +0,0 @@
import { background } from '@shared/messages';
import type { Course } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { Button } from '@views/components/common/Button/Button';
import Card from '@views/components/common/Card/Card';
import Icon from '@views/components/common/Icon/Icon';
import Text from '@views/components/common/Text/Text';
import React from 'react';
import styles from './CourseButtons.module.scss';
type Props = {
activeSchedule?: UserSchedule;
course: Course;
};
const { openNewTab, addCourse, removeCourse } = background;
/**
* This component displays the buttons for the course info popup, that allow the user to either
* navigate to other pages that are useful for the course, or to do actions on the current course.
*/
export default function CourseButtons({ course, activeSchedule }: Props) {
const openRateMyProfessorURL = () => {
const primaryInstructor = course.instructors?.[0];
if (!primaryInstructor) return;
const name = primaryInstructor.toString({
format: 'first_last',
case: 'capitalize',
});
const url = new URL('https://www.ratemyprofessors.com/search.jsp');
url.searchParams.append('queryBy', 'teacherName');
url.searchParams.append('schoolName', 'university of texas at austin');
url.searchParams.append('queryoption', 'HEADER');
url.searchParams.append('query', name);
url.searchParams.append('facetSearch', 'true');
openNewTab({ url: url.toString() });
};
const openSyllabiURL = () => {
const { department, number } = course;
const { firstName, lastName } = course.instructors?.[0] ?? {};
const url = new URL('https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/');
url.searchParams.append('department', department);
url.searchParams.append('course_number', number);
url.searchParams.append('instructor_first', firstName ?? '');
url.searchParams.append('instructor_last', lastName ?? '');
url.searchParams.append('course_type', 'In Residence');
url.searchParams.append('search', 'Search');
openNewTab({ url: url.toString() });
};
const openTextbookURL = () => {
const { department, number, semester, uniqueId } = course;
const url = new URL('https://www.universitycoop.com/adoption-search-results');
url.searchParams.append('sn', `${semester.code}__${department}__${number}__${uniqueId}`);
openNewTab({ url: url.toString() });
};
const handleSaveCourse = async () => {
if (!activeSchedule) return;
addCourse({ course, scheduleName: activeSchedule.name });
};
const handleRemoveCourse = async () => {
if (!activeSchedule) return;
removeCourse({ course, scheduleName: activeSchedule.name });
};
const isCourseSaved = (() => {
if (!activeSchedule) return false;
return Boolean(activeSchedule.containsCourse(course));
})();
return (
<Card className={styles.container}>
<Button
onClick={openRateMyProfessorURL}
disabled={!course.instructors.length}
variant='filled'
className={styles.button}
color='ut-black'
title='Search for this professor on RateMyProfessor'
>
<Text /* size='medium' weight='regular' */ color='white'>RateMyProf</Text>
<Icon className={styles.icon} color='white' name='school' size='medium' />
</Button>
<Button
onClick={openSyllabiURL}
variant='filled'
className={styles.button}
color='ut-black'
title='Search for syllabi for this course'
>
<Text /* size='medium' weight='regular' */ color='white'>Syllabi</Text>
<Icon className={styles.icon} color='white' name='grading' size='medium' />
</Button>
<Button
onClick={openTextbookURL}
variant='filled'
className={styles.button}
color='ut-black'
title='Search for textbooks for this course'
>
<Text /* size='medium' weight='regular' color='white' */>Textbook</Text>
<Icon className={styles.icon} color='white' name='collections_bookmark' size='medium' />
</Button>
<Button
disabled={!activeSchedule}
onClick={isCourseSaved ? handleRemoveCourse : handleSaveCourse}
title={isCourseSaved ? 'Remove this course from your schedule' : 'Add this course to your schedule'}
variant='filled'
className={styles.button}
color='ut-black'
>
<Text /* size='medium' weight='regular' color='white' */>{isCourseSaved ? 'Remove' : 'Add'}</Text>
<Icon className={styles.icon} color='white' name={isCourseSaved ? 'remove' : 'add'} size='medium' />
</Button>
</Card>
);
}

View File

@@ -1,45 +0,0 @@
@import 'src/views/styles/base.module.scss';
.header {
height: auto;
color: white;
padding: 12px;
margin: 20px;
align-items: center;
position: relative;
justify-content: center;
.close {
position: absolute;
top: 12px;
right: 12px;
cursor: pointer;
}
.title {
display: flex;
align-items: center;
margin-right: 40px;
.courseName {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
// underline
}
.uniqueId {
flex: 1;
margin-left: 8px;
}
}
.instructors {
margin-top: 8px;
}
.meeting {
margin-top: 8px;
}
}

View File

@@ -1,135 +0,0 @@
import type { Course } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { Button } from '@views/components/common/Button/Button';
import Card from '@views/components/common/Card/Card';
import Icon from '@views/components/common/Icon/Icon';
import Link from '@views/components/common/Link/Link';
import Text from '@views/components/common/Text/Text';
import React from 'react';
import CloseIcon from '~icons/material-symbols/close';
import CopyIcon from '~icons/material-symbols/content-copy';
import CourseButtons from './CourseButtons/CourseButtons';
import styles from './CourseHeader.module.scss';
type Props = {
course: Course;
activeSchedule?: UserSchedule;
onClose: () => void;
};
/**
* This component displays the header of the course info popup.
* It displays the course name, unique id, instructors, and schedule, all formatted nicely.
*/
export default function CourseHeader({ course, activeSchedule, onClose }: Props) {
// const getBuildingUrl = (building?: string): string | undefined => {
// if (!building) return undefined;
// return `https://utdirect.utexas.edu/apps/campus/buildings/nlogon/maps/UTM/${building}/`;
// };
return (
<div className='mx-6 my-5'>
<div className='flex items-center justify-start'>
<Text variant='h1' className='shrink truncate'>
{course.courseName}
</Text>
<Text variant='h1' className='ml-1 shrink-0'>
{`(${course.department} ${course.number})`}
</Text>
<div className='ml-auto min-w-fit flex shrink-0 gap-0'>
<Button icon={CopyIcon} variant='single' className='mr-1 px-2' color='ut-burntorange'>
{course.uniqueId}
</Button>
<button className='bg-transparent p-0 btn'>
<CloseIcon className='h-7 w-7' />
</button>
</div>
</div>
<div>
<Text variant='p'>
with{' '}
{course.instructors.map(instructor => (
<span className=''>{instructor.lastName}</span>
))}
</Text>
</div>
</div>
// <Card className={styles.header}>
// <Icon className={styles.close} /* size='large' */ name='close' onClick={onClose} />
// <div className={styles.title}>
// <Text className={styles.courseName} /* size='large' weight='bold' color='black' */>
// {course.courseName} ({course.department} {course.number}) blahhhhh
// </Text>
// <Link
// url={course.url}
// className={styles.uniqueId}
// /* size='medium'
// weight='semi_bold' */
// color='burnt_orange'
// title='View course details on UT Course Schedule'
// >
// #{course.uniqueId}
// </Link>
// </div>
// <Text /* size='medium' className={styles.instructors} */>
// {`with ${!course.instructors.length ? 'TBA' : ''}`}
// {course.instructors.map((instructor, index) => {
// const name = instructor.toString({
// format: 'first_last',
// case: 'capitalize',
// });
// const url = instructor.getDirectoryUrl();
// const numInstructors = course.instructors.length;
// const isLast = course.instructors.length > 1 && index === course.instructors.length - 1;
// return (
// <span key={name}>
// {numInstructors > 1 && index === course.instructors.length - 1 ? '& ' : ''}
// <Link
// key={name}
// /* size='medium'
// weight='normal' */
// url={url}
// title="View instructor's directory page"
// >
// {name}
// </Link>
// {numInstructors > 2 && !isLast ? ', ' : ''}
// </span>
// );
// })}
// </Text>
// {course.schedule.meetings.map(meeting => (
// <Text /* size='medium' */ className={styles.meeting} key={meeting.startTime}>
// <Text as='span' /* size='medium' weight='bold' */ color='black'>
// {meeting.getDaysString({
// format: 'long',
// separator: 'short',
// })}
// </Text>
// {' at '}
// <Text as='span' /* size='medium' */>
// {meeting.getTimeString({
// separator: 'to',
// capitalize: true,
// })}
// </Text>
// {' in '}
// <Link
// /* size='medium'
// weight='normal' */
// title='View building on UT Map'
// url={getBuildingUrl(meeting.location?.building)}
// disabled={!meeting.location?.building}
// >
// {meeting.location?.building ?? 'TBA'}
// </Link>
// </Text>
// ))}
// <CourseButtons course={course} activeSchedule={activeSchedule} />
// </Card>
);
}

View File

@@ -1,21 +0,0 @@
.popup {
border-radius: 12px;
position: relative;
width: 55%;
overflow-y: auto;
max-height: 90%;
// fade in animation
animation: fadeIn 0.2s ease-out;
}
// fade in animation
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -1,28 +0,0 @@
import type { Course } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
import Popup from '@views/components/common/Popup/Popup';
import React from 'react';
import CourseDescription from './CourseDescription/CourseDescription';
import CourseHeader from './CourseHeader/CourseHeader';
import styles from './CoursePopup.module.scss';
import GradeDistribution from './GradeDistribution/GradeDistribution';
interface Props {
course: Course;
activeSchedule?: UserSchedule;
onClose: () => void;
}
/**
* The popup that appears when the user clicks on a course for more details.
*/
export default function CoursePopup({ course, activeSchedule, onClose }: Props) {
return (
<Popup className={styles.popup} overlay onClose={onClose}>
<CourseHeader course={course} activeSchedule={activeSchedule} onClose={onClose} />
<CourseDescription course={course} />
<GradeDistribution course={course} />
</Popup>
);
}

View File

@@ -1,48 +0,0 @@
@use 'src/views/styles/colors.module.scss';
@use 'src/views/styles/elevation.module.scss';
.chartContainer {
height: 250px;
margin: 20px;
padding: 12px;
position: relative;
.selectContainer {
display: flex;
position: absolute;
width: 100%;
margin-top: -8px;
justify-content: center;
select {
z-index: elevation.$MAX_Z_INDEX;
padding: 4px;
font-family: 'Inter';
border-radius: 8px;
border-color: colors.$charcoal;
}
}
:global(.highcharts-background) {
fill: transparent;
}
}
.textContainer {
margin: 20px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
.text {
padding: 12px;
box-shadow: none;
text-align: center;
// add some vertical padding to each element
> * {
margin: 0.2em 0;
}
}
}

View File

@@ -1,224 +0,0 @@
import type { Course, Semester } from '@shared/types/Course';
import type { Distribution, LetterGrade } from '@shared/types/Distribution';
import Card from '@views/components/common/Card/Card';
import Icon from '@views/components/common/Icon/Icon';
import Spinner from '@views/components/common/Spinner/Spinner';
import Text from '@views/components/common/Text/Text';
import {
NoDataError,
queryAggregateDistribution,
querySemesterDistribution,
} from '@views/lib/database/queryDistribution';
import colors from '@views/styles/colors.module.scss';
import Highcharts from 'highcharts';
import HighchartsReact from 'highcharts-react-official';
import React, { useEffect, useRef, useState } from 'react';
import styles from './GradeDistribution.module.scss';
const DataStatus = {
LOADING: 'LOADING',
FOUND: 'FOUND',
NOT_FOUND: 'NOT_FOUND',
ERROR: 'ERROR',
} as const;
type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus];
interface Props {
course: Course;
}
const GRADE_COLORS = {
A: colors.turtle_pond,
'A-': colors.turtle_pond,
'B+': colors.cactus,
B: colors.cactus,
'B-': colors.cactus,
'C+': colors.sunshine,
C: colors.sunshine,
'C-': colors.sunshine,
'D+': colors.tangerine,
D: colors.tangerine,
'D-': colors.tangerine,
F: colors.speedway_brick,
} as const satisfies Record<LetterGrade, string>;
/**
* A chart to fetch and display the grade distribution for a course
* @returns
*/
export default function GradeDistribution({ course }: Props) {
const ref = useRef<HighchartsReact.RefObject>(null);
const [semesters, setSemesters] = useState<Semester[]>([]);
const [selectedSemester, setSelectedSemester] = useState<Semester | null>(null);
const [distribution, setDistribution] = useState<Distribution | null>(null);
const [status, setStatus] = useState<DataStatusType>(DataStatus.LOADING);
const [chartOptions, setChartOptions] = useState<Highcharts.Options>({
title: {
text: undefined,
},
subtitle: {
text: undefined,
},
legend: {
enabled: false,
},
xAxis: {
title: {
text: 'Grades',
},
categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'],
crosshair: true,
},
yAxis: {
min: 0,
title: {
text: 'Students',
},
},
chart: {
style: {
fontFamily: 'Inter',
fontWeight: '600',
},
spacingBottom: 25,
spacingTop: 25,
height: 250,
},
credits: {
enabled: false,
},
accessibility: {
enabled: false,
},
tooltip: {
headerFormat: '<span style="font-size:small; font-weight:bold">{point.key}</span><table>',
pointFormat:
'<td style="color:{black};padding:0;font-size:small; font-weight:bold;"><b>{point.y:.0f} Students</b></td>',
footerFormat: '</table>',
shared: true,
useHTML: true,
},
plotOptions: {
bar: {
pointPadding: 0.2,
borderWidth: 0,
},
series: {
animation: {
duration: 700,
},
},
},
series: [
{
type: 'column',
name: 'Grades',
data: Array.from({ length: 12 }, () => 0),
},
],
});
const updateChart = (distribution: Distribution) => {
setChartOptions(options => ({
...options,
series: [
{
type: 'column',
name: 'Grades',
data: Object.entries(distribution).map(([grade, count]) => ({
y: count,
color: GRADE_COLORS[grade as LetterGrade],
})),
},
],
}));
window.dispatchEvent(new Event('resize'));
};
useEffect(() => {
queryAggregateDistribution(course)
.then(([distribution, semesters]) => {
setSemesters(semesters);
updateChart(distribution);
setStatus(DataStatus.FOUND);
})
.catch(err => {
if (err instanceof NoDataError) {
return setStatus(DataStatus.NOT_FOUND);
}
return setStatus(DataStatus.ERROR);
});
}, [course]);
useEffect(() => {
(async () => {
let distribution: Distribution;
if (selectedSemester) {
distribution = await querySemesterDistribution(course, selectedSemester);
} else {
[distribution] = await queryAggregateDistribution(course);
}
updateChart(distribution);
setStatus(DataStatus.FOUND);
})().catch(err => {
if (err instanceof NoDataError) {
return setStatus(DataStatus.NOT_FOUND);
}
return setStatus(DataStatus.ERROR);
});
}, [selectedSemester, course]);
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = parseInt(event.target.value, 10);
if (index === 0) {
setSelectedSemester(null);
} else {
setSelectedSemester(semesters[index - 1]);
}
};
if (status === DataStatus.FOUND) {
return (
<Card className={styles.chartContainer}>
{semesters.length > 0 && (
<div className={styles.selectContainer}>
<select onChange={handleSelectSemester}>
<option value={0}>Aggregate</option>
{semesters.map((semester, index) => (
<option key={semester.season + semester.year} value={index + 1}>
{semester.season} {semester.year}
</option>
))}
</select>
</div>
)}
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
</Card>
);
}
return (
<Card className={styles.textContainer}>
{status === DataStatus.LOADING && <Spinner />}
{status === DataStatus.ERROR && (
<Card className={styles.text}>
<Text color='speedway_brick' /* size='medium' weight='semi_bold' */>
There was an error fetching the grade distribution data
</Text>
<Icon color='speedway_brick' /* size='large' */ name='sentiment_dissatisfied' />
</Card>
)}
{status === DataStatus.NOT_FOUND && (
<Card className={styles.text}>
<Text color='charcoal' /* size='medium' weight='semi_bold' */>
No grade distribution data was found for this course
</Text>
<Icon color='charcoal' /* size='x_large' */ name='search_off' />
</Card>
)}
</Card>
);
}