From 82bdb5c5a29854d6a8182d4c1cd27365e9a4b25d Mon Sep 17 00:00:00 2001 From: actlikewill Date: Tue, 22 Jan 2019 20:30:38 +0300 Subject: [PATCH] feat(search) : search functionality - allows user to search the articles by author, title or tag [#161966583]t --- src/App.js | 5 +- src/actions/__tests__/searchActions.test.js | 65 +++++++++++++++++++ src/actions/actionTypes.js | 3 + src/actions/searchActions.js | 33 ++++++++++ src/components/Articles/ArticlesContainer.jsx | 6 +- src/components/Header/Header.jsx | 17 ++++- src/components/Header/header.scss | 4 ++ src/components/Search/Search.scss | 19 ++++++ src/components/Search/SearchBox.jsx | 61 +++++++++++++++++ .../Search/SearchResultsComponent.jsx | 49 ++++++++++++++ src/components/Search/SearchResultsPage.jsx | 65 +++++++++++++++++++ .../Search/__tests__/SearchBox.test.js | 25 +++++++ .../__tests__/SearchResultsPage.test.js | 53 +++++++++++++++ src/reducers/__tests__/searchReducer.test.js | 47 ++++++++++++++ src/reducers/index.js | 2 + src/reducers/searchReducer.js | 24 +++++++ 16 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 src/actions/__tests__/searchActions.test.js create mode 100644 src/actions/searchActions.js create mode 100644 src/components/Search/Search.scss create mode 100644 src/components/Search/SearchBox.jsx create mode 100644 src/components/Search/SearchResultsComponent.jsx create mode 100644 src/components/Search/SearchResultsPage.jsx create mode 100644 src/components/Search/__tests__/SearchBox.test.js create mode 100644 src/components/Search/__tests__/SearchResultsPage.test.js create mode 100644 src/reducers/__tests__/searchReducer.test.js create mode 100644 src/reducers/searchReducer.js diff --git a/src/App.js b/src/App.js index daeb6ed..1438e05 100755 --- a/src/App.js +++ b/src/App.js @@ -3,9 +3,10 @@ import 'semantic-ui-css/semantic.min.css'; import { Route } from 'react-router'; import { Switch, BrowserRouter } from 'react-router-dom'; +import RegistrationPage from './components/Authentication/Registration/RegistrationPage'; +import SearchResultsPage from './components/Search/SearchResultsPage'; import Error from './components/ErrorHandlers/MissingPageError'; import ArticleContainer from './components/Articles/ArticlesContainer'; -import RegistrationPage from './components/Authentication/Registration/RegistrationPage'; import LoginContainer from './components/Authentication/Login/LoginContainer'; @@ -19,6 +20,8 @@ class App extends Component { + + diff --git a/src/actions/__tests__/searchActions.test.js b/src/actions/__tests__/searchActions.test.js new file mode 100644 index 0000000..26dc108 --- /dev/null +++ b/src/actions/__tests__/searchActions.test.js @@ -0,0 +1,65 @@ +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import moxios from 'moxios'; +import * as searchActions from '../searchActions'; +import * as types from '../actionTypes'; + +const middleware = [thunk]; +const mockStore = configureMockStore(middleware); + +describe('Search Actions', () => { + beforeEach(() => { + moxios.install(); + }); + beforeEach(() => { + moxios.install(); + }); + + it('should call the SEARCH_SUCCESS action on success', () => { + moxios.wait(() => { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 200, + // TODO : fix this response message + response: { results: 'keywords present' }, + }); + }); + + const searchKeyWords = 'some search keywords'; + + const results = { results: 'keywords present' }; + + const expectedActions = [ + { type: types.SEARCH_SUBMIT, searchKeyWords }, + { type: types.SEARCH_SUCCESS, results }, + ]; + const store = mockStore({}); + return store.dispatch(searchActions.searchAction(searchKeyWords)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should call the SEARCH_FAIL action on failure', () => { + moxios.wait(() => { + const request = moxios.requests.mostRecent(); + request.respondWith({ + status: 400, + // TODO : fix this response message + response: { error: 'no results found' }, + }); + }); + + const searchKeyWords = 'some search keywords'; + + const response = { error: 'no results found' }; + + const expectedActions = [ + { type: types.SEARCH_SUBMIT, searchKeyWords }, + { type: types.SEARCH_FAIL, response }, + ]; + const store = mockStore({}); + return store.dispatch(searchActions.searchAction(searchKeyWords)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); +}); diff --git a/src/actions/actionTypes.js b/src/actions/actionTypes.js index 44286e9..e78c090 100644 --- a/src/actions/actionTypes.js +++ b/src/actions/actionTypes.js @@ -8,3 +8,6 @@ export const FETCH_ARTICLES_FAILS = 'FETCH_ARTICLES'; export const LOGIN_ACTION = 'LOGIN_ACTION'; export const LOGIN_SUCCESSFUL = 'LOGIN_SUCCESSFUL'; export const LOGIN_REJECTED = 'LOGIN_REJECTED'; +export const SEARCH_SUBMIT = 'SEARCH_SUBMIT'; +export const SEARCH_SUCCESS = 'SEARCH_SUCCESS'; +export const SEARCH_FAIL = 'SEARCH_FAIL'; diff --git a/src/actions/searchActions.js b/src/actions/searchActions.js new file mode 100644 index 0000000..3b53468 --- /dev/null +++ b/src/actions/searchActions.js @@ -0,0 +1,33 @@ +import Axios from 'axios'; +import * as types from './actionTypes'; + +export const searchSubmit = searchKeyWords => ({ + type: types.SEARCH_SUBMIT, + searchKeyWords, +}); + +export const searchSuccess = results => ({ + type: types.SEARCH_SUCCESS, + results, +}); + +export const searchFail = response => ({ + type: types.SEARCH_FAIL, + response, +}); +/** + * This is the main action to dispatch the call to the API + * @function searchAction + * @param {string} searchKeyWords + * @return {Promise} + */ +export const searchAction = searchKeyWords => (dispatch) => { + dispatch(searchSubmit(searchKeyWords)); + return Axios.get(`https://ah-technocrats.herokuapp.com/api/article/?search=${searchKeyWords}`) + .then((res) => { + dispatch(searchSuccess(res.data)); + }) + .catch((error) => { + dispatch(searchFail(error.response.data)); + }); +}; diff --git a/src/components/Articles/ArticlesContainer.jsx b/src/components/Articles/ArticlesContainer.jsx index 319460b..9cb8270 100644 --- a/src/components/Articles/ArticlesContainer.jsx +++ b/src/components/Articles/ArticlesContainer.jsx @@ -26,10 +26,10 @@ export class ArticleContainer extends React.Component { } render() { - const { articles } = this.props; + const { articles, history } = this.props; return ( -
+
@@ -45,11 +45,13 @@ export class ArticleContainer extends React.Component { ArticleContainer.propTypes = { fetchArticles: PropTypes.func, articles: PropTypes.arrayOf(PropTypes.object), + history: PropTypes.shape({}), }; ArticleContainer.defaultProps = { fetchArticles: () => {}, articles: [], + history: null, }; const mapStateToProps = state => ({ diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx index ae66871..c9ac54b 100644 --- a/src/components/Header/Header.jsx +++ b/src/components/Header/Header.jsx @@ -1,10 +1,11 @@ import React from 'react'; +import PropTypes from 'prop-types'; import './header.scss'; import { Menu, Image, Sticky, } from 'semantic-ui-react'; import ahLogoWide from './ah-logo-wide.svg'; - +import SearchBox from '../Search/SearchBox'; export const toggleSideBar = () => { const sideBar = document.getElementById('sidebar'); @@ -15,15 +16,27 @@ export const toggleSideBar = () => { }; -const Header = () => ( +const Header = ({ history }) => (
+
+ +
); +Header.propTypes = { + history: PropTypes.shape({}), +}; + +Header.defaultProps = { + history: null, +}; + + export default Header; diff --git a/src/components/Header/header.scss b/src/components/Header/header.scss index cef9e08..c243e8a 100644 --- a/src/components/Header/header.scss +++ b/src/components/Header/header.scss @@ -40,6 +40,10 @@ $menu_color: rgba(0,0,0,.87); } +.searchBox { + padding: 20px +} + @media screen and (max-width: 552px) { .logo { diff --git a/src/components/Search/Search.scss b/src/components/Search/Search.scss new file mode 100644 index 0000000..dca948c --- /dev/null +++ b/src/components/Search/Search.scss @@ -0,0 +1,19 @@ +.searchResultsPage { + min-height: (100vh)-25; +} + +.searchResultsTitle { + padding: 20px; +} + +.noSearchResults { + margin: 0 auto; +} + +.searchResults { + margin-left: 150px !important; +} + +.searchResult { + margin: 30px !important; +} diff --git a/src/components/Search/SearchBox.jsx b/src/components/Search/SearchBox.jsx new file mode 100644 index 0000000..bb41aeb --- /dev/null +++ b/src/components/Search/SearchBox.jsx @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Input } from 'semantic-ui-react'; +import { connect } from 'react-redux'; +import { searchAction } from '../../actions/searchActions'; + +export class SearchBar extends Component { + constructor(props) { + super(props); + this.state = { + searchKeyWords: '', + }; + this.handleChange = this.handleChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + onSubmit(e) { + e.preventDefault(); + const { search, history } = this.props; + const { searchKeyWords } = this.state; + search(searchKeyWords); + history.push('/search'); + } + + handleChange(event) { + this.setState({ [event.target.name]: event.target.value }); + } + + render() { + return ( +
+
+
+ + +
+ +
+ ); + } +} + +SearchBar.propTypes = { + search: PropTypes.func, + history: PropTypes.shape({}), +}; + +SearchBar.defaultProps = { + search: null, + history: null, +}; + +function mapDispatchToProps(dispatch) { + return { + search: keyword => ( + dispatch(searchAction(keyword)) + ), + }; +} + +export default connect(null, mapDispatchToProps)(SearchBar); diff --git a/src/components/Search/SearchResultsComponent.jsx b/src/components/Search/SearchResultsComponent.jsx new file mode 100644 index 0000000..8adbb25 --- /dev/null +++ b/src/components/Search/SearchResultsComponent.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +const SearchResultsComponent = ({ results, noResults }) => ( +
+
+ { results + ? results.map(result => ( +
+
+
+ {result.title} +
+
+ Written By: + {' '} + {result.author.username} +
+
+
+ )) + : null + } + { noResults + ? ( +
+
+
No results Found.
+
+
+ ) + : null + } +
+
+); + +SearchResultsComponent.propTypes = { + results: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + noResults: PropTypes.bool, +}; + +SearchResultsComponent.defaultProps = { + noResults: null, +}; + + +export default SearchResultsComponent; diff --git a/src/components/Search/SearchResultsPage.jsx b/src/components/Search/SearchResultsPage.jsx new file mode 100644 index 0000000..b4b3067 --- /dev/null +++ b/src/components/Search/SearchResultsPage.jsx @@ -0,0 +1,65 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Segment, Sidebar, Loader } from 'semantic-ui-react'; +import Header from '../Header/Header'; +import Footer from '../Footer/Footer'; +import SideBarMenu from '../Menu/Menu'; +import SearchResultsComponent from './SearchResultsComponent'; +import './Search.scss'; + + +export class SearchResultsPage extends Component { + render() { + const { + results, history, keyWords, loading, noResults, + } = this.props; + return ( + +
+ + + +
+

+ Search Results for: + “ + {keyWords} + ” +

+ {loading ? Searching : null} + +
+
+
+