From 493db23f1dad754ec77e8aff268f18db784ee0f0 Mon Sep 17 00:00:00 2001 From: Chuong Tran Date: Wed, 31 Oct 2018 21:47:26 +0800 Subject: [PATCH 1/2] Implement Solo chatbox --- package.json | 11 +++- src/App.css | 13 ++++ src/App.js | 99 ++++++++++++++++++++++++++--- src/ChatArea.js | 9 --- src/MessageBar.js | 16 ----- src/SendButton.js | 9 --- src/TextInput.js | 13 ---- src/actions.js | 11 ++++ src/components/ChatArea.js | 39 ++++++++++++ src/components/MessageBar.js | 86 +++++++++++++++++++++++++ src/components/SuggestedCommands.js | 55 ++++++++++++++++ src/constants.js | 5 ++ src/index.js | 13 +++- src/reducers.js | 33 ++++++++++ src/selectors.js | 16 +++++ src/utils.js | 17 +++++ 16 files changed, 385 insertions(+), 60 deletions(-) delete mode 100644 src/ChatArea.js delete mode 100644 src/MessageBar.js delete mode 100644 src/SendButton.js delete mode 100644 src/TextInput.js create mode 100644 src/actions.js create mode 100644 src/components/ChatArea.js create mode 100644 src/components/MessageBar.js create mode 100644 src/components/SuggestedCommands.js create mode 100644 src/constants.js create mode 100644 src/reducers.js create mode 100644 src/selectors.js create mode 100644 src/utils.js diff --git a/package.json b/package.json index ef9fa82..97ac06b 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,16 @@ "version": "0.1.0", "private": true, "dependencies": { + "axios": "^0.18.0", + "immutable": "^3.8.2", "react": "^16.2.0", "react-dom": "^16.2.0", - "react-scripts": "1.1.0" + "react-redux": "^5.1.0", + "react-scripts": "1.1.0", + "redux": "^4.0.1", + "redux-immutable": "^4.0.0", + "reselect": "^4.0.0", + "styled-components": "^4.0.2" }, "scripts": { "start": "react-scripts start", @@ -13,4 +20,4 @@ "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" } -} \ No newline at end of file +} diff --git a/src/App.css b/src/App.css index 43515ed..6c83d8c 100644 --- a/src/App.css +++ b/src/App.css @@ -9,6 +9,10 @@ body, padding: 0; } +body * { + box-sizing: border-box; +} + .app { display: flex; flex-direction: column; @@ -16,10 +20,19 @@ body, .chatArea { flex-grow: 1; + flex: 1; + overflow: auto; margin: 10px; border: 1px solid #ccc; background-color: #fafafa; } +.chatArea > p { + padding: 12px; + margin: 0; +} +.chatArea > p:nth-of-type(even) { + background: #eee; +} .messageBar { display: flex; diff --git a/src/App.js b/src/App.js index 932a66d..6c09dae 100644 --- a/src/App.js +++ b/src/App.js @@ -1,17 +1,96 @@ import React, { Component } from "react"; -import MessageBar from "./MessageBar"; -import ChatArea from "./ChatArea"; +import { connect } from 'react-redux'; +import axios from 'axios'; + +import { commands } from './constants'; +import MessageBar from "./components/MessageBar"; +import ChatArea from "./components/ChatArea"; import "./App.css"; +import { IDGenerator } from './utils'; +import { addMessage, updateMessage, stopApplication } from './actions'; class App extends Component { - render() { - return ( -
- - -
- ); + searchForCharacter = (messageId, query) => { + axios.get(`https://swapi.co/api/people/?search=${query}`).then(data => { + if(data) { + const { results } = data.data; + if (Array.isArray(results) && results.length) { + this.props.updateMessage(messageId, `Search results for \`${query}\`: ${results[0].name}`); + } + else { + this.props.updateMessage(messageId, `No results for \`${query}\``); + } + } + }, err => { + this.props.updateMessage(messageId, `There is an error when searching \`${query}\``); + }); + } + messageConverter = (message) => { + let messageResults = null; + const messageArray = message.split(' '); + const messageCommand = messageArray.shift(); + const id = IDGenerator(); + + const firstChar = messageCommand[0]; + if (firstChar === '/') { + message = messageCommand.substring(1); + if (commands.indexOf(message) > -1) { + switch(message) { + case 'time': + messageResults = `Current time: ${new Date().toString()}`; + break; + case 'starwars': + this.searchForCharacter(id, messageArray.join(' ')); + messageResults = `Searching character by name: ${messageArray.join(' ')}`; + break; + case 'goodbye': + this.props.stopApplication(); + messageResults = 'Bye! See you again.'; + break; + default: + messageResults = message; + break; + } + } + else { + messageResults = `Command \`${message}\` does not exist.`; + } + } + else { + messageResults = message; + } + if (!messageResults) return null; + return { id, content: messageResults }; + } + onSubmit = (message) => { + const finalMessage = this.messageConverter(message); + if (finalMessage) { + this.props.addMessage(finalMessage); } + } + render() { + return ( +
+ + +
+ ); + } } -export default App; +function mapDispatchToProps(dispatch) { + return { + addMessage(message) { + dispatch(addMessage(message)); + }, + updateMessage(messageId, content) { + dispatch(updateMessage(messageId, content)); + }, + stopApplication() { + dispatch(stopApplication()); + }, + }; +} + +export default connect(null, mapDispatchToProps)(App); + diff --git a/src/ChatArea.js b/src/ChatArea.js deleted file mode 100644 index c4e314c..0000000 --- a/src/ChatArea.js +++ /dev/null @@ -1,9 +0,0 @@ -import React, { Component } from "react"; - -class ChatArea extends Component { - render() { - return
; - } -} - -export default ChatArea; diff --git a/src/MessageBar.js b/src/MessageBar.js deleted file mode 100644 index 05a6028..0000000 --- a/src/MessageBar.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Component } from "react"; -import TextInput from "./TextInput"; -import SendButton from "./SendButton"; - -class MessageBar extends Component { - render() { - return ( -
- - -
- ); - } -} - -export default MessageBar; diff --git a/src/SendButton.js b/src/SendButton.js deleted file mode 100644 index dd4ae98..0000000 --- a/src/SendButton.js +++ /dev/null @@ -1,9 +0,0 @@ -import React, { Component } from "react"; - -class SendButton extends Component { - render() { - return ; - } -} - -export default SendButton; diff --git a/src/TextInput.js b/src/TextInput.js deleted file mode 100644 index 4b2acd0..0000000 --- a/src/TextInput.js +++ /dev/null @@ -1,13 +0,0 @@ -import React, { Component } from "react"; - -class MessageBar extends Component { - render() { - return ( -
- -
- ); - } -} - -export default MessageBar; diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000..db98bcb --- /dev/null +++ b/src/actions.js @@ -0,0 +1,11 @@ +import { STOP_APPLICATION, ADD_MESSAGE, UPDATE_MESSAGE } from './constants'; + +export function stopApplication() { + return { type: STOP_APPLICATION }; +} +export function addMessage(message) { + return { type: ADD_MESSAGE, message }; +} +export function updateMessage(messageId, content) { + return { type: UPDATE_MESSAGE, messageId, content }; +} diff --git a/src/components/ChatArea.js b/src/components/ChatArea.js new file mode 100644 index 0000000..1e63dcc --- /dev/null +++ b/src/components/ChatArea.js @@ -0,0 +1,39 @@ +import React, { Component } from "react"; +import PropTypes from 'prop-types'; +import { createStructuredSelector } from 'reselect'; +import { connect } from 'react-redux'; +import { selectApplicationStopped, selectMessages } from '../selectors'; +import { stopApplication } from '../actions'; + +class ChatArea extends Component { + render() { + const { messages } = this.props; + if (typeof messages === 'undefined') return null; + const messagesDisplay = messages.map((item, itemIndex) =>

{ item.content }

) + return
+ { messagesDisplay } +
+ } +} + +ChatArea.defaultProps = { + contents: [] +}; + +ChatArea.propTypes = { + contents: PropTypes.array +}; + +function mapDispatchToProps(dispatch) { + return { + stopApplication() { + dispatch(stopApplication()); + } + }; +} + +const mapStateToProps = createStructuredSelector({ + applicationStopped: selectApplicationStopped(), + messages: selectMessages() +}); +export default connect(mapStateToProps, mapDispatchToProps)(ChatArea); diff --git a/src/components/MessageBar.js b/src/components/MessageBar.js new file mode 100644 index 0000000..15ccf7b --- /dev/null +++ b/src/components/MessageBar.js @@ -0,0 +1,86 @@ +import React, { Component } from "react"; +import styled from 'styled-components'; +import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; +import PropTypes from 'prop-types'; + +import { selectApplicationStopped } from '../selectors'; +import { addMessage } from '../actions'; + +import SuggestedCommands from './SuggestedCommands'; + +const StyledWrapper = styled.form` + display: flex; + position: relative; +`; +const StyledInput = styled.input` + height: 34px; + padding: 0 12px; + flex: 1; + margin-right: 15px; +`; + +const StyledButton = styled.button` + height: 34px; + padding: 0 12px; +`; + +class MessageBar extends Component { + constructor(props) { + super(props); + this.state = { + message: '', + commandOpened: false + } + }; + + onMessageChange = (evt) => { + const message = evt.target.value; + const commandOpened = message[0] === '/'; + this.setState({ message, commandOpened }); + }; + onSubmit = (evt) => { + evt.preventDefault(); + const { onSubmit } = this.props; + onSubmit(this.state.message); + this.setState({ message: '', commandOpened: false }); + } + + onCommandSelect = (command) => { + this.setState({ message: `/${command}`, commandOpened: false }); + this.messageInput.focus(); + } + + messageInput = null; + + render() { + const { message, commandOpened } = this.state; + const { applicationStopped } = this.props; + + return ( + + { this.messageInput = el; }} /> + { !applicationStopped ? SEND : null } + + + ); + } +} + +MessageBar.propTypes = { + onSubmit: PropTypes.func, +}; + +function mapDispatchToProps(dispatch) { + return { + addMessage(message) { + dispatch(addMessage(message)); + } + }; +} +const mapStateToProps = createStructuredSelector({ + applicationStopped: selectApplicationStopped() +}); + +export default connect(mapStateToProps, mapDispatchToProps)(MessageBar); + diff --git a/src/components/SuggestedCommands.js b/src/components/SuggestedCommands.js new file mode 100644 index 0000000..80d8990 --- /dev/null +++ b/src/components/SuggestedCommands.js @@ -0,0 +1,55 @@ +import React from 'react'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; + +import { commands } from '../constants'; + +const StyledWrapper = styled.ul` + position: absolute; + margin: -10px 0 0 0; + padding: 8px 0; + border-radius: 4px; + top: 0; + transform: translateY(-100%); + background: #eee; + box-shadow: 0px 5px 17px 0px rgba(0,0,0,.2); + min-width: 120px; + + li { + list-style: none; + padding: 4px 12px; + cursor: pointer; + &:hover { + background: #333; + color: #fff; + } + } +`; + +class SuggestedCommands extends React.Component { + + onCommandSelect = (item) => { + const { onCommandSelect } = this.props; + if (typeof onCommandSelect === 'function') { + if (item === 'starwars') item += ' '; + onCommandSelect(item); + } + } + renderCommand = () => { + return commands.map((item) =>
  • { this.onCommandSelect(item); }} key={`command-${item}`}>{ item }
  • ) + } + render() { + const { open } = this.props; + return !open ? null : + { this.renderCommand() } + + } +} + +SuggestedCommands.propTypes = { + message: PropTypes.string, + open: PropTypes.bool, + onCommandSelect: PropTypes.func, +}; + +export default SuggestedCommands; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..b03aeef --- /dev/null +++ b/src/constants.js @@ -0,0 +1,5 @@ +export const STOP_APPLICATION = 'soloChat/STOP_APPLICATION'; +export const ADD_MESSAGE = 'soloChat/ADD_MESSAGE'; +export const UPDATE_MESSAGE = 'soloChat/UPDATE_MESSAGE'; + +export const commands = ['time', 'starwars', 'goodbye']; diff --git a/src/index.js b/src/index.js index c24e9d8..e3504b6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,16 @@ import React from "react"; import ReactDOM from "react-dom"; +import { Provider } from 'react-redux' +import { createStore } from 'redux' +import MainReducers from './reducers'; + import App from "./App"; -ReactDOM.render(, document.getElementById("root")); +const store = createStore(MainReducers); + +ReactDOM.render( + + + , + document.getElementById("root") +); diff --git a/src/reducers.js b/src/reducers.js new file mode 100644 index 0000000..0bbd3a9 --- /dev/null +++ b/src/reducers.js @@ -0,0 +1,33 @@ +import { fromJS } from 'immutable'; +import { STOP_APPLICATION, ADD_MESSAGE, UPDATE_MESSAGE } from './constants'; + +import Immutable from 'immutable'; +const initialState = Immutable.fromJS({ + applicationStopped: false, + messages: [] +}); + +const MainReducer = (state = initialState, action) => { + const messages = state.get('messages').toJS(); + switch (action.type) { + case STOP_APPLICATION: + return state + .set('applicationStopped', true); + case ADD_MESSAGE: + messages.push(action.message); + return state + .set('messages', fromJS(messages)); + case UPDATE_MESSAGE: + messages.forEach(mess => { + if (mess.id === action.messageId) { + mess.content = action.content; + } + }); + return state + .set('messages', fromJS(messages)); + + default: return state; + } +}; + +export default MainReducer; \ No newline at end of file diff --git a/src/selectors.js b/src/selectors.js new file mode 100644 index 0000000..2e349bc --- /dev/null +++ b/src/selectors.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; + +const selectGlobal = () => state => state; +const selectApplicationStopped = () => createSelector( + selectGlobal(), + state => (state ? state.get('applicationStopped') : false), +); + +const selectMessages = () => createSelector( + selectGlobal(), + state => (state ? state.get('messages').toJS() : []), +); +export { + selectApplicationStopped, + selectMessages +} \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..9f377f7 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,17 @@ +export const IDGenerator = () => { + const _getRandomInt = ( min, max ) => { + return Math.floor( Math.random() * ( max - min + 1 ) ) + min; + } + + const timestamp = +new Date(); + const length = 8; + const ts = timestamp.toString(); + const parts = ts.split( "" ).reverse(); + let id = ""; + + for( let i = 0; i < length; ++i ) { + const index = _getRandomInt( 0, parts.length - 1 ); + id += parts[index]; + } + return id; +} \ No newline at end of file From ce14c44927a1f6a3d59c25222bb4f82abcf46701 Mon Sep 17 00:00:00 2001 From: Chuong Tran Date: Wed, 31 Oct 2018 22:37:10 +0800 Subject: [PATCH 2/2] Add Error Message --- src/App.css | 2 ++ src/App.js | 22 ++++++++++++++++++++-- src/components/ErrorMessage.js | 25 +++++++++++++++++++++++++ src/components/MessageBar.js | 6 ++++-- 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/components/ErrorMessage.js diff --git a/src/App.css b/src/App.css index 6c83d8c..8fbcb6a 100644 --- a/src/App.css +++ b/src/App.css @@ -16,6 +16,8 @@ body * { .app { display: flex; flex-direction: column; + position: relative; + padding-bottom: 40px; } .chatArea { diff --git a/src/App.js b/src/App.js index 6c09dae..78f066f 100644 --- a/src/App.js +++ b/src/App.js @@ -5,11 +5,18 @@ import axios from 'axios'; import { commands } from './constants'; import MessageBar from "./components/MessageBar"; import ChatArea from "./components/ChatArea"; +import ErrorMessage from "./components/ErrorMessage"; import "./App.css"; import { IDGenerator } from './utils'; import { addMessage, updateMessage, stopApplication } from './actions'; class App extends Component { + constructor(props) { + super(props); + this.state = { + errorMessage: '' + } + } searchForCharacter = (messageId, query) => { axios.get(`https://swapi.co/api/people/?search=${query}`).then(data => { if(data) { @@ -40,8 +47,14 @@ class App extends Component { messageResults = `Current time: ${new Date().toString()}`; break; case 'starwars': - this.searchForCharacter(id, messageArray.join(' ')); - messageResults = `Searching character by name: ${messageArray.join(' ')}`; + const query = messageArray.join(' '); + if (!query.replace(/ /g,'')) { + messageResults = null; + this.setState({ errorMessage: 'Please enter a query' }); + break; + } + this.searchForCharacter(id, query); + messageResults = `Searching character by name: ${query}`; break; case 'goodbye': this.props.stopApplication(); @@ -63,16 +76,21 @@ class App extends Component { return { id, content: messageResults }; } onSubmit = (message) => { + this.setState({ errorMessage: '' }); const finalMessage = this.messageConverter(message); if (finalMessage) { this.props.addMessage(finalMessage); + return true; } + return false; + } render() { return (
    +
    ); } diff --git a/src/components/ErrorMessage.js b/src/components/ErrorMessage.js new file mode 100644 index 0000000..1bd4db0 --- /dev/null +++ b/src/components/ErrorMessage.js @@ -0,0 +1,25 @@ +import React from "react"; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; + +const StyledMessage = styled.div` + color: red; + font-size: 12px; + padding-left: 12px; + position: absolute; + bottom: 30px; +`; +const ErrorMessage = props => { + const { message } = props; + return { message } +} + +ErrorMessage.defaultProps = { + message: ' ' +}; + +ErrorMessage.propTypes = { + message: PropTypes.string +}; +export default ErrorMessage; + diff --git a/src/components/MessageBar.js b/src/components/MessageBar.js index 15ccf7b..c259e7f 100644 --- a/src/components/MessageBar.js +++ b/src/components/MessageBar.js @@ -42,8 +42,10 @@ class MessageBar extends Component { onSubmit = (evt) => { evt.preventDefault(); const { onSubmit } = this.props; - onSubmit(this.state.message); - this.setState({ message: '', commandOpened: false }); + var result = onSubmit(this.state.message); + if (result) { + this.setState({ message: '', commandOpened: false }); + } } onCommandSelect = (command) => {