From 503f94199f7fc730da4a1a1165c2863956d10000 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 00:22:36 +0100 Subject: Setup react-redux env --- src/client/react/actions.js | 0 src/client/react/index.js | 14 ++++++++++++++ src/client/react/reducers.js | 8 ++++++++ 3 files changed, 22 insertions(+) create mode 100644 src/client/react/actions.js create mode 100644 src/client/react/index.js create mode 100644 src/client/react/reducers.js (limited to 'src/client/react') diff --git a/src/client/react/actions.js b/src/client/react/actions.js new file mode 100644 index 0000000..e69de29 diff --git a/src/client/react/index.js b/src/client/react/index.js new file mode 100644 index 0000000..7e5f559 --- /dev/null +++ b/src/client/react/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import reducer from './reducers'; + +const store = createStore(reducer); + +ReactDOM.render( + +
Hello World!
+
, + document.getElementById('root'), +); diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js new file mode 100644 index 0000000..1afcec5 --- /dev/null +++ b/src/client/react/reducers.js @@ -0,0 +1,8 @@ +const reducer = (state = [], action) => { + switch (action.type) { + default: + return state; + } +}; + +export default reducer; -- cgit v1.1 From 0141d1f9f4c7ca1755e0a5da908e9d27cf7aa0e1 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 00:41:32 +0100 Subject: Add presentation search component --- src/client/react/App.js | 15 ++++++++++++++ .../react/components/presentational/Search.js | 24 ++++++++++++++++++++++ src/client/react/index.js | 3 ++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/client/react/App.js create mode 100644 src/client/react/components/presentational/Search.js (limited to 'src/client/react') diff --git a/src/client/react/App.js b/src/client/react/App.js new file mode 100644 index 0000000..b8bb301 --- /dev/null +++ b/src/client/react/App.js @@ -0,0 +1,15 @@ +import React from 'react'; +import Search from './components/presentational/Search'; + +const App = () => ( +
+ {}} + results={[ + { type: 's', name: '18561' }, + ]} + /> +
+); + +export default App; diff --git a/src/client/react/components/presentational/Search.js b/src/client/react/components/presentational/Search.js new file mode 100644 index 0000000..0dde8a6 --- /dev/null +++ b/src/client/react/components/presentational/Search.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Search = ({ onInput, results }) => ( +
+ +
    + {results.map(result =>
  • {result.name}
  • )} +
+
+); + +Search.propTypes = { + onInput: PropTypes.func.isRequired, + results: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.require, + type: PropTypes.string.require, + })).isRequired, +}; + +export default Search; diff --git a/src/client/react/index.js b/src/client/react/index.js index 7e5f559..bb7a21b 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -3,12 +3,13 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; import reducer from './reducers'; +import App from './App'; const store = createStore(reducer); ReactDOM.render( -
Hello World!
+
, document.getElementById('root'), ); -- cgit v1.1 From 4ce420528dd747021f7fa51483710388f5733724 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 01:01:36 +0100 Subject: Add Search container --- src/client/react/App.js | 9 ++------- src/client/react/actions.js | 5 +++++ src/client/react/components/container/Search.js | 20 ++++++++++++++++++++ src/client/react/components/presentational/Search.js | 6 +++--- src/client/react/reducers.js | 15 ++++++++++++++- 5 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 src/client/react/components/container/Search.js (limited to 'src/client/react') diff --git a/src/client/react/App.js b/src/client/react/App.js index b8bb301..d79826e 100644 --- a/src/client/react/App.js +++ b/src/client/react/App.js @@ -1,14 +1,9 @@ import React from 'react'; -import Search from './components/presentational/Search'; +import Search from './components/container/Search'; const App = () => (
- {}} - results={[ - { type: 's', name: '18561' }, - ]} - /> +
); diff --git a/src/client/react/actions.js b/src/client/react/actions.js index e69de29..a754943 100644 --- a/src/client/react/actions.js +++ b/src/client/react/actions.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const type = typedValue => ({ + type: 'TYPE', + typedValue, +}); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js new file mode 100644 index 0000000..1b5a41d --- /dev/null +++ b/src/client/react/components/container/Search.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { type } from '../../actions'; +import PresentationalSearch from '../presentational/Search'; + +const mapStateToProps = state => ({ + results: state.searchResults, +}); + +const mapDispatchToProps = dispatch => ({ + onType: (event) => { + dispatch(type(event.target.value)); + }, +}); + +const Search = connect( + mapStateToProps, + mapDispatchToProps, +)(PresentationalSearch); + +export default Search; diff --git a/src/client/react/components/presentational/Search.js b/src/client/react/components/presentational/Search.js index 0dde8a6..ce4a09d 100644 --- a/src/client/react/components/presentational/Search.js +++ b/src/client/react/components/presentational/Search.js @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -const Search = ({ onInput, results }) => ( +const Search = ({ onType, results }) => (
    @@ -14,7 +14,7 @@ const Search = ({ onInput, results }) => ( ); Search.propTypes = { - onInput: PropTypes.func.isRequired, + onType: PropTypes.func.isRequired, results: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string.require, type: PropTypes.string.require, diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js index 1afcec5..3fb884b 100644 --- a/src/client/react/reducers.js +++ b/src/client/react/reducers.js @@ -1,5 +1,18 @@ -const reducer = (state = [], action) => { +const DEFAULT_STATE = { + searchInput: '', + searchResults: [], +}; + +const reducer = (state = DEFAULT_STATE, action) => { switch (action.type) { + case 'TYPE': + return { + ...state, + searchInput: action.typedValue, + searchResults: [ + { type: 's', name: '18561' }, + ], + }; default: return state; } -- cgit v1.1 From b7fab958633456346d67c9cdd68eef05572882ab Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 01:07:11 +0100 Subject: Add redux devtools support --- src/client/react/components/container/Search.js | 1 + src/client/react/components/presentational/Search.js | 4 +++- src/client/react/index.js | 7 ++++++- 3 files changed, 10 insertions(+), 2 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 1b5a41d..0722128 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -4,6 +4,7 @@ import PresentationalSearch from '../presentational/Search'; const mapStateToProps = state => ({ results: state.searchResults, + value: state.searchInput, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/client/react/components/presentational/Search.js b/src/client/react/components/presentational/Search.js index ce4a09d..f75e612 100644 --- a/src/client/react/components/presentational/Search.js +++ b/src/client/react/components/presentational/Search.js @@ -1,10 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -const Search = ({ onType, results }) => ( +const Search = ({ onType, value, results }) => (
      @@ -15,6 +16,7 @@ const Search = ({ onType, results }) => ( Search.propTypes = { onType: PropTypes.func.isRequired, + value: PropTypes.func.isRequired, results: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string.require, type: PropTypes.string.require, diff --git a/src/client/react/index.js b/src/client/react/index.js index bb7a21b..e1bae3c 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -5,7 +5,12 @@ import { createStore } from 'redux'; import reducer from './reducers'; import App from './App'; -const store = createStore(reducer); +/* eslint-disable no-underscore-dangle */ +const store = createStore( + reducer, + window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), +); +/* eslint-enable */ ReactDOM.render( -- cgit v1.1 From 7bd3b6766536e33146bb55506c79619a1ab7d3b3 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 11:10:05 +0100 Subject: Move reducers and actions into seperate folders --- src/client/react/App.js | 10 --------- src/client/react/App.jsx | 10 +++++++++ src/client/react/actions.js | 5 ----- src/client/react/actions/search.js | 5 +++++ src/client/react/components/container/Search.js | 6 ++--- .../react/components/presentational/Search.js | 26 ---------------------- .../react/components/presentational/Search.jsx | 26 ++++++++++++++++++++++ src/client/react/index.js | 20 ----------------- src/client/react/index.jsx | 20 +++++++++++++++++ src/client/react/reducers.js | 25 +++++---------------- src/client/react/reducers/search.js | 21 +++++++++++++++++ 11 files changed, 91 insertions(+), 83 deletions(-) delete mode 100644 src/client/react/App.js create mode 100644 src/client/react/App.jsx delete mode 100644 src/client/react/actions.js create mode 100644 src/client/react/actions/search.js delete mode 100644 src/client/react/components/presentational/Search.js create mode 100644 src/client/react/components/presentational/Search.jsx delete mode 100644 src/client/react/index.js create mode 100644 src/client/react/index.jsx create mode 100644 src/client/react/reducers/search.js (limited to 'src/client/react') diff --git a/src/client/react/App.js b/src/client/react/App.js deleted file mode 100644 index d79826e..0000000 --- a/src/client/react/App.js +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import Search from './components/container/Search'; - -const App = () => ( -
      - -
      -); - -export default App; diff --git a/src/client/react/App.jsx b/src/client/react/App.jsx new file mode 100644 index 0000000..d79826e --- /dev/null +++ b/src/client/react/App.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Search from './components/container/Search'; + +const App = () => ( +
      + +
      +); + +export default App; diff --git a/src/client/react/actions.js b/src/client/react/actions.js deleted file mode 100644 index a754943..0000000 --- a/src/client/react/actions.js +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const type = typedValue => ({ - type: 'TYPE', - typedValue, -}); diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js new file mode 100644 index 0000000..82db383 --- /dev/null +++ b/src/client/react/actions/search.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const type = typedValue => ({ + type: 'SEARCH/TYPE', + typedValue, +}); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 0722128..ddfb0a6 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -1,10 +1,10 @@ import { connect } from 'react-redux'; -import { type } from '../../actions'; +import { type } from '../../actions/search'; import PresentationalSearch from '../presentational/Search'; const mapStateToProps = state => ({ - results: state.searchResults, - value: state.searchInput, + results: state.search.searchResults, + value: state.search.searchInput, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/client/react/components/presentational/Search.js b/src/client/react/components/presentational/Search.js deleted file mode 100644 index f75e612..0000000 --- a/src/client/react/components/presentational/Search.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const Search = ({ onType, value, results }) => ( -
      - -
        - {results.map(result =>
      • {result.name}
      • )} -
      -
      -); - -Search.propTypes = { - onType: PropTypes.func.isRequired, - value: PropTypes.func.isRequired, - results: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.require, - type: PropTypes.string.require, - })).isRequired, -}; - -export default Search; diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx new file mode 100644 index 0000000..1e00192 --- /dev/null +++ b/src/client/react/components/presentational/Search.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Search = ({ onType, value, results }) => ( +
      + +
        + {results.map(result =>
      • {result.name}
      • )} +
      +
      +); + +Search.propTypes = { + onType: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, + results: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.require, + type: PropTypes.string.require, + })).isRequired, +}; + +export default Search; diff --git a/src/client/react/index.js b/src/client/react/index.js deleted file mode 100644 index e1bae3c..0000000 --- a/src/client/react/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Provider } from 'react-redux'; -import { createStore } from 'redux'; -import reducer from './reducers'; -import App from './App'; - -/* eslint-disable no-underscore-dangle */ -const store = createStore( - reducer, - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), -); -/* eslint-enable */ - -ReactDOM.render( - - - , - document.getElementById('root'), -); diff --git a/src/client/react/index.jsx b/src/client/react/index.jsx new file mode 100644 index 0000000..e1bae3c --- /dev/null +++ b/src/client/react/index.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import reducer from './reducers'; +import App from './App'; + +/* eslint-disable no-underscore-dangle */ +const store = createStore( + reducer, + window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), +); +/* eslint-enable */ + +ReactDOM.render( + + + , + document.getElementById('root'), +); diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js index 3fb884b..9fdf2c4 100644 --- a/src/client/react/reducers.js +++ b/src/client/react/reducers.js @@ -1,21 +1,8 @@ -const DEFAULT_STATE = { - searchInput: '', - searchResults: [], -}; +import { combineReducers } from 'redux'; +import search from './reducers/search'; -const reducer = (state = DEFAULT_STATE, action) => { - switch (action.type) { - case 'TYPE': - return { - ...state, - searchInput: action.typedValue, - searchResults: [ - { type: 's', name: '18561' }, - ], - }; - default: - return state; - } -}; +const rootReducer = combineReducers({ + search, +}); -export default reducer; +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..05926c9 --- /dev/null +++ b/src/client/react/reducers/search.js @@ -0,0 +1,21 @@ +const DEFAULT_STATE = { + searchInput: '', + searchResults: [], +}; + +const search = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'SEARCH/TYPE': + return { + ...state, + searchInput: action.typedValue, + searchResults: [ + { type: 's', name: '18561' }, + ], + }; + default: + return state; + } +}; + +export default search; -- cgit v1.1 From 1286c6556115f80218a4828d29b288f56b3d795f Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 11:13:08 +0100 Subject: Rename onType to onInputChange --- src/client/react/actions/search.js | 4 ++-- src/client/react/components/container/Search.js | 6 +++--- src/client/react/components/presentational/Search.jsx | 6 +++--- src/client/react/reducers/search.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js index 82db383..e50d851 100644 --- a/src/client/react/actions/search.js +++ b/src/client/react/actions/search.js @@ -1,5 +1,5 @@ // eslint-disable-next-line import/prefer-default-export -export const type = typedValue => ({ - type: 'SEARCH/TYPE', +export const inputChange = typedValue => ({ + type: 'SEARCH/INPUT_CHANGE', typedValue, }); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index ddfb0a6..2489084 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { type } from '../../actions/search'; +import { inputChange } from '../../actions/search'; import PresentationalSearch from '../presentational/Search'; const mapStateToProps = state => ({ @@ -8,8 +8,8 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - onType: (event) => { - dispatch(type(event.target.value)); + onInputChange: (event) => { + dispatch(inputChange(event.target.value)); }, }); diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index 1e00192..a713db4 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -const Search = ({ onType, value, results }) => ( +const Search = ({ onInputChange, value, results }) => (
      @@ -15,7 +15,7 @@ const Search = ({ onType, value, results }) => ( ); Search.propTypes = { - onType: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, value: PropTypes.string.isRequired, results: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string.require, diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 05926c9..08be519 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -5,7 +5,7 @@ const DEFAULT_STATE = { const search = (state = DEFAULT_STATE, action) => { switch (action.type) { - case 'SEARCH/TYPE': + case 'SEARCH/INPUT_CHANGE': return { ...state, searchInput: action.typedValue, -- cgit v1.1 From 9f6a36d1f1a16c1a777a23fcc8c986c45ee0a116 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 13:25:46 +0100 Subject: Add some basic styling --- src/client/react/actions/search.js | 5 ++++ src/client/react/components/container/Search.js | 9 +++++- .../react/components/presentational/Search.jsx | 35 ++++++++++++++++------ src/client/react/reducers/search.js | 6 ++++ 4 files changed, 45 insertions(+), 10 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js index e50d851..53993d3 100644 --- a/src/client/react/actions/search.js +++ b/src/client/react/actions/search.js @@ -3,3 +3,8 @@ export const inputChange = typedValue => ({ type: 'SEARCH/INPUT_CHANGE', typedValue, }); + +export const focusChange = hasFocus => ({ + type: 'SEARCH/FOCUS_CHANGE', + hasFocus, +}); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 2489084..206a6a1 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -1,16 +1,23 @@ import { connect } from 'react-redux'; -import { inputChange } from '../../actions/search'; +import { inputChange, focusChange } from '../../actions/search'; import PresentationalSearch from '../presentational/Search'; const mapStateToProps = state => ({ results: state.search.searchResults, value: state.search.searchInput, + hasFocus: state.search.hasFocus, }); const mapDispatchToProps = dispatch => ({ onInputChange: (event) => { dispatch(inputChange(event.target.value)); }, + onFocus: () => { + dispatch(focusChange(true)); + }, + onBlur: () => { + dispatch(focusChange(false)); + }, }); const Search = connect( diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index a713db4..01c6f7d 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -1,13 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import SearchIcon from 'react-icons/lib/md/search'; -const Search = ({ onInputChange, value, results }) => ( -
      - +const Search = ({ + onInputChange, + onFocus, + onBlur, + hasFocus, + value, + results, +}) => ( +
      +
      +
      + +
        {results.map(result =>
      • {result.name}
      • )}
      @@ -16,10 +30,13 @@ const Search = ({ onInputChange, value, results }) => ( Search.propTypes = { onInputChange: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, + hasFocus: PropTypes.bool.isRequired, value: PropTypes.string.isRequired, results: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.require, - type: PropTypes.string.require, + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, })).isRequired, }; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 08be519..a695184 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -1,6 +1,7 @@ const DEFAULT_STATE = { searchInput: '', searchResults: [], + hasFocus: false, }; const search = (state = DEFAULT_STATE, action) => { @@ -13,6 +14,11 @@ const search = (state = DEFAULT_STATE, action) => { { type: 's', name: '18561' }, ], }; + case 'SEARCH/FOCUS_CHANGE': + return { + ...state, + hasFocus: action.hasFocus, + }; default: return state; } -- cgit v1.1 From 7e63aa14ea4298a1511f75e875ca001d2bd61ee8 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 13:53:07 +0100 Subject: Add results --- .../react/components/presentational/Search.jsx | 30 ++++++++++++++++------ src/client/react/reducers/search.js | 2 +- 2 files changed, 23 insertions(+), 9 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index 01c6f7d..40cd3e8 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -2,6 +2,23 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import SearchIcon from 'react-icons/lib/md/search'; +import PersonIcon from 'react-icons/lib/md/person'; + +const userShape = { + value: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +const Result = ({ user }) => ( +
      +
      +
      {user.value}
      +
      +); + +Result.propTypes = { + user: PropTypes.shape(userShape).isRequired, +}; const Search = ({ onInputChange, @@ -11,7 +28,7 @@ const Search = ({ value, results, }) => ( -
      +
      0 })}>
      -
        - {results.map(result =>
      • {result.name}
      • )} -
      + {results.map(user => ( + + ))}
      ); @@ -34,10 +51,7 @@ Search.propTypes = { onBlur: PropTypes.func.isRequired, hasFocus: PropTypes.bool.isRequired, value: PropTypes.string.isRequired, - results: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - })).isRequired, + results: PropTypes.arrayOf(PropTypes.shape(userShape)).isRequired, }; export default Search; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index a695184..50233a7 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -11,7 +11,7 @@ const search = (state = DEFAULT_STATE, action) => { ...state, searchInput: action.typedValue, searchResults: [ - { type: 's', name: '18561' }, + { type: 's', value: '18561' }, ], }; case 'SEARCH/FOCUS_CHANGE': -- cgit v1.1 From 29338e66b28daee52f7fe5a5cdab49140b3e5a60 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 14:06:11 +0100 Subject: Show correct icon based on user type --- .../react/components/presentational/Search.jsx | 38 ++++++++++++++++++---- src/client/react/reducers/search.js | 3 ++ 2 files changed, 34 insertions(+), 7 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index 40cd3e8..bdddf06 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -2,19 +2,43 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import SearchIcon from 'react-icons/lib/md/search'; -import PersonIcon from 'react-icons/lib/md/person'; +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 userShape = { value: PropTypes.string.isRequired, type: PropTypes.string.isRequired, }; -const Result = ({ user }) => ( -
      -
      -
      {user.value}
      -
      -); +const Result = ({ user }) => { + let icon; + + switch (user.type) { + case 'c': + icon = ; + break; + case 't': + icon = ; + break; + case 's': + icon = ; + break; + case 'r': + icon = ; + break; + default: + throw new Error(`Invalid user type: ${user.type}`); + } + + return ( +
      +
      {icon}
      +
      {user.value}
      +
      + ); +}; Result.propTypes = { user: PropTypes.shape(userShape).isRequired, diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 50233a7..4e2032d 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -12,6 +12,9 @@ const search = (state = DEFAULT_STATE, action) => { searchInput: action.typedValue, searchResults: [ { type: 's', value: '18561' }, + { type: 'c', value: '5H2' }, + { type: 't', value: 'akh' }, + { type: 'r', value: '008-mk' }, ], }; case 'SEARCH/FOCUS_CHANGE': -- cgit v1.1 From 36dc0fb88258af24069f61935656334c35ef13b3 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 14:23:35 +0100 Subject: Make search function --- src/client/react/reducers/search.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 4e2032d..72fa469 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -1,21 +1,36 @@ +/* global USERS */ +import fuzzy from 'fuzzy'; + const DEFAULT_STATE = { searchInput: '', - searchResults: [], + searchResults: [ + { type: 's', value: '18561' }, + ], hasFocus: false, }; +function getSearchResults(query) { + if (query.trim() === '') { + return []; + } + + const allResults = fuzzy.filter(query, USERS, { + extract: user => user.value, + }); + + const firstResults = allResults.splice(0, 4); + const users = firstResults.map(result => result.original); + + return users; +} + const search = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'SEARCH/INPUT_CHANGE': return { ...state, searchInput: action.typedValue, - searchResults: [ - { type: 's', value: '18561' }, - { type: 'c', value: '5H2' }, - { type: 't', value: 'akh' }, - { type: 'r', value: '008-mk' }, - ], + searchResults: getSearchResults(action.typedValue), }; case 'SEARCH/FOCUS_CHANGE': return { -- cgit v1.1 From 797fb96cd0001d6c739c89507befc73d3d8a6614 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 15:58:25 +0100 Subject: Show user icon instead of search icon on exact match --- src/client/react/components/container/Search.js | 1 + .../react/components/presentational/Search.jsx | 51 +++++++++++++--------- src/client/react/reducers/search.js | 15 ++++++- 3 files changed, 45 insertions(+), 22 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 206a6a1..70b3685 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -6,6 +6,7 @@ const mapStateToProps = state => ({ results: state.search.searchResults, value: state.search.searchInput, hasFocus: state.search.hasFocus, + exactMatch: state.search.exactMatch, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index bdddf06..78beb56 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -12,34 +12,37 @@ const userShape = { type: PropTypes.string.isRequired, }; -const Result = ({ user }) => { - let icon; - - switch (user.type) { +const IconFromUserType = ({ userType }) => { + switch (userType) { case 'c': - icon = ; - break; + return ; case 't': - icon = ; - break; + return ; case 's': - icon = ; - break; + return ; case 'r': - icon = ; - break; + return ; default: - throw new Error(`Invalid user type: ${user.type}`); + return ; } +}; - return ( -
      -
      {icon}
      -
      {user.value}
      -
      - ); +IconFromUserType.propTypes = { + userType: PropTypes.string, }; +IconFromUserType.defaultProps = { + userType: null, +}; + + +const Result = ({ user }) => ( +
      +
      +
      {user.value}
      +
      +); + Result.propTypes = { user: PropTypes.shape(userShape).isRequired, }; @@ -51,10 +54,12 @@ const Search = ({ hasFocus, value, results, + exactMatch, }) => (
      0 })}>
      -
      + {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */} +
      { switch (action.type) { - case 'SEARCH/INPUT_CHANGE': + case 'SEARCH/INPUT_CHANGE': { + let results = getSearchResults(action.typedValue); + let exactMatch = false; + + if ((results.length > 0) && (action.typedValue === results[0].value)) { + [exactMatch] = results; + results = results.splice(1); + } + return { ...state, searchInput: action.typedValue, - searchResults: getSearchResults(action.typedValue), + searchResults: results, + exactMatch, }; + } case 'SEARCH/FOCUS_CHANGE': return { ...state, -- cgit v1.1 From 088f0a2c1ca5d305d83ff001650cca729643c718 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 19:12:20 +0100 Subject: Use when when there is no exact match --- src/client/react/reducers/search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index dd737ed..5d8fee5 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -29,7 +29,7 @@ const search = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'SEARCH/INPUT_CHANGE': { let results = getSearchResults(action.typedValue); - let exactMatch = false; + let exactMatch = null; if ((results.length > 0) && (action.typedValue === results[0].value)) { [exactMatch] = results; -- cgit v1.1 From ddccdcf22bb5008022e93c4789311fb2da95d7cf Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 10 Dec 2017 19:24:53 +0100 Subject: Refactor state names --- src/client/react/components/container/Search.js | 4 ++-- src/client/react/reducers/search.js | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 70b3685..f17a517 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -3,8 +3,8 @@ import { inputChange, focusChange } from '../../actions/search'; import PresentationalSearch from '../presentational/Search'; const mapStateToProps = state => ({ - results: state.search.searchResults, - value: state.search.searchInput, + results: state.search.results, + value: state.search.input, hasFocus: state.search.hasFocus, exactMatch: state.search.exactMatch, }); diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 5d8fee5..52f3b4e 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -2,8 +2,8 @@ import fuzzy from 'fuzzy'; const DEFAULT_STATE = { - searchInput: '', - searchResults: [ + input: '', + results: [ { type: 's', value: '18561' }, ], exactMatch: null, @@ -31,6 +31,8 @@ const search = (state = DEFAULT_STATE, action) => { let results = getSearchResults(action.typedValue); let exactMatch = null; + // Is the typed value exactly the same as the first result? Then show the + // appropiate icon instead of the generic search icon. if ((results.length > 0) && (action.typedValue === results[0].value)) { [exactMatch] = results; results = results.splice(1); @@ -38,8 +40,8 @@ const search = (state = DEFAULT_STATE, action) => { return { ...state, - searchInput: action.typedValue, - searchResults: results, + input: action.typedValue, + results, exactMatch, }; } -- cgit v1.1 From 6ef491badd4ac0190ab17cc41ebd27abbf87c896 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Mon, 11 Dec 2017 19:26:56 +0100 Subject: Select search input on focus --- src/client/react/components/container/Search.js | 1 + src/client/react/components/presentational/Search.jsx | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index f17a517..4517191 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -15,6 +15,7 @@ const mapDispatchToProps = dispatch => ({ }, onFocus: () => { dispatch(focusChange(true)); + document.querySelector('#search__input').select(); }, onBlur: () => { dispatch(focusChange(false)); diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index 78beb56..fc8ca98 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -35,7 +35,6 @@ IconFromUserType.defaultProps = { userType: null, }; - const Result = ({ user }) => (
      @@ -61,6 +60,7 @@ const Search = ({ {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */}
      Date: Mon, 11 Dec 2017 19:36:50 +0100 Subject: Move IconFromUserType and Result to seperate file --- .../components/presentational/IconFromUserType.jsx | 37 +++++++++++++++++ .../react/components/presentational/Result.jsx | 18 ++++++++ .../react/components/presentational/Search.jsx | 48 ++++------------------ 3 files changed, 64 insertions(+), 39 deletions(-) create mode 100644 src/client/react/components/presentational/IconFromUserType.jsx create mode 100644 src/client/react/components/presentational/Result.jsx (limited to 'src/client/react') diff --git a/src/client/react/components/presentational/IconFromUserType.jsx b/src/client/react/components/presentational/IconFromUserType.jsx new file mode 100644 index 0000000..6bd2a21 --- /dev/null +++ b/src/client/react/components/presentational/IconFromUserType.jsx @@ -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 ; + case 't': + return ; + case 's': + return ; + case 'r': + return ; + 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.react, +}; + +IconFromUserType.defaultProps = { + userType: null, + defaultIcon: null, +}; + +export default IconFromUserType; diff --git a/src/client/react/components/presentational/Result.jsx b/src/client/react/components/presentational/Result.jsx new file mode 100644 index 0000000..4876493 --- /dev/null +++ b/src/client/react/components/presentational/Result.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import IconFromUserType from './IconFromUserType'; + +const Result = ({ user }) => ( +
      +
      +
      {user.value}
      +
      +); + +Result.propTypes = { + user: PropTypes.shape({ + value: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + }).isRequired, +}; diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx index fc8ca98..096cdf3 100644 --- a/src/client/react/components/presentational/Search.jsx +++ b/src/client/react/components/presentational/Search.jsx @@ -2,50 +2,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import SearchIcon from 'react-icons/lib/md/search'; -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'; + +import IconFromUserType from './IconFromUserType'; +import Result from './Result'; const userShape = { value: PropTypes.string.isRequired, type: PropTypes.string.isRequired, }; -const IconFromUserType = ({ userType }) => { - switch (userType) { - case 'c': - return ; - case 't': - return ; - case 's': - return ; - case 'r': - return ; - default: - return ; - } -}; - -IconFromUserType.propTypes = { - userType: PropTypes.string, -}; - -IconFromUserType.defaultProps = { - userType: null, -}; - -const Result = ({ user }) => ( -
      -
      -
      {user.value}
      -
      -); - -Result.propTypes = { - user: PropTypes.shape(userShape).isRequired, -}; - const Search = ({ onInputChange, onFocus, @@ -58,7 +23,12 @@ const Search = ({
      0 })}>
      {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */} -
      +
      + } + /> +
      Date: Mon, 11 Dec 2017 19:43:23 +0100 Subject: Move PresentationalSearch back to the container --- src/client/react/components/container/Search.js | 30 -------- src/client/react/components/container/Search.jsx | 85 ++++++++++++++++++++++ .../react/components/presentational/Search.jsx | 61 ---------------- 3 files changed, 85 insertions(+), 91 deletions(-) delete mode 100644 src/client/react/components/container/Search.js create mode 100644 src/client/react/components/container/Search.jsx delete mode 100644 src/client/react/components/presentational/Search.jsx (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js deleted file mode 100644 index 4517191..0000000 --- a/src/client/react/components/container/Search.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { inputChange, focusChange } from '../../actions/search'; -import PresentationalSearch from '../presentational/Search'; - -const mapStateToProps = state => ({ - results: state.search.results, - value: state.search.input, - hasFocus: state.search.hasFocus, - exactMatch: state.search.exactMatch, -}); - -const mapDispatchToProps = dispatch => ({ - onInputChange: (event) => { - dispatch(inputChange(event.target.value)); - }, - onFocus: () => { - dispatch(focusChange(true)); - document.querySelector('#search__input').select(); - }, - onBlur: () => { - dispatch(focusChange(false)); - }, -}); - -const Search = connect( - mapStateToProps, - mapDispatchToProps, -)(PresentationalSearch); - -export default Search; diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx new file mode 100644 index 0000000..7a2822f --- /dev/null +++ b/src/client/react/components/container/Search.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; + +import SearchIcon from 'react-icons/lib/md/search'; + +import { inputChange, focusChange } from '../../actions/search'; + +import IconFromUserType from '../presentational/IconFromUserType'; +import Result from '../presentational/Result'; + +const userShape = { + value: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +const Search = ({ + onInputChange, + onFocus, + onBlur, + hasFocus, + value, + results, + exactMatch, +}) => ( +
      0 })}> +
      + {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */} +
      + } + /> +
      + +
      + {results.map(user => ( + + ))} +
      +); + +Search.propTypes = { + onInputChange: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, + hasFocus: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired, + results: PropTypes.arrayOf(PropTypes.shape(userShape)).isRequired, + exactMatch: PropTypes.shape(userShape), +}; + +Search.defaultProps = { + exactMatch: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + value: state.search.input, + hasFocus: state.search.hasFocus, + exactMatch: state.search.exactMatch, +}); + +const mapDispatchToProps = dispatch => ({ + onInputChange: (event) => { + dispatch(inputChange(event.target.value)); + }, + onFocus: () => { + dispatch(focusChange(true)); + document.querySelector('#search__input').select(); + }, + onBlur: () => { + dispatch(focusChange(false)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/src/client/react/components/presentational/Search.jsx b/src/client/react/components/presentational/Search.jsx deleted file mode 100644 index 096cdf3..0000000 --- a/src/client/react/components/presentational/Search.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import SearchIcon from 'react-icons/lib/md/search'; - -import IconFromUserType from './IconFromUserType'; -import Result from './Result'; - -const userShape = { - value: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, -}; - -const Search = ({ - onInputChange, - onFocus, - onBlur, - hasFocus, - value, - results, - exactMatch, -}) => ( -
      0 })}> -
      - {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */} -
      - } - /> -
      - -
      - {results.map(user => ( - - ))} -
      -); - -Search.propTypes = { - onInputChange: PropTypes.func.isRequired, - onFocus: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired, - hasFocus: PropTypes.bool.isRequired, - value: PropTypes.string.isRequired, - results: PropTypes.arrayOf(PropTypes.shape(userShape)).isRequired, - exactMatch: PropTypes.shape(userShape), -}; - -Search.defaultProps = { - exactMatch: null, -}; - -export default Search; -- cgit v1.1 From 2cac093369ecb965ddbef60e6dc2aa6e50f0e937 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Mon, 11 Dec 2017 20:36:51 +0100 Subject: Refactor Results out of Search --- src/client/react/components/container/Results.jsx | 15 +++++++++++++++ src/client/react/components/container/Search.jsx | 13 ++++--------- .../react/components/presentational/IconFromUserType.jsx | 2 +- src/client/react/components/presentational/Result.jsx | 2 ++ 4 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 src/client/react/components/container/Results.jsx (limited to 'src/client/react') diff --git a/src/client/react/components/container/Results.jsx b/src/client/react/components/container/Results.jsx new file mode 100644 index 0000000..04d1f84 --- /dev/null +++ b/src/client/react/components/container/Results.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Result from '../presentational/Result'; + +const Results = (({ results }) => ( + results.map(user => ( + + )) +)); + +const mapStateToProps = state => ({ + results: state.search.results, +}); + +export default connect(mapStateToProps)(Results); diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index 7a2822f..7890423 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -7,8 +7,8 @@ import SearchIcon from 'react-icons/lib/md/search'; import { inputChange, focusChange } from '../../actions/search'; +import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; -import Result from '../presentational/Result'; const userShape = { value: PropTypes.string.isRequired, @@ -21,16 +21,14 @@ const Search = ({ onBlur, hasFocus, value, - results, exactMatch, }) => ( -
      0 })}> +
      - {/* Show the icon from the exact match if there is an exact match, otherwise show the search icon. */}
      } + defaultIcon={} />
      - {results.map(user => ( - - ))} +
      ); @@ -54,7 +50,6 @@ Search.propTypes = { onBlur: PropTypes.func.isRequired, hasFocus: PropTypes.bool.isRequired, value: PropTypes.string.isRequired, - results: PropTypes.arrayOf(PropTypes.shape(userShape)).isRequired, exactMatch: PropTypes.shape(userShape), }; diff --git a/src/client/react/components/presentational/IconFromUserType.jsx b/src/client/react/components/presentational/IconFromUserType.jsx index 6bd2a21..ee0e04b 100644 --- a/src/client/react/components/presentational/IconFromUserType.jsx +++ b/src/client/react/components/presentational/IconFromUserType.jsx @@ -26,7 +26,7 @@ const IconFromUserType = ({ userType, defaultIcon }) => { IconFromUserType.propTypes = { userType: PropTypes.string, - defaultIcon: PropTypes.react, + defaultIcon: PropTypes.element, }; IconFromUserType.defaultProps = { diff --git a/src/client/react/components/presentational/Result.jsx b/src/client/react/components/presentational/Result.jsx index 4876493..a4a0b8e 100644 --- a/src/client/react/components/presentational/Result.jsx +++ b/src/client/react/components/presentational/Result.jsx @@ -16,3 +16,5 @@ Result.propTypes = { type: PropTypes.string.isRequired, }).isRequired, }; + +export default Result; -- cgit v1.1 From 2216d1ceed02e54620f16fb826e5947b6c2cb9bf Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Mon, 11 Dec 2017 20:43:07 +0100 Subject: Add line between results and search --- src/client/react/components/container/Results.jsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Results.jsx b/src/client/react/components/container/Results.jsx index 04d1f84..1c38c8b 100644 --- a/src/client/react/components/container/Results.jsx +++ b/src/client/react/components/container/Results.jsx @@ -1,13 +1,28 @@ 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 }) => ( - results.map(user => ( - - )) +
      0, + })} + > + {results.map(user => ( + + ))} +
      )); +Results.propTypes = { + results: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + value: PropTypes.value, + })).isRequired, +}; + const mapStateToProps = state => ({ results: state.search.results, }); -- cgit v1.1 From b00e556ebff50558e6532683ff2763825c51646f Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Mon, 11 Dec 2017 20:52:17 +0100 Subject: Move hasFocus to internal state --- src/client/react/components/container/Search.jsx | 95 ++++++++++++++---------- src/client/react/reducers/search.js | 6 -- 2 files changed, 57 insertions(+), 44 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index 7890423..a26c277 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -15,40 +15,67 @@ const userShape = { type: PropTypes.string.isRequired, }; -const Search = ({ - onInputChange, - onFocus, - onBlur, - hasFocus, - value, - exactMatch, -}) => ( -
      -
      -
      - } - /> +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); + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + render() { + const { + onInputChange, + value, + exactMatch, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( +
      +
      +
      + } + /> +
      + +
      +
      - -
      - -
      -); + ); + } +} Search.propTypes = { onInputChange: PropTypes.func.isRequired, - onFocus: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired, - hasFocus: PropTypes.bool.isRequired, value: PropTypes.string.isRequired, exactMatch: PropTypes.shape(userShape), }; @@ -60,7 +87,6 @@ Search.defaultProps = { const mapStateToProps = state => ({ results: state.search.results, value: state.search.input, - hasFocus: state.search.hasFocus, exactMatch: state.search.exactMatch, }); @@ -68,13 +94,6 @@ const mapDispatchToProps = dispatch => ({ onInputChange: (event) => { dispatch(inputChange(event.target.value)); }, - onFocus: () => { - dispatch(focusChange(true)); - document.querySelector('#search__input').select(); - }, - onBlur: () => { - dispatch(focusChange(false)); - }, }); export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 52f3b4e..d61689b 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -7,7 +7,6 @@ const DEFAULT_STATE = { { type: 's', value: '18561' }, ], exactMatch: null, - hasFocus: false, }; function getSearchResults(query) { @@ -45,11 +44,6 @@ const search = (state = DEFAULT_STATE, action) => { exactMatch, }; } - case 'SEARCH/FOCUS_CHANGE': - return { - ...state, - hasFocus: action.hasFocus, - }; default: return state; } -- cgit v1.1 From 58f69c010e55ded87dd5976a61a543183e26cba9 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 13 Dec 2017 12:15:40 +0100 Subject: Add react-router to the mix --- src/client/react/App.jsx | 10 ---------- src/client/react/LandingPage.jsx | 10 ++++++++++ src/client/react/index.jsx | 9 +++++++-- 3 files changed, 17 insertions(+), 12 deletions(-) delete mode 100644 src/client/react/App.jsx create mode 100644 src/client/react/LandingPage.jsx (limited to 'src/client/react') diff --git a/src/client/react/App.jsx b/src/client/react/App.jsx deleted file mode 100644 index d79826e..0000000 --- a/src/client/react/App.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import Search from './components/container/Search'; - -const App = () => ( -
      - -
      -); - -export default App; diff --git a/src/client/react/LandingPage.jsx b/src/client/react/LandingPage.jsx new file mode 100644 index 0000000..d79826e --- /dev/null +++ b/src/client/react/LandingPage.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Search from './components/container/Search'; + +const App = () => ( +
      + +
      +); + +export default App; diff --git a/src/client/react/index.jsx b/src/client/react/index.jsx index e1bae3c..8c92bb1 100644 --- a/src/client/react/index.jsx +++ b/src/client/react/index.jsx @@ -1,9 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; import { createStore } from 'redux'; import reducer from './reducers'; -import App from './App'; +import LandingPage from './LandingPage'; /* eslint-disable no-underscore-dangle */ const store = createStore( @@ -14,7 +15,11 @@ const store = createStore( ReactDOM.render( - + +
      + +
      +
      , document.getElementById('root'), ); -- cgit v1.1 From 41a01f056984dc74f47e0380e2fe28fa16a59ff7 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 13 Dec 2017 12:19:37 +0100 Subject: Use inline dispatch --- src/client/react/components/container/Search.jsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index a26c277..e974bd9 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -5,7 +5,7 @@ import classnames from 'classnames'; import SearchIcon from 'react-icons/lib/md/search'; -import { inputChange, focusChange } from '../../actions/search'; +import { inputChange } from '../../actions/search'; import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; @@ -41,9 +41,9 @@ class Search extends React.Component { render() { const { - onInputChange, value, exactMatch, + dispatch, } = this.props; const { @@ -61,7 +61,7 @@ class Search extends React.Component {
      dispatch(inputChange(event.target.value))} value={value} placeholder="Zoeken" onFocus={this.onFocus} @@ -75,9 +75,9 @@ class Search extends React.Component { } Search.propTypes = { - onInputChange: PropTypes.func.isRequired, value: PropTypes.string.isRequired, exactMatch: PropTypes.shape(userShape), + dispatch: PropTypes.func.isRequired, }; Search.defaultProps = { @@ -90,10 +90,4 @@ const mapStateToProps = state => ({ exactMatch: state.search.exactMatch, }); -const mapDispatchToProps = dispatch => ({ - onInputChange: (event) => { - dispatch(inputChange(event.target.value)); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Search); +export default connect(mapStateToProps)(Search); -- cgit v1.1 From fe27a0819a60caaa69b059f0c86d95ab0c4084b7 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 13 Dec 2017 12:26:36 +0100 Subject: Prepair changeSelectedResult --- src/client/react/actions/search.js | 12 ++++++++---- src/client/react/components/container/Search.jsx | 13 ++++++++----- src/client/react/reducers/search.js | 12 ++++++++---- 3 files changed, 24 insertions(+), 13 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js index 53993d3..1b6847d 100644 --- a/src/client/react/actions/search.js +++ b/src/client/react/actions/search.js @@ -1,10 +1,14 @@ -// eslint-disable-next-line import/prefer-default-export export const inputChange = typedValue => ({ type: 'SEARCH/INPUT_CHANGE', typedValue, }); -export const focusChange = hasFocus => ({ - type: 'SEARCH/FOCUS_CHANGE', - hasFocus, +/** + * 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/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index e974bd9..7e33e84 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -42,7 +42,8 @@ class Search extends React.Component { render() { const { value, - exactMatch, + selectedResult, + isExactMatch, dispatch, } = this.props; @@ -55,7 +56,7 @@ class Search extends React.Component {
      } />
      @@ -76,18 +77,20 @@ class Search extends React.Component { Search.propTypes = { value: PropTypes.string.isRequired, - exactMatch: PropTypes.shape(userShape), + selectedResult: PropTypes.shape(userShape), + isExactMatch: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, }; Search.defaultProps = { - exactMatch: null, + selectedResult: null, }; const mapStateToProps = state => ({ results: state.search.results, value: state.search.input, - exactMatch: state.search.exactMatch, + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, }); export default connect(mapStateToProps)(Search); diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index d61689b..f566b49 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -6,7 +6,8 @@ const DEFAULT_STATE = { results: [ { type: 's', value: '18561' }, ], - exactMatch: null, + selectedResult: null, + isExactMatch: false, }; function getSearchResults(query) { @@ -28,12 +29,14 @@ const search = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'SEARCH/INPUT_CHANGE': { let results = getSearchResults(action.typedValue); - let exactMatch = null; + let selectedResult = null; + let isExactMatch = false; // Is the typed value exactly the same as the first result? Then show the // appropiate icon instead of the generic search icon. if ((results.length > 0) && (action.typedValue === results[0].value)) { - [exactMatch] = results; + [selectedResult] = results; + isExactMatch = true; results = results.splice(1); } @@ -41,7 +44,8 @@ const search = (state = DEFAULT_STATE, action) => { ...state, input: action.typedValue, results, - exactMatch, + selectedResult, + isExactMatch, }; } default: -- cgit v1.1 From 503ab5c66ab524dfe36aed84a01899cd07ed2bc5 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 13 Dec 2017 15:53:19 +0100 Subject: Allow selection of result using keyboard --- src/client/react/components/container/Results.jsx | 15 +++++++++--- src/client/react/components/container/Search.jsx | 20 +++++++++++++++- .../react/components/presentational/Result.jsx | 10 ++++++-- src/client/react/reducers/search.js | 28 ++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Results.jsx b/src/client/react/components/container/Results.jsx index 1c38c8b..9be2639 100644 --- a/src/client/react/components/container/Results.jsx +++ b/src/client/react/components/container/Results.jsx @@ -4,14 +4,14 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import Result from '../presentational/Result'; -const Results = (({ results }) => ( +const Results = (({ results, selectedResult }) => (
      0, })} > {results.map(user => ( - + ))}
      )); @@ -19,12 +19,21 @@ const Results = (({ results }) => ( Results.propTypes = { results: PropTypes.arrayOf(PropTypes.shape({ type: PropTypes.string, - value: PropTypes.value, + value: PropTypes.string, })).isRequired, + selectedResult: PropTypes.shape({ + type: PropTypes.string, + value: PropTypes.string, + }), +}; + +Results.defaultProps = { + selectedResult: null, }; const mapStateToProps = state => ({ results: state.search.results, + selectedResult: state.search.selectedResult, }); export default connect(mapStateToProps)(Results); diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index 7e33e84..50917dd 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -5,7 +5,7 @@ import classnames from 'classnames'; import SearchIcon from 'react-icons/lib/md/search'; -import { inputChange } from '../../actions/search'; +import { inputChange, changeSelectedResult } from '../../actions/search'; import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; @@ -25,6 +25,7 @@ class Search extends React.Component { this.onFocus = this.onFocus.bind(this); this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); } onFocus() { @@ -39,6 +40,22 @@ class Search extends React.Component { }); } + onKeyDown(event) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.dispatch(changeSelectedResult(-1)); + break; + case 'ArrowDown': + this.props.dispatch(changeSelectedResult(+1)); + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + render() { const { value, @@ -63,6 +80,7 @@ class Search extends React.Component { dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} value={value} placeholder="Zoeken" onFocus={this.onFocus} diff --git a/src/client/react/components/presentational/Result.jsx b/src/client/react/components/presentational/Result.jsx index a4a0b8e..80f65d4 100644 --- a/src/client/react/components/presentational/Result.jsx +++ b/src/client/react/components/presentational/Result.jsx @@ -1,10 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classnames from 'classnames'; import IconFromUserType from './IconFromUserType'; -const Result = ({ user }) => ( -
      +const Result = ({ user, selected }) => ( +
      {user.value}
      @@ -15,6 +20,7 @@ Result.propTypes = { value: PropTypes.string.isRequired, type: PropTypes.string.isRequired, }).isRequired, + selected: PropTypes.bool.isRequired, }; export default Result; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index f566b49..6ef0f4d 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -48,6 +48,34 @@ const search = (state = DEFAULT_STATE, action) => { isExactMatch, }; } + + case 'SEARCH/CHANGE_SELECTED_RESULT': { + const { results, isExactMatch } = 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; + } + + let nextSelectedResult = + nextSelectedResultIndex === -1 + ? null + : results[nextSelectedResultIndex]; + + if (isExactMatch) { + nextSelectedResult = prevSelectedResult; + } + + return { + ...state, + selectedResult: nextSelectedResult, + }; + } default: return state; } -- cgit v1.1 From 778dfdc728a101fca9ece3a14e590d3b8e1d43e1 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 12:32:07 +0100 Subject: Use id's instead of user objects --- src/client/react/components/container/Results.jsx | 20 +++---- src/client/react/components/container/Search.jsx | 10 +--- .../react/components/presentational/Result.jsx | 16 +++--- src/client/react/reducers/search.js | 26 ++++----- src/client/react/users.js | 66 ++++++++++++++++++++++ 5 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 src/client/react/users.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/Results.jsx b/src/client/react/components/container/Results.jsx index 9be2639..911ea27 100644 --- a/src/client/react/components/container/Results.jsx +++ b/src/client/react/components/container/Results.jsx @@ -4,27 +4,22 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import Result from '../presentational/Result'; -const Results = (({ results, selectedResult }) => ( +const Results = (({ results, isExactMatch, selectedResult }) => (
      0, + 'search__results--has-results': !isExactMatch && results.length > 0, })} > - {results.map(user => ( - + {!isExactMatch && results.map(userId => ( + ))}
      )); Results.propTypes = { - results: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - value: PropTypes.string, - })).isRequired, - selectedResult: PropTypes.shape({ - type: PropTypes.string, - value: PropTypes.string, - }), + results: PropTypes.arrayOf(PropTypes.string).isRequired, + isExactMatch: PropTypes.bool.isRequired, + selectedResult: PropTypes.string, }; Results.defaultProps = { @@ -33,6 +28,7 @@ Results.defaultProps = { const mapStateToProps = state => ({ results: state.search.results, + isExactMatch: state.search.isExactMatch, selectedResult: state.search.selectedResult, }); diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index 50917dd..babe0c4 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -7,14 +7,10 @@ import SearchIcon from 'react-icons/lib/md/search'; import { inputChange, changeSelectedResult } from '../../actions/search'; +import users from '../../users'; import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; -const userShape = { - value: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, -}; - class Search extends React.Component { constructor(props) { super(props); @@ -73,7 +69,7 @@ class Search extends React.Component {
      } />
      @@ -95,7 +91,7 @@ class Search extends React.Component { Search.propTypes = { value: PropTypes.string.isRequired, - selectedResult: PropTypes.shape(userShape), + selectedResult: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, }; diff --git a/src/client/react/components/presentational/Result.jsx b/src/client/react/components/presentational/Result.jsx index 80f65d4..0b9e024 100644 --- a/src/client/react/components/presentational/Result.jsx +++ b/src/client/react/components/presentational/Result.jsx @@ -1,26 +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 = ({ user, selected }) => ( +const Result = ({ userId, isSelected }) => (
      -
      -
      {user.value}
      +
      +
      {users.byId[userId].value}
      ); Result.propTypes = { - user: PropTypes.shape({ - value: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - }).isRequired, - selected: PropTypes.bool.isRequired, + userId: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, }; export default Result; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 6ef0f4d..cad491b 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -1,43 +1,42 @@ -/* global USERS */ import fuzzy from 'fuzzy'; +import users from '../users'; const DEFAULT_STATE = { input: '', results: [ - { type: 's', value: '18561' }, + 's/18562', ], selectedResult: null, isExactMatch: false, }; -function getSearchResults(query) { +function getSearchResults(allUsers, query) { if (query.trim() === '') { return []; } - const allResults = fuzzy.filter(query, USERS, { + const allResults = fuzzy.filter(query, allUsers, { extract: user => user.value, }); const firstResults = allResults.splice(0, 4); - const users = firstResults.map(result => result.original); + const userIds = firstResults.map(result => result.original.id); - return users; + return userIds; } const search = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'SEARCH/INPUT_CHANGE': { - let results = getSearchResults(action.typedValue); + const results = getSearchResults(users.allUsers, action.typedValue); let selectedResult = null; let isExactMatch = false; // Is the typed value exactly the same as the first result? Then show the // appropiate icon instead of the generic search icon. - if ((results.length > 0) && (action.typedValue === results[0].value)) { + if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { [selectedResult] = results; isExactMatch = true; - results = results.splice(1); } return { @@ -51,6 +50,9 @@ const search = (state = DEFAULT_STATE, action) => { case 'SEARCH/CHANGE_SELECTED_RESULT': { const { results, isExactMatch } = state; + + if (isExactMatch) return state; + const prevSelectedResult = state.selectedResult; const prevSelectedResultIndex = results.indexOf(prevSelectedResult); let nextSelectedResultIndex = @@ -62,15 +64,11 @@ const search = (state = DEFAULT_STATE, action) => { nextSelectedResultIndex = -1; } - let nextSelectedResult = + const nextSelectedResult = nextSelectedResultIndex === -1 ? null : results[nextSelectedResultIndex]; - if (isExactMatch) { - nextSelectedResult = prevSelectedResult; - } - return { ...state, selectedResult: nextSelectedResult, 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; -- cgit v1.1 From a77272bfbebed7239c0c49049e211f4a6e597617 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 12:32:13 +0100 Subject: Add tests for search.js --- src/client/react/reducers/search.test.js | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/client/react/reducers/search.test.js (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js new file mode 100644 index 0000000..ccf6fae --- /dev/null +++ b/src/client/react/reducers/search.test.js @@ -0,0 +1,142 @@ +window.USERS = []; + +const deepFreeze = require('deep-freeze'); +const search = require('./search').default; +const { changeSelectedResult } = require('../actions/search'); + +describe('reducers', () => { + describe('search', () => { + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { + it('Does nothing when there are no results', () => { + const prevState = { + results: [], + 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'], + 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'], + 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'], + 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'], + selectedResult: 's/18563', + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }); + }); + + it('Properly wraps arround when decrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18563', + isExactMatch: false, + }); + }); + }); + }); +}); -- cgit v1.1 From 5caa2d35af001302b4b837ede18ef5c7041287af Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 12:45:23 +0100 Subject: Add tests for SEARCH/INPUT_CHANGE --- src/client/react/reducers/search.test.js | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js index ccf6fae..13f79d2 100644 --- a/src/client/react/reducers/search.test.js +++ b/src/client/react/reducers/search.test.js @@ -1,11 +1,66 @@ -window.USERS = []; +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 { changeSelectedResult } = require('../actions/search'); +const { inputChange, changeSelectedResult } = require('../actions/search'); describe('reducers', () => { describe('search', () => { + describe('SEARCH/INPUT_CHANGE', () => { + it('Returns no results when nothing is typed in', () => { + expect(search(undefined, inputChange(''))).toEqual({ + input: '', + results: [], + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Returns no results when a space is typed in', () => { + expect(search(undefined, inputChange(' '))).toEqual({ + input: ' ', + results: [], + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Preforms a basic search, only returning four results', () => { + expect(search(undefined, inputChange('18'))).toEqual({ + input: '18', + results: [ + 's/18561', + 's/18562', + 's/18563', + 's/18564', + ], + 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({ + input: '18561', + results: [ + 's/18561', + ], + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { it('Does nothing when there are no results', () => { const prevState = { -- cgit v1.1 From ebb7aaf5d62fea0d3b9ac0ceb7c8b3fa3578ba59 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 12:47:02 +0100 Subject: Add an extra new line --- src/client/react/reducers/search.js | 1 + 1 file changed, 1 insertion(+) (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index cad491b..29d0e5c 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -74,6 +74,7 @@ const search = (state = DEFAULT_STATE, action) => { selectedResult: nextSelectedResult, }; } + default: return state; } -- cgit v1.1 From bb5b3629445ed6a52c3a088dbc2dd08b7a326f8a Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 16:24:10 +0100 Subject: Remove input value from redux --- src/client/react/components/container/Search.jsx | 4 ---- src/client/react/reducers/search.js | 2 -- src/client/react/reducers/search.test.js | 4 ---- 3 files changed, 10 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx index babe0c4..e49e6a7 100644 --- a/src/client/react/components/container/Search.jsx +++ b/src/client/react/components/container/Search.jsx @@ -54,7 +54,6 @@ class Search extends React.Component { render() { const { - value, selectedResult, isExactMatch, dispatch, @@ -77,7 +76,6 @@ class Search extends React.Component { id="search__input" onChange={event => dispatch(inputChange(event.target.value))} onKeyDown={this.onKeyDown} - value={value} placeholder="Zoeken" onFocus={this.onFocus} onBlur={this.onBlur} @@ -90,7 +88,6 @@ class Search extends React.Component { } Search.propTypes = { - value: PropTypes.string.isRequired, selectedResult: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, @@ -102,7 +99,6 @@ Search.defaultProps = { const mapStateToProps = state => ({ results: state.search.results, - value: state.search.input, selectedResult: state.search.selectedResult, isExactMatch: state.search.isExactMatch, }); diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 29d0e5c..2a7e7a5 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -2,7 +2,6 @@ import fuzzy from 'fuzzy'; import users from '../users'; const DEFAULT_STATE = { - input: '', results: [ 's/18562', ], @@ -41,7 +40,6 @@ const search = (state = DEFAULT_STATE, action) => { return { ...state, - input: action.typedValue, results, selectedResult, isExactMatch, diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js index 13f79d2..e0ca18e 100644 --- a/src/client/react/reducers/search.test.js +++ b/src/client/react/reducers/search.test.js @@ -19,7 +19,6 @@ describe('reducers', () => { describe('SEARCH/INPUT_CHANGE', () => { it('Returns no results when nothing is typed in', () => { expect(search(undefined, inputChange(''))).toEqual({ - input: '', results: [], selectedResult: null, isExactMatch: false, @@ -28,7 +27,6 @@ describe('reducers', () => { it('Returns no results when a space is typed in', () => { expect(search(undefined, inputChange(' '))).toEqual({ - input: ' ', results: [], selectedResult: null, isExactMatch: false, @@ -37,7 +35,6 @@ describe('reducers', () => { it('Preforms a basic search, only returning four results', () => { expect(search(undefined, inputChange('18'))).toEqual({ - input: '18', results: [ 's/18561', 's/18562', @@ -51,7 +48,6 @@ describe('reducers', () => { it('Selects the first result and sets isExactMatch to true when there is an exact match', () => { expect(search(undefined, inputChange('18561'))).toEqual({ - input: '18561', results: [ 's/18561', ], -- cgit v1.1 From 43c72f445c7906ab707493b3e857acf83d5bcd1f Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 16:36:23 +0100 Subject: Add redux-logger --- src/client/react/index.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/index.jsx b/src/client/react/index.jsx index 8c92bb1..f0e4e23 100644 --- a/src/client/react/index.jsx +++ b/src/client/react/index.jsx @@ -2,16 +2,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { BrowserRouter as Router, Route } from 'react-router-dom'; -import { createStore } from 'redux'; +import { createStore, applyMiddleware, compose } from 'redux'; +import logger from 'redux-logger'; import reducer from './reducers'; import LandingPage from './LandingPage'; -/* eslint-disable no-underscore-dangle */ +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store = createStore( reducer, - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), + composeEnhancers(applyMiddleware(logger)), ); -/* eslint-enable */ ReactDOM.render( -- cgit v1.1 From 8e9d5e98793f7594a8a1a1b5956163eca6795164 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 16:38:11 +0100 Subject: Add redux-thunk --- src/client/react/index.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src/client/react') diff --git a/src/client/react/index.jsx b/src/client/react/index.jsx index f0e4e23..5279bf4 100644 --- a/src/client/react/index.jsx +++ b/src/client/react/index.jsx @@ -4,6 +4,7 @@ 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 LandingPage from './LandingPage'; @@ -11,7 +12,7 @@ import LandingPage from './LandingPage'; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store = createStore( reducer, - composeEnhancers(applyMiddleware(logger)), + composeEnhancers(applyMiddleware(logger, thunk)), ); ReactDOM.render( -- cgit v1.1 From 569b2969d530f08e55798c5cb3079948c7c037cd Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 14 Dec 2017 18:54:00 +0100 Subject: Use .js extention instead of .jsx --- src/client/react/LandingPage.js | 10 ++ src/client/react/LandingPage.jsx | 10 -- src/client/react/components/container/Results.js | 35 +++++++ src/client/react/components/container/Results.jsx | 35 ------- src/client/react/components/container/Search.js | 106 +++++++++++++++++++++ src/client/react/components/container/Search.jsx | 106 --------------------- .../components/presentational/IconFromUserType.js | 37 +++++++ .../components/presentational/IconFromUserType.jsx | 37 ------- .../react/components/presentational/Result.js | 24 +++++ .../react/components/presentational/Result.jsx | 24 ----- src/client/react/index.js | 27 ++++++ src/client/react/index.jsx | 27 ------ 12 files changed, 239 insertions(+), 239 deletions(-) create mode 100644 src/client/react/LandingPage.js delete mode 100644 src/client/react/LandingPage.jsx create mode 100644 src/client/react/components/container/Results.js delete mode 100644 src/client/react/components/container/Results.jsx create mode 100644 src/client/react/components/container/Search.js delete mode 100644 src/client/react/components/container/Search.jsx create mode 100644 src/client/react/components/presentational/IconFromUserType.js delete mode 100644 src/client/react/components/presentational/IconFromUserType.jsx create mode 100644 src/client/react/components/presentational/Result.js delete mode 100644 src/client/react/components/presentational/Result.jsx create mode 100644 src/client/react/index.js delete mode 100644 src/client/react/index.jsx (limited to 'src/client/react') diff --git a/src/client/react/LandingPage.js b/src/client/react/LandingPage.js new file mode 100644 index 0000000..d79826e --- /dev/null +++ b/src/client/react/LandingPage.js @@ -0,0 +1,10 @@ +import React from 'react'; +import Search from './components/container/Search'; + +const App = () => ( +
      + +
      +); + +export default App; diff --git a/src/client/react/LandingPage.jsx b/src/client/react/LandingPage.jsx deleted file mode 100644 index d79826e..0000000 --- a/src/client/react/LandingPage.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import Search from './components/container/Search'; - -const App = () => ( -
      - -
      -); - -export default App; diff --git a/src/client/react/components/container/Results.js b/src/client/react/components/container/Results.js new file mode 100644 index 0000000..911ea27 --- /dev/null +++ b/src/client/react/components/container/Results.js @@ -0,0 +1,35 @@ +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 }) => ( +
      0, + })} + > + {!isExactMatch && results.map(userId => ( + + ))} +
      +)); + +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/Results.jsx b/src/client/react/components/container/Results.jsx deleted file mode 100644 index 911ea27..0000000 --- a/src/client/react/components/container/Results.jsx +++ /dev/null @@ -1,35 +0,0 @@ -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 }) => ( -
      0, - })} - > - {!isExactMatch && results.map(userId => ( - - ))} -
      -)); - -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..e49e6a7 --- /dev/null +++ b/src/client/react/components/container/Search.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; + +import SearchIcon from 'react-icons/lib/md/search'; + +import { 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); + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + onKeyDown(event) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.dispatch(changeSelectedResult(-1)); + break; + case 'ArrowDown': + this.props.dispatch(changeSelectedResult(+1)); + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + + render() { + const { + selectedResult, + isExactMatch, + dispatch, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( +
      +
      +
      + } + /> +
      + dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} + /> +
      + +
      + ); + } +} + +Search.propTypes = { + selectedResult: PropTypes.string, + isExactMatch: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, +}; + +Search.defaultProps = { + selectedResult: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, +}); + +export default connect(mapStateToProps)(Search); diff --git a/src/client/react/components/container/Search.jsx b/src/client/react/components/container/Search.jsx deleted file mode 100644 index e49e6a7..0000000 --- a/src/client/react/components/container/Search.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; - -import SearchIcon from 'react-icons/lib/md/search'; - -import { 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); - } - - onFocus() { - this.setState({ - hasFocus: true, - }); - } - - onBlur() { - this.setState({ - hasFocus: false, - }); - } - - onKeyDown(event) { - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - event.preventDefault(); - switch (event.key) { - case 'ArrowUp': - this.props.dispatch(changeSelectedResult(-1)); - break; - case 'ArrowDown': - this.props.dispatch(changeSelectedResult(+1)); - break; - default: - throw new Error('This should never happen... pls?'); - } - } - } - - render() { - const { - selectedResult, - isExactMatch, - dispatch, - } = this.props; - - const { - hasFocus, - } = this.state; - - return ( -
      -
      -
      - } - /> -
      - dispatch(inputChange(event.target.value))} - onKeyDown={this.onKeyDown} - placeholder="Zoeken" - onFocus={this.onFocus} - onBlur={this.onBlur} - /> -
      - -
      - ); - } -} - -Search.propTypes = { - selectedResult: PropTypes.string, - isExactMatch: PropTypes.bool.isRequired, - dispatch: PropTypes.func.isRequired, -}; - -Search.defaultProps = { - selectedResult: null, -}; - -const mapStateToProps = state => ({ - results: state.search.results, - selectedResult: state.search.selectedResult, - isExactMatch: state.search.isExactMatch, -}); - -export default connect(mapStateToProps)(Search); 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 ; + case 't': + return ; + case 's': + return ; + case 'r': + return ; + 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/IconFromUserType.jsx b/src/client/react/components/presentational/IconFromUserType.jsx deleted file mode 100644 index ee0e04b..0000000 --- a/src/client/react/components/presentational/IconFromUserType.jsx +++ /dev/null @@ -1,37 +0,0 @@ -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 ; - case 't': - return ; - case 's': - return ; - case 'r': - return ; - 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/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 }) => ( +
      +
      +
      {users.byId[userId].value}
      +
      +); + +Result.propTypes = { + userId: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, +}; + +export default Result; diff --git a/src/client/react/components/presentational/Result.jsx b/src/client/react/components/presentational/Result.jsx deleted file mode 100644 index 0b9e024..0000000 --- a/src/client/react/components/presentational/Result.jsx +++ /dev/null @@ -1,24 +0,0 @@ -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 }) => ( -
      -
      -
      {users.byId[userId].value}
      -
      -); - -Result.propTypes = { - userId: PropTypes.string.isRequired, - isSelected: PropTypes.bool.isRequired, -}; - -export default Result; diff --git a/src/client/react/index.js b/src/client/react/index.js new file mode 100644 index 0000000..5279bf4 --- /dev/null +++ b/src/client/react/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +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 LandingPage from './LandingPage'; + +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const store = createStore( + reducer, + composeEnhancers(applyMiddleware(logger, thunk)), +); + +ReactDOM.render( + + +
      + +
      +
      +
      , + document.getElementById('root'), +); diff --git a/src/client/react/index.jsx b/src/client/react/index.jsx deleted file mode 100644 index 5279bf4..0000000 --- a/src/client/react/index.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -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 LandingPage from './LandingPage'; - -// eslint-disable-next-line no-underscore-dangle -const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -const store = createStore( - reducer, - composeEnhancers(applyMiddleware(logger, thunk)), -); - -ReactDOM.render( - - -
      - -
      -
      -
      , - document.getElementById('root'), -); -- cgit v1.1 From f0c8cf0e79f003514fd65a70def5820205955a77 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 21 Dec 2017 12:06:41 +0100 Subject: Move to typescript --- src/client/react/LandingPage.js | 10 -- src/client/react/LandingPage.tsx | 10 ++ src/client/react/actions/search.js | 14 -- src/client/react/actions/search.ts | 24 +++ src/client/react/components/container/Results.js | 35 ---- src/client/react/components/container/Results.tsx | 26 +++ src/client/react/components/container/Search.js | 106 ----------- src/client/react/components/container/Search.tsx | 131 ++++++++++++++ .../components/presentational/IconFromUserType.js | 37 ---- .../components/presentational/IconFromUserType.tsx | 31 ++++ .../react/components/presentational/Result.js | 24 --- .../react/components/presentational/Result.tsx | 18 ++ src/client/react/index.js | 27 --- src/client/react/index.tsx | 27 +++ src/client/react/reducers.js | 8 - src/client/react/reducers.ts | 12 ++ src/client/react/reducers/search.js | 81 --------- src/client/react/reducers/search.test.js | 193 --------------------- src/client/react/reducers/search.test.ts | 193 +++++++++++++++++++++ src/client/react/reducers/search.ts | 90 ++++++++++ src/client/react/users.js | 66 ------- src/client/react/users.ts | 89 ++++++++++ 22 files changed, 651 insertions(+), 601 deletions(-) delete mode 100644 src/client/react/LandingPage.js create mode 100644 src/client/react/LandingPage.tsx delete mode 100644 src/client/react/actions/search.js create mode 100644 src/client/react/actions/search.ts delete mode 100644 src/client/react/components/container/Results.js create mode 100644 src/client/react/components/container/Results.tsx delete mode 100644 src/client/react/components/container/Search.js create mode 100644 src/client/react/components/container/Search.tsx delete mode 100644 src/client/react/components/presentational/IconFromUserType.js create mode 100644 src/client/react/components/presentational/IconFromUserType.tsx delete mode 100644 src/client/react/components/presentational/Result.js create mode 100644 src/client/react/components/presentational/Result.tsx delete mode 100644 src/client/react/index.js create mode 100644 src/client/react/index.tsx delete mode 100644 src/client/react/reducers.js create mode 100644 src/client/react/reducers.ts delete mode 100644 src/client/react/reducers/search.js delete mode 100644 src/client/react/reducers/search.test.js create mode 100644 src/client/react/reducers/search.test.ts create mode 100644 src/client/react/reducers/search.ts delete mode 100644 src/client/react/users.js create mode 100644 src/client/react/users.ts (limited to 'src/client/react') diff --git a/src/client/react/LandingPage.js b/src/client/react/LandingPage.js deleted file mode 100644 index d79826e..0000000 --- a/src/client/react/LandingPage.js +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import Search from './components/container/Search'; - -const App = () => ( -
      - -
      -); - -export default App; diff --git a/src/client/react/LandingPage.tsx b/src/client/react/LandingPage.tsx new file mode 100644 index 0000000..f8bb58c --- /dev/null +++ b/src/client/react/LandingPage.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import Search from './components/container/Search'; + +const App = () => ( +
      + +
      +); + +export default App; diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js deleted file mode 100644 index 1b6847d..0000000 --- a/src/client/react/actions/search.js +++ /dev/null @@ -1,14 +0,0 @@ -export const inputChange = typedValue => ({ - type: 'SEARCH/INPUT_CHANGE', - typedValue, -}); - -/** - * 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/search.ts b/src/client/react/actions/search.ts new file mode 100644 index 0000000..45c31fb --- /dev/null +++ b/src/client/react/actions/search.ts @@ -0,0 +1,24 @@ +export interface InputChangeAction { + type: 'SEARCH/INPUT_CHANGE', + typedValue: string, +} + +export const inputChange = (typedValue: string): InputChangeAction => ({ + type: 'SEARCH/INPUT_CHANGE', + typedValue, +}); + +export interface ChangeSelectedResultAction { + type: 'SEARCH/CHANGE_SELECTED_RESULT', + relativeChange: 1 | -1, +} + +/** + * Change the selected result. + * @param {+1/-1} relativeChange usually +1 or -1, the change relative to the + * current result. + */ +export const changeSelectedResult = (relativeChange: 1 | -1): ChangeSelectedResultAction => ({ + type: 'SEARCH/CHANGE_SELECTED_RESULT', + relativeChange, +}); diff --git a/src/client/react/components/container/Results.js b/src/client/react/components/container/Results.js deleted file mode 100644 index 911ea27..0000000 --- a/src/client/react/components/container/Results.js +++ /dev/null @@ -1,35 +0,0 @@ -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 }) => ( -
      0, - })} - > - {!isExactMatch && results.map(userId => ( - - ))} -
      -)); - -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/Results.tsx b/src/client/react/components/container/Results.tsx new file mode 100644 index 0000000..21d3378 --- /dev/null +++ b/src/client/react/components/container/Results.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import * as classnames from 'classnames'; +import Result from '../presentational/Result'; +import { User } from '../../users'; +import { State } from '../../reducers'; + +const Results: React.StatelessComponent<{ results: string[], isExactMatch: boolean, selectedResult: string }> = (props) => ( +
      0, + })} + > + {!props.isExactMatch && props.results.map(userId => ( + + ))} +
      +); + +const mapStateToProps = (state: 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 deleted file mode 100644 index e49e6a7..0000000 --- a/src/client/react/components/container/Search.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; - -import SearchIcon from 'react-icons/lib/md/search'; - -import { 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); - } - - onFocus() { - this.setState({ - hasFocus: true, - }); - } - - onBlur() { - this.setState({ - hasFocus: false, - }); - } - - onKeyDown(event) { - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - event.preventDefault(); - switch (event.key) { - case 'ArrowUp': - this.props.dispatch(changeSelectedResult(-1)); - break; - case 'ArrowDown': - this.props.dispatch(changeSelectedResult(+1)); - break; - default: - throw new Error('This should never happen... pls?'); - } - } - } - - render() { - const { - selectedResult, - isExactMatch, - dispatch, - } = this.props; - - const { - hasFocus, - } = this.state; - - return ( -
      -
      -
      - } - /> -
      - dispatch(inputChange(event.target.value))} - onKeyDown={this.onKeyDown} - placeholder="Zoeken" - onFocus={this.onFocus} - onBlur={this.onBlur} - /> -
      - -
      - ); - } -} - -Search.propTypes = { - selectedResult: PropTypes.string, - isExactMatch: PropTypes.bool.isRequired, - dispatch: PropTypes.func.isRequired, -}; - -Search.defaultProps = { - selectedResult: null, -}; - -const mapStateToProps = state => ({ - results: state.search.results, - selectedResult: state.search.selectedResult, - isExactMatch: state.search.isExactMatch, -}); - -export default connect(mapStateToProps)(Search); diff --git a/src/client/react/components/container/Search.tsx b/src/client/react/components/container/Search.tsx new file mode 100644 index 0000000..fdd6c83 --- /dev/null +++ b/src/client/react/components/container/Search.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import * as classnames from 'classnames'; + +import SearchIcon = require('react-icons/lib/md/search'); + +import { inputChange, changeSelectedResult } from '../../actions/search'; +import { Action } from '../../reducers/search'; +import { State } from '../../reducers'; + +import users from '../../users'; +import Results from './Results'; +import IconFromUserType from '../presentational/IconFromUserType'; + +interface SearchStatehProps { + selectedResult: string, + isExactMatch: boolean, +} + +interface SearchDispatchProps { + changeSelectedResult(relativeChange: 1 | -1): void, + inputChange(typedValue: string): void, +} + +class Search extends React.Component { + constructor(props: SearchStatehProps & SearchDispatchProps) { + super(props); + + this.state = { + hasFocus: false, + }; + + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.changeSelectedResult(-1); + break; + case 'ArrowDown': + this.props.changeSelectedResult(+1); + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + + render() { + const { + selectedResult, + isExactMatch, + inputChange, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( +
      +
      +
      + } + /> +
      + inputChange(event.target.value)} + onKeyDown={this.onKeyDown} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} + /> +
      + +
      + ); + } +} + +// Search.propTypes = { +// selectedResult: PropTypes.string, +// isExactMatch: PropTypes.bool.isRequired, +// dispatch: PropTypes.func.isRequired, +// }; + +// Search.defaultProps = { +// selectedResult: null, +// }; + +const mapStateToProps = (state: State):SearchStatehProps => ({ + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, +}); + +// const mapDispatchToProps = { +// inputChange, +// changeSelectedResult, +// }; + +const mapDispatchToProps = (dispatch: any): SearchDispatchProps => ({ + inputChange(typedValue) { + dispatch(inputChange(typedValue)); + }, + changeSelectedResult(relativeChange) { + dispatch(changeSelectedResult(relativeChange)) + } +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/src/client/react/components/presentational/IconFromUserType.js b/src/client/react/components/presentational/IconFromUserType.js deleted file mode 100644 index ee0e04b..0000000 --- a/src/client/react/components/presentational/IconFromUserType.js +++ /dev/null @@ -1,37 +0,0 @@ -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 ; - case 't': - return ; - case 's': - return ; - case 'r': - return ; - 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/IconFromUserType.tsx b/src/client/react/components/presentational/IconFromUserType.tsx new file mode 100644 index 0000000..d77ea1b --- /dev/null +++ b/src/client/react/components/presentational/IconFromUserType.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import StudentIcon = require('react-icons/lib/md/person'); +import RoomIcon = require('react-icons/lib/md/room'); +import ClassIcon = require('react-icons/lib/md/group'); +import TeacherIcon = require('react-icons/lib/md/account-circle'); + +// interface IconFromUserTypeProps { +// userType: string, +// defaultIcon?: JSX.Element, +// } + +const IconFromUserType: React.StatelessComponent<{ userType: string, defaultIcon?: JSX.Element }> = (props) => { + switch (props.userType) { + case 'c': + return ; + case 't': + return ; + case 's': + return ; + case 'r': + return ; + default: + if (props.defaultIcon) { + return props.defaultIcon; + } + + throw new Error('`userType` was invalid or not given, but `defaultIcon` is not defined.'); + } +}; + +export default IconFromUserType; diff --git a/src/client/react/components/presentational/Result.js b/src/client/react/components/presentational/Result.js deleted file mode 100644 index 0b9e024..0000000 --- a/src/client/react/components/presentational/Result.js +++ /dev/null @@ -1,24 +0,0 @@ -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 }) => ( -
      -
      -
      {users.byId[userId].value}
      -
      -); - -Result.propTypes = { - userId: PropTypes.string.isRequired, - isSelected: PropTypes.bool.isRequired, -}; - -export default Result; diff --git a/src/client/react/components/presentational/Result.tsx b/src/client/react/components/presentational/Result.tsx new file mode 100644 index 0000000..b33a365 --- /dev/null +++ b/src/client/react/components/presentational/Result.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import * as classnames from 'classnames'; +import users from '../../users'; + +import IconFromUserType from './IconFromUserType'; + +const Result: React.StatelessComponent<{ userId: string, isSelected: boolean }> = ({ userId, isSelected }) => ( +
      +
      +
      {users.byId[userId].value}
      +
      +); + +export default Result; diff --git a/src/client/react/index.js b/src/client/react/index.js deleted file mode 100644 index 5279bf4..0000000 --- a/src/client/react/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -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 LandingPage from './LandingPage'; - -// eslint-disable-next-line no-underscore-dangle -const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -const store = createStore( - reducer, - composeEnhancers(applyMiddleware(logger, thunk)), -); - -ReactDOM.render( - - -
      - -
      -
      -
      , - document.getElementById('root'), -); diff --git a/src/client/react/index.tsx b/src/client/react/index.tsx new file mode 100644 index 0000000..f0c3226 --- /dev/null +++ b/src/client/react/index.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +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 LandingPage from './LandingPage'; + +// eslint-disable-next-line no-underscore-dangle +// const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const store = createStore( + reducer, + compose(applyMiddleware(logger, thunk)), +); + +ReactDOM.render( + + +
      + +
      +
      +
      , + document.getElementById('root'), +); diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js deleted file mode 100644 index 9fdf2c4..0000000 --- a/src/client/react/reducers.js +++ /dev/null @@ -1,8 +0,0 @@ -import { combineReducers } from 'redux'; -import search from './reducers/search'; - -const rootReducer = combineReducers({ - search, -}); - -export default rootReducer; diff --git a/src/client/react/reducers.ts b/src/client/react/reducers.ts new file mode 100644 index 0000000..254fe76 --- /dev/null +++ b/src/client/react/reducers.ts @@ -0,0 +1,12 @@ +import { combineReducers } from 'redux'; +import search, { State as SearchState } from './reducers/search'; + +export interface State { + search: SearchState, +} + +const rootReducer = combineReducers({ + search, +}); + +export default rootReducer; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js deleted file mode 100644 index 2a7e7a5..0000000 --- a/src/client/react/reducers/search.js +++ /dev/null @@ -1,81 +0,0 @@ -import fuzzy from 'fuzzy'; -import users from '../users'; - -const DEFAULT_STATE = { - results: [ - 's/18562', - ], - 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/INPUT_CHANGE': { - const results = getSearchResults(users.allUsers, action.typedValue); - let selectedResult = null; - let isExactMatch = false; - - // Is the typed value exactly the same as the first result? Then show the - // appropiate icon instead of the generic search icon. - if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { - [selectedResult] = results; - isExactMatch = true; - } - - return { - ...state, - results, - 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; diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js deleted file mode 100644 index e0ca18e..0000000 --- a/src/client/react/reducers/search.test.js +++ /dev/null @@ -1,193 +0,0 @@ -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 { inputChange, changeSelectedResult } = require('../actions/search'); - -describe('reducers', () => { - describe('search', () => { - describe('SEARCH/INPUT_CHANGE', () => { - it('Returns no results when nothing is typed in', () => { - expect(search(undefined, inputChange(''))).toEqual({ - results: [], - selectedResult: null, - isExactMatch: false, - }); - }); - - it('Returns no results when a space is typed in', () => { - expect(search(undefined, inputChange(' '))).toEqual({ - results: [], - 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', - ], - 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', - ], - selectedResult: 's/18561', - isExactMatch: true, - }); - }); - }); - - describe('SEARCH/CHANGE_SELECTED_RESULT', () => { - it('Does nothing when there are no results', () => { - const prevState = { - results: [], - 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'], - 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'], - 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'], - 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'], - selectedResult: 's/18563', - isExactMatch: false, - }, changeSelectedResult(+1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }); - - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }, changeSelectedResult(+1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18561', - isExactMatch: false, - }); - }); - - it('Properly wraps arround when decrementing', () => { - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18561', - isExactMatch: false, - }, changeSelectedResult(-1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }); - - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }, changeSelectedResult(-1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18563', - isExactMatch: false, - }); - }); - }); - }); -}); diff --git a/src/client/react/reducers/search.test.ts b/src/client/react/reducers/search.test.ts new file mode 100644 index 0000000..5869b81 --- /dev/null +++ b/src/client/react/reducers/search.test.ts @@ -0,0 +1,193 @@ +(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 { inputChange, changeSelectedResult } = require('../actions/search'); + +describe('reducers', () => { + describe('search', () => { + describe('SEARCH/INPUT_CHANGE', () => { + it('Returns no results when nothing is typed in', () => { + expect(search(undefined, inputChange(''))).toEqual({ + results: [], + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Returns no results when a space is typed in', () => { + expect(search(undefined, inputChange(' '))).toEqual({ + results: [], + 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', + ], + 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', + ], + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { + it('Does nothing when there are no results', () => { + const prevState = { + results: [], + 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'], + 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'], + 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'], + 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'], + selectedResult: 's/18563', + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }); + }); + + it('Properly wraps arround when decrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18563', + isExactMatch: false, + }); + }); + }); + }); +}); diff --git a/src/client/react/reducers/search.ts b/src/client/react/reducers/search.ts new file mode 100644 index 0000000..658d3ca --- /dev/null +++ b/src/client/react/reducers/search.ts @@ -0,0 +1,90 @@ +import * as fuzzy from 'fuzzy'; +import users, { User } from '../users'; +import { InputChangeAction, ChangeSelectedResultAction } from '../actions/search'; + +export interface State { + results: string[], + selectedResult: string | null, + isExactMatch: boolean, +}; + +export type Action = InputChangeAction | ChangeSelectedResultAction; + +const DEFAULT_STATE: State = { + results: [ + 's/18562', + ], + selectedResult: null, + isExactMatch: false, +}; + +function getSearchResults(allUsers: User[], query: string) { + if (query.trim() === '') { + return []; + } + + const allResults = fuzzy.filter(query, allUsers, { + extract: (user: User) => user.value, + }); + + const firstResults = allResults.splice(0, 4); + const userIds = firstResults.map((result: { original: User }) => result.original.id); + + return userIds; +} + +const search = (state = DEFAULT_STATE, action: Action): State => { + switch (action.type) { + case 'SEARCH/INPUT_CHANGE': { + const results = getSearchResults(users.allUsers, action.typedValue); + let selectedResult = null; + let isExactMatch = false; + + // Is the typed value exactly the same as the first result? Then show the + // appropiate icon instead of the generic search icon. + if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { + [selectedResult] = results; + isExactMatch = true; + } + + return { + ...state, + results, + 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; diff --git a/src/client/react/users.js b/src/client/react/users.js deleted file mode 100644 index 01ff093..0000000 --- a/src/client/react/users.js +++ /dev/null @@ -1,66 +0,0 @@ -/* 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; diff --git a/src/client/react/users.ts b/src/client/react/users.ts new file mode 100644 index 0000000..a80a1c5 --- /dev/null +++ b/src/client/react/users.ts @@ -0,0 +1,89 @@ +/* global USERS */ + +import { combineReducers, createStore } from 'redux'; + +export interface User { + type: string, + value: string, + id: string, +} + +type Action = { + type: 'USERS/ADD_USER', + user: User, +} + +declare global { + interface Window { + USERS: User[]; + } +} + +const getId = ({ type, value }: User) => `${type}/${value}`; + +const byId = (state = {}, action: Action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return { + ...state, + [action.user.id]: { + ...action.user, + }, + }; + default: + return state; + } +}; + +const allIds = (state : any[] = [], action : Action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + action.user.id, + ]; + default: + return state; + } +}; + +const allUsers = (state : any[] = [], action : Action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + { + ...action.user, + }, + ]; + default: + return state; + } +}; + +interface State { + byId: any, + allIds: string[], + allUsers: User[] +} + +const store = createStore(combineReducers({ + byId, + allIds, + allUsers, +})); + +window.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; -- cgit v1.1 From 4ca30295d7d9f3dd7ba2e105952ff627f6b702a4 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Thu, 21 Dec 2017 13:10:05 +0100 Subject: Add strict typing Except for functions because of https://github.com/reactjs/redux/issues/2709 --- src/client/react/components/container/Search.tsx | 27 +++++----------------- .../components/presentational/IconFromUserType.tsx | 2 +- src/client/react/reducers.ts | 1 + src/client/react/reducers/search.ts | 2 +- src/client/react/users.ts | 23 +++++++++++++----- 5 files changed, 26 insertions(+), 29 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.tsx b/src/client/react/components/container/Search.tsx index fdd6c83..b22c26e 100644 --- a/src/client/react/components/container/Search.tsx +++ b/src/client/react/components/container/Search.tsx @@ -13,8 +13,8 @@ import users from '../../users'; import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; -interface SearchStatehProps { - selectedResult: string, +interface SearchStateProps { + selectedResult: string | null, isExactMatch: boolean, } @@ -23,8 +23,8 @@ interface SearchDispatchProps { inputChange(typedValue: string): void, } -class Search extends React.Component { - constructor(props: SearchStatehProps & SearchDispatchProps) { +class Search extends React.Component { + constructor(props: SearchStateProps & SearchDispatchProps) { super(props); this.state = { @@ -80,7 +80,7 @@ class Search extends React.Component
      } />
      @@ -99,26 +99,11 @@ class Search extends React.Component ({ +const mapStateToProps = (state: State):SearchStateProps => ({ selectedResult: state.search.selectedResult, isExactMatch: state.search.isExactMatch, }); -// const mapDispatchToProps = { -// inputChange, -// changeSelectedResult, -// }; - const mapDispatchToProps = (dispatch: any): SearchDispatchProps => ({ inputChange(typedValue) { dispatch(inputChange(typedValue)); diff --git a/src/client/react/components/presentational/IconFromUserType.tsx b/src/client/react/components/presentational/IconFromUserType.tsx index d77ea1b..83af34c 100644 --- a/src/client/react/components/presentational/IconFromUserType.tsx +++ b/src/client/react/components/presentational/IconFromUserType.tsx @@ -9,7 +9,7 @@ import TeacherIcon = require('react-icons/lib/md/account-circle'); // defaultIcon?: JSX.Element, // } -const IconFromUserType: React.StatelessComponent<{ userType: string, defaultIcon?: JSX.Element }> = (props) => { +const IconFromUserType: React.StatelessComponent<{ userType?: string, defaultIcon?: JSX.Element }> = (props) => { switch (props.userType) { case 'c': return ; diff --git a/src/client/react/reducers.ts b/src/client/react/reducers.ts index 254fe76..6f92e1d 100644 --- a/src/client/react/reducers.ts +++ b/src/client/react/reducers.ts @@ -5,6 +5,7 @@ export interface State { search: SearchState, } +// @ts-ignore const rootReducer = combineReducers({ search, }); diff --git a/src/client/react/reducers/search.ts b/src/client/react/reducers/search.ts index 658d3ca..470e650 100644 --- a/src/client/react/reducers/search.ts +++ b/src/client/react/reducers/search.ts @@ -61,7 +61,7 @@ const search = (state = DEFAULT_STATE, action: Action): State => { if (isExactMatch) return state; const prevSelectedResult = state.selectedResult; - const prevSelectedResultIndex = results.indexOf(prevSelectedResult); + const prevSelectedResultIndex = prevSelectedResult ? results.indexOf(prevSelectedResult) : -1; let nextSelectedResultIndex = prevSelectedResultIndex + action.relativeChange; diff --git a/src/client/react/users.ts b/src/client/react/users.ts index a80a1c5..a16e40f 100644 --- a/src/client/react/users.ts +++ b/src/client/react/users.ts @@ -1,6 +1,9 @@ /* global USERS */ import { combineReducers, createStore } from 'redux'; +import { AnyAction } from 'redux'; +import { Reducer } from 'redux'; +import { ReducersMapObject } from 'redux'; export interface User { type: string, @@ -21,7 +24,11 @@ declare global { const getId = ({ type, value }: User) => `${type}/${value}`; -const byId = (state = {}, action: Action) => { +type ByIdState = { + [userId: string]: User, +} + +const byId = (state: ByIdState = {}, action: Action): ByIdState => { switch (action.type) { case 'USERS/ADD_USER': return { @@ -35,7 +42,9 @@ const byId = (state = {}, action: Action) => { } }; -const allIds = (state : any[] = [], action : Action) => { +type AllIdsState = string[] + +const allIds = (state: AllIdsState = [], action: Action): AllIdsState => { switch (action.type) { case 'USERS/ADD_USER': return [ @@ -47,7 +56,9 @@ const allIds = (state : any[] = [], action : Action) => { } }; -const allUsers = (state : any[] = [], action : Action) => { +type AllUsersState = User[]; + +const allUsers = (state: AllUsersState = [], action: Action): AllUsersState => { switch (action.type) { case 'USERS/ADD_USER': return [ @@ -62,9 +73,9 @@ const allUsers = (state : any[] = [], action : Action) => { }; interface State { - byId: any, - allIds: string[], - allUsers: User[] + byId: ByIdState, + allIds: AllIdsState, + allUsers: AllUsersState, } const store = createStore(combineReducers({ -- cgit v1.1 From 95041dffbd23fe81802efd5fb25cffe492cdb551 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 12:11:05 +0100 Subject: Revert "Add strict typing" This reverts commit 4ca30295d7d9f3dd7ba2e105952ff627f6b702a4. --- src/client/react/components/container/Search.tsx | 27 +++++++++++++++++----- .../components/presentational/IconFromUserType.tsx | 2 +- src/client/react/reducers.ts | 1 - src/client/react/reducers/search.ts | 2 +- src/client/react/users.ts | 23 +++++------------- 5 files changed, 29 insertions(+), 26 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.tsx b/src/client/react/components/container/Search.tsx index b22c26e..fdd6c83 100644 --- a/src/client/react/components/container/Search.tsx +++ b/src/client/react/components/container/Search.tsx @@ -13,8 +13,8 @@ import users from '../../users'; import Results from './Results'; import IconFromUserType from '../presentational/IconFromUserType'; -interface SearchStateProps { - selectedResult: string | null, +interface SearchStatehProps { + selectedResult: string, isExactMatch: boolean, } @@ -23,8 +23,8 @@ interface SearchDispatchProps { inputChange(typedValue: string): void, } -class Search extends React.Component { - constructor(props: SearchStateProps & SearchDispatchProps) { +class Search extends React.Component { + constructor(props: SearchStatehProps & SearchDispatchProps) { super(props); this.state = { @@ -80,7 +80,7 @@ class Search extends React.Component
      } />
      @@ -99,11 +99,26 @@ class Search extends React.Component ({ +// Search.propTypes = { +// selectedResult: PropTypes.string, +// isExactMatch: PropTypes.bool.isRequired, +// dispatch: PropTypes.func.isRequired, +// }; + +// Search.defaultProps = { +// selectedResult: null, +// }; + +const mapStateToProps = (state: State):SearchStatehProps => ({ selectedResult: state.search.selectedResult, isExactMatch: state.search.isExactMatch, }); +// const mapDispatchToProps = { +// inputChange, +// changeSelectedResult, +// }; + const mapDispatchToProps = (dispatch: any): SearchDispatchProps => ({ inputChange(typedValue) { dispatch(inputChange(typedValue)); diff --git a/src/client/react/components/presentational/IconFromUserType.tsx b/src/client/react/components/presentational/IconFromUserType.tsx index 83af34c..d77ea1b 100644 --- a/src/client/react/components/presentational/IconFromUserType.tsx +++ b/src/client/react/components/presentational/IconFromUserType.tsx @@ -9,7 +9,7 @@ import TeacherIcon = require('react-icons/lib/md/account-circle'); // defaultIcon?: JSX.Element, // } -const IconFromUserType: React.StatelessComponent<{ userType?: string, defaultIcon?: JSX.Element }> = (props) => { +const IconFromUserType: React.StatelessComponent<{ userType: string, defaultIcon?: JSX.Element }> = (props) => { switch (props.userType) { case 'c': return ; diff --git a/src/client/react/reducers.ts b/src/client/react/reducers.ts index 6f92e1d..254fe76 100644 --- a/src/client/react/reducers.ts +++ b/src/client/react/reducers.ts @@ -5,7 +5,6 @@ export interface State { search: SearchState, } -// @ts-ignore const rootReducer = combineReducers({ search, }); diff --git a/src/client/react/reducers/search.ts b/src/client/react/reducers/search.ts index 470e650..658d3ca 100644 --- a/src/client/react/reducers/search.ts +++ b/src/client/react/reducers/search.ts @@ -61,7 +61,7 @@ const search = (state = DEFAULT_STATE, action: Action): State => { if (isExactMatch) return state; const prevSelectedResult = state.selectedResult; - const prevSelectedResultIndex = prevSelectedResult ? results.indexOf(prevSelectedResult) : -1; + const prevSelectedResultIndex = results.indexOf(prevSelectedResult); let nextSelectedResultIndex = prevSelectedResultIndex + action.relativeChange; diff --git a/src/client/react/users.ts b/src/client/react/users.ts index a16e40f..a80a1c5 100644 --- a/src/client/react/users.ts +++ b/src/client/react/users.ts @@ -1,9 +1,6 @@ /* global USERS */ import { combineReducers, createStore } from 'redux'; -import { AnyAction } from 'redux'; -import { Reducer } from 'redux'; -import { ReducersMapObject } from 'redux'; export interface User { type: string, @@ -24,11 +21,7 @@ declare global { const getId = ({ type, value }: User) => `${type}/${value}`; -type ByIdState = { - [userId: string]: User, -} - -const byId = (state: ByIdState = {}, action: Action): ByIdState => { +const byId = (state = {}, action: Action) => { switch (action.type) { case 'USERS/ADD_USER': return { @@ -42,9 +35,7 @@ const byId = (state: ByIdState = {}, action: Action): ByIdState => { } }; -type AllIdsState = string[] - -const allIds = (state: AllIdsState = [], action: Action): AllIdsState => { +const allIds = (state : any[] = [], action : Action) => { switch (action.type) { case 'USERS/ADD_USER': return [ @@ -56,9 +47,7 @@ const allIds = (state: AllIdsState = [], action: Action): AllIdsState => { } }; -type AllUsersState = User[]; - -const allUsers = (state: AllUsersState = [], action: Action): AllUsersState => { +const allUsers = (state : any[] = [], action : Action) => { switch (action.type) { case 'USERS/ADD_USER': return [ @@ -73,9 +62,9 @@ const allUsers = (state: AllUsersState = [], action: Action): AllUsersState => { }; interface State { - byId: ByIdState, - allIds: AllIdsState, - allUsers: AllUsersState, + byId: any, + allIds: string[], + allUsers: User[] } const store = createStore(combineReducers({ -- cgit v1.1 From 77dccd31b32ee0a9a53b2186bae231069c5ab152 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 12:11:19 +0100 Subject: Revert "Move to typescript" This reverts commit f0c8cf0e79f003514fd65a70def5820205955a77. --- src/client/react/LandingPage.js | 10 ++ src/client/react/LandingPage.tsx | 10 -- src/client/react/actions/search.js | 14 ++ src/client/react/actions/search.ts | 24 --- src/client/react/components/container/Results.js | 35 ++++ src/client/react/components/container/Results.tsx | 26 --- src/client/react/components/container/Search.js | 106 +++++++++++ src/client/react/components/container/Search.tsx | 131 -------------- .../components/presentational/IconFromUserType.js | 37 ++++ .../components/presentational/IconFromUserType.tsx | 31 ---- .../react/components/presentational/Result.js | 24 +++ .../react/components/presentational/Result.tsx | 18 -- src/client/react/index.js | 27 +++ src/client/react/index.tsx | 27 --- src/client/react/reducers.js | 8 + src/client/react/reducers.ts | 12 -- src/client/react/reducers/search.js | 81 +++++++++ src/client/react/reducers/search.test.js | 193 +++++++++++++++++++++ src/client/react/reducers/search.test.ts | 193 --------------------- src/client/react/reducers/search.ts | 90 ---------- src/client/react/users.js | 66 +++++++ src/client/react/users.ts | 89 ---------- 22 files changed, 601 insertions(+), 651 deletions(-) create mode 100644 src/client/react/LandingPage.js delete mode 100644 src/client/react/LandingPage.tsx create mode 100644 src/client/react/actions/search.js delete mode 100644 src/client/react/actions/search.ts create mode 100644 src/client/react/components/container/Results.js delete mode 100644 src/client/react/components/container/Results.tsx create mode 100644 src/client/react/components/container/Search.js delete mode 100644 src/client/react/components/container/Search.tsx create mode 100644 src/client/react/components/presentational/IconFromUserType.js delete mode 100644 src/client/react/components/presentational/IconFromUserType.tsx create mode 100644 src/client/react/components/presentational/Result.js delete mode 100644 src/client/react/components/presentational/Result.tsx create mode 100644 src/client/react/index.js delete mode 100644 src/client/react/index.tsx create mode 100644 src/client/react/reducers.js delete mode 100644 src/client/react/reducers.ts create mode 100644 src/client/react/reducers/search.js create mode 100644 src/client/react/reducers/search.test.js delete mode 100644 src/client/react/reducers/search.test.ts delete mode 100644 src/client/react/reducers/search.ts create mode 100644 src/client/react/users.js delete mode 100644 src/client/react/users.ts (limited to 'src/client/react') diff --git a/src/client/react/LandingPage.js b/src/client/react/LandingPage.js new file mode 100644 index 0000000..d79826e --- /dev/null +++ b/src/client/react/LandingPage.js @@ -0,0 +1,10 @@ +import React from 'react'; +import Search from './components/container/Search'; + +const App = () => ( +
      + +
      +); + +export default App; diff --git a/src/client/react/LandingPage.tsx b/src/client/react/LandingPage.tsx deleted file mode 100644 index f8bb58c..0000000 --- a/src/client/react/LandingPage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -import Search from './components/container/Search'; - -const App = () => ( -
      - -
      -); - -export default App; diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js new file mode 100644 index 0000000..1b6847d --- /dev/null +++ b/src/client/react/actions/search.js @@ -0,0 +1,14 @@ +export const inputChange = typedValue => ({ + type: 'SEARCH/INPUT_CHANGE', + typedValue, +}); + +/** + * 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/search.ts b/src/client/react/actions/search.ts deleted file mode 100644 index 45c31fb..0000000 --- a/src/client/react/actions/search.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface InputChangeAction { - type: 'SEARCH/INPUT_CHANGE', - typedValue: string, -} - -export const inputChange = (typedValue: string): InputChangeAction => ({ - type: 'SEARCH/INPUT_CHANGE', - typedValue, -}); - -export interface ChangeSelectedResultAction { - type: 'SEARCH/CHANGE_SELECTED_RESULT', - relativeChange: 1 | -1, -} - -/** - * Change the selected result. - * @param {+1/-1} relativeChange usually +1 or -1, the change relative to the - * current result. - */ -export const changeSelectedResult = (relativeChange: 1 | -1): ChangeSelectedResultAction => ({ - type: 'SEARCH/CHANGE_SELECTED_RESULT', - relativeChange, -}); diff --git a/src/client/react/components/container/Results.js b/src/client/react/components/container/Results.js new file mode 100644 index 0000000..911ea27 --- /dev/null +++ b/src/client/react/components/container/Results.js @@ -0,0 +1,35 @@ +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 }) => ( +
      0, + })} + > + {!isExactMatch && results.map(userId => ( + + ))} +
      +)); + +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/Results.tsx b/src/client/react/components/container/Results.tsx deleted file mode 100644 index 21d3378..0000000 --- a/src/client/react/components/container/Results.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react'; -import { connect } from 'react-redux'; -import * as classnames from 'classnames'; -import Result from '../presentational/Result'; -import { User } from '../../users'; -import { State } from '../../reducers'; - -const Results: React.StatelessComponent<{ results: string[], isExactMatch: boolean, selectedResult: string }> = (props) => ( -
      0, - })} - > - {!props.isExactMatch && props.results.map(userId => ( - - ))} -
      -); - -const mapStateToProps = (state: 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..e49e6a7 --- /dev/null +++ b/src/client/react/components/container/Search.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; + +import SearchIcon from 'react-icons/lib/md/search'; + +import { 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); + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + onKeyDown(event) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.dispatch(changeSelectedResult(-1)); + break; + case 'ArrowDown': + this.props.dispatch(changeSelectedResult(+1)); + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + + render() { + const { + selectedResult, + isExactMatch, + dispatch, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( +
      +
      +
      + } + /> +
      + dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} + /> +
      + +
      + ); + } +} + +Search.propTypes = { + selectedResult: PropTypes.string, + isExactMatch: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, +}; + +Search.defaultProps = { + selectedResult: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, +}); + +export default connect(mapStateToProps)(Search); diff --git a/src/client/react/components/container/Search.tsx b/src/client/react/components/container/Search.tsx deleted file mode 100644 index fdd6c83..0000000 --- a/src/client/react/components/container/Search.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import * as React from 'react'; -import { Dispatch } from 'redux'; -import { connect } from 'react-redux'; -import * as classnames from 'classnames'; - -import SearchIcon = require('react-icons/lib/md/search'); - -import { inputChange, changeSelectedResult } from '../../actions/search'; -import { Action } from '../../reducers/search'; -import { State } from '../../reducers'; - -import users from '../../users'; -import Results from './Results'; -import IconFromUserType from '../presentational/IconFromUserType'; - -interface SearchStatehProps { - selectedResult: string, - isExactMatch: boolean, -} - -interface SearchDispatchProps { - changeSelectedResult(relativeChange: 1 | -1): void, - inputChange(typedValue: string): void, -} - -class Search extends React.Component { - constructor(props: SearchStatehProps & SearchDispatchProps) { - super(props); - - this.state = { - hasFocus: false, - }; - - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - } - - onFocus() { - this.setState({ - hasFocus: true, - }); - } - - onBlur() { - this.setState({ - hasFocus: false, - }); - } - - onKeyDown(event: React.KeyboardEvent) { - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - event.preventDefault(); - switch (event.key) { - case 'ArrowUp': - this.props.changeSelectedResult(-1); - break; - case 'ArrowDown': - this.props.changeSelectedResult(+1); - break; - default: - throw new Error('This should never happen... pls?'); - } - } - } - - render() { - const { - selectedResult, - isExactMatch, - inputChange, - } = this.props; - - const { - hasFocus, - } = this.state; - - return ( -
      -
      -
      - } - /> -
      - inputChange(event.target.value)} - onKeyDown={this.onKeyDown} - placeholder="Zoeken" - onFocus={this.onFocus} - onBlur={this.onBlur} - /> -
      - -
      - ); - } -} - -// Search.propTypes = { -// selectedResult: PropTypes.string, -// isExactMatch: PropTypes.bool.isRequired, -// dispatch: PropTypes.func.isRequired, -// }; - -// Search.defaultProps = { -// selectedResult: null, -// }; - -const mapStateToProps = (state: State):SearchStatehProps => ({ - selectedResult: state.search.selectedResult, - isExactMatch: state.search.isExactMatch, -}); - -// const mapDispatchToProps = { -// inputChange, -// changeSelectedResult, -// }; - -const mapDispatchToProps = (dispatch: any): SearchDispatchProps => ({ - inputChange(typedValue) { - dispatch(inputChange(typedValue)); - }, - changeSelectedResult(relativeChange) { - dispatch(changeSelectedResult(relativeChange)) - } -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Search); 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 ; + case 't': + return ; + case 's': + return ; + case 'r': + return ; + 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/IconFromUserType.tsx b/src/client/react/components/presentational/IconFromUserType.tsx deleted file mode 100644 index d77ea1b..0000000 --- a/src/client/react/components/presentational/IconFromUserType.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import StudentIcon = require('react-icons/lib/md/person'); -import RoomIcon = require('react-icons/lib/md/room'); -import ClassIcon = require('react-icons/lib/md/group'); -import TeacherIcon = require('react-icons/lib/md/account-circle'); - -// interface IconFromUserTypeProps { -// userType: string, -// defaultIcon?: JSX.Element, -// } - -const IconFromUserType: React.StatelessComponent<{ userType: string, defaultIcon?: JSX.Element }> = (props) => { - switch (props.userType) { - case 'c': - return ; - case 't': - return ; - case 's': - return ; - case 'r': - return ; - default: - if (props.defaultIcon) { - return props.defaultIcon; - } - - throw new Error('`userType` was invalid or not given, but `defaultIcon` is not defined.'); - } -}; - -export default IconFromUserType; 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 }) => ( +
      +
      +
      {users.byId[userId].value}
      +
      +); + +Result.propTypes = { + userId: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, +}; + +export default Result; diff --git a/src/client/react/components/presentational/Result.tsx b/src/client/react/components/presentational/Result.tsx deleted file mode 100644 index b33a365..0000000 --- a/src/client/react/components/presentational/Result.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import * as classnames from 'classnames'; -import users from '../../users'; - -import IconFromUserType from './IconFromUserType'; - -const Result: React.StatelessComponent<{ userId: string, isSelected: boolean }> = ({ userId, isSelected }) => ( -
      -
      -
      {users.byId[userId].value}
      -
      -); - -export default Result; diff --git a/src/client/react/index.js b/src/client/react/index.js new file mode 100644 index 0000000..5279bf4 --- /dev/null +++ b/src/client/react/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +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 LandingPage from './LandingPage'; + +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const store = createStore( + reducer, + composeEnhancers(applyMiddleware(logger, thunk)), +); + +ReactDOM.render( + + +
      + +
      +
      +
      , + document.getElementById('root'), +); diff --git a/src/client/react/index.tsx b/src/client/react/index.tsx deleted file mode 100644 index f0c3226..0000000 --- a/src/client/react/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -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 LandingPage from './LandingPage'; - -// eslint-disable-next-line no-underscore-dangle -// const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -const store = createStore( - reducer, - compose(applyMiddleware(logger, thunk)), -); - -ReactDOM.render( - - -
      - -
      -
      -
      , - document.getElementById('root'), -); diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js new file mode 100644 index 0000000..9fdf2c4 --- /dev/null +++ b/src/client/react/reducers.js @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import search from './reducers/search'; + +const rootReducer = combineReducers({ + search, +}); + +export default rootReducer; diff --git a/src/client/react/reducers.ts b/src/client/react/reducers.ts deleted file mode 100644 index 254fe76..0000000 --- a/src/client/react/reducers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { combineReducers } from 'redux'; -import search, { State as SearchState } from './reducers/search'; - -export interface State { - search: SearchState, -} - -const rootReducer = combineReducers({ - search, -}); - -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..2a7e7a5 --- /dev/null +++ b/src/client/react/reducers/search.js @@ -0,0 +1,81 @@ +import fuzzy from 'fuzzy'; +import users from '../users'; + +const DEFAULT_STATE = { + results: [ + 's/18562', + ], + 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/INPUT_CHANGE': { + const results = getSearchResults(users.allUsers, action.typedValue); + let selectedResult = null; + let isExactMatch = false; + + // Is the typed value exactly the same as the first result? Then show the + // appropiate icon instead of the generic search icon. + if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { + [selectedResult] = results; + isExactMatch = true; + } + + return { + ...state, + results, + 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; diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js new file mode 100644 index 0000000..e0ca18e --- /dev/null +++ b/src/client/react/reducers/search.test.js @@ -0,0 +1,193 @@ +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 { inputChange, changeSelectedResult } = require('../actions/search'); + +describe('reducers', () => { + describe('search', () => { + describe('SEARCH/INPUT_CHANGE', () => { + it('Returns no results when nothing is typed in', () => { + expect(search(undefined, inputChange(''))).toEqual({ + results: [], + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Returns no results when a space is typed in', () => { + expect(search(undefined, inputChange(' '))).toEqual({ + results: [], + 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', + ], + 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', + ], + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { + it('Does nothing when there are no results', () => { + const prevState = { + results: [], + 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'], + 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'], + 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'], + 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'], + selectedResult: 's/18563', + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }); + }); + + it('Properly wraps arround when decrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18561', + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + selectedResult: 's/18563', + isExactMatch: false, + }); + }); + }); + }); +}); diff --git a/src/client/react/reducers/search.test.ts b/src/client/react/reducers/search.test.ts deleted file mode 100644 index 5869b81..0000000 --- a/src/client/react/reducers/search.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -(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 { inputChange, changeSelectedResult } = require('../actions/search'); - -describe('reducers', () => { - describe('search', () => { - describe('SEARCH/INPUT_CHANGE', () => { - it('Returns no results when nothing is typed in', () => { - expect(search(undefined, inputChange(''))).toEqual({ - results: [], - selectedResult: null, - isExactMatch: false, - }); - }); - - it('Returns no results when a space is typed in', () => { - expect(search(undefined, inputChange(' '))).toEqual({ - results: [], - 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', - ], - 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', - ], - selectedResult: 's/18561', - isExactMatch: true, - }); - }); - }); - - describe('SEARCH/CHANGE_SELECTED_RESULT', () => { - it('Does nothing when there are no results', () => { - const prevState = { - results: [], - 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'], - 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'], - 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'], - 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'], - selectedResult: 's/18563', - isExactMatch: false, - }, changeSelectedResult(+1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }); - - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }, changeSelectedResult(+1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18561', - isExactMatch: false, - }); - }); - - it('Properly wraps arround when decrementing', () => { - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18561', - isExactMatch: false, - }, changeSelectedResult(-1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }); - - expect(search({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: null, - isExactMatch: false, - }, changeSelectedResult(-1))).toEqual({ - results: ['s/18561', 's/18562', 's/18563'], - selectedResult: 's/18563', - isExactMatch: false, - }); - }); - }); - }); -}); diff --git a/src/client/react/reducers/search.ts b/src/client/react/reducers/search.ts deleted file mode 100644 index 658d3ca..0000000 --- a/src/client/react/reducers/search.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as fuzzy from 'fuzzy'; -import users, { User } from '../users'; -import { InputChangeAction, ChangeSelectedResultAction } from '../actions/search'; - -export interface State { - results: string[], - selectedResult: string | null, - isExactMatch: boolean, -}; - -export type Action = InputChangeAction | ChangeSelectedResultAction; - -const DEFAULT_STATE: State = { - results: [ - 's/18562', - ], - selectedResult: null, - isExactMatch: false, -}; - -function getSearchResults(allUsers: User[], query: string) { - if (query.trim() === '') { - return []; - } - - const allResults = fuzzy.filter(query, allUsers, { - extract: (user: User) => user.value, - }); - - const firstResults = allResults.splice(0, 4); - const userIds = firstResults.map((result: { original: User }) => result.original.id); - - return userIds; -} - -const search = (state = DEFAULT_STATE, action: Action): State => { - switch (action.type) { - case 'SEARCH/INPUT_CHANGE': { - const results = getSearchResults(users.allUsers, action.typedValue); - let selectedResult = null; - let isExactMatch = false; - - // Is the typed value exactly the same as the first result? Then show the - // appropiate icon instead of the generic search icon. - if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { - [selectedResult] = results; - isExactMatch = true; - } - - return { - ...state, - results, - 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; 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; diff --git a/src/client/react/users.ts b/src/client/react/users.ts deleted file mode 100644 index a80a1c5..0000000 --- a/src/client/react/users.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* global USERS */ - -import { combineReducers, createStore } from 'redux'; - -export interface User { - type: string, - value: string, - id: string, -} - -type Action = { - type: 'USERS/ADD_USER', - user: User, -} - -declare global { - interface Window { - USERS: User[]; - } -} - -const getId = ({ type, value }: User) => `${type}/${value}`; - -const byId = (state = {}, action: Action) => { - switch (action.type) { - case 'USERS/ADD_USER': - return { - ...state, - [action.user.id]: { - ...action.user, - }, - }; - default: - return state; - } -}; - -const allIds = (state : any[] = [], action : Action) => { - switch (action.type) { - case 'USERS/ADD_USER': - return [ - ...state, - action.user.id, - ]; - default: - return state; - } -}; - -const allUsers = (state : any[] = [], action : Action) => { - switch (action.type) { - case 'USERS/ADD_USER': - return [ - ...state, - { - ...action.user, - }, - ]; - default: - return state; - } -}; - -interface State { - byId: any, - allIds: string[], - allUsers: User[] -} - -const store = createStore(combineReducers({ - byId, - allIds, - allUsers, -})); - -window.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; -- cgit v1.1 From 13cac5dcd54bf87767396763226fd33719964d22 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 13:00:08 +0100 Subject: Go to user url on enter --- src/client/react/LandingPage.js | 9 +++++++-- src/client/react/components/container/Search.js | 13 +++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/LandingPage.js b/src/client/react/LandingPage.js index d79826e..24e0078 100644 --- a/src/client/react/LandingPage.js +++ b/src/client/react/LandingPage.js @@ -1,10 +1,15 @@ import React from 'react'; +import PropTypes from 'prop-types'; import Search from './components/container/Search'; -const App = () => ( +const App = ({ location }) => (
      - +
      ); +App.propTypes = { + location: PropTypes.object.isRequired, +}; + export default App; diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index e49e6a7..957f76e 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -2,6 +2,7 @@ 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'; @@ -37,7 +38,7 @@ class Search extends React.Component { } onKeyDown(event) { - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Enter') { event.preventDefault(); switch (event.key) { case 'ArrowUp': @@ -46,6 +47,11 @@ class Search extends React.Component { case 'ArrowDown': this.props.dispatch(changeSelectedResult(+1)); break; + case 'Enter': + if (this.props.selectedResult) { + this.props.history.push(`/${this.props.selectedResult}`); + } + break; default: throw new Error('This should never happen... pls?'); } @@ -91,6 +97,9 @@ Search.propTypes = { selectedResult: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, }; Search.defaultProps = { @@ -103,4 +112,4 @@ const mapStateToProps = state => ({ isExactMatch: state.search.isExactMatch, }); -export default connect(mapStateToProps)(Search); +export default connect(mapStateToProps)(withRouter(Search)); -- cgit v1.1 From 70a9b0be3782122750388c24eb98b0d45e6fc6d1 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 13:12:11 +0100 Subject: Save searchText in redux store --- src/client/react/actions/search.js | 4 ++-- src/client/react/components/container/Search.js | 4 ++++ src/client/react/reducers/search.js | 7 +++++-- src/client/react/reducers/search.test.js | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js index 1b6847d..d903e64 100644 --- a/src/client/react/actions/search.js +++ b/src/client/react/actions/search.js @@ -1,6 +1,6 @@ -export const inputChange = typedValue => ({ +export const inputChange = searchText => ({ type: 'SEARCH/INPUT_CHANGE', - typedValue, + searchText, }); /** diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 957f76e..06523be 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -62,6 +62,7 @@ class Search extends React.Component { const { selectedResult, isExactMatch, + searchText, dispatch, } = this.props; @@ -82,6 +83,7 @@ class Search extends React.Component { id="search__input" onChange={event => dispatch(inputChange(event.target.value))} onKeyDown={this.onKeyDown} + value={searchText} placeholder="Zoeken" onFocus={this.onFocus} onBlur={this.onBlur} @@ -96,6 +98,7 @@ class Search extends React.Component { Search.propTypes = { selectedResult: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, + searchText: PropTypes.string.isRequired, dispatch: PropTypes.func.isRequired, history: PropTypes.shape({ push: PropTypes.func.isRequired, @@ -108,6 +111,7 @@ Search.defaultProps = { const mapStateToProps = state => ({ results: state.search.results, + searchText: state.search.searchText, selectedResult: state.search.selectedResult, isExactMatch: state.search.isExactMatch, }); diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 2a7e7a5..6027ed7 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -5,6 +5,7 @@ const DEFAULT_STATE = { results: [ 's/18562', ], + searchText: '', selectedResult: null, isExactMatch: false, }; @@ -27,13 +28,14 @@ function getSearchResults(allUsers, query) { const search = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'SEARCH/INPUT_CHANGE': { - const results = getSearchResults(users.allUsers, action.typedValue); + 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 // appropiate icon instead of the generic search icon. - if ((results.length === 1) && (action.typedValue === users.byId[results[0]].value)) { + if ((results.length === 1) && (action.searchText === users.byId[results[0]].value)) { [selectedResult] = results; isExactMatch = true; } @@ -41,6 +43,7 @@ const search = (state = DEFAULT_STATE, action) => { return { ...state, results, + searchText, selectedResult, isExactMatch, }; diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js index e0ca18e..ddd7f9b 100644 --- a/src/client/react/reducers/search.test.js +++ b/src/client/react/reducers/search.test.js @@ -20,6 +20,7 @@ describe('reducers', () => { it('Returns no results when nothing is typed in', () => { expect(search(undefined, inputChange(''))).toEqual({ results: [], + searchText: '', selectedResult: null, isExactMatch: false, }); @@ -28,6 +29,7 @@ describe('reducers', () => { it('Returns no results when a space is typed in', () => { expect(search(undefined, inputChange(' '))).toEqual({ results: [], + searchText: ' ', selectedResult: null, isExactMatch: false, }); @@ -41,6 +43,7 @@ describe('reducers', () => { 's/18563', 's/18564', ], + searchText: '18', selectedResult: null, isExactMatch: false, }); @@ -51,6 +54,7 @@ describe('reducers', () => { results: [ 's/18561', ], + searchText: '18561', selectedResult: 's/18561', isExactMatch: true, }); @@ -61,6 +65,7 @@ describe('reducers', () => { it('Does nothing when there are no results', () => { const prevState = { results: [], + searchText: '', selectedResult: null, isExactMatch: false, }; @@ -79,6 +84,7 @@ describe('reducers', () => { it('Does nothing when there is an exact match', () => { const prevState = { results: ['s/18561'], + searchText: '18561', selectedResult: 's/18561', isExactMatch: true, }; @@ -98,6 +104,7 @@ describe('reducers', () => { 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, }; @@ -123,6 +130,7 @@ describe('reducers', () => { 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, }; @@ -148,20 +156,24 @@ describe('reducers', () => { 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, }); @@ -170,20 +182,24 @@ describe('reducers', () => { 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, }); -- cgit v1.1 From 928edee90f4a35eea20d581e093b002be04e9b47 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 13:16:31 +0100 Subject: Move LandingPage.js to component/page/Index.js --- src/client/react/LandingPage.js | 15 --------------- src/client/react/components/page/Index.js | 16 ++++++++++++++++ src/client/react/index.js | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) delete mode 100644 src/client/react/LandingPage.js create mode 100644 src/client/react/components/page/Index.js (limited to 'src/client/react') diff --git a/src/client/react/LandingPage.js b/src/client/react/LandingPage.js deleted file mode 100644 index 24e0078..0000000 --- a/src/client/react/LandingPage.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Search from './components/container/Search'; - -const App = ({ location }) => ( -
      - -
      -); - -App.propTypes = { - location: PropTypes.object.isRequired, -}; - -export default App; diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js new file mode 100644 index 0000000..dcc521d --- /dev/null +++ b/src/client/react/components/page/Index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Search from '../container/Search'; + +const App = ({ location }) => ( +
      + +
      +); + +App.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + location: PropTypes.object.isRequired, +}; + +export default App; diff --git a/src/client/react/index.js b/src/client/react/index.js index 5279bf4..18dacf5 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -6,7 +6,7 @@ import { createStore, applyMiddleware, compose } from 'redux'; import logger from 'redux-logger'; import thunk from 'redux-thunk'; import reducer from './reducers'; -import LandingPage from './LandingPage'; +import Index from './components/page/Index'; // eslint-disable-next-line no-underscore-dangle const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -19,7 +19,7 @@ ReactDOM.render(
      - +
      , -- cgit v1.1 From c0aa588bc8f85b13b5a55ccd6cdf11bf99048a1c Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sat, 6 Jan 2018 15:42:04 +0100 Subject: Add user page --- src/client/react/actions/search.js | 5 ++++ src/client/react/components/container/Search.js | 10 ++++++-- src/client/react/components/page/Index.js | 10 ++------ src/client/react/components/page/User.js | 31 +++++++++++++++++++++++++ src/client/react/index.js | 2 ++ src/client/react/reducers/search.js | 20 ++++++++++++++++ src/client/react/reducers/search.test.js | 22 +++++++++++++++++- 7 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 src/client/react/components/page/User.js (limited to 'src/client/react') diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js index d903e64..22daeca 100644 --- a/src/client/react/actions/search.js +++ b/src/client/react/actions/search.js @@ -1,3 +1,8 @@ +export const setUser = user => ({ + type: 'SEARCH/SET_USER', + user, +}); + export const inputChange = searchText => ({ type: 'SEARCH/INPUT_CHANGE', searchText, diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 06523be..27b0563 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -6,7 +6,7 @@ import { withRouter } from 'react-router-dom'; import SearchIcon from 'react-icons/lib/md/search'; -import { inputChange, changeSelectedResult } from '../../actions/search'; +import { setUser, inputChange, changeSelectedResult } from '../../actions/search'; import users from '../../users'; import Results from './Results'; @@ -25,6 +25,10 @@ class Search extends React.Component { this.onKeyDown = this.onKeyDown.bind(this); } + componentDidMount() { + this.props.dispatch(setUser(this.props.urlUser)); + } + onFocus() { this.setState({ hasFocus: true, @@ -97,6 +101,7 @@ class Search extends React.Component { Search.propTypes = { selectedResult: PropTypes.string, + urlUser: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, searchText: PropTypes.string.isRequired, dispatch: PropTypes.func.isRequired, @@ -107,6 +112,7 @@ Search.propTypes = { Search.defaultProps = { selectedResult: null, + urlUser: null, }; const mapStateToProps = state => ({ @@ -116,4 +122,4 @@ const mapStateToProps = state => ({ isExactMatch: state.search.isExactMatch, }); -export default connect(mapStateToProps)(withRouter(Search)); +export default withRouter(connect(mapStateToProps)(Search)); diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js index dcc521d..a91d7a9 100644 --- a/src/client/react/components/page/Index.js +++ b/src/client/react/components/page/Index.js @@ -1,16 +1,10 @@ import React from 'react'; -import PropTypes from 'prop-types'; import Search from '../container/Search'; -const App = ({ location }) => ( +const App = () => (
      - +
      ); -App.propTypes = { - // eslint-disable-next-line react/forbid-prop-types - location: PropTypes.object.isRequired, -}; - export default App; diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js new file mode 100644 index 0000000..2ad65a6 --- /dev/null +++ b/src/client/react/components/page/User.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Redirect } from 'react-router-dom'; +import Search from '../container/Search'; +import users from '../../users'; + +const App = ({ match }) => { + const user = `${match.params.type}/${match.params.value}`; + + if (!users.allIds.includes(user)) { + // Invalid user, redirect to index. + return ; + } + + return ( +
      + +
      + ); +}; + +App.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + type: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default App; diff --git a/src/client/react/index.js b/src/client/react/index.js index 18dacf5..ffa5403 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -7,6 +7,7 @@ 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'; // eslint-disable-next-line no-underscore-dangle const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -20,6 +21,7 @@ ReactDOM.render(
      +
      , diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 6027ed7..7c7e917 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -27,6 +27,22 @@ function getSearchResults(allUsers, query) { 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); @@ -82,3 +98,7 @@ const search = (state = DEFAULT_STATE, action) => { }; 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 index ddd7f9b..22d32e2 100644 --- a/src/client/react/reducers/search.test.js +++ b/src/client/react/reducers/search.test.js @@ -12,10 +12,30 @@ window.USERS = [ const deepFreeze = require('deep-freeze'); const search = require('./search').default; -const { inputChange, changeSelectedResult } = require('../actions/search'); +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({ -- cgit v1.1 From 1b3f4ea79f947558573fbce5a2e2d0c2c5dd6a8d Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 17 Jan 2018 16:26:04 +0100 Subject: Add view code --- src/client/react/actions/view.js | 28 ++++++++++ src/client/react/components/container/Search.js | 21 ++++++-- src/client/react/components/container/View.js | 70 +++++++++++++++++++++++++ src/client/react/components/page/User.js | 2 + src/client/react/index.js | 2 +- src/client/react/reducers.js | 2 + src/client/react/reducers/view.js | 38 ++++++++++++++ 7 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/client/react/actions/view.js create mode 100644 src/client/react/components/container/View.js create mode 100644 src/client/react/reducers/view.js (limited to 'src/client/react') diff --git a/src/client/react/actions/view.js b/src/client/react/actions/view.js new file mode 100644 index 0000000..f9f0be2 --- /dev/null +++ b/src/client/react/actions/view.js @@ -0,0 +1,28 @@ +// eslint-disable-next-line import/prefer-default-export +export const fetchSchedule = user => (dispatch) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_REQUEST', + user, + }); + + fetch(`/get/${user}`).then( + // success + (r) => { + r.text().then((htmlStr) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_SUCCESS', + user, + htmlStr, + }); + }); + }, + + // error + () => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_FAILURE', + user, + }); + }, + ); +}; diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 27b0563..9a99833 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -29,6 +29,12 @@ class Search extends React.Component { 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, @@ -51,10 +57,18 @@ class Search extends React.Component { case 'ArrowDown': this.props.dispatch(changeSelectedResult(+1)); break; - case 'Enter': - if (this.props.selectedResult) { - this.props.history.push(`/${this.props.selectedResult}`); + 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?'); @@ -100,6 +114,7 @@ class Search extends React.Component { } Search.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, selectedResult: PropTypes.string, urlUser: PropTypes.string, isExactMatch: PropTypes.bool.isRequired, diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js new file mode 100644 index 0000000..9bac66f --- /dev/null +++ b/src/client/react/components/container/View.js @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createDOMPurify from 'dompurify'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; + +import { fetchSchedule } from '../../actions/view'; + +function cleanMeetingpointHTML(htmlStr) { + const DOMPurify = createDOMPurify(window); + + return DOMPurify.sanitize(htmlStr, { + ADD_ATTR: ['rules'], + }); +} + +class View extends React.Component { + componentDidMount() { + if (!this.loadingFinished(this.props.user)) { + this.props.dispatch(fetchSchedule(this.props.user)); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.user !== this.props.user && !this.loadingFinished(nextProps.user)) { + this.props.dispatch(fetchSchedule(nextProps.user)); + } + } + + loadingFinished(user) { + return this.props.schedules.hasOwnProperty(user) && + this.props.schedules[user].state === 'finished'; + } + + render() { + if (!this.loadingFinished(this.props.user)) { + return ( +
      + Loading... +
      + ); + } + + const cleanHTML = cleanMeetingpointHTML(this.props.schedules[this.props.user].htmlStr); + + return ( + // eslint-disable-next-line react/no-danger +
      + ); + } +} + +View.propTypes = { + user: PropTypes.string, + dispatch: PropTypes.func.isRequired, + schedules: PropTypes.objectOf(PropTypes.shape({ + state: PropTypes.string.isRequired, + htmlStr: PropTypes.string, + })).isRequired, +}; + +View.defaultProps = { + user: null, +}; + +const mapStateToProps = state => ({ + schedules: state.view.schedules, +}); + +export default withRouter(connect(mapStateToProps)(View)); diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js index 2ad65a6..ea8cd10 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Redirect } from 'react-router-dom'; import Search from '../container/Search'; +import View from '../container/View'; import users from '../../users'; const App = ({ match }) => { @@ -15,6 +16,7 @@ const App = ({ match }) => { return (
      +
      ); }; diff --git a/src/client/react/index.js b/src/client/react/index.js index ffa5403..a7006d4 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -13,7 +13,7 @@ import User from './components/page/User'; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store = createStore( reducer, - composeEnhancers(applyMiddleware(logger, thunk)), + composeEnhancers(applyMiddleware(thunk, logger)), ); ReactDOM.render( diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js index 9fdf2c4..fb97228 100644 --- a/src/client/react/reducers.js +++ b/src/client/react/reducers.js @@ -1,8 +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/view.js b/src/client/react/reducers/view.js new file mode 100644 index 0000000..276d8ae --- /dev/null +++ b/src/client/react/reducers/view.js @@ -0,0 +1,38 @@ +const DEFAULT_STATE = { + schedules: {}, +}; + +const view = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'VIEW/FETCH_SCHEDULE_REQUEST': + return { + ...state, + schedules: { + ...state.schedules, + [action.user]: { + state: 'fetching', + }, + }, + }; + case 'VIEW/FETCH_SCHEDULE_SUCCESS': + return { + ...state, + schedules: { + ...state.schedules, + [action.user]: { + ...state.schedules[action.user], + state: 'finished', + htmlStr: action.htmlStr, + }, + }, + }; + default: + return state; + } +}; + +export default view; + +export const _test = { + DEFAULT_STATE, +}; -- cgit v1.1 From 2232877ed5b3c0b60789940d2a367726ee8919c5 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 17 Jan 2018 16:45:17 +0100 Subject: Add some basic styling --- src/client/react/components/page/Index.js | 3 ++- src/client/react/index.js | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'src/client/react') diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js index a91d7a9..9b8dbd7 100644 --- a/src/client/react/components/page/Index.js +++ b/src/client/react/components/page/Index.js @@ -2,7 +2,8 @@ import React from 'react'; import Search from '../container/Search'; const App = () => ( -
      +
      + Metis
      ); diff --git a/src/client/react/index.js b/src/client/react/index.js index a7006d4..e70b442 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -27,3 +27,7 @@ ReactDOM.render( , 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(); -- cgit v1.1 From 71c95bb19be0446bab80abffd9d4fc563374def3 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 17 Jan 2018 16:47:23 +0100 Subject: Fix typo --- src/client/react/reducers/search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/client/react') diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 7c7e917..2274a33 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -50,7 +50,7 @@ const search = (state = DEFAULT_STATE, action) => { let isExactMatch = false; // Is the typed value exactly the same as the first result? Then show the - // appropiate icon instead of the generic search icon. + // appropriate icon instead of the generic search icon. if ((results.length === 1) && (action.searchText === users.byId[results[0]].value)) { [selectedResult] = results; isExactMatch = true; -- cgit v1.1 From 3f228d0d84868e4a02aa7a62b09dde8092f2cc4c Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Fri, 19 Jan 2018 14:58:49 +0100 Subject: Add helpbox --- src/client/react/components/container/HelpBox.js | 25 ++++++++++++++++++++++++ src/client/react/components/page/Index.js | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/client/react/components/container/HelpBox.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/HelpBox.js b/src/client/react/components/container/HelpBox.js new file mode 100644 index 0000000..b4556a5 --- /dev/null +++ b/src/client/react/components/container/HelpBox.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +const HelpBox = ({ results }) => { + if (results.length > 0) { + return
      ; + } + + return ( +
      + Voer hier een docentafkorting, klas, leerlingnummer of lokaalnummer in. +
      + ); +}; + +HelpBox.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +const mapStateToProps = state => ({ + results: state.search.results, +}); + +export default connect(mapStateToProps)(HelpBox); diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js index 9b8dbd7..c2994c9 100644 --- a/src/client/react/components/page/Index.js +++ b/src/client/react/components/page/Index.js @@ -1,10 +1,14 @@ import React from 'react'; import Search from '../container/Search'; +import HelpBox from '../container/HelpBox'; const App = () => (
      - Metis - +
      + Metis + + +
      ); -- cgit v1.1 From 0bddf7661d7ece709a18f2d167b928749638f318 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 24 Jan 2018 15:44:31 +0100 Subject: Add animation to results --- src/client/react/components/container/HelpBox.js | 11 ++++++++--- src/client/react/components/container/Results.js | 3 +++ src/client/react/reducers/search.js | 7 ++++--- 3 files changed, 15 insertions(+), 6 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/HelpBox.js b/src/client/react/components/container/HelpBox.js index b4556a5..a74b43c 100644 --- a/src/client/react/components/container/HelpBox.js +++ b/src/client/react/components/container/HelpBox.js @@ -2,24 +2,29 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -const HelpBox = ({ results }) => { - if (results.length > 0) { +const HelpBox = ({ results, searchText }) => { + if (results.length > 0 || searchText !== '') { return
      ; } return (
      - Voer hier een docentafkorting, klas, leerlingnummer of lokaalnummer in. +
      +
      + Voer hier een docentafkorting, klas, leerlingnummer of lokaalnummer in. +
      ); }; 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 index 911ea27..1fb5f44 100644 --- a/src/client/react/components/container/Results.js +++ b/src/client/react/components/container/Results.js @@ -9,6 +9,9 @@ const Results = (({ results, isExactMatch, selectedResult }) => ( className={classnames('search__results', { 'search__results--has-results': !isExactMatch && results.length > 0, })} + style={{ + minHeight: isExactMatch ? 0 : results.length * 54, + }} > {!isExactMatch && results.map(userId => ( diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js index 2274a33..770cdcb 100644 --- a/src/client/react/reducers/search.js +++ b/src/client/react/reducers/search.js @@ -2,9 +2,10 @@ import fuzzy from 'fuzzy'; import users from '../users'; const DEFAULT_STATE = { - results: [ - 's/18562', - ], + // results: [ + // 's/18562', + // ], + results: [], searchText: '', selectedResult: null, isExactMatch: false, -- cgit v1.1 From 16723546de81e29fa8a31acc4070df5acb182b24 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Wed, 24 Jan 2018 16:08:49 +0100 Subject: Add basic styling to user page --- src/client/react/components/container/Search.js | 34 +++++++++++++------------ src/client/react/components/page/User.js | 8 ++++-- 2 files changed, 24 insertions(+), 18 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js index 9a99833..8acbe99 100644 --- a/src/client/react/components/container/Search.js +++ b/src/client/react/components/container/Search.js @@ -89,25 +89,27 @@ class Search extends React.Component { } = this.state; return ( -
      -
      -
      - } +
      +
      +
      +
      + } + /> +
      + dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} + value={searchText} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} />
      - dispatch(inputChange(event.target.value))} - onKeyDown={this.onKeyDown} - value={searchText} - placeholder="Zoeken" - onFocus={this.onFocus} - onBlur={this.onBlur} - /> +
      -
      ); } diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js index ea8cd10..ff304c0 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -14,8 +14,12 @@ const App = ({ match }) => { } return ( -
      - +
      +
      +
      + +
      +
      ); -- cgit v1.1 From 19534b4770b4f4097b02f5fa021a24822b12d907 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Fri, 26 Jan 2018 20:30:34 +0100 Subject: Add week selector --- .../react/components/container/WeekSelector.js | 34 ++++++++++++++++++++++ src/client/react/components/page/User.js | 11 ++++++- src/client/react/index.js | 3 ++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/client/react/components/container/WeekSelector.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/WeekSelector.js b/src/client/react/components/container/WeekSelector.js new file mode 100644 index 0000000..9f308f0 --- /dev/null +++ b/src/client/react/components/container/WeekSelector.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import momentPropTypes from 'react-moment-proptypes'; +import queryString from 'query-string'; +import { withRouter } from 'react-router-dom'; + +const WeekSelector = ({ urlWeek, location, history }) => { + const updateWeek = (change) => { + const newWeek = moment().week(urlWeek.week() + change); + const isCurrentWeek = moment().week() === newWeek.week(); + history.push(`${location.pathname}?${queryString.stringify({ week: isCurrentWeek ? undefined : newWeek.week() })}`); + }; + + return ( +
      + + Week {urlWeek.week()} + +
      + ); +}; + +WeekSelector.propTypes = { + urlWeek: momentPropTypes.momentObj.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/User.js b/src/client/react/components/page/User.js index ff304c0..980070f 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -1,12 +1,17 @@ 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 Search from '../container/Search'; import View from '../container/View'; import users from '../../users'; +import WeekSelector from '../container/WeekSelector'; -const App = ({ match }) => { +const App = ({ match, location }) => { const user = `${match.params.type}/${match.params.value}`; + const weekStr = queryString.parse(location.search).week; + const week = weekStr ? moment().week(weekStr) : moment(); if (!users.allIds.includes(user)) { // Invalid user, redirect to index. @@ -18,6 +23,7 @@ const App = ({ match }) => {
      +
      @@ -32,6 +38,9 @@ App.propTypes = { value: PropTypes.string.isRequired, }).isRequired, }).isRequired, + location: PropTypes.shape({ + search: PropTypes.string.isRequired, + }).isRequired, }; export default App; diff --git a/src/client/react/index.js b/src/client/react/index.js index e70b442..122d54b 100644 --- a/src/client/react/index.js +++ b/src/client/react/index.js @@ -1,5 +1,6 @@ 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'; @@ -9,6 +10,8 @@ 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( -- cgit v1.1 From 0c99c0b4d84f53675cc3d42fa518879789cc86b0 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Fri, 26 Jan 2018 20:34:35 +0100 Subject: Make updateWeek sub 80 chars --- src/client/react/components/container/WeekSelector.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/WeekSelector.js b/src/client/react/components/container/WeekSelector.js index 9f308f0..c9174ca 100644 --- a/src/client/react/components/container/WeekSelector.js +++ b/src/client/react/components/container/WeekSelector.js @@ -9,7 +9,11 @@ const WeekSelector = ({ urlWeek, location, history }) => { const updateWeek = (change) => { const newWeek = moment().week(urlWeek.week() + change); const isCurrentWeek = moment().week() === newWeek.week(); - history.push(`${location.pathname}?${queryString.stringify({ week: isCurrentWeek ? undefined : newWeek.week() })}`); + + const query = queryString.stringify({ + week: isCurrentWeek ? undefined : newWeek.week(), + }); + history.push(`${location.pathname}?${query}`); }; return ( -- cgit v1.1 From 8670ada517bc8beb69d152c82f282322b9ea8d64 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 15:43:11 +0100 Subject: Implement week selector in the view --- src/client/react/actions/view.js | 7 +++-- src/client/react/components/container/View.js | 18 ++++++----- src/client/react/components/page/User.js | 3 +- src/client/react/reducers/view.js | 43 ++++++++++++++++++--------- 4 files changed, 46 insertions(+), 25 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/actions/view.js b/src/client/react/actions/view.js index f9f0be2..79ec143 100644 --- a/src/client/react/actions/view.js +++ b/src/client/react/actions/view.js @@ -1,17 +1,19 @@ // eslint-disable-next-line import/prefer-default-export -export const fetchSchedule = user => (dispatch) => { +export const fetchSchedule = (user, week) => (dispatch) => { dispatch({ type: 'VIEW/FETCH_SCHEDULE_REQUEST', user, + week, }); - fetch(`/get/${user}`).then( + fetch(`/get/${user}?week=${week}`).then( // success (r) => { r.text().then((htmlStr) => { dispatch({ type: 'VIEW/FETCH_SCHEDULE_SUCCESS', user, + week, htmlStr, }); }); @@ -21,6 +23,7 @@ export const fetchSchedule = user => (dispatch) => { () => { dispatch({ type: 'VIEW/FETCH_SCHEDULE_FAILURE', + week, user, }); }, diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js index 9bac66f..be550cd 100644 --- a/src/client/react/components/container/View.js +++ b/src/client/react/components/container/View.js @@ -16,24 +16,26 @@ function cleanMeetingpointHTML(htmlStr) { class View extends React.Component { componentDidMount() { - if (!this.loadingFinished(this.props.user)) { - this.props.dispatch(fetchSchedule(this.props.user)); + if (!this.loadingFinished(this.props.user, this.props.week)) { + this.props.dispatch(fetchSchedule(this.props.user, this.props.week)); } } componentWillReceiveProps(nextProps) { - if (nextProps.user !== this.props.user && !this.loadingFinished(nextProps.user)) { - this.props.dispatch(fetchSchedule(nextProps.user)); + if ((nextProps.user !== this.props.user || nextProps.week !== this.props.week) + && !this.loadingFinished(nextProps.user, nextProps.week)) { + this.props.dispatch(fetchSchedule(nextProps.user, nextProps.week)); } } - loadingFinished(user) { + loadingFinished(user, week) { return this.props.schedules.hasOwnProperty(user) && - this.props.schedules[user].state === 'finished'; + this.props.schedules[user].hasOwnProperty(week) && + this.props.schedules[user][week].state === 'finished'; } render() { - if (!this.loadingFinished(this.props.user)) { + if (!this.loadingFinished(this.props.user, this.props.week)) { return (
      Loading... @@ -41,7 +43,7 @@ class View extends React.Component { ); } - const cleanHTML = cleanMeetingpointHTML(this.props.schedules[this.props.user].htmlStr); + const cleanHTML = cleanMeetingpointHTML(this.props.schedules[this.props.user][this.props.week].htmlStr); return ( // eslint-disable-next-line react/no-danger diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js index 980070f..72e92c2 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -26,7 +26,8 @@ const App = ({ match, location }) => {
      - + {/* The View object just wants the week number. */} +
      ); }; diff --git a/src/client/react/reducers/view.js b/src/client/react/reducers/view.js index 276d8ae..603f1d4 100644 --- a/src/client/react/reducers/view.js +++ b/src/client/react/reducers/view.js @@ -1,3 +1,21 @@ +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: {}, }; @@ -5,25 +23,22 @@ const DEFAULT_STATE = { const view = (state = DEFAULT_STATE, action) => { switch (action.type) { case 'VIEW/FETCH_SCHEDULE_REQUEST': - return { - ...state, - schedules: { - ...state.schedules, - [action.user]: { - state: 'fetching', - }, - }, - }; case 'VIEW/FETCH_SCHEDULE_SUCCESS': return { ...state, schedules: { ...state.schedules, - [action.user]: { - ...state.schedules[action.user], - state: 'finished', - htmlStr: action.htmlStr, - }, + [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: -- cgit v1.1 From 3b98d4c4f13424c89a10580065075998d37ae857 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 16:06:56 +0100 Subject: Improve week logic --- src/client/react/components/container/WeekSelector.js | 13 +++++++------ src/client/react/components/page/User.js | 6 +++--- src/client/react/lib/purifyWeek.js | 9 +++++++++ 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 src/client/react/lib/purifyWeek.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/WeekSelector.js b/src/client/react/components/container/WeekSelector.js index c9174ca..eef8d8d 100644 --- a/src/client/react/components/container/WeekSelector.js +++ b/src/client/react/components/container/WeekSelector.js @@ -1,17 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import momentPropTypes from 'react-moment-proptypes'; 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 = moment().week(urlWeek.week() + change); - const isCurrentWeek = moment().week() === newWeek.week(); + const newWeek = purifyWeek(urlWeek + change); + const isCurrentWeek = moment().week() === newWeek; const query = queryString.stringify({ - week: isCurrentWeek ? undefined : newWeek.week(), + week: isCurrentWeek ? undefined : newWeek, }); history.push(`${location.pathname}?${query}`); }; @@ -19,14 +20,14 @@ const WeekSelector = ({ urlWeek, location, history }) => { return (
      - Week {urlWeek.week()} + Week {urlWeek}
      ); }; WeekSelector.propTypes = { - urlWeek: momentPropTypes.momentObj.isRequired, + urlWeek: PropTypes.number.isRequired, history: PropTypes.shape({ push: PropTypes.func.isRequired, }).isRequired, diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js index 72e92c2..10d608d 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -3,6 +3,7 @@ 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'; @@ -11,7 +12,7 @@ import WeekSelector from '../container/WeekSelector'; const App = ({ match, location }) => { const user = `${match.params.type}/${match.params.value}`; const weekStr = queryString.parse(location.search).week; - const week = weekStr ? moment().week(weekStr) : moment(); + const week = purifyWeek(weekStr ? parseInt(weekStr, 10) : moment().week()); if (!users.allIds.includes(user)) { // Invalid user, redirect to index. @@ -26,8 +27,7 @@ const App = ({ match, location }) => {
      - {/* The View object just wants the week number. */} - +
      ); }; 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(); +} -- cgit v1.1 From 9f438225db07b7214e4a41d133634309cba80073 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 16:10:10 +0100 Subject: Fix View.js prop types --- src/client/react/components/container/View.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js index be550cd..7539609 100644 --- a/src/client/react/components/container/View.js +++ b/src/client/react/components/container/View.js @@ -54,11 +54,12 @@ class View extends React.Component { View.propTypes = { user: PropTypes.string, + week: PropTypes.number.isRequired, dispatch: PropTypes.func.isRequired, - schedules: PropTypes.objectOf(PropTypes.shape({ + schedules: PropTypes.objectOf(PropTypes.objectOf(PropTypes.shape({ state: PropTypes.string.isRequired, htmlStr: PropTypes.string, - })).isRequired, + }))).isRequired, }; View.defaultProps = { -- cgit v1.1 From 7dd214d57c7dab9626abef1516a862f46c1e02bd Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 19:11:00 +0100 Subject: Refactor View.js --- src/client/react/components/container/View.js | 68 ++++++++++++--------------- src/client/react/lib/extractSchedule.js | 10 ++++ src/client/react/reducers/view.js | 4 +- 3 files changed, 41 insertions(+), 41 deletions(-) create mode 100644 src/client/react/lib/extractSchedule.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js index 7539609..2823fe9 100644 --- a/src/client/react/components/container/View.js +++ b/src/client/react/components/container/View.js @@ -5,55 +5,49 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { fetchSchedule } from '../../actions/view'; - -function cleanMeetingpointHTML(htmlStr) { - const DOMPurify = createDOMPurify(window); - - return DOMPurify.sanitize(htmlStr, { - ADD_ATTR: ['rules'], - }); -} +import extractSchedule from '../../lib/extractSchedule'; class View extends React.Component { - componentDidMount() { - if (!this.loadingFinished(this.props.user, this.props.week)) { - this.props.dispatch(fetchSchedule(this.props.user, this.props.week)); - } - } - - componentWillReceiveProps(nextProps) { - if ((nextProps.user !== this.props.user || nextProps.week !== this.props.week) - && !this.loadingFinished(nextProps.user, nextProps.week)) { - this.props.dispatch(fetchSchedule(nextProps.user, nextProps.week)); - } - } - - loadingFinished(user, week) { - return this.props.schedules.hasOwnProperty(user) && - this.props.schedules[user].hasOwnProperty(week) && - this.props.schedules[user][week].state === 'finished'; + renderLoading() { + return ( +
      + Loading... +
      + ); } - render() { - if (!this.loadingFinished(this.props.user, this.props.week)) { - return ( -
      - Loading... -
      - ); - } + renderSchedule(htmlStr) { + const DOMPurify = createDOMPurify(window); - const cleanHTML = cleanMeetingpointHTML(this.props.schedules[this.props.user][this.props.week].htmlStr); + const cleanHTML = DOMPurify.sanitize(htmlStr, { + ADD_ATTR: ['rules'], + }); return ( // eslint-disable-next-line react/no-danger
      ); } + + render() { + const schedule = extractSchedule(this.props.schedules, this.props.user, this.props.week); + + switch (schedule.state) { + case 'NOT_REQUESTED': + this.props.dispatch(fetchSchedule(this.props.user, this.props.week)); + return this.renderLoading(); + case 'FETCHING': + return this.renderLoading(); + case 'FINISHED': + return this.renderSchedule(schedule.htmlStr); + default: + throw new Error(`${schedule.state} is not a valid schedule state.`); + } + } } View.propTypes = { - user: PropTypes.string, + user: PropTypes.string.isRequired, week: PropTypes.number.isRequired, dispatch: PropTypes.func.isRequired, schedules: PropTypes.objectOf(PropTypes.objectOf(PropTypes.shape({ @@ -62,10 +56,6 @@ View.propTypes = { }))).isRequired, }; -View.defaultProps = { - user: null, -}; - const mapStateToProps = state => ({ schedules: state.view.schedules, }); 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/reducers/view.js b/src/client/react/reducers/view.js index 603f1d4..301a1cf 100644 --- a/src/client/react/reducers/view.js +++ b/src/client/react/reducers/view.js @@ -3,12 +3,12 @@ const schedule = (state = {}, action) => { case 'VIEW/FETCH_SCHEDULE_REQUEST': return { ...state, - state: 'fetching', + state: 'FETCHING', }; case 'VIEW/FETCH_SCHEDULE_SUCCESS': return { ...state, - state: 'finished', + state: 'FINISHED', htmlStr: action.htmlStr, }; default: -- cgit v1.1 From 9e539c650b40ea76f9c7d00d9b28b33905d1b1d6 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 19:25:17 +0100 Subject: Make a stateless component --- src/client/react/components/container/View.js | 73 ++++++++++++++------------- 1 file changed, 37 insertions(+), 36 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js index 2823fe9..7ac5d3e 100644 --- a/src/client/react/components/container/View.js +++ b/src/client/react/components/container/View.js @@ -7,53 +7,54 @@ import { withRouter } from 'react-router-dom'; import { fetchSchedule } from '../../actions/view'; import extractSchedule from '../../lib/extractSchedule'; -class View extends React.Component { - renderLoading() { - return ( -
      - Loading... -
      - ); - } +const Loading = () =>
      Loading...
      ; - renderSchedule(htmlStr) { - const DOMPurify = createDOMPurify(window); +const Schedule = ({ htmlStr }) => { + const DOMPurify = createDOMPurify(window); - const cleanHTML = DOMPurify.sanitize(htmlStr, { - ADD_ATTR: ['rules'], - }); + const cleanHTML = DOMPurify.sanitize(htmlStr, { + ADD_ATTR: ['rules'], + }); - return ( - // eslint-disable-next-line react/no-danger -
      - ); - } + return ( + // eslint-disable-next-line react/no-danger +
      + ); +}; - render() { - const schedule = extractSchedule(this.props.schedules, this.props.user, this.props.week); - - switch (schedule.state) { - case 'NOT_REQUESTED': - this.props.dispatch(fetchSchedule(this.props.user, this.props.week)); - return this.renderLoading(); - case 'FETCHING': - return this.renderLoading(); - case 'FINISHED': - return this.renderSchedule(schedule.htmlStr); - default: - throw new Error(`${schedule.state} is not a valid schedule state.`); - } +Schedule.propTypes = { + htmlStr: PropTypes.string.isRequired, +}; + +const View = ({ + schedules, + user, + week, + dispatch, +}) => { + const schedule = extractSchedule(schedules, user, week); + + switch (schedule.state) { + case 'NOT_REQUESTED': + dispatch(fetchSchedule(user, week)); + return ; + case 'FETCHING': + return ; + case 'FINISHED': + return ; + default: + throw new Error(`${schedule.state} is not a valid schedule state.`); } -} +}; View.propTypes = { - user: PropTypes.string.isRequired, - week: PropTypes.number.isRequired, - dispatch: PropTypes.func.isRequired, 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 => ({ -- cgit v1.1 From dde583c2fa9b990e1d30f7292f9cf28d9310e570 Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 19:59:09 +0100 Subject: Move Schedule and Loading to seperate files --- src/client/react/components/container/View.js | 21 ++------------------- .../react/components/presentational/Loading.js | 5 +++++ .../react/components/presentational/Schedule.js | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 src/client/react/components/presentational/Loading.js create mode 100644 src/client/react/components/presentational/Schedule.js (limited to 'src/client/react') diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js index 7ac5d3e..4f16100 100644 --- a/src/client/react/components/container/View.js +++ b/src/client/react/components/container/View.js @@ -1,30 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import createDOMPurify from 'dompurify'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { fetchSchedule } from '../../actions/view'; import extractSchedule from '../../lib/extractSchedule'; -const Loading = () =>
      Loading...
      ; - -const Schedule = ({ htmlStr }) => { - const DOMPurify = createDOMPurify(window); - - const cleanHTML = DOMPurify.sanitize(htmlStr, { - ADD_ATTR: ['rules'], - }); - - return ( - // eslint-disable-next-line react/no-danger -
      - ); -}; - -Schedule.propTypes = { - htmlStr: PropTypes.string.isRequired, -}; +import Schedule from '../presentational/Schedule'; +import Loading from '../presentational/Loading'; const View = ({ schedules, 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 = () =>
      Loading...
      ; + +export default Loading; 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 +
      + ); +}; + +Schedule.propTypes = { + htmlStr: PropTypes.string.isRequired, +}; + +export default Schedule; -- cgit v1.1 From 9a9edd1865d619caada787231c8bb34be25af3af Mon Sep 17 00:00:00 2001 From: Noah Loomans Date: Sun, 28 Jan 2018 20:00:44 +0100 Subject: Give IndexPage and UserPage the correct name --- src/client/react/components/page/Index.js | 4 ++-- src/client/react/components/page/User.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src/client/react') diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js index c2994c9..e5e47c5 100644 --- a/src/client/react/components/page/Index.js +++ b/src/client/react/components/page/Index.js @@ -2,7 +2,7 @@ import React from 'react'; import Search from '../container/Search'; import HelpBox from '../container/HelpBox'; -const App = () => ( +const IndexPage = () => (
      Metis @@ -12,4 +12,4 @@ const App = () => (
      ); -export default App; +export default IndexPage; diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js index 10d608d..215a6e0 100644 --- a/src/client/react/components/page/User.js +++ b/src/client/react/components/page/User.js @@ -9,7 +9,7 @@ import View from '../container/View'; import users from '../../users'; import WeekSelector from '../container/WeekSelector'; -const App = ({ match, location }) => { +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()); @@ -32,7 +32,7 @@ const App = ({ match, location }) => { ); }; -App.propTypes = { +UserPage.propTypes = { match: PropTypes.shape({ params: PropTypes.shape({ type: PropTypes.string.isRequired, @@ -44,4 +44,4 @@ App.propTypes = { }).isRequired, }; -export default App; +export default UserPage; -- cgit v1.1