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/components | |
parent | f18692872cdc28d29917247ef4f8ef7553a8b023 (diff) | |
parent | 9a9edd1865d619caada787231c8bb34be25af3af (diff) |
Merge pull request #15 from nloomans/react
Move project over to react
Diffstat (limited to 'src/client/react/components')
-rw-r--r-- | src/client/react/components/container/HelpBox.js | 30 | ||||
-rw-r--r-- | src/client/react/components/container/Results.js | 38 | ||||
-rw-r--r-- | src/client/react/components/container/Search.js | 142 | ||||
-rw-r--r-- | src/client/react/components/container/View.js | 47 | ||||
-rw-r--r-- | src/client/react/components/container/WeekSelector.js | 39 | ||||
-rw-r--r-- | src/client/react/components/page/Index.js | 15 | ||||
-rw-r--r-- | src/client/react/components/page/User.js | 47 | ||||
-rw-r--r-- | src/client/react/components/presentational/IconFromUserType.js | 37 | ||||
-rw-r--r-- | src/client/react/components/presentational/Loading.js | 5 | ||||
-rw-r--r-- | src/client/react/components/presentational/Result.js | 24 | ||||
-rw-r--r-- | src/client/react/components/presentational/Schedule.js | 22 |
11 files changed, 446 insertions, 0 deletions
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; |