diff --git a/packages/api-client/index.ts b/packages/api-client/index.ts index 239ac3088..9416dfd2c 100644 --- a/packages/api-client/index.ts +++ b/packages/api-client/index.ts @@ -310,6 +310,7 @@ class SearchAPIClient { searchCourses = async ( searchQuery: string, catalogYear?: number, + nupath?: NUPathEnum[], minIndex = 0, maxIndex = 9999 ): Promise[]> => { @@ -327,7 +328,13 @@ class SearchAPIClient { /** Search courses from the latest terms to the older year terms. */ const allCourses = await Promise.all( termsOrderedByYear.map((termId) => - this.searchCoursesForTerm(searchQuery, termId, minIndex, maxIndex) + this.searchCoursesForTerm( + searchQuery, + termId, + nupath, + minIndex, + maxIndex + ) ) ); @@ -351,6 +358,7 @@ class SearchAPIClient { private searchCoursesForTerm = async ( searchQuery: string, termId: string, + nupath: NUPathEnum[] = [], minIndex = 0, maxIndex = 9999 ): Promise[]> => { @@ -360,7 +368,9 @@ class SearchAPIClient { data: JSON.stringify({ query: ` { - search(termId:"${termId}", query: "${searchQuery}", classIdRange: {min: ${minIndex}, max: ${maxIndex}}) { + search(termId:"${termId}", query: "${searchQuery}", classIdRange: {min: ${minIndex}, max: ${maxIndex}}${ + nupath.length > 0 ? `, nupath: ${JSON.stringify(nupath)}` : "" + }) { totalCount pageInfo { hasNextPage } nodes { ... on ClassOccurrence { name subject maxCredits minCredits prereqs coreqs nupath classId diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 2e3ddfb78..b7ac8216f 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -3,13 +3,13 @@ * breadth requirements. */ export enum NUPathEnum { - ND = "Natural and Designed World", - EI = "Creative Expression/Innovation", + ND = "Natural/Designed World", + EI = "Creative Express/Innov", IC = "Interpreting Culture", - FQ = "Formal and Quantitative Reasoning", - SI = "Societies and Institutions", + FQ = "Formal/Quant Reasoning", + SI = "Societies/Institutions", AD = "Analyzing/Using Data", - DD = "Difference and Diversity", + DD = "Difference/Diversity", ER = "Ethical Reasoning", WF = "1st Yr Writing", WD = "Adv Writ Dscpl", diff --git a/packages/frontend/components/AddCourseModal/AddCourseModal.tsx b/packages/frontend/components/AddCourseModal/AddCourseModal.tsx index 8e621dec4..7d9edc399 100644 --- a/packages/frontend/components/AddCourseModal/AddCourseModal.tsx +++ b/packages/frontend/components/AddCourseModal/AddCourseModal.tsx @@ -1,30 +1,33 @@ -import { InfoIcon } from "@chakra-ui/icons"; +import { AddIcon, InfoIcon } from "@chakra-ui/icons"; import { + Button, + Flex, + Grid, Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, ModalBody, + ModalCloseButton, + ModalContent, ModalFooter, - Button, - VStack, + ModalHeader, + ModalOverlay, Text, - Flex, + VStack, } from "@chakra-ui/react"; -import { ScheduleCourse2 } from "@graduate/common"; +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; import { useState } from "react"; import { useSearchCourses } from "../../hooks"; import { - isEqualCourses, getCourseDisplayString, getRequiredCourseCoreqs, + isEqualCourses, } from "../../utils"; +import { sortCoursesByNUPath } from "../../utils/course/sortCoursesByNUPath"; +import { GraduateToolTip } from "../GraduateTooltip"; +import { HelperToolTip } from "../Help"; +import { NUPathCheckBox } from "./NUPathCheckBox"; import { SearchCoursesInput } from "./SearchCoursesInput"; import { SearchResult } from "./SearchResult"; import { SelectedCourse } from "./SelectedCourse"; -import { GraduateToolTip } from "../GraduateTooltip"; -import { HelperToolTip } from "../Help"; interface AddCourseModalProps { isOpen: boolean; @@ -55,21 +58,20 @@ export const AddCourseModal: React.FC = ({ ScheduleCourse2[] >([]); const [isLoadingSelectCourse, setIsLoadingSelectCourse] = useState(false); + const [selectedNUPaths, setSelectedNUPaths] = useState([]); const { courses, - isLoading: isCoursesLoading, + isLoading: isCourseSearchLoading, error, - } = useSearchCourses(searchQuery, catalogYear); + } = useSearchCourses(searchQuery, catalogYear, selectedNUPaths); const addSelectedCourse = async (course: ScheduleCourse2) => { // don't allow courses to be selected multiple times if (isCourseAlreadySelected(course)) { return; } - setIsLoadingSelectCourse(true); - const updatedSelectedCourses = [...selectedCourses]; // grab any coreqs of the course that haven't already been selected/added to the term const coreqs = isAutoSelectCoreqs @@ -82,7 +84,7 @@ export const AddCourseModal: React.FC = ({ }) : []; - updatedSelectedCourses.push(course, ...coreqs); + const updatedSelectedCourses = [...selectedCourses, course, ...coreqs]; setSelectedCourses(updatedSelectedCourses); setIsLoadingSelectCourse(false); @@ -106,7 +108,6 @@ export const AddCourseModal: React.FC = ({ if (selectedCourses.length === 0) { return; } - addSelectedClasses(selectedCourses); onClose(); }; @@ -118,112 +119,160 @@ export const AddCourseModal: React.FC = ({ }; return ( - + - + Add Courses - - - - + + {/* NUPath sidebar */} + - {error && ( - - - - - Oops, sorry we couldn't search for courses... try - again in a little bit! - - - - )} - {courses && - courses.map((searchResult) => ( - + NUPath + + + {Object.keys(NUPathEnum).map((nuPath) => ( + ))} - {!error && (!courses || courses.length === 0) && ( + + + {/* Course Work Area */} + + + - - - Search for your course and press enter to see search - results. - + {/* No course search */} + {!error && (!courses || courses.length === 0) && ( + + + + Search results will show up here. + + + )} + {/* On error */} + {error && ( + + + + + Oops, sorry we couldn't search for courses... try + again in a little bit! + + + + )} + {/* Show courses */} + {courses && + sortCoursesByNUPath(courses, selectedNUPaths).map( + (course) => ( + + ) + )} - )} - - {courses && courses.length > 0 && selectedCourses.length === 0 && ( - - - - Select the courses you wish to add to this semester using the - "+" button. + + + {/* Selected Courses Area */} + + + Courses to Add: + + {selectedCourses.map((selectedCourse) => ( + + ))} + - )} - - {selectedCourses.map((selectedCourse) => ( - - ))} - - + + + + + + - - - - - - ); diff --git a/packages/frontend/components/AddCourseModal/NUPathCheckBox.tsx b/packages/frontend/components/AddCourseModal/NUPathCheckBox.tsx new file mode 100644 index 000000000..d6bb134fc --- /dev/null +++ b/packages/frontend/components/AddCourseModal/NUPathCheckBox.tsx @@ -0,0 +1,51 @@ +import { Checkbox, Flex, Text } from "@chakra-ui/react"; +import { NUPathEnum } from "@graduate/common"; +import { useMemo } from "react"; + +interface NUPathCheckProps { + nuPath: keyof typeof NUPathEnum; + selectedNUPaths: NUPathEnum[]; + setSelectedNUPaths: React.Dispatch>; +} + +export const NUPathCheckBox: React.FC = ({ + selectedNUPaths, + nuPath, + setSelectedNUPaths, +}) => { + const isChecked = useMemo( + () => selectedNUPaths.includes(NUPathEnum[nuPath]), + [nuPath, selectedNUPaths] + ); + + const updateFilters = () => { + if (isChecked) { + setSelectedNUPaths( + selectedNUPaths.filter((curNUPath) => curNUPath !== NUPathEnum[nuPath]) + ); + } else { + setSelectedNUPaths([...selectedNUPaths, NUPathEnum[nuPath]]); + } + }; + + return ( + + + + {nuPath} + + {NUPathEnum[nuPath]} + + + ); +}; diff --git a/packages/frontend/components/AddCourseModal/NUPathLabel.tsx b/packages/frontend/components/AddCourseModal/NUPathLabel.tsx new file mode 100644 index 000000000..53de46c05 --- /dev/null +++ b/packages/frontend/components/AddCourseModal/NUPathLabel.tsx @@ -0,0 +1,77 @@ +import { NUPathEnum } from "@graduate/common"; +import { Flex, Text } from "@chakra-ui/react"; + +interface NuPathLabelProps { + nuPaths: NUPathEnum[]; + filteredPaths: NUPathEnum[]; +} + +const pathToAbbrev = (path: NUPathEnum): string => { + switch (path) { + case NUPathEnum.AD: + return "AD"; + case NUPathEnum.CE: + return "CE"; + case NUPathEnum.DD: + return "DD"; + case NUPathEnum.EI: + return "EI"; + case NUPathEnum.ER: + return "ER"; + case NUPathEnum.EX: + return "EX"; + case NUPathEnum.FQ: + return "FQ"; + case NUPathEnum.IC: + return "IC"; + case NUPathEnum.ND: + return "ND"; + case NUPathEnum.SI: + return "SI"; + case NUPathEnum.WD: + return "WD"; + case NUPathEnum.WF: + return "WF"; + case NUPathEnum.WI: + return "WI"; + default: + return path; + } +}; + +export const NUPathLabel: React.FC = ({ + nuPaths, + filteredPaths, +}) => { + if (nuPaths.length === 0) { + return null; + } + + return ( + + {nuPaths.map((nuPath) => ( + + {pathToAbbrev(nuPath)} + + ))} + + ); +}; diff --git a/packages/frontend/components/AddCourseModal/SearchCoursesInput.tsx b/packages/frontend/components/AddCourseModal/SearchCoursesInput.tsx index 8a4b211b6..85ef321d7 100644 --- a/packages/frontend/components/AddCourseModal/SearchCoursesInput.tsx +++ b/packages/frontend/components/AddCourseModal/SearchCoursesInput.tsx @@ -1,5 +1,11 @@ import { Search2Icon } from "@chakra-ui/icons"; -import { InputGroup, InputLeftElement, Input } from "@chakra-ui/react"; +import { + InputGroup, + InputLeftElement, + Input, + InputRightElement, + IconButton, +} from "@chakra-ui/react"; import { Dispatch, SetStateAction, @@ -9,10 +15,12 @@ import { interface SearchCoursesInputProps { setSearchQuery: Dispatch>; + isCourseSearchLoading: boolean; } export const SearchCoursesInput: React.FC = ({ setSearchQuery, + isCourseSearchLoading, }) => { const [searchTerm, setSearchTerm] = useState(""); @@ -22,10 +30,14 @@ export const SearchCoursesInput: React.FC = ({ } }; + const onSubmit = () => { + setSearchQuery(searchTerm); + }; + return ( - + = ({ setSearchTerm(e.target.value); }} onKeyDown={onKeyDown} - borderRadius={10} fontSize="sm" color="primary.blue.light.main" - backgroundColor="neutral.100" - placeholder="SEARCH BY NAME, CRN, ETC." + placeholder="Search by name or CRN..." + textColor="neutral.500" /> + + } + onClick={onSubmit} + borderRadius="5" + borderLeftRadius="0" + isLoading={isCourseSearchLoading} + > + ); }; diff --git a/packages/frontend/components/AddCourseModal/SearchResult.tsx b/packages/frontend/components/AddCourseModal/SearchResult.tsx index f903ae224..8d9ba63ec 100644 --- a/packages/frontend/components/AddCourseModal/SearchResult.tsx +++ b/packages/frontend/components/AddCourseModal/SearchResult.tsx @@ -1,24 +1,27 @@ -import { SmallAddIcon } from "@chakra-ui/icons"; -import { Flex, Box, Heading, IconButton, Text } from "@chakra-ui/react"; -import { ScheduleCourse2 } from "@graduate/common"; -import { getCourseDisplayString } from "../../utils"; +import { AddIcon } from "@chakra-ui/icons"; +import { Box, Flex, IconButton, Text } from "@chakra-ui/react"; +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; +import { getCourseDisplayString } from "../../utils/"; import { GraduateToolTip } from "../GraduateTooltip"; +import { NUPathLabel } from "./NUPathLabel"; interface SearchResultProps { - searchResult: ScheduleCourse2; - addSelectedCourse: (course: ScheduleCourse2) => void; + course: ScheduleCourse2; + addSelectedCourse: (course: ScheduleCourse2) => Promise; isResultAlreadySelected: boolean; isResultAlreadyAdded: boolean; /** Another course is currently in the process of being selected. */ isSelectingAnotherCourse?: boolean; + selectedNUPaths: NUPathEnum[]; } export const SearchResult: React.FC = ({ - searchResult, + course, addSelectedCourse, isResultAlreadySelected, isResultAlreadyAdded, isSelectingAnotherCourse, + selectedNUPaths: filteredPaths, }) => { const isAddButtonDisabled = isResultAlreadyAdded || isResultAlreadySelected; const addButtonTooltip = isResultAlreadyAdded @@ -28,34 +31,44 @@ export const SearchResult: React.FC = ({ : undefined; return ( - - - - {searchResult.name} - - - {getCourseDisplayString(searchResult)} + + + + + {getCourseDisplayString(course)} + + + {course.name} + + } - variant="solid" - borderColor="primary.blue.light.main" - backgroundColor="primary.blue.light.200" + icon={} color="primary.blue.light.main" - colorScheme="primary.blue.light" - borderRadius="3xl" - size="sm" - onClick={() => addSelectedCourse(searchResult)} - isDisabled={isResultAlreadyAdded || isResultAlreadySelected} + borderColor="primary.blue.light.main" + colorScheme="primary.blue.light.main" + isRound + size="xs" + onClick={() => addSelectedCourse(course)} isLoading={isSelectingAnotherCourse} + isDisabled={isResultAlreadyAdded || isResultAlreadySelected} + alignSelf="center" /> diff --git a/packages/frontend/components/AddCourseModal/SelectedCourse.tsx b/packages/frontend/components/AddCourseModal/SelectedCourse.tsx index 1465558a3..9d750fae8 100644 --- a/packages/frontend/components/AddCourseModal/SelectedCourse.tsx +++ b/packages/frontend/components/AddCourseModal/SelectedCourse.tsx @@ -1,31 +1,54 @@ -import { Flex, Text } from "@chakra-ui/react"; -import { ScheduleCourse2 } from "@graduate/common"; -import { getCourseDisplayString } from "../../utils"; -import { CourseTrashButton } from "../ScheduleCourse/CourseTrashButton"; +import { MinusIcon } from "@chakra-ui/icons"; +import { Box, Flex, IconButton, Text } from "@chakra-ui/react"; +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; +import { getCourseDisplayString } from "../../utils/"; +import { NUPathLabel } from "./NUPathLabel"; interface SelectedCourseProps { selectedCourse: ScheduleCourse2; + selectedNUPaths?: NUPathEnum[]; removeSelectedCourse: (course: ScheduleCourse2) => void; } export const SelectedCourse: React.FC = ({ selectedCourse, removeSelectedCourse, + selectedNUPaths: filteredPaths, }) => { return ( - - {getCourseDisplayString(selectedCourse)} - - - {selectedCourse.name} - - removeSelectedCourse(selectedCourse)} /> + + + + {getCourseDisplayString(selectedCourse)} + + + {selectedCourse.name} + + + + + } + color="primary.red.main" + borderColor="primary.red.main" + colorScheme="primary.red.main" + isRound + size="xs" + onClick={() => removeSelectedCourse(selectedCourse)} + alignSelf="center" + /> ); }; diff --git a/packages/frontend/components/Error/ConfirmEmailWarningModal.tsx b/packages/frontend/components/Error/ConfirmEmailWarningModal.tsx index ddc7ad897..1452867dc 100644 --- a/packages/frontend/components/Error/ConfirmEmailWarningModal.tsx +++ b/packages/frontend/components/Error/ConfirmEmailWarningModal.tsx @@ -31,7 +31,6 @@ export const ConfirmEmailWarningModal: React.FC< const { onClose, isOpen } = useDisclosure({ defaultIsOpen: !student.isEmailConfirmed && !alreadyVisited, }); - console.log(!student.isEmailConfirmed && !alreadyVisited); const closeModal = () => { setAlreadyVisited(true); diff --git a/packages/frontend/hooks/useSearchCourses.ts b/packages/frontend/hooks/useSearchCourses.ts index 25b1f2a67..fa421e7fb 100644 --- a/packages/frontend/hooks/useSearchCourses.ts +++ b/packages/frontend/hooks/useSearchCourses.ts @@ -1,7 +1,7 @@ import useSWR, { KeyedMutator, SWRResponse } from "swr"; import { SearchAPI } from "@graduate/api-client"; import { AxiosError } from "axios"; -import { ScheduleCourse2 } from "@graduate/common"; +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; type SearchCoursesResponse = Omit< SWRResponse[], AxiosError | Error>, @@ -16,23 +16,26 @@ type SearchCoursesReturn = SearchCoursesResponse & { /** * @param searchQuery - The user query term for the class they are searching for + * @param nupaths - NUPaths to filter for and search for * @param minIndex - The lower bound of course ID to search by. Default 0 * @param maxIndex - The upper bound of course ID to search by. Default 9999 */ export function useSearchCourses( searchQuery: string, catalogYear?: number, + nupaths?: NUPathEnum[], minIndex = 0, maxIndex = 9999 ): SearchCoursesReturn { const { data, mutate, ...rest } = useSWR( `/searchCourses/${searchQuery}/${minIndex}/${maxIndex}${ catalogYear && `/${catalogYear}` - }`, + }${nupaths && nupaths?.length > 0 && `/${JSON.stringify(nupaths.sort())}`}`, async () => await SearchAPI.searchCourses( searchQuery.trim(), catalogYear, + nupaths, minIndex, maxIndex ) @@ -40,7 +43,7 @@ export function useSearchCourses( return { ...rest, - courses: searchQuery ? data : [], + courses: data, isLoading: !data && !rest.error, mutateCourses: mutate, }; diff --git a/packages/frontend/utils/course/countFilteredPaths.ts b/packages/frontend/utils/course/countFilteredPaths.ts new file mode 100644 index 000000000..58e1bcd4d --- /dev/null +++ b/packages/frontend/utils/course/countFilteredPaths.ts @@ -0,0 +1,19 @@ +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; + +export const countFilteredPaths = ( + course: ScheduleCourse2, + filteredNuPaths: NUPathEnum[] +): number => { + if (!course.nupaths) { + return -1; + } + + let count = 0; + course.nupaths.forEach((element) => { + if (filteredNuPaths.includes(element)) { + count++; + } + }); + + return count; +}; diff --git a/packages/frontend/utils/course/sortCoursesByNUPath.ts b/packages/frontend/utils/course/sortCoursesByNUPath.ts new file mode 100644 index 000000000..5e265653d --- /dev/null +++ b/packages/frontend/utils/course/sortCoursesByNUPath.ts @@ -0,0 +1,19 @@ +import { NUPathEnum, ScheduleCourse2 } from "@graduate/common"; +import { countFilteredPaths } from "./countFilteredPaths"; + +/** + * Sorts a list of courses by the number of NUPaths they have + * + * @param courses List of classes + */ +export const sortCoursesByNUPath = ( + courses: ScheduleCourse2[], + filteredNuPaths: NUPathEnum[] +): ScheduleCourse2[] => { + return courses.sort((course1, course2) => { + return ( + countFilteredPaths(course2, filteredNuPaths) - + countFilteredPaths(course1, filteredNuPaths) + ); + }); +}; diff --git a/packages/frontend/utils/theme/components/buttons.ts b/packages/frontend/utils/theme/components/buttons.ts index f6cb4a92b..afa200fc9 100644 --- a/packages/frontend/utils/theme/components/buttons.ts +++ b/packages/frontend/utils/theme/components/buttons.ts @@ -46,6 +46,14 @@ export const Button: ComponentStyleConfig = { color: "black", borderRadius: "0px", }, + whiteCancelOutline: { + border: "2px solid", + borderColor: "primary.blue.light.100", + color: "primary.blue.light.100", + colorScheme: "primary.blue.light.100", + bg: "white", + borderRadius: "20px", + }, }, // default size and variant values defaultProps: {