diff --git a/src/components/basket/export-options/crn-script.tsx b/src/components/basket/export-options/crn-script.tsx index b2e487e..88074b2 100644 --- a/src/components/basket/export-options/crn-script.tsx +++ b/src/components/basket/export-options/crn-script.tsx @@ -147,6 +147,30 @@ const CRNScript = ({isOpen, onClose}: CRNScriptProps) => { ) } + { + basketState.courseIds.length > 0 && ( + + + Warning: + + You have {basketState.courseIds.length} {basketState.courseIds.length > 2 ? 'courses' : 'course'} (instead of {basketState.courseIds.length > 2 ? 'sections' : 'section'}) in your basket. They will not be added to the generated script. + + + ) + } + + { + basketState.searchQueries.length > 0 && ( + + + Warning: + + You have {basketState.searchQueries.length} search queries in your basket. They will not be added to the generated script. + + + ) + } + Operating system: diff --git a/src/components/basket/table.tsx b/src/components/basket/table.tsx index 2e41e77..b5b70e7 100644 --- a/src/components/basket/table.tsx +++ b/src/components/basket/table.tsx @@ -8,6 +8,7 @@ import TimeDisplay from 'src/components/sections-table/time-display'; import useStore from 'src/lib/state/context'; import getCreditsString from 'src/lib/get-credits-str'; import {BasketState} from 'src/lib/state/basket'; +import {ICourseFromAPI} from 'src/lib/api-types'; import styles from './styles/table.module.scss'; const SkeletonRow = () => ( @@ -42,12 +43,15 @@ const SkeletonRow = () => ( ); type RowProps = { - section: BasketState['sections'][0]; - isForCapture: boolean; + isForCapture?: boolean; handleSearch: (query: string) => void; }; -const Row = observer(({section, isForCapture, handleSearch}: RowProps) => { +type SectionRowProps = RowProps & { + section: BasketState['sections'][0]; +}; + +const SectionRow = observer(({section, isForCapture, handleSearch}: SectionRowProps) => { const {basketState, apiState} = useStore(); return ( @@ -124,6 +128,107 @@ const Row = observer(({section, isForCapture, handleSearch}: RowProps) => { ); }); +type CourseRowProps = RowProps & { + course: ICourseFromAPI; +}; + +const CourseRow = observer(({isForCapture, handleSearch, course}: CourseRowProps) => { + const {basketState} = useStore(); + + return ( + + + {course.title} + + + + {course.credits} + + + { + !isForCapture && ( + <> + + + } + size="sm" + aria-label="Go to course" + onClick={() => { + handleSearch(`${course.subject}${course.crse}`); + }}/> + + + } + size="sm" + aria-label="Remove from basket" + onClick={() => { + basketState.removeCourse(course.id); + }}/> + + + ) + } + + ); +}); + +type SearchQueryRowProps = RowProps & { + query: { + query: string; + credits?: [number, number]; + }; +}; + +const SearchQueryRow = observer(({isForCapture, handleSearch, query}: SearchQueryRowProps) => { + const {basketState} = useStore(); + + return ( + + + + {query.query} + + + + + {query.credits ? getCreditsString(...query.credits) : ''} + + + { + !isForCapture && ( + <> + + + } + size="sm" + aria-label="Go to section" + onClick={() => { + handleSearch(query.query); + }}/> + + + } + size="sm" + aria-label="Remove from basket" + onClick={() => { + basketState.removeSearchQuery(query.query); + }}/> + + + ) + } + + ); +}); + type BasketTableProps = { onClose?: () => void; isForCapture?: boolean; @@ -142,56 +247,42 @@ const BodyWithData = observer(({onClose, isForCapture}: BasketTableProps) => { return ( - {basketState.sections.map(section => ( - - ))} { - basketState.searchQueries.map(query => ( - - - - {query} - - - { - !isForCapture && ( - <> - - } - size="sm" - aria-label="Go to section" - onClick={() => { - handleSearch(query); - }}/> - - - } - size="sm" - aria-label="Remove from basket" - onClick={() => { - basketState.removeSearchQuery(query); - }}/> - - - ) - } - + basketState.sections.map(section => ( + + )) + } + + { + basketState.courses.map(course => ( + + )) + } + + { + basketState.parsedQueries.map(query => ( + )) } Total: - {/* eslint-disable-next-line react/no-array-index-key */} - {Array.from({length: 5}).map((_, i) => ())} - {basketState.totalCredits} + + + {getCreditsString(...basketState.totalCredits)} + { !isForCapture && ( <> diff --git a/src/components/courses-table/details-row.tsx b/src/components/courses-table/details-row.tsx index 6fc8698..2909a2c 100644 --- a/src/components/courses-table/details-row.tsx +++ b/src/components/courses-table/details-row.tsx @@ -1,5 +1,18 @@ import React from 'react'; -import {Tr, Td, VStack, Text, Box, Heading, Button, Collapse, IconButton, HStack, Spacer} from '@chakra-ui/react'; +import { + Tr, + Td, + VStack, + Text, + Box, + Heading, + Button, + Collapse, + IconButton, + HStack, + Spacer, + Stack, +} from '@chakra-ui/react'; import {observer} from 'mobx-react-lite'; import {faShare} from '@fortawesome/free-solid-svg-icons'; import SectionsTable from 'src/components/sections-table'; @@ -7,6 +20,8 @@ import CourseStats from 'src/components/course-stats'; import useStore from 'src/lib/state/context'; import {ICourseWithFilteredSections} from 'src/lib/state/ui'; import WrappedFontAwesomeIcon from 'src/components/wrapped-font-awesome-icon'; +import toTitleCase from 'src/lib/to-title-case'; +import {AddIcon, DeleteIcon} from '@chakra-ui/icons'; const Stats = observer(({courseKey}: {courseKey: string}) => { const store = useStore(); @@ -29,8 +44,21 @@ const Stats = observer(({courseKey}: {courseKey: string}) => { }); const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: {course: ICourseWithFilteredSections; onlyShowSections: boolean; onShowEverything: () => void; onShareCourse: () => void}) => { + const {basketState} = useStore(); const courseKey = `${course.course.subject}${course.course.crse}`; + const courseSections = course.sections.wasFiltered ? course.sections.filtered : course.sections.all; + + const isCourseInBasket = basketState.hasCourse(course.course.id); + + const handleBasketAction = () => { + if (isCourseInBasket) { + basketState.removeCourse(course.course.id); + } else { + basketState.addCourse(course.course.id); + } + }; + return ( @@ -47,13 +75,41 @@ const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: - - Description: - {course.course.description} - + + + Description: + {course.course.description} + + + { + course.course.offered.length > 0 && ( + + Semesters offered: + {toTitleCase(course.course.offered.join(', '))} + + ) + } + - } aria-label="Share course" variant="ghost" colorScheme="brand" title="Share course" onClick={onShareCourse}/> + + + } + aria-label="Share course" + variant="ghost" + colorScheme="brand" + title="Share course" + onClick={onShareCourse}/> + + : } + aria-label="Add course to basket" + title="Add course to basket" + size="xs" + colorScheme={isCourseInBasket ? 'red' : undefined} + onClick={handleBasketAction}/> + { @@ -70,17 +126,21 @@ const DetailsRow = ({course, onlyShowSections, onShowEverything, onShareCourse}: - - {!onlyShowSections && ( - Sections - )} + { + courseSections.length > 0 && ( + + {!onlyShowSections && ( + Sections + )} - - + + + ) + } ); }; -export default DetailsRow; +export default observer(DetailsRow); diff --git a/src/components/courses-table/row.tsx b/src/components/courses-table/row.tsx index 013db29..8a67b7a 100644 --- a/src/components/courses-table/row.tsx +++ b/src/components/courses-table/row.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Tr, Td, IconButton, useDisclosure, usePrevious} from '@chakra-ui/react'; import {InfoIcon, InfoOutlineIcon} from '@chakra-ui/icons'; import {observer} from 'mobx-react-lite'; @@ -16,6 +16,11 @@ const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFiltered const creditsString: string = useMemo(() => { if (sections.length === 0) { + const {credits} = course.course; + if (credits !== null) { + return getCreditsStr(credits, credits); + } + return ''; } @@ -34,8 +39,7 @@ const TableRow = observer(({course, onShareCourse}: {course: ICourseWithFiltered return getCreditsStr(min, max); }, [sections]); - // TODO: can this use useEffect instead? - useLayoutEffect(() => { + useEffect(() => { if (course.sections.wasFiltered !== wasPreviouslyFiltered) { if (course.sections.wasFiltered) { setOnlyShowSections(true); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index fe8513a..c831a2a 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -6,6 +6,8 @@ import {observer} from 'mobx-react-lite'; import useStore from 'src/lib/state/context'; import Logo from 'public/images/logo.svg'; import {SEMESTER_DISPLAY_MAPPING} from 'src/lib/constants'; +import {ISemesterFilter} from 'src/lib/state/api'; +import toTitleCase from 'src/lib/to-title-case'; import ColorModeToggle from './color-mode-toggle'; import Link from './link'; @@ -24,6 +26,16 @@ const PAGES = [ }, ]; +const getSemesterDisplayName = (semester: ISemesterFilter) => { + if (semester.isFuture) { + return toTitleCase(`Future ${semester.semester.toLowerCase()} Semester`); + } + + return `${SEMESTER_DISPLAY_MAPPING[semester.semester]} ${semester.year}`; +}; + +const PATHS_THAT_REQUIRE_SEMESTER_SELECTOR = new Set(['/', '/help/registration-script']); + const Navbar = () => { const router = useRouter(); const store = useStore(); @@ -34,9 +46,10 @@ const Navbar = () => { const handleSemesterSelect = useCallback(async (event: React.ChangeEvent) => { store.apiState.setSelectedSemester(JSON.parse(event.target.value)); - await store.apiState.revalidate(); }, [store]); + const shouldShowSemesterSelector = PATHS_THAT_REQUIRE_SEMESTER_SELECTOR.has(router.pathname); + return ( @@ -68,8 +81,7 @@ const Navbar = () => { > { - // TODO: this should be extracted out into a common layout helper attribute - ['/', '/help/registration-script'].includes(router.pathname) && ( + shouldShowSemesterSelector && (