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