From b974aff913f9f7d77292a04860204e11763bf2ee Mon Sep 17 00:00:00 2001 From: Shaam <109339363+Shzmj@users.noreply.github.com> Date: Tue, 14 May 2024 22:08:32 +1000 Subject: [PATCH] Feature/ntgl 404 persisting prev data (#792) * Changed the default storage object to include a timetable array and added a useState for selected Timetable index * Create handlers for changing and deleting timetables * fix: updateTimetableEvents now fetches current timetable properly * Fixed main idea for deleting timetables. Created timetables component to automatically rerender * Fixed multiple timetables functionality Fixed multiple timetables functionality + fixed issue with not allowing user to select classes * refactor(client): NTGL-384 Move logic of timetable tabs to separate file * feat(client): NTGL-384 Initial creation of styled-components for timetable tabs bar * fix(client): NTGL-384 Change add timetable click handler to work on entire tab instead of just the icon * style(client): NTGL-384 Add shadow and border styling for tabs * Added timetable renaming functionality * feat(client): NTGL-384 Add delete modal to menu * fix(client): NTGL-384 Fix bug where displayTimetables[selectedTimetable].name would throw an error * Fixed deleting timetables functionality * Fixed issue with timetable data being passed to other timetables * Fixed bugs with deleting and adding new timetables * Added error checking to timetable rename and changed style * Added brief documentation * Changed timetable rename dialog style * Added tab duplication, themes, and improved speed of switching between tabs * feat(client): NTGL-384 Add alert for adding or copying a timetable when at timetable limit * feat(client): NTGL-384 Add right click tab handler to switch tabs and bring up menu * Added hotkeys to quick delete timetables * Fixed bugs with enter keybind * Added hotkey shortcut for creating new timetables * Fixed duplicate timetables moving both events * Fixed deep clone issue with duplicate timetables * Added drag and drop to sort timetable tabs + fixed some styling with tabs * Added hotkey to allow pressing enter to confirm timetable rename * Forgot to push the timetabledata interface * Added tool tips for adding and deleting timetable tabs * fixed scrolling on timetable tabs, add timetable button sticks to scroll bar for ease of access * fixed animation issue with custom events and switching between duplicate timetables * feat(client): NTGL-384 Add core history functionality to support multiple timetables (WIP) * feat(client): NTGL-384 Add modal supporting clearing current and all timetables, improvements to history logic * style(client): NTGL-384 connect tab nav bar to timetable * made timetable tabs similar to google tabs interface (changed border radius) * Minor changes to tab UI and tab clearing feature * Attempt at fixing history.ts for multiple timetables * feat(client): NTGL-384 Add undo alert after deleting timetable * feat(client): NTGL-384 Change history.ts to use hashmap/object instead of 2D array * NTGL-384 Generate timetableId function checks for collisions with displayTimetables, duplicated timetables now given ids * NTGL 384 Undo/redo works with timetable renaming * fix(client): NTGL-384 Rearranging timetable will successfully trigger save to local storage * Changed new tab button look * fix(client): NTGL-384 resetAll resets timetables to default * feat(client): NTGL-384 Re-wrote defaultReset logic to disable buttons on modal, and handle clearOne/clearAll seperately * style/refactor(client): Cleaned up code, move tab styling to a separate file * style(client): Minor style changes to history.ts * Moved timetable tab popups to separate file + tidied up some of the logic for timetable tabs * Fixed styling of rename timetable popup modal * Updated the delete timetable popup modal * Updated the style of clear timetables modal * Ran prettier on files * Context menu for rename, duplicate, delete is consistent with events. Got rid of inline styling for timetableTabs. Still yet to get rid of inline styling in the EditTabPopups. Fixed bug from context menu, now changes color when changing from light to dark mode * Changed EditTabPopups component to TimetableTabContextMenu for consistency with events * Tidied up rename timetable dialog * Refactored the repeated code for timetable states * made dialog for delete timetable consistent with discard changes for events * Made dialog for clear timetable(s) consistent with discard changes to event info * Cleaned up code and fixed tooltips and keyboard shortcuts for context menu * Added snackbar alert message for restoring deleted timetable - need to fix up the colour scheme for this * Fixed colour styling for restore timetable snackbar alert * Minor changes to restoreTimetable action component to be consistent with dialog messages * NTGL-384 refactor(client): Move/create helper functions related to creating timetables/history, simplify logic in history.ts * NTGL-384 refactor(client): minor changes to TimetableTab (move event duplicate function to utils) and History (fix logic) * NTGL-384 bugfix(client): fixed bug when history.ts creates a new timetableActions array for timetable, and added back disabling reset button functionality * Added styling to delete timetable button, fixed behaviour and bugs with undo/redo buttons * Edited padding of ExpandedClassView, ExpandedEventView and Rename timetable dialog * fix(client): NTGL-384 timetable time bounds properly reset upon timetable switching * fix(client): NTGL-384 timetable tab and course select wrapper aligned with timetable layout * fix(client): NTGL-384 close the edit timetable menu when they click out of edit timetable name * fix(client): NTGL-384 Location no longer a necessary field for CustomEvent creation/editing * Created CourseData object in defaults.ts * changed courseData object structure * feat: NTGL-402 Added color picker menu to ExpandedClassView, users can select colors for courses * added subtype property * disabled dragging of tutorial and laboratory classes * disabled edit on tutoring class cards * disabled edit icon on tutoring class card * changed context menu of tutoring card to only have a delete option * increasing timeout value for recieving course info * Added basic layout for switching between terms * Added basic implementation for storage of term data for switching terms * feat: colors for courses are independent for each timetable. Color choices are stored locally. * feat: duplicating timetables does not break custom colors * feat: History stores the assigned colors when reversing deletion of a course * resolved conflicts * disabled event link in tutoring and lab events * testing: add debugging code to App.tsx * fixed usage of createEventObj in timetableHelpers.ts * removed edit icon tutoring event * fixed styling * fixed syntax errors from merge * Added todos for jeremy, brainstormed/drafted mock behaviour for switching terms * fix: bug where initially loaded timetable resets to default assigned colors FIXED! * fix: minor changes to history to fix unwanted behaviour * feat: add save button to expanded class view * minor fix: disable save button functionality fix * able to change colors on tutorial card * bugfix: History branching bug fixed - reference to newly branched timetable is properly made * fix: older stored timetables will not break upon updating to assignedColors * created a new TutorialColorPicker file, that is the exact same as ColorPicker except with a Save button * Fixed errors with handling terms n history.ts * changed props of ColorPick to accept Save function and display save button only for tutorial events * fix: increased timeout value for fetching course info + refactored fetchTermData to fix issue with restoring previously saved data * styled colour picker in class cards to be consistent with tutoring classes * changed prop name of Color Picker from handleSaveNewTutorialColor to handleSaveNewColor * Fixed behaviour of preserving prev calendar data * Border outline of select term stays white and aligned, padded component * forcing tabs to only be dragged horizontally * chore: remove CTF artifacts * style: move inline styling of divider and list item into styled components * fix: add subtype to createLinkEvent to silence errors * refactoring and cleaning up code for termSelect * re-ordered terms in term select * removed comment and fixing linting errors * fixed undefined errors with rendering data on first load * testing fix to populate timetable data when moving to multiple terms version * debugged issues with migration to multiple terms version * removed unused imports and variables * NTGL-402 Fix minor styling issues * Fix other issues * style(event share, period): added new line and removed unused styled component * bugfix - colors retained on delete. Also removed code to prevent timetables on assigned color migration * Remove code that supported migration to assignedcolors * resolving comments, improved typing and removed unnecessary data * bugfix: loading timetable colours when switching terms * updated calls to scraper with generic path for fetching previous term data * fix: FIXED OUTLINE DISAPPEARING WHEN FOCUSED * removed redundant styles * updated changelog --------- Co-authored-by: eklavya-joshi Co-authored-by: Michael Siu Co-authored-by: manhualu Co-authored-by: Jasmine Tran Co-authored-by: Jeremy Le --- client/src/App.tsx | 62 ++++++++--- client/src/api/getCourseInfo.ts | 7 +- client/src/components/EventShareModal.tsx | 15 ++- client/src/components/controls/History.tsx | 67 +++++++----- client/src/components/navbar/Changelog.tsx | 4 + client/src/components/navbar/Navbar.tsx | 27 +---- client/src/components/navbar/TermSelect.tsx | 102 ++++++++++++++++++ .../timetableTabs/TimetableTabContextMenu.tsx | 59 ++++++---- .../timetableTabs/TimetableTabs.tsx | 94 ++++++++++------ client/src/constants/defaults.ts | 2 +- client/src/constants/timetable.ts | 91 +++++++++++----- client/src/context/AppContext.tsx | 22 +++- client/src/index.tsx | 2 +- client/src/interfaces/Periods.ts | 14 +++ 14 files changed, 407 insertions(+), 161 deletions(-) create mode 100644 client/src/components/navbar/TermSelect.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index a64c96bc6..2d351eaa5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -34,13 +34,14 @@ import { ClassData, CourseCode, CourseData, + DisplayTimetablesMap, InInventory, SelectedClasses, - TimetableData, } from './interfaces/Periods'; import { setDropzoneRange, useDrag } from './utils/Drag'; import { downloadIcsFile } from './utils/generateICS'; import storage from './utils/storage'; +import { createDefaultTimetable } from './utils/timetableHelpers'; const StyledApp = styled(Box)` height: 100%; @@ -109,6 +110,7 @@ const App: React.FC = () => { firstDayOfTerm, setFirstDayOfTerm, setTermName, + setTermsData, setTermNumber, setCoursesList, setLastUpdated, @@ -163,13 +165,31 @@ const App: React.FC = () => { * Retrieves term data from the scraper backend */ const fetchTermData = async () => { - const termData = await getAvailableTermDetails(); - const { term, termName, termNumber, year, firstDayOfTerm } = termData; + const { term, termName, termNumber, year, firstDayOfTerm, termsData } = await getAvailableTermDetails(); setTerm(term); setTermName(termName); setTermNumber(termNumber); setYear(year); setFirstDayOfTerm(firstDayOfTerm); + setTermsData(termsData); + const oldData = storage.get('timetables'); + + // avoid overwriting data from previous save + const newTimetableTerms: DisplayTimetablesMap = { + ...{ + [termsData.prevTerm.term]: oldData.hasOwnProperty(termsData.prevTerm.term) + ? oldData[termsData.prevTerm.term] + : createDefaultTimetable(), + }, + ...{ + [termsData.newTerm.term]: oldData.hasOwnProperty(termsData.newTerm.term) + ? oldData[termsData.newTerm.term] + : createDefaultTimetable(), + }, + }; + + setDisplayTimetables(newTimetableTerms); + storage.set('timetables', newTimetableTerms); }; fetchReliably(fetchTermData); @@ -186,11 +206,11 @@ const App: React.FC = () => { }; if (year !== invalidYearFormat) fetchReliably(fetchCoursesList); - }, [year]); + }, [term, year]); // Fetching the saved timetables from local storage useEffect(() => { - const savedTimetables: TimetableData[] = storage.get('timetables'); + const savedTimetables: DisplayTimetablesMap = storage.get('timetables'); if (savedTimetables) { setDisplayTimetables(savedTimetables); } @@ -280,7 +300,7 @@ const App: React.FC = () => { const newSelectedCourses = [...selectedCourses]; const newCourseData = courseData; - // Update the existing courses with the new data (for changing timezones). + // Update the existing courses with the new data (for changing timezone). addedCourses.forEach((addedCourse) => { if (newSelectedCourses.find((x) => x.code === addedCourse.code)) { const index = newSelectedCourses.findIndex((x) => x.code === addedCourse.code); @@ -295,11 +315,10 @@ const App: React.FC = () => { newCourseData.map.push(addedCourse); } }); - setSelectedCourses(newSelectedCourses); setCourseData(newCourseData); - if (displayTimetables.length > 0) { + if (displayTimetables[term].length > 0) { setAssignedColors( useColorMapper( newSelectedCourses.map((course) => course.code), @@ -323,7 +342,7 @@ const App: React.FC = () => { setSelectedCourses(newSelectedCourses); const newCourseData = courseData; newCourseData.map = courseData.map.filter(() => { - for (const timetable of displayTimetables) { + for (const timetable of displayTimetables[term]) { for (const course of timetable.selectedCourses) { if (course.code.localeCompare(courseCode)) { return true; @@ -348,11 +367,20 @@ const App: React.FC = () => { * Populate selected courses, classes and created events with the data saved in local storage */ const updateTimetableEvents = () => { + if (!storage.get('timetables')[term]) { + // data stored in local storage not up to date with current term + const updatedWithTerms = { [term]: storage.get('timetables') }; + + storage.set('timetables', updatedWithTerms); + setDisplayTimetables(updatedWithTerms); + } + handleSelectCourse( - storage.get('timetables')[selectedTimetable].selectedCourses.map((course: CourseData) => course.code), + storage.get('timetables')[term][selectedTimetable].selectedCourses.map((course: CourseData) => course.code), true, (newSelectedCourses) => { - const timetableSelectedClasses: SelectedClasses = storage.get('timetables')[selectedTimetable].selectedClasses; + const timetableSelectedClasses: SelectedClasses = + storage.get('timetables')[term][selectedTimetable].selectedClasses; const savedClasses: SavedClasses = {}; @@ -391,8 +419,8 @@ const App: React.FC = () => { setSelectedClasses(newSelectedClasses); }, ); - setCreatedEvents(storage.get('timetables')[selectedTimetable].createdEvents); - setAssignedColors(storage.get('timetables')[selectedTimetable].assignedColors); + setCreatedEvents(storage.get('timetables')[term][selectedTimetable].createdEvents); + setAssignedColors(storage.get('timetables')[term][selectedTimetable].assignedColors); }; useEffect(() => { @@ -401,7 +429,7 @@ const App: React.FC = () => { // The following three useUpdateEffects update local storage whenever a change is made to the timetable useUpdateEffect(() => { - displayTimetables[selectedTimetable].selectedCourses = selectedCourses; + displayTimetables[term][selectedTimetable].selectedCourses = selectedCourses; const newCourseData = courseData; storage.set('courseData', newCourseData); storage.set('timetables', displayTimetables); @@ -409,19 +437,19 @@ const App: React.FC = () => { }, [selectedCourses]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].selectedClasses = selectedClasses; + displayTimetables[term][selectedTimetable].selectedClasses = selectedClasses; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [selectedClasses]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].createdEvents = createdEvents; + displayTimetables[term][selectedTimetable].createdEvents = createdEvents; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [createdEvents]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].assignedColors = assignedColors; + displayTimetables[term][selectedTimetable].assignedColors = assignedColors; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [assignedColors]); diff --git a/client/src/api/getCourseInfo.ts b/client/src/api/getCourseInfo.ts index baf786bd5..3bbda1d92 100644 --- a/client/src/api/getCourseInfo.ts +++ b/client/src/api/getCourseInfo.ts @@ -85,16 +85,17 @@ const getCourseInfo = async ( isConvertToLocalTimezone: boolean, ): Promise => { const baseURL = `${API_URL.timetable}/terms/${year}-${term}`; + const COURSE_API_TIMEOUT = 2000; try { - const data = await timeoutPromise(1000, fetch(`${baseURL}/courses/${courseCode}/`)); + const data = await timeoutPromise(COURSE_API_TIMEOUT, fetch(`${baseURL}/courses/${courseCode}/`)); // Remove any leftover courses from localStorage if they are not offered in the current term // which is why a 400 error is returned if (data.status === 400) { - const selectedCourses = storage.get('timetables')[0].selectedCourses; + const selectedCourses = storage.get('timetables')[term][0].selectedCourses; if (selectedCourses.includes(courseCode)) { delete selectedCourses[courseCode]; - storage.set('timetables[0].selectedCourses', selectedCourses); + storage.set(`timetables[${term}][0].selectedCourses`, selectedCourses); } else { throw new NetworkError('Internal server error'); } diff --git a/client/src/components/EventShareModal.tsx b/client/src/components/EventShareModal.tsx index 14a1fa98c..46c861d09 100644 --- a/client/src/components/EventShareModal.tsx +++ b/client/src/components/EventShareModal.tsx @@ -1,8 +1,10 @@ -import { useState, useContext, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Add, Close, LocationOn } from '@mui/icons-material'; +import { Add, Close } from '@mui/icons-material'; import { Card, CardProps, Dialog, IconButton } from '@mui/material'; import { styled } from '@mui/system'; +import isBase64 from 'is-base64'; +import { useContext, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + import { AppContext } from '../context/AppContext'; import { CourseContext } from '../context/CourseContext'; import { StyledDialogTitle, StyledTitleContainer, StyledTopIcons } from '../styles/ControlStyles'; @@ -10,7 +12,6 @@ import { ExecuteButton, StyledListItemText, StyledLocationIcon } from '../styles import { StyledCardName } from '../styles/DroppedCardStyles'; import { createEventObj } from '../utils/createEvent'; import { resizeWeekArray } from '../utils/eventTimes'; -import isBase64 from 'is-base64'; const PreviewCard = styled(Card)` padding: 24px 16px; @@ -26,12 +27,8 @@ const PreviewCard = styled(Card)` color: white; `; -const StyledLocationOn = styled(LocationOn)` - font-size: 12px; -`; - const EventShareModal = () => { - let { encrypted } = useParams(); + const { encrypted } = useParams(); const navigate = useNavigate(); const [open, setOpen] = useState(true); diff --git a/client/src/components/controls/History.tsx b/client/src/components/controls/History.tsx index 2c878903b..eb967d52e 100644 --- a/client/src/components/controls/History.tsx +++ b/client/src/components/controls/History.tsx @@ -4,7 +4,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import { AppContext } from '../../context/AppContext'; import { CourseContext } from '../../context/CourseContext'; -import { CourseData, CreatedEvents, SelectedClasses, TimetableData } from '../../interfaces/Periods'; +import { CourseData, CreatedEvents, DisplayTimetablesMap, SelectedClasses } from '../../interfaces/Periods'; import { StyledDialogButtons, StyledDialogContent, StyledTitleContainer } from '../../styles/ControlStyles'; import { ActionsPointer, @@ -28,7 +28,7 @@ const History: React.FC = () => { const { selectedCourses, setSelectedCourses, selectedClasses, setSelectedClasses, createdEvents, setCreatedEvents } = useContext(CourseContext); - const { isDrag, setIsDrag, selectedTimetable, setSelectedTimetable, displayTimetables, setDisplayTimetables } = + const { isDrag, setIsDrag, selectedTimetable, setSelectedTimetable, displayTimetables, setDisplayTimetables, term } = useContext(AppContext); const timetableActions = useRef({}); @@ -41,8 +41,8 @@ const History: React.FC = () => { courses: CourseData[], classes: SelectedClasses, events: CreatedEvents, - timetableArg: TimetableData[] | ((prev: TimetableData[]) => void), - selected?: number + timetableArg: DisplayTimetablesMap, + selected?: number, ) => { setSelectedCourses(courses); setSelectedClasses(classes); @@ -59,7 +59,7 @@ const History: React.FC = () => { * @param direction Which way to update (1 for increment, -1 for decrement) */ const incrementActionsPointer = (direction: number) => { - const timetableId = displayTimetables[selectedTimetable].id; + const timetableId = displayTimetables[term][selectedTimetable].id; actionsPointer.current[timetableId] += direction; setDisableLeft(actionsPointer.current[timetableId] < 1); setDisableRight(actionsPointer.current[timetableId] + 1 >= timetableActions.current[timetableId].length); @@ -75,11 +75,16 @@ const History: React.FC = () => { return; // Prevents adding change induced by clicking redo/undo } - if (selectedTimetable >= displayTimetables.length) { + if (selectedTimetable >= displayTimetables[term]?.length) { return; } - const currentTimetable = displayTimetables[selectedTimetable]; + // waiting for timetable data to render + if (!(term in displayTimetables)) { + return; + } + + const currentTimetable = displayTimetables[term][selectedTimetable]; // Create object if it doesn't exist if (!timetableActions.current[currentTimetable.id]) { @@ -99,16 +104,16 @@ const History: React.FC = () => { } timetableActions.current[currentTimetable.id].push({ - name: displayTimetables[selectedTimetable].name, + name: displayTimetables[term][selectedTimetable].name, courses: [...selectedCourses], classes: duplicateClasses(selectedClasses), - events: { ...createdEvents } + events: { ...createdEvents }, }); incrementActionsPointer(1); }, [selectedClasses, isDrag, createdEvents, displayTimetables]); - //Disables reset timetable button when there is no courses, classes and events selected. + // Disables reset timetable button when there is no courses, classes and events selected. useEffect(() => { if (!isMounted.current) { isMounted.current = true; @@ -118,12 +123,12 @@ const History: React.FC = () => { const disableStatus = { current: true, all: true }; // More than one timetable is resetAll-able - if (displayTimetables.length > 1) { + if (displayTimetables[term].length > 1) { disableStatus.all = false; } // Current timetable being non-empty is resetAll and resetOne-able - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; // if new timetable has been created then set reset to be true since no courses, classes or events selected if (actionsPointer.current[currentTimetable.id] < 1) { disableStatus.current = true; @@ -149,11 +154,12 @@ const History: React.FC = () => { }, [selectedTimetable, selectedCourses, selectedClasses, createdEvents, displayTimetables]); useEffect(() => { - if (displayTimetables.length < 1) { + // waiting for timetable data to render with valid term data + if (!(term in displayTimetables) || displayTimetables[term].length < 1) { return; } - const timetableId = displayTimetables[selectedTimetable].id; + const timetableId = displayTimetables[term][selectedTimetable].id; const currentPointer = actionsPointer.current[timetableId]; setDisableLeft(currentPointer === undefined || currentPointer < 1); setDisableRight(currentPointer === undefined || currentPointer + 1 >= timetableActions.current[timetableId].length); @@ -167,24 +173,37 @@ const History: React.FC = () => { incrementActionsPointer(direction); dontAdd.current = true; - const timetableId = displayTimetables[selectedTimetable].id; - const modifyTimetableName = (prev: TimetableData[]) => { - return prev.map((timetable) => { - return timetable.id === timetableId - ? { ...timetable, name: timetableActions.current[timetableId][actionsPointer.current[timetableId]].name } + const modifiedTimetableId = displayTimetables[term][selectedTimetable].id; + const modifiedTimetableName = { + ...displayTimetables, + [term]: displayTimetables[term].map((timetable) => { + return timetable.id === modifiedTimetableId + ? { + ...timetable, + name: timetableActions.current[modifiedTimetableId][actionsPointer.current[modifiedTimetableId]].name, + } : timetable; - }); + }), }; - const { courses, classes, events } = extractHistoryInfo(timetableId, timetableActions.current, actionsPointer.current); - setTimetableState(courses, classes, events, modifyTimetableName); + const { courses, classes, events } = extractHistoryInfo( + modifiedTimetableId, + timetableActions.current, + actionsPointer.current, + ); + + setTimetableState(courses, classes, events, modifiedTimetableName); }; /** * Resets all timetables - leave one as default */ const clearAll = () => { - setTimetableState([], {}, {}, createDefaultTimetable(), 0); + const newDisplayTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: createDefaultTimetable(), + }; + setTimetableState([], {}, {}, newDisplayTimetables, 0); }; /** @@ -195,7 +214,7 @@ const History: React.FC = () => { // event.metaKey corresponds to the Cmd key on Mac if (!(event.ctrlKey || event.metaKey) || !(event.key === 'z' || event.key === 'y' || event.key === 'd')) return; - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; event.preventDefault(); diff --git a/client/src/components/navbar/Changelog.tsx b/client/src/components/navbar/Changelog.tsx index 46d234c98..98a48ec78 100644 --- a/client/src/components/navbar/Changelog.tsx +++ b/client/src/components/navbar/Changelog.tsx @@ -14,6 +14,10 @@ import React from 'react'; type Change = { date: string; changes: string[] }; const changelog: Change[] = [ + { + date: 'May 14, 2024', + changes: ['Users can access data across multiple terms'], + }, { date: 'February 16, 2024', changes: ['Footer redesign with icons and sponsor logos'], diff --git a/client/src/components/navbar/Navbar.tsx b/client/src/components/navbar/Navbar.tsx index 8d1187d21..8d37d2130 100644 --- a/client/src/components/navbar/Navbar.tsx +++ b/client/src/components/navbar/Navbar.tsx @@ -1,17 +1,16 @@ import { Description, Info, Security, Settings as SettingsIcon } from '@mui/icons-material'; -import { AppBar, Toolbar, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { AppBar, Toolbar, Typography } from '@mui/material'; import { styled } from '@mui/system'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import notanglesLogoGif from '../../assets/notangles.gif'; import notanglesLogo from '../../assets/notangles_1.png'; -import { ThemeType } from '../../constants/theme'; -import { AppContext } from '../../context/AppContext'; import About from './About'; import Changelog from './Changelog'; import CustomModal from './CustomModal'; import Privacy from './Privacy'; import Settings from './Settings'; +import TermSelect from './TermSelect'; const LogoImg = styled('img')` height: 46px; @@ -37,22 +36,8 @@ const NavbarTitle = styled(Typography)` z-index: 1201; `; -const Weak = styled('span')` - font-weight: 300; - opacity: 0.8; - margin-left: 15px; - font-size: 90%; - vertical-align: middle; - position: relative; - bottom: 1px; - z-index: 1201; -`; - const Navbar: React.FC = () => { const [currLogo, setCurrLogo] = useState(notanglesLogo); - const { term, termName, year } = useContext(AppContext); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); return ( @@ -64,10 +49,8 @@ const Navbar: React.FC = () => { onMouseOver={() => setCurrLogo(notanglesLogoGif)} onMouseOut={() => setCurrLogo(notanglesLogo)} /> - - Notangles - {isMobile ? term : termName.concat(', ', year)} - + Notangles + } diff --git a/client/src/components/navbar/TermSelect.tsx b/client/src/components/navbar/TermSelect.tsx new file mode 100644 index 000000000..f6d1ce97a --- /dev/null +++ b/client/src/components/navbar/TermSelect.tsx @@ -0,0 +1,102 @@ +import { FormControl, InputLabel, MenuItem, Select, useMediaQuery, useTheme } from '@mui/material'; +import { borderColor, styled } from '@mui/system'; +import React, { useContext } from 'react'; + +import { ThemeType } from '../../constants/theme'; +import { AppContext } from '../../context/AppContext'; +import { CourseContext } from '../../context/CourseContext'; + +const StyledInputLabel = styled(InputLabel)(() => ({ + '&.Mui-focused': { + color: 'white', + }, +})); + +const StyledSelect = styled(Select)(() => ({ + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, + '.MuiSelect-icon': { + color: 'white', + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, +})); + +const TermSelect: React.FC = () => { + const { term, termName, setTermName, year, setTerm, setYear, setSelectedTimetable, displayTimetables, termsData } = + useContext(AppContext); + + const { setSelectedCourses, setSelectedClasses, setCreatedEvents, setAssignedColors } = useContext(CourseContext); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + let prevTermName = `Term ${termsData.prevTerm.term[1]}`; + if (prevTermName.includes('Summer')) { + prevTermName = 'Summer Term'; + } + + let newTermName = `Term ${termsData.newTerm.term[1]}`; + if (newTermName.includes('Summer')) { + newTermName = 'Summer Term'; + } + + const termData = new Set([ + ...(termsData.prevTerm.term.length > 0 ? [`${prevTermName}, ${termsData.prevTerm.year}`] : []), + `${newTermName}, ${termsData.newTerm.year}`, + ]); + + const selectTerm = (e: any) => { + const defaultStartTimetable = 0; + + const newTermName = e.target.value.split(', ')[0]; + let termNum = 'T' + newTermName.split(' ')[1]; + const newYear = e.target.value.split(', ')[1]; + + if (e.target.value.includes('Summer')) { + // This is a summer term. + termNum = 'Summer'; + } + + setTerm(termNum); + setYear(newYear); + setTermName(newTermName); + setSelectedTimetable(defaultStartTimetable); + setSelectedClasses(displayTimetables[termNum][defaultStartTimetable].selectedClasses); + setCreatedEvents(displayTimetables[termNum][defaultStartTimetable].createdEvents); + setSelectedCourses(displayTimetables[termNum][defaultStartTimetable].selectedCourses); + setAssignedColors(displayTimetables[termNum][defaultStartTimetable].assignedColors); + }; + + return ( + + + Select term + + + {Array.from(termData).map((term, index) => { + return ( + + {term} + + ); + })} + + + ); +}; + +export default TermSelect; diff --git a/client/src/components/timetableTabs/TimetableTabContextMenu.tsx b/client/src/components/timetableTabs/TimetableTabContextMenu.tsx index 5ffd71ecd..be4168d33 100644 --- a/client/src/components/timetableTabs/TimetableTabContextMenu.tsx +++ b/client/src/components/timetableTabs/TimetableTabContextMenu.tsx @@ -38,6 +38,7 @@ const TimetableTabContextMenu: React.FC = ({ ancho setSelectedTimetable, displayTimetables, setDisplayTimetables, + term, setAlertMsg, setAlertFunction, alertFunction, @@ -79,10 +80,10 @@ const TimetableTabContextMenu: React.FC = ({ ancho */ // Handler for deleting a timetable const handleDeleteTimetable = (targetIndex: number) => { - if (displayTimetables.length > 1) { + if (displayTimetables[term].length > 1) { prevTimetables = { selected: selectedTimetable, - timetables: displayTimetables.map((timetable: TimetableData) => { + timetables: displayTimetables[term].map((timetable: TimetableData) => { return { name: timetable.name, id: timetable.id, @@ -94,26 +95,35 @@ const TimetableTabContextMenu: React.FC = ({ ancho }), }; - const newIndex = targetIndex === displayTimetables.length - 1 ? targetIndex - 1 : targetIndex; - const newTimetables = displayTimetables.filter( - (timetable: TimetableData, index: number) => index !== targetIndex, - ); + const newIndex = targetIndex === displayTimetables[term].length - 1 ? targetIndex - 1 : targetIndex; + + const newDisplayTimetables = { + ...displayTimetables, + [term]: displayTimetables[term].filter((timetable: TimetableData, index: number) => index !== targetIndex), + }; + // Updating the timetables state to the new timetable index - setDisplayTimetables(newTimetables); + setDisplayTimetables(newDisplayTimetables); + // Destructure and rename (for clarity, do not shadow context variables) const { selectedCourses: newCourses, selectedClasses: newClasses, createdEvents: newEvents, assignedColors: newColors, - } = newTimetables[newIndex]; + } = newDisplayTimetables[term][newIndex]; setTimetableState(newCourses, newClasses, newEvents, newColors, newIndex); setOpenRestoreAlert(true); // If user chooses to undo the deletion then we will restore the previous state setAlertFunction(() => () => { - setDisplayTimetables(prevTimetables.timetables); + const restoredTimetables = { + ...displayTimetables, + [term]: prevTimetables.timetables, + }; + + setDisplayTimetables(restoredTimetables); const { selectedCourses, selectedClasses, createdEvents } = prevTimetables.timetables[prevTimetables.selected]; setTimetableState(selectedCourses, selectedClasses, createdEvents, assignedColors, prevTimetables.selected); return; @@ -132,7 +142,7 @@ const TimetableTabContextMenu: React.FC = ({ ancho }; const handleRenameOpen = () => { - const timetableName = displayTimetables[selectedTimetable].name; + const timetableName = displayTimetables[term][selectedTimetable].name; setRenamedString(timetableName); setRenamedHelper(`${timetableName.length}/30`); timetableName.length > 30 ? setRenamedErr(true) : setRenamedErr(false); @@ -149,11 +159,15 @@ const TimetableTabContextMenu: React.FC = ({ ancho if (renamedErr) return; - const newTimetables = [...displayTimetables]; - newTimetables[selectedTimetable].name = renamedString; + const newTimetables = { + ...displayTimetables, + [term]: displayTimetables[term].map((timetable, index) => { + return index === selectedTimetable ? { ...timetable, name: renamedString } : timetable; + }), + }; - storage.set('timetables', [...newTimetables]); - setDisplayTimetables([...newTimetables]); + storage.set('timetables', newTimetables); + setDisplayTimetables(newTimetables); setRenameOpen(false); }; @@ -168,11 +182,11 @@ const TimetableTabContextMenu: React.FC = ({ ancho // Handler to duplicate the selected timetable const handleDuplicateTimetable = () => { - if (displayTimetables.length >= TIMETABLE_LIMIT) { + if (displayTimetables[term].length >= TIMETABLE_LIMIT) { setAlertMsg('Maximum timetables reached'); setErrorVisibility(true); } else { - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; const newTimetable = { name: currentTimetable.name + ' - Copy', @@ -184,13 +198,18 @@ const TimetableTabContextMenu: React.FC = ({ ancho }; const newTimetables = [ - ...displayTimetables.slice(0, selectedTimetable + 1), + ...displayTimetables[term].slice(0, selectedTimetable + 1), newTimetable, - ...displayTimetables.slice(selectedTimetable + 1), + ...displayTimetables[term].slice(selectedTimetable + 1), ]; - storage.set('timetables', newTimetables); - setDisplayTimetables(newTimetables); + const updatedTimetables = { + ...displayTimetables, + [term]: newTimetables, + }; + + storage.set('timetables', updatedTimetables); + setDisplayTimetables(updatedTimetables); const { selectedCourses, selectedClasses, createdEvents } = newTimetable; setTimetableState(selectedCourses, selectedClasses, createdEvents, assignedColors, selectedTimetable + 1); handleMenuClose(); diff --git a/client/src/components/timetableTabs/TimetableTabs.tsx b/client/src/components/timetableTabs/TimetableTabs.tsx index 024dc64b9..f56db7396 100644 --- a/client/src/components/timetableTabs/TimetableTabs.tsx +++ b/client/src/components/timetableTabs/TimetableTabs.tsx @@ -7,7 +7,13 @@ import { v4 as uuidv4 } from 'uuid'; import { darkTheme, lightTheme } from '../../constants/theme'; import { AppContext } from '../../context/AppContext'; import { CourseContext } from '../../context/CourseContext'; -import { CourseData, CreatedEvents, SelectedClasses, TimetableData } from '../../interfaces/Periods'; +import { + CourseData, + CreatedEvents, + DisplayTimetablesMap, + SelectedClasses, + TimetableData, +} from '../../interfaces/Periods'; import { createTimetableStyle, StyledIconButton, @@ -33,6 +39,7 @@ const TimetableTabs: React.FC = () => { setDisplayTimetables, setAlertMsg, setErrorVisibility, + term, } = useContext(AppContext); const { setSelectedCourses, setSelectedClasses, setCreatedEvents, setAssignedColors } = useContext(CourseContext); @@ -58,7 +65,7 @@ const TimetableTabs: React.FC = () => { selectedClasses: SelectedClasses, createdEvents: CreatedEvents, assignedColors: Record, - timetableIndex: number + timetableIndex: number, ) => { setSelectedCourses(selectedCourses); setSelectedClasses(selectedClasses); @@ -72,11 +79,11 @@ const TimetableTabs: React.FC = () => { */ // Creates new timetable const handleCreateTimetable = () => { - if (displayTimetables.length >= TIMETABLE_LIMIT) { + if (displayTimetables[term].length >= TIMETABLE_LIMIT) { setAlertMsg('Maximum timetables reached'); setErrorVisibility(true); } else { - const nextIndex = displayTimetables.length; + const nextIndex = displayTimetables[term].length; const newTimetable: TimetableData = { name: 'New Timetable', @@ -86,9 +93,14 @@ const TimetableTabs: React.FC = () => { createdEvents: {}, assignedColors: {}, }; - storage.set('timetables', [...displayTimetables, newTimetable]); - setDisplayTimetables([...displayTimetables, newTimetable]); + const addingNewTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: [...displayTimetables[term], newTimetable], + }; + storage.set('timetables', addingNewTimetables); + + setDisplayTimetables(addingNewTimetables); // Clearing the selected courses, classes and created events for the new timetable setTimetableState([], {}, {}, {}, nextIndex); @@ -116,11 +128,17 @@ const TimetableTabs: React.FC = () => { return; } - const newTimetables = [...displayTimetables]; + const newTimetables: TimetableData[] = [...displayTimetables[term]]; const draggedItem = newTimetables[source.index]; newTimetables.splice(source.index, 1); newTimetables.splice(destination.index, 0, draggedItem); - setDisplayTimetables(newTimetables); + + const rearrangedTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: newTimetables, + }; + + setDisplayTimetables(rearrangedTimetables); handleSwitchTimetables(newTimetables, destination.index); }; @@ -137,7 +155,7 @@ const TimetableTabs: React.FC = () => { // Right clicking a tab will switch to that tab and open the menu const handleRightTabClick = (event: React.MouseEvent, index: number) => { event.preventDefault(); - handleSwitchTimetables(displayTimetables, index); + handleSwitchTimetables(displayTimetables[term], index); // Anchoring the menu to the mouse position setAnchorElement({ x: event.clientX, y: event.clientY }); @@ -145,35 +163,43 @@ const TimetableTabs: React.FC = () => { return ( - + {(props) => ( - {displayTimetables.map((timetable: TimetableData, index: number) => ( - - {(props) => ( - handleSwitchTimetables(displayTimetables, index)} - onContextMenu={(e) => handleRightTabClick(e, index)} - > - {timetable.name} - {selectedTimetable === index ? ( - - - - ) : ( - <> - )} - - )} - - ))} + {Object.keys(displayTimetables).length > 0 + ? displayTimetables[term]?.map((timetable: TimetableData, index: number) => ( + + {(props) => { + if (props.draggableProps.style?.transform) { + const horizShift = props.draggableProps.style?.transform.match(/(-?\d+)/g)?.map(Number)![0]; + // forcing horizontal movement + props.draggableProps.style.transform = `translate(${horizShift ? horizShift : 0}px, 0)`; + } + return ( + handleSwitchTimetables(displayTimetables[term], index)} + onContextMenu={(e) => handleRightTabClick(e, index)} + ref={props.innerRef} + {...props.draggableProps} + {...props.dragHandleProps} + sx={TabStyle(index, selectedTimetable)} + > + {timetable.name} + {selectedTimetable === index ? ( + + + + ) : ( + <> + )} + + ); + }} + + )) + : null} {props.placeholder} )} diff --git a/client/src/constants/defaults.ts b/client/src/constants/defaults.ts index 0472ad111..4a1462648 100644 --- a/client/src/constants/defaults.ts +++ b/client/src/constants/defaults.ts @@ -10,7 +10,7 @@ const defaults: Record = { isHideExamClasses: false, isConvertToLocalTimezone: true, courseData: { map: [] }, - timetables: createDefaultTimetable(), + timetables: { 'T0': createDefaultTimetable() }, }; export default defaults; diff --git a/client/src/constants/timetable.ts b/client/src/constants/timetable.ts index f065a0868..8e17e1490 100644 --- a/client/src/constants/timetable.ts +++ b/client/src/constants/timetable.ts @@ -1,9 +1,21 @@ import { API_URL } from '../api/config'; import NetworkError from '../interfaces/NetworkError'; +import { TermDataMap } from '../interfaces/Periods'; import timeoutPromise from '../utils/timeoutPromise'; const REGULAR_TERM_STR_LEN = 2; +const parseYear = (termDate: string) => { + const regexp = /(\d{2})\/(\d{2})\/(\d{4})/; + + const matched = termDate.match(regexp); + let extractedYear = ''; + if (matched != null) { + extractedYear = matched[3]; + } + return extractedYear; +}; + /** * @returns The details of the latest term there is data for */ @@ -21,57 +33,84 @@ export const getAvailableTermDetails = async () => { if (localStorage.getItem('termData')) { termData = JSON.parse(localStorage.getItem('termData')!); } + let year = termData.year || '0000'; - let termNumber = Number(termData.termNumber) || 1; - let term = termData.termName || `T${termNumber}`; - let termName = `Term ${termNumber}`; + const termNumber = Number(termData.termNumber) || 1; let firstDayOfTerm = termData.firstDayOfTerm || `0000-00-00`; + const parseTermData = (termId: string) => { + let termNum; + let term = termData.termName || `T${termNumber}`; + let termName = `Term ${termNumber}`; + + if (termId.length === REGULAR_TERM_STR_LEN) { + // This is not a summer term. + termNum = parseInt(termId.substring(1)); + term = `T${termNum}`; + termName = `Term ${termNum}`; + } else { + // This is a summer term. + termName = `Summer Term`; + term = termId; + termNum = 0; + } + + return { term: term, termName: termName, termNum: termNum }; + }; + try { - const termDateFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/startdate/notangles`)); + // get the latest/new term start date available from the scraper + const termDateFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/availablestartdate`)); const termDateRes = await termDateFetch.text(); + const termIdFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/availableterm`)); + const termIdRes = await termIdFetch.text(); - const regexp = /(\d{2})\/(\d{2})\/(\d{4})/; + // get the current/previous term date + const prevTermDate = await timeoutPromise(1000, fetch(`${API_URL.timetable}/currentstartdate`)); + const prevTermRes = await prevTermDate.text(); - const matched = termDateRes.match(regexp); - if (matched != null) { - year = matched[3]; + const prevTermId = await timeoutPromise(1000, fetch(`${API_URL.timetable}/currentterm`)); + const prevTermIdRes = await prevTermId.text(); + + const extractedCurrYear = parseYear(termDateRes); + if (extractedCurrYear.length > 0) { + year = extractedCurrYear; } + const prevYear = parseYear(prevTermRes); + const termDateSplit = termDateRes.split('/'); firstDayOfTerm = termDateSplit.reverse().join('-'); - const termIdRes = await termIdFetch.text(); - if (termIdRes.length === REGULAR_TERM_STR_LEN) { - // This is not a summer term. - termNumber = parseInt(termIdRes.substring(1)); - term = `T${termNumber}`; - termName = `Term ${termNumber}`; - } else { - // This is a summer term. - termName = `Summer Term`; - term = termIdRes; - termNumber = 0; // This is a summer term. - } + const newTerm = parseTermData(termIdRes); + const prevTerm = parseTermData(prevTermIdRes); + + const termsData: TermDataMap = { + prevTerm: { year: prevYear, term: prevTerm.term }, + newTerm: { year: year, term: newTerm.term }, + }; + // Store the term details in local storage. localStorage.setItem( 'termData', JSON.stringify({ year: year, - term: term, - termNumber: termNumber, - termName: termName, + term: newTerm.term, + termNumber: newTerm.termNum, + termName: newTerm.termName, firstDayOfTerm: firstDayOfTerm, + termsData: termsData, }), ); return { - term: term, - termName: termName, - termNumber: termNumber, year: year, + term: newTerm.term, + termNumber: newTerm.termNum, + termName: newTerm.termName, firstDayOfTerm: firstDayOfTerm, + termsData: termsData, }; } catch (e) { throw new NetworkError('Could not connect to timetable scraper!'); diff --git a/client/src/context/AppContext.tsx b/client/src/context/AppContext.tsx index a7e2bf089..c33fd8fbd 100644 --- a/client/src/context/AppContext.tsx +++ b/client/src/context/AppContext.tsx @@ -2,7 +2,7 @@ import { createContext, useState } from 'react'; import { getDefaultEndTime, getDefaultStartTime } from '../constants/timetable'; import { CoursesList } from '../interfaces/Courses'; -import { CourseDataMap, TimetableData } from '../interfaces/Periods'; +import { CourseDataMap, DisplayTimetablesMap, TermDataMap } from '../interfaces/Periods'; import { AppContextProviderProps } from '../interfaces/PropTypes'; import storage from '../utils/storage'; @@ -70,6 +70,9 @@ export interface IAppContext { termName: string; setTermName: (newTermName: string) => void; + termsData: TermDataMap; + setTermsData: (newTermData: TermDataMap) => void; + termNumber: number; setTermNumber: (newTermNumber: number) => void; @@ -85,7 +88,7 @@ export interface IAppContext { selectedTimetable: number; setSelectedTimetable: (newSelectedTimetable: number) => void; - displayTimetables: TimetableData[]; + displayTimetables: DisplayTimetablesMap; setDisplayTimetables: (newDisplayTimetable: any) => void; courseData: CourseDataMap; @@ -153,6 +156,9 @@ export const AppContext = createContext({ termName: `Term 0`, setTermName: () => {}, + termsData: { prevTerm: { year: '', term: '' }, newTerm: { year: '', term: '' } }, + setTermsData: () => {}, + termNumber: 0, setTermNumber: () => {}, @@ -168,7 +174,7 @@ export const AppContext = createContext({ selectedTimetable: 0, setSelectedTimetable: () => {}, - displayTimetables: [], + displayTimetables: {}, setDisplayTimetables: () => {}, courseData: { map: [] }, @@ -210,10 +216,16 @@ const AppContextProvider = ({ children }: AppContextProviderProps) => { const [term, setTerm] = useState(termData.term || `T0`); const [termName, setTermName] = useState(`Term ${termNumber}`); const [year, setYear] = useState(termData.year || '0000'); + const [termsData, setTermsData] = useState({ + prevTerm: { year: '', term: '' }, + newTerm: { year: year, term: term }, + }); const [firstDayOfTerm, setFirstDayOfTerm] = useState(termData.firstDayOfTerm || `0000-00-00`); const [coursesList, setCoursesList] = useState([]); const [selectedTimetable, setSelectedTimetable] = useState(0); - const [displayTimetables, setDisplayTimetables] = useState([]); + const [displayTimetables, setDisplayTimetables] = useState({ + [termData.term.length > 0 ? termData.term : '0']: [], + }); const [courseData, setCourseData] = useState({ map: [] }); const initialContext: IAppContext = { @@ -257,6 +269,8 @@ const AppContextProvider = ({ children }: AppContextProviderProps) => { setTerm, termName, setTermName, + termsData, + setTermsData, termNumber, setTermNumber, year, diff --git a/client/src/index.tsx b/client/src/index.tsx index a41ce9da1..d06bc6fbf 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -8,10 +8,10 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import App from './App'; +import EventShareModal from './components/EventShareModal'; import AppContextProvider from './context/AppContext'; import CourseContextProvider from './context/CourseContext'; import * as swRegistration from './serviceWorkerRegistration'; -import EventShareModal from './components/EventShareModal'; Sentry.init({ dsn: import.meta.env.VITE_APP_SENTRY_INGEST_CLIENT, diff --git a/client/src/interfaces/Periods.ts b/client/src/interfaces/Periods.ts index cf42a2a33..27d2c8d21 100644 --- a/client/src/interfaces/Periods.ts +++ b/client/src/interfaces/Periods.ts @@ -20,6 +20,11 @@ export interface CourseData { inventoryData: Record; } +export interface TermData { + year: string; + term: string; +} + export interface ClassData { id: string; courseCode: CourseCode; @@ -132,3 +137,12 @@ export interface Action { export interface CourseDataMap { map: CourseData[]; } + +export interface TermDataMap { + prevTerm: TermData; + newTerm: TermData; +} + +export interface DisplayTimetablesMap { + [key: string]: TimetableData[]; +}