Files
UT-Registration-Plus/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx
Uthman 549c52a39f feat: add drag-and-drop import for schedules (#661)
* testing

* feat: add drag-and-drop support to FileUpload (#446)

* chore: remove testing comment

* chore: fix lint issues

* chore: format FileUpload.tsx with prettier

---------

Co-authored-by: Uthman Ogungbo <uthmanogungbo@Uthmans-MacBook-Pro.local>
Co-authored-by: Uthman Ogungbo <uthmanogungbo@wireless-10-148-166-229.public.utexas.edu>
Co-authored-by: Derek <derex1987@gmail.com>
2025-11-06 23:03:55 -06:00

318 lines
14 KiB
TypeScript

import createSchedule from '@pages/background/lib/createSchedule';
import switchSchedule from '@pages/background/lib/switchSchedule';
import {
ArrowUpRight,
CalendarDots,
ChatText,
Check,
Copy,
FileText,
Minus,
Plus,
Smiley,
X,
} from '@phosphor-icons/react';
import { background } from '@shared/messages';
import type { Course } from '@shared/types/Course';
import type Instructor from '@shared/types/Instructor';
import type { UserSchedule } from '@shared/types/UserSchedule';
import { englishStringifyList } from '@shared/util/string';
import { Button } from '@views/components/common/Button';
import { Chip, coreMap, flagMap } from '@views/components/common/Chip';
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
import Divider from '@views/components/common/Divider';
import Link from '@views/components/common/Link';
import Text from '@views/components/common/Text/Text';
import { useCalendar } from '@views/contexts/CalendarContext';
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import DisplayMeetingInfo from './DisplayMeetingInfo';
const { openNewTab, addCourse, removeCourse, openCESPage } = background;
/**
* Capitalizes the first letter of a string and converts the rest of the letters to lowercase.
*
* @param str - The string to be capitalized.
* @returns The capitalized string.
*/
const capitalizeString = (str: string) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
interface HeadingAndActionProps {
/* The course to display */
course: Course;
/* The active schedule */
activeSchedule: UserSchedule;
/* The function to call when the popup should be closed */
onClose: () => void;
}
/**
* Renders the heading component for the CoursePopup component.
*
* @param course - The course object containing course details.
* @param activeSchedule - The active schedule object.
* @param onClose - The function to close the popup.
* @returns The rendered component.
*/
export default function HeadingAndActions({ course, activeSchedule, onClose }: HeadingAndActionProps): JSX.Element {
const { courseName, department, number: courseNumber, uniqueId, instructors, flags, core } = course;
const courseAdded = activeSchedule.courses.some(ourCourse => ourCourse.uniqueId === uniqueId);
const formattedUniqueId = uniqueId.toString().padStart(5, '0');
const isInCalendar = useCalendar();
const [isCopied, setIsCopied] = useState<boolean>(false);
const lastCopyTime = useRef<number>(0);
const showDialog = usePrompt();
const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' });
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
const now = Date.now();
if (now - lastCopyTime.current < 500) {
return;
}
lastCopyTime.current = now;
await navigator.clipboard.writeText(formattedUniqueId);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 500);
};
const handleOpenRateMyProf = async () => {
const openTabs = instructors.map(instructor => {
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 () => {
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 () => {
for (const instructor of instructors) {
let { firstName = '', lastName = '' } = instructor;
firstName = capitalizeString(firstName);
lastName = capitalizeString(lastName);
const url = `https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&department=${department}&course_number=${courseNumber}&course_title=&unique=&instructor_first=${firstName}&instructor_last=${lastName}&course_type=In+Residence&search=Search`;
openNewTab({ url });
}
// Show the course's syllabi when no instructors listed
if (instructors.length === 0) {
const url = `https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&department=${department}&course_number=${courseNumber}&course_title=&unique=&instructor_first=&instructor_last=&course_type=In+Residence&search=Search`;
openNewTab({ url });
}
};
const handleAddToNewSchedule = async (close: () => void) => {
const newScheduleId = await createSchedule(`${course.semester.season} ${course.semester.year}`);
switchSchedule(newScheduleId);
addCourse({ course, scheduleId: newScheduleId });
close();
};
const handleAddOrRemoveCourse = async () => {
const uniqueSemesterCodes = [
...new Set(
activeSchedule.courses
.map(course => course.semester.code)
.filter((code): code is string => code !== undefined)
),
];
uniqueSemesterCodes.sort();
const codeToReadableMap: Record<string, string> = {};
activeSchedule.courses.forEach(course => {
const { code } = course.semester;
if (code) {
const readable = `${course.semester.season} ${course.semester.year}`;
codeToReadableMap[code] = readable;
}
});
const sortedSemesters = uniqueSemesterCodes
.map(code => codeToReadableMap[code])
.filter((value): value is string => value !== undefined);
const activeSemesters = englishStringifyList(sortedSemesters);
if (!activeSchedule) return;
if (!courseAdded) {
const currentSemesterCode = course.semester.code;
// Show warning if this course is for a different semester than the selected schedule
if (
activeSchedule.courses.length > 0 &&
activeSchedule.courses.every(otherCourse => otherCourse.semester.code !== currentSemesterCode)
) {
const dialogButtons = (close: () => void) => (
<>
<Button variant='minimal' color='ut-black' onClick={close}>
Cancel
</Button>
<Button
variant='filled'
color='ut-burntorange'
onClick={() => {
handleAddToNewSchedule(close);
}}
>
Start a new schedule
</Button>
</>
);
showDialog({
title: 'This course section is from a different semester!',
description: (
<>
The section you&apos;re adding is for{' '}
<span className='whitespace-nowrap text-ut-burntorange'>
{course.semester.season} {course.semester.year}
</span>
, but your current schedule contains sections in{' '}
<span className='whitespace-nowrap text-ut-burntorange'>{activeSemesters}</span>. Mixing
semesters in one schedule may cause confusion.
</>
),
buttons: dialogButtons,
});
} else {
addCourse({ course, scheduleId: activeSchedule.id });
}
} else {
removeCourse({ course, scheduleId: activeSchedule.id });
}
};
return (
<div className='w-full px-2 pb-3 pt-5 text-ut-black'>
<div className='flex flex-col'>
<div className='flex items-center gap-1'>
<Text variant='h1' className='truncate text-theme-black'>
{courseName}
</Text>
<Text variant='h1' className='flex-1 whitespace-nowrap text-theme-black'>
({department} {courseNumber})
</Text>
<Button color='ut-burntorange' variant='minimal' onClick={handleCopy}>
<div className='relative h-5.5 w-5.5'>
<Check
className={clsx(
'absolute size-full inset-0 text-ut-burntorange transition-all duration-250 ease-in-out',
isCopied ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
)}
/>
<Copy
className={clsx(
'absolute size-full inset-0 text-ut-burntorange transition-all duration-250 ease-in-out',
isCopied ? 'opacity-0 scale-75' : 'opacity-100 scale-100'
)}
/>
</div>
{formattedUniqueId}
</Button>
<button className='bg-transparent p-0 text-ut-black btn' onClick={onClose}>
<X className='h-6 w-6' />
</button>
</div>
<div className='flex items-center gap-2'>
{instructors.length > 0 ? (
<Text variant='h4' as='p'>
with{' '}
{instructors
.map(instructor => (
<Link
key={instructor.fullName}
variant='h4'
href={`https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&course_title=&unique=&instructor_first=${instructor.firstName}&instructor_last=${instructor.lastName}&course_type=In+Residence&search=Search`}
className='link'
>
{getInstructorFullName(instructor)}
</Link>
))
.flatMap((el, i) => (i === 0 ? [el] : [', ', el]))}
</Text>
) : (
<Text variant='h4' as='p'>
(No instructor has been provided)
</Text>
)}
<div className='flex items-center gap-1'>
{flags.map((flag: string) => (
<Chip
key={flagMap[flag as keyof typeof flagMap]}
label={flagMap[flag as keyof typeof flagMap]}
variant='flag'
/>
))}
{core.map((coreVal: string) => (
<Chip
key={coreMap[coreVal as keyof typeof coreMap]}
label={coreMap[coreVal as keyof typeof coreMap]}
variant='core'
/>
))}
</div>
</div>
<DisplayMeetingInfo course={course} />
</div>
<div className='my-3 flex flex-wrap items-center gap-x-3.75 gap-y-2.5'>
<Button
variant='filled'
color='ut-burntorange'
icon={isInCalendar ? ArrowUpRight : CalendarDots}
onClick={() => {
if (isInCalendar) {
openNewTab({
url: course.url,
});
} else {
background.switchToCalendarTab({});
}
}}
/>
<Divider size='1.75rem' orientation='vertical' />
<Button
variant='outline'
color='ut-blue'
icon={ChatText}
onClick={handleOpenRateMyProf}
disabled={instructors.length === 0}
>
RateMyProf
</Button>
<Button
variant='outline'
color='ut-teal'
icon={Smiley}
onClick={handleOpenCES}
disabled={instructors.length === 0}
>
CES
</Button>
<Button variant='outline' color='ut-orange' icon={FileText} onClick={handleOpenPastSyllabi}>
Past Syllabi
</Button>
<Button
variant='filled'
color={!courseAdded ? 'ut-green' : 'theme-red'}
icon={!courseAdded ? Plus : Minus}
onClick={handleAddOrRemoveCourse}
>
{!courseAdded ? 'Add Course' : 'Remove Course'}
</Button>
</div>
<Divider orientation='horizontal' size='100%' />
</div>
);
}