diff options
author | Noah Loomans <noahloomans@gmail.com> | 2018-01-29 16:31:05 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-01-29 16:31:05 +0100 |
commit | 694580bc532239a32c2fbf61d7f09e793fd1cb11 (patch) | |
tree | acd21e2654d6c5e70dc41c675972794ce95b4062 /src/client/react | |
parent | f18692872cdc28d29917247ef4f8ef7553a8b023 (diff) | |
parent | 9a9edd1865d619caada787231c8bb34be25af3af (diff) |
Merge pull request #15 from nloomans/react
Move project over to react
Diffstat (limited to 'src/client/react')
21 files changed, 1014 insertions, 0 deletions
diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js new file mode 100644 index 0000000..22daeca --- /dev/null +++ b/src/client/react/actions/search.js @@ -0,0 +1,19 @@ +export const setUser = user => ({ + type: 'SEARCH/SET_USER', + user, +}); + +export const inputChange = searchText => ({ + type: 'SEARCH/INPUT_CHANGE', + searchText, +}); + +/** + * Change the selected result. + * @param {+1/-1} relativeChange usually +1 or -1, the change relative to the + * current result. + */ +export const changeSelectedResult = relativeChange => ({ + type: 'SEARCH/CHANGE_SELECTED_RESULT', + relativeChange, +}); diff --git a/src/client/react/actions/view.js b/src/client/react/actions/view.js new file mode 100644 index 0000000..79ec143 --- /dev/null +++ b/src/client/react/actions/view.js @@ -0,0 +1,31 @@ +// eslint-disable-next-line import/prefer-default-export +export const fetchSchedule = (user, week) => (dispatch) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_REQUEST', + user, + week, + }); + + fetch(`/get/${user}?week=${week}`).then( + // success + (r) => { + r.text().then((htmlStr) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_SUCCESS', + user, + week, + htmlStr, + }); + }); + }, + + // error + () => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_FAILURE', + week, + user, + }); + }, + ); +}; diff --git a/src/client/react/components/container/HelpBox.js b/src/client/react/components/container/HelpBox.js new file mode 100644 index 0000000..a74b43c --- /dev/null +++ b/src/client/react/components/container/HelpBox.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +const HelpBox = ({ results, searchText }) => { + if (results.length > 0 || searchText !== '') { + return <div />; + } + + return ( + <div className="help-box"> + <div className="arrow" /> + <div className="bubble"> + Voer hier een docentafkorting, klas, leerlingnummer of lokaalnummer in. + </div> + </div> + ); +}; + +HelpBox.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + searchText: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + searchText: state.search.searchText, +}); + +export default connect(mapStateToProps)(HelpBox); diff --git a/src/client/react/components/container/Results.js b/src/client/react/components/container/Results.js new file mode 100644 index 0000000..1fb5f44 --- /dev/null +++ b/src/client/react/components/container/Results.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import Result from '../presentational/Result'; + +const Results = (({ results, isExactMatch, selectedResult }) => ( + <div + className={classnames('search__results', { + 'search__results--has-results': !isExactMatch && results.length > 0, + })} + style={{ + minHeight: isExactMatch ? 0 : results.length * 54, + }} + > + {!isExactMatch && results.map(userId => ( + <Result key={userId} userId={userId} isSelected={userId === selectedResult} /> + ))} + </div> +)); + +Results.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + isExactMatch: PropTypes.bool.isRequired, + selectedResult: PropTypes.string, +}; + +Results.defaultProps = { + selectedResult: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + isExactMatch: state.search.isExactMatch, + selectedResult: state.search.selectedResult, +}); + +export default connect(mapStateToProps)(Results); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js new file mode 100644 index 0000000..8acbe99 --- /dev/null +++ b/src/client/react/components/container/Search.js @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import SearchIcon from 'react-icons/lib/md/search'; + +import { setUser, inputChange, changeSelectedResult } from '../../actions/search'; + +import users from '../../users'; +import Results from './Results'; +import IconFromUserType from '../presentational/IconFromUserType'; + +class Search extends React.Component { + constructor(props) { + super(props); + + this.state = { + hasFocus: false, + }; + + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + } + + componentDidMount() { + this.props.dispatch(setUser(this.props.urlUser)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.urlUser !== this.props.urlUser) { + this.props.dispatch(setUser(nextProps.urlUser)); + } + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + onKeyDown(event) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Enter') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.dispatch(changeSelectedResult(-1)); + break; + case 'ArrowDown': + this.props.dispatch(changeSelectedResult(+1)); + break; + case 'Enter': { + const result = this.props.selectedResult || this.props.results[0]; + + if (result === this.props.urlUser) { + // EDGE CASE: The user is set if the user changes, but it doesn't + // change if the result is already the one we are viewing. + // Therefor, we need to dispatch the SET_USER command manually. + this.props.dispatch(setUser(this.props.urlUser)); + } else if (result) { + this.props.history.push(`/${result}`); + } + } + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + + render() { + const { + selectedResult, + isExactMatch, + searchText, + dispatch, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( + <div className="search"> + <div className={classnames('search-overflow', { 'search--has-focus': hasFocus })}> + <div className="search__input-wrapper"> + <div className="search__icon-wrapper"> + <IconFromUserType + userType={isExactMatch ? users.byId[selectedResult].type : null} + defaultIcon={<SearchIcon />} + /> + </div> + <input + id="search__input" + onChange={event => dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} + value={searchText} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} + /> + </div> + <Results /> + </div> + </div> + ); + } +} + +Search.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + selectedResult: PropTypes.string, + urlUser: PropTypes.string, + isExactMatch: PropTypes.bool.isRequired, + searchText: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, +}; + +Search.defaultProps = { + selectedResult: null, + urlUser: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + searchText: state.search.searchText, + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, +}); + +export default withRouter(connect(mapStateToProps)(Search)); diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js new file mode 100644 index 0000000..4f16100 --- /dev/null +++ b/src/client/react/components/container/View.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; + +import { fetchSchedule } from '../../actions/view'; +import extractSchedule from '../../lib/extractSchedule'; + +import Schedule from '../presentational/Schedule'; +import Loading from '../presentational/Loading'; + +const View = ({ + schedules, + user, + week, + dispatch, +}) => { + const schedule = extractSchedule(schedules, user, week); + + switch (schedule.state) { + case 'NOT_REQUESTED': + dispatch(fetchSchedule(user, week)); + return <Loading />; + case 'FETCHING': + return <Loading />; + case 'FINISHED': + return <Schedule htmlStr={schedule.htmlStr} />; + default: + throw new Error(`${schedule.state} is not a valid schedule state.`); + } +}; + +View.propTypes = { + schedules: PropTypes.objectOf(PropTypes.objectOf(PropTypes.shape({ + state: PropTypes.string.isRequired, + htmlStr: PropTypes.string, + }))).isRequired, + user: PropTypes.string.isRequired, + week: PropTypes.number.isRequired, + dispatch: PropTypes.func.isRequired, +}; + +const mapStateToProps = state => ({ + schedules: state.view.schedules, +}); + +export default withRouter(connect(mapStateToProps)(View)); diff --git a/src/client/react/components/container/WeekSelector.js b/src/client/react/components/container/WeekSelector.js new file mode 100644 index 0000000..eef8d8d --- /dev/null +++ b/src/client/react/components/container/WeekSelector.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import queryString from 'query-string'; +import { withRouter } from 'react-router-dom'; + +import purifyWeek from '../../lib/purifyWeek'; + +const WeekSelector = ({ urlWeek, location, history }) => { + const updateWeek = (change) => { + const newWeek = purifyWeek(urlWeek + change); + const isCurrentWeek = moment().week() === newWeek; + + const query = queryString.stringify({ + week: isCurrentWeek ? undefined : newWeek, + }); + history.push(`${location.pathname}?${query}`); + }; + + return ( + <div> + <button onClick={() => updateWeek(-1)}>Prev</button> + Week {urlWeek} + <button onClick={() => updateWeek(+1)}>Next</button> + </div> + ); +}; + +WeekSelector.propTypes = { + urlWeek: PropTypes.number.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + }).isRequired, +}; + +export default withRouter(WeekSelector); diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js new file mode 100644 index 0000000..e5e47c5 --- /dev/null +++ b/src/client/react/components/page/Index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import Search from '../container/Search'; +import HelpBox from '../container/HelpBox'; + +const IndexPage = () => ( + <div className="page-index"> + <div className="container"> + <img src="/icons/mml-logo.png" alt="Metis" /> + <Search /> + <HelpBox /> + </div> + </div> +); + +export default IndexPage; diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js new file mode 100644 index 0000000..215a6e0 --- /dev/null +++ b/src/client/react/components/page/User.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Redirect } from 'react-router-dom'; +import queryString from 'query-string'; +import moment from 'moment'; +import purifyWeek from '../../lib/purifyWeek'; +import Search from '../container/Search'; +import View from '../container/View'; +import users from '../../users'; +import WeekSelector from '../container/WeekSelector'; + +const UserPage = ({ match, location }) => { + const user = `${match.params.type}/${match.params.value}`; + const weekStr = queryString.parse(location.search).week; + const week = purifyWeek(weekStr ? parseInt(weekStr, 10) : moment().week()); + + if (!users.allIds.includes(user)) { + // Invalid user, redirect to index. + return <Redirect to="/" />; + } + + return ( + <div className="page-user"> + <div className="menu"> + <div className="menu-container"> + <Search urlUser={user} /> + <WeekSelector urlWeek={week} /> + </div> + </div> + <View user={user} week={week} /> + </div> + ); +}; + +UserPage.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + type: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, + location: PropTypes.shape({ + search: PropTypes.string.isRequired, + }).isRequired, +}; + +export default UserPage; diff --git a/src/client/react/components/presentational/IconFromUserType.js b/src/client/react/components/presentational/IconFromUserType.js new file mode 100644 index 0000000..ee0e04b --- /dev/null +++ b/src/client/react/components/presentational/IconFromUserType.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import StudentIcon from 'react-icons/lib/md/person'; +import RoomIcon from 'react-icons/lib/md/room'; +import ClassIcon from 'react-icons/lib/md/group'; +import TeacherIcon from 'react-icons/lib/md/account-circle'; + +const IconFromUserType = ({ userType, defaultIcon }) => { + switch (userType) { + case 'c': + return <ClassIcon />; + case 't': + return <TeacherIcon />; + case 's': + return <StudentIcon />; + case 'r': + return <RoomIcon />; + default: + if (defaultIcon) { + return defaultIcon; + } + + throw new Error('`userType` was invalid or not given, but `defaultIcon` is not defined.'); + } +}; + +IconFromUserType.propTypes = { + userType: PropTypes.string, + defaultIcon: PropTypes.element, +}; + +IconFromUserType.defaultProps = { + userType: null, + defaultIcon: null, +}; + +export default IconFromUserType; diff --git a/src/client/react/components/presentational/Loading.js b/src/client/react/components/presentational/Loading.js new file mode 100644 index 0000000..84eaac7 --- /dev/null +++ b/src/client/react/components/presentational/Loading.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const Loading = () => <div>Loading...</div>; + +export default Loading; diff --git a/src/client/react/components/presentational/Result.js b/src/client/react/components/presentational/Result.js new file mode 100644 index 0000000..0b9e024 --- /dev/null +++ b/src/client/react/components/presentational/Result.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import users from '../../users'; + +import IconFromUserType from './IconFromUserType'; + +const Result = ({ userId, isSelected }) => ( + <div + className={classnames('search__result', { + 'search__result--selected': isSelected, + })} + > + <div className="search__icon-wrapper"><IconFromUserType userType={users.byId[userId].type} /></div> + <div className="search__result__text">{users.byId[userId].value}</div> + </div> +); + +Result.propTypes = { + userId: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, +}; + +export default Result; diff --git a/src/client/react/components/presentational/Schedule.js b/src/client/react/components/presentational/Schedule.js new file mode 100644 index 0000000..256c1b4 --- /dev/null +++ b/src/client/react/components/presentational/Schedule.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createDOMPurify from 'dompurify'; + +const Schedule = ({ htmlStr }) => { + const DOMPurify = createDOMPurify(window); + + const cleanHTML = DOMPurify.sanitize(htmlStr, { + ADD_ATTR: ['rules'], + }); + + return ( + // eslint-disable-next-line react/no-danger + <div dangerouslySetInnerHTML={{ __html: cleanHTML }} /> + ); +}; + +Schedule.propTypes = { + htmlStr: PropTypes.string.isRequired, +}; + +export default Schedule; diff --git a/src/client/react/index.js b/src/client/react/index.js new file mode 100644 index 0000000..122d54b --- /dev/null +++ b/src/client/react/index.js @@ -0,0 +1,36 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import moment from 'moment'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { createStore, applyMiddleware, compose } from 'redux'; +import logger from 'redux-logger'; +import thunk from 'redux-thunk'; +import reducer from './reducers'; +import Index from './components/page/Index'; +import User from './components/page/User'; + +moment.locale('nl'); + +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const store = createStore( + reducer, + composeEnhancers(applyMiddleware(thunk, logger)), +); + +ReactDOM.render( + <Provider store={store}> + <Router> + <div> + <Route exact path="/" component={Index} /> + <Route path="/:type/:value" component={User} /> + </div> + </Router> + </Provider>, + document.getElementById('root'), +); + +// We only want to focus the input on page load. NOT on a in-javascript +// redirect. This is because that is when people usually want to start typing. +document.querySelector('.search input').focus(); diff --git a/src/client/react/lib/extractSchedule.js b/src/client/react/lib/extractSchedule.js new file mode 100644 index 0000000..e74411b --- /dev/null +++ b/src/client/react/lib/extractSchedule.js @@ -0,0 +1,10 @@ +export default function extractSchedule(schedules, user, week) { + const scheduleExists = + schedules.hasOwnProperty(user) && schedules[user].hasOwnProperty(week); + + if (!scheduleExists) { + return { state: 'NOT_REQUESTED' }; + } + + return schedules[user][week]; +} diff --git a/src/client/react/lib/purifyWeek.js b/src/client/react/lib/purifyWeek.js new file mode 100644 index 0000000..939f5af --- /dev/null +++ b/src/client/react/lib/purifyWeek.js @@ -0,0 +1,9 @@ +import moment from 'moment'; + +export default function purifyWeek(week) { + // This ensures that week 0 will become week 52 and that week 53 will become + // week 1. This also accounts for leap years. Because date logic can be so + // complicated we off load it to moment.js so that we can be sure it's bug + // free. + return moment().week(week).week(); +} diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js new file mode 100644 index 0000000..fb97228 --- /dev/null +++ b/src/client/react/reducers.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux'; +import search from './reducers/search'; +import view from './reducers/view'; + +const rootReducer = combineReducers({ + search, + view, +}); + +export default rootReducer; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js new file mode 100644 index 0000000..770cdcb --- /dev/null +++ b/src/client/react/reducers/search.js @@ -0,0 +1,105 @@ +import fuzzy from 'fuzzy'; +import users from '../users'; + +const DEFAULT_STATE = { + // results: [ + // 's/18562', + // ], + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, +}; + +function getSearchResults(allUsers, query) { + if (query.trim() === '') { + return []; + } + + const allResults = fuzzy.filter(query, allUsers, { + extract: user => user.value, + }); + + const firstResults = allResults.splice(0, 4); + const userIds = firstResults.map(result => result.original.id); + + return userIds; +} + +const search = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'SEARCH/SET_USER': { + const { user } = action; + + if (user == null) { + return DEFAULT_STATE; + } + + return { + ...state, + results: [], + searchText: users.byId[user].value, + selectedResult: user, + isExactMatch: true, + }; + } + + case 'SEARCH/INPUT_CHANGE': { + const { searchText } = action; + const results = getSearchResults(users.allUsers, action.searchText); + let selectedResult = null; + let isExactMatch = false; + + // Is the typed value exactly the same as the first result? Then show the + // appropriate icon instead of the generic search icon. + if ((results.length === 1) && (action.searchText === users.byId[results[0]].value)) { + [selectedResult] = results; + isExactMatch = true; + } + + return { + ...state, + results, + searchText, + selectedResult, + isExactMatch, + }; + } + + case 'SEARCH/CHANGE_SELECTED_RESULT': { + const { results, isExactMatch } = state; + + if (isExactMatch) return state; + + const prevSelectedResult = state.selectedResult; + const prevSelectedResultIndex = results.indexOf(prevSelectedResult); + let nextSelectedResultIndex = + prevSelectedResultIndex + action.relativeChange; + + if (nextSelectedResultIndex < -1) { + nextSelectedResultIndex = results.length - 1; + } else if (nextSelectedResultIndex > results.length - 1) { + nextSelectedResultIndex = -1; + } + + const nextSelectedResult = + nextSelectedResultIndex === -1 + ? null + : results[nextSelectedResultIndex]; + + return { + ...state, + selectedResult: nextSelectedResult, + }; + } + + default: + return state; + } +}; + +export default search; + +export const _test = { + DEFAULT_STATE, +}; diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js new file mode 100644 index 0000000..22d32e2 --- /dev/null +++ b/src/client/react/reducers/search.test.js @@ -0,0 +1,229 @@ +window.USERS = [ + { type: 's', value: '18561' }, + { type: 's', value: '18562' }, + { type: 's', value: '18563' }, + { type: 's', value: '18564' }, + { type: 's', value: '18565' }, + { type: 's', value: '18566' }, + { type: 's', value: '18567' }, + { type: 's', value: '18568' }, + { type: 's', value: '18569' }, +]; + +const deepFreeze = require('deep-freeze'); +const search = require('./search').default; +const { _test } = require('./search'); +const { + setUser, + inputChange, + changeSelectedResult, +} = require('../actions/search'); + +describe('reducers', () => { + describe('search', () => { + describe('SEARCH/SET_USER', () => { + it('Resets to the default state if the user is null', () => { + expect(search({ foo: 'bar' }, setUser(null))).toEqual(_test.DEFAULT_STATE); + }); + + it('Sets all the values of that user properly', () => { + expect(search(undefined, setUser('s/18561'))).toEqual({ + results: [], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/INPUT_CHANGE', () => { + it('Returns no results when nothing is typed in', () => { + expect(search(undefined, inputChange(''))).toEqual({ + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Returns no results when a space is typed in', () => { + expect(search(undefined, inputChange(' '))).toEqual({ + results: [], + searchText: ' ', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Preforms a basic search, only returning four results', () => { + expect(search(undefined, inputChange('18'))).toEqual({ + results: [ + 's/18561', + 's/18562', + 's/18563', + 's/18564', + ], + searchText: '18', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Selects the first result and sets isExactMatch to true when there is an exact match', () => { + expect(search(undefined, inputChange('18561'))).toEqual({ + results: [ + 's/18561', + ], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { + it('Does nothing when there are no results', () => { + const prevState = { + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + expect(nextStatePlus).toEqual(prevState); + expect(nextStateMin).toEqual(prevState); + }); + + it('Does nothing when there is an exact match', () => { + const prevState = { + results: ['s/18561'], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual(prevState); + expect(nextStateMin).toEqual(prevState); + }); + + it('Switches to the correct selectedResult when no selected result is selected', () => { + const prevState = { + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual({ + ...prevState, + selectedResult: 's/18561', + }); + expect(nextStateMin).toEqual({ + ...prevState, + selectedResult: 's/18563', + }); + }); + + it('Switches to the correct selectedResult when there is a selected result selected', () => { + const prevState = { + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18562', + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual({ + ...prevState, + selectedResult: 's/18563', + }); + expect(nextStateMin).toEqual({ + ...prevState, + selectedResult: 's/18561', + }); + }); + + it('Properly wraps arround when incrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18563', + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18561', + isExactMatch: false, + }); + }); + + it('Properly wraps arround when decrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18561', + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18563', + isExactMatch: false, + }); + }); + }); + }); +}); diff --git a/src/client/react/reducers/view.js b/src/client/react/reducers/view.js new file mode 100644 index 0000000..301a1cf --- /dev/null +++ b/src/client/react/reducers/view.js @@ -0,0 +1,53 @@ +const schedule = (state = {}, action) => { + switch (action.type) { + case 'VIEW/FETCH_SCHEDULE_REQUEST': + return { + ...state, + state: 'FETCHING', + }; + case 'VIEW/FETCH_SCHEDULE_SUCCESS': + return { + ...state, + state: 'FINISHED', + htmlStr: action.htmlStr, + }; + default: + return state; + } +}; + +const DEFAULT_STATE = { + schedules: {}, +}; + +const view = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'VIEW/FETCH_SCHEDULE_REQUEST': + case 'VIEW/FETCH_SCHEDULE_SUCCESS': + return { + ...state, + schedules: { + ...state.schedules, + [action.user]: + state.schedules[action.user] + ? { + // This user already exists in our state, extend it. + ...state.schedules[action.user], + [action.week]: schedule(state.schedules[action.user][action.week], action), + } + : { + // This user does not already exist in our state. + [action.week]: schedule(undefined, action), + }, + }, + }; + default: + return state; + } +}; + +export default view; + +export const _test = { + DEFAULT_STATE, +}; diff --git a/src/client/react/users.js b/src/client/react/users.js new file mode 100644 index 0000000..01ff093 --- /dev/null +++ b/src/client/react/users.js @@ -0,0 +1,66 @@ +/* global USERS */ + +import { combineReducers, createStore } from 'redux'; + +const getId = ({ type, value }) => `${type}/${value}`; + +const byId = (state = {}, action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return { + ...state, + [action.user.id]: { + ...action.user, + }, + }; + default: + return state; + } +}; + +const allIds = (state = [], action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + action.user.id, + ]; + default: + return state; + } +}; + +const allUsers = (state = [], action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + { + ...action.user, + }, + ]; + default: + return state; + } +}; + +const store = createStore(combineReducers({ + byId, + allIds, + allUsers, +})); + +USERS.forEach((user) => { + store.dispatch({ + type: 'USERS/ADD_USER', + user: { + type: user.type, + value: user.value, + id: getId(user), + }, + }); +}); + +const users = store.getState(); + +export default users; |