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