diff --git a/src/components/CourseInfo/index.tsx b/src/components/CourseInfo/index.tsx index e9de017c..1bf55d1d 100644 --- a/src/components/CourseInfo/index.tsx +++ b/src/components/CourseInfo/index.tsx @@ -11,6 +11,7 @@ import { ErrorWithFields, softError } from '../../log'; import { getSemesterName } from '../../utils/semesters'; import ProfessorInfoCard from '../ProfessorInfoCard'; import Button from '../Button'; +import { MajorRestrictionsView } from '../MajorRestrictionsView'; import './stylesheet.scss'; @@ -73,7 +74,6 @@ function CourseHeader({ ); } -// Need to create course info short and course info long export default function CourseInfo({ courseId, enableTermSelect = false, @@ -89,6 +89,9 @@ export default function CourseInfo({ useState(false); const [selectedTermKey, setSelectedTermKey] = useState(null); const [gpaMap, setGpaMap] = useState(null); + const [restrictionsViewMajors, setRestrictionsViewMajors] = React.useState< + string[] | null + >(null); useEffect(() => { if (course) { @@ -254,6 +257,17 @@ export default function CourseInfo({ return
; } + if (restrictionsViewMajors !== null) { + return ( + setRestrictionsViewMajors(null)} + onHide={onHide} + courseName={`${course.subject} ${course.number}`} + /> + ); + } + return (
@@ -323,6 +337,9 @@ export default function CourseInfo({ isRatingsLoaded={isProfessorRatingsLoaded} course={course} displaySectionInfo={selectedTermKey === term} + onViewRestrictions={ + isModal ? setRestrictionsViewMajors : undefined + } /> ))}
diff --git a/src/components/MajorRestrictionsView/index.tsx b/src/components/MajorRestrictionsView/index.tsx new file mode 100644 index 00000000..94d3a0a3 --- /dev/null +++ b/src/components/MajorRestrictionsView/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { + faChevronLeft, + faChevronRight, + faXmark, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import './stylesheet.scss'; + +export type MajorRestrictionsViewProps = { + majors: string[]; + onBack?: () => void; + onHide?: () => void; + courseName?: string; +}; + +export function MajorRestrictionsView({ + majors, + onBack, + onHide, + courseName = undefined, +}: MajorRestrictionsViewProps): React.ReactElement { + return ( +
+
+
+ {onBack && ( +
+ +
+ )} +

Major Restrictions

+
+ +
+ +
+
+ + {onBack && courseName && ( +
+
+ {courseName} Section Details +
+
+ +
+ Major Restrictions +
+ )} + +

+ Must be enrolled in one of the following Majors: +

+ +
    + {majors.map((major, i) => ( +
  • {major}
  • + ))} +
+
+ ); +} diff --git a/src/components/MajorRestrictionsView/stylesheet.scss b/src/components/MajorRestrictionsView/stylesheet.scss new file mode 100644 index 00000000..f21859c1 --- /dev/null +++ b/src/components/MajorRestrictionsView/stylesheet.scss @@ -0,0 +1,82 @@ +@import '../../variables'; + +.modal-restrictions-body { + text-align: left; + padding: 4px; + padding-bottom: 40px; + + .modal-header-custom { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 48px; + + .modal-header-back-title-container { + display: flex; + align-items: center; + gap: 20px; + + h3 { + font-size: 20px; + margin: 0; + font-weight: 400; + } + } + + .close-x-icon, + .back-icon { + color: #666666; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover, + &:focus { + @include dark(color, $theme-light-background); + @include light(color, #000); + outline: none; + } + } + } + + .modal-section-details-container { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 20px; + + .modal-course-name { + color: $information-blue; + cursor: pointer; + font-size: 16px; + } + + .modal-section-chevron { + width: 6px; + height: 11px; + display: flex; + align-items: center; + justify-content: center; + } + } + + p { + margin-bottom: 20px; + margin-top: 24px; + font-size: 14px; + } + + .no-bullet-list { + list-style-type: none; + margin: 0; + max-height: 700px; + overflow-y: auto; + padding-left: 40px; + + li { + font-size: 14px; + border-bottom: none; + } + } +} diff --git a/src/components/ProfessorInfoCard/index.tsx b/src/components/ProfessorInfoCard/index.tsx index 25489ad7..16e15e1a 100644 --- a/src/components/ProfessorInfoCard/index.tsx +++ b/src/components/ProfessorInfoCard/index.tsx @@ -15,6 +15,8 @@ import ActionRow from '../ActionRow'; import MetricsCard, { Metric } from '../MetricsCard'; import useScrollFade from '../../hooks/useScrollFade'; import { OccupiedInfo } from '../../types'; +import Modal from '../Modal'; +import { MajorRestrictionsView } from '../MajorRestrictionsView'; import './stylesheet.scss'; @@ -29,6 +31,7 @@ export type ProfessorInfoCardProps = { isRatingsLoaded: boolean; course: Course; displaySectionInfo: boolean; + onViewRestrictions?: (majors: string[]) => void; }; function SectionRow({ @@ -37,14 +40,24 @@ function SectionRow({ isPinned, onAdd, onRemove, + onViewRestrictions, }: { section: Section; term: string; isPinned: boolean; onAdd: () => void; onRemove: () => void; + onViewRestrictions?: (majors: string[]) => void; }): React.ReactElement { const meeting = section.meetings[0]; + const [isModalOpen, setIsModalOpen] = useState(false); + const majors = + section.restrictions?.allowed + ?.filter((res) => res.category === 'Majors') + .map((res) => res.value) || []; + const majorString = majors.join(', ') || 'None'; + // Only show the link if there are multiple majors or a long string + const needsViewAll = majors.length > 1 || majorString.length > 25; const [isLoaded, setIsLoaded] = useState(false); @@ -82,6 +95,14 @@ function SectionRow({ return `${info.occupied} / ${info.total}`; }; + const handleViewAllClick = (): void => { + if (onViewRestrictions) { + onViewRestrictions(majors); + } else { + setIsModalOpen(true); + } + }; + if (!section) { return
; } @@ -114,6 +135,34 @@ function SectionRow({ {formatSeatData(seating?.waitlist ?? null)}
{meeting?.where || 'TBA'}
+
+
+ {majorString} +
+ + {needsViewAll && ( +
+ View all +
+ )} + + setIsModalOpen(false)} + width={800} + className="major-restrictions-modal" + > + setIsModalOpen(false)} + /> + +
); @@ -126,6 +175,7 @@ export default function ProfessorInfoCard({ isRatingsLoaded, course, displaySectionInfo, + onViewRestrictions, }: ProfessorInfoCardProps): React.ReactElement { const scrollRef = React.useRef(null); const { fadeLeft, fadeRight } = useScrollFade(scrollRef, displaySectionInfo); @@ -212,6 +262,7 @@ export default function ProfessorInfoCard({
Seats Filled
Waitlist
Location
+
Major Lock
@@ -224,6 +275,7 @@ export default function ProfessorInfoCard({ isPinned={pinnedCrns.includes(section.crn)} onAdd={(): void => handleAddSection(section)} onRemove={(): void => handleRemoveSection(section)} + onViewRestrictions={onViewRestrictions} /> ); })} diff --git a/src/components/ProfessorInfoCard/stylesheet.scss b/src/components/ProfessorInfoCard/stylesheet.scss index 31acc168..195c14af 100644 --- a/src/components/ProfessorInfoCard/stylesheet.scss +++ b/src/components/ProfessorInfoCard/stylesheet.scss @@ -45,7 +45,8 @@ 150px /* Time */ 115px /* Seats Filled */ 103px /* Waitlist */ - 210px; /* Location */ + 210px /* Location */ + 210px; /* Major Lock */ align-items: center; } @@ -91,6 +92,7 @@ display: flex; align-items: center; min-height: 46px; + min-width: 0; .action-button { background: none; @@ -105,6 +107,26 @@ color: $information-blue; } } + + &.restrictions-cell { + gap: 6px; + + .restrictions-text { + flex: 1 1 0%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .view-all-link { + flex-shrink: 0; + color: $information-blue; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + } + } } } } diff --git a/src/data/beans/Oscar.ts b/src/data/beans/Oscar.ts index f7e29590..bd030cbc 100644 --- a/src/data/beans/Oscar.ts +++ b/src/data/beans/Oscar.ts @@ -48,6 +48,10 @@ export default class Oscar { locations: (Location | null)[]; + restrictions: string[]; + + restrictionValues: Record; + updatedAt: Date; version: number; @@ -159,6 +163,8 @@ export default class Oscar { this.attributes = caches.attributes; this.gradeBases = caches.gradeBases; this.locations = caches.locations; + this.restrictions = caches.restrictions || []; + this.restrictionValues = caches.restrictionValues || {}; this.updatedAt = new Date(updatedAt); this.version = version; @@ -469,6 +475,8 @@ export const EMPTY_OSCAR = new Oscar( finalDates: [], finalTimes: [], fullCourseNames: {}, + restrictions: [], + restrictionValues: {}, }, // This converts the Date to the expected string // that it serializes to in the crawler diff --git a/src/data/beans/Section.ts b/src/data/beans/Section.ts index 8882ac9f..c664fa05 100644 --- a/src/data/beans/Section.ts +++ b/src/data/beans/Section.ts @@ -6,8 +6,23 @@ import { unique } from '../../utils/misc'; import { DELIVERY_MODES, BACKEND_BASE_URL } from '../../constants'; import Course from './Course'; import Oscar from './Oscar'; -import { CrawlerMeeting, Meeting } from '../../types'; import { ErrorWithFields, softError } from '../../log'; +import { + CrawlerMeeting, + Meeting, + SectionRestrictions, + Restriction, +} from '../../types'; + +export interface DecodedRestriction { + category: string; + value: string; +} + +export interface DecodedSectionRestrictions { + allowed: DecodedRestriction[]; + disallowed: DecodedRestriction[]; +} export type Seating = [ seating: @@ -31,7 +46,8 @@ type SectionConstructionData = [ campusIndex: number, attributeIndices: number[], gradeBasisIndex: number, - sectionTitle: string + sectionTitle: string, + restrictionData: SectionRestrictions ]; export default class Section { @@ -69,6 +85,8 @@ export default class Section { plannedCount: number | undefined; + restrictions: DecodedSectionRestrictions; + constructor( oscar: Oscar, course: Course, @@ -85,6 +103,7 @@ export default class Section { attributeIndices, gradeBasisIndex, sectionTitle, + restrictionData, ] = data; this.plannedCount = oscar.plannedCounts?.sectionCounts[crn]; @@ -97,6 +116,38 @@ export default class Section { this.scheduleType = oscar.scheduleTypes[scheduleTypeIndex] ?? 'unknown'; this.campus = oscar.campuses[campusIndex] ?? 'unknown'; this.sectionTitle = sectionTitle; + this.restrictions = { + allowed: [], + disallowed: [], + }; + + if (restrictionData && restrictionData.length === 2) { + const [allowedTuples, disallowedTuples] = restrictionData; + // Helper to decode [categoryIdx, valueIdx] using the Oscar caches + const decodeRestrictions = ( + tuples: Restriction[] + ): DecodedRestriction[] => { + return tuples.map(([catIdx, valIdx]) => { + const category = oscar.restrictions[catIdx] ?? 'Unknown Category'; + let value = 'Unknown Value'; + + // Route campus restrictions to the global campuses array + if (category.toLowerCase().includes('campus')) { + value = oscar.campuses[valIdx] ?? 'Unknown Campus'; + } else { + // Standard routing for other restrictions + value = + oscar.restrictionValues?.[category]?.[valIdx] ?? 'Unknown Value'; + } + + return { category, value }; + }); + }; + + this.restrictions.allowed = decodeRestrictions(allowedTuples); + this.restrictions.disallowed = decodeRestrictions(disallowedTuples); + } + const attributes = attributeIndices .map((attributeIndex) => oscar.attributes[attributeIndex]) .flatMap((attribute) => (attribute == null ? [] : [attribute])); diff --git a/src/types.ts b/src/types.ts index c15b8216..465b8ebb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,6 +116,13 @@ export type SeatData = { waitlist: OccupiedInfo | null; }; +export type Restriction = [categoryIndex: number, valueIndex: number]; +export type RestrictionStatus = 'success' | 'parse-error' | 'fetch-error'; + +export type SectionRestrictions = + | [allowed: Restriction[], disallowed: Restriction[]] + | []; + // Meeting type (imported as `CrawlerMeeting`): // Copied from https://github.com/gt-scheduler/crawler/blob/master/src/types.ts @@ -212,7 +219,11 @@ export type CrawlerSection = [ * the section-specific title of the course (e.g. "Animal Interaction"), * used for 8803 Special Topics courses */ - sectionTitle: string + sectionTitle: string, + /** + * restriction information for this section with status + */ + restrictionData: SectionRestrictions ]; // Prerequisite types: @@ -276,6 +287,13 @@ export interface CrawlerCaches { * (e.g. `"Georgia Tech-Atlanta *"` or `"Online"`) */ campuses: string[]; + /** List of restrictions on a course (e.g. `"Campus"`, or `"Level"`) */ + restrictions: string[]; + /** A dedicated object to hold the dynamically discovered restriction values. + * Keys are the category names (e.g., "campuses" or "degree_programs"), + * values are the arrays of strings. + */ + restrictionValues: Record; /** * List of other miscellaneous attributes that can be associated with a class * (e.g. `"Hybrid Course"`, `"Honors Program"`, or `"Capstone"`)