From ef605f7a2d1fa9e0269d55cea7ff49b778b9ef67 Mon Sep 17 00:00:00 2001 From: Michelle Li Date: Mon, 13 Nov 2023 18:22:31 -0500 Subject: [PATCH] searchbar.tsx --- client/src/modules/Globals/Navbar.tsx | 3 + client/src/modules/Home/Components/Home.tsx | 7 +- .../modules/SearchBar/Components/Course.tsx | 1 - .../SearchBar/Components/SearchBar.jsx | 425 ------------------ .../SearchBar/Components/SearchBar.tsx | 354 +++++++++++++++ 5 files changed, 361 insertions(+), 429 deletions(-) delete mode 100644 client/src/modules/SearchBar/Components/SearchBar.jsx create mode 100644 client/src/modules/SearchBar/Components/SearchBar.tsx diff --git a/client/src/modules/Globals/Navbar.tsx b/client/src/modules/Globals/Navbar.tsx index 6afa82056..1c713ff3d 100644 --- a/client/src/modules/Globals/Navbar.tsx +++ b/client/src/modules/Globals/Navbar.tsx @@ -78,6 +78,9 @@ export default function Navbar({ userInput }: NavbarProps) { userInput={userInput} contrastingResultsBackground={true} isInNavbar={true} + imgSrc={netId} + signOut={signOut} + isLoggedIn={isLoggedIn} /> {displayButton()} diff --git a/client/src/modules/Home/Components/Home.tsx b/client/src/modules/Home/Components/Home.tsx index 753ef8d1c..ad74bc9e7 100644 --- a/client/src/modules/Home/Components/Home.tsx +++ b/client/src/modules/Home/Components/Home.tsx @@ -11,15 +11,15 @@ import DTIWhiteLogo from '../../../assets/img/dti-text-white-logo.png' import '../home.css' /** - Home Page. - + Home Page. + Uppermost View component in the component tree, the first element of the HTML body tag grabbed by index.html. @returns the application homepage with a navbar and searchbar, popular classes and recent reviews components. @param imgSrc for search bar - + */ export const Home = (imgSrc: any) => { const [isLoggedIn, token, netId, signIn, signOut] = useAuthOptionalLogin() @@ -113,6 +113,7 @@ export const Home = (imgSrc: any) => { imgSrc={`${String(imgSrc.imgSrc)}`} signOut={signOut} isLoggedIn={isLoggedIn} + isInNavbar={false} /> diff --git a/client/src/modules/SearchBar/Components/Course.tsx b/client/src/modules/SearchBar/Components/Course.tsx index 8cfb928f2..9ba043a18 100644 --- a/client/src/modules/SearchBar/Components/Course.tsx +++ b/client/src/modules/SearchBar/Components/Course.tsx @@ -21,7 +21,6 @@ type Props = { active: boolean enter: number mouse: number - handler: Function key?: string } diff --git a/client/src/modules/SearchBar/Components/SearchBar.jsx b/client/src/modules/SearchBar/Components/SearchBar.jsx deleted file mode 100644 index 318b14d65..000000000 --- a/client/src/modules/SearchBar/Components/SearchBar.jsx +++ /dev/null @@ -1,425 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' - -import ProfileDropdown from '../../Globals/ProfileDropdown' - -import { Redirect } from 'react-router' -import axios from 'axios' -import { Session } from '../../../session-store' - -import '../Styles/SearchBar.css' -import Course from './Course' -import SubjectResult from './SubjectResult' -import ProfessorResult from './ProfessorResult' - -/* - SearchBar Component. - - Simple Styling Component that renders a searchbar as an input element with a list of - results that match the user's query. - - The component does not control the input element - it is instead controlled by - this componet's parent, which saves the value of the query each time it changes. - It takes in this query and requests a list of relevant classes from the local - meteor database and displays them. -*/ - -let newSearchState = { selected: false, mouse: 0, enter: 0, index: 0 } - -const initState = { - showDropdown: true, - index: 0, //the initial state is the first element - enter: 0, //to keep track of the initial state of enter as false - mouse: 0, //keep track of the initial state of mouse hovering in the list cells as false - selected: false, //whether or not user has clicked yet, - query: '', //user's query, - allCourses: [], - allSubjects: [], - allProfessors: [], -} - -export class SearchBar extends Component { - static DEBOUNCE_TIME = 200 - controller - searchTimeout - - constructor(props) { - super(props) - - this.state = initState - this.updateQuery = this.updateQuery.bind(this) - this.checkForCourseMatch = this.checkForCourseMatch.bind(this) - } - - // Set the local state variable 'query' to the current value of the input (given by user) - // Passed as a prop to SearchBar component, which calls this when user changes their query. - updateQuery = (event) => { - // Reset index, enter, mouse, and selected - this.setState(newSearchState) - // trim the query to remove trailing spaces - let query = event.target.value.trim() - - // This is used to make "cs2110" and "cs 2110" equivalent - if (query && query.split(' ').length === 1) { - query = query.match(/[a-z]+|[^a-z]+/gi).join(' ') - } - - if (this.checkForCourseMatch(query)) { - // If query is exact match to a class, - // highlight this class by setting index to index of this class - // in search results dropdown - this.setState({ index: this.state.allSubjects.length + 1 }) - } - this.setState({ query: query }) - - Session.setPersistent({ 'last-search': query }) - } - - /** - * Compares classes based on score, then class number, then alphabetically by - * subject. - * @param {Class} a - * @param {Class} b - * @returns -1, 0, or 1 - */ - sortCourses(a, b) { - const sortByAlphabet = (a, b) => { - const aSub = a.classSub.toLowerCase() - const bSub = b.classSub.toLowerCase() - if (aSub < bSub) { - return -1 - } else if (aSub > bSub) { - return 1 - } else { - return 0 - } - } - - return b.score - a.score || a.classNum - b.classNum || sortByAlphabet(a, b) - } - - componentDidUpdate(prevProps, prevState) { - if ( - this.state.query.toLowerCase() !== '' && - (this.state.query.toLowerCase() !== prevState.query.toLowerCase() || - this.props !== prevProps) - ) { - if (this.controller) this.controller.abort() - this.controller = new AbortController() - clearTimeout(this.searchTimeout) - - this.searchTimeout = setTimeout(() => { - axios - .post( - `/v2/getClassesByQuery`, - { query: this.state.query }, - { signal: this.controller.signal } - ) - .then((response) => { - const queryCourseList = response.data.result - if (queryCourseList.length !== 0) { - this.setState({ - allCourses: queryCourseList.sort(this.sortCourses), - }) - } else { - this.setState({ - allCourses: [], - }) - } - }) - .catch((e) => console.log('Getting courses failed!')) - - axios - .post( - `/v2/getSubjectsByQuery`, - { query: this.state.query }, - { signal: this.controller.signal } - ) - .then((response) => { - const subjectList = response.data.result - if (subjectList && subjectList.length !== 0) { - // Save the list of Subject objects that matches the request - this.setState({ - allSubjects: subjectList, - }) - } else { - this.setState({ - allSubjects: [], - }) - } - }) - .catch((e) => console.log('Getting subjects failed!')) - - axios - .post( - `/v2/getProfessorsByQuery`, - { query: this.state.query }, - { signal: this.controller.signal } - ) - .then((response) => { - const professorList = response.data.result - if (professorList && professorList.length !== 0) { - // Save the list of Subject objects that matches the request - this.setState({ - allProfessors: professorList, - }) - } else { - this.setState({ - allProfessors: [], - }) - } - }) - .catch((e) => console.log('Getting professors failed!')) - }, this.DEBOUNCE_TIME) - } - } - - handleKeyPress = (e) => { - //detect some arrow key movement (up, down, or enter) - if (e.key === 'ArrowDown') { - //if the down arrow was detected, increase the index value by 1 to highlight the next element - this.setState((prevState) => ({ - index: prevState.index + 1, - })) - } else if (e.key === 'ArrowUp') { - //if the up arrow key was detected, decrease the index value by 1 to highlight the prev element - //never index below 0 (the first element) - this.setState((prevState) => ({ - index: Math.max(prevState.index - 1, 0), - })) - } else if (e.key === 'Enter') { - //if the enter key was detected, change the state of enter to 1 (true) - this.setState({ - enter: 1, - }) - } else { - this.updateQuery(e) - } - } - - mouseHover = () => { - this.setState({ - mouse: 1, - }) - } - - mouseLeave = () => { - this.setState({ - mouse: 0, - }) - this.setState({ - index: 0, //resets the index to the first element - }) - } - - showDropdown = () => { - this.setState({ showDropdown: true }) - } - - hideDropdown = () => { - this.setState({ showDropdown: false }) - } - - checkForCourseMatch(query) { - let isMatch = false - let querySplit = query.toLowerCase().split(' ') - let queryNum = '' - let querySub = '' - if (querySplit.length === 2) { - querySub = querySplit[0] - queryNum = querySplit[1] - } - this.state.allCourses.forEach((course) => { - let classNum = course.classNum.toLowerCase() - let classSub = course.classSub.toLowerCase() - if (classNum === queryNum && classSub === querySub) { - isMatch = true - } - }) - - return isMatch - } - - // Convert the class amd major objects that satisfy this query into a styled list of search results. - // Each one will act as a button, such that clicking a course will take the user - // to that class's ClassView. The name of the class will have underline and bold - // where it matches the query. - // Clicking a major will take the user to the results page for that major's Classes - renderResults() { - if (this.state.query !== '' && !this.state.selected) { - let results = [] - - // Used for "enter" key on 'Search: "query" ' button for exact search - // Sends user to /results/keyword/query+query - if (this.state.index === 0 && this.state.enter === 1) { - this.setState(initState) - return ( - - ) - } - - let exact_search = ( - -

{'Search: "' + this.state.query + '"'}

-
- ) - - results.push(exact_search) - - let subjectList = this.state.allSubjects.slice(0, 3).map((subject, i) => ( - //create a new class "button" that will set the selected class to this class when it is clicked. - - //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to - //the index matching with the course - //the prop "enter" will pass through the value of the enter state - //the prop "mouse" will pass through the value of the mouse state - )) - - // Resets searchbar if user hit "enter" on a major in dropdown - if (this.state.enter === 1) { - this.setState(initState) - } - - results.push(subjectList) - - // Generate list of matching professors and add to results list - let professorList = this.state.allProfessors - .slice(0, 3) - .map((professor, i) => ( - //create a new class "button" that will set the selected class to this class when it is clicked. - - //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to - //the index matching with the course - //the prop "enter" will pass through the value of the enter state - //the prop "mouse" will pass through the value of the mouse state - )) - - results.push(professorList) - - results.push( - this.state.allCourses.slice(0, 5).map((course, i) => ( - //create a new class "button" that will set the selected class to this class when it is clicked. - - //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to - //the index matching with the course - //the prop "enter" will pass through the value of the enter state - //the prop "mouse" will pass through the value of the mouse state - )) - ) - - return results - } else { - return
- } - } - - render() { - const text = - window.innerWidth >= 840 - ? 'Search by any keyword e.g. “FWS”, “ECON” or “CS 2110”' - : 'Search any keyword' - return ( -
-
- - {this.props.isInNavbar && this.props.isLoggedIn ? ( - - ) : ( - '' - )} - -
    - {this.renderResults()} -
-
-
- ) - } -} - -// SearchBar requires the user's query from the parent component, a function -// to call when the query changes so the parent can update its copy of the query, -// and a list of all courses that satisfy the query. -SearchBar.propTypes = { - isInNavbar: PropTypes.bool, // true if input should not have a placeholder - loading: PropTypes.bool, // optional - contrastingResultsBackground: PropTypes.bool, // Used to display contrasting background for search results - userInput: PropTypes.string, // optional previously entered search term -} diff --git a/client/src/modules/SearchBar/Components/SearchBar.tsx b/client/src/modules/SearchBar/Components/SearchBar.tsx new file mode 100644 index 000000000..ff26d20b9 --- /dev/null +++ b/client/src/modules/SearchBar/Components/SearchBar.tsx @@ -0,0 +1,354 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +import ProfileDropdown from '../../Globals/ProfileDropdown' + +import { Redirect } from 'react-router' +import axios from 'axios' +import { Session } from '../../../session-store' + +import '../Styles/SearchBar.css' +import Course from './Course' +import SubjectResult from './SubjectResult' +import ProfessorResult from './ProfessorResult' +import { Class, Subject, Professor } from 'common' + +type SearchBarProps = { + isInNavbar: boolean + contrastingResultsBackground?: boolean + userInput?: string + imgSrc: string + signOut: Function + isLoggedIn: boolean +} + +export const SearchBar = ({ + isInNavbar, + contrastingResultsBackground, + userInput, + imgSrc, + signOut, + isLoggedIn +}: SearchBarProps) => { + const [dropdown, setDropdown] = useState(true) + const [index, setIndex] = useState(0) + const [enter, setEnter] = useState<0 | 1>(0) + const [mouse, setMouse] = useState<0 | 1>(0) + const [selected, setSelected] = useState(false) + const [courses, setCourses] = useState([]) + const [subjects, setSubjects] = useState([]) + const [professors, setProfessors] = useState([]) + const [query, setQuery] = useState('') + const DEBOUNCE_TIME = 200 + + useEffect(() => { + if ( + query.toLowerCase() !== '' + ) { + setTimeout(() => { + axios + .post( + `/v2/getClassesByQuery`, + { query: query }, + ) + .then((response) => { + const queryCourseList = response.data.result + if (queryCourseList.length !== 0) { + setCourses(queryCourseList.sort(sortCourses)) + + } else { + setCourses([]) + } + }) + .catch((e) => console.log('Getting courses failed!')) + + axios + .post( + `/v2/getSubjectsByQuery`, + { query: query }, + ) + .then((response) => { + const subjectList = response.data.result + if (subjectList && subjectList.length !== 0) { + // Save the list of Subject objects that matches the request + setSubjects(subjectList) + } else { + setSubjects([]) + } + }) + .catch((e) => console.log('Getting subjects failed!')) + + axios + .post( + `/v2/getProfessorsByQuery`, + { query: query }, + ) + .then((response) => { + const professorList = response.data.result + if (professorList && professorList.length !== 0) { + // Save the list of Subject objects that matches the request + setProfessors(professorList) + } else { + setProfessors([]) + } + }) + .catch((e) => console.log('Getting professors failed!')) + }, DEBOUNCE_TIME) + } + }, [query]) + + /** + * Compares classes based on score, then class number, then alphabetically by + * subject. + * @param {Class} a + * @param {Class} b + * @returns -1, 0, or 1 + */ + const sortCourses = (a: Class, b: Class) => { + const sortByAlphabet = (a: Class, b: Class) => { + const aSub = a.classSub.toLowerCase() + const bSub = b.classSub.toLowerCase() + if (aSub < bSub) { + return -1 + } else if (aSub > bSub) { + return 1 + } else { + return 0 + } + } + + return sortByAlphabet(a, b) //|| b.score - a.score || a.classNum - b.classNum || + } + + const text = + window.innerWidth >= 840 + ? 'Search by any keyword e.g. “FWS”, “ECON” or “CS 2110”' + : 'Search any keyword' + + const setNewSearchState = () => { + setSelected(false) + setMouse(0) + setEnter(0) + setIndex(0) + } + + const handleKeyPress = (e: any) => { + //detect some arrow key movement (up, down, or enter) + if (e.key === 'ArrowDown') { + //if the down arrow was detected, increase the index value by 1 to highlight the next element + setIndex(index + 1) + } else if (e.key === 'ArrowUp') { + //if the up arrow key was detected, decrease the index value by 1 to highlight the prev element + //never index below 0 (the first element) + setIndex(Math.max(index - 1, 0)) + } else if (e.key === 'Enter') { + //if the enter key was detected, change the state of enter to 1 (true) + setEnter(1) + } else { + updateQuery(e) + } + } + + const checkForCourseMatch = (query: string) => { + let isMatch = false + let querySplit = query.toLowerCase().split(' ') + let queryNum = '' + let querySub = '' + if (querySplit.length === 2) { + querySub = querySplit[0] + queryNum = querySplit[1] + } + courses.forEach((course) => { + let classNum = course.classNum.toLowerCase() + let classSub = course.classSub.toLowerCase() + if (classNum === queryNum && classSub === querySub) { + isMatch = true + } + }) + + return isMatch + } + + // Set the local state variable 'query' to the current value of the input (given by user) + // Passed as a prop to SearchBar component, which calls this when user changes their query. + const updateQuery = (event: any) => { + // Reset index, enter, mouse, and selected + setNewSearchState() + // trim the query to remove trailing spaces + let query = event.target.value.trim() + + // This is used to make "cs2110" and "cs 2110" equivalent + if (query && query.split(' ').length === 1) { + query = query.match(/[a-z]+|[^a-z]+/gi).join(' ') + } + + if (checkForCourseMatch(query)) { + // If query is exact match to a class, + // highlight this class by setting index to index of this class + // in search results dropdown + setIndex(subjects.length + 1) + } + setQuery(query) + + Session.setPersistent({ 'last-search': query }) + } + + const setInitState = () => { + setDropdown(true) + setIndex(0) + setEnter(0) + setMouse(0) + setSelected(false) + setQuery('') + setCourses([]) + setSubjects([]) + setProfessors([]) + } + + const renderResults = () => { + if (query !== '' && !selected) { + let results = [] + + // Used for "enter" key on 'Search: "query" ' button for exact search + // Sends user to /results/keyword/query+query + if (index === 0 && enter === 1) { + setInitState() + + return ( + + ) + } + + let exact_search = ( + +

{'Search: "' + query + '"'}

+
+ ) + + results.push(exact_search) + + let subjectList = subjects.slice(0, 3).map((subject, i) => ( + //create a new class "button" that will set the selected class to this class when it is clicked. + + //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to + //the index matching with the course + //the prop "enter" will pass through the value of the enter state + //the prop "mouse" will pass through the value of the mouse state + )) + + // Resets searchbar if user hit "enter" on a major in dropdown + if (enter === 1) { + setInitState() + } + + results.push(subjectList) + + // Generate list of matching professors and add to results list + let professorList = professors.slice(0, 3).map((professor, i) => ( + //create a new class "button" that will set the selected class to this class when it is clicked. + + //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to + //the index matching with the course + //the prop "enter" will pass through the value of the enter state + //the prop "mouse" will pass through the value of the mouse state + )) + + results.push(professorList) + + results.push( + courses.slice(0, 5).map((course, i) => ( + //create a new class "button" that will set the selected class to this class when it is clicked. + + //the prop "active" will pass through a bool indicating if the index affected through arrow movement is equal to + //the index matching with the course + //the prop "enter" will pass through the value of the enter state + //the prop "mouse" will pass through the value of the mouse state + )) + ) + + return results + } else { + return
+ } + } + + return ( +
+
+ + {isInNavbar && isLoggedIn ? ( + + ) : ( + '' + )} + +
    setMouse(1)} + onMouseLeave={() => setMouse(0)} + > + {renderResults()} +
+
+
+ ) +}