* 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>
318 lines
14 KiB
TypeScript
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'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>
|
|
);
|
|
}
|