From d011ebccb9dd1b456597cd9a40b6cc2e8c7d3300 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Tue, 10 Dec 2024 23:16:37 -0800 Subject: [PATCH] feat: optimize app by reducing rerenders (#1074) --- .../Calendar/CalendarCourseEvent.tsx | 69 ++++ .../Calendar/CalendarCourseEventWrapper.tsx | 46 +++ .../Calendar/CalendarEventPopover.tsx | 58 +++ .../src/components/Calendar/CalendarRoot.tsx | 257 +++++-------- .../components/Calendar/CalendarToolbar.tsx | 364 ------------------ .../Calendar/CourseCalendarEvent.tsx | 28 +- .../Calendar/toolbar/CalendarToolbar.tsx | 121 ++++++ .../CustomEventDialog/CustomEventDialog.tsx | 0 .../CustomEventDialog/DaySelector.tsx | 0 .../CustomEventDialog/ScheduleSelector.tsx | 0 .../toolbar/CustomEventDialog/index.ts | 1 + .../EditSchedule/DeleteScheduleDialog.tsx | 0 .../EditSchedule/ScheduleNameDialog.tsx | 0 .../toolbar/ScheduleSelect/ScheduleSelect.tsx | 147 +++++++ .../AddScheduleButton.tsx | 36 ++ .../DeleteScheduleButton.tsx | 40 ++ .../RenameScheduleButton.tsx | 35 ++ .../antalmanac/src/components/ColorPicker.tsx | 2 +- .../AddedCourses/CustomEventDetailView.tsx | 2 +- .../RightPane/CoursePane/CourseRenderPane.tsx | 6 +- .../RightPane/SectionTable/SectionTable.tsx | 23 +- .../SectionTableBody/SectionTableBody.tsx | 94 +++++ .../SectionTableBodyRow.tsx} | 148 +++---- apps/antalmanac/src/components/SharedRoot.tsx | 12 +- .../src/components/dialogs/RenameSchedule.tsx | 2 +- apps/antalmanac/src/index.tsx | 6 + apps/antalmanac/src/routes/Home.tsx | 4 +- apps/antalmanac/src/stores/HoveredStore.ts | 51 +-- .../src/stores/SelectedEventStore.ts | 23 ++ .../src/stores/calendarizeHelpers.ts | 4 +- 30 files changed, 864 insertions(+), 715 deletions(-) create mode 100644 apps/antalmanac/src/components/Calendar/CalendarCourseEvent.tsx create mode 100644 apps/antalmanac/src/components/Calendar/CalendarCourseEventWrapper.tsx create mode 100644 apps/antalmanac/src/components/Calendar/CalendarEventPopover.tsx delete mode 100644 apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/CalendarToolbar.tsx rename apps/antalmanac/src/components/Calendar/{Toolbar => toolbar}/CustomEventDialog/CustomEventDialog.tsx (100%) rename apps/antalmanac/src/components/Calendar/{Toolbar => toolbar}/CustomEventDialog/DaySelector.tsx (100%) rename apps/antalmanac/src/components/Calendar/{Toolbar => toolbar}/CustomEventDialog/ScheduleSelector.tsx (100%) create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/index.ts rename apps/antalmanac/src/components/Calendar/{Toolbar => toolbar}/EditSchedule/DeleteScheduleDialog.tsx (100%) rename apps/antalmanac/src/components/Calendar/{Toolbar => toolbar}/EditSchedule/ScheduleNameDialog.tsx (100%) create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/ScheduleSelect.tsx create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/AddScheduleButton.tsx create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/DeleteScheduleButton.tsx create mode 100644 apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/RenameScheduleButton.tsx create mode 100644 apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx rename apps/antalmanac/src/components/RightPane/SectionTable/{SectionTableBody.tsx => SectionTableBody/SectionTableBodyRow.tsx} (80%) create mode 100644 apps/antalmanac/src/stores/SelectedEventStore.ts diff --git a/apps/antalmanac/src/components/Calendar/CalendarCourseEvent.tsx b/apps/antalmanac/src/components/Calendar/CalendarCourseEvent.tsx new file mode 100644 index 000000000..cd989b415 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/CalendarCourseEvent.tsx @@ -0,0 +1,69 @@ +import { Box } from '@material-ui/core'; +import { memo } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { CalendarEvent } from '$components/Calendar/CourseCalendarEvent'; +import locationIds from '$lib/location_ids'; +import { useSelectedEventStore } from '$stores/SelectedEventStore'; + +export const CalendarCourseEvent = memo(({ event }: { event: CalendarEvent }) => { + const setSelectedEvent = useSelectedEventStore((state) => state.setSelectedEvent, shallow); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setSelectedEvent(e, event); + }; + + if (event.isCustomEvent) { + return ( + + + {event.title} + + + + {Object.keys(locationIds).find((key) => locationIds[key] === parseInt(event.building))} + + + ); + } + + return ( + + + {event.title} + {event.sectionType} + + + + {event.showLocationInfo + ? event.locations.map((location) => `${location.building} ${location.room}`).join(', ') + : event.locations.length > 1 + ? `${event.locations.length} Locations` + : `${event.locations[0].building} ${event.locations[0].room}`} + + {event.sectionCode} + + + ); +}); + +CalendarCourseEvent.displayName = 'CalendarCourseEvent'; diff --git a/apps/antalmanac/src/components/Calendar/CalendarCourseEventWrapper.tsx b/apps/antalmanac/src/components/Calendar/CalendarCourseEventWrapper.tsx new file mode 100644 index 000000000..3f9951602 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/CalendarCourseEventWrapper.tsx @@ -0,0 +1,46 @@ +import { Box } from '@mui/material'; +import { useCallback, useEffect, useRef } from 'react'; +import { EventWrapperProps } from 'react-big-calendar'; +import { shallow } from 'zustand/shallow'; + +import type { CalendarEvent } from '$components/Calendar/CourseCalendarEvent'; +import { useSelectedEventStore } from '$stores/SelectedEventStore'; + +interface CalendarCourseEventWrapperProps extends EventWrapperProps { + children?: React.ReactNode; +} + +/** + * CalendarCourseEventWrapper allows us to override the default onClick event behavior which problamtically rerenders the entire calendar + */ +export const CalendarCourseEventWrapper = ({ children, ...props }: CalendarCourseEventWrapperProps) => { + const ref = useRef(null); + + const setSelectedEvent = useSelectedEventStore((state) => state.setSelectedEvent, shallow); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setSelectedEvent(e, props.event); + }, + [props.event, setSelectedEvent] + ); + + useEffect(() => { + const node = ref.current; + if (!node) { + return; + } + + const rbcEvent = node.querySelector('.rbc-event') as HTMLDivElement; + if (!rbcEvent) { + return; + } + + rbcEvent.onclick = (e) => handleClick(e as unknown as React.MouseEvent); // the native onclick requires a little type hacking + }, [handleClick]); + + return {children}; +}; diff --git a/apps/antalmanac/src/components/Calendar/CalendarEventPopover.tsx b/apps/antalmanac/src/components/Calendar/CalendarEventPopover.tsx new file mode 100644 index 000000000..788578aea --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/CalendarEventPopover.tsx @@ -0,0 +1,58 @@ +import { Popover } from '@mui/material'; +import { useCallback, useEffect, useState } from 'react'; +import { shallow } from 'zustand/shallow'; + +import CourseCalendarEvent from '$components/Calendar/CourseCalendarEvent'; +import AppStore from '$stores/AppStore'; +import { useSelectedEventStore } from '$stores/SelectedEventStore'; + +export function CalendarEventPopover() { + const [anchorEl, selectedEvent, setSelectedEvent] = useSelectedEventStore( + (state) => [state.selectedEventAnchorEl, state.selectedEvent, state.setSelectedEvent], + shallow + ); + + const [scheduleNames, setScheduleNames] = useState(() => AppStore.getScheduleNames()); + + const handleClosePopover = useCallback(() => { + setSelectedEvent(null, null); + }, [setSelectedEvent]); + + useEffect(() => { + const updateScheduleNames = () => { + setScheduleNames(AppStore.getScheduleNames()); + }; + + AppStore.on('scheduleNamesChange', updateScheduleNames); + + return () => { + AppStore.off('scheduleNamesChange', updateScheduleNames); + }; + }, []); + + if (!selectedEvent) { + return null; + } + + return ( + + + + ); +} diff --git a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx index 8bd5f30cc..49e4d0351 100644 --- a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx +++ b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx @@ -1,139 +1,85 @@ import 'react-big-calendar/lib/css/react-big-calendar.css'; import './calendar.css'; -import { Box, ClickAwayListener, Popper } from '@material-ui/core'; +import { Box } from '@material-ui/core'; import moment from 'moment'; -import { SyntheticEvent, useEffect, useState } from 'react'; -import { Calendar, DateLocalizer, momentLocalizer, Views } from 'react-big-calendar'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { Calendar, Components, DateLocalizer, momentLocalizer, Views, ViewsProps } from 'react-big-calendar'; +import { shallow } from 'zustand/shallow'; -import CalendarToolbar from './CalendarToolbar'; -import CourseCalendarEvent, { CalendarEvent, CourseEvent } from './CourseCalendarEvent'; +import { CalendarEvent, CourseEvent } from './CourseCalendarEvent'; -import locationIds from '$lib/location_ids'; +import { CalendarCourseEvent } from '$components/Calendar/CalendarCourseEvent'; +import { CalendarCourseEventWrapper } from '$components/Calendar/CalendarCourseEventWrapper'; +import { CalendarEventPopover } from '$components/Calendar/CalendarEventPopover'; +import { CalendarToolbar } from '$components/Calendar/toolbar/CalendarToolbar'; import { getDefaultFinalsStartDate, getFinalsStartDateForTerm } from '$lib/termData'; import AppStore from '$stores/AppStore'; import { useHoveredStore } from '$stores/HoveredStore'; import { useTimeFormatStore } from '$stores/SettingsStore'; -const localizer = momentLocalizer(moment); - -const AntAlmanacEvent = ({ event }: { event: CalendarEvent }) => { - return event.isCustomEvent ? ( - - - {event.title} - - - - {Object.keys(locationIds).find((key) => locationIds[key] === parseInt(event.building))} - - - ) : ( - - - {event.title} - {event.sectionType} - - - - {event.showLocationInfo - ? event.locations.map((location) => `${location.building} ${location.room}`).join(', ') - : event.locations.length > 1 - ? `${event.locations.length} Locations` - : `${event.locations[0].building} ${event.locations[0].room}`} - - {event.sectionCode} - - - ); +const CALENDAR_LOCALIZER: DateLocalizer = momentLocalizer(moment); +const CALENDAR_VIEWS: ViewsProps = [Views.WEEK, Views.WORK_WEEK]; +const CALENDAR_COMPONENTS: Components = { + event: CalendarCourseEvent, + eventWrapper: CalendarCourseEventWrapper, }; +const CALENDAR_MAX_DATE = new Date(2018, 0, 1, 23); -interface ScheduleCalendarProps { - isMobile?: boolean; -} - -export default function ScheduleCalendar(_props?: ScheduleCalendarProps) { - const [anchorEl, setAnchorEl] = useState(null); +export const ScheduleCalendar = memo(() => { const [showFinalsSchedule, setShowFinalsSchedule] = useState(false); - const [courseInMoreInfo, setCourseInMoreInfo] = useState(null); - const [calendarEventKey, setCalendarEventKey] = useState(null); - const [eventsInCalendar, setEventsInCalendar] = useState(AppStore.getEventsInCalendar()); - const [finalsEventsInCalendar, setFinalEventsInCalendar] = useState(AppStore.getFinalEventsInCalendar()); - const [currentScheduleIndex, setCurrentScheduleIndex] = useState(AppStore.getCurrentScheduleIndex()); - const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); + const [eventsInCalendar, setEventsInCalendar] = useState(() => AppStore.getEventsInCalendar()); + const [finalsEventsInCalendar, setFinalEventsInCalendar] = useState(() => AppStore.getFinalEventsInCalendar()); + const [currentScheduleIndex, setCurrentScheduleIndex] = useState(() => AppStore.getCurrentScheduleIndex()); + const [scheduleNames, setScheduleNames] = useState(() => AppStore.getScheduleNames()); const { isMilitaryTime } = useTimeFormatStore(); - const [hoveredCalendarizedCourses, hoveredCalendarizedFinal] = useHoveredStore((store) => [ - store.hoveredCalendarizedCourses, - store.hoveredCalendarizedFinal, - ]); + const [hoveredCalendarizedCourses, hoveredCalendarizedFinal] = useHoveredStore( + (state) => [state.hoveredCalendarizedCourses, state.hoveredCalendarizedFinal], + shallow + ); - const getEventsForCalendar = (): CalendarEvent[] => { + const getEventsForCalendar = useCallback((): CalendarEvent[] => { if (showFinalsSchedule) return hoveredCalendarizedFinal ? [...finalsEventsInCalendar, hoveredCalendarizedFinal] : finalsEventsInCalendar; else return hoveredCalendarizedCourses ? [...eventsInCalendar, ...hoveredCalendarizedCourses] : eventsInCalendar; - }; - - const handleClosePopover = () => { - setAnchorEl(null); - }; + }, [ + eventsInCalendar, + finalsEventsInCalendar, + hoveredCalendarizedCourses, + hoveredCalendarizedFinal, + showFinalsSchedule, + ]); - const toggleDisplayFinalsSchedule = () => { - handleClosePopover(); + const events = getEventsForCalendar(); + const toggleDisplayFinalsSchedule = useCallback(() => { setShowFinalsSchedule((prevState) => !prevState); - }; - - const handleEventClick = (event: CalendarEvent, e: SyntheticEvent) => { - const { currentTarget } = e; - e.stopPropagation(); - - if (event.isCustomEvent || event.sectionType !== 'Fin') { - setAnchorEl((prevAnchorEl) => (prevAnchorEl === currentTarget ? null : currentTarget)); - setCourseInMoreInfo(event); - setCalendarEventKey(Math.random()); - } - }; + }, []); /** * Finds the earliest start time and returns that or 7AM, whichever is earlier * @returns A date with the earliest time or 7AM */ - const getStartTime = () => { - const eventStartHours = getEventsForCalendar().map((event) => event.start.getHours()); + const getStartTime = useCallback(() => { + const eventStartHours = events.map((event) => event.start.getHours()); return new Date(2018, 0, 1, Math.min(7, Math.min(...eventStartHours))); - }; - - const eventStyleGetter = (event: CalendarEvent) => { - return { - style: { - backgroundColor: event.color, - cursor: 'pointer', - borderStyle: 'none', - borderRadius: '4px', - color: colorContrastSufficient(event.color) ? 'white' : 'black', - }, + }, [events]); + + const eventStyleGetter = useCallback((event: CalendarEvent) => { + const style = { + backgroundColor: event.color, + cursor: 'pointer', + borderStyle: 'none', + borderRadius: '4px', + color: colorContrastSufficient(event.color) ? 'white' : 'black', }; - }; + + return { style }; + }, []); const colorContrastSufficient = (bg: string) => { // This equation is taken from w3c, does not use the colour difference part @@ -158,7 +104,6 @@ export default function ScheduleCalendar(_props?: ScheduleCalendarProps) { return Math.abs(bgBrightness - textBrightness) > minBrightnessDiff; }; - const events = getEventsForCalendar(); const hasWeekendCourse = events.some((event) => event.start.getDay() === 0 || event.start.getDay() === 6); const calendarTimeFormat = isMilitaryTime ? 'HH:mm' : 'h:mm A'; @@ -175,15 +120,34 @@ export default function ScheduleCalendar(_props?: ScheduleCalendarProps) { const finalsDateFormat = finalsDate ? 'ddd MM/DD' : 'ddd'; const date = showFinalsSchedule && finalsDate ? finalsDate : new Date(2018, 0, 1); - /** - * If a final is on a Saturday or Sunday, let the calendar start on Saturday - */ - // eslint-disable-next-line import/no-named-as-default-member -- moment doesn't expose named exports: https://github.com/vitejs/vite-plugin-react/issues/202 - moment.updateLocale('es-us', { - week: { - dow: hasWeekendCourse && showFinalsSchedule ? 6 : 0, - }, - }); + const formats = useMemo( + () => ({ + timeGutterFormat: (date: Date, culture?: string, localizer?: DateLocalizer) => + date.getMinutes() > 0 || !localizer ? '' : localizer.format(date, calendarGutterTimeFormat, culture), + dayFormat: showFinalsSchedule ? finalsDateFormat : 'ddd', + eventTimeRangeFormat: (range: { start: Date; end: Date }, culture?: string, localizer?: DateLocalizer) => + localizer + ? `${localizer.format(range.start, calendarTimeFormat, culture)} - ${localizer.format( + range.end, + calendarTimeFormat, + culture + )}` + : '', + }), + [calendarGutterTimeFormat, calendarTimeFormat, finalsDateFormat, showFinalsSchedule] + ); + + useEffect(() => { + /** + * If a final is on a Saturday or Sunday, let the calendar start on Saturday + */ + // eslint-disable-next-line import/no-named-as-default-member -- moment doesn't expose named exports: https://github.com/vitejs/vite-plugin-react/issues/202 + moment.updateLocale('es-us', { + week: { + dow: hasWeekendCourse && showFinalsSchedule ? 6 : 0, + }, + }); + }, [hasWeekendCourse, showFinalsSchedule]); useEffect(() => { const updateEventsInCalendar = () => { @@ -219,57 +183,15 @@ export default function ScheduleCalendar(_props?: ScheduleCalendarProps) { showFinalsSchedule={showFinalsSchedule} scheduleNames={scheduleNames} /> + - - - - - - - + + - localizer={localizer} + localizer={CALENDAR_LOCALIZER} toolbar={false} - formats={{ - timeGutterFormat: (date: Date, culture?: string, localizer?: DateLocalizer) => - date.getMinutes() > 0 || !localizer - ? '' - : localizer.format(date, calendarGutterTimeFormat, culture), - dayFormat: `${showFinalsSchedule ? finalsDateFormat : 'ddd'}`, - eventTimeRangeFormat: ( - range: { start: Date; end: Date }, - culture?: string, - localizer?: DateLocalizer - ) => - !localizer - ? '' - : localizer.format(range.start, calendarTimeFormat, culture) + - ' - ' + - localizer.format(range.end, calendarTimeFormat, culture), - }} - views={[Views.WEEK, Views.WORK_WEEK]} + formats={formats} + views={CALENDAR_VIEWS} defaultView={Views.WORK_WEEK} view={hasWeekendCourse ? Views.WEEK : Views.WORK_WEEK} onView={() => { @@ -278,16 +200,19 @@ export default function ScheduleCalendar(_props?: ScheduleCalendarProps) { step={15} timeslots={2} date={date} - onNavigate={() => undefined} + onNavigate={() => { + return; + }} min={getStartTime()} - max={new Date(2018, 0, 1, 23)} + max={CALENDAR_MAX_DATE} events={events} eventPropGetter={eventStyleGetter} showMultiDayTimes={false} - components={{ event: AntAlmanacEvent }} - onSelectEvent={handleEventClick} + components={CALENDAR_COMPONENTS} /> ); -} +}); + +ScheduleCalendar.displayName = 'ScheduleCalendar'; diff --git a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx b/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx deleted file mode 100644 index 85e33f543..000000000 --- a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import { - Add as AddIcon, - ArrowDropDown as ArrowDropDownIcon, - Edit as EditIcon, - Undo as UndoIcon, - Clear as ClearIcon, -} from '@mui/icons-material'; -import { Box, Button, IconButton, Paper, Popover, Tooltip, Typography, useTheme } from '@mui/material'; -import { useState, useMemo, useCallback, useEffect } from 'react'; - -import CustomEventDialog from './Toolbar/CustomEventDialog/CustomEventDialog'; - -import { changeCurrentSchedule, undoDelete } from '$actions/AppStoreActions'; -import { ClearScheduleButton } from '$components/buttons/Clear'; -import { CopyScheduleButton } from '$components/buttons/Copy'; -import DownloadButton from '$components/buttons/Download'; -import ScreenshotButton from '$components/buttons/Screenshot'; -import AddScheduleDialog from '$components/dialogs/AddSchedule'; -import DeleteScheduleDialog from '$components/dialogs/DeleteSchedule'; -import RenameScheduleDialog from '$components/dialogs/RenameSchedule'; -import analyticsEnum, { logAnalytics } from '$lib/analytics'; -import AppStore from '$stores/AppStore'; - -function handleScheduleChange(index: number) { - logAnalytics({ - category: analyticsEnum.calendar.title, - action: analyticsEnum.calendar.actions.CHANGE_SCHEDULE, - }); - changeCurrentSchedule(index); -} - -/** - * Creates an event handler callback that will change the current schedule to the one at a specified index. - */ -function createScheduleSelector(index: number) { - return () => { - handleScheduleChange(index); - }; -} - -function handleUndo() { - logAnalytics({ - category: analyticsEnum.calendar.title, - action: analyticsEnum.calendar.actions.UNDO, - }); - undoDelete(null); -} - -interface RenameScheduleButtonProps { - index: number; - disabled?: boolean; -} - -function RenameScheduleButton({ index, disabled }: RenameScheduleButtonProps) { - const [open, setOpen] = useState(false); - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - }, []); - - return ( - - - - - - - - - - - ); -} - -interface DeleteScheduleButtonProps { - index: number; - disabled?: boolean; -} - -function DeleteScheduleButton({ index, disabled }: DeleteScheduleButtonProps) { - const [open, setOpen] = useState(false); - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - }, []); - - return ( - - - - - - - - - - - ); -} - -interface AddScheduleButtonProps { - disabled: boolean; -} - -/** - * MenuItem nested in the select menu to add a new schedule through a dialog. - */ -function AddScheduleButton({ disabled }: AddScheduleButtonProps) { - const [open, setOpen] = useState(false); - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - }, []); - - return ( - <> - - - - ); -} - -/** - * Simulates an HTML select element using a popover. - * - * Can select a schedule, and also control schedule settings with buttons. - */ -function SelectSchedulePopover(props: { scheduleNames: string[] }) { - const [currentScheduleIndex, setCurrentScheduleIndex] = useState(AppStore.getCurrentScheduleIndex()); - const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); - - const [anchorEl, setAnchorEl] = useState(); - - const theme = useTheme(); - - // TODO: maybe these widths should be dynamic based on i.e. the viewport width? - - const minWidth = useMemo(() => 100, []); - const maxWidth = useMemo(() => 150, []); - - const open = useMemo(() => Boolean(anchorEl), [anchorEl]); - - const currentScheduleName = useMemo(() => { - return props.scheduleNames[currentScheduleIndex]; - }, [props.scheduleNames, currentScheduleIndex]); - - const handleClick = useCallback((event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }, []); - - const handleClose = useCallback(() => { - setAnchorEl(undefined); - }, []); - - const handleScheduleIndexChange = useCallback(() => { - setCurrentScheduleIndex(AppStore.getCurrentScheduleIndex()); - }, []); - - const handleSkeletonModeChange = () => { - setSkeletonMode(AppStore.getSkeletonMode()); - }; - - useEffect(() => { - AppStore.on('addedCoursesChange', handleScheduleIndexChange); - AppStore.on('customEventsChange', handleScheduleIndexChange); - AppStore.on('colorChange', handleScheduleIndexChange); - AppStore.on('currentScheduleIndexChange', handleScheduleIndexChange); - AppStore.on('skeletonModeChange', handleSkeletonModeChange); - - return () => { - AppStore.off('addedCoursesChange', handleScheduleIndexChange); - AppStore.off('customEventsChange', handleScheduleIndexChange); - AppStore.off('colorChange', handleScheduleIndexChange); - AppStore.off('currentScheduleIndexChange', handleScheduleIndexChange); - AppStore.off('skeletonModeChange', handleSkeletonModeChange); - }; - }, [handleScheduleIndexChange]); - - return ( - - - - - - {props.scheduleNames.map((name, index) => ( - - - - - - - - - - - ))} - - - - - - - - ); -} - -export interface CalendarPaneToolbarProps { - scheduleNames: string[]; - currentScheduleIndex: number; - showFinalsSchedule: boolean; - toggleDisplayFinalsSchedule: () => void; -} - -/** - * The root toolbar will pass down the schedule names to its children. - */ -function CalendarPaneToolbar(props: CalendarPaneToolbarProps) { - const { showFinalsSchedule, toggleDisplayFinalsSchedule } = props; - const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); - const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); - const [skeletonScheduleNames, setSkeletonScheduleNames] = useState(AppStore.getSkeletonScheduleNames()); - - const handleToggleFinals = useCallback(() => { - logAnalytics({ - category: analyticsEnum.calendar.title, - action: analyticsEnum.calendar.actions.DISPLAY_FINALS, - }); - toggleDisplayFinalsSchedule(); - }, [toggleDisplayFinalsSchedule]); - - const handleScheduleNamesChange = useCallback(() => { - setScheduleNames(AppStore.getScheduleNames()); - }, []); - - useEffect(() => { - const handleSkeletonModeChange = () => { - setSkeletonMode(AppStore.getSkeletonMode()); - setSkeletonScheduleNames(AppStore.getSkeletonScheduleNames()); - }; - - AppStore.on('skeletonModeChange', handleSkeletonModeChange); - - return () => { - AppStore.off('skeletonModeChange', handleSkeletonModeChange); - }; - }, []); - - useEffect(() => { - AppStore.on('scheduleNamesChange', handleScheduleNamesChange); - - return () => { - AppStore.off('scheduleNamesChange', handleScheduleNamesChange); - }; - }, [handleScheduleNamesChange]); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export default CalendarPaneToolbar; diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index f1636ce66..aa96f66b6 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -7,9 +7,8 @@ import { useEffect, useRef, useCallback } from 'react'; import { Event } from 'react-big-calendar'; import { Link } from 'react-router-dom'; -import CustomEventDialog from './Toolbar/CustomEventDialog/CustomEventDialog'; - import { deleteCourse, deleteCustomEvent } from '$actions/AppStoreActions'; +import CustomEventDialog from '$components/Calendar/toolbar/CustomEventDialog/'; import ColorPicker from '$components/ColorPicker'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; import buildingCatalogue from '$lib/buildingCatalogue'; @@ -23,7 +22,6 @@ import { formatTimes } from '$stores/calendarizeHelpers'; const styles: Styles = { courseContainer: { padding: '0.5rem', - margin: '0 1rem', minWidth: '15rem', }, customEventContainer: { @@ -125,7 +123,7 @@ export interface CourseEvent extends CommonCalendarEvent { } /** - * There is another CustomEvent interface in CourseCalendarEvent and they are slightly different. The this one represents only one day, like the event on Monday, and needs to be duplicated to be repeated across multiple days. The other one, `CustomEventDialog`'s `RepeatingCustomEvent`, encapsulates the occurences of an event on multiple days, like Monday Tuesday Wednesday all in the same object as specified by the `days` array. + * There is another CustomEvent interface in CourseCalendarEvent and they are slightly different. The this one represents only one day, like the event on Monday, and needs to be duplicated to be repeated across multiple days. The other one, `CustomEventDialog`'s `RepeatingCustomEvent`, encapsulates the occurrences of an event on multiple days, like Monday Tuesday Wednesday all in the same object as specified by the `days` array. * https://github.com/icssc/AntAlmanac/wiki/The-Great-AntAlmanac-TypeScript-Rewritening%E2%84%A2#duplicate-interface-names-%EF%B8%8F */ export interface CustomEvent extends CommonCalendarEvent { @@ -139,7 +137,7 @@ export type CalendarEvent = CourseEvent | CustomEvent; interface CourseCalendarEventProps { classes: ClassNameMap; - courseInMoreInfo: CalendarEvent; + selectedEvent: CalendarEvent; scheduleNames: string[]; closePopover: () => void; } @@ -172,10 +170,10 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { setActiveTab(2); }, [setActiveTab]); - const { classes, courseInMoreInfo } = props; + const { classes, selectedEvent } = props; - if (!courseInMoreInfo.isCustomEvent) { - const { term, instructors, sectionCode, title, finalExam, locations, sectionType } = courseInMoreInfo; + if (!selectedEvent.isCustomEvent) { + const { term, instructors, sectionCode, title, finalExam, locations, sectionType } = selectedEvent; let finalExamString = ''; @@ -269,10 +267,10 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { Color @@ -282,7 +280,7 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { ); } else { - const { title, customEventID, building } = courseInMoreInfo; + const { title, customEventID, building } = selectedEvent; return (
{title}
@@ -301,9 +299,9 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
diff --git a/apps/antalmanac/src/components/Calendar/toolbar/CalendarToolbar.tsx b/apps/antalmanac/src/components/Calendar/toolbar/CalendarToolbar.tsx new file mode 100644 index 000000000..2a147c429 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/CalendarToolbar.tsx @@ -0,0 +1,121 @@ +import { Undo as UndoIcon } from '@mui/icons-material'; +import { Box, Button, IconButton, Paper, Tooltip } from '@mui/material'; +import { useState, useCallback, useEffect, memo } from 'react'; + +import { undoDelete } from '$actions/AppStoreActions'; +import CustomEventDialog from '$components/Calendar/toolbar/CustomEventDialog'; +import { SelectSchedulePopover } from '$components/Calendar/toolbar/ScheduleSelect/ScheduleSelect'; +import { ClearScheduleButton } from '$components/buttons/Clear'; +import DownloadButton from '$components/buttons/Download'; +import ScreenshotButton from '$components/buttons/Screenshot'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import AppStore from '$stores/AppStore'; + +function handleUndo() { + logAnalytics({ + category: analyticsEnum.calendar.title, + action: analyticsEnum.calendar.actions.UNDO, + }); + undoDelete(null); +} + +export interface CalendarPaneToolbarProps { + scheduleNames: string[]; + currentScheduleIndex: number; + showFinalsSchedule: boolean; + toggleDisplayFinalsSchedule: () => void; +} + +/** + * The root toolbar will pass down the schedule names to its children. + */ +export const CalendarToolbar = memo((props: CalendarPaneToolbarProps) => { + const { showFinalsSchedule, toggleDisplayFinalsSchedule } = props; + const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); + const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); + const [skeletonScheduleNames, setSkeletonScheduleNames] = useState(AppStore.getSkeletonScheduleNames()); + + const handleToggleFinals = useCallback(() => { + logAnalytics({ + category: analyticsEnum.calendar.title, + action: analyticsEnum.calendar.actions.DISPLAY_FINALS, + }); + toggleDisplayFinalsSchedule(); + }, [toggleDisplayFinalsSchedule]); + + const handleScheduleNamesChange = useCallback(() => { + setScheduleNames(AppStore.getScheduleNames()); + }, []); + + useEffect(() => { + const handleSkeletonModeChange = () => { + setSkeletonMode(AppStore.getSkeletonMode()); + setSkeletonScheduleNames(AppStore.getSkeletonScheduleNames()); + }; + + AppStore.on('skeletonModeChange', handleSkeletonModeChange); + + return () => { + AppStore.off('skeletonModeChange', handleSkeletonModeChange); + }; + }, []); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +CalendarToolbar.displayName = 'CalendarToolbar'; diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx b/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/CustomEventDialog.tsx similarity index 100% rename from apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx rename to apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/CustomEventDialog.tsx diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/DaySelector.tsx b/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/DaySelector.tsx similarity index 100% rename from apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/DaySelector.tsx rename to apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/DaySelector.tsx diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/ScheduleSelector.tsx b/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/ScheduleSelector.tsx similarity index 100% rename from apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/ScheduleSelector.tsx rename to apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/ScheduleSelector.tsx diff --git a/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/index.ts b/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/index.ts new file mode 100644 index 000000000..16036f1ec --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/CustomEventDialog/index.ts @@ -0,0 +1 @@ +export { default } from './CustomEventDialog'; diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx b/apps/antalmanac/src/components/Calendar/toolbar/EditSchedule/DeleteScheduleDialog.tsx similarity index 100% rename from apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx rename to apps/antalmanac/src/components/Calendar/toolbar/EditSchedule/DeleteScheduleDialog.tsx diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx b/apps/antalmanac/src/components/Calendar/toolbar/EditSchedule/ScheduleNameDialog.tsx similarity index 100% rename from apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx rename to apps/antalmanac/src/components/Calendar/toolbar/EditSchedule/ScheduleNameDialog.tsx diff --git a/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/ScheduleSelect.tsx b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/ScheduleSelect.tsx new file mode 100644 index 000000000..6cdbf3fa5 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/ScheduleSelect.tsx @@ -0,0 +1,147 @@ +import { ArrowDropDown as ArrowDropDownIcon } from '@mui/icons-material'; +import { Box, Button, Popover, Typography, useTheme } from '@mui/material'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { changeCurrentSchedule } from '$actions/AppStoreActions'; +import { AddScheduleButton } from '$components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/AddScheduleButton'; +import { DeleteScheduleButton } from '$components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/DeleteScheduleButton'; +import { RenameScheduleButton } from '$components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/RenameScheduleButton'; +import { CopyScheduleButton } from '$components/buttons/Copy'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import AppStore from '$stores/AppStore'; + +function handleScheduleChange(index: number) { + logAnalytics({ + category: analyticsEnum.calendar.title, + action: analyticsEnum.calendar.actions.CHANGE_SCHEDULE, + }); + changeCurrentSchedule(index); +} + +/** + * Creates an event handler callback that will change the current schedule to the one at a specified index. + */ +function createScheduleSelector(index: number) { + return () => { + handleScheduleChange(index); + }; +} + +/** + * Simulates an HTML select element using a popover. + * + * Can select a schedule, and also control schedule settings with buttons. + */ +export function SelectSchedulePopover(props: { scheduleNames: string[] }) { + const theme = useTheme(); + + const [currentScheduleIndex, setCurrentScheduleIndex] = useState(() => AppStore.getCurrentScheduleIndex()); + const [skeletonMode, setSkeletonMode] = useState(() => AppStore.getSkeletonMode()); + const [anchorEl, setAnchorEl] = useState(); + + // TODO: maybe these widths should be dynamic based on i.e. the viewport width? + const minWidth = useMemo(() => 100, []); + const maxWidth = useMemo(() => 150, []); + + const open = useMemo(() => Boolean(anchorEl), [anchorEl]); + + const currentScheduleName = useMemo(() => { + return props.scheduleNames[currentScheduleIndex]; + }, [props.scheduleNames, currentScheduleIndex]); + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(undefined); + }, []); + + const handleScheduleIndexChange = useCallback(() => { + setCurrentScheduleIndex(AppStore.getCurrentScheduleIndex()); + }, []); + + const handleSkeletonModeChange = () => { + setSkeletonMode(AppStore.getSkeletonMode()); + }; + + useEffect(() => { + AppStore.on('addedCoursesChange', handleScheduleIndexChange); + AppStore.on('customEventsChange', handleScheduleIndexChange); + AppStore.on('colorChange', handleScheduleIndexChange); + AppStore.on('currentScheduleIndexChange', handleScheduleIndexChange); + AppStore.on('skeletonModeChange', handleSkeletonModeChange); + + return () => { + AppStore.off('addedCoursesChange', handleScheduleIndexChange); + AppStore.off('customEventsChange', handleScheduleIndexChange); + AppStore.off('colorChange', handleScheduleIndexChange); + AppStore.off('currentScheduleIndexChange', handleScheduleIndexChange); + AppStore.off('skeletonModeChange', handleSkeletonModeChange); + }; + }, [handleScheduleIndexChange]); + + return ( + + + + + + {props.scheduleNames.map((name, index) => ( + + + + + + + + + + + ))} + + + + + + + + ); +} diff --git a/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/AddScheduleButton.tsx b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/AddScheduleButton.tsx new file mode 100644 index 000000000..887adbcc4 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/AddScheduleButton.tsx @@ -0,0 +1,36 @@ +import { Add as AddIcon } from '@mui/icons-material'; +import { Box, Button, Typography } from '@mui/material'; +import { useCallback, useState } from 'react'; + +import AddScheduleDialog from '$components/dialogs/AddSchedule'; + +interface AddScheduleButtonProps { + disabled: boolean; +} + +/** + * MenuItem nested in the select menu to add a new schedule through a dialog. + */ +export function AddScheduleButton({ disabled }: AddScheduleButtonProps) { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + ); +} diff --git a/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/DeleteScheduleButton.tsx b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/DeleteScheduleButton.tsx new file mode 100644 index 000000000..9b67f8796 --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/DeleteScheduleButton.tsx @@ -0,0 +1,40 @@ +import { Clear as ClearIcon } from '@mui/icons-material'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import { useCallback, useState } from 'react'; + +import DeleteScheduleDialog from '$components/dialogs/DeleteSchedule'; +import AppStore from '$stores/AppStore'; + +interface DeleteScheduleButtonProps { + index: number; + disabled?: boolean; +} + +export function DeleteScheduleButton({ index, disabled }: DeleteScheduleButtonProps) { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + + + + + ); +} diff --git a/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/RenameScheduleButton.tsx b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/RenameScheduleButton.tsx new file mode 100644 index 000000000..ac69be9ec --- /dev/null +++ b/apps/antalmanac/src/components/Calendar/toolbar/ScheduleSelect/schedule-select-buttons/RenameScheduleButton.tsx @@ -0,0 +1,35 @@ +import { Edit as EditIcon } from '@mui/icons-material'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import { useCallback, useState } from 'react'; + +import RenameScheduleDialog from '$components/dialogs/RenameSchedule'; + +interface RenameScheduleButtonProps { + index: number; + disabled?: boolean; +} + +export function RenameScheduleButton({ index, disabled }: RenameScheduleButtonProps) { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + + + + + ); +} diff --git a/apps/antalmanac/src/components/ColorPicker.tsx b/apps/antalmanac/src/components/ColorPicker.tsx index 2a53f4d57..764758a85 100644 --- a/apps/antalmanac/src/components/ColorPicker.tsx +++ b/apps/antalmanac/src/components/ColorPicker.tsx @@ -81,7 +81,7 @@ class ColorPicker extends PureComponent { <> { this.handleClick(e); }} diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx index 6e8987d99..3c6d29201 100644 --- a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx +++ b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx @@ -5,10 +5,10 @@ import moment from 'moment'; import { useEffect, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import CustomEventDialog from '../../Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; import ColorPicker from '../../ColorPicker'; import { deleteCustomEvent } from '$actions/AppStoreActions'; +import CustomEventDialog from '$components/Calendar/toolbar/CustomEventDialog/'; import analyticsEnum from '$lib/analytics'; import buildingCatalogue from '$lib/buildingCatalogue'; import AppStore from '$stores/AppStore'; diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index d539c5006..2f71592d3 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -185,7 +185,7 @@ export default function CourseRenderPane(props: { id?: number }) { const [error, setError] = useState(false); const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); - const setHoveredEvents = useHoveredStore((store) => store.setHoveredEvents); + const setHoveredEvent = useHoveredStore((store) => store.setHoveredEvent); const loadCourses = useCallback(async () => { setLoading(true); @@ -276,9 +276,9 @@ export default function CourseRenderPane(props: { id?: number }) { */ useEffect(() => { return () => { - setHoveredEvents(undefined); + setHoveredEvent(undefined); }; - }, [setHoveredEvents]); + }, [setHoveredEvent]); return ( <> diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index f2e7c159b..026440098 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -2,7 +2,6 @@ import { Box, Paper, Table, - TableBody, TableCell, TableContainer, TableHead, @@ -21,8 +20,8 @@ import CourseInfoButton from './CourseInfoButton'; import { EnrollmentHistoryPopup } from './EnrollmentHistoryPopup'; import GradesPopup from './GradesPopup'; import { SectionTableProps } from './SectionTable.types'; -import SectionTableBody from './SectionTableBody'; +import { SectionTableBody } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBody'; import analyticsEnum from '$lib/analytics'; import { useColumnStore, SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore'; @@ -196,20 +195,12 @@ function SectionTable(props: SectionTableProps) { - - {courseDetails.sections.map((section) => { - return ( - - ); - })} - + diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx new file mode 100644 index 000000000..dcee7a92e --- /dev/null +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -0,0 +1,94 @@ +import { TableBody } from '@material-ui/core'; +import { AACourse, AASection } from '@packages/antalmanac-types'; +import { useCallback, useEffect, useState } from 'react'; + +import SectionTableBodyRow from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow'; +import AppStore from '$stores/AppStore'; +import { normalizeTime, parseDaysString } from '$stores/calendarizeHelpers'; + +interface SectionTableBodyProps { + courseDetails: AACourse; + term: string; + scheduleNames: string[]; + allowHighlight: boolean; +} + +export function SectionTableBody({ courseDetails, term, scheduleNames, allowHighlight }: SectionTableBodyProps) { + const [calendarEvents, setCalendarEvents] = useState(() => AppStore.getCourseEventsInCalendar()); + + /** + * Additional information about the current section being rendered. + * i.e. time information, which is compared with the calendar events to find conflicts. + */ + const parseSectionDetails = useCallback((section: AASection) => { + return { + daysOccurring: parseDaysString(section.meetings[0].timeIsTBA ? null : section.meetings[0].days), + ...normalizeTime(section.meetings[0]), + }; + }, []); + + /** + * Determine if the current section conflicts with any of the calendar events. + */ + const scheduleConflict = useCallback( + (section: AASection) => { + const sectionDetails = parseSectionDetails(section); + + // If no calendar events exist, no conflicts can occur. + if (calendarEvents.length === 0) return false; + + // If the section's time wasn't parseable, don't consider conflicts. + const { startTime, endTime, daysOccurring } = sectionDetails; + if (!startTime || !endTime) return false; + + // Check for conflicting events + return calendarEvents.some((event) => { + // Check if the event happens on overlapping days + if (!daysOccurring?.includes(event.start.getDay())) return false; + + const eventStartTime = event.start.toTimeString().slice(0, 5); // HH:mm + const eventEndTime = event.end.toTimeString().slice(0, 5); // HH:mm + + const happensBefore = endTime <= eventStartTime; + const happensAfter = startTime >= eventEndTime; + + return !(happensBefore || happensAfter); // Overlaps if neither before nor after + }); + }, + [calendarEvents, parseSectionDetails] + ); + + const updateCalendarEvents = useCallback(() => { + setCalendarEvents(AppStore.getCourseEventsInCalendar()); + }, [setCalendarEvents]); + + useEffect(() => { + AppStore.on('addedCoursesChange', updateCalendarEvents); + AppStore.on('currentScheduleIndexChange', updateCalendarEvents); + + return () => { + AppStore.removeListener('addedCoursesChange', updateCalendarEvents); + AppStore.removeListener('currentScheduleIndexChange', updateCalendarEvents); + }; + }, [updateCalendarEvents]); + + return ( + + {courseDetails.sections.map((section) => { + const conflict = scheduleConflict(section); + + return ( + + ); + })} + + ); +} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx similarity index 80% rename from apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx rename to apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx index 85b5e15ba..fad74aa3b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx @@ -14,15 +14,14 @@ import { withStyles } from '@material-ui/core/styles'; import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles'; import { AASection, WebsocSectionEnrollment, WebsocSectionMeeting, CourseDetails } from '@packages/antalmanac-types'; import classNames from 'classnames'; -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { MOBILE_BREAKPOINT } from '../../../globals'; - -import GradesPopup from './GradesPopup'; -import { OpenSpotAlertPopoverProps } from './OpenSpotAlertPopover'; -import { SectionActionCell } from './cells/action'; -import restrictionsMapping from './static/restrictionsMapping.json'; +import { MOBILE_BREAKPOINT } from '../../../../globals'; +import GradesPopup from '../GradesPopup'; +import { OpenSpotAlertPopoverProps } from '../OpenSpotAlertPopover'; +import { SectionActionCell } from '../cells/action'; +import restrictionsMapping from '../static/restrictionsMapping.json'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; import { Grades } from '$lib/grades'; @@ -33,7 +32,7 @@ import { useColumnStore, type SectionTableColumn } from '$stores/ColumnStore'; import { useHoveredStore } from '$stores/HoveredStore'; import { usePreviewStore, useTimeFormatStore, useThemeStore } from '$stores/SettingsStore'; import { useTabStore } from '$stores/TabStore'; -import { normalizeTime, parseDaysString, formatTimes } from '$stores/calendarizeHelpers'; +import { formatTimes } from '$stores/calendarizeHelpers'; const styles: Styles = (theme) => ({ sectionCode: { @@ -476,6 +475,7 @@ interface SectionTableBodyProps { term: string; allowHighlight: boolean; scheduleNames: string[]; + scheduleConflict: boolean; } // These components have too varied of types, any is fine here @@ -493,53 +493,35 @@ const tableBodyCells: Record> = { status: StatusCell, }; -const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { - const { classes, section, courseDetails, term, allowHighlight, scheduleNames } = props; +const SectionTableBodyRow = memo((props: SectionTableBodyProps) => { + const { classes, section, courseDetails, term, allowHighlight, scheduleNames, scheduleConflict } = props; const isDark = useThemeStore((store) => store.isDark); const activeColumns = useColumnStore((store) => store.activeColumns); const previewMode = usePreviewStore((store) => store.previewMode); + const setHoveredEvent = useHoveredStore((store) => store.setHoveredEvent); const [addedCourse, setAddedCourse] = useState( AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`) ); - const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar()); - - /** - * Additional information about the current section being rendered. - * i.e. time information, which is compared with the calendar events to find conflicts. - */ - const sectionDetails = useMemo(() => { - return { - daysOccurring: parseDaysString(section.meetings[0].timeIsTBA ? null : section.meetings[0].days), - ...normalizeTime(section.meetings[0]), - }; - }, [section.meetings]); - // Stable references to event listeners will synchronize React state with the store. const updateHighlight = useCallback(() => { setAddedCourse(AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`)); }, [section.sectionCode, term]); - const updateCalendarEvents = useCallback(() => { - setCalendarEvents(AppStore.getCourseEventsInCalendar()); - }, [setCalendarEvents]); - - const [hoveredEvents, setHoveredEvents] = useHoveredStore((store) => [store.hoveredEvents, store.setHoveredEvents]); - - const alreadyHovered = useMemo(() => { - return hoveredEvents?.some((scheduleCourse) => scheduleCourse.section.sectionCode == section.sectionCode); - }, [hoveredEvents, section.sectionCode]); - - const handleHover = useCallback(() => { - if (!previewMode || alreadyHovered || addedCourse) { - setHoveredEvents(undefined); + const handleMouseEnter = useCallback(() => { + if (!previewMode || addedCourse) { + setHoveredEvent(undefined); } else { - setHoveredEvents(section, courseDetails, term); + setHoveredEvent(section, courseDetails, term); } - }, [previewMode, alreadyHovered, addedCourse, setHoveredEvents, section, courseDetails, term]); + }, [previewMode, addedCourse, setHoveredEvent, section, courseDetails, term]); + + const handleMouseLeave = useCallback(() => { + setHoveredEvent(undefined); + }, [setHoveredEvent]); // Attach event listeners to the store. useEffect(() => { @@ -552,81 +534,37 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { }; }, [updateHighlight]); - useEffect(() => { - AppStore.on('addedCoursesChange', updateCalendarEvents); - AppStore.on('currentScheduleIndexChange', updateCalendarEvents); - - return () => { - AppStore.removeListener('addedCoursesChange', updateCalendarEvents); - AppStore.removeListener('currentScheduleIndexChange', updateCalendarEvents); - }; - }, [updateCalendarEvents]); - - /** - * Whether the current section conflicts with any of the calendar events. - */ - const scheduleConflict = useMemo(() => { - // If there are currently no calendar events, there can't be any conflicts. - if (calendarEvents.length === 0) { - return false; + const computedRowStyle = useMemo(() => { + /* allowHighlight is always false on CourseRenderPane and always true on AddedCoursePane */ + const computedAddedCourseStyle = allowHighlight + ? isDark + ? { background: '#b0b04f' } + : { background: '#fcfc97' } + : {}; + const computedScheduleConflictStyle = scheduleConflict + ? isDark + ? { background: '#121212', opacity: '0.6' } + : { background: '#a0a0a0', opacity: '1' } + : {}; + + if (addedCourse) { + return computedAddedCourseStyle; } - // If the section's time wasn't parseable, then don't consider conflicts. - if (sectionDetails.startTime == null || sectionDetails.endTime == null) { - return false; + if (scheduleConflict) { + return computedScheduleConflictStyle; } - const { startTime, endTime } = sectionDetails; - - const conflictingEvent = calendarEvents.find((event) => { - // If it occurs on a different day, no conflict. - if (!sectionDetails?.daysOccurring?.includes(event.start.getDay())) { - return false; - } - - /** - * A time normalized to ##:## - * @example '10:00' - */ - const eventStartTime = event.start.toString().split(' ')[4].slice(0, -3); - - /** - * Normalized to ##:## - * @example '10:00' - */ - const eventEndTime = event.end.toString().split(' ')[4].slice(0, -3); - - const happensBefore = startTime <= eventStartTime && endTime <= eventStartTime; - - const happensAfter = startTime >= eventEndTime && endTime >= eventEndTime; - - return !(happensBefore || happensAfter); - }); - - return Boolean(conflictingEvent); - }, [calendarEvents, sectionDetails]); - - /* allowHighlight is always false on CourseRenderPane and always true on AddedCoursePane */ - const computedAddedCourseStyle = allowHighlight - ? isDark - ? { background: '#b0b04f' } - : { background: '#fcfc97' } - : {}; - const computedScheduleConflictStyle = scheduleConflict - ? isDark - ? { background: '#121212', opacity: '0.6' } - : { background: '#a0a0a0', opacity: '1' } - : {}; - - const computedRowStyle = addedCourse ? computedAddedCourseStyle : computedScheduleConflictStyle; + return {}; + }, [allowHighlight, isDark, scheduleConflict, addedCourse]); return ( {Object.entries(tableBodyCells) .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) @@ -653,4 +591,6 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { ); }); -export default withStyles(styles)(SectionTableBody); +SectionTableBodyRow.displayName = 'SectionTableBodyRow'; + +export default withStyles(styles)(SectionTableBodyRow); diff --git a/apps/antalmanac/src/components/SharedRoot.tsx b/apps/antalmanac/src/components/SharedRoot.tsx index fa23ff437..e8a647ed7 100644 --- a/apps/antalmanac/src/components/SharedRoot.tsx +++ b/apps/antalmanac/src/components/SharedRoot.tsx @@ -3,7 +3,7 @@ import { GlobalStyles, Paper, Stack, Tab, Tabs, Typography, useMediaQuery, useTh import { Suspense, lazy, useEffect, useRef, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; -import Calendar from './Calendar/CalendarRoot'; +import { ScheduleCalendar } from './Calendar/CalendarRoot'; import AddedCoursePane from './RightPane/AddedCourses/AddedCoursePane'; import darkModeLoadingGif from './RightPane/CoursePane/SearchForm/Gifs/dark-loading.gif'; import loadingGif from './RightPane/CoursePane/SearchForm/Gifs/loading.gif'; @@ -163,13 +163,13 @@ function ScheduleManagementDesktopTabs(props: ScheduleManagementTabsProps) { } function ScheduleManagementTabsContent(props: { activeTab: number; isMobile: boolean }) { - const { activeTab, isMobile } = props; + const { activeTab } = props; const isDark = useThemeStore((store) => store.isDark); switch (activeTab) { case 0: - return ; + return ; case 1: return ; case 2: @@ -241,7 +241,7 @@ export default function ScheduleManagement() { } else { setActiveTab(1); } - }, []); + }, [setActiveTab]); // Handle tab index for mobile screens. useEffect(() => { @@ -254,7 +254,7 @@ export default function ScheduleManagement() { if (activeTab == 0) { setActiveTab(1); } - }, [activeTab, isMobile, tab]); + }, [activeTab, isMobile, setActiveTab, tab]); // Restore scroll position if it has been previously saved. useEffect(() => { @@ -278,7 +278,7 @@ export default function ScheduleManagement() { }, [activeTab, positions]); if (activeTab === 0 && !isMobile) { - return ; + return ; } return ( diff --git a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx index a365413d3..aca9c387a 100644 --- a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx @@ -38,7 +38,7 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { const handleCancel = useCallback(() => { onClose?.({}, 'escapeKeyDown'); - }, [onClose, index]); + }, [onClose]); const handleNameChange = useCallback((event: React.ChangeEvent) => { setName(event.target.value); diff --git a/apps/antalmanac/src/index.tsx b/apps/antalmanac/src/index.tsx index 46cad7369..99a9da6cc 100644 --- a/apps/antalmanac/src/index.tsx +++ b/apps/antalmanac/src/index.tsx @@ -18,6 +18,12 @@ async function main() { throw new Error(`Please create an element with id ${rootId}`); } + if (import.meta.env.DEV) { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/react-scan/dist/auto.global.js'; + document.head.appendChild(script); + } + createRoot(root).render(); } diff --git a/apps/antalmanac/src/routes/Home.tsx b/apps/antalmanac/src/routes/Home.tsx index c3f1b9b91..e326eef86 100644 --- a/apps/antalmanac/src/routes/Home.tsx +++ b/apps/antalmanac/src/routes/Home.tsx @@ -4,7 +4,7 @@ import { CssBaseline, useMediaQuery, useTheme, Stack } from '@mui/material'; import { useCallback, useEffect, useRef } from 'react'; import Split from 'react-split'; -import Calendar from '$components/Calendar/CalendarRoot'; +import { ScheduleCalendar } from '$components/Calendar/CalendarRoot'; import Header from '$components/Header'; import NotificationSnackbar from '$components/NotificationSnackbar'; import PatchNotes from '$components/PatchNotes'; @@ -70,7 +70,7 @@ function DesktopHome() { onDrag={handleDrag} > - + diff --git a/apps/antalmanac/src/stores/HoveredStore.ts b/apps/antalmanac/src/stores/HoveredStore.ts index 0b1980ed8..a5cc3302a 100644 --- a/apps/antalmanac/src/stores/HoveredStore.ts +++ b/apps/antalmanac/src/stores/HoveredStore.ts @@ -7,14 +7,14 @@ import { CourseEvent } from '$components/Calendar/CourseCalendarEvent'; const HOVERED_SECTION_COLOR = '#80808080'; export interface HoveredStore { - hoveredEvents: ScheduleCourse[] | undefined; - setHoveredEvents: (section?: AASection, courseDetails?: CourseDetails, term?: string) => void; + hoveredEvent: ScheduleCourse | undefined; + setHoveredEvent: (section?: AASection, courseDetails?: CourseDetails, term?: string) => void; hoveredCalendarizedCourses: CourseEvent[] | undefined; hoveredCalendarizedFinal: CourseEvent | undefined; } const DEFAULT_HOVERED_STORE = { - hoveredEvents: undefined, + hoveredEvent: undefined, hoveredCalendarizedCourses: undefined, hoveredCalendarizedFinal: undefined, }; @@ -22,42 +22,25 @@ const DEFAULT_HOVERED_STORE = { export const useHoveredStore = create((set) => { return { ...DEFAULT_HOVERED_STORE, - setHoveredEvents: (section, courseDetails, term) => { + setHoveredEvent: (section, courseDetails, term) => { if (section == null || courseDetails == null || term == null) { set({ ...DEFAULT_HOVERED_STORE }); return; } + + const event = { + ...courseDetails, + section: { + ...section, + color: HOVERED_SECTION_COLOR, + }, + term, + }; + set({ - hoveredEvents: [ - { - ...courseDetails, - section: { - ...section, - color: HOVERED_SECTION_COLOR, - }, - term, - }, - ], - hoveredCalendarizedCourses: calendarizeCourseEvents([ - { - ...courseDetails, - section: { - ...section, - color: HOVERED_SECTION_COLOR, - }, - term, - }, - ]), - hoveredCalendarizedFinal: calendarizeFinals([ - { - ...courseDetails, - section: { - ...section, - color: HOVERED_SECTION_COLOR, - }, - term, - }, - ])[0], + hoveredEvent: event, + hoveredCalendarizedCourses: calendarizeCourseEvents([event]), + hoveredCalendarizedFinal: calendarizeFinals([event])[0], }); }, }; diff --git a/apps/antalmanac/src/stores/SelectedEventStore.ts b/apps/antalmanac/src/stores/SelectedEventStore.ts new file mode 100644 index 000000000..8f779d20f --- /dev/null +++ b/apps/antalmanac/src/stores/SelectedEventStore.ts @@ -0,0 +1,23 @@ +import { SyntheticEvent } from 'react'; +import { create } from 'zustand'; + +import { CalendarEvent } from '$components/Calendar/CourseCalendarEvent'; + +export interface SelectedEventStore { + selectedEvent: CalendarEvent | null; + selectedEventAnchorEl: Element | null; + setSelectedEvent: (anchorEl: SyntheticEvent | null, selectedEvent: CalendarEvent | null) => void; +} + +export const useSelectedEventStore = create((set) => { + return { + selectedEvent: null, + selectedEventAnchorEl: null, + setSelectedEvent: (anchorEl: SyntheticEvent | null, selectedEvent: CalendarEvent | null) => { + set({ + selectedEvent: selectedEvent, + selectedEventAnchorEl: anchorEl?.currentTarget, + }); + }, + }; +}); diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts index 7dc2280f0..5f98f0213 100644 --- a/apps/antalmanac/src/stores/calendarizeHelpers.ts +++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts @@ -18,7 +18,7 @@ export function getLocation(location: string): Location { return { building, room }; } -export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { +export const calendarizeCourseEvents = (currentCourses: ScheduleCourse[] = []): CourseEvent[] => { return currentCourses.flatMap((course) => { return course.section.meetings .filter((meeting) => !meeting.timeIsTBA) @@ -80,7 +80,7 @@ export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): }); }); }); -} +}; export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { return currentCourses