diff --git a/frontend/package.json b/frontend/package.json index 44ff0085e..0bd0558cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,10 @@ }, "dependencies": { "@apollo/client": "^3.2.5", + "@radix-ui/react-tabs": "^1.0.4", "axios": "^1.2.6", "bootstrap": "^4.5.2", + "clsx": "^2.0.0", "color": "^3.1.3", "date-fns": "^2.21.1", "fuse.js": "^6.6.2", @@ -41,7 +43,7 @@ "react-select": "^5.7.2", "react-tooltip": "^5.7.2", "react-window": "^1.8.5", - "recharts": "^2.3.2", + "recharts": "^2.8.0", "redux": "^4.0.4", "redux-thunk": "^2.3.0" }, @@ -79,7 +81,7 @@ "sass": "^1.50.1", "serve": "^14.2.0", "typescript": "^4.9.5", - "vite": "^4.3.1", + "vite": "^4.4.11", "vite-plugin-html": "^3.2.0", "vite-plugin-svgr": "^2.4.0", "vite-tsconfig-paths": "^4.2.0" diff --git a/frontend/src/Berkeleytime.tsx b/frontend/src/Berkeleytime.tsx index 9d6f45d6a..dc460a79c 100644 --- a/frontend/src/Berkeleytime.tsx +++ b/frontend/src/Berkeleytime.tsx @@ -1,11 +1,10 @@ -import { memo, useEffect } from 'react'; +import { useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { openBanner, enterMobile, exitMobile, openLandingModal } from './redux/common/actions'; import useDimensions from 'react-cool-dimensions'; import easterEgg from 'utils/easterEgg'; import Routes from './Routes'; -import { fetchEnrollContext } from 'redux/actions'; -import { IconoirProvider } from 'iconoir-react'; +import { fetchEnrollContext, fetchGradeContext } from 'redux/actions'; const Berkeleytime = () => { const dispatch = useDispatch(); @@ -22,8 +21,10 @@ const Berkeleytime = () => { }); useEffect(() => { + observe(document.getElementById('root')); // Fetch enrollment context early on for catalog and enrollment page. dispatch(fetchEnrollContext()); + dispatch(fetchGradeContext()); const bannerType = 'fa23recruitment'; // should match value in ./redux/common/reducer.ts if (localStorage.getItem('bt-hide-banner') !== bannerType) { @@ -41,15 +42,9 @@ const Berkeleytime = () => { if (localStorage.getItem(key) === null) { localStorage.setItem(key, key); } - }, [dispatch]); - - return ( -
- - - -
- ); + }, [dispatch, observe]); + + return ; }; -export default memo(Berkeleytime); +export default Berkeleytime; diff --git a/frontend/src/app/Catalog/Catalog.tsx b/frontend/src/app/Catalog/Catalog.tsx deleted file mode 100644 index 3345cf5c9..000000000 --- a/frontend/src/app/Catalog/Catalog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState } from 'react'; -import { CurrentFilters, SortOption } from './types'; -import catalogService from './service'; -import styles from './Catalog.module.scss'; -import CatalogFilters from './CatalogFilters'; -import CatalogList from './CatalogList'; -import CatalogView from './CatalogView'; -import { CourseFragment } from 'graphql'; -import { useLocation } from 'react-router-dom'; - -const { SORT_OPTIONS, INITIAL_FILTERS } = catalogService; - -const Catalog = () => { - const [currentFilters, setCurrentFilters] = useState(INITIAL_FILTERS); - const [currentCourse, setCurrentCourse] = useState(null); - const [sortQuery, setSortQuery] = useState(SORT_OPTIONS[0]); - const [searchQuery, setSearchQuery] = useState(''); - const location = useLocation(); - const [sortDir, setDir] = useState(false); - - useEffect(() => { - const params = new URLSearchParams(location.search); - if (params.has('q')) setSearchQuery(params.get('q') ?? ''); - }, [location.search]); - - return ( -
- - - -
- ); -}; - -export default Catalog; diff --git a/frontend/src/app/Catalog/CatalogFilters/index.ts b/frontend/src/app/Catalog/CatalogFilters/index.ts deleted file mode 100644 index 978858c71..000000000 --- a/frontend/src/app/Catalog/CatalogFilters/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CatalogFilters from './CatalogFilters'; - -export default CatalogFilters; diff --git a/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx b/frontend/src/app/Catalog/CatalogFilters/index.tsx similarity index 58% rename from frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx rename to frontend/src/app/Catalog/CatalogFilters/index.tsx index 2d66d9786..a86acd4ec 100644 --- a/frontend/src/app/Catalog/CatalogFilters/CatalogFilters.tsx +++ b/frontend/src/app/Catalog/CatalogFilters/index.tsx @@ -1,77 +1,47 @@ -import { - Dispatch, - memo, - SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState -} from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { ActionMeta } from 'react-select'; import BTSelect from 'components/Custom/Select'; - -import catalogService from '../service'; import { ReactComponent as SearchIcon } from 'assets/svg/common/search.svg'; import { ReactComponent as FilterIcon } from 'assets/svg/catalog/filter.svg'; import BTInput from 'components/Custom/Input'; -import { CurrentFilters, FilterOption, SortOption, CatalogFilterKeys, CatalogSlug } from '../types'; - +import { FilterOption, SortOption, CatalogFilterKey, CatalogSlug, FilterTemplate } from '../types'; import { useGetFiltersQuery } from 'graphql'; import BTLoader from 'components/Common/BTLoader'; -import { useNavigate, useParams } from 'react-router-dom'; - +import { useNavigate, useLocation, useParams } from 'react-router'; import styles from './CatalogFilters.module.scss'; import { SortDown, SortUp } from 'iconoir-react'; +import useCatalog from '../useCatalog'; +import { FILTER_TEMPLATE, SORT_OPTIONS, putFilterOptions } from '../service'; -type CatalogFilterProps = { - currentFilters: CurrentFilters; - sortQuery: SortOption; - searchQuery: string; - setCurrentFilters: Dispatch>; - setSortQuery: Dispatch>; - setSearchQuery: Dispatch>; - setDir: Dispatch>; - sortDir: boolean; -}; - -const { SORT_OPTIONS, FILTER_TEMPLATE, INITIAL_FILTERS } = catalogService; - -const CatalogFilters = (props: CatalogFilterProps) => { - const { - currentFilters, - setCurrentFilters, - sortQuery, - searchQuery, - setSortQuery, - setSearchQuery, - setDir, - sortDir - } = props; +const CatalogFilters = () => { + const [template, setTemplate] = useState(null); + const { loading, error } = useGetFiltersQuery({ + onCompleted: (data) => setTemplate(putFilterOptions(FILTER_TEMPLATE, data)) + }); - const { data, loading, error } = useGetFiltersQuery(); - const [isOpen, setOpen] = useState(false); - const filters = useMemo(() => catalogService.processFilterData(data), [data]); const navigate = useNavigate(); + const location = useLocation(); const slug = useParams(); + const [isOpen, setOpen] = useState(false); const modalRef = useRef(null); + const [{ sortDir, sortQuery, searchQuery, filters }, dispatch] = useCatalog(); - const filterList = useMemo( - () => catalogService.putFilterOptions(FILTER_TEMPLATE, filters), - [filters] - ); + useEffect(() => { + const params = new URLSearchParams(location.search); + dispatch({ type: 'search', query: params.get('q') ?? '' }); + }, [dispatch, location.search]); useEffect(() => { - if (filterList?.semester) { - const options = filterList.semester.options as FilterOption[]; + if (template?.semester) { + const options = template.semester.options as FilterOption[]; const semester = options.find(({ label }) => label === slug?.semester) ?? null; - setCurrentFilters((prev) => ({ - ...prev, - semester: semester ?? options[0] - })); + dispatch({ + type: 'filter', + filters: { semester: semester ?? options[0] } + }); } - }, [filterList, setCurrentFilters, slug?.semester]); + }, [template, slug?.semester, dispatch]); useEffect(() => { const ref = modalRef; @@ -88,33 +58,24 @@ const CatalogFilters = (props: CatalogFilterProps) => { }; }, [isOpen, modalRef, setOpen]); - const handleFilterReset = useCallback(() => { - setSortQuery(SORT_OPTIONS[0]); - setSearchQuery(''); - - if (filterList) { - const semester = filterList.semester.options[0] as FilterOption; - setCurrentFilters({ - ...INITIAL_FILTERS, - semester - }); - + const handleFilterReset = () => { + if (template) { + const semester = template.semester.options[0] as FilterOption; + dispatch({ type: 'reset', filters: { semester } }); navigate({ pathname: `/catalog/${semester.value.name}` }); } - }, [filterList, navigate, setCurrentFilters, setSearchQuery, setSortQuery]); + }; const handleFilterChange = ( newValue: FilterOption | readonly FilterOption[] | null, meta: ActionMeta ) => { - const key = meta.name as CatalogFilterKeys; - setCurrentFilters((prev) => ({ - ...prev, - [key]: newValue - })); - + dispatch({ + type: 'filter', + filters: { [meta.name as CatalogFilterKey]: newValue } + }); // Update the url slug if semester filter changes. - if (key === 'semester') { + if (meta.name === 'semester') { navigate({ pathname: `/catalog/${(newValue as FilterOption)?.value?.name}` .concat(slug?.abbreviation ? `/${slug.abbreviation}` : '') @@ -131,8 +92,11 @@ const CatalogFilters = (props: CatalogFilterProps) => { style={{ border: 'none', width: '100%' }} value={searchQuery} onChange={(e) => { - navigate({ pathname: location.pathname, search: `q=${e.target.value}` }); - setSearchQuery(e.target.value); + navigate( + { pathname: location.pathname, search: `q=${e.target.value}` }, + { replace: true } + ); + dispatch({ type: 'search', query: e.target.value }); }} type="search" placeholder="Search for a class..." @@ -153,8 +117,11 @@ const CatalogFilters = (props: CatalogFilterProps) => { { - navigate({ pathname: location.pathname, search: `q=${e.target.value}` }); - setSearchQuery(e.target.value); + navigate( + { pathname: location.pathname, search: `q=${e.target.value}` }, + { replace: true } + ); + dispatch({ type: 'search', query: e.target.value }); }} type="search" placeholder="Search for a class..." @@ -168,20 +135,22 @@ const CatalogFilters = (props: CatalogFilterProps) => { isClearable={false} options={SORT_OPTIONS} isSearchable={false} - onChange={(newValue) => setSortQuery(newValue as SortOption)} + onChange={(newValue) => + dispatch({ type: 'sort', query: newValue as SortOption }) + } /> - - {filterList && - Object.entries(filterList).map(([key, filter]) => ( + {template && + Object.entries(template).map(([key, filter]) => (

{filter.name}

>; - selectedId: string | null; - searchQuery: string; - sortQuery: SortOption; - sortDir: boolean; -}; - -type Skeleton = { __typename: 'Skeleton'; id: number }; - -/** - * Component for course list - */ -const CatalogList = (props: CatalogListProps) => { - const { currentFilters, setCurrentCourse, selectedId, searchQuery, sortQuery, sortDir } = props; - const { observe, height } = useDimensions(); - const [fetchCatalogList, { data, loading, called }] = useGetCoursesForFilterLazyQuery({}); - const navigate = useNavigate(); - - const courses = useMemo(() => { - if (!data) - return [...Array(20).keys()].map( - (key) => - ({ - __typename: 'Skeleton', - id: key - } as Skeleton) - ); - - let courseResults = CatalogService.searchCatalog( - data.allCourses.edges.map((edge) => edge.node), - searchQuery - ).sort(sortByAttribute(sortQuery.value)); - - //TODO: Very big problem to inspect - server is returning duplicate entries of same courses. - // Here we filter the duplicates to ensure catalog list consistency. - courseResults = courseResults.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i); - - // Inspect one case of duplication: - // console.log(courses.filter((v, i, a) => v.id === 'Q291cnNlVHlwZTo0NDc1')); - - return sortDir ? CatalogService.descending(courseResults, sortQuery) : courseResults; - }, [data, searchQuery, sortDir, sortQuery]); - - useEffect(() => { - const playlistString = Object.values(currentFilters ?? {}) - .filter((val) => val !== null) - .map((item) => (Array.isArray(item) ? item.map((v) => v.value.id) : item?.value.id)) - .flat() - .join(','); - - if (playlistString) fetchCatalogList({ variables: { playlists: playlistString } }); - }, [fetchCatalogList, currentFilters]); - - const handleCourseSelect = (course: CourseFragment) => { - setCurrentCourse(course); - navigate({ - pathname: `/catalog/${(currentFilters.semester as FilterOption)?.value?.name}/${ - course.abbreviation - }/${course.courseNumber}`, - search: location.search - }); - }; - - return ( -
-
- {called && !loading && courses?.length > 0 && {courses.length} Results} -
- {height && courses.length > 0 ? ( - courses[index]?.id} - > - {({ index, style }) => ( - - )} - - ) : ( -
There are no courses matching your filters.
- )} -
- ); -}; - -export default memo(CatalogList); diff --git a/frontend/src/app/Catalog/CatalogList/CatalogListItem.tsx b/frontend/src/app/Catalog/CatalogList/CatalogListItem.tsx index f5370a199..cdedb0c48 100644 --- a/frontend/src/app/Catalog/CatalogList/CatalogListItem.tsx +++ b/frontend/src/app/Catalog/CatalogList/CatalogListItem.tsx @@ -5,12 +5,10 @@ import { CSSProperties, memo } from 'react'; import { areEqual } from 'react-window'; import { ReactComponent as BookmarkSaved } from 'assets/svg/catalog/bookmark-saved.svg'; import { ReactComponent as BookmarkUnsaved } from 'assets/svg/catalog/bookmark-unsaved.svg'; -import catalogService from '../service'; - -const { colorEnrollment, formatEnrollment } = catalogService; import styles from './CatalogList.module.scss'; -import Skeleton from 'react-loading-skeleton'; +import { colorEnrollment, formatEnrollment } from '../service'; +import useCatalog from '../useCatalog'; function formatUnits(units: string) { return `${units} Unit${units === '1.0' || units === '1' ? '' : 's'}` @@ -21,15 +19,17 @@ function formatUnits(units: string) { type CatalogListItemProps = { data: { - course: CourseFragment | { __typename: 'Skeleton'; id: number }; + course: CourseFragment; handleCourseSelect: (course: CourseFragment) => void; isSelected: boolean; }; - style: CSSProperties; + style?: CSSProperties; + simple?: boolean; }; -const CatalogListItem = ({ style, data }: CatalogListItemProps) => { +const CatalogListItem = ({ style, data, simple }: CatalogListItemProps) => { const { course, handleCourseSelect, isSelected } = data; + const [{ course: currentCourse }] = useCatalog(); const { user } = useUser(); const saveCourse = useSaveCourse(); @@ -37,25 +37,22 @@ const CatalogListItem = ({ style, data }: CatalogListItemProps) => { const isSaved = user?.savedClasses?.some((savedCourse) => savedCourse?.id === course.id); - return course.__typename === 'Skeleton' ? ( -
-
- -
-
- ) : ( -
handleCourseSelect(course)}> + return ( +
{ + if (currentCourse?.id === course.id) return; + handleCourseSelect(course); + }} + >
{`${course.abbreviation} ${course.courseNumber}`}

{course.title}

-
+
{user && (
{ {isSaved ? : }
)} - - {course.letterAverage !== '' ? course.letterAverage : ''} - + {!simple && ( + + {course.letterAverage !== '' ? course.letterAverage : ''} + + )}
-
- - {formatEnrollment(course.enrolledPercentage)} - - • {course.units ? formatUnits(course.units) : 'N/A'} -
+ {!simple && ( +
+ + {formatEnrollment(course.enrolledPercentage)} enrolled + + • {course.units ? formatUnits(course.units) : 'N/A'} +
+ )}
); }; +CatalogListItem.defaultProps = { + simple: false +}; + export default memo(CatalogListItem, areEqual); diff --git a/frontend/src/app/Catalog/CatalogList/index.ts b/frontend/src/app/Catalog/CatalogList/index.ts deleted file mode 100644 index 7e8e5cec0..000000000 --- a/frontend/src/app/Catalog/CatalogList/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CatalogList from './CatalogList'; - -export default CatalogList; diff --git a/frontend/src/app/Catalog/CatalogList/index.tsx b/frontend/src/app/Catalog/CatalogList/index.tsx new file mode 100644 index 000000000..41305c362 --- /dev/null +++ b/frontend/src/app/Catalog/CatalogList/index.tsx @@ -0,0 +1,83 @@ +import { FixedSizeList } from 'react-window'; +import CatalogListItem from './CatalogListItem'; +import { CourseFragment, useGetCoursesForFilterLazyQuery } from 'graphql'; +import { memo, useEffect } from 'react'; +import useDimensions from 'react-cool-dimensions'; +import styles from './CatalogList.module.scss'; +import { useNavigate } from 'react-router'; +import useCatalog from '../useCatalog'; +import { buildPlaylist } from '../service'; +import Skeleton from 'react-loading-skeleton'; + +const CatalogList = () => { + const navigate = useNavigate(); + const { observe, height } = useDimensions(); + const [{ course, courses, filters }, dispatch] = useCatalog(); + + const [fetchCatalogList, { loading, called }] = useGetCoursesForFilterLazyQuery({ + onCompleted: (data) => + dispatch({ + type: 'setCourseList', + allCourses: data.allCourses.edges.map((edge) => edge.node) + }) + }); + + useEffect(() => { + const playlists = buildPlaylist(filters); + if (playlists) fetchCatalogList({ variables: { playlists } }); + }, [fetchCatalogList, filters]); + + const handleCourseSelect = (course: CourseFragment) => { + dispatch({ type: 'setCourse', course }); + if (filters.semester) { + const { name } = filters.semester.value; + const { abbreviation, courseNumber } = course; + navigate({ + pathname: `/catalog/${name}/${abbreviation}/${courseNumber}`, + search: location.search + }); + } + }; + + return ( +
+
+ {called && !loading && courses?.length > 0 && {courses.length} Results} +
+ {height && courses.length > 0 && ( + courses[index]?.id} + > + {({ index, style }) => ( + + )} + + )} + {loading && + [...Array(20).keys()].map((key) => ( +
+
+ +
+
+ ))} + {!loading && called && courses.length === 0 && ( +
There are no courses matching your filters.
+ )} +
+ ); +}; + +export default memo(CatalogList); diff --git a/frontend/src/app/Catalog/CatalogView/CatalogView.module.scss b/frontend/src/app/Catalog/CatalogView/CatalogView.module.scss index bb576282e..7a89d2b7f 100644 --- a/frontend/src/app/Catalog/CatalogView/CatalogView.module.scss +++ b/frontend/src/app/Catalog/CatalogView/CatalogView.module.scss @@ -22,8 +22,9 @@ grid-area: view; flex-direction: column; padding: 30px; + padding-right: 20px; gap: 15px; - overflow-y: auto; + overflow-y: scroll; overflow-x: hidden; -webkit-animation: fadeIn 0.1s; animation: fadeIn 0.1s; @@ -37,10 +38,6 @@ color: $bt-base-text; font-weight: 700; font-size: 18px; - - span { - color: $bt-blue; - } } h6 { @@ -56,36 +53,35 @@ height: 100%; z-index: 300; width: 100vw; + padding: 20px; + padding-right: 10px; // Add size of fixed header. padding-top: 30px; - &[data-modal='false'] { - display: none; - } + } - [data-modal='true'] { - display: flex; - } + &[data-modal='false'] { + display: none; + } + + &[data-modal='true'] { + display: flex; } } .modalButton { - display: none; - @include media(mobile) { - display: flex; - justify-content: flex-start; - align-items: center; - text-align: center; - border: none; - background: none; - line-height: 20px; - color: $bt-grey-text; - gap: 10px; - padding: 0; - margin-bottom: 15px; + display: flex; + justify-content: flex-start; + align-items: center; + text-align: center; + border: none; + background: none; + line-height: 20px; + color: $bt-grey-text; + gap: 10px; + padding: 0; - &:hover { - color: #2f80ed; - } + &:hover { + color: #2f80ed; } } @@ -201,60 +197,226 @@ } } -.sectionRoot { +.sections { display: flex; flex-direction: column; + border-radius: 16px; gap: 10px; + + h6 { + font-size: 16px; + min-height: 21px; + margin: 0; + font-weight: 500; + color: $bt-base-text; + } + + p { + margin: 0; + font-size: 16px; + font-weight: 500; + color: $bt-base-text; + } +} + +.sectionRoot { + display: flex; + flex-direction: column; + gap: 20px; } .sectionItem { display: flex; flex-direction: row; - // background: #F8F8F8; - padding: 12px 24px; - border-radius: 12px !important; border: 1.5px solid #eaeaea; - gap: 20px; - h5 { - font-size: 16px; - margin: 0; + padding: 12px 16px; + border-radius: 12px; + gap: 10px; +} + +@include media(mobile) { + .sectionItem { + flex-direction: column; } - h6 { - font-size: 14px; + .sectionRight { + & > h6, span { - text-transform: capitalize; + justify-content: flex-start !important; + text-align: left !important; } } } -.sectionInfo { +.sectionLeft { display: flex; - gap: 5px; flex-direction: column; flex: 1; + + h6 { + display: flex; + align-items: center; + font-weight: 700; + min-height: 24px; + } + + span { + display: flex; + align-items: center; + justify-content: flex-start; + text-transform: capitalize; + color: $bt-light-text; + font-weight: 400; + font-size: 14px; + text-align: right; + } +} + +.sectionRight { + display: flex; + flex-direction: column; + justify-content: flex-start; + font-size: 16px; + + h6, + span { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-size: 14px; + color: $bt-light-text; + font-weight: 400; + text-align: right; + } + + & > h6 { + font-size: 14px; + } } -.sectionStats { +.tabList { display: flex; flex-direction: row; - flex-wrap: wrap; - gap: 5px; - color: $bt-light-text; - margin-top: 5px; - font-size: 14px; - text-transform: none !important; + // gap: 30px; + border-bottom: 1.5px solid #eaeaea; + margin: 0 -30px; + padding: 0 30px; } -.enrolled { +.tabContent { + display: flex; + flex-direction: column; + padding-top: 30px; +} + +.tabGraph { + margin: 0 -10px 0 -15px; +} + +.tab { display: flex; - justify-content: center; align-items: center; - font-size: 14px; - font-weight: 600; - gap: 5px; + outline: none; + border: none; + background: transparent; + padding: 5px 0; + font-size: 16px; + margin-bottom: -1px; + color: $bt-light-text; + padding: 5px 15px; + border-bottom: 1.5px solid transparent; + transition-duration: 0.2s; + border-radius: 8px 8px 0px 0px; +} + +.tab:hover { + cursor: pointer; + color: $bt-base-text; + background: #f8f8f8; + border-bottom: 1.5px solid #eaeaea; +} - img { - width: 14px; +.active { + border-bottom: 1.5px solid #2f80ed !important; + color: $bt-base-text; + background: #f8f8f8; +} + +.saveRoot { + display: flex; + flex-direction: row; + padding: 0 20px; + grid-area: view; + overflow: hidden; +} + +@include media(mobile) { + .saveRoot { + display: none; } } + +.saveContainer { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: scroll; + overflow-x: hidden; + margin: 20px 0px; + + &:first-child { + padding-right: 10px; + // border-right: 1.5px solid #eaeaea; + } + + &:last-child { + padding-left: 10px; + } +} + +.header { + // margin-bottom: 20px; + display: flex; + justify-content: space-between; + flex-direction: row; + align-items: baseline; + gap: 30px; + padding: 6px 23px; + + span { + font-size: 20px; + font-weight: 600; + } + + border-bottom: 1.5px solid #eaeaea; + + button { + color: #2f80ed; + transition: 0.2s; + border: none; + background: none; + padding: 0 8px; + border-radius: 4px; + font-size: 16px; + &:hover { + background: hsla(0, 0%, 92.2%, 0.6196078431); + } + } +} + +.seperator { + display: flex; + width: 1.5px; + margin: 20px 0px; + background: #eaeaea; +} + +.emptyView { + display: flex; + align-items: center; + justify-content: center; + color: $bt-light-text; + padding: 20px 0; + text-align: center; +} diff --git a/frontend/src/app/Catalog/CatalogView/CatalogView.tsx b/frontend/src/app/Catalog/CatalogView/CatalogView.tsx index 402948786..ecf1623d6 100644 --- a/frontend/src/app/Catalog/CatalogView/CatalogView.tsx +++ b/frontend/src/app/Catalog/CatalogView/CatalogView.tsx @@ -1,153 +1,139 @@ -import { Dispatch, memo, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo } from 'react'; import people from 'assets/svg/catalog/people.svg'; import chart from 'assets/svg/catalog/chart.svg'; import book from 'assets/svg/catalog/book.svg'; import launch from 'assets/svg/catalog/launch.svg'; import { ReactComponent as BackArrow } from 'assets/img/images/catalog/backarrow.svg'; -import catalogService from '../service'; import { applyIndicatorPercent, applyIndicatorGrade, formatUnits } from 'utils/utils'; import { CourseFragment, PlaylistType, useGetCourseForNameLazyQuery } from 'graphql'; -import { CurrentFilters } from 'app/Catalog/types'; -import { useNavigate, useParams } from 'react-router-dom'; -import { sortSections } from 'utils/sections/sort'; +import { useNavigate, useParams } from 'react-router'; import Skeleton from 'react-loading-skeleton'; import ReadMore from './ReadMore'; +import { useSelector } from 'react-redux'; +import useCatalog from '../useCatalog'; +import { sortPills } from '../service'; +import CourseTabs from './Tabs'; import styles from './CatalogView.module.scss'; -import { useSelector } from 'react-redux'; -import SectionTable from './SectionTable'; -import { courseToName } from 'lib/courses/course'; +import { CatalogSlug, FilterOption } from '../types'; import Meta from 'components/Common/Meta'; - -interface CatalogViewProps { - coursePreview: CourseFragment | null; - setCurrentCourse: Dispatch>; - setCurrentFilters: Dispatch>; -} +import { courseToName } from 'lib/courses/course'; +import { useUser } from 'graphql/hooks/user'; +import CatalogListItem from '../CatalogList/CatalogListItem'; const skeleton = [...Array(8).keys()]; -const CatalogView = (props: CatalogViewProps) => { - const { coursePreview, setCurrentFilters, setCurrentCourse } = props; - const { abbreviation, courseNumber, semester } = useParams<{ - abbreviation: string; - courseNumber: string; - semester: string; - }>(); +// Debug saved courses with dummy array since logging in doesn't work in DEV. +// const dummy = [ +// { +// id: 'Q291cnNlVHlwZToyMTcxOQ==', +// abbreviation: 'MATH', +// courseNumber: '56', +// description: +// 'This is a first course in Linear Algebra. Core topics include: algebra and geometry of vectors and matrices; systems of linear equations and Gaussian elimination; eigenvalues and eigenvectors; Gram-Schmidt and least squares; symmetric matrices and quadratic forms; singular value decomposition and other factorizations. Time permitting, additional topics may include: Markov chains and Perron-Frobenius, dimensionality reduction, or linear programming. This course differs from Math 54 in that it does not cover Differential Equations, but focuses on Linear Algebra motivated by first applications in Data Science and Statistics.', +// title: 'Linear Algebra ', +// gradeAverage: -1, +// letterAverage: 'A', +// openSeats: 0, +// enrolledPercentage: 1, +// enrolled: 292, +// enrolledMax: 292, +// units: '4.0', +// __typename: 'CourseType' +// } +// ]; - const [course, setCourse] = useState(coursePreview); - const [isOpen, setOpen] = useState(false); +const CatalogView = () => { + const [{ course, filters, recentCourses }, dispatch] = useCatalog(); const navigate = useNavigate(); + const { abbreviation, courseNumber, semester } = useParams(); const legacyId = useSelector( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (state: any) => state.enrollment?.context?.courses?.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (c: any) => c.abbreviation === abbreviation && c.course_number === courseNumber )?.id ?? null ); - const [getCourse, { data, loading }] = useGetCourseForNameLazyQuery({ + const [getCourse, { data }] = useGetCourseForNameLazyQuery({ onCompleted: (data) => { const course = data.allCourses.edges[0].node; if (course) { - setCourse(course); - setOpen(true); + dispatch({ type: 'setCourse', course }); } } }); - useEffect(() => { - if (!abbreviation || !courseNumber) { - setOpen(false); - } - }, [abbreviation, courseNumber]); + const { user } = useUser(); + const savedClasses = useMemo(() => user?.savedClasses ?? [], [user?.savedClasses]); useEffect(() => { - const [sem, year] = semester?.split(' ') ?? [null, null]; + const [sem, year] = semester?.split(' ') ?? [undefined, undefined]; + + type coursePayload = { + abbreviation: string; + courseNumber: string; + semester: string; + year: string; + }; const variables = { - abbreviation: abbreviation ?? null, - courseNumber: courseNumber ?? null, - semester: sem?.toLowerCase() ?? null, + abbreviation: abbreviation, + courseNumber: courseNumber, + semester: sem?.toLowerCase(), year: year }; // Only fetch the course if every parameter has a value. - if (Object.values(variables).every((value) => value !== null)) getCourse({ variables }); + if (Object.values(variables).every((value) => value !== undefined)) + getCourse({ variables: variables as coursePayload }); }, [getCourse, abbreviation, courseNumber, semester]); useEffect(() => { - const course = data?.allCourses.edges[0].node; + const newCourse = data?.allCourses.edges[0].node; - if (course && course?.id === coursePreview?.id) { - setCourse(course); - setOpen(true); - } else if (coursePreview) { - setCourse(coursePreview); - setOpen(true); + if (newCourse && newCourse?.id === course?.id) { + dispatch({ type: 'setCourse', course: newCourse }); + } else if (course) { + dispatch({ type: 'setCourse', course }); } - }, [coursePreview, data]); + }, [course, data, dispatch]); - const [playlists, sections] = useMemo(() => { + const playlists = useMemo(() => { let playlists = null; - let sections = null; - if (course?.playlistSet) { - const { edges } = course.playlistSet; - playlists = catalogService.sortPills(edges.map((e) => e.node as PlaylistType)); - } - - if (course?.sectionSet) { - const { edges } = course.sectionSet; - sections = sortSections(edges.map((e) => e.node)); - } + if (course?.playlistSet) + playlists = sortPills(course.playlistSet.edges.map((e) => e.node as PlaylistType)); - return [playlists ?? skeleton, sections ?? null]; + return playlists ?? skeleton; }, [course]); - const handlePill = (pillItem: PlaylistType) => { - setCurrentFilters((prev) => { - if (['haas', 'ls', 'engineering', 'university'].includes(pillItem.category)) { - const reqs = prev.requirements; - if (reqs?.find((el) => el.label === pillItem.name)) { - return prev; - } - - return { - ...prev, - requirements: [...(reqs ?? []), { label: pillItem.name, value: pillItem }] - }; - } - - return { - ...prev, - [pillItem.category]: { label: pillItem.name, value: pillItem } - }; - }); - }; - const enrollPath = legacyId - ? `/enrollment/0-${legacyId}-${semester.replace(' ', '-')}-all` + ? `/enrollment/0-${legacyId}-${semester?.replace(' ', '-')}-all` : `/enrollment`; const gradePath = legacyId ? `/grades/0-${legacyId}-all-all` : `/grades`; return ( -
- - - {course && ( - <> + <> + + {course ? ( +
+

{course.abbreviation} {course.courseNumber}

@@ -195,7 +181,11 @@ const CatalogView = (props: CatalogViewProps) => { typeof req === 'number' ? ( ) : ( - handlePill(req)}> + dispatch({ type: 'setPill', pillItem: req })} + > {req.name} ) @@ -213,47 +203,76 @@ const CatalogView = (props: CatalogViewProps) => {

-
Class Times - {semester ?? ''}
- {sections && sections.length > 0 ? ( - - ) : !loading ? ( - There are no class times for the selected course. - ) : null} - - {/* - Redesigned catalog sections - - */} - - {/* Good feature whenever we want... -
Past Offerings
-
- {pastSemesters ? ( - pastSemesters.map((req) => ( - - )) + +
+ ) : ( +
+
+
+ Recent + +
+ {recentCourses.length > 0 ? ( +
+ {recentCourses.map((course) => ( + { + dispatch({ type: 'setCourse', course }); + navigate({ + pathname: `/catalog/${(filters.semester as FilterOption)?.value?.name}/${ + course.abbreviation + }/${course.courseNumber}`, + search: location.search + }); + }, + isSelected: false + }} + /> + ))} +
+ ) : ( +
Recently viewed courses will appear here!
+ )} +
+
+
+ Saved +
+ {savedClasses.length > 0 ? ( +
+ {savedClasses.map((course) => ( + { + dispatch({ type: 'setCourse', course }); + navigate({ + pathname: `/catalog/${(filters.semester as FilterOption)?.value?.name}/${ + course.abbreviation + }/${course.courseNumber}`, + search: location.search + }); + }, + isSelected: false + }} + /> + ))} +
) : ( - +
+ Click on the bookmarks in the course list while signed-in to save courses! +
)} - */} - +
+
)} -
+ ); }; diff --git a/frontend/src/app/Catalog/CatalogView/SectionTable.tsx b/frontend/src/app/Catalog/CatalogView/SectionTable.tsx deleted file mode 100644 index f3b70bf21..000000000 --- a/frontend/src/app/Catalog/CatalogView/SectionTable.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { SectionFragment } from 'graphql'; -import { CSSProperties } from 'react'; -import { Table } from 'react-bootstrap'; - -import denero from 'assets/img/eggs/denero.png'; -import hug from 'assets/img/eggs/hug.png'; -import hilf from 'assets/img/eggs/hilf.png'; -import sahai from 'assets/img/eggs/sahai.png'; -import scott from 'assets/img/eggs/scott.png'; -import kubi from 'assets/img/eggs/kubi.png'; -import garcia from 'assets/img/eggs/garcia.png'; -import { formatSectionTime } from 'utils/sections/section'; - -const easterEggImages = new Map([ - ['DENERO J', denero], - ['HUG J', hug], - ['SAHAI A', sahai], - ['HILFINGER P', hilf], - ['SHENKER S', scott], - ['KUBIATOWICZ J', kubi], - ['GARCIA D', garcia] -]); - -function findInstructor(instr: string | null): CSSProperties { - if (instr === null) return {}; - - for (const [name, eggUrl] of easterEggImages) { - if (instr.includes(name)) { - return { - cursor: `url("${eggUrl}"), pointer` - } as CSSProperties; - } - } - - return {}; -} - -type Props = { - sections: SectionFragment[] | null; -}; - -const SectionTable = ({ sections }: Props) => { - return ( -
-
- - - - - - - - - - - - - - {sections?.map((section) => { - return ( - - - - - {section.startTime && section.endTime ? ( - - ) : ( - - )} - - - - - ); - })} - -
TypeCCNInstructorTimeLocationEnrolledWaitlist
{section.kind}{section.ccn}{section.instructor} - {section.wordDays} {formatSectionTime(section)} - {section.locationName} - {section.enrolled}/{section.enrolledMax} - {section.waitlisted}
-
-
- ); -}; - -export default SectionTable; diff --git a/frontend/src/app/Catalog/CatalogView/Tabs/SectionTable.tsx b/frontend/src/app/Catalog/CatalogView/Tabs/SectionTable.tsx new file mode 100644 index 000000000..80a4241fe --- /dev/null +++ b/frontend/src/app/Catalog/CatalogView/Tabs/SectionTable.tsx @@ -0,0 +1,64 @@ +import { SectionFragment } from 'graphql'; +import { useMemo } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import styles from '../CatalogView.module.scss'; +import SectionTableItem from './SectionTableItem'; + +interface Props { + sections: SectionFragment[] | null; +} + +const SectionTable = ({ sections }: Props) => { + const [lectures, discussions, labs] = useMemo(() => { + if (!sections) return [[], [], []]; + + const lectures = sections.filter((section) => section.kind === 'Lecture'); + const discussions = sections.filter((section) => section.kind === 'Discussion'); + const labs = sections.filter((section) => section.kind === 'Laboratory'); + + return [lectures, discussions, labs]; + }, [sections]); + + if (!sections || sections.length === 0) { + return ( + + ); + } + + return ( +
+
+
Lectures
+ {lectures && lectures.length > 0 ? ( + lectures.map((section) => ) + ) : ( + No lectures available. + )} +
+
+
Discussions
+ {discussions && discussions.length > 0 ? ( + discussions.map((section) => ) + ) : ( + No discussions available. + )} +
+
+
Labs
+ {labs && labs.length > 0 ? ( + labs.map((section) => ) + ) : ( + No labs available. + )} +
+
+ ); +}; + +export default SectionTable; diff --git a/frontend/src/app/Catalog/CatalogView/Tabs/SectionTableItem.tsx b/frontend/src/app/Catalog/CatalogView/Tabs/SectionTableItem.tsx new file mode 100644 index 000000000..e9525b212 --- /dev/null +++ b/frontend/src/app/Catalog/CatalogView/Tabs/SectionTableItem.tsx @@ -0,0 +1,75 @@ +import { SectionFragment } from 'graphql'; +import { Clock, Group, User } from 'iconoir-react'; +import { colorEnrollment } from '../../service'; +import { formatSectionTime } from 'utils/sections/section'; + +import denero from 'assets/img/eggs/denero.png'; +import hug from 'assets/img/eggs/hug.png'; +import hilf from 'assets/img/eggs/hilf.png'; +import sahai from 'assets/img/eggs/sahai.png'; +import scott from 'assets/img/eggs/scott.png'; +import kubi from 'assets/img/eggs/kubi.png'; +import garcia from 'assets/img/eggs/garcia.png'; + +import styles from '../CatalogView.module.scss'; +import { CSSProperties } from 'react'; + +const easterEggImages = new Map([ + ['DENERO J', denero], + ['HUG J', hug], + ['SAHAI A', sahai], + ['HILFINGER P', hilf], + ['SHENKER S', scott], + ['KUBIATOWICZ J', kubi], + ['GARCIA D', garcia] +]); + +function findInstructor(instr: string | null): CSSProperties { + if (instr === null) return {}; + + for (const [name, eggUrl] of easterEggImages) { + if (instr.includes(name)) { + return { + cursor: `url("${eggUrl}"), pointer` + } as CSSProperties; + } + } + + return {}; +} + +interface SectionTableItem { + section: SectionFragment; +} + +const SectionTableItem = ({ section }: SectionTableItem) => { + const color = colorEnrollment(section.enrolled / section.enrolledMax); + + return ( +
+
+
{section.locationName || 'Unknown Location'}
+ + {' '} + {section.instructor?.toLowerCase() || 'unknown instructor'} + +
+
+
+ + {section.enrolled}/{section.enrolledMax} Enrolled +
+ + + {section.wordDays} {formatSectionTime(section)} + +
+
+ ); +}; + +export default SectionTableItem; diff --git a/frontend/src/app/Catalog/CatalogView/Tabs/index.tsx b/frontend/src/app/Catalog/CatalogView/Tabs/index.tsx new file mode 100644 index 000000000..0cbf81b78 --- /dev/null +++ b/frontend/src/app/Catalog/CatalogView/Tabs/index.tsx @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as Tabs from '@radix-ui/react-tabs'; +import useCatalog from '../../useCatalog'; +import CatalogViewSections from './SectionTable'; +import { useEffect, useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { sortSections } from 'utils/sections/sort'; +import styles from '../CatalogView.module.scss'; +import GradesGraph from 'components/Graphs/GradesGraph'; +import { + fetchCatalogGrades, + fetchLegacyGradeObjects, + fetchLegacyEnrollmentObjects, + fetchCatalogEnrollment +} from 'redux/actions'; +import { useSelector } from 'react-redux'; +import EnrollmentGraph from 'components/Graphs/EnrollmentGraph'; +import { useParams } from 'react-router-dom'; +import { CatalogSlug } from '../../types'; + +type TabKey = 'times' | 'grades' | 'enrollment'; + +const CourseTabs = () => { + const [value, setValue] = useState('times'); + const [{ course }] = useCatalog(); + const [gradeData, setGradeData] = useState([]); + const [enrollmentData, setEnrollmentData] = useState(null); + const { abbreviation, courseNumber, semester } = useParams(); + + const sections = useMemo( + () => (course?.sectionSet ? sortSections(course.sectionSet.edges.map((e) => e.node)) : []), + [course] + ); + + const legacyGradeId = useSelector( + (state: any) => + state.grade?.context?.courses?.find((c: any) => { + return c.course_number === course?.courseNumber; + })?.id || null + ); + + const legacyEnrollmentId = useSelector( + (state: any) => + state.enrollment?.context?.courses?.find( + (c: any) => c.abbreviation === abbreviation && c.course_number === courseNumber + )?.id ?? null + ); + + useEffect(() => { + if (!course) return; + setGradeData(null); + setEnrollmentData(null); + + const fetchGrades = async () => { + if (!legacyGradeId) return; + const objects = await fetchLegacyGradeObjects(legacyGradeId); + + const res = await fetchCatalogGrades([ + { ...course, sections: objects.map((s: any) => s.grade_id) } + ]); + + setGradeData(res); + }; + + const fetchEnrollment = async () => { + if (!semester || !legacyEnrollmentId) return setEnrollmentData(null); + + const objects = await fetchLegacyEnrollmentObjects(legacyEnrollmentId); + const [sem, year] = semester.split(' ') ?? [null, null]; + + const currentSection = objects.find( + (o: any) => o.semester === sem?.toLowerCase() && o.year === year + ); + + if (currentSection === undefined) return setEnrollmentData([]); + + const res = await fetchCatalogEnrollment([ + { ...course, sections: [currentSection.sections[0].section_id] } + ]); + + setEnrollmentData(res); + }; + + fetchGrades(); + fetchEnrollment(); + }, [course, legacyEnrollmentId, legacyGradeId, semester]); + + return ( + setValue(key as TabKey)} value={value}> + + + Sections + + + Grades + + + Enrollment + + + + + + +
+ +
+
+ +
+ +
+
+
+ ); +}; + +export default CourseTabs; diff --git a/frontend/src/app/Catalog/CatalogView/__new_SectionTable.tsx b/frontend/src/app/Catalog/CatalogView/__new_SectionTable.tsx deleted file mode 100644 index a33b596c1..000000000 --- a/frontend/src/app/Catalog/CatalogView/__new_SectionTable.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { SectionFragment } from 'graphql'; -import { CSSProperties } from 'react'; -import { formatSectionTime } from 'utils/sections/section'; -import catalogService from '../service'; -import Skeleton from 'react-loading-skeleton'; - -import people from 'assets/svg/catalog/people.svg'; -import denero from 'assets/img/eggs/denero.png'; -import hug from 'assets/img/eggs/hug.png'; -import hilf from 'assets/img/eggs/hilf.png'; -import sahai from 'assets/img/eggs/sahai.png'; -import scott from 'assets/img/eggs/scott.png'; -import kubi from 'assets/img/eggs/kubi.png'; -import garcia from 'assets/img/eggs/garcia.png'; - -import styles from './CatalogView.module.scss'; - -const { colorEnrollment, formatEnrollment } = catalogService; - -const easterEggImages = new Map([ - ['DENERO J', denero], - ['HUG J', hug], - ['SAHAI A', sahai], - ['HILFINGER P', hilf], - ['SHENKER S', scott], - ['KUBIATOWICZ J', kubi], - ['GARCIA D', garcia] -]); - -function findInstructor(instr: string | null): CSSProperties { - if (instr === null) return {}; - - for (const [name, eggUrl] of easterEggImages) { - if (instr.includes(name)) { - return { - cursor: `url("${eggUrl}"), pointer` - } as CSSProperties; - } - } - - return {}; -} - -type Props = { - sections: SectionFragment[] | null; -}; - -const CatalogViewSections = ({ sections }: Props) => { - if (!sections) { - return ( - - ); - } - - return ( -
- {sections.length > 0 ? ( - sections.map((section) => ( -
-
-
- {section.kind} -{' '} - {section.locationName ? section.locationName : 'Unknown Location'} -
-
- {section?.instructor?.toLowerCase() ?? 'instructor'},{' '} - {section.wordDays} {formatSectionTime(section)} -
- - - {formatEnrollment(section.enrolled / section.enrolledMax)} - - • {section.waitlisted} waitlisted - • CCN: {section.ccn} - -
-
- - {section.enrolled}/{section.enrolledMax} -
-
- )) - ) : ( -
There are no class sections for this course.
- )} -
- ); -}; - -export default CatalogViewSections; diff --git a/frontend/src/app/Catalog/index.ts b/frontend/src/app/Catalog/index.ts deleted file mode 100644 index ddc203ace..000000000 --- a/frontend/src/app/Catalog/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Catalog from './Catalog'; - -export default Catalog; diff --git a/frontend/src/app/Catalog/index.tsx b/frontend/src/app/Catalog/index.tsx new file mode 100644 index 000000000..809c8137a --- /dev/null +++ b/frontend/src/app/Catalog/index.tsx @@ -0,0 +1,20 @@ +import styles from './Catalog.module.scss'; +import CatalogFilters from './CatalogFilters'; +import CatalogList from './CatalogList'; +import CatalogView from './CatalogView'; +import { CatalogProvider } from './useCatalog'; + +const Catalog = () => { + + return ( +
+ + + + + +
+ ); +}; + +export default Catalog; diff --git a/frontend/src/app/Catalog/service.ts b/frontend/src/app/Catalog/service.ts index cf410484e..21051dd07 100644 --- a/frontend/src/app/Catalog/service.ts +++ b/frontend/src/app/Catalog/service.ts @@ -1,22 +1,16 @@ import Fuse from 'fuse.js'; -import { - FilterFragment, - GetFiltersQuery, - PlaylistType, - CourseOverviewFragment, - CourseFragment -} from 'graphql'; +import { FilterFragment, GetFiltersQuery, PlaylistType, CourseOverviewFragment } from 'graphql'; import { laymanTerms } from 'lib/courses/course'; import { courseToName } from 'lib/courses/course'; import { CatalogCategoryKeys, FilterTemplate, - CatalogFilterKeys, + CatalogFilterKey, FilterOptions, SortOption, - CurrentFilters, CourseInfo, - CatalogSortKeys + CatalogSortKeys, + CatalogFilters } from './types'; import styles from './CatalogList/CatalogList.module.scss'; @@ -27,7 +21,7 @@ const SEMESTER_VALUES = { fall: 0.2 }; -const SORT_OPTIONS: SortOption[] = [ +export const SORT_OPTIONS: SortOption[] = [ { value: 'relevance', label: 'Sort By: Relevance' }, { value: 'average_grade', label: 'Sort By: Average Grade' }, { value: 'department_name', label: 'Sort By: Department Name' }, @@ -35,7 +29,7 @@ const SORT_OPTIONS: SortOption[] = [ { value: 'enrolled_percentage', label: 'Sort By: Percent Enrolled' } ]; -const INITIAL_FILTERS: CurrentFilters = { +export const DEFAULT_FILTERS: CatalogFilters = { department: null, semester: null, units: null, @@ -43,7 +37,7 @@ const INITIAL_FILTERS: CurrentFilters = { requirements: null }; -const FILTER_TEMPLATE: FilterTemplate = { +export const FILTER_TEMPLATE: FilterTemplate = { requirements: { name: 'Requirements', isClearable: true, @@ -96,9 +90,7 @@ const FILTER_TEMPLATE: FilterTemplate = { * @returns An object where the keys are filter categories and the values are * a sorted array of `FilterFragments` or null if no data was passed. */ -const processFilterData = (data?: GetFiltersQuery) => { - if (!data) return null; - +const processFilterData = (data: GetFiltersQuery) => { const filters = data.allPlaylists.edges .map((edge) => edge.node) .reduce((prev, filter) => { @@ -122,6 +114,14 @@ const processFilterData = (data?: GetFiltersQuery) => { return filters; }; +export const buildPlaylist = (filters: CatalogFilters) => { + return Object.values(filters ?? {}) + .filter((val) => val !== null) + .map((item) => (Array.isArray(item) ? item.map((v) => v.value.id) : item?.value.id)) + .flat() + .join(','); +}; + /** * * @param filterItems an empty filter template @@ -130,8 +130,10 @@ const processFilterData = (data?: GetFiltersQuery) => { * appropriate filter options from the server. * @returns a `FilterTemplate` with populated `options` */ -const putFilterOptions = (filterItems: FilterTemplate, filters?: FilterOptions | null) => { - if (!filters) return null; +export const putFilterOptions = (filterItems: FilterTemplate, data?: GetFiltersQuery | null) => { + if (!data) return null; + + const filters = processFilterData(data); const result = { ...filterItems }; @@ -154,7 +156,7 @@ const putFilterOptions = (filterItems: FilterTemplate, filters?: FilterOptions | Object.entries(result) .filter(([key]) => key !== 'requirements') .map(([k]) => { - const key = k as CatalogFilterKeys; + const key = k as CatalogFilterKey; result[key].options = filters[key].map((filter) => ({ label: filter.name, value: filter @@ -164,17 +166,7 @@ const putFilterOptions = (filterItems: FilterTemplate, filters?: FilterOptions | return result; }; -/** - * - * @param courses an array of `CourseOverviewFragment` - * @param rawQuery A string to search for within the `courses` array - * @description Applies `rawQuery` over a list of courses and returns the best matches - * within `courses` - * @returns an array of CourseOverviewFragment - */ -const searchCatalog = (courses: CourseOverviewFragment[], rawQuery: string) => { - if (!rawQuery || rawQuery === '' || rawQuery === null) return courses; - +export const buildCourseIndex = (courses: CourseOverviewFragment[]) => { const options: Fuse.IFuseOptions = { includeScore: true, shouldSort: true, @@ -200,7 +192,7 @@ const searchCatalog = (courses: CourseOverviewFragment[], rawQuery: string) => { } }; - const courseInfo = courses.map((course) => { + const searchTerms = courses.map((course) => { const { title, abbreviation, courseNumber } = course; const abbreviations = @@ -226,8 +218,16 @@ const searchCatalog = (courses: CourseOverviewFragment[], rawQuery: string) => { }; }); - const fuse = new Fuse(courseInfo, options); - return fuse.search(rawQuery.trim().toLowerCase()).map((res) => courses[res.refIndex]); + return new Fuse(searchTerms, options); +}; + +export const searchCatalog = ( + courseIndex: Fuse | null, + query: string, + courses: CourseOverviewFragment[] +) => { + if (!query || query === '' || query === null || !courseIndex) return courses; + return courseIndex.search(query.trim().toLowerCase()).map((res) => courses[res.refIndex]); }; /** @@ -260,7 +260,7 @@ const SemesterToValue = (semesterFilter: FilterFragment) => { * @description sorts the playlists alphabetically, and * by putting the `units` followed by the semester category at the end of the list */ -const sortPills = (playlists: PlaylistType[]) => { +export const sortPills = (playlists: PlaylistType[]) => { const semesters = sortSemestersByLatest(playlists.filter((p) => p.category === 'semester')); const units = sortByName(playlists.filter((p) => p.category === 'units')); const rest = sortByName(playlists.filter((p) => !['units', 'semester'].includes(p.category))); @@ -271,12 +271,16 @@ const sortPills = (playlists: PlaylistType[]) => { export const sortByName = (arr: T) => arr.sort((a, b) => a.name.localeCompare(b.name)); -function formatEnrollment(percentage: number) { - if (percentage === -1) return 'N/A'; - return `${Math.floor(percentage * 100)}% enrolled`; +export function formatEnrollment(percentage: number) { + if (percentage === -1 || isNaN(percentage)) return 'N/A'; + /* + Take the min of the percentage and 100 to prevent + absurd percentages from being displayed (e.g. 1000% enrolled) + */ + return `${Math.min(Math.floor(percentage * 100), 100)}%`; } -function colorEnrollment(percentage: number) { +export function colorEnrollment(percentage: number) { if (percentage === -1) return ''; const pct = percentage * 100; @@ -289,7 +293,14 @@ function colorEnrollment(percentage: number) { } } -const descending = (courses: CourseOverviewFragment[], sortQuery: SortOption) => { +/** + * This function takes in a list of courses, and effectively inverts the list to + * replicate a sort by ascending or descending order. + * + * If the list was in asceding order, it will be returned in descending order and vice versa. + * The list will be flipped by the appropriate field based on the provided sort query. + */ +export const flipCourseList = (courses: CourseOverviewFragment[], sortQuery: SortOption) => { const keys: Record = { average_grade: 'gradeAverage', enrolled_percentage: 'enrolledPercentage', @@ -301,30 +312,20 @@ const descending = (courses: CourseOverviewFragment[], sortQuery: SortOption) => const { value } = sortQuery; const param = keys[value]; + let result = []; + switch (value) { case 'average_grade': case 'enrolled_percentage': case 'open_seats': - return [ + result = [ ...courses.filter((course) => course[param] === -1 || course[param] === null), ...courses.filter((course) => course[param] !== -1 && course[param] !== null) - ].reverse(); + ]; + break; default: - return courses.reverse(); + result = courses; } -}; -export default { - FILTER_TEMPLATE, - SORT_OPTIONS, - INITIAL_FILTERS, - processFilterData, - putFilterOptions, - sortByName, - sortSemestersByLatest, - sortPills, - formatEnrollment, - colorEnrollment, - searchCatalog, - descending + return result.reverse(); }; diff --git a/frontend/src/app/Catalog/types.ts b/frontend/src/app/Catalog/types.ts index 009a3004e..bcd257dd1 100644 --- a/frontend/src/app/Catalog/types.ts +++ b/frontend/src/app/Catalog/types.ts @@ -1,5 +1,7 @@ import { FilterFragment } from 'graphql'; import { GroupBase } from 'react-select'; +import { CourseFragment, CourseOverviewFragment, PlaylistType } from 'graphql'; +import Fuse from 'fuse.js'; export type CatalogSlug = { abbreviation?: string; @@ -18,7 +20,7 @@ export type CatalogCategoryKeys = | 'university' | 'requirements'; -export type CatalogFilterKeys = Exclude< +export type CatalogFilterKey = Exclude< CatalogCategoryKeys, 'haas' | 'ls' | 'engineering' | 'university' >; @@ -27,7 +29,7 @@ export type FilterOptions = { [category in CatalogCategoryKeys]: FilterFragment[]; }; -export type CurrentFilters = { +export type CatalogFilters = { department: FilterOption | null; level: FilterOption[] | null; units: FilterOption[] | null; @@ -46,7 +48,7 @@ export type SortOption = { value: CatalogSortKeys; label: string }; export type FilterOption = { value: FilterFragment; label: string }; export type FilterTemplate = { - [key in CatalogFilterKeys]: { + [key in CatalogFilterKey]: { name: string; isClearable: boolean; isMulti: boolean; @@ -63,4 +65,32 @@ export type CourseInfo = { courseNumber: string; fullCourseCode: string; abbreviations: string[]; -}; \ No newline at end of file +}; + +export type SortDir = 'ASC' | 'DESC'; + +export type CatalogContext = { + filters: CatalogFilters; + sortQuery: SortOption; + sortDir: SortDir; + searchQuery: string; + allCourses: CourseOverviewFragment[]; + courses: CourseOverviewFragment[]; + course: CourseFragment | null; + courseIndex: Fuse | null; + recentCourses: CourseFragment[]; +}; + +export type CatalogAction = + | { type: 'sortDir' } + | { type: 'setCourse'; course: CatalogContext['course'] } + | { type: 'search'; query?: CatalogContext['searchQuery'] } + | { type: 'sort'; query: CatalogContext['sortQuery'] } + | { type: 'filter'; filters: Partial } + | { type: 'setCourseList'; allCourses: CatalogContext['courses'] } + | { type: 'reset'; filters?: Partial } + | { type: 'setPill'; pillItem: PlaylistType } + | { type: 'clearRecents' }; + + +export type CatalogActions = CatalogAction[keyof CatalogAction]; diff --git a/frontend/src/app/Catalog/useCatalog.tsx b/frontend/src/app/Catalog/useCatalog.tsx new file mode 100644 index 000000000..85226966a --- /dev/null +++ b/frontend/src/app/Catalog/useCatalog.tsx @@ -0,0 +1,172 @@ +import { useReducer, createContext, Dispatch, useContext } from 'react'; +import { DEFAULT_FILTERS, SORT_OPTIONS, buildCourseIndex } from './service'; +import { CatalogFilters, CatalogContext, CatalogAction } from './types'; +import { searchCatalog, flipCourseList } from './service'; +import { byAttribute } from 'lib/courses/sorting'; +import { CourseFragment } from 'graphql'; + +const getRecents = (): CourseFragment[] => { + const recents = localStorage.getItem('recentlyViewedCourses'); + return recents ? JSON.parse(recents) : []; +}; + +const initialCatalog: CatalogContext = { + filters: DEFAULT_FILTERS, + sortQuery: SORT_OPTIONS[0], + sortDir: 'ASC', + searchQuery: '', + courses: [], + allCourses: [], + courseIndex: null, + course: null, + recentCourses: getRecents() +}; + +const Context = createContext(initialCatalog); + +export const CatalogDispatch = createContext>( + (() => null) as Dispatch +); + +export const CatalogProvider = ({ children }: { children: React.ReactNode }) => { + const [catalog, dispatch] = useReducer(catalogReducer, initialCatalog); + + return ( + + {children} + + ); +}; + +function catalogReducer(catalog: CatalogContext, action: CatalogAction): CatalogContext { + const { courses, sortQuery, sortDir, searchQuery, filters } = catalog; + + switch (action.type) { + case 'sortDir': + return { + ...catalog, + sortDir: sortDir === 'ASC' ? 'DESC' : 'ASC', + courses: flipCourseList(courses, sortQuery) + }; + case 'setCourse': { + const recentCourses = setRecentlyViewed(action.course); + + return { + ...catalog, + course: action.course, + recentCourses + }; + } + case 'search': { + const newQuery = action.query ?? searchQuery; + return { + ...catalog, + searchQuery: newQuery, + courses: setSearch(catalog, newQuery) + }; + } + case 'sort': + return { + ...catalog, + sortQuery: action.query, + courses: courses.sort(byAttribute(action.query.value)) + }; + case 'filter': + return { + ...catalog, + filters: { + ...filters, + ...action.filters + } + }; + case 'setCourseList': { + // Here we filter to ensure there are no duplicate course entries. + const allCourses = action.allCourses.filter( + (v, i, a) => a.findIndex((t) => t.id === v.id) === i + ); + + const payload = { + ...catalog, + allCourses, + courseIndex: buildCourseIndex(allCourses) + }; + + return { + ...payload, + courses: setSearch(payload) + }; + } + case 'reset': { + return { + ...initialCatalog, + allCourses: catalog.allCourses, + filters: { ...DEFAULT_FILTERS, ...action.filters } + }; + } + case 'setPill': { + const { pillItem } = action; + const newItem = { label: pillItem.name, value: pillItem }; + + let newFilter: Partial = {}; + const requirements = ['haas', 'ls', 'engineering', 'university']; + if (requirements.includes(pillItem.category)) { + if (filters.requirements?.find((value) => value.label === pillItem.name)) return catalog; + + newFilter = { requirements: [...(filters.requirements ?? []), newItem] }; + } else { + newFilter = { [pillItem.category]: newItem }; + } + + return { + ...catalog, + filters: { + ...filters, + ...newFilter + } + }; + } + case 'clearRecents': { + localStorage.removeItem('recentlyViewedCourses'); + return { + ...catalog, + recentCourses: [] + }; + } + default: + return catalog; + } +} + +const useCatalog = (): [ + Omit, + Dispatch +] => [useContext(Context), useContext(CatalogDispatch)]; + +export const useCatalogDispatch = (): Dispatch => useContext(CatalogDispatch); + +const setSearch = (catalog: CatalogContext, query: string | null = null) => { + const { courseIndex, searchQuery, allCourses, sortQuery } = catalog; + return searchCatalog(courseIndex, query ?? searchQuery, allCourses).sort( + byAttribute(sortQuery.value) + ); +}; + +const setRecentlyViewed = (course: CourseFragment | null): CourseFragment[] => { + let recents = getRecents(); + + if (!course) return recents; + + // If the course was already viewed, don't add it. + if (recents.find((c) => c.id === course.id)) return recents; + recents = [course, ...recents]; + + // Limit the size; + const maxSize = 25; + recents = recents.slice(-maxSize); + + localStorage.setItem('recentlyViewedCourses', JSON.stringify(recents)); + + return recents; +}; + +export default useCatalog; diff --git a/frontend/src/assets/scss/bt/grades/_graph.scss b/frontend/src/assets/scss/bt/grades/_graph.scss index d8e8bc353..0c0f0fd75 100644 --- a/frontend/src/assets/scss/bt/grades/_graph.scss +++ b/frontend/src/assets/scss/bt/grades/_graph.scss @@ -1,5 +1,4 @@ .grades-graph { - margin-top: 20px; max-width: 90vw; g.recharts-layer.recharts-cartesian-axis.recharts-xAxis.xAxis, diff --git a/frontend/src/components/GraphCard/GradesGraphCard.jsx b/frontend/src/components/GraphCard/GradesGraphCard.jsx index 5ce7e5f05..dbc4b1f1e 100644 --- a/frontend/src/components/GraphCard/GradesGraphCard.jsx +++ b/frontend/src/components/GraphCard/GradesGraphCard.jsx @@ -11,7 +11,7 @@ import GradesInfoCard from '../GradesInfoCard/GradesInfoCard'; import { fetchGradeData } from '../../redux/actions'; export default function GradesGraphCard({ isMobile, updateClassCardGrade }) { - const { gradesData, graphData, selectedCourses } = useSelector((state) => state.grade); + const { gradesData, selectedCourses } = useSelector((state) => state.grade); const [hoveredClass, setHoveredClass] = useState(false); const [updateMobileHover, setUpdateMobileHover] = useState(true); const dispatch = useDispatch(); @@ -106,7 +106,6 @@ export default function GradesGraphCard({ isMobile, updateClassCardGrade }) { > {isMobile &&
Grade Distribution
} diff --git a/frontend/src/components/GraphCard/Graphs.module.scss b/frontend/src/components/GraphCard/Graphs.module.scss new file mode 100644 index 000000000..d2827824d --- /dev/null +++ b/frontend/src/components/GraphCard/Graphs.module.scss @@ -0,0 +1,17 @@ +.graphView { + display: grid; + width: 100%; + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr; + @include media(tablet) { + grid-template-columns: 1fr; + grid-template-rows: min-content 1fr; + } +} + +.simpleGraphView { + display: grid; + width: 100%; + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} diff --git a/frontend/src/components/Graphs/EnrollmentGraph.jsx b/frontend/src/components/Graphs/EnrollmentGraph.jsx index d4d825e8c..8c842f7dd 100644 --- a/frontend/src/components/Graphs/EnrollmentGraph.jsx +++ b/frontend/src/components/Graphs/EnrollmentGraph.jsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { LineChart, XAxis, @@ -13,14 +14,12 @@ import { import vars from '../../utils/variables'; import emptyImage from '../../assets/img/images/graphs/empty.svg'; -const EmptyLabel = (props) => { +export const EmptyLabel = ({ children }) => { return (
empty state -

- You have not added any
classes yet. -

+

{children}

); @@ -52,106 +51,111 @@ function getYTickRange(limit) { return arr; } -export default function EnrollmentGraph({ - graphData, - enrollmentData, - updateLineHover, - updateGraphHover, - isMobile, - graphEmpty, - selectedCourses -}) { - const labelStyle = { - textAnchor: 'middle', - fontSize: '12px' - }; +const labelStyle = { + textAnchor: 'middle', + fontSize: '12px' +}; + +export default function EnrollmentGraph(props) { + const { enrollmentData, updateLineHover, updateGraphHover, isMobile, selectedCourses, color } = + props; + + const isEmpty = + enrollmentData === null || + enrollmentData.length === 0 || + selectedCourses === null || + selectedCourses.length === 0; + + const graphData = useMemo(() => { + if (!enrollmentData || enrollmentData.length <= 0) return []; + const days = [...Array(200).keys()]; + + return days.map((day) => { + const ret = { + name: day + }; + for (const enrollment of enrollmentData) { + const validTimes = enrollment.data.filter((time) => time.day >= 0); + const enrollmentTimes = {}; + for (const validTime of validTimes) { + enrollmentTimes[validTime.day] = validTime; + } + + if (day in enrollmentTimes) { + ret[enrollment.id] = (enrollmentTimes[day].enrolled_percent * 100).toFixed(1); + } + } + return ret; + }); + }, [enrollmentData]); + return ( -
-
- - - - +
+ + + + - `${value}%`} - labelFormatter={(label) => `Day ${label - 1}`} - cursor={graphEmpty ? false : true} - /> + `${value}%`} + labelFormatter={(label) => `Day ${label - 1}`} + cursor={isEmpty ? false : true} + /> - {!graphEmpty && - enrollmentData.map((item, i) => ( - updateLineHover(e.dataKey, e.payload.name) }} - connectNulls - /> - ))} - {!graphEmpty && ( - - - - )} - {!graphEmpty && ( - - - - )} + {!isEmpty && + enrollmentData.map((item, i) => ( + updateLineHover(e.dataKey, e.payload.name) }} + connectNulls + /> + ))} + {!isEmpty && ( + + + + )} + {!isEmpty && ( + + + + )} - {isMobile && ( - - )} - - -
+ {isMobile && ( + + )} +
+
- {graphEmpty && } + {enrollmentData?.length == 0 && No data for the selected semester.}
); } - -// -// -// -// -// -// diff --git a/frontend/src/components/Graphs/GradesGraph.jsx b/frontend/src/components/Graphs/GradesGraph.jsx index 46e43147a..ac77cfa22 100644 --- a/frontend/src/components/Graphs/GradesGraph.jsx +++ b/frontend/src/components/Graphs/GradesGraph.jsx @@ -1,5 +1,6 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { percentileToString } from '../../utils/utils'; +import { useMemo } from 'react'; import vars from '../../utils/variables'; import emptyImage from '../../assets/img/images/graphs/empty.svg'; @@ -55,24 +56,42 @@ const PercentageLabel = (props) => { ); }; -export default function GradesGraph({ - graphData, - gradesData, - updateBarHover, - updateGraphHover, - course, - semester, - instructor, - selectedPercentiles, - denominator, - color, - isMobile, - graphEmpty -}) { - let numClasses = gradesData.length; +export default function GradesGraph(props) { + const { + gradesData, + updateBarHover, + updateGraphHover, + course, + semester, + instructor, + selectedPercentiles, + denominator, + color, + isMobile + } = props; + + let numClasses = gradesData?.length || 0; + + const graphData = useMemo(() => { + if (!gradesData) return null; + + return ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F', 'P', 'NP'].map( + (letterGrade) => { + const ret = { + name: letterGrade + }; + for (const grade of gradesData) { + ret[grade.id] = (grade[letterGrade].numerator / grade.denominator) * 100; + } + return ret; + } + ); + }, [gradesData]); + + const isLoaded = gradesData && graphData; return ( -
+
{!isMobile ? ( // desktop or wide viewport
@@ -83,24 +102,26 @@ export default function GradesGraph({ margin={{ top: 0, right: 0, left: -15, bottom: 0 }} > - {!graphEmpty ? ( + {isLoaded ? ( ) : ( )} - [`${Math.round(value * 10) / 10}%`, name]} - cursor={graphEmpty ? { fill: '#fff' } : { fill: '#EAEAEA' }} - /> + {isLoaded && ( + [`${Math.round(value * 10) / 10}%`, name]} + cursor={{ fill: '#EAEAEA' }} + /> + )} - {!graphEmpty && + {isLoaded && gradesData.map((item, i) => ( @@ -111,7 +132,7 @@ export default function GradesGraph({ ) : ( // mobile or narrow viewport
- + - {!graphEmpty ? ( + {isLoaded ? ( ) : ( )} - {!graphEmpty ? ( + {isLoaded ? ( } radius={[0, 4, 4, 0]} @@ -169,7 +190,7 @@ export default function GradesGraph({
)} - {graphEmpty && } + {!isLoaded && }
); } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 401078b17..404bff4fd 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,7 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { ApolloProvider } from '@apollo/client'; +import { IconoirProvider } from 'iconoir-react'; import Berkeleytime from './Berkeleytime'; import store from './redux/store'; diff --git a/frontend/src/lib/courses/sorting.ts b/frontend/src/lib/courses/sorting.ts index 6b5a75f5f..4ea77750a 100644 --- a/frontend/src/lib/courses/sorting.ts +++ b/frontend/src/lib/courses/sorting.ts @@ -55,7 +55,7 @@ const compareEnrollmentPercentage: CompareFn = (courseA, courseB) => { /** * Returns comparator based on the sort */ -export function sortByAttribute(sortAttribute: CatalogSortKeys) { +export function byAttribute(sortAttribute: CatalogSortKeys) { switch (sortAttribute) { case 'relevance': return compareRelevance; diff --git a/frontend/src/react-app-env.d.ts b/frontend/src/react-app-env.d.ts index 15d0c8b42..ee9857a62 100644 --- a/frontend/src/react-app-env.d.ts +++ b/frontend/src/react-app-env.d.ts @@ -1,6 +1,2 @@ /// /// - -declare module 'react-ios-switch' { - export = Switch; -} diff --git a/frontend/src/redux/actions.js b/frontend/src/redux/actions.js index 574ff99c7..f2d4a85bc 100644 --- a/frontend/src/redux/actions.js +++ b/frontend/src/redux/actions.js @@ -16,7 +16,9 @@ import { UPDATE_ENROLL_SELECTED } from './actionTypes'; -axios.defaults.baseURL = import.meta.env.PROD ? axios.defaults.baseURL : 'https://staging.berkeleytime.com'; +axios.defaults.baseURL = import.meta.env.PROD + ? axios.defaults.baseURL + : 'https://staging.berkeleytime.com'; // update grade list const updateGradeContext = (data) => ({ @@ -159,6 +161,28 @@ export function fetchGradeData(classData) { ); } +export async function fetchCatalogGrades(classData) { + const promises = []; + for (const course of classData) { + const { sections } = course; + const url = `/api/grades/sections/${sections.join('&')}/`; + promises.push(axios.get(url)); + } + + const result = await axios.all(promises); + + const test = result.map((res, i) => { + let gradesData = res.data; + gradesData['id'] = classData[i].id; + gradesData['instructor'] = classData[i].instructor = 'All Instructors'; + gradesData['semester'] = classData[i].semester = 'All Semesters'; + gradesData['colorId'] = classData[i].colorId; + return gradesData; + }); + + return test; +} + export function fetchGradeSelected(updatedClass) { const url = `/api/grades/course_grades/${updatedClass.value}/`; return (dispatch) => @@ -174,6 +198,12 @@ export function fetchGradeSelected(updatedClass) { ); } +export async function fetchLegacyGradeObjects(id) { + const url = `/api/grades/course_grades/${id}/`; + const res = await axios.get(url); + return res.data; +} + export function fetchGradeFromUrl(url, navigate) { const toUrlForm = (s) => s.replace('/', '_').toLowerCase().split(' ').join('-'); const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); @@ -356,21 +386,47 @@ export function fetchEnrollData(classData) { ); } +export async function fetchCatalogEnrollment(classData) { + const promises = []; + for (const course of classData) { + const { instructor, courseID, semester, sections } = course; + let url; + if (instructor === 'all') { + const [sem, year] = semester.split(' '); + url = `/api/enrollment/aggregate/${courseID}/${sem.toLowerCase()}/${year}/`; + } else { + url = `/api/enrollment/data/${sections[0]}/`; + } + promises.push(axios.get(url)); + } + + const result = await axios.all(promises); + + return result.map((res, i) => { + let enrollmentData = res.data; + enrollmentData['id'] = classData[i].id; + enrollmentData['colorId'] = classData[i].colorId; + return enrollmentData; + }); +} + export function fetchEnrollSelected(updatedClass) { const url = `/api/enrollment/sections/${updatedClass.value}/`; return (dispatch) => axios.get(url).then( (res) => { dispatch(updatedEnrollSelected(res.data)); - // if (updatedClass.addSelected) { - // this.addSelected(); - // this.handleClassSelect({value: updatedClass.value, addSelected: false}); - // } }, (error) => console.log('An error occurred.', error) ); } +export async function fetchLegacyEnrollmentObjects(courseId) { + const url = `/api/enrollment/sections/${courseId}/`; + const result = await axios.get(url); + return result.data; +} + export function fetchEnrollFromUrl(url, navigate) { const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); let courseUrls = url.split('/')[2].split('&'); diff --git a/frontend/src/utils/sections/section.ts b/frontend/src/utils/sections/section.ts index 15f5743b7..40b69c63c 100644 --- a/frontend/src/utils/sections/section.ts +++ b/frontend/src/utils/sections/section.ts @@ -56,7 +56,7 @@ export const formatSectionTime = (section: SectionFragment, showNoTime = true): section.startTime && section.endTime ? `${formatTime(section.startTime)} \u{2013} ${formatTime(section.endTime)}` : showNoTime - ? `no time` + ? `Unknown time` : ''; export const formatSectionEnrollment = (section: SectionFragment): ReactNode => diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e4e81120e..c9ff20514 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,7 +21,6 @@ "types": ["vite/client"] }, "include": ["src"], - "types": ["vite/client"], "references": [ { "path": "./tsconfig.vite.json" diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 853b31236..8866ae2f2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,10 +11,7 @@ export default defineConfig({ server: { host: true, port: 3000, - open: true, - watch: { - usePolling: true - } + open: true }, publicDir: path.resolve(__dirname, 'public'), plugins: [