diff --git a/frontend/package.json b/frontend/package.json index b18c57236..e6cb986bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,8 +36,7 @@ "react-loading-skeleton": "^3.1.1", "react-markdown": "^8.0.5", "react-redux": "^7.2.0", - "react-router": "^5.0.1", - "react-router-dom": "^5.0.1", + "react-router-dom": "^6.15.0", "react-select": "^5.7.2", "react-tooltip": "^5.7.2", "react-window": "^1.8.5", @@ -61,7 +60,6 @@ "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "@types/react-redux": "^7.1.9", - "@types/react-router-dom": "^5.1.5", "@types/react-select": "^3.0.27", "@types/react-window": "^1.8.2", "@types/recharts": "^1.8.15", diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index bcbe39b40..4778161f2 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -1,90 +1,68 @@ -/*eslint max-len: ["error", { "code": 180 }]*/ -import { lazy, Suspense } from 'react'; -import { Switch, Route, RouteProps, Redirect } from 'react-router-dom'; -import BTLoader from 'components/Common/BTLoader'; +import { createBrowserRouter, Navigate, RouterProvider, useParams } from 'react-router-dom'; import Catalog from './app/Catalog'; import Landing from './views/Landing'; -import Error from './views/Error/Error'; +import Error from './views/Error'; import Layout from 'components/Common/Layout'; -const Grades = lazy(() => import('./views/Grades/Grades')); -const Enrollment = lazy(() => import('./views/Enrollment/Enrollment')); -const About = lazy(() => import('./views/About')); -const Releases = lazy(() => import('./views/Releases/Releases')); -const Faq = lazy(() => import('./views/Faq')); -const Profile = lazy(() => import('./views/Profile/Profile')); -const Login = lazy(() => import('./views/Login/Login')); -const Logout = lazy(() => import('./views/Profile/Logout')); -const SchedulerOnboard = lazy(() => import('./views/Scheduler/SchedulerOnboard')); -const LocalScheduler = lazy(() => import('./views/Scheduler/LocalSchedulerPage')); -const RemoteScheduler = lazy(() => import('./views/Scheduler/RemoteSchedulerPage')); -const ViewSchedule = lazy(() => import('./views/Scheduler/ViewSchedule')); -const PrivacyPolicy = lazy(() => import('./views/Policies/PrivacyPolicy')); -const TermsOfService = lazy(() => import('./views/Policies/TermsOfService')); -const RedirectLink = lazy(() => import('./views/RedirectLink')); -const Apply = lazy(() => import('./views/Apply')); +const Grades = () => import('./app/Grades'); +const Enrollment = () => import('./app/Enrollment'); +const About = () => import('./views/About'); +const Releases = () => import('./views/Releases'); +const Faq = () => import('./views/Faq'); +const Profile = () => import('./views/Profile'); +const Login = () => import('./views/Login'); +const Logout = () => import('./views/Logout'); +const SchedulerOnboard = () => import('./app/Scheduler/SchedulerOnboard'); +const LocalScheduler = () => import('./app/Scheduler/LocalSchedulerPage'); +const RemoteScheduler = () => import('./app/Scheduler/RemoteSchedulerPage'); +const ViewSchedule = () => import('./app/Scheduler/ViewSchedule'); +const PrivacyPolicy = () => import('./views/PrivacyPolicy'); +const TermsOfService = () => import('./views/TermsOfService'); +const RedirectLink = () => import('./views/RedirectLink'); +const Apply = () => import('./views/Apply'); -const routes: Array = [ - { path: '/landing', component: Landing }, - { path: '/catalog', component: Catalog, exact: false }, - { path: '/grades', component: Grades, exact: false }, - { path: '/enrollment', component: Enrollment, exact: false }, - { path: '/about', component: About }, - { path: '/releases', component: Releases }, - { path: '/faq', component: Faq }, - { path: '/profile', component: Profile }, - { path: '/oauth2callback', component: Login }, - { path: '/logout', component: Logout }, - { path: '/scheduler', component: SchedulerOnboard }, - { path: '/scheduler/new', component: LocalScheduler }, - { path: '/scheduler/:scheduleId', component: RemoteScheduler }, - { path: '/schedule/:scheduleId', component: ViewSchedule }, - { path: '/error', component: Error }, - { path: '/legal/privacy', component: PrivacyPolicy }, - { path: '/legal/terms', component: TermsOfService }, - { path: '/redirect', component: RedirectLink, exact: false }, - { path: '/apply', component: Apply, sensitive: false }, -]; +function ScheduleRedirect() { + const { scheduleId } = useParams(); + return ; +} -const Routes = () => ( - - - - } - > - - - +const router = createBrowserRouter([ + { + element: , + ErrorBoundary: Error, + children: [ + { path: '/', index: true, Component: Landing }, + { path: '/landing', element: }, + { path: '/s/:scheduleId', Component: ScheduleRedirect }, + { path: '/grades/*', lazy: Grades }, + { path: '/enrollment/*', lazy: Enrollment }, + { path: '/about', lazy: About }, + { path: '/releases', lazy: Releases }, + { path: '/faq', lazy: Faq }, + { path: '/profile', lazy: Profile }, + { path: '/oauth2callback', lazy: Login }, + { path: '/logout', lazy: Logout }, + { path: '/scheduler', lazy: SchedulerOnboard }, + { path: '/scheduler/:scheduleId', lazy: RemoteScheduler }, + { path: '/schedule/:scheduleId', lazy: ViewSchedule }, + { path: '/error', Component: Error }, + { path: '/legal/privacy', lazy: PrivacyPolicy }, + { path: '/legal/terms', lazy: TermsOfService }, + { path: '/redirect', lazy: RedirectLink }, + { path: '/apply', lazy: Apply } + ] + }, + { + element: , + ErrorBoundary: Error, + children: [ + { path: '/catalog/:semester?/:abbreviation?/:courseNumber?', Component: Catalog }, + { path: '/scheduler/new', lazy: LocalScheduler } + ] + } +]); - - - - - - - - - - {routes.map((route) => ( - - ))} - - - - - - - - -); - -export default Routes; +export default function Routes() { + return ; +} diff --git a/frontend/src/app/Catalog/Catalog.tsx b/frontend/src/app/Catalog/Catalog.tsx index 476129367..3345cf5c9 100644 --- a/frontend/src/app/Catalog/Catalog.tsx +++ b/frontend/src/app/Catalog/Catalog.tsx @@ -6,7 +6,7 @@ import CatalogFilters from './CatalogFilters'; import CatalogList from './CatalogList'; import CatalogView from './CatalogView'; import { CourseFragment } from 'graphql'; -import { useLocation } from 'react-router'; +import { useLocation } from 'react-router-dom'; const { SORT_OPTIONS, INITIAL_FILTERS } = catalogService; diff --git a/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx b/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx index fdc70bc3b..2d66d9786 100644 --- a/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx +++ b/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx @@ -19,7 +19,7 @@ import { CurrentFilters, FilterOption, SortOption, CatalogFilterKeys, CatalogSlu import { useGetFiltersQuery } from 'graphql'; import BTLoader from 'components/Common/BTLoader'; -import { useHistory, useParams } from 'react-router'; +import { useNavigate, useParams } from 'react-router-dom'; import styles from './CatalogFilters.module.scss'; import { SortDown, SortUp } from 'iconoir-react'; @@ -52,7 +52,7 @@ const CatalogFilters = (props: CatalogFilterProps) => { const { data, loading, error } = useGetFiltersQuery(); const [isOpen, setOpen] = useState(false); const filters = useMemo(() => catalogService.processFilterData(data), [data]); - const history = useHistory(); + const navigate = useNavigate(); const slug = useParams(); const modalRef = useRef(null); @@ -99,9 +99,9 @@ const CatalogFilters = (props: CatalogFilterProps) => { semester }); - history.push({ pathname: `/catalog/${semester.value.name}` }); + navigate({ pathname: `/catalog/${semester.value.name}` }); } - }, [filterList, history, setCurrentFilters, setSearchQuery, setSortQuery]); + }, [filterList, navigate, setCurrentFilters, setSearchQuery, setSortQuery]); const handleFilterChange = ( newValue: FilterOption | readonly FilterOption[] | null, @@ -115,7 +115,7 @@ const CatalogFilters = (props: CatalogFilterProps) => { // Update the url slug if semester filter changes. if (key === 'semester') { - history.push({ + navigate({ pathname: `/catalog/${(newValue as FilterOption)?.value?.name}` .concat(slug?.abbreviation ? `/${slug.abbreviation}` : '') .concat(slug?.courseNumber ? `/${slug.courseNumber}` : ''), @@ -131,7 +131,7 @@ const CatalogFilters = (props: CatalogFilterProps) => { style={{ border: 'none', width: '100%' }} value={searchQuery} onChange={(e) => { - history.replace({ pathname: location.pathname, search: `q=${e.target.value}` }); + navigate({ pathname: location.pathname, search: `q=${e.target.value}` }); setSearchQuery(e.target.value); }} type="search" @@ -153,7 +153,7 @@ const CatalogFilters = (props: CatalogFilterProps) => { { - history.replace({ pathname: location.pathname, search: `q=${e.target.value}` }); + navigate({ pathname: location.pathname, search: `q=${e.target.value}` }); setSearchQuery(e.target.value); }} type="search" diff --git a/frontend/src/app/Catalog/CatalogList/CatalogList.tsx b/frontend/src/app/Catalog/CatalogList/CatalogList.tsx index c364ef4de..41e606d5c 100644 --- a/frontend/src/app/Catalog/CatalogList/CatalogList.tsx +++ b/frontend/src/app/Catalog/CatalogList/CatalogList.tsx @@ -5,7 +5,7 @@ import { CurrentFilters, FilterOption, SortOption } from '../types'; import { Dispatch, memo, SetStateAction, useEffect, useMemo } from 'react'; import useDimensions from 'react-cool-dimensions'; import styles from './CatalogList.module.scss'; -import { useHistory } from 'react-router'; +import { useNavigate } from 'react-router-dom'; import CatalogService from '../service'; import { sortByAttribute } from 'lib/courses/sorting'; @@ -27,7 +27,7 @@ const CatalogList = (props: CatalogListProps) => { const { currentFilters, setCurrentCourse, selectedId, searchQuery, sortQuery, sortDir } = props; const { observe, height } = useDimensions(); const [fetchCatalogList, { data, loading, called }] = useGetCoursesForFilterLazyQuery({}); - const history = useHistory(); + const navigate = useNavigate(); const courses = useMemo(() => { if (!data) @@ -66,7 +66,7 @@ const CatalogList = (props: CatalogListProps) => { const handleCourseSelect = (course: CourseFragment) => { setCurrentCourse(course); - history.push({ + navigate({ pathname: `/catalog/${(currentFilters.semester as FilterOption)?.value?.name}/${ course.abbreviation }/${course.courseNumber}`, diff --git a/frontend/src/app/Catalog/CatalogView/CatalogView.tsx b/frontend/src/app/Catalog/CatalogView/CatalogView.tsx index db8131b33..de4f1c8a7 100644 --- a/frontend/src/app/Catalog/CatalogView/CatalogView.tsx +++ b/frontend/src/app/Catalog/CatalogView/CatalogView.tsx @@ -8,7 +8,7 @@ import catalogService from '../service'; import { applyIndicatorPercent, applyIndicatorGrade, formatUnits } from 'utils/utils'; import { CourseFragment, PlaylistType, useGetCourseForNameLazyQuery } from 'graphql'; import { CurrentFilters } from 'app/Catalog/types'; -import { useHistory, useParams } from 'react-router'; +import { useNavigate, useParams } from 'react-router-dom'; import { sortSections } from 'utils/sections/sort'; import Skeleton from 'react-loading-skeleton'; import ReadMore from './ReadMore'; @@ -35,7 +35,7 @@ const CatalogView = (props: CatalogViewProps) => { const [course, setCourse] = useState(coursePreview); const [isOpen, setOpen] = useState(false); - const history = useHistory(); + const navigate = useNavigate(); const legacyId = useSelector( (state: any) => @@ -137,7 +137,7 @@ const CatalogView = (props: CatalogViewProps) => { onClick={() => { setCurrentCourse(null); setCourse(null); - history.replace(`/catalog/${semester}`); + navigate(`/catalog/${semester}`, { replace: true }); }} > @@ -231,7 +231,7 @@ const CatalogView = (props: CatalogViewProps) => { className={styles.pill} key={req.id} onClick={() => - history.push(`/catalog/${req.name}/${course.abbreviation}/${course.courseNumber}`) + navigate(`/catalog/${req.name}/${course.abbreviation}/${course.courseNumber}`) } > {req.name} diff --git a/frontend/src/app/Enrollment/index.jsx b/frontend/src/app/Enrollment/index.jsx new file mode 100644 index 000000000..1dcafff05 --- /dev/null +++ b/frontend/src/app/Enrollment/index.jsx @@ -0,0 +1,176 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useDispatch, useSelector } from 'react-redux'; +import ClassCardList from '../../components/ClassCards/ClassCardList'; +import EnrollmentGraphCard from '../../components/GraphCard/EnrollmentGraphCard'; +import EnrollmentSearchBar from '../../components/ClassSearchBar/EnrollmentSearchBar'; + +import info from '../../assets/img/images/graphs/info.svg'; + +import { + fetchEnrollContext, + fetchEnrollClass, + enrollRemoveCourse, + enrollReset, + fetchEnrollFromUrl +} from '../../redux/actions'; +import { useCallback, useEffect, useState } from 'react'; + +const toUrlForm = (s) => { + return s.toLowerCase().split(' ').join('-'); +}; + +export function Component() { + const [additionalInfo, setAdditionalInfo] = useState([]); + + const { context, selectedCourses, usedColorIds } = useSelector((state) => state.enrollment); + const { mobile: isMobile } = useSelector((state) => state.common); + + const dispatch = useDispatch(); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const fillFromUrl = () => { + try { + let url = location.pathname; + + if (url && (url === '/enrollment/' || url === '/enrollment')) { + dispatch(enrollReset()); + } else if (url) { + dispatch(fetchEnrollFromUrl(url, navigate)); + } + } catch (err) { + navigate('/error'); + } + }; + + dispatch(fetchEnrollContext()); + dispatch(enrollReset()); + fillFromUrl(); + }, []); + + const addToUrl = useCallback( + (course) => { + let instructor = course.instructor === 'all' ? 'all' : course.sections[0]; + + let courseUrl = `${course.colorId}-${course.courseID}-${toUrlForm( + course.semester + )}-${instructor}`; + + let url = location.pathname; + + if (url && !url.includes(courseUrl)) { + url += url === '/enrollment' ? '/' : ''; + url += url === '/enrollment/' ? '' : '&'; + url += courseUrl; + + navigate(url, { replace: true }); + } + }, + [navigate, location.pathname] + ); + + const addCourse = useCallback( + (course) => { + for (let selected of selectedCourses) { + if (selected.id === course.id) { + return; + } + } + + let newColorId = ''; + + for (let i = 0; i < 4; i++) { + if (!usedColorIds.includes(i.toString())) { + newColorId = i.toString(); + break; + } + } + + course.colorId = newColorId; + + addToUrl(course); + dispatch(fetchEnrollClass(course)); + }, + [dispatch, addToUrl, selectedCourses, usedColorIds] + ); + + const refillUrl = useCallback( + (id) => { + let updatedCourses = selectedCourses.filter((classInfo) => classInfo.id !== id); + + let url = '/enrollment/'; + + for (let i = 0; i < updatedCourses.length; i++) { + let c = updatedCourses[i]; + if (i !== 0) url += '&'; + let instructor = c.instructor === 'all' ? 'all' : c.sections[0]; + url += `${c.colorId}-${c.courseID}-${toUrlForm(c.semester)}-${instructor}`; + } + + navigate(url, { replace: true }); + }, + [navigate, selectedCourses] + ); + + const removeCourse = useCallback( + (id, color) => { + refillUrl(id); + dispatch(enrollRemoveCourse(id, color)); + }, + [refillUrl, dispatch] + ); + + const updateClassCardEnrollment = useCallback( + (latest_point, telebears, enrolled_info, waitlisted_info) => { + var info = []; + + for (var i = 0; i < latest_point.length; i++) { + info.push([latest_point[i], telebears[i], enrolled_info[i], waitlisted_info[i]]); + } + + setAdditionalInfo(info); + }, + [] + ); + + return ( +
+
+ + + + + + + {!isMobile &&
Days After Phase 1
} +
+ +

+ We source our historic course and enrollment data directly from Berkeley{' '} + Student Information System's Course and + Class APIs. +

+
+
+
+ ); +} diff --git a/frontend/src/app/Grades/index.jsx b/frontend/src/app/Grades/index.jsx new file mode 100644 index 000000000..694d0bf2c --- /dev/null +++ b/frontend/src/app/Grades/index.jsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useDispatch, useSelector } from 'react-redux'; +import ClassCardList from '../../components/ClassCards/ClassCardList'; +import GradesGraphCard from '../../components/GraphCard/GradesGraphCard'; +import GradesSearchBar from '../../components/ClassSearchBar/GradesSearchBar'; + +import info from '../../assets/img/images/graphs/info.svg'; + +import { + fetchGradeContext, + fetchGradeClass, + gradeRemoveCourse, + gradeReset, + fetchGradeFromUrl +} from '../../redux/actions'; + +const toUrlForm = (s) => { + s = s.replace('/', '_'); + return s.toLowerCase().split(' ').join('-'); +}; + +export function Component() { + const [additionalInfo, setAdditionalInfo] = useState([]); + + const location = useLocation(); + const navigate = useNavigate(); + + const { context, selectedCourses, usedColorIds } = useSelector((state) => state.grade); + const { mobile: isMobile } = useSelector((state) => state.common); + + const dispatch = useDispatch(); + + const addToUrl = useCallback( + (course) => { + let instructor = toUrlForm(course.instructor); + + let courseUrl = `${course.colorId}-${course.courseID}-${toUrlForm( + course.semester + )}-${instructor}`; + + let url = location.pathname; + + if (url && !url.includes(courseUrl)) { + url += url === '/grades' ? '/' : ''; + url += url === '/grades/' ? '' : '&'; + url += courseUrl; + + navigate(url, { replace: true }); + } + }, + [navigate, location.pathname] + ); + + const addCourse = useCallback( + (course) => { + for (let selected of selectedCourses) { + if (selected.id === course.id) { + return; + } + } + + let newColorId = ''; + for (let i = 0; i < 4; i++) { + if (!usedColorIds.includes(i.toString())) { + newColorId = i.toString(); + break; + } + } + course.colorId = newColorId; + + addToUrl(course); + dispatch(fetchGradeClass(course)); + }, + [dispatch, addToUrl, selectedCourses, usedColorIds] + ); + + const refillUrl = useCallback( + (id) => { + let updatedCourses = selectedCourses.filter((classInfo) => classInfo.id !== id); + let url = '/grades/'; + for (let i = 0; i < updatedCourses.length; i++) { + let c = updatedCourses[i]; + if (i !== 0) url += '&'; + url += `${c.colorId}-${c.courseID}-${toUrlForm(c.semester)}-${toUrlForm(c.instructor)}`; + } + navigate(url, { replace: true }); + }, + [navigate, selectedCourses] + ); + + const removeCourse = useCallback( + (id, color) => { + refillUrl(id); + dispatch(gradeRemoveCourse(id, color)); + }, + [dispatch, refillUrl] + ); + + const updateClassCardGrade = useCallback( + (course_letter, course_gpa, section_letter, section_gpa) => { + var info = []; + for (var i = 0; i < course_letter.length; i++) { + info.push([course_letter[i], course_gpa[i], section_letter[i], section_gpa[i]]); + } + setAdditionalInfo(info); + }, + [] + ); + + useEffect(() => { + const fillFromUrl = () => { + try { + let url = location.pathname; + + if (url && (url === '/grades/' || url === '/grades')) { + dispatch(gradeReset()); + } else if (url) { + dispatch(fetchGradeFromUrl(url, navigate)); + } + } catch (err) { + navigate('/error'); + } + }; + + dispatch(fetchGradeContext()); + dispatch(gradeReset()); + fillFromUrl(); + }, []); + + return ( +
+
+ + + + + + +
+ +

+ We source our course grade data from Berkeley's official{' '} + CalAnswers database. +

+
+
+
+ ); +} diff --git a/frontend/src/views/Scheduler/LocalSchedulerPage.tsx b/frontend/src/app/Scheduler/LocalSchedulerPage.tsx similarity index 93% rename from frontend/src/views/Scheduler/LocalSchedulerPage.tsx rename to frontend/src/app/Scheduler/LocalSchedulerPage.tsx index 7970f3577..1bf7bb157 100644 --- a/frontend/src/views/Scheduler/LocalSchedulerPage.tsx +++ b/frontend/src/app/Scheduler/LocalSchedulerPage.tsx @@ -6,20 +6,20 @@ import { useUser } from 'graphql/hooks/user'; import { useCreateSchedule } from 'graphql/hooks/schedule'; import { useLocalStorageState } from 'utils/hooks'; import ScheduleEditor from '../../components/Scheduler/ScheduleEditor'; -import { useHistory } from 'react-router'; +import { useNavigate } from 'react-router-dom'; import { useSemester } from 'graphql/hooks/semester'; import Callout from '../../components/Scheduler/Callout'; import { ReduxState } from 'redux/store'; import { useSelector } from 'react-redux'; -const LocalScheduler = () => { +export function Component() { const [schedule, setSchedule] = useLocalStorageState( SCHEDULER_LOCALSTORAGE_KEY, DEFAULT_SCHEDULE ); const { isLoggedIn, loading: loadingUser } = useUser(); - const history = useHistory(); + const navigate = useNavigate(); const { semester, error: semesterError } = useSemester(); @@ -36,7 +36,7 @@ const LocalScheduler = () => { // Defer this to the next tick setTimeout(() => { - history.push(`/scheduler/${scheduleUUID}`); + navigate(`/scheduler/${scheduleUUID}`); }); } } @@ -105,6 +105,4 @@ const LocalScheduler = () => { /> ); -}; - -export default LocalScheduler; +} diff --git a/frontend/src/views/Scheduler/RemoteSchedulerPage.tsx b/frontend/src/app/Scheduler/RemoteSchedulerPage.tsx similarity index 88% rename from frontend/src/views/Scheduler/RemoteSchedulerPage.tsx rename to frontend/src/app/Scheduler/RemoteSchedulerPage.tsx index d2dc4fc85..9a4175604 100644 --- a/frontend/src/views/Scheduler/RemoteSchedulerPage.tsx +++ b/frontend/src/app/Scheduler/RemoteSchedulerPage.tsx @@ -1,13 +1,13 @@ import BTLoader from 'components/Common/BTLoader'; import { useUser } from 'graphql/hooks/user'; import { useState } from 'react'; -import { Redirect, useParams } from 'react-router'; +import { Navigate, useParams } from 'react-router-dom'; import { DEFAULT_SCHEDULE, Schedule } from 'utils/scheduler/scheduler'; import RemoteScheduler from '../../components/Scheduler/Editor/RemoteScheduler'; import { ReduxState } from 'redux/store'; import { useSelector } from 'react-redux'; -const RemoteSchedulePage = () => { +export function Component() { const { isLoggedIn, loading: userLoading } = useUser(); const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE); const { scheduleId: scheduleUUID } = useParams<{ scheduleId: string }>(); @@ -39,7 +39,7 @@ const RemoteSchedulePage = () => { // if you're not logged in, we'll go to the schedule preview if (!isLoggedIn && !userLoading && scheduleUUID) { - return ; + return ; } return ( @@ -47,6 +47,4 @@ const RemoteSchedulePage = () => { ); -}; - -export default RemoteSchedulePage; +} diff --git a/frontend/src/views/Scheduler/SchedulerOnboard.tsx b/frontend/src/app/Scheduler/SchedulerOnboard.tsx similarity index 95% rename from frontend/src/views/Scheduler/SchedulerOnboard.tsx rename to frontend/src/app/Scheduler/SchedulerOnboard.tsx index eba6e379f..19a156b72 100644 --- a/frontend/src/views/Scheduler/SchedulerOnboard.tsx +++ b/frontend/src/app/Scheduler/SchedulerOnboard.tsx @@ -28,7 +28,7 @@ const pages: { // }, ]; -const SchedulerOnboard = () => { +export function Component() { const [pageIndex, setPageIndex] = useState(0); const PageComponent = pages[pageIndex].component; @@ -55,6 +55,4 @@ const SchedulerOnboard = () => { ); -}; - -export default SchedulerOnboard; +} diff --git a/frontend/src/views/Scheduler/ViewSchedule.tsx b/frontend/src/app/Scheduler/ViewSchedule.tsx similarity index 93% rename from frontend/src/views/Scheduler/ViewSchedule.tsx rename to frontend/src/app/Scheduler/ViewSchedule.tsx index 848b77b8e..e7fc96810 100644 --- a/frontend/src/views/Scheduler/ViewSchedule.tsx +++ b/frontend/src/app/Scheduler/ViewSchedule.tsx @@ -1,6 +1,6 @@ import { useGetScheduleForIdQuery } from 'graphql'; import BTLoader from 'components/Common/BTLoader'; -import { Redirect, useParams } from 'react-router'; +import { Navigate, useParams } from 'react-router-dom'; import { deserializeSchedule, formatScheduleError, @@ -13,7 +13,7 @@ import { Button } from 'bt/custom'; import { ReduxState } from 'redux/store'; import { useSelector } from 'react-redux'; -const ViewSchedule = () => { +export function Component() { const { scheduleId: scheduleUUID } = useParams<{ scheduleId?: string }>(); const { user } = useUser(); @@ -37,7 +37,7 @@ const ViewSchedule = () => { } if (!scheduleUUID) { - return ; + return ; } if (!data) { @@ -78,6 +78,4 @@ const ViewSchedule = () => { ); -}; - -export default ViewSchedule; +} diff --git a/frontend/src/components/ClassCards/ClassCardList.jsx b/frontend/src/components/ClassCards/ClassCardList.jsx index ba5b4d7b2..fc85a09ea 100644 --- a/frontend/src/components/ClassCards/ClassCardList.jsx +++ b/frontend/src/components/ClassCards/ClassCardList.jsx @@ -1,36 +1,35 @@ -import { PureComponent } from 'react'; import { Container, Row } from 'react-bootstrap'; import ClassCard from './ClassCard'; -import vars from '../../variables/Variables'; +import vars from '../../utils/variables'; -class ClassCardList extends PureComponent { - render() { - const { selectedCourses, removeCourse, additionalInfo, type, isMobile } = this.props; - - return ( - - - {selectedCourses.map((item, i) => ( - - ))} - - - ); - } +export default function ClassCardList({ + selectedCourses, + removeCourse, + additionalInfo, + type, + isMobile +}) { + return ( + + + {selectedCourses.map((item, i) => ( + + ))} + + + ); } - -export default ClassCardList; diff --git a/frontend/src/components/ClassSearchBar/EnrollmentSearchBar.jsx b/frontend/src/components/ClassSearchBar/EnrollmentSearchBar.jsx index 65441119c..f320e166d 100644 --- a/frontend/src/components/ClassSearchBar/EnrollmentSearchBar.jsx +++ b/frontend/src/components/ClassSearchBar/EnrollmentSearchBar.jsx @@ -1,320 +1,276 @@ -import { Component } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Container, Row, Col, Button } from 'react-bootstrap'; import hash from 'object-hash'; import { fetchEnrollSelected } from '../../redux/actions'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import BTSelect from 'components/Custom/Select'; -class EnrollmentSearchBar extends Component { - constructor(props) { - super(props); +const buildCoursesOptions = (courses) => { + if (!courses) { + return []; + } + const options = courses.map((course) => ({ + value: course.id, + label: `${course.abbreviation} ${course.course_number}`, + course + })); - this.state = { - selectedClass: 0, - selectPrimary: this.props.selectPrimary, - selectSecondary: this.props.selectSecondary - }; + return options; +}; - this.queryCache = {}; +const capitalize = (str) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; - this.handleClassSelect = this.handleClassSelect.bind(this); - this.handlePrimarySelect = this.handlePrimarySelect.bind(this); - this.handleSecondarySelect = this.handleSecondarySelect.bind(this); - this.buildCoursesOptions = this.buildCoursesOptions.bind(this); - this.buildPrimaryOptions = this.buildPrimaryOptions.bind(this); - this.buildSecondaryOptions = this.buildSecondaryOptions.bind(this); - this.getFilteredSections = this.getFilteredSections.bind(this); - this.addSelected = this.addSelected.bind(this); - this.reset = this.reset.bind(this); - } +const getSectionSemester = (section) => { + return `${capitalize(section.semester)} ${section.year}`; +}; - componentDidMount() { - let { fromCatalog } = this.props; - if (fromCatalog) { - this.handleClassSelect({ value: fromCatalog.id, addSelected: true }); - } - } +const buildPrimaryOptions = (sections) => { + const ret = []; + const map = new Map(); - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.selectPrimary !== this.state.selectPrimary) { - this.setState({ - selectPrimary: nextProps.selectPrimary - }); - } - if (nextProps.selectSecondary !== this.state.selectSecondary) { - this.setState({ - selectSecondary: nextProps.selectSecondary + for (const section of sections) { + let semester = getSectionSemester(section); + if (!map.has(semester)) { + map.set(semester, true); + ret.push({ + value: semester, + label: semester }); } } - handleClassSelect(updatedClass) { - const { fetchEnrollSelected } = this.props; - if (updatedClass === null) { - this.reset(); - this.setState({ - selectedClass: 0 - }); - return; - } - - this.setState({ - selectedClass: updatedClass.value, - selectPrimary: '', - selectSecondary: '' - }); - - fetchEnrollSelected(updatedClass); - } - - handlePrimarySelect(primary) { - this.setState({ - selectPrimary: primary ? primary.value : '', - selectSecondary: primary ? { value: 'all', label: 'All Instructors' } : '' - }); - } + return ret; +}; - handleSecondarySelect(secondary) { - this.setState({ - selectSecondary: secondary ? secondary : { value: 'all', label: 'All Instructors' } - }); +const buildSecondaryOptions = (semesters, selectPrimary) => { + if (semesters.length === 0 || selectPrimary === undefined || selectPrimary === '') { + return []; } - buildCoursesOptions(courses) { - if (!courses) { - return []; - } - const options = courses.map((course) => ({ - value: course.id, - label: `${course.abbreviation} ${course.course_number}`, - course - })); - - return options; - } + const ret = []; - capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); + let sections = semesters.filter((semester) => getSectionSemester(semester) === selectPrimary)[0] + .sections; + if (sections.length > 1) { + ret.push({ value: 'all', label: 'All Instructors' }); } - getSectionSemester(section) { - return `${this.capitalize(section.semester)} ${section.year}`; + for (var section of sections) { + let instructor = `${ + section.instructor === null || section.instructor === '' ? 'None' : section.instructor + } / ${section.section_number}`; + ret.push({ + value: instructor, + label: instructor, + sectionNumber: instructor.split(' / ')[1], + sectionId: section.section_id + }); } + return ret; +}; - buildPrimaryOptions(sections) { - const ret = []; - const map = new Map(); +const customStyles = { + clearIndicator: (provided) => ({ + ...provided, + marginRight: 0, + paddingRight: 0 + }) +}; - for (const section of sections) { - let semester = this.getSectionSemester(section); - if (!map.has(semester)) { - map.set(semester, true); - ret.push({ - value: semester, - label: semester - }); +export default function EnrollmentSearchBar({ + classes, + isFull, + isMobile, + addCourse, + sectionNumber, + fromCatalog +}) { + const [selectedClass, setSelectedClass] = useState(0); + const { sections, selectPrimary, selectSecondary } = useSelector((state) => state.enrollment); + const [localSelectPrimary, setLocalSelectPrimary] = useState(selectPrimary); + const [localSelectSecondary, setLocalSelectSecondary] = useState(selectSecondary); + const dispatch = useDispatch(); + + const handleClassSelect = useCallback( + (updatedClass) => { + if (updatedClass === null) { + reset(); + setSelectedClass(0); + return; } - } - - return ret; - } - - buildSecondaryOptions(semesters, selectPrimary) { - if (semesters.length === 0 || selectPrimary === undefined || selectPrimary === '') { - return []; - } - - const ret = []; - let sections = semesters.filter( - (semester) => this.getSectionSemester(semester) === selectPrimary - )[0].sections; - if (sections.length > 1) { - ret.push({ value: 'all', label: 'All Instructors' }); - } + setSelectedClass(updatedClass.value); + setLocalSelectPrimary(''); + setLocalSelectSecondary(''); + dispatch(fetchEnrollSelected(updatedClass)); + }, + [dispatch] + ); + + useEffect(() => { + if (!fromCatalog) return; + handleClassSelect({ value: fromCatalog.id, addSelected: true }); + }, [fromCatalog, handleClassSelect]); + + useEffect(() => setLocalSelectPrimary(selectPrimary), [selectPrimary]); + useEffect(() => setLocalSelectSecondary(selectSecondary), [selectSecondary]); + + const handlePrimarySelect = (primary) => { + setLocalSelectPrimary(primary ? primary.value : ''); + setLocalSelectSecondary(primary ? { value: 'all', label: 'All Instructors' } : ''); + }; - for (var section of sections) { - let instructor = `${ - section.instructor === null || section.instructor === '' ? 'None' : section.instructor - } / ${section.section_number}`; - ret.push({ - value: instructor, - label: instructor, - sectionNumber: instructor.split(' / ')[1], - sectionId: section.section_id - }); - } - return ret; - } + const handleSecondarySelect = (secondary) => { + setLocalSelectSecondary(secondary ? secondary : { value: 'all', label: 'All Instructors' }); + }; - getFilteredSections() { - const { selectPrimary, selectSecondary, sectionNumber } = this.state; - const { sections } = this.props; + const getFilteredSections = () => { let ret; ret = sections .filter((section) => { - return this.getSectionSemester(section) === selectPrimary; + return getSectionSemester(section) === localSelectPrimary; })[0] .sections.filter((section) => { - return selectSecondary.value === 'all' + return localSelectSecondary.value === 'all' ? true - : section.instructor === selectSecondary.value.split(' / ')[0]; + : section.instructor === localSelectSecondary.value.split(' / ')[0]; }) .filter((section) => { return sectionNumber ? section.section_number === sectionNumber : true; }) .map((s) => s.section_id); return ret; - } + }; - addSelected() { - const { selectedClass, selectPrimary, selectSecondary } = this.state; - const { sections } = this.props; - let secondaryOptions = this.buildSecondaryOptions(sections, selectPrimary); + const addSelected = () => { + let secondaryOptions = buildSecondaryOptions(sections, localSelectPrimary); let instructor = ''; let sectionId = []; if (secondaryOptions.length === 1) { instructor = secondaryOptions[0].value; sectionId = [secondaryOptions[0].sectionId]; } else { - if (selectSecondary.value === 'all') { + if (localSelectSecondary.value === 'all') { instructor = 'all'; } else { - instructor = selectSecondary.value; + instructor = localSelectSecondary.value; } - if (selectSecondary.sectionId) { - sectionId = [selectSecondary.sectionId]; + if (localSelectSecondary.sectionId) { + sectionId = [localSelectSecondary.sectionId]; } else { - sectionId = this.getFilteredSections(); + sectionId = getFilteredSections(); } } let playlist = { courseID: selectedClass, instructor: instructor, - semester: selectPrimary, + semester: localSelectPrimary, sections: sectionId }; playlist.id = hash(playlist); - this.props.addCourse(playlist); - this.reset(); - } - - reset() { - this.setState({ - selectPrimary: '', - selectSecondary: '' - }); - } - - render() { - const { classes, isFull, sections, isMobile } = this.props; - const { selectPrimary, selectSecondary, selectedClass } = this.state; - let primaryOptions = this.buildPrimaryOptions(sections); - let secondaryOptions = this.buildSecondaryOptions(sections, selectPrimary); - let onePrimaryOption = primaryOptions && primaryOptions.length === 1 && selectPrimary; - let oneSecondaryOption = - secondaryOptions && secondaryOptions.length === 1 && selectSecondary.value; - - let primaryOption = { value: selectPrimary, label: selectPrimary }; - let secondaryOption = selectSecondary; - - if (selectSecondary === 'all') { - secondaryOption = { value: 'all', label: 'All Instructors' }; - } - - if (selectPrimary === '') { - primaryOption = ''; - } - if (selectSecondary === '') { - secondaryOption = ''; - } - - const customStyles = { - clearIndicator: (provided, state) => ({ - ...provided, - marginRight: 0, - paddingRight: 0 - }) - }; - - return ( - - - - null - }} - styles={customStyles} - /> - - - null - }} - styles={customStyles} - /> - - - null - }} - styles={customStyles} - /> - - - - - - - ); - } -} - -const mapDispatchToProps = (dispatch) => { - return { - dispatch, - fetchEnrollSelected: (updatedClass) => dispatch(fetchEnrollSelected(updatedClass)) + addCourse(playlist); + reset(); }; -}; -const mapStateToProps = (state) => { - const { sections, selectPrimary, selectSecondary } = state.enrollment; - return { - sections, - selectPrimary, - selectSecondary + const reset = () => { + setLocalSelectPrimary(''); + setLocalSelectSecondary(''); }; -}; -export default connect(mapStateToProps, mapDispatchToProps)(EnrollmentSearchBar); + const primaryOptions = useMemo(() => buildPrimaryOptions(sections), [sections]); + + const secondaryOptions = useMemo( + () => buildSecondaryOptions(sections, localSelectPrimary), + [sections, localSelectPrimary] + ); + + const onePrimaryOption = useMemo( + () => primaryOptions && primaryOptions.length === 1 && localSelectPrimary, + [primaryOptions, localSelectPrimary] + ); + + const oneSecondaryOption = useMemo( + () => secondaryOptions && secondaryOptions.length === 1 && localSelectSecondary.value, + [secondaryOptions, localSelectSecondary] + ); + + const primaryOption = useMemo( + () => + localSelectPrimary === '' ? '' : { value: localSelectPrimary, label: localSelectPrimary }, + [localSelectPrimary] + ); + + const secondaryOption = useMemo( + () => + localSelectSecondary === '' + ? '' + : localSelectSecondary === '' + ? { value: 'all', label: 'All Instructors' } + : localSelectSecondary, + [localSelectSecondary] + ); + + return ( + + + + null + }} + styles={customStyles} + /> + + + null + }} + styles={customStyles} + /> + + + null + }} + styles={customStyles} + /> + + + + + + + ); +} diff --git a/frontend/src/components/ClassSearchBar/GradesSearchBar.jsx b/frontend/src/components/ClassSearchBar/GradesSearchBar.jsx index c4b57a4de..39038bedc 100644 --- a/frontend/src/components/ClassSearchBar/GradesSearchBar.jsx +++ b/frontend/src/components/ClassSearchBar/GradesSearchBar.jsx @@ -1,8 +1,8 @@ -import { Component } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Container, Row, Col, Button } from 'react-bootstrap'; import hash from 'object-hash'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { fetchGradeSelected } from '../../redux/actions'; import BTSelect from 'components/Custom/Select'; @@ -11,247 +11,215 @@ const sortOptions = [ { value: 'instructor', label: 'By Instructor' }, { value: 'semester', label: 'By Semester' } ]; -class GradesSearchBar extends Component { - constructor(props) { - super(props); - - this.state = { - selectedClass: 0, - selectType: 'instructor', - selectPrimary: props.selectPrimary, - selectSecondary: props.selectSecondary - }; - this.queryCache = {}; - - this.handleClassSelect = this.handleClassSelect.bind(this); - this.handleSortSelect = this.handleSortSelect.bind(this); - this.handlePrimarySelect = this.handlePrimarySelect.bind(this); - this.handleSecondarySelect = this.handleSecondarySelect.bind(this); - this.buildCoursesOptions = this.buildCoursesOptions.bind(this); - this.buildPrimaryOptions = this.buildPrimaryOptions.bind(this); - this.buildSecondaryOptions = this.buildSecondaryOptions.bind(this); - this.getFilteredSections = this.getFilteredSections.bind(this); - this.addSelected = this.addSelected.bind(this); - this.reset = this.reset.bind(this); - } - componentDidMount() { - const { fromCatalog } = this.props; - this.setState({ - selectType: 'instructor' - }); - if (fromCatalog) { - this.handleClassSelect({ value: fromCatalog.id, addSelected: true }); - } +const buildCoursesOptions = (courses) => { + if (!courses) { + return []; } - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.selectPrimary !== this.state.selectPrimary) { - this.setState({ - selectPrimary: nextProps.selectPrimary - }); + const options = courses.map((course) => ({ + value: course.id, + label: `${course.abbreviation} ${course.course_number}`, + course + })); + + return options; +}; + +const capitalize = (str) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +const getSectionSemester = (section) => { + return `${capitalize(section.semester)} ${section.year}`; +}; + +const buildPrimaryOptions = (sections, selectType) => { + const ret = []; + const map = new Map(); + + if (selectType === 'instructor') { + if (sections.length > 1) { + ret.push({ value: 'all', label: 'All Instructors' }); } - if (nextProps.selectSecondary !== this.state.selectSecondary) { - this.setState({ - selectSecondary: nextProps.selectSecondary - }); + for (const section of sections) { + if (!map.has(section.instructor)) { + map.set(section.instructor, true); + ret.push({ + value: section.instructor, + label: section.instructor + }); + } + } + } else { + if (sections.length > 1) { + ret.push({ value: 'all', label: 'All Semesters' }); + } + for (const section of sections) { + const semester = getSectionSemester(section); + if (!map.has(semester)) { + map.set(semester, true); + ret.push({ + value: semester, + label: semester + }); + } } } - handleClassSelect(updatedClass) { - const { fetchGradeSelected } = this.props; - if (updatedClass === null) { - this.reset(); - this.setState({ - selectedClass: 0, - selectPrimary: '', - selectSecondary: '' - }); - return; - } + return ret; +}; - this.setState({ - selectedClass: updatedClass.value - }); +const buildSecondaryOptions = (sections, selectType, selectPrimary) => { + const ret = []; - fetchGradeSelected(updatedClass); - } + if (selectPrimary === 'all') { + let options; + if (selectType === 'instructor') { + options = [ + ...new Set(sections.map((s) => `${getSectionSemester(s)} / ${s.section_number}`)) + ].map((semester) => ({ + value: semester.split(' / ')[0], + label: semester, + sectionNumber: semester.split(' / ')[1] + })); + } else { + options = [...new Set(sections.map((s) => `${s.instructor} / ${s.section_number}`))].map( + (instructor) => ({ + value: instructor.split(' / ')[0], + label: instructor, + sectionNumber: instructor.split(' / ')[1] + }) + ); + } - handleSortSelect(sortBy) { - this.setState({ - selectType: sortBy.value, - selectPrimary: '', - selectSecondary: '' - }); - } + if (options.length > 1) { + const label = selectType === 'instructor' ? 'All Semesters' : 'All Instructors'; + ret.push({ value: 'all', label }); + } - handlePrimarySelect(primary) { - const { sections } = this.props; - const { selectType } = this.state; - const secondaryOptions = this.buildSecondaryOptions(sections, selectType, primary.value); - this.setState({ - selectPrimary: primary ? primary.value : '', - selectSecondary: secondaryOptions.length === 1 ? secondaryOptions[0].value : 'all' - }); - } + for (const o of options) { + ret.push(o); + } + } else { + let options; + if (selectType === 'instructor') { + options = sections + .filter((section) => section.instructor === selectPrimary) + .map((section) => { + const semester = `${getSectionSemester(section)} / ${section.section_number}`; - handleSecondarySelect(secondary) { - this.setState({ - selectSecondary: secondary ? secondary.value : '' - }); - } + return { + value: semester, + label: semester, + sectionNumber: semester.split(' / ')[1] + }; + }); + } else { + options = sections + .filter((section) => getSectionSemester(section) === selectPrimary) + .map((section) => { + const instructor = `${section.instructor} / ${section.section_number}`; + return { + value: instructor, + label: instructor, + sectionNumber: instructor.split(' / ')[1] + }; + }); + } - buildCoursesOptions(courses) { - if (!courses) { - return []; + if (options.length > 1) { + const label = selectType === 'instructor' ? 'All Semesters' : 'All Instructors'; + ret.push({ value: 'all', label }); } - const options = courses.map((course) => ({ - value: course.id, - label: `${course.abbreviation} ${course.course_number}`, - course - })); - return options; + for (const o of options) { + ret.push(o); + } } - capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); - } + return ret; +}; - getSectionSemester(section) { - return `${this.capitalize(section.semester)} ${section.year}`; - } +const customStyles = { + clearIndicator: (provided) => ({ + ...provided, + marginRight: 0, + paddingRight: 0 + }) +}; - buildPrimaryOptions(sections, selectType) { - const ret = []; - const map = new Map(); - if (selectType === 'instructor') { - if (sections.length > 1) { - ret.push({ value: 'all', label: 'All Instructors' }); - } - for (const section of sections) { - if (!map.has(section.instructor)) { - map.set(section.instructor, true); - ret.push({ - value: section.instructor, - label: section.instructor - }); - } +export default function GradesSearchBar({ fromCatalog, addCourse, isFull, isMobile, classes }) { + const { sections, selectPrimary, selectSecondary } = useSelector((state) => state.grade); + const [localSelectPrimary, setLocalSelectPrimary] = useState(selectPrimary); + const [localSelectSecondary, setLocalSelectSecondary] = useState(selectSecondary); + const [selectedClass, setSelectedClass] = useState(0); + const [selectType, setSelectType] = useState('instructor'); + const dispatch = useDispatch(); + + const handleClassSelect = useCallback( + (updatedClass) => { + if (updatedClass === null) { + reset(); + setSelectedClass(0); + setLocalSelectPrimary(''); + setLocalSelectSecondary(''); + return; } - } else { - if (sections.length > 1) { - ret.push({ value: 'all', label: 'All Semesters' }); - } - for (const section of sections) { - const semester = this.getSectionSemester(section); - if (!map.has(semester)) { - map.set(semester, true); - ret.push({ - value: semester, - label: semester - }); - } - } - } - return ret; - } + setSelectedClass(updatedClass.value); + dispatch(fetchGradeSelected(updatedClass)); + }, + [dispatch] + ); - buildSecondaryOptions(sections, selectType, selectPrimary) { - const ret = []; - - if (selectPrimary === 'all') { - let options; - if (selectType === 'instructor') { - options = [ - ...new Set(sections.map((s) => `${this.getSectionSemester(s)} / ${s.section_number}`)) - ].map((semester) => ({ - value: semester.split(' / ')[0], - label: semester, - sectionNumber: semester.split(' / ')[1] - })); - } else { - options = [...new Set(sections.map((s) => `${s.instructor} / ${s.section_number}`))].map( - (instructor) => ({ - value: instructor.split(' / ')[0], - label: instructor, - sectionNumber: instructor.split(' / ')[1] - }) - ); - } + useEffect(() => { + setSelectType('instructor'); - if (options.length > 1) { - const label = selectType === 'instructor' ? 'All Semesters' : 'All Instructors'; - ret.push({ value: 'all', label }); - } + if (!fromCatalog) return; - for (const o of options) { - ret.push(o); - } - } else { - let options; - if (selectType === 'instructor') { - options = sections - .filter((section) => section.instructor === selectPrimary) - .map((section) => { - const semester = `${this.getSectionSemester(section)} / ${section.section_number}`; - - return { - value: semester, - label: semester, - sectionNumber: semester.split(' / ')[1] - }; - }); - } else { - options = sections - .filter((section) => this.getSectionSemester(section) === selectPrimary) - .map((section) => { - const instructor = `${section.instructor} / ${section.section_number}`; - return { - value: instructor, - label: instructor, - sectionNumber: instructor.split(' / ')[1] - }; - }); - } + handleClassSelect({ value: fromCatalog.id, addSelected: true }); + }, [fromCatalog, handleClassSelect]); - if (options.length > 1) { - const label = selectType === 'instructor' ? 'All Semesters' : 'All Instructors'; - ret.push({ value: 'all', label }); - } + useEffect(() => setLocalSelectPrimary(selectPrimary), [selectPrimary]); + useEffect(() => setLocalSelectSecondary(selectSecondary), [selectSecondary]); - for (const o of options) { - ret.push(o); - } - } + const handleSortSelect = (sortBy) => { + setSelectType(sortBy.value); + setLocalSelectPrimary(''); + setLocalSelectSecondary(''); + }; - return ret; - } + const handlePrimarySelect = (primary) => { + const secondaryOptions = buildSecondaryOptions(sections, selectType, primary.value); + setLocalSelectPrimary(primary ? primary.value : ''); + setLocalSelectSecondary(secondaryOptions.length === 1 ? secondaryOptions[0].value : 'all'); + }; - getFilteredSections() { - const { selectType, selectPrimary, selectSecondary } = this.state; - const { sections } = this.props; - let semester = selectSecondary; + const handleSecondarySelect = (secondary) => { + setLocalSelectSecondary(secondary ? secondary.value : ''); + }; + + const getFilteredSections = () => { + let semester = localSelectSecondary; let number = -1; - if (selectSecondary.split(' ').length > 2) { - semester = selectSecondary.split(' ').slice(0, 2).join(' '); - number = selectSecondary.split(' ')[3]; + if (localSelectSecondary.split(' ').length > 2) { + semester = localSelectSecondary.split(' ').slice(0, 2).join(' '); + number = localSelectSecondary.split(' ')[3]; } let ret; if (selectType === 'instructor') { ret = sections .filter((section) => - selectPrimary === 'all' ? true : section.instructor === selectPrimary - ) - .filter((section) => - semester === 'all' ? true : this.getSectionSemester(section) === semester + localSelectPrimary === 'all' ? true : section.instructor === localSelectPrimary ) + .filter((section) => (semester === 'all' ? true : getSectionSemester(section) === semester)) .filter((section) => (number !== -1 ? section.section_number === number : true)); } else { ret = sections .filter((section) => - selectPrimary === 'all' ? true : this.getSectionSemester(section) === selectPrimary + localSelectPrimary === 'all' ? true : getSectionSemester(section) === localSelectPrimary ) .filter((section) => (semester === 'all' ? true : section.instructor === semester)) .filter((section) => (number !== -1 ? section.section_number === number : true)); @@ -259,162 +227,144 @@ class GradesSearchBar extends Component { ret = ret.map((s) => s.grade_id); return ret; - } - - addSelected() { - const { selectedClass, selectType, selectPrimary, selectSecondary } = this.state; + }; + const addSelected = () => { const playlist = { courseID: selectedClass, - instructor: selectType === 'instructor' ? selectPrimary : selectSecondary, - semester: selectType === 'semester' ? selectPrimary : selectSecondary, - sections: this.getFilteredSections() + instructor: selectType === 'instructor' ? localSelectPrimary : localSelectSecondary, + semester: selectType === 'semester' ? localSelectPrimary : localSelectSecondary, + sections: getFilteredSections() }; playlist.id = hash(playlist); - this.props.addCourse(playlist); - this.reset(); - } + addCourse(playlist); + reset(); + }; - reset() { - this.setState({ - selectPrimary: '', - selectSecondary: '' - }); - } + const reset = () => { + setLocalSelectPrimary(''); + setLocalSelectSecondary(''); + }; - render() { - const { classes, isFull, isMobile } = this.props; - const { selectType, selectPrimary, selectSecondary, selectedClass } = this.state; - const { sections } = this.props; - const primaryOptions = this.buildPrimaryOptions(sections, selectType); - const secondaryOptions = this.buildSecondaryOptions(sections, selectType, selectPrimary); - const onePrimaryOption = primaryOptions && primaryOptions.length === 1 && selectPrimary; - const oneSecondaryOption = secondaryOptions && secondaryOptions.length === 1 && selectSecondary; + const primaryOptions = useMemo( + () => buildPrimaryOptions(sections, selectType), + [sections, selectType] + ); + + const secondaryOptions = useMemo( + () => buildSecondaryOptions(sections, selectType, localSelectPrimary), + [sections, selectType, localSelectPrimary] + ); + + const onePrimaryOption = useMemo( + () => primaryOptions && primaryOptions.length === 1 && localSelectPrimary, + [localSelectPrimary, primaryOptions] + ); + + const oneSecondaryOption = useMemo( + () => secondaryOptions && secondaryOptions.length === 1 && localSelectSecondary, + [secondaryOptions, localSelectSecondary] + ); + + let primaryOption = useMemo(() => { + if (localSelectPrimary === '') return ''; + + if (localSelectPrimary === 'all') { + return { + value: 'all', + label: selectType === 'instructor' ? 'All Instructors' : 'All Semesters' + }; + } - let primaryOption = { value: selectPrimary, label: selectPrimary }; - let secondaryOption = { value: selectSecondary, label: selectSecondary }; + return { value: localSelectPrimary, label: localSelectPrimary }; + }, [localSelectPrimary, selectType]); - if (selectType === 'instructor') { - if (selectPrimary === 'all') { - primaryOption = { value: 'all', label: 'All Instructors' }; - } - if (selectSecondary === 'all') { - secondaryOption = { value: 'all', label: 'All Semesters' }; - } - } else { - if (selectPrimary === 'all') { - primaryOption = { value: 'all', label: 'All Semesters' }; - } - if (selectSecondary === 'all') { - secondaryOption = { value: 'all', label: 'All Instructors' }; - } - } + let secondaryOption = useMemo(() => { + if (localSelectSecondary === '') return ''; - if (selectPrimary === '') { - primaryOption = ''; - } - if (selectSecondary === '') { - secondaryOption = ''; + if (localSelectSecondary === 'all') { + return { + value: 'all', + label: selectType === 'instructor' ? 'All Semesters' : 'All Instructors' + }; } - const customStyles = { - clearIndicator: (provided, state) => ({ - ...provided, - marginRight: 0, - paddingRight: 0 - }) - }; - - return ( - - - - null - }} - styles={customStyles} - /> - - - null - }} - styles={customStyles} - /> - - - null - }} - styles={customStyles} - /> - - - null - }} - styles={customStyles} - /> - - - - - - - ); - } + return { value: localSelectSecondary, label: localSelectSecondary }; + }, [localSelectSecondary, selectType]); + + return ( + + + + null + }} + styles={customStyles} + /> + + + null + }} + styles={customStyles} + /> + + + null + }} + styles={customStyles} + /> + + + null + }} + styles={customStyles} + /> + + + + + + + ); } - -const mapDispatchToProps = (dispatch) => ({ - dispatch, - fetchGradeSelected: (updatedClass) => dispatch(fetchGradeSelected(updatedClass)) -}); - -const mapStateToProps = (state) => { - const { sections, selectPrimary, selectSecondary } = state.grade; - return { - sections, - selectPrimary, - selectSecondary - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(GradesSearchBar); diff --git a/frontend/src/components/Common/Banner.tsx b/frontend/src/components/Common/Banner.tsx index 21a0fc230..61295a1f4 100644 --- a/frontend/src/components/Common/Banner.tsx +++ b/frontend/src/components/Common/Banner.tsx @@ -1,42 +1,23 @@ -import { FC } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -// import { Button } from 'react-bootstrap' -import { useHistory } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; import { Button } from 'bt/custom'; -import { ReduxState } from '../../redux/store'; import { closeBanner } from '../../redux/common/actions'; import close from '../../assets/svg/common/close.svg'; +import { ReduxState } from 'redux/store'; -type Props = PropsFromRedux; +export default function Banner() { + const { banner } = useSelector((state: ReduxState) => state.common); + const dispatch = useDispatch(); -const Banner: FC = (props) => { - const history = useHistory(); - function redirect(site: string) { - history.push('/redirect?site=' + site); - } - - return props.banner ? ( + return banner ? (

Berkeleytime is looking for student designers and developers to join our team!

- +
- close + close dispatch(closeBanner())} />
) : null; -}; - -const mapState = (state: ReduxState) => ({ - banner: state.common.banner -}); - -const mapDispatch = { - closeBanner -}; - -const connector = connect(mapState, mapDispatch); - -type PropsFromRedux = ConnectedProps; - -export default connector(Banner); +} diff --git a/frontend/src/components/Common/Description.tsx b/frontend/src/components/Common/Description.tsx index c158e412d..35d147380 100644 --- a/frontend/src/components/Common/Description.tsx +++ b/frontend/src/components/Common/Description.tsx @@ -1,51 +1,58 @@ -import { Component } from 'react'; +import { useEffect, useState } from 'react'; import Markdown from 'react-markdown'; import { Container, Row, Col, Button } from 'react-bootstrap'; import { Link } from 'react-router-dom'; -export default class Description extends Component { - constructor(props) { - super(props); - this.state = { - body: '' - }; - } +interface DescriptionProps { + title: string; + bodyURL: string; +} - componentDidMount() { - fetch(this.props.bodyURL) - .then((response) => response.text()) - .then((text) => this.setState({ body: text })); - } +interface LinkBarProps { + link: string; + linkName: string; +} - render() { - const { body } = this.state; - const { title, link, linkName } = this.props; +export default function Description({ + title, + link, + linkName, + bodyURL +}: LinkBarProps & DescriptionProps) { + const [body, setBody] = useState(''); - return ( -
- - - - -
-

{title}

-
- - {body} - - - - -
-
-
- ); - } -} + useEffect(() => { + const initialize = async () => { + const response = await fetch(bodyURL); + const text = await response.text(); + setBody(text); + }; -export function LinkBar(props) { - const { link, linkName } = props; + initialize(); + }, [bodyURL]); + return ( +
+ + + + +
+

{title}

+
+ + {body} + + + + +
+
+
+ ); +} + +export function LinkBar({ link, linkName }: LinkBarProps) { return (
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Common/FitToViewport.tsx b/frontend/src/components/Common/FitToViewport.tsx deleted file mode 100644 index b9f98a764..000000000 --- a/frontend/src/components/Common/FitToViewport.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { FC } from 'react'; - -interface Props { - underNavbar?: boolean; -} - -const FitToViewport: FC = (props) => { - const className = 'fit-to-viewport' + (props.underNavbar ? ' under-navbar' : ''); - - return
{props.children}
; -}; - -export default FitToViewport; diff --git a/frontend/src/components/Common/Footer.tsx b/frontend/src/components/Common/Footer.tsx index 6bffb4f90..0d48d8ff7 100644 --- a/frontend/src/components/Common/Footer.tsx +++ b/frontend/src/components/Common/Footer.tsx @@ -1,69 +1,68 @@ -import React, { FC } from 'react'; import { Container, Row, Col } from 'react-bootstrap'; import { H6, A } from 'bt/custom'; -const Footer: FC = () => ( - -); - -export default Footer; + + + + + ); +} diff --git a/frontend/src/components/Common/Layout.tsx b/frontend/src/components/Common/Layout.tsx index 9ca154148..e67cb2b12 100644 --- a/frontend/src/components/Common/Layout.tsx +++ b/frontend/src/components/Common/Layout.tsx @@ -1,24 +1,39 @@ -import Navigation from 'components/Common/Navigation'; -import Footer from 'components/Common/Footer'; -import { ReactNode } from 'react'; +import { Suspense, useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; +import ReactGA from 'react-ga'; +import Banner from './Banner'; +import Navigation from './Navigation'; +import BTLoader from './BTLoader'; +import Footer from './Footer'; + +ReactGA.initialize('UA-35316609-1'); interface LayoutProps { - noFooter?: boolean; - children: ReactNode; + footer?: boolean; } -const Layout = ({ children, noFooter }: LayoutProps) => { +export default function RootLayout({ footer }: LayoutProps) { + const location = useLocation(); + + useEffect(() => { + // Scroll to top + window.scrollTo({ top: 0 }); + + // Log page view + ReactGA.set({ page: window.location.pathname }); + ReactGA.pageview(window.location.pathname); + }, [location.pathname]); + return ( <> + - {children} - {!noFooter &&