aboutsummaryrefslogtreecommitdiff
path: root/src/client/react/components
diff options
context:
space:
mode:
authorNoah Loomans <noahloomans@gmail.com>2018-01-29 16:31:05 +0100
committerGitHub <noreply@github.com>2018-01-29 16:31:05 +0100
commit694580bc532239a32c2fbf61d7f09e793fd1cb11 (patch)
treeacd21e2654d6c5e70dc41c675972794ce95b4062 /src/client/react/components
parentf18692872cdc28d29917247ef4f8ef7553a8b023 (diff)
parent9a9edd1865d619caada787231c8bb34be25af3af (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.js30
-rw-r--r--src/client/react/components/container/Results.js38
-rw-r--r--src/client/react/components/container/Search.js142
-rw-r--r--src/client/react/components/container/View.js47
-rw-r--r--src/client/react/components/container/WeekSelector.js39
-rw-r--r--src/client/react/components/page/Index.js15
-rw-r--r--src/client/react/components/page/User.js47
-rw-r--r--src/client/react/components/presentational/IconFromUserType.js37
-rw-r--r--src/client/react/components/presentational/Loading.js5
-rw-r--r--src/client/react/components/presentational/Result.js24
-rw-r--r--src/client/react/components/presentational/Schedule.js22
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;