src/screens/course-searcher.js
import React, {Component, PropTypes, findDOMNode} from 'react'
import cx from 'classnames'
import groupBy from 'lodash/collection/groupBy'
import includes from 'lodash/collection/includes'
import uniq from 'lodash/array/uniq'
import flatten from 'lodash/array/flatten'
import map from 'lodash/collection/map'
import pairs from 'lodash/object/pairs'
import sortBy from 'lodash/collection/sortBy'
import sortByAll from 'lodash/collection/sortByAll'
import present from 'present'
import toPrettyTerm from '../helpers/to-pretty-term'
import {oxford} from 'humanize-plus'
import buildDept from '../helpers/build-dept'
import semesterName from '../helpers/semester-name'
import expandYear from '../helpers/expand-year'
import queryCourseDatabase from '../lib/query-course-database'
import padLeft from 'lodash/string/padLeft'
import size from 'lodash/collection/size'
import keymage from 'keymage'
import Button from '../components/button'
import Course from '../components/course'
import Icon from '../components/icon'
import Loading from '../components/loading'
import Student from '../models/student'
import stickyfill from '../lib/init-stickyfill'
import './course-searcher.scss'
const SORT_BY = {
'Year': 'Year',
'Title': 'Title',
'Department': 'Department',
'Day of Week': 'Day of Week',
'Time of Day': 'Time of Day',
}
const GROUP_BY = {
'Day of Week': 'Day of Week',
'Department': 'Department',
'GenEd': 'GenEd',
'Semester': 'Semester',
'Term': 'Term',
'Time of Day': 'Time of Day',
'Year': 'Year',
'None': 'None',
}
const REVERSE_ORDER = ['Year', 'Term', 'Semester']
function split24HourTime(time) {
time = padLeft(String(time), 4, '0')
return {
hour: parseInt(time.slice(0, 2)),
minute: parseInt(time.slice(2, 4)),
}
}
function to12Hour(time) {
const {hour, minute} = split24HourTime(time)
const paddedMinute = padLeft(minute, 2, '0')
const fullHour = ((hour + 11) % 12 + 1)
const meridian = hour < 12 ? 'am' : 'pm'
return `${fullHour}:${paddedMinute}${meridian}`
}
const DAY_OF_WEEK = course => course.offerings
? map(course.offerings, offer => offer.day).join('/')
: 'No Days Listed'
const TIME_OF_DAY = course => course.offerings
? oxford(sortBy(uniq(flatten(map(course.offerings, offer => map(offer.times, time => `${to12Hour(time.start)}-${to12Hour(time.end)}`))))))
: 'No Times Listed'
const DEPARTMENT = course => course.depts ? buildDept(course) : 'No Department'
const GEREQ = course => course.gereqs ? oxford(course.gereqs) : 'No GEs'
const GROUP_BY_TO_KEY = {
'Day of Week': DAY_OF_WEEK,
'Department': DEPARTMENT,
'GenEd': GEREQ,
'Semester': 'semester',
'Term': 'term',
'Time of Day': TIME_OF_DAY,
'Year': 'year',
'None': false,
}
const GROUP_BY_TO_TITLE = {
'Day of Week': days => days,
'Department': depts => depts,
'GenEd': gereqs => gereqs,
'Semester': sem => semesterName(sem),
'Term': term => toPrettyTerm(term),
'Time of Day': times => times,
'Year': year => expandYear(year),
'None': () => '',
}
const SORT_BY_TO_KEY = {
'Year': 'year',
'Title': 'title',
'Department': course => course.depts ? buildDept(course) : 'No Department',
'Day of Week': DAY_OF_WEEK,
'Time of Day': TIME_OF_DAY,
}
export default class CourseSearcher extends Component {
static propTypes = {
baseSearchQuery: PropTypes.object,
isHidden: PropTypes.bool,
student: PropTypes.instanceOf(Student).isRequired,
toggle: PropTypes.func.isRequired,
}
constructor() {
super()
this.state = {
isQuerying: false,
hasQueried: false,
results: [],
queryString: '',
lastQuery: '',
queryInProgress: false,
sortBy: SORT_BY['Year'],
groupBy: GROUP_BY['Term'],
}
}
componentDidMount() {
stickyfill.add(findDOMNode(this))
findDOMNode(this.refs.searchbox).focus()
}
componentWillUnmount() {
stickyfill.remove(findDOMNode(this))
}
onSubmit = () => {
if (this.state.queryString !== this.state.lastQuery || size(this.props.baseSearchQuery)) {
if (process.env.NODE_ENV === 'production') {
try {
window.ga('send', 'event', 'search_query', 'submit', this.state.queryString, 1)
}
catch (e) {} // eslint-disable-line no-empty
}
this.query(this.state.queryString)
}
}
onChange = evt => {
this.setState({queryString: evt.target.value})
}
onKeyDown = evt => {
if (evt.keyCode === 13) {
this.onSubmit()
}
}
query = searchQuery => {
if ((searchQuery.length === 0 && !size(this.props.baseSearchQuery)) || this.state.queryInProgress) {
return
}
this.setState({results: [], hasQueried: false})
const startQueryTime = present()
queryCourseDatabase(searchQuery, this.props.baseSearchQuery)
.then(results => {
console.info(`query took ${(present() - startQueryTime)}ms.`)
console.log('results', results)
// Run an intial sort on the results.
const sortedByIdentifier = sortByAll(results, ['year', 'deptnum', 'semester', 'section'])
this.setState({
results: sortedByIdentifier,
hasQueried: true,
queryInProgress: false,
})
})
.catch(err => console.error(err))
this.setState({queryInProgress: true, lastQuery: searchQuery})
}
render() {
// console.log('SearchButton#render')
const showNoResults = this.state.results.length === 0 && this.state.hasQueried
const showIndicator = this.state.queryInProgress
let contents = <li className='no-results course-group'>No Results Found</li>
if (showIndicator) {
contents = <li className='loading course-group'><Loading>Searching…</Loading></li>
}
else if (!showNoResults) {
const sorted = sortBy(this.state.results, SORT_BY_TO_KEY[this.state.sortBy])
// Group them by term, then turn the object into an array of pairs.
const groupedAndPaired = pairs(groupBy(sorted, GROUP_BY_TO_KEY[this.state.groupBy]))
// Sort the result arrays by the first element, the term, because
// object keys don't have an implicit sort.
const searchResults = sortBy(groupedAndPaired, group => group[0])
if (includes(REVERSE_ORDER, this.state.groupBy)) {
// Also reverse it, so the most recent is at the top.
searchResults.reverse()
}
contents = map(searchResults, ([groupTitle, courses]) =>
<li key={groupTitle} className='course-group'>
{GROUP_BY_TO_TITLE[this.state.groupBy](groupTitle) && <p className='course-group-title'>{GROUP_BY_TO_TITLE[this.state.groupBy](groupTitle)}</p>}
<ul className='course-list'>
{map(courses, (course, index) =>
<li key={index}><Course course={course} student={this.props.student} /></li>)}
</ul>
</li>
)
}
let placeholderExtension = ''
if (size(this.props.baseSearchQuery)) {
placeholderExtension = ` in ${semesterName(this.props.baseSearchQuery.semester)} ${expandYear(this.props.baseSearchQuery.year, true, '–')}`
}
return (
<div className={cx('search-sidebar', this.props.isHidden && 'is-hidden')}>
<header className='sidebar-heading'>
<div className='row'>
<input type='search' className='search-box'
placeholder={'Search Courses' + placeholderExtension}
defaultValue={this.state.query}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onFocus={() => keymage.pushScope('search')}
onBlur={() => keymage.popScope()}
autoFocus={true}
ref='searchbox'
/>
<Button
className='close-sidebar'
title='Close Sidebar'
type='flat'
onClick={this.props.toggle}>
<Icon name='ionicon-close' />
</Button>
</div>
{this.state.hasQueried &&
<div className='row search-filters'>
<span className='filter'>
<label htmlFor='sort'>Sort by:</label><br/>
<select id='sort' value={this.state.sortBy} onChange={ev => this.setState({sortBy: ev.target.value})}>
{map(SORT_BY, opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</span>
<span className='filter'>
<label htmlFor='group'>Group by:</label><br/>
<select id='group' value={this.state.groupBy} onChange={ev => this.setState({groupBy: ev.target.value})}>
{map(GROUP_BY, opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</span>
</div>}
</header>
<ul className='term-list'>
{contents}
</ul>
</div>
)
}
}