From e5c42c5d5e902f4c959d8ef4e7d1aeee0edf4ac0 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Mon, 29 Jan 2024 14:01:18 -0800 Subject: [PATCH] feat: refactor Import (#844) --- .../src/components/Header/Header.tsx | 4 +- .../src/components/Header/Import.tsx | 247 ++++++++++++++++ .../src/components/Header/ImportStudyList.tsx | 271 ------------------ .../CoursePane/SearchForm/SearchForm.tsx | 2 +- .../CoursePane/SearchForm/TermSelector.tsx | 93 +++--- apps/antalmanac/src/lib/websoc.ts | 1 + 6 files changed, 294 insertions(+), 324 deletions(-) create mode 100644 apps/antalmanac/src/components/Header/Import.tsx delete mode 100644 apps/antalmanac/src/components/Header/ImportStudyList.tsx diff --git a/apps/antalmanac/src/components/Header/Header.tsx b/apps/antalmanac/src/components/Header/Header.tsx index c6a06278b..c62b6ce2d 100644 --- a/apps/antalmanac/src/components/Header/Header.tsx +++ b/apps/antalmanac/src/components/Header/Header.tsx @@ -7,7 +7,7 @@ import { useState, type MouseEventHandler } from 'react'; import AboutPage from './AboutPage'; import Feedback from './Feedback'; -import ImportStudyList from './ImportStudyList'; +import Import from './Import'; import LoadSaveScheduleFunctionality from './LoadSaveFunctionality'; import SettingsMenu from './SettingsMenu'; import Export from './Export'; @@ -42,7 +42,7 @@ interface CustomAppBarProps { } const components = [ - , + , , , , diff --git a/apps/antalmanac/src/components/Header/Import.tsx b/apps/antalmanac/src/components/Header/Import.tsx new file mode 100644 index 000000000..405a6fd5f --- /dev/null +++ b/apps/antalmanac/src/components/Header/Import.tsx @@ -0,0 +1,247 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + TextField, + Tooltip, +} from '@material-ui/core'; +import InputLabel from '@material-ui/core/InputLabel'; +import { PostAdd } from '@material-ui/icons'; +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; + +import TermSelector from '../RightPane/CoursePane/SearchForm/TermSelector'; +import RightPaneStore from '../RightPane/RightPaneStore'; +import { addCustomEvent, openSnackbar } from '$actions/AppStoreActions'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import { warnMultipleTerms } from '$lib/helpers'; +import AppStore from '$stores/AppStore'; +import WebSOC from '$lib/websoc'; +import { CourseInfo } from '$lib/course_data.types'; +import { addCourse } from '$actions/AppStoreActions'; +import { ZotCourseResponse, queryZotCourse } from '$lib/zotcourse'; + +function Import() { + const [open, setOpen] = useState(false); + const [term, setTerm] = useState(RightPaneStore.getFormData().term); + const [importSource, setImportSource] = useState('studylist'); + const [studyListText, setStudyListText] = useState(''); + const [zotcourseScheduleName, setZotcourseScheduleName] = useState(''); + + const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleSubmit = async () => { + const currentSchedule = AppStore.getCurrentScheduleIndex(); + + let zotcourseImport: ZotCourseResponse | null = null; + if (importSource === 'zotcourse') { + try { + zotcourseImport = await queryZotCourse(zotcourseScheduleName); + } catch (e) { + openSnackbar('error', 'Could not import from Zotcourse.'); + console.error(e); + handleClose(); + return; + } + } + + const sectionCodes = zotcourseImport ? zotcourseImport.codes : studyListText.match(/\d{5}/g); + + if (!sectionCodes) { + openSnackbar('error', 'Cannot import an empty/invalid Study List/Zotcourse.'); + handleClose(); + return; + } + + // Import Custom Events from Zotcourse + if (zotcourseImport) { + const events = zotcourseImport.customEvents; + for (const event of events) { + addCustomEvent(event, [currentSchedule]); + } + } + + try { + const sectionsAdded = addCoursesMultiple( + await WebSOC.getCourseInfo({ + term: term, + sectionCodes: sectionCodes.join(','), + }), + term, + currentSchedule + ); + + logAnalytics({ + category: analyticsEnum.nav.title, + action: analyticsEnum.nav.actions.IMPORT_STUDY_LIST, + value: sectionsAdded / (sectionCodes.length || 1), + }); + + if (sectionsAdded === sectionCodes.length) { + openSnackbar('success', `Successfully imported ${sectionsAdded} of ${sectionsAdded} classes!`); + } else if (sectionsAdded !== 0) { + openSnackbar( + 'warning', + `Only successfully imported ${sectionsAdded} of ${sectionCodes.length} classes. + Please make sure that you selected the correct term and that none of your classes are missing.` + ); + } else { + openSnackbar( + 'error', + 'Failed to import any classes! Please make sure that you pasted the correct Study List.' + ); + } + } catch (e) { + openSnackbar('error', 'An error occurred while trying to import the Study List.'); + console.error(e); + } + + setStudyListText(''); + handleClose(); + }; + + const addCoursesMultiple = ( + courseInfo: { [sectionCode: string]: CourseInfo }, + term: string, + scheduleIndex: number + ) => { + for (const section of Object.values(courseInfo)) { + addCourse(section.section, section.courseDetails, term, scheduleIndex, true); + } + + const terms = AppStore.termsInSchedule(term); + if (terms.size > 1) { + warnMultipleTerms(terms); + } + + return Object.values(courseInfo).length; + }; + + const handleImportSourceChange = useCallback((event: ChangeEvent) => { + setImportSource(event.currentTarget.value); + }, []); + + const handleStudyListTextChange = useCallback((event: React.ChangeEvent) => { + setStudyListText(event.currentTarget.value); + }, []); + + const handleZotcourseScheduleNameChange = useCallback((event: React.ChangeEvent) => { + setZotcourseScheduleName(event.currentTarget.value); + }, []); + + useEffect(() => { + const handleSkeletonModeChange = () => { + setSkeletonMode(AppStore.getSkeletonMode()); + }; + + AppStore.on('skeletonModeChange', handleSkeletonModeChange); + + return () => { + AppStore.off('skeletonModeChange', handleSkeletonModeChange); + }; + }, []); + + return ( + <> + {/* TODO after mui v5 migration: change icon to ContentPasteGo */} + + + + + Import Schedule + + + + } + label="From Study List" + /> + } + label="From Zotcourse" + /> + + + {importSource === 'studylist' ? ( + + + Paste the contents of your Study List below to import it into AntAlmanac. +
+ To find your Study List, go to{' '} + WebReg or{' '} + StudentAccess, and click + on Study List once you've logged in. Copy everything below the column names (Code, + Dept, etc.) under the Enrolled Classes section. +
+ Study List + +
+
+ ) : ( + + + Paste your Zotcourse schedule name below to import it into AntAlmanac. + + Zotcourse Schedule + +
+
+ )} + + Make sure you also have the right term selected. + +
+ + + + +
+ + ); +} + +export default Import; diff --git a/apps/antalmanac/src/components/Header/ImportStudyList.tsx b/apps/antalmanac/src/components/Header/ImportStudyList.tsx deleted file mode 100644 index 7a611cfda..000000000 --- a/apps/antalmanac/src/components/Header/ImportStudyList.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - FormControl, - FormControlLabel, - Radio, - RadioGroup, - TextField, - Tooltip, -} from '@material-ui/core'; -import InputLabel from '@material-ui/core/InputLabel'; -import { withStyles } from '@material-ui/core/styles'; -import { ClassNameMap } from '@material-ui/core/styles/withStyles'; -import { PostAdd } from '@material-ui/icons'; -import { PureComponent } from 'react'; - -import TermSelector from '../RightPane/CoursePane/SearchForm/TermSelector'; -import RightPaneStore from '../RightPane/RightPaneStore'; -import { addCustomEvent, openSnackbar } from '$actions/AppStoreActions'; -import analyticsEnum, { logAnalytics } from '$lib/analytics'; -import { warnMultipleTerms } from '$lib/helpers'; -import AppStore from '$stores/AppStore'; -import WebSOC from '$lib/websoc'; -import { CourseInfo } from '$lib/course_data.types'; -import { addCourse } from '$actions/AppStoreActions'; -import { ZotCourseResponse, queryZotCourse } from '$lib/zotcourse'; - -const styles = { - inputLabel: { - 'font-size': '9px', - }, -}; - -interface ImportStudyListProps { - classes: ClassNameMap; -} - -interface ImportStudyListState { - isOpen: boolean; - selectedTerm: string; - studyListText: string; - zotcourseScheduleName: string; - importSource: string; -} - -class ImportStudyList extends PureComponent { - state: ImportStudyListState = { - isOpen: false, - selectedTerm: RightPaneStore.getFormData().term, - studyListText: '', - zotcourseScheduleName: '', - importSource: 'studylist', - }; - - onTermSelectorChange = (field: string, value: string) => { - this.setState({ selectedTerm: value }); - }; - - handleError = (error: Error) => { - openSnackbar('error', 'An error occurred while trying to import the Study List.'); - console.error(error); - }; - - handleOpen = () => { - this.setState({ isOpen: true }); - }; - - addCoursesMultiple = (courseInfo: { [sectionCode: string]: CourseInfo }, term: string, scheduleIndex: number) => { - for (const section of Object.values(courseInfo)) { - addCourse(section.section, section.courseDetails, term, scheduleIndex, true); - } - const terms = AppStore.termsInSchedule(term); - if (terms.size > 1) warnMultipleTerms(terms); - return Object.values(courseInfo).length; - }; - - handleClose = (doImport: boolean) => { - this.setState({ isOpen: false }, async () => { - document.removeEventListener('keydown', this.enterEvent, false); - if (doImport) { - const currSchedule = AppStore.getCurrentScheduleIndex(); - let zotcourseImport: ZotCourseResponse | null = null; - if (this.state.importSource === 'zotcourse') { - try { - zotcourseImport = await queryZotCourse(this.state.zotcourseScheduleName); - } catch (e) { - /* empty */ - } - } - const sectionCodes = zotcourseImport ? zotcourseImport.codes : this.state.studyListText.match(/\d{5}/g); - if (!sectionCodes) { - openSnackbar('error', 'Cannot import an empty/invalid Study List/Zotcourse.'); - return; - } - // Import Custom Events from zotcourse - if (zotcourseImport) { - const events = zotcourseImport.customEvents; - for (const event of events) { - addCustomEvent(event, [currSchedule]); - } - } - - try { - const sectionsAdded = this.addCoursesMultiple( - await WebSOC.getCourseInfo({ - term: this.state.selectedTerm, - sectionCodes: sectionCodes.join(','), - }), - this.state.selectedTerm, - currSchedule - ); - logAnalytics({ - category: analyticsEnum.nav.title, - action: analyticsEnum.nav.actions.IMPORT_STUDY_LIST, - value: sectionsAdded / (sectionCodes.length || 1), - }); - if (sectionsAdded === sectionCodes.length) { - openSnackbar('success', `Successfully imported ${sectionsAdded} of ${sectionsAdded} classes!`); - } else if (sectionsAdded !== 0) { - openSnackbar( - 'warning', - `Successfully imported ${sectionsAdded} of ${sectionCodes.length} classes. - Please make sure that you selected the correct term and that none of your classes are missing.` - ); - } else { - openSnackbar( - 'error', - 'Failed to import any classes! Please make sure that you pasted the correct Study List.' - ); - } - } catch (e) { - if (e instanceof Error) this.handleError(e); - } - } - this.setState({ studyListText: '' }); - }); - }; - - enterEvent = (event: KeyboardEvent) => { - const charCode = event.which ? event.which : event.keyCode; - // enter (13) or newline (10) - if (charCode === 13 || charCode === 10) { - event.preventDefault(); - this.handleClose(true); - } - }; - - componentDidUpdate(prevProps: ImportStudyListProps, prevState: ImportStudyListState) { - if (!prevState.isOpen && this.state.isOpen) { - document.addEventListener('keydown', this.enterEvent, false); - } else if (prevState.isOpen && !this.state.isOpen) { - document.removeEventListener('keydown', this.enterEvent, false); - } - } - - toggleImportSource(radioGroupEvent: React.ChangeEvent) { - this.setState({ importSource: radioGroupEvent.target.value }); - } - - render() { - const { classes } = this.props; - - return ( - <> - {/* TODO after mui v5 migration: change icon to ContentPasteGo */} - - - - - this.setState({ isOpen: false, studyListText: '' }, async () => { - document.removeEventListener('keydown', this.enterEvent, false); - }) - } - > - Import Schedule - - - { - this.toggleImportSource(event); - }} - > - } - label="From Study List" - /> - } - label="From Zotcourse" - /> - - - {this.state.importSource === 'studylist' ? ( -
- - Paste the contents of your Study List below to import it into AntAlmanac. -
- To find your Study List, go to{' '} - WebReg or{' '} - StudentAccess, and - click on Study List once you've logged in. Copy everything below the column - names (Code, Dept, etc.) under the Enrolled Classes section. - {/* ' is an apostrophe (') */} -
- Study List - this.setState({ studyListText: event.target.value })} - /> -
-
- ) : ( -
- - Paste your Zotcourse schedule name below to import it into AntAlmanac. - {/* ' is an apostrophe (') */} - - Zotcourse Schedule - this.setState({ zotcourseScheduleName: event.target.value })} - /> -
-
- )} - - Make sure you also have the right term selected. - -
- - - - -
- - ); - } -} - -export default withStyles(styles)(ImportStudyList); diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx index e0049dda2..74ccfe216 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx @@ -80,7 +80,7 @@ const SearchForm = (props: { classes: ClassNameMap; toggleSearch: () => void })
RightPaneStore.updateFormValue(field, value)} + changeTerm={(field: string, value: string) => RightPaneStore.updateFormValue(field, value)} fieldName={'term'} /> diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/TermSelector.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/TermSelector.tsx index 344839614..0efc8ed68 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/TermSelector.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/TermSelector.tsx @@ -1,71 +1,64 @@ -import FormControl from '@material-ui/core/FormControl'; -import InputLabel from '@material-ui/core/InputLabel'; -import MenuItem from '@material-ui/core/MenuItem'; -import Select from '@material-ui/core/Select'; -import { ChangeEvent, PureComponent } from 'react'; +import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core'; +import { ChangeEvent, useEffect, useState } from 'react'; import RightPaneStore from '../../RightPaneStore'; import { termData } from '$lib/termData'; interface TermSelectorProps { - changeState: (field: string, value: string) => void; + changeTerm: (field: string, value: string) => void; fieldName: string; } -class TermSelector extends PureComponent { - updateTermAndGetFormData() { - RightPaneStore.updateFormValue('term', RightPaneStore.getUrlTermValue()); - return RightPaneStore.getFormData().term; - } +function TermSelector(props: TermSelectorProps) { + const { changeTerm, fieldName } = props; - getTerm() { - return RightPaneStore.getUrlTermValue() ? this.updateTermAndGetFormData() : RightPaneStore.getFormData().term; - } + const getTerm = () => { + const urlTerm = RightPaneStore.getUrlTermValue(); - state = { - term: this.getTerm(), - }; + if (urlTerm) { + RightPaneStore.updateFormValue('term', urlTerm); + } - resetField = () => { - this.setState({ term: RightPaneStore.getFormData().term }); + return RightPaneStore.getFormData().term; }; - componentDidMount = () => { - RightPaneStore.on('formReset', this.resetField); - }; + const [term, setTerm] = useState(getTerm()); - componentWillUnmount() { - RightPaneStore.removeListener('formReset', this.resetField); - } + const handleChange = (event: ChangeEvent<{ name?: string | undefined; value: unknown }>) => { + const newValue = event.target.value as string; - handleChange = (event: ChangeEvent<{ name?: string | undefined; value: unknown }>) => { - this.setState({ term: event.target.value }); - this.props.changeState(this.props.fieldName, event.target.value as string); + setTerm(newValue); + changeTerm(fieldName, newValue); - const stateObj = { url: 'url' }; - const url = new URL(window.location.href); - const urlParam = new URLSearchParams(url.search); - urlParam.delete('term'); - urlParam.append('term', event.target.value as string); - const param = urlParam.toString(); - const new_url = `${param && param !== 'null' ? '?' : ''}${param}`; - history.replaceState(stateObj, 'url', '/' + new_url); + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('term', newValue); + history.replaceState({ url: 'url' }, 'url', `/?${urlParams}`); }; - render() { - return ( - - Term - - - ); - } + const resetField = () => { + setTerm(RightPaneStore.getFormData().term); + }; + + useEffect(() => { + RightPaneStore.on('formReset', resetField); + + return () => { + RightPaneStore.off('formReset', resetField); + }; + }); + + return ( + + Term + + + ); } export default TermSelector; diff --git a/apps/antalmanac/src/lib/websoc.ts b/apps/antalmanac/src/lib/websoc.ts index 5bed3e793..c79b1c3a1 100644 --- a/apps/antalmanac/src/lib/websoc.ts +++ b/apps/antalmanac/src/lib/websoc.ts @@ -58,6 +58,7 @@ class _WebSOC { async getCourseInfo(websoc_params: Record) { const SOCObject = await this.query(websoc_params); + const courseInfo: { [sectionCode: string]: CourseInfo } = {}; for (const school of SOCObject.schools) { for (const department of school.departments) {