├── .gitignore ├── README.md ├── json-server.js ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.js ├── components │ ├── Header.js │ ├── Icon.js │ ├── Layout.js │ ├── MessageBrowser │ │ ├── MessageBrowser.js │ │ ├── MessageBrowserContainer.js │ │ ├── MessageList.js │ │ ├── MessageListItem.js │ │ ├── SearchInput.js │ │ └── Sidebar.js │ └── MessageDetail │ │ ├── MessageDetail.js │ │ ├── MessageDetail.test.js │ │ └── MessageDetailContainer.js ├── index.js ├── routes.js └── styles │ ├── animations.css │ └── general.css └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactMail 2 | 3 | An Example [React.js](https://facebook.github.io/react/index.html) App for Practical Learning Purposes 4 | 5 | Let me begin by saying that there are many great resources out there for learning React etc... 6 | 7 | - Pete Hunt's react-howto: https://github.com/petehunt/react-howto 8 | - Tyler McGinnis' React.js Program: http://www.reactjsprogram.com/ 9 | - React docs: https://facebook.github.io/react/docs/thinking-in-react.html 10 | - Egghead.io Videos: https://egghead.io/technologies/react 11 | - So many more... 12 | 13 | My goal with this project, is for it to serve as a practical example that goes beyond a basic TODO list. 14 | 15 | **Contributions, Feedback, and Code Review are welcome!** Please, feel free to reach out with any questions, suggestions for improvements, or ideally Issues and/or Pull Requests :) 16 | 17 | ## The App 18 | 19 | ``` 20 | # Install dependencies 21 | npm install 22 | 23 | # Start development servers (client + API) 24 | npm start 25 | 26 | # Run tests 27 | npm test 28 | ``` 29 | 30 | ![reactmaillistanimation](https://cloud.githubusercontent.com/assets/1240178/14709257/f80d4ec6-078c-11e6-95bc-63c5c2817da8.gif) 31 | 32 | Some Basic Features/Highlights. _Hint: these should become tests :)_ 33 | 34 | - Fetch data from JSON API (read) 35 | - Submit data to the JSON API (write) 36 | - Maintain local state 37 | - View a listing of messages 38 | - Text search 39 | - "Sort by" 40 | - Filter by "flagged" 41 | - "Load More" (i.e. pagination) 42 | - Toggle a message's "flagged" status 43 | - Delete a message 44 | - View a single message 45 | - Toggle a message's "flagged" status 46 | - Delete a message 47 | - Navigate directly to `/:id` route and have appropriate message requested 48 | - Redirect back to "messages" when `/:id` is not found OR is deleted 49 | - Animation via [ReactCSSTransitionGroup](https://facebook.github.io/react/docs/animation.html) 50 | 51 | ### React 52 | 53 | - [Stateless Function Components](https://facebook.github.io/react/docs/reusable-components.html#stateless-functions) 54 | - [React Class Components](https://facebook.github.io/react/docs/component-specs.html) 55 | - Reacting to state changes. 56 | - See [MessageBrowserContainer.componentDidUpdate](./src/components/MessageBrowser/MessageBrowserContainer.js) for an example. 57 | - Separating logic/http/state from presenation using "container" and "presentational" components 58 | - See [MessageBrowser.js](./src/components/MessageBrowser/MessageBrowser.js) and [MessageBrowserContainer.js](./src/components/MessageBrowser/MessageBrowserContainer.js) for an example. 59 | - Read: Dan Abromov's [Presentational and Container Components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.nxmg5vhby) 60 | - Read: [Container Components](https://medium.com/@learnreact/container-components-c0e67432e005#.o2nv78kp1) 61 | - Watch: [Egghead.io Video](https://egghead.io/lessons/react-increasing-reusability-with-react-container-components) 62 | - Illustrate by example that JSX is merely a light layer of sugar on top of Javascript function calls. 63 | - See [Header.js](./src/components/Header.js) for equivalent examples both with JSX and without JSX 64 | 65 | ### [React Router](https://github.com/reactjs/react-router) (2.x) 66 | 67 | - Setup basic routes [routes.js](./src/routes.js) 68 | - Utilize React component lifecycle to initiate our HTTP requests based on entry route 69 | 70 | ### Setting up a React, ES6+, Webpack, Babel Environment 71 | 72 | Just kidding. We'll let [create-react-app](https://github.com/facebookincubator/create-react-app) do all of that for us :) This way we don't get hung up on the myriad of ways we *could* go about this. 73 | 74 | A few commands to know: 75 | 76 | - `npm install`: Install dependencies 77 | - `npm start`: Start development server (open your browser to http://localhost:3000/ and you should see the app running) 78 | - `npm test`: Run tests. 79 | 80 | ### Communicating With a JSON API 81 | 82 | Using [axios](https://github.com/mzabriskie/axios), a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) based HTTP client, we communicate with a JSON API (powered by [json-server](https://github.com/typicode/json-server)) to: 83 | 84 | - Retrieve a resource listing (`GET /`) 85 | - Work with managing our "query parameters" for pagination, filtering, and search 86 | - Retrieve a single resource (`GET /:id`) 87 | - Update a single resource (`PATCH /:id`) 88 | - Destroy a single resource (`DELETE /:id`) 89 | 90 | ### Useful Libraries 91 | 92 | - [axios](https://github.com/mzabriskie/axios) HTTP client 93 | - [lodash](https://lodash.com/docs) general Javascript utility library 94 | - [classsnames](https://github.com/JedWatson/classnames) to make dynamic HTML classNames more pleasant 95 | 96 | ### Testing 97 | 98 | Running tests: `npm test` 99 | 100 | - Test runner: [jest](https://facebook.github.io/jest/) 101 | - Assertions (and spies): [expect](https://www.npmjs.com/package/expect) 102 | - Unit testing React components with shallow rendering [Enzyme](http://airbnb.io/enzyme/docs/api/shallow.html) 103 | 104 | Unit tests live directly adjacent to the file under test. Example: 105 | 106 | ``` 107 | src/some/path/someModule.js 108 | src/some/path/someModule.test.js 109 | ``` 110 | 111 | ### Some Visual Highlights 112 | 113 | ![image](https://cloud.githubusercontent.com/assets/1240178/14581298/7b94dd6c-03a6-11e6-9a2e-b083e7f01746.png) 114 | 115 | ![image](https://cloud.githubusercontent.com/assets/1240178/14581305/bccbd18c-03a6-11e6-9023-a71a0d2f7676.png) 116 | 117 | ![embracethejsx](https://cloud.githubusercontent.com/assets/1240178/14581308/ca584060-03a6-11e6-8155-e3dd735bfe66.png) 118 | -------------------------------------------------------------------------------- /json-server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const faker = require('faker'); 3 | const fakeBody = () => 4 | `${faker.company.catchPhrase()}. ${faker.company.catchPhrase()}. ${faker.lorem.paragraphs(faker.random.number({min: 2, max: 8}))}.`; 5 | 6 | const message = (props) => Object.assign({ 7 | from: `${faker.name.firstName()} ${faker.name.lastName()}`, 8 | fromAvatar: faker.image.avatar(), 9 | subject: faker.company.catchPhrase(), 10 | body: fakeBody(), 11 | flagged: faker.random.boolean(), 12 | sent: faker.date.past(1).toISOString(), 13 | }, props); 14 | 15 | const messages = []; 16 | for (let id = 1; id < 100; id++) 17 | messages.push(message({id})); 18 | 19 | 20 | function getInitialData() { 21 | return { 22 | messages, 23 | } 24 | } 25 | 26 | module.exports = getInitialData; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "faker": "^3.1.0", 7 | "json-server": "^0.8.9", 8 | "react-scripts": "0.7.0" 9 | }, 10 | "dependencies": { 11 | "axios": "~0.9.1", 12 | "classnames": "^2.2.3", 13 | "enzyme": "^2.6.0", 14 | "expect": "^1.20.2", 15 | "react": "~15.4.0", 16 | "react-addons-css-transition-group": "^15.4.0", 17 | "react-addons-test-utils": "^15.4.0", 18 | "react-dom": "~15.4.0", 19 | "react-router": "2.x" 20 | }, 21 | "scripts": { 22 | "start": "npm run json-server & react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject", 26 | "json-server": "json-server json-server.js --port=3001 --delay=500" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikthedeveloper/example-react-app-react-mail/f90be7b8e995452c33ac86907821b399e21a2792/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | React App 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import axios from 'axios'; 3 | import _ from 'lodash'; 4 | import { Layout } from './components/Layout'; 5 | 6 | /** 7 | * The top level App component! 8 | */ 9 | export const App = React.createClass({ 10 | 11 | getInitialState() { 12 | return { 13 | messages: [], 14 | // These are on the App level to persist between browser/detail navigation 15 | filterFlagged: false, 16 | searchText: '', 17 | sentOrder: 'DESC', 18 | page: 1, 19 | }; 20 | }, 21 | 22 | updateMessages(messages) { 23 | this.setState({messages}); 24 | }, 25 | 26 | updateFilterFlagged(filterFlagged) { 27 | this.setState({filterFlagged}); 28 | }, 29 | 30 | updateSearchText(searchText) { 31 | this.setState({searchText}); 32 | }, 33 | 34 | updateSentOrder(sentOrder) { 35 | this.setState({sentOrder}); 36 | }, 37 | 38 | updatePage(page) { 39 | this.setState({page}); 40 | }, 41 | 42 | /** 43 | * PATCH messages/:id - Toggle a message's flagged state. 44 | * @param id 45 | */ 46 | toggleMessageFlagged(id) { 47 | const message = _.find(this.state.messages, {id: Number(id)}); 48 | const flagged = !message.flagged; 49 | 50 | axios.patch(`messages/${id}`, {flagged}) 51 | .then(() => this.setState({ 52 | messages: this.state.messages 53 | .map(message => message.id !== id 54 | ? message 55 | : {...message, flagged} 56 | ) 57 | }) 58 | ); 59 | }, 60 | 61 | /** 62 | * DELETE messages/:id - Delete a message. 63 | * @param id 64 | */ 65 | deleteMessage(id) { 66 | axios.delete(`messages/${id}`) 67 | .then(() => this.setState({ 68 | messages: this.state.messages 69 | .filter(message => message.id !== id) 70 | })); 71 | }, 72 | 73 | render() { 74 | const childProps = { 75 | ...this.state, 76 | updateMessages: this.updateMessages, 77 | deleteMessage: this.deleteMessage, 78 | toggleMessageFlagged: this.toggleMessageFlagged, 79 | updateFilterFlagged: this.updateFilterFlagged, 80 | updateSearchText: this.updateSearchText, 81 | updateSentOrder: this.updateSentOrder, 82 | updatePage: this.updatePage, 83 | }; 84 | 85 | return ( 86 | 87 | {React.Children.map( 88 | this.props.children, 89 | child => React.cloneElement(child, childProps) 90 | )} 91 | 92 | ); 93 | } 94 | }); 95 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Embrace the JSX! It isn't magic. Just sugar. 3 | * See for yourself! 4 | * All 3 Header components below are equivalent. 5 | * 1) With JSX 6 | * 2) Without JSX 7 | * 3) Without JSX (using DOM helpers) 8 | * React must be within the Scope for JSX 9 | * http://facebook.github.io/react/docs/jsx-in-depth.html 10 | */ 11 | import React from 'react'; 12 | 13 | export const Header = ({title, subtitle}) => ( 14 |
15 |
16 |
17 |

18 | {title} 19 |

20 |

21 | {subtitle} 22 |

23 |
24 |
25 |
26 | ); 27 | 28 | /** 29 | * React.createElement({string|ReactClass} type, [{object} props], [children ...]) 30 | * http://facebook.github.io/react/docs/top-level-api.html#react.createelement 31 | * Example JSX -> JS 32 | * JSX:
Text
33 | * JS: React.createElement('div', {className: 'hero'}, 'Text') 34 | * Import createElement and shorten to el 35 | */ 36 | import { createElement as el } from 'react'; 37 | 38 | export const HeaderWithoutJsx = ({title, subtitle}) => ( 39 | el('section', {className: 'hero is-primary is-bold'}, 40 | el('div', {className: 'hero-content'}, 41 | el('div', {className: 'container'}, 42 | el('h1', {className: 'title'}, 43 | title 44 | ), 45 | el('h2', {className: 'subtitle'}, 46 | subtitle + ' Without JSX' 47 | ) 48 | ) 49 | ) 50 | ) 51 | ); 52 | 53 | /** 54 | * React.DOM provides convenience wrappers around React.createElement for DOM components. 55 | * http://facebook.github.io/react/docs/top-level-api.html#react.dom 56 | */ 57 | import { DOM } from 'react'; 58 | const {section, div, h1, h2} = DOM; 59 | 60 | export const HeaderWithDomHelpers = ({title, subtitle}) => ( 61 | section({className: 'hero is-primary is-bold'}, 62 | div({className: 'hero-content'}, 63 | div({className: 'container'}, 64 | h1({className: 'title'}, 65 | title 66 | ), 67 | h2({className: 'subtitle'}, 68 | subtitle + ' Without JSX (using DOM helpers)' 69 | ) 70 | ) 71 | ) 72 | ) 73 | ); 74 | -------------------------------------------------------------------------------- /src/components/Icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Icon = ({name, ...otherProps}) => ( 4 | 5 | 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Header } from './Header'; 3 | 4 | export const Layout = ({children}) => { 5 | return ( 6 |
7 |
11 |
12 |
13 | {children} 14 |
15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/MessageBrowser.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import { Sidebar } from './Sidebar'; 4 | import { SearchInput } from './SearchInput'; 5 | import { MessageList } from './MessageList'; 6 | 7 | export const MessageBrowser = (props) => { 8 | return ( 9 |
10 |
11 | 17 |
18 |
19 | 24 | 31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/MessageBrowserContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import axios from 'axios'; 3 | import _ from 'lodash'; 4 | import { MessageBrowser } from './MessageBrowser'; 5 | 6 | export const MessageBrowserContainer = React.createClass({ 7 | 8 | getInitialState() { 9 | return { 10 | loading: false, 11 | }; 12 | }, 13 | 14 | componentDidMount() { 15 | // Debounce requestMessages so we aren't submitting crazy requests on typing/etc...! 16 | this.requestMessages = _.debounce(this.requestMessages, 750, {leading: true}); 17 | const {props: {messages}, requestMessages} = this; 18 | if (messages.length <= 1) { 19 | requestMessages(); 20 | } 21 | }, 22 | 23 | componentDidUpdate(prevProps) { 24 | if (paramsChanged(this.props, prevProps)) { 25 | // If anything other than the pagination info changed, reset back to 1st page. 26 | if (this.props.page > 1 && this.props.page === prevProps.page) { 27 | this.props.updatePage(1); 28 | } else { 29 | this.requestMessages(); 30 | } 31 | } 32 | }, 33 | 34 | /** 35 | * GET /messages - Query string based on props. 36 | */ 37 | requestMessages() { 38 | this.setState({loading: true}); 39 | 40 | axios.get('messages', {params: propsToParams(this.props)}) 41 | .then(messages => { 42 | this.setState({loading: false}); 43 | const newMessages = this.props.page > 1 44 | ? [...this.props.messages, ...messages] 45 | : messages; 46 | this.props.updateMessages(newMessages); 47 | }) 48 | .catch(err => this.setState({loading: false})); 49 | }, 50 | 51 | loadMore() { 52 | this.props.updatePage(this.props.page + 1); 53 | }, 54 | 55 | render() { 56 | const props = { 57 | ...this.props, 58 | loadMore: this.loadMore, 59 | loading: this.state.loading, 60 | }; 61 | 62 | return ; 63 | }, 64 | }); 65 | 66 | /** 67 | * Build up query params from props. 68 | * @param props 69 | * @return string 70 | */ 71 | function propsToParams(props) { 72 | const {filterFlagged, sentOrder, searchText, page} = props; 73 | const pageSize = 5; 74 | const queryParams = { 75 | _start: (page - 1) * pageSize, 76 | _end: ((page - 1) * pageSize) + pageSize, 77 | _sort: 'sent', 78 | _order: sentOrder, 79 | }; 80 | if (filterFlagged) 81 | queryParams.flagged = true; 82 | if (searchText) 83 | queryParams.q = searchText.trim(); 84 | 85 | return queryParams; 86 | } 87 | 88 | /** 89 | * Check if queryParams have changed. 90 | * @param propsA 91 | * @param propsB 92 | * @return {boolean} 93 | */ 94 | function paramsChanged(propsA, propsB) { 95 | return !_.isEqual(propsToParams(propsB), propsToParams(propsA)) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/MessageList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 3 | import classnames from 'classnames'; 4 | import { MessageListItem } from './MessageListItem'; 5 | 6 | export const MessageList = (props) => { 7 | return ( 8 |
9 | 14 | {props.messages.map(message => ( 15 |
16 | props.deleteMessage(message.id)} 18 | toggleFlagged={() => props.toggleMessageFlagged(message.id)} 19 | message={message} 20 | /> 21 |
22 | ))} 23 |
24 |
25 |
26 | 32 |
33 |
34 | ) 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/MessageListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import { Link } from 'react-router'; 4 | import { Icon } from '../Icon'; 5 | 6 | const makeEventKiller = fn => event => { 7 | event.preventDefault(); 8 | event.stopPropagation(); 9 | fn(); 10 | }; 11 | 12 | /** 13 | * Get the difference between 2 dates in days. 14 | * @param {Date} before 15 | * @param {Date} after 16 | * @return {number} 17 | */ 18 | function humanizedDiff(before, after = new Date()) { 19 | const daysDiff = Math.round((after - before) / (24 * 60 * 60 * 1000)); 20 | return (daysDiff < 30) 21 | ? `${daysDiff} days` 22 | : `${Math.round(daysDiff / 30)} months`; 23 | } 24 | 25 | export const MessageListItem = (props) => { 26 | const { 27 | message, 28 | deleteMessage, 29 | toggleFlagged, 30 | } = props; 31 | 32 | const onClickFlag = makeEventKiller(toggleFlagged); 33 | const onClickDelete = makeEventKiller(deleteMessage); 34 | const onClickReply = makeEventKiller(() => alert(`Reply to ${message.from}`)); 35 | 36 | return ( 37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 | 46 | {message.subject} 47 | 48 |

49 | {`${message.body.substring(0, 100 )}...`} 50 |

51 |
52 | 53 | 54 | Reply to {message.from} 55 | 56 | {' ● '} 57 | {humanizedDiff(new Date(message.sent))} ago ● 58 | 59 | 60 |
61 |
62 |
63 | 67 |
68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | export const SearchInput = (props) => { 5 | const {loading, onChange, ...otherProps} = props; 6 | const updateValue = 7 | ({target: {value}}) => onChange(value); 8 | 9 | return ( 10 |
11 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/MessageBrowser/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Sidebar = (props) => { 4 | const { 5 | filterFlagged, 6 | updateFilterFlagged, 7 | sentOrder, 8 | updateSentOrder, 9 | } = props; 10 | 11 | return ( 12 |
13 |

14 | Sidebar Tools 15 |

16 | 17 | 20 |

21 | 22 | 29 | 30 |

31 | 32 | 35 | 36 |
37 | 45 |
46 | 47 |
48 | 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/MessageDetail/MessageDetail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import { Link } from 'react-router'; 4 | import { Icon } from '../Icon'; 5 | 6 | const btnClasses = "button is-outlined is-pulled-right"; 7 | 8 | export const MessageDetail = ({message, deleteMessage, toggleFlagged}) => { 9 | return ( 10 |
11 | 12 | Back 13 | 14 | 17 | 20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 |

29 | {message.from} 30 |

31 |

32 | {message.subject} 33 |

34 |

35 | {message.body} 36 |

37 |
38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/MessageDetail/MessageDetail.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import expect from 'expect'; 4 | 5 | import { MessageDetail } from './MessageDetail'; 6 | 7 | describe('Component: MessageDetail', () => { 8 | // Minimum required props to render w/o errors/warnings 9 | const minProps = { 10 | message: {}, 11 | deleteMessage: () => {}, 12 | toggleFlagged: () => {}, 13 | }; 14 | 15 | it('renders without exploding', () => { 16 | expect( 17 | shallow().length 18 | ).toEqual(1); 19 | }); 20 | 21 | it('links back to the message listing "search" page', () => { 22 | expect( 23 | shallow() 24 | .find('Link').props().to 25 | ).toEqual('/'); 26 | }); 27 | 28 | // Given props, render and pluck out the "Flag" button 29 | const renderToFlagButton = 30 | (props) => shallow() 31 | .find('button') 32 | .find({onClick: props.toggleFlagged}); 33 | 34 | it('indicates whether the message is "flagged"', () => { 35 | /** Given a message, assert that there is an "indicator"*/ 36 | const hasIndicator = (message) => 37 | renderToFlagButton({...minProps, message}) 38 | .hasClass('text-red'); 39 | 40 | expect(hasIndicator({flagged: true})).toEqual(true); 41 | expect(hasIndicator({flagged: false})).toEqual(false); 42 | }); 43 | 44 | it('toggles "flagged" status when flag button is clicked', () => { 45 | const props = { 46 | ...minProps, 47 | toggleFlagged: expect.createSpy(), 48 | }; 49 | renderToFlagButton(props) 50 | .simulate('click'); 51 | expect(props.toggleFlagged).toHaveBeenCalled(); 52 | }); 53 | 54 | it('deletes itself when the "delete" button is clicked', () => { 55 | const props = { 56 | ...minProps, 57 | deleteMessage: expect.createSpy(), 58 | }; 59 | shallow() 60 | .find('button') 61 | .find({onClick: props.deleteMessage}) 62 | .simulate('click'); 63 | expect(props.deleteMessage).toHaveBeenCalled(); 64 | }); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /src/components/MessageDetail/MessageDetailContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import axios from 'axios'; 3 | import _ from 'lodash'; 4 | import { MessageDetail } from './MessageDetail'; 5 | 6 | export const MessageDetailContainer = React.createClass({ 7 | 8 | contextTypes: { 9 | router: React.PropTypes.object, 10 | }, 11 | 12 | getInitialState() { 13 | return { 14 | loading: false, 15 | }; 16 | }, 17 | 18 | componentDidMount() { 19 | const {props: {params}, requestMessage} = this; 20 | if (!routeMessage(this.props)) { 21 | requestMessage(params.id); 22 | } 23 | }, 24 | 25 | componentWillReceiveProps(nextProps) { 26 | // We got our route message! Is this the ideal solution/pattern? 27 | if (this.props.messages.length === 0 && nextProps.messages.length > 0) { 28 | this.setState({loading: false}); 29 | } 30 | }, 31 | 32 | componentDidUpdate(prevProps, prevState) { 33 | // Based on "before" and "now", should we redirect? 34 | const shouldRedirect = 35 | // If we don't have it now... 36 | !routeMessage(this.props) && 37 | ( 38 | // ...and we had it before (deleted) 39 | routeMessage(prevProps) || 40 | // ...or we couldn't retrieve it 41 | (prevState.loading && !this.state.loading) 42 | ); 43 | 44 | if (shouldRedirect) { 45 | this.context.router.push('/'); 46 | } 47 | }, 48 | 49 | deleteMessage() { 50 | const {params: {id}, deleteMessage} = this.props; 51 | deleteMessage(Number(id)); 52 | }, 53 | 54 | toggleFlagged() { 55 | const {params: {id}, toggleMessageFlagged} = this.props; 56 | toggleMessageFlagged(Number(id)); 57 | }, 58 | 59 | /** 60 | * GET /messages/:id 61 | */ 62 | requestMessage(id) { 63 | this.setState({loading: true}); 64 | 65 | axios.get(`messages/${id}`) 66 | .then(message => this.props.updateMessages([message])) 67 | .catch(err => this.setState({loading: false})); 68 | }, 69 | 70 | render() { 71 | const message = routeMessage(this.props); 72 | 73 | if (this.props.loading || !message) { 74 | return ( 75 |
Loading...
76 | ); 77 | } 78 | 79 | const props = { 80 | message, 81 | deleteMessage: this.deleteMessage, 82 | toggleFlagged: this.toggleFlagged, 83 | }; 84 | 85 | return ; 86 | }, 87 | }); 88 | 89 | /** 90 | * Pluck "active" message for route out of props 91 | * @param props 92 | * @return {{}|bool} 93 | */ 94 | function routeMessage(props) { 95 | return _.find(props.messages, { 96 | id: Number(props.params.id), 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import axios from 'axios'; 4 | import { routes } from './routes'; 5 | import './styles/general.css'; 6 | import './styles/animations.css'; 7 | 8 | // Some basic axios setup. 9 | axios.defaults.baseURL = 'http://localhost:3001'; 10 | const responseToBody = ({data}) => data; 11 | // Convert all response directly to data/JSON 12 | axios.interceptors.response.use(responseToBody); 13 | 14 | render(routes, document.querySelector('#root')); 15 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route, IndexRoute, browserHistory } from 'react-router'; 3 | import { App } from './App'; 4 | import { MessageBrowserContainer } from './components/MessageBrowser/MessageBrowserContainer'; 5 | import { MessageDetailContainer } from './components/MessageDetail/MessageDetailContainer'; 6 | 7 | export const routes = ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/styles/animations.css: -------------------------------------------------------------------------------- 1 | .animated-list-item-enter, .animated-list-item-leave { 2 | transition: all 250ms ease-in; 3 | } 4 | 5 | /* Entering Start */ 6 | .animated-list-item-enter { 7 | height: 0; 8 | opacity: 0; 9 | } 10 | 11 | /* Entering Complete */ 12 | .animated-list-item-enter-active { 13 | height: 110px; 14 | opacity: 1; 15 | } 16 | 17 | /* Leaving Start */ 18 | .animated-list-item-leave { 19 | height: 110px; 20 | opacity: 1; 21 | } 22 | 23 | /* Leaving Complete */ 24 | .animated-list-item-leave-active { 25 | height: 0; 26 | opacity: 0; 27 | } 28 | -------------------------------------------------------------------------------- /src/styles/general.css: -------------------------------------------------------------------------------- 1 | .flex-center-all { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | } 6 | 7 | .icon { 8 | font-size: inherit; 9 | } 10 | 11 | .icon, 12 | .clickable { 13 | cursor: pointer; 14 | } 15 | 16 | .message-list-item { 17 | margin-top: 0 !important; 18 | padding-bottom: 10px; 19 | overflow: hidden; 20 | } 21 | 22 | .text-red { 23 | color: red; 24 | } 25 | 26 | .text-red:hover { 27 | color: #D00000; 28 | } 29 | 30 | .button.is-loading:focus { 31 | color: transparent; 32 | } 33 | 34 | body, html { 35 | background-color: #FFF; 36 | } 37 | --------------------------------------------------------------------------------