├── .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 | 
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 | 
114 |
115 | 
116 |
117 | 
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 |
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 |
--------------------------------------------------------------------------------