├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── better-observable-solution ├── .babelrc ├── README.md ├── actions │ └── index.js ├── api │ ├── friends.js │ └── index.js ├── components │ ├── FriendList.js │ ├── FriendThumbnail.js │ └── SearchInput.js ├── constants │ └── actionTypes.js ├── containers │ └── FriendSearchView.js ├── index.html ├── index.js ├── package.json ├── reducers │ └── index.js ├── server.js ├── store │ └── configureStore.js └── webpack.config.js ├── cyclejs-snabbdom-solution ├── .babelrc ├── .eslintrc ├── README.md ├── index.html ├── loader.css ├── package.json └── src │ ├── api │ ├── friends.js │ └── index.js │ ├── app.js │ ├── components │ ├── FriendList.js │ └── SearchInput.js │ ├── drivers │ └── fetch.js │ └── main.js ├── cyclejs-solution ├── .babelrc ├── README.md ├── api │ ├── friends.js │ └── index.js ├── components │ ├── FriendList.js │ ├── FriendThumbnail.js │ └── SearchInput.js ├── containers │ ├── App.js │ └── FriendSearchView.js ├── drivers │ ├── fetch.js │ ├── history.js │ └── view.js ├── index.html ├── index.js ├── package.json ├── server.js └── webpack.config.js ├── friendlist.gif ├── imperative-solution ├── .babelrc ├── README.md ├── actions │ └── index.js ├── api │ ├── friends.js │ └── index.js ├── components │ ├── FriendList.js │ ├── FriendThumbnail.js │ └── SearchInput.js ├── constants │ └── actionTypes.js ├── containers │ └── FriendSearchView.js ├── index.html ├── index.js ├── package.json ├── reducers │ └── index.js ├── server.js ├── store │ └── configureStore.js └── webpack.config.js ├── motorcyclejs-solution ├── .babelrc ├── .eslintrc ├── README.md ├── index.html ├── loader.css ├── package.json └── src │ ├── api │ ├── friends.js │ └── index.js │ ├── app.js │ ├── components │ ├── FriendList.js │ └── SearchInput.js │ ├── drivers │ └── fetch.js │ └── main.js ├── package.json ├── part-observable-solution ├── .babelrc ├── README.md ├── actions │ └── index.js ├── api │ ├── friends.js │ └── index.js ├── components │ ├── FriendList.js │ ├── FriendThumbnail.js │ └── SearchInput.js ├── constants │ └── actionTypes.js ├── containers │ └── FriendSearchView.js ├── index.html ├── index.js ├── package.json ├── reducers │ └── index.js ├── server.js ├── store │ └── configureStore.js └── webpack.config.js └── redux-saga-solution ├── .babelrc ├── README.md ├── actions └── index.js ├── api ├── friends.js └── index.js ├── components ├── FriendList.js ├── FriendThumbnail.js └── SearchInput.js ├── constants └── actionTypes.js ├── containers ├── App.js └── FriendSearchView.js ├── index.html ├── index.js ├── package.json ├── reducers └── index.js ├── sagaMonitor └── index.js ├── sagas └── index.js ├── server.js ├── store └── configureStore.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/static/* 2 | **/node_modules/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | static 3 | npm-debug.log 4 | .DS_Store 5 | IDEAS.md 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Friend List 2 | A non-trivial (yet simple) front-end programming challenge. Featuring solutions in [React](https://facebook.github.io/react/), [Redux](http://redux.js.org), [Redux-Saga](https://github.com/yelouafi/redux-saga), [Cycle.js](http://cycle.js.org), [Motorcycle.js](https://github.com/motorcyclejs/core) and [Snabbdom](https://github.com/paldepind/snabbdom). 3 | 4 | Check out the Elm solution [here](https://github.com/DerekCuevas/friend-list-elm)! 5 | 6 | ![alt tag](friendlist.gif) 7 | 8 | ## The Problem 9 | Create an app with a dynamic and search-able list of data that keeps a search input text query in sync with the URL via a query parameter at all times. Assume the data will be fetched from some API and the API will perform the actual search. The query should be a simple string and kept in sync with the URL via a query parameter 'q' (ex. localhost:3000/?q=batman). 10 | 11 | This problem is harder than it first appears, actions must be managed in the correct order, and if not can result in infinite loops and other undesirable behavior. 12 | 13 | ## The Spec 14 | - Hit the API **once and only once** per query change. 15 | - When the query updates -> update the URL and fetch results from the API. 16 | - When the URL updates -> update the query and fetch results from the API. 17 | - The browser's back / forward buttons should keep the app state (query + results) in sync with the URL (this is a gotcha if not thought about carefully). 18 | 19 | #### Bonus Features 20 | - Handle the concurrent actions issue (see the [redux-saga-solution](redux-saga-solution/), the [cyclejs-solution](cyclejs-solution/), the [motorcyclejs-solution](motorcyclejs-solution/), and the [better-observable-solution](better-observable-solution/)) - "If the user changes the query input while there is still a pending request from a previous query change, the current pending request should be cancelled and a new request should be made." - Thanks [@yelouafi](https://github.com/yelouafi) 21 | - Debounce the fetching of results by 100ms. 22 | - Log any state changing action with the newly changed state. 23 | - Add loading and/or error states (see the redux-meta-reducer [friend-list](https://github.com/DerekCuevas/redux-meta-reducer/tree/master/examples/friend-list) example). 24 | 25 | ## Solutions 26 | Solutions are in their own subdirectories above. Check out the README files in each of the subdirectories for example specific details. 27 | 28 | Many have similar structures (identical store state + hitting the same mock API). The difference being when and where the apps read router state and when and where the apps dispatch actions. 29 | 30 | #### To run 31 | First clone the repo. 32 | 33 | ```sh 34 | git clone https://github.com/DerekCuevas/friend-list.git 35 | ``` 36 | 37 | Then cd into an example, 'npm install' and 'npm start' to get going. 38 | 39 | ```sh 40 | cd friend-list/imperative-solution/ # or the others 41 | npm install 42 | npm start 43 | ``` 44 | 45 | ## Contributors 46 | - [redux-saga-solution](redux-saga-solution/) - [@yelouafi](https://github.com/yelouafi) 47 | - [cyclejs-solution](cyclejs-solution/) - [@justinwoo](https://github.com/justinwoo) 48 | - [motorcyclejs-solution](motorcyclejs-solution/), [cyclejs-snabbdom-solution](cyclejs-snabbdom-solution/) - [@TylorS](https://github.com/TylorS) 49 | 50 | #### Have a better implementation? 51 | Please make an issue or send in a pull request. 52 | -------------------------------------------------------------------------------- /better-observable-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /better-observable-solution/README.md: -------------------------------------------------------------------------------- 1 | # Better "observable" approach: 2 | This approach builds on the patterns used in the part "observable" approach, with significant simplifications being made to the react container components. 3 | 4 | Dispatching side effects of updating the URL (via the browser's back/forward buttons) are managed with [history's](https://github.com/mjackson/history) history.listen() method. 5 | 6 | ```javascript 7 | history.listen(location => { 8 | if (location.action === 'POP') { 9 | store.dispatch(setQuery(location.query.q)); 10 | } 11 | }); 12 | ``` 13 | (see - [index.js](index.js#L36)) 14 | 15 | The solution doesn't need react-router as none of the react components need to read router state. Compare this with the part-observable-solution's container component [FriendSearchView](../part-observable-solution/containers/FriendSearchView.js) where 'componentDidMount' and 'componentWillReceiveProps' are needed to implement query changes on route transitions. 16 | 17 | ## Bonus Features 18 | This solution solves the bonus problem of handling the concurrent actions issue. It does so using [redux-thunk](https://github.com/gaearon/redux-thunk), however instead of cancelling requests the solution ignores responses that would put the store (query + results) in an inconsistent state. 19 | 20 | ```javascript 21 | export function fetchFriends(history) { 22 | return (dispatch, getState) => { 23 | const { query } = getState(); 24 | 25 | dispatch(requestFriends()); 26 | 27 | search(query).then(friends => { 28 | const { query: currentQuery } = getState(); 29 | 30 | if (query === currentQuery) { 31 | history.push({ 32 | query: { q: query || undefined }, 33 | }); 34 | 35 | dispatch(receiveFriends(friends)); 36 | } 37 | }); 38 | }; 39 | } 40 | ``` 41 | (see - [actions/index.js](actions/index.js#L24)) 42 | 43 | The disposing of responses ensures a consistent state between the current query and the current results. This state can occur when responses arrive in a different order than they were requested. (This is mocked by setting a random timeout in the mock api [search function](api/index.js#L22).) 44 | 45 | Finally, this solution debounces fetching of friends from the mock API by 100ms. It does so by binding dispatch to the fetchFriends action. 46 | 47 | ```javascript 48 | const fetch = debounce( 49 | store.dispatch.bind(undefined, fetchFriends(history)), 50 | 100 51 | ); 52 | ``` 53 | (see - [index.js](index.js#L20)) 54 | 55 | Refer to \*/index.js and \*/containers/FriendSearchView.js for the difference in approach between this example, the [part-observable-solution](../part-observable-solution), and the [imperative-solution](../imperative-solution). 56 | 57 | ## To run 58 | ```sh 59 | npm install 60 | npm start 61 | ``` 62 | -------------------------------------------------------------------------------- /better-observable-solution/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | import search from '../api'; 3 | 4 | export function setQuery(query = '') { 5 | return { 6 | type: types.SET_QUERY, 7 | query, 8 | }; 9 | } 10 | 11 | export function requestFriends() { 12 | return { 13 | type: types.REQUEST_FRIENDS, 14 | }; 15 | } 16 | 17 | export function receiveFriends(friends = []) { 18 | return { 19 | type: types.RECEIVE_FRIENDS, 20 | friends, 21 | }; 22 | } 23 | 24 | export function fetchFriends(history) { 25 | return (dispatch, getState) => { 26 | const { query } = getState(); 27 | 28 | dispatch(requestFriends()); 29 | 30 | search(query).then(friends => { 31 | const { query: currentQuery } = getState(); 32 | 33 | if (query === currentQuery) { 34 | history.push({ 35 | query: { q: query || undefined }, 36 | }); 37 | 38 | dispatch(receiveFriends(friends)); 39 | } 40 | }); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /better-observable-solution/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman', 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman', 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto', 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic', 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX', 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor', 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing', 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch', 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad', 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan', 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /better-observable-solution/api/index.js: -------------------------------------------------------------------------------- 1 | import friends from './friends'; 2 | 3 | // mock api search 4 | export default function search(query) { 5 | const results = friends.filter(friend => { 6 | let keep = false; 7 | 8 | Object.keys(friend).forEach(key => { 9 | const val = friend[key].toString(); 10 | 11 | if (val.toLowerCase().includes(query.toLowerCase())) { 12 | keep = true; 13 | } 14 | }); 15 | 16 | return keep; 17 | }); 18 | 19 | // setting a more realistic (random) timeout 20 | return new Promise((resolve) => { 21 | // increase timeout to ~500 to see more responses being disposed 22 | setTimeout(() => resolve(results), Math.ceil(Math.random() * 250)); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /better-observable-solution/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FriendThumbnail from './FriendThumbnail'; 3 | 4 | const propTypes = { 5 | isFetching: PropTypes.bool, 6 | friends: PropTypes.arrayOf(PropTypes.shape({ 7 | id: PropTypes.number.isRequired, 8 | username: PropTypes.string.isRequired, 9 | name: PropTypes.string.isRequired, 10 | })), 11 | }; 12 | 13 | const defaultProps = { 14 | isFetching: false, 15 | friends: [], 16 | }; 17 | 18 | function FriendList({ isFetching, friends }) { 19 | return ( 20 | 27 | ); 28 | } 29 | 30 | FriendList.propTypes = propTypes; 31 | FriendList.defaultProps = defaultProps; 32 | 33 | export default FriendList; 34 | -------------------------------------------------------------------------------- /better-observable-solution/components/FriendThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | name: PropTypes.string, 5 | username: PropTypes.string, 6 | }; 7 | 8 | function FriendThumbnail({ name, username }) { 9 | return ( 10 |
11 |

{name} {username}

12 |
13 | ); 14 | } 15 | 16 | FriendThumbnail.propTypes = propTypes; 17 | export default FriendThumbnail; 18 | -------------------------------------------------------------------------------- /better-observable-solution/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const ENTER_KEYCODE = 13; 4 | 5 | const propTypes = { 6 | value: PropTypes.string, 7 | handleSearch: PropTypes.func.isRequired, 8 | }; 9 | 10 | const defaultProps = { 11 | value: '', 12 | }; 13 | 14 | function SearchInput(props) { 15 | const { value, handleSearch } = props; 16 | 17 | const onChange = (e) => handleSearch(e.target.value); 18 | const onKeyDown = (e) => { 19 | if (e.keyCode === ENTER_KEYCODE) { 20 | handleSearch(e.target.value); 21 | } 22 | }; 23 | 24 | return ( 25 | 33 | ); 34 | } 35 | 36 | SearchInput.propTypes = propTypes; 37 | SearchInput.defaultProps = defaultProps; 38 | 39 | export default SearchInput; 40 | -------------------------------------------------------------------------------- /better-observable-solution/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_QUERY = 'SET_QUERY'; 2 | export const REQUEST_FRIENDS = 'REQUEST_FRIENDS'; 3 | export const RECEIVE_FRIENDS = 'RECEIVE_FRIENDS'; 4 | -------------------------------------------------------------------------------- /better-observable-solution/containers/FriendSearchView.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | 5 | import SearchInput from '../components/SearchInput'; 6 | import FriendList from '../components/FriendList'; 7 | import { setQuery } from '../actions'; 8 | 9 | const propTypes = { 10 | handleSearch: PropTypes.func.isRequired, 11 | isFetching: PropTypes.bool, 12 | query: PropTypes.string, 13 | friends: PropTypes.array, 14 | }; 15 | 16 | const defaultProps = { 17 | isFetching: false, 18 | query: '', 19 | friends: [], 20 | }; 21 | 22 | function FriendSearchView({ isFetching, query, friends, handleSearch }) { 23 | return ( 24 |
25 | 30 | 34 |
35 | ); 36 | } 37 | 38 | FriendSearchView.propTypes = propTypes; 39 | FriendSearchView.defaultProps = defaultProps; 40 | 41 | function mapDispatchToProps(dispatch) { 42 | return { 43 | handleSearch: bindActionCreators(setQuery, dispatch), 44 | }; 45 | } 46 | 47 | export default connect(state => state, mapDispatchToProps)(FriendSearchView); 48 | -------------------------------------------------------------------------------- /better-observable-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 79 | 80 | 81 | 82 |
83 |
84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /better-observable-solution/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { createHistory, useQueries } from 'history'; 6 | import debounce from 'lodash.debounce'; 7 | 8 | import configureStore from './store/configureStore'; 9 | 10 | import { setQuery, fetchFriends } from './actions'; 11 | import FriendSearchView from './containers/FriendSearchView'; 12 | 13 | const history = useQueries(createHistory)(); 14 | const store = configureStore(); 15 | 16 | store.subscribe((() => { 17 | let prevState = undefined; 18 | 19 | const fetch = debounce( 20 | store.dispatch.bind(undefined, fetchFriends(history)), 21 | 100 22 | ); 23 | 24 | return () => { 25 | const state = store.getState(); 26 | 27 | if (!prevState || (prevState.query !== state.query)) { 28 | fetch(); 29 | } 30 | 31 | prevState = state; 32 | }; 33 | })()); 34 | 35 | history.listen(location => { 36 | if (location.action === 'POP') { 37 | store.dispatch(setQuery(location.query.q)); 38 | } 39 | }); 40 | 41 | ReactDOM.render( 42 | 43 | 44 | , 45 | document.getElementById('root') 46 | ); 47 | -------------------------------------------------------------------------------- /better-observable-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Derek Cuevas", 10 | "license": "MIT", 11 | "dependencies": { 12 | "babel-polyfill": "^6.7.4", 13 | "express": "^4.13.4", 14 | "history": "^2.0.0", 15 | "lodash.debounce": "^4.0.5", 16 | "react": "^15.0.1", 17 | "react-dom": "^15.0.1", 18 | "react-redux": "^4.4.4", 19 | "redux": "^3.4.0", 20 | "redux-thunk": "^2.0.1" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^6.7.6", 24 | "babel-eslint": "^6.0.2", 25 | "babel-loader": "^6.2.2", 26 | "babel-preset-es2015": "^6.3.13", 27 | "babel-preset-react": "^6.3.13", 28 | "babel-preset-react-hmre": "^1.1.0", 29 | "redux-logger": "^2.5.0", 30 | "webpack": "^1.12.15", 31 | "webpack-dev-middleware": "^1.6.1", 32 | "webpack-hot-middleware": "^2.6.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /better-observable-solution/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | const initialState = { 4 | isFetching: false, 5 | query: '', 6 | friends: [], 7 | }; 8 | 9 | export default function friendListReducer(state = initialState, action) { 10 | switch (action.type) { 11 | 12 | case types.SET_QUERY: 13 | return Object.assign({}, state, { 14 | query: action.query, 15 | }); 16 | 17 | case types.REQUEST_FRIENDS: 18 | return Object.assign({}, state, { 19 | isFetching: true, 20 | }); 21 | 22 | case types.RECEIVE_FRIENDS: 23 | return Object.assign({}, state, { 24 | isFetching: false, 25 | friends: action.friends, 26 | }); 27 | 28 | default: 29 | return state; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /better-observable-solution/server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackDevMiddleware = require('webpack-dev-middleware'); 3 | const webpackHotMiddleware = require('webpack-hot-middleware'); 4 | const config = require('./webpack.config'); 5 | 6 | const app = new (require('express'))(); 7 | const port = 3000; 8 | const compiler = webpack(config); 9 | 10 | app.use(webpackDevMiddleware(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath 13 | })); 14 | app.use(webpackHotMiddleware(compiler)); 15 | 16 | app.get('/', (req, res) => { 17 | res.sendFile(__dirname + '/index.html'); 18 | }); 19 | 20 | app.listen(port, error => { 21 | if (error) { 22 | console.error(error); 23 | } else { 24 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /better-observable-solution/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import createLogger from 'redux-logger'; 4 | import reducer from '../reducers'; 5 | 6 | const middleware = [thunk, createLogger()]; 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore(reducer, initialState, applyMiddleware(...middleware)); 10 | 11 | if (module.hot) { 12 | module.hot.accept('../reducers', () => { 13 | const nextReducer = require('../reducers').default; 14 | store.replaceReducer(nextReducer); 15 | }); 16 | } 17 | 18 | return store; 19 | } 20 | -------------------------------------------------------------------------------- /better-observable-solution/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index', 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/', 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | ], 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.js$/, 24 | loaders: ['babel'], 25 | exclude: /node_modules/, 26 | include: __dirname, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-cycle", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "quotes": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/README.md: -------------------------------------------------------------------------------- 1 | # Friend List - Cycle.js Solution 2 | 3 | This is an example [Cycle.js](https://github.com/cyclejs) solution, but using 4 | the [cycle-snabbdom](https://github.com/tylors/cycle-snabbdom) for rendering. 5 | All side-effects take place in what we know as `drivers`, at the very edge of 6 | our applications to keep our `main()` function pure. All functionality is out 7 | of the box with the only exception of the 'fetch' driver to grab from the mock 8 | API. 9 | 10 | ### To run 11 | ```shell 12 | $ npm install 13 | $ npm start 14 | ``` 15 | Open your browser to [http://localhost:3474](http://localhost:3474) 16 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 | 79 |
80 | Rendering... 81 |
82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/loader.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | z-index: 100000; 3 | margin: 60px auto; 4 | font-size: 10px; 5 | position: relative; 6 | text-indent: -9999em; 7 | border-top: 1.1em solid rgba(50, 50, 255, 0.9); 8 | border-right: 1.1em solid rgba(50, 50, 255, 0.9); 9 | border-bottom: 1.1em solid rgba(50, 50, 255, 0.9); 10 | border-left: 1.1em solid #ffffff; 11 | -webkit-transform: translateZ(0); 12 | -ms-transform: translateZ(0); 13 | transform: translateZ(0); 14 | -webkit-animation: load8 1.1s infinite linear; 15 | animation: load8 1.1s infinite linear; 16 | } 17 | .loader, 18 | .loader:after { 19 | border-radius: 50%; 20 | width: 10em; 21 | height: 10em; 22 | } 23 | @-webkit-keyframes load8 { 24 | 0% { 25 | -webkit-transform: rotate(0deg); 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | -webkit-transform: rotate(360deg); 30 | transform: rotate(360deg); 31 | } 32 | } 33 | @keyframes load8 { 34 | 0% { 35 | -webkit-transform: rotate(0deg); 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | -webkit-transform: rotate(360deg); 40 | transform: rotate(360deg); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle-snabbdom-solution", 3 | "version": "0.0.0", 4 | "description": "A motorcyclejs solution to fried-list problem ", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rimraf dist/ && mkdirp dist/ && browserify -t babelify src/app.js -o dist/app.js", 8 | "server": "superstatic ./", 9 | "start": "npm run build && npm run server", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Tylor Steinberger (github.com/TylorS)", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@cycle/core": "^6.0.3", 16 | "@cycle/history": "^2.0.1", 17 | "cycle-snabbdom": "^1.2.1", 18 | "history": "^2.0.1", 19 | "rx": "^4.1.0" 20 | }, 21 | "devDependencies": { 22 | "babel-eslint": "^6.0.2", 23 | "babel-preset-es2015": "^6.6.0", 24 | "babelify": "^7.2.0", 25 | "browserify": "^13.0.0", 26 | "eslint": "^1.10.3", 27 | "eslint-config-cycle": "^3.2.0", 28 | "eslint-plugin-cycle": "^1.0.2", 29 | "eslint-plugin-no-class": "^0.1.0", 30 | "mkdirp": "^0.5.1", 31 | "rimraf": "^2.5.2", 32 | "superstatic": "^4.0.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman', 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman', 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto', 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic', 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX', 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor', 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing', 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch', 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad', 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan', 51 | }, 52 | ] 53 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/api/index.js: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rx' 2 | import friends from './friends' 3 | 4 | // mock api search 5 | export default function search(query = ``) { 6 | const results = friends.filter(friend => { 7 | const keys = Object.keys(friend) 8 | // faster search 9 | for (let i = 0; i < keys.length; ++i) { 10 | const val = friend[keys[i]].toString().toLowerCase() 11 | if (val.includes(query.toLowerCase())) { 12 | return true 13 | } 14 | } 15 | return false 16 | }) 17 | 18 | // use an stream for our search API so that it's actually cancellable when we dispose of our subscription 19 | return Observable.create((observer) => { 20 | const timeout = setTimeout(() => { 21 | console.log(`RESOLVING search ${timeout}`) 22 | observer.onNext(results) 23 | }, Math.ceil(100 + Math.random() * 500)) // make delay longer to make cancellation and loading screen obvious 24 | 25 | observer.onNext('loading') // send 'loading' state 26 | console.log(`STARTING search ${timeout}`) 27 | 28 | return () => { 29 | console.log(`DISPOSING search ${timeout}`) 30 | clearTimeout(timeout) 31 | } 32 | }).startWith(friends) 33 | } 34 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/app.js: -------------------------------------------------------------------------------- 1 | import {run} from '@cycle/core' 2 | import {makeDOMDriver} from 'cycle-snabbdom' 3 | import {makeHistoryDriver, supportsHistory} from '@cycle/history' 4 | import {createHistory, createHashHistory, useQueries} from 'history' 5 | 6 | import fetchDriver from './drivers/fetch' 7 | 8 | import main from './main' 9 | 10 | const history = supportsHistory() ? 11 | useQueries(createHistory)() : 12 | useQueries(createHashHistory)() 13 | 14 | run(main, { 15 | DOM: makeDOMDriver('.container'), 16 | history: makeHistoryDriver(history), 17 | fetch: fetchDriver, 18 | }) 19 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rx' 2 | import {ul, li, div, h1, h4, span} from 'cycle-snabbdom' 3 | 4 | const style = { 5 | zIndex: '0', 6 | opacity: 0, 7 | margin: '2em', 8 | transition: 'all 0.3s ease-in-out', 9 | delayed: { 10 | opacity: 1, 11 | }, 12 | remove: { 13 | opacity: 0, 14 | } 15 | } 16 | 17 | function Friend({name, username}) { 18 | return div('.friend-thumbnail', [ 19 | h4([name, span('.username', [username])]), 20 | ]) 21 | } 22 | 23 | const ulStyle = (isLoading) => ({ 24 | margin: '2em', 25 | opacity: isLoading ? '0.2' : '1', 26 | transition: 'all 0.3s ease-in-out', 27 | }) 28 | 29 | const liStyle = (i) => ({ 30 | zIndex: '-1', 31 | margin: '0 auto', 32 | textAlign: 'center', 33 | transition: 'all 0.6s ease-in-out', 34 | transform: `translateY(0px) rotateX(-90deg)`, 35 | height: '30px', 36 | opacity: 0, 37 | delayed: { 38 | opacity: 1, 39 | transform: `translateY(${i * 15}px) rotateX(0deg)` 40 | } 41 | }) 42 | 43 | function renderFriendList(friends, isLoading) { 44 | return ul('.friend-list', {static: true, style: ulStyle(isLoading)}, 45 | friends.map( 46 | (friend, i) => 47 | li({key: friend.id, style: liStyle(i)}, [ Friend(friend) ]) 48 | ) 49 | ) 50 | } 51 | 52 | function renderNoMatches(friends) { 53 | return friends.length > 0 ? 54 | null: h1('No matches could be found, try again!') 55 | } 56 | 57 | const loadingStyle = Object.assign(style, { 58 | position: 'fixed', 59 | top: '0', 60 | bottom: '0', 61 | left: '0', 62 | right: '0', 63 | display: 'flex', 64 | alignItems: 'center', 65 | justifyContent: 'center', 66 | backgroundColor: 'rgba(21, 21, 21, 0.8)', 67 | color: 'white', 68 | margin: '0', 69 | transition: 'all 0.2s ease-in-out', 70 | }) 71 | 72 | function renderLoading(isLoading) { 73 | return isLoading ? 74 | div({style: loadingStyle}, [ div('.loader', {}, [])]) : 75 | null 76 | } 77 | 78 | function view(friends, isLoading) { 79 | return div({style: {marginTop: '2em'}}, [ 80 | renderLoading(isLoading), 81 | friends.length > 0 ? 82 | renderFriendList(friends, isLoading) : 83 | renderNoMatches(friends) 84 | ]) 85 | } 86 | 87 | export default friend$ => { 88 | const friendList$ = friend$.filter(Array.isArray).share() 89 | const loading$ = friend$.filter(friends => friends === 'loading').map(true) 90 | const isLoading$ = loading$.merge(friendList$.map(false)) 91 | .startWith(false) 92 | .share() 93 | .debounce(20) 94 | 95 | return friendList$.combineLatest(isLoading$, view) 96 | } 97 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import {input} from 'cycle-snabbdom' 2 | 3 | const style = { 4 | position: 'absolute', 5 | zIndex: '1000', 6 | left: '25%', 7 | width: '50%', 8 | } 9 | 10 | function view(query$) { 11 | return query$.map(value => 12 | input('#search-input', { 13 | static: true, 14 | props: {value, type: 'search'}, 15 | style, 16 | }) 17 | ) 18 | } 19 | 20 | function SearchInput({DOM, history}) { 21 | const searchValue$ = DOM.select('#search-input') 22 | .events('input') 23 | .map(evt => evt.target.value) 24 | .debounce(100) // debounce 100 milliseconds 25 | 26 | const query$ = history.map(({query: {q}}) => q || '') 27 | 28 | return { 29 | DOM: view(query$), 30 | searchValue$, 31 | } 32 | } 33 | 34 | export default SearchInput 35 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/drivers/fetch.js: -------------------------------------------------------------------------------- 1 | import search from '../api' 2 | export default query$ => query$.map(search).switch() 3 | -------------------------------------------------------------------------------- /cyclejs-snabbdom-solution/src/main.js: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rx' 2 | import {div, hr} from 'cycle-snabbdom' 3 | 4 | import SearchInput from './components/SearchInput' 5 | import FriendList from './components/FriendList' 6 | 7 | const style = { 8 | display: 'flex', 9 | justifyContent: 'center', 10 | alignItems: 'center', 11 | } 12 | 13 | function view(searchInput, friendList) { 14 | return div('.app', {style}, [ 15 | searchInput, 16 | friendList, 17 | ]) 18 | } 19 | 20 | function main(sources) { 21 | const searchInput = SearchInput(sources) 22 | 23 | const view$ = Observable.combineLatest( 24 | searchInput.DOM, 25 | FriendList(sources.fetch), 26 | view 27 | ) 28 | 29 | return { 30 | DOM: view$, 31 | history: searchInput.searchValue$.map(q => q && {query: {q}} || '/' ), 32 | fetch: sources.history.map(({query}) => query.q), 33 | } 34 | } 35 | 36 | export default main 37 | -------------------------------------------------------------------------------- /cyclejs-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cyclejs-solution/README.md: -------------------------------------------------------------------------------- 1 | # Cycle.js approach 2 | 3 | This branch contains a Cycle.js solution that reuses most of the components defined for the redux apps, but uses RxJS for much simpler code. All effects in our system are also constrained to driver definitions, so we can be sure that all requests for fetching data come in through the input stream of our fetch driver and all history actions go through the history driver. 4 | 5 | This also accomplishes the bonus objective of cancelling search requests by using an Observable for the search call, and `flatMapLatest` to only use the newest non-resolved query. 6 | 7 | ## To run 8 | ```sh 9 | npm install 10 | npm start 11 | ``` 12 | -------------------------------------------------------------------------------- /cyclejs-solution/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman' 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman' 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto' 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic' 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX' 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor' 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing' 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch' 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad' 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan' 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /cyclejs-solution/api/index.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rx'; 2 | 3 | import friends from './friends'; 4 | 5 | // mock api search 6 | export default function search(query = '') { 7 | const results = friends.filter(friend => { 8 | let keep = false; 9 | 10 | Object.keys(friend).forEach(key => { 11 | const val = friend[key].toString(); 12 | 13 | if (val.toLowerCase().includes(query.toLowerCase())) { 14 | keep = true; 15 | } 16 | }); 17 | 18 | return keep; 19 | }); 20 | 21 | // use an observable for our search API so that it's actually cancellable when we dispose of our subscription 22 | return Observable.create(observer => { 23 | const timeout = setTimeout(() => { 24 | console.log(`RESOLVING search ${timeout}`); 25 | observer.onNext(results) 26 | }, Math.ceil(100 + Math.random() * 250)); // make delay longer to make cancellation more obvious 27 | 28 | console.log(`STARTING search ${timeout}`); 29 | 30 | return () => { 31 | console.log(`DISPOSING search ${timeout}`); 32 | clearTimeout(timeout) 33 | }; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /cyclejs-solution/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FriendThumbnail from './FriendThumbnail'; 3 | 4 | const propTypes = { 5 | friends: PropTypes.arrayOf(PropTypes.shape({ 6 | id: PropTypes.number.isRequired, 7 | username: PropTypes.string.isRequired, 8 | name: PropTypes.string.isRequired 9 | })) 10 | }; 11 | 12 | const defaultProps = { 13 | friends: [] 14 | }; 15 | 16 | const FriendList = ({ friends }) => ( 17 | 24 | ); 25 | 26 | FriendList.propTypes = propTypes; 27 | FriendList.defaultProps = defaultProps; 28 | 29 | export default FriendList; 30 | -------------------------------------------------------------------------------- /cyclejs-solution/components/FriendThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | name: PropTypes.string, 5 | username: PropTypes.string 6 | }; 7 | 8 | const FriendThumbnail = ({ name, username }) => ( 9 |
10 |

{name} {username}

11 |
12 | ); 13 | 14 | FriendThumbnail.propTypes = propTypes; 15 | export default FriendThumbnail; 16 | -------------------------------------------------------------------------------- /cyclejs-solution/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | handleSearchChange: PropTypes.func.isRequired, 5 | handleSearchKeyDown: PropTypes.func.isRequired 6 | }; 7 | 8 | class SearchInput extends Component { 9 | render() { 10 | return ( 11 | 19 | ); 20 | } 21 | } 22 | 23 | SearchInput.propTypes = propTypes; 24 | 25 | export default SearchInput; 26 | -------------------------------------------------------------------------------- /cyclejs-solution/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | children: PropTypes.object.isRequired 5 | }; 6 | 7 | const App = ({ children }) => ( 8 |
{children}
9 | ); 10 | 11 | App.propTypes = propTypes; 12 | export default App; 13 | -------------------------------------------------------------------------------- /cyclejs-solution/containers/FriendSearchView.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import SearchInput from '../components/SearchInput'; 4 | import FriendList from '../components/FriendList'; 5 | 6 | const propTypes = { 7 | searchChange$: PropTypes.object.isRequired, 8 | searchKeyDown$: PropTypes.object.isRequired, 9 | query: PropTypes.string, 10 | friends: PropTypes.array 11 | }; 12 | 13 | class FriendSearchView extends Component { 14 | constructor(props, context) { 15 | super(props, context); 16 | this.handleSearchChange = (e) => this.props.searchChange$.onNext(e); 17 | this.handleSearchKeyDown = (e) => this.props.searchKeyDown$.onNext(e); 18 | } 19 | 20 | render() { 21 | const { query, friends } = this.props; 22 | 23 | return ( 24 |
25 | 31 | 32 |
33 | ); 34 | } 35 | } 36 | 37 | FriendSearchView.propTypes = propTypes; 38 | 39 | export default FriendSearchView; 40 | -------------------------------------------------------------------------------- /cyclejs-solution/drivers/fetch.js: -------------------------------------------------------------------------------- 1 | import search from '../api'; 2 | 3 | export default function fetchDriver(query$) { 4 | // for each query item, create a new observable to pass downstream 5 | // our search observable will be active until the next query comes in, 6 | // when we will unsubscribe and dispose of the previous search observable 7 | return query$.flatMapLatest(x => search(x)); 8 | } 9 | -------------------------------------------------------------------------------- /cyclejs-solution/drivers/history.js: -------------------------------------------------------------------------------- 1 | import { browserHistory } from 'react-router'; 2 | import { ReplaySubject } from 'rx'; 3 | 4 | export default function historyDriver(userQuery$) { 5 | const newQuery$ = new ReplaySubject(1); 6 | 7 | userQuery$.subscribe(query => { 8 | browserHistory.push({ 9 | query: { q: query || undefined } 10 | }); 11 | }); 12 | 13 | browserHistory.listen(location => { 14 | if (location.action === 'POP') { 15 | newQuery$.onNext(location.query.q); 16 | } 17 | }); 18 | 19 | return newQuery$; 20 | } 21 | -------------------------------------------------------------------------------- /cyclejs-solution/drivers/view.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ReplaySubject } from 'rx'; 4 | 5 | import App from '../containers/App'; 6 | import FriendSearchView from '../containers/FriendSearchView'; 7 | 8 | export default function viewDriver(state$) { 9 | const searchChange$ = new ReplaySubject(1); 10 | const searchKeyDown$ = new ReplaySubject(1); 11 | 12 | state$.subscribe(state => { 13 | ReactDOM.render( 14 | 15 | 22 | , 23 | document.getElementById('root') 24 | ); 25 | }); 26 | 27 | return { 28 | searchChange$, 29 | searchKeyDown$ 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cyclejs-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 |
79 | Rendering... 80 |
81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /cyclejs-solution/index.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rx'; 2 | import { run } from '@cycle/core'; 3 | 4 | import fetchDriver from './drivers/fetch'; 5 | import viewDriver from './drivers/view'; 6 | import historyDriver from './drivers/history'; 7 | 8 | const drivers = { 9 | fetch: fetchDriver, 10 | view: viewDriver, 11 | history: historyDriver 12 | }; 13 | 14 | function main(drivers) { 15 | const initialState = { 16 | query: '', 17 | friends: [] 18 | }; 19 | 20 | const userQuery$ = drivers.view.searchChange$.map(e => e.target.value); 21 | 22 | const query$ = Observable 23 | .merge( 24 | userQuery$, 25 | drivers.history 26 | ); 27 | 28 | const requestFetch$ = Observable 29 | .merge( 30 | drivers.view.searchKeyDown$.map(e => e.keyCode === 13), 31 | query$ 32 | ); 33 | 34 | const fetchRequest$ = query$ 35 | .sample(requestFetch$) 36 | .distinctUntilChanged(); 37 | 38 | const state$ = Observable 39 | .merge( 40 | query$.map(query => ({ query })), 41 | drivers.fetch.map(friends => ({ friends })) 42 | ) 43 | .startWith(initialState) 44 | .scan((state, partial) => Object.assign({}, state, partial)); 45 | 46 | return { 47 | fetch: fetchRequest$, 48 | history: userQuery$, 49 | view: state$ 50 | } 51 | } 52 | 53 | run(main, drivers); 54 | -------------------------------------------------------------------------------- /cyclejs-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Derek Cuevas", 10 | "license": "MIT", 11 | "dependencies": { 12 | "express": "^4.13.4", 13 | "react": "^0.14.7", 14 | "react-dom": "^0.14.7" 15 | }, 16 | "devDependencies": { 17 | "@cycle/core": "^6.0.2", 18 | "babel-core": "^6.4.5", 19 | "babel-eslint": "^4.1.8", 20 | "babel-loader": "^6.2.2", 21 | "babel-preset-es2015": "^6.3.13", 22 | "babel-preset-react": "^6.3.13", 23 | "babel-preset-react-hmre": "^1.1.0", 24 | "eslint": "^1.10.3", 25 | "eslint-config-reactjs": "^1.0.0", 26 | "eslint-plugin-objects": "^1.1.1", 27 | "eslint-plugin-react": "^3.16.1", 28 | "react-router": "^2.0.0", 29 | "rimraf": "^2.5.1", 30 | "rx": "^4.0.8", 31 | "webpack": "^1.12.13", 32 | "webpack-dev-middleware": "^1.5.1", 33 | "webpack-hot-middleware": "^2.6.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cyclejs-solution/server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackDevMiddleware = require('webpack-dev-middleware'); 3 | const webpackHotMiddleware = require('webpack-hot-middleware'); 4 | const config = require('./webpack.config'); 5 | 6 | const app = new (require('express'))(); 7 | const port = 3000; 8 | const compiler = webpack(config); 9 | 10 | app.use(webpackDevMiddleware(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath 13 | })); 14 | app.use(webpackHotMiddleware(compiler)); 15 | 16 | app.get('/', (req, res) => { 17 | res.sendFile(__dirname + '/index.html'); 18 | }); 19 | 20 | app.listen(port, error => { 21 | if (error) { 22 | console.error(error); 23 | } else { 24 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /cyclejs-solution/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index' 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/' 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin() 19 | ], 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.js$/, 24 | loaders: [ 'babel' ], 25 | exclude: /node_modules/, 26 | include: __dirname 27 | } 28 | ] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /friendlist.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DerekCuevas/friend-list/9f6cd8852d40cdf49f64300a2427a81cd7cbfe0c/friendlist.gif -------------------------------------------------------------------------------- /imperative-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /imperative-solution/README.md: -------------------------------------------------------------------------------- 1 | # Imperative approach: 2 | Currently one of the most straightforward solutions. Logic for dispatching actions and reading router state is all contained in react components. The most "imperative" of the examples. 3 | 4 | Refer to \*/index.js and \*/containers/FriendSearchView.js for the difference in approach between this example, the [better-observable-solution](../better-observable-solution), and the [part-observable-solution](../part-observable-solution). 5 | 6 | ## To run 7 | ```sh 8 | npm install 9 | npm start 10 | ``` 11 | -------------------------------------------------------------------------------- /imperative-solution/actions/index.js: -------------------------------------------------------------------------------- 1 | import { browserHistory } from 'react-router'; 2 | import * as types from '../constants/actionTypes'; 3 | import search from '../api'; 4 | 5 | export function setQuery(query = '') { 6 | return { 7 | type: types.SET_QUERY, 8 | query, 9 | }; 10 | } 11 | 12 | export function setFriends(friends = []) { 13 | return { 14 | type: types.SET_FRIENDS, 15 | friends, 16 | }; 17 | } 18 | 19 | export function fetchFriends() { 20 | return (dispatch, getState) => { 21 | const { query } = getState(); 22 | 23 | browserHistory.push({ 24 | query: { q: query || undefined }, 25 | }); 26 | 27 | search(query).then(friends => { 28 | dispatch(setFriends(friends)); 29 | }); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /imperative-solution/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman', 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman', 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto', 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic', 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX', 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor', 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing', 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch', 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad', 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan', 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /imperative-solution/api/index.js: -------------------------------------------------------------------------------- 1 | import friends from './friends'; 2 | 3 | // mock api search 4 | export default function search(query) { 5 | const results = friends.filter(friend => { 6 | let keep = false; 7 | 8 | Object.keys(friend).forEach(key => { 9 | const val = friend[key].toString(); 10 | 11 | if (val.toLowerCase().includes(query.toLowerCase())) { 12 | keep = true; 13 | } 14 | }); 15 | 16 | return keep; 17 | }); 18 | 19 | // setting a more realistic (random) timeout 20 | return new Promise((resolve) => { 21 | setTimeout(() => resolve(results), Math.ceil(Math.random() * 250)); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /imperative-solution/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FriendThumbnail from './FriendThumbnail'; 3 | 4 | const propTypes = { 5 | friends: PropTypes.arrayOf(PropTypes.shape({ 6 | id: PropTypes.number.isRequired, 7 | username: PropTypes.string.isRequired, 8 | name: PropTypes.string.isRequired, 9 | })), 10 | }; 11 | 12 | const defaultProps = { 13 | friends: [], 14 | }; 15 | 16 | function FriendList({ friends }) { 17 | return ( 18 | 25 | ); 26 | } 27 | 28 | FriendList.propTypes = propTypes; 29 | FriendList.defaultProps = defaultProps; 30 | 31 | export default FriendList; 32 | -------------------------------------------------------------------------------- /imperative-solution/components/FriendThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | name: PropTypes.string, 5 | username: PropTypes.string, 6 | }; 7 | 8 | function FriendThumbnail({ name, username }) { 9 | return ( 10 |
11 |

{name} {username}

12 |
13 | ); 14 | } 15 | 16 | FriendThumbnail.propTypes = propTypes; 17 | export default FriendThumbnail; 18 | -------------------------------------------------------------------------------- /imperative-solution/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const ENTER_KEYCODE = 13; 4 | 5 | const propTypes = { 6 | value: PropTypes.string, 7 | handleSearch: PropTypes.func.isRequired, 8 | }; 9 | 10 | const defaultProps = { 11 | value: '', 12 | }; 13 | 14 | function SearchInput(props) { 15 | const { value, handleSearch } = props; 16 | 17 | const onChange = (e) => handleSearch(e.target.value); 18 | const onKeyDown = (e) => { 19 | if (e.keyCode === ENTER_KEYCODE) { 20 | handleSearch(e.target.value); 21 | } 22 | }; 23 | 24 | return ( 25 | 33 | ); 34 | } 35 | 36 | SearchInput.propTypes = propTypes; 37 | SearchInput.defaultProps = defaultProps; 38 | 39 | export default SearchInput; 40 | -------------------------------------------------------------------------------- /imperative-solution/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_QUERY = 'SET_QUERY'; 2 | export const SET_FRIENDS = 'SET_FRIENDS'; 3 | -------------------------------------------------------------------------------- /imperative-solution/containers/FriendSearchView.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import SearchInput from '../components/SearchInput'; 5 | import FriendList from '../components/FriendList'; 6 | import { setQuery, fetchFriends } from '../actions'; 7 | 8 | const propTypes = { 9 | dispatch: PropTypes.func.isRequired, 10 | location: PropTypes.object.isRequired, 11 | query: PropTypes.string, 12 | friends: PropTypes.array, 13 | }; 14 | 15 | const defaultProps = { 16 | query: '', 17 | friends: [], 18 | }; 19 | 20 | class FriendSearchView extends Component { 21 | constructor(props, context) { 22 | super(props, context); 23 | this.handleSearch = this.handleSearch.bind(this); 24 | } 25 | 26 | // fetch on page load 27 | componentDidMount() { 28 | this.fetchFromLocation(this.props.location); 29 | } 30 | 31 | // needed to fetch on back/forward, 32 | componentWillReceiveProps({ location }) { 33 | if (location.action === 'POP') { 34 | this.fetchFromLocation(location); 35 | } 36 | } 37 | 38 | fetchFromLocation({ query: { q } }) { 39 | this.handleSearch(q); 40 | } 41 | 42 | handleSearch(value) { 43 | const { dispatch } = this.props; 44 | 45 | dispatch(setQuery(value)); 46 | dispatch(fetchFriends()); 47 | } 48 | 49 | render() { 50 | const { query, friends } = this.props; 51 | 52 | return ( 53 |
54 | 59 | 60 |
61 | ); 62 | } 63 | } 64 | 65 | FriendSearchView.propTypes = propTypes; 66 | FriendSearchView.defaultProps = defaultProps; 67 | 68 | export default connect(state => state)(FriendSearchView); 69 | -------------------------------------------------------------------------------- /imperative-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /imperative-solution/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { Router, Route, IndexRoute, browserHistory } from 'react-router'; 6 | 7 | import configureStore from './store/configureStore'; 8 | import FriendSearchView from './containers/FriendSearchView'; 9 | 10 | const store = configureStore(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | , 20 | document.getElementById('root') 21 | ); 22 | -------------------------------------------------------------------------------- /imperative-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Derek Cuevas", 10 | "license": "MIT", 11 | "dependencies": { 12 | "babel-polyfill": "^6.7.4", 13 | "express": "^4.13.4", 14 | "react": "^15.0.1", 15 | "react-dom": "^15.0.1", 16 | "react-redux": "^4.4.4", 17 | "react-router": "^2.1.1", 18 | "redux": "^3.4.0", 19 | "redux-thunk": "^2.0.1" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.7.6", 23 | "babel-eslint": "^6.0.2", 24 | "babel-loader": "^6.2.2", 25 | "babel-preset-es2015": "^6.3.13", 26 | "babel-preset-react": "^6.3.13", 27 | "babel-preset-react-hmre": "^1.1.0", 28 | "redux-logger": "^2.5.0", 29 | "webpack": "^1.12.15", 30 | "webpack-dev-middleware": "^1.6.1", 31 | "webpack-hot-middleware": "^2.6.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /imperative-solution/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | const initialState = { 4 | query: '', 5 | friends: [], 6 | }; 7 | 8 | export default function friendListReducer(state = initialState, action) { 9 | switch (action.type) { 10 | 11 | case types.SET_QUERY: 12 | return Object.assign({}, state, { 13 | query: action.query, 14 | }); 15 | 16 | case types.SET_FRIENDS: 17 | return Object.assign({}, state, { 18 | friends: action.friends, 19 | }); 20 | 21 | default: 22 | return state; 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /imperative-solution/server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackDevMiddleware = require('webpack-dev-middleware'); 3 | const webpackHotMiddleware = require('webpack-hot-middleware'); 4 | const config = require('./webpack.config'); 5 | 6 | const app = new (require('express'))(); 7 | const port = 3000; 8 | const compiler = webpack(config); 9 | 10 | app.use(webpackDevMiddleware(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath 13 | })); 14 | app.use(webpackHotMiddleware(compiler)); 15 | 16 | app.get('/', (req, res) => { 17 | res.sendFile(__dirname + '/index.html'); 18 | }); 19 | 20 | app.listen(port, error => { 21 | if (error) { 22 | console.error(error); 23 | } else { 24 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /imperative-solution/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import createLogger from 'redux-logger'; 4 | import reducer from '../reducers'; 5 | 6 | const middleware = [thunk, createLogger()]; 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore(reducer, initialState, applyMiddleware(...middleware)); 10 | 11 | if (module.hot) { 12 | module.hot.accept('../reducers', () => { 13 | const nextReducer = require('../reducers').default; 14 | store.replaceReducer(nextReducer); 15 | }); 16 | } 17 | 18 | return store; 19 | } 20 | -------------------------------------------------------------------------------- /imperative-solution/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index', 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/', 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | ], 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.js$/, 24 | loaders: ['babel'], 25 | exclude: /node_modules/, 26 | include: __dirname, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /motorcyclejs-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /motorcyclejs-solution/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-cycle", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "quotes": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /motorcyclejs-solution/README.md: -------------------------------------------------------------------------------- 1 | # Friend List - Motorcycle.js Solution 2 | 3 | This is an example [Motorcycle.js](https://github.com/motorcyclejs) solution. 4 | All side-effects take place in what we know as `drivers`, at the very edge of 5 | our applications to keep our `main()` function pure. All functionality is out 6 | of the box with the only exception of the 'fetch' driver to grab from the mock 7 | API. 8 | 9 | ### To run 10 | ```shell 11 | $ npm install 12 | $ npm start 13 | ``` 14 | Open your browser to [http://localhost:3474](http://localhost:3474) 15 | -------------------------------------------------------------------------------- /motorcyclejs-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 | 79 |
80 | Rendering... 81 |
82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /motorcyclejs-solution/loader.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | z-index: 100000; 3 | margin: 60px auto; 4 | font-size: 10px; 5 | position: relative; 6 | text-indent: -9999em; 7 | border-top: 1.1em solid rgba(50, 50, 255, 0.9); 8 | border-right: 1.1em solid rgba(50, 50, 255, 0.9); 9 | border-bottom: 1.1em solid rgba(50, 50, 255, 0.9); 10 | border-left: 1.1em solid #ffffff; 11 | -webkit-transform: translateZ(0); 12 | -ms-transform: translateZ(0); 13 | transform: translateZ(0); 14 | -webkit-animation: load8 1.1s infinite linear; 15 | animation: load8 1.1s infinite linear; 16 | } 17 | .loader, 18 | .loader:after { 19 | border-radius: 50%; 20 | width: 10em; 21 | height: 10em; 22 | } 23 | @-webkit-keyframes load8 { 24 | 0% { 25 | -webkit-transform: rotate(0deg); 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | -webkit-transform: rotate(360deg); 30 | transform: rotate(360deg); 31 | } 32 | } 33 | @keyframes load8 { 34 | 0% { 35 | -webkit-transform: rotate(0deg); 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | -webkit-transform: rotate(360deg); 40 | transform: rotate(360deg); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /motorcyclejs-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motorcyclejs-solution", 3 | "version": "0.0.0", 4 | "description": "A motorcyclejs solution to fried-list problem ", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rimraf dist/ && mkdirp dist/ && browserify -t babelify src/app.js -o dist/app.js", 8 | "server": "superstatic ./", 9 | "start": "npm run build && npm run server", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Tylor Steinberger (github.com/TylorS)", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@motorcycle/core": "^1.2.1", 16 | "@motorcycle/dom": "^1.4.0", 17 | "@motorcycle/history": "^2.1.2", 18 | "history": "^2.0.1", 19 | "most": "^0.18.8" 20 | }, 21 | "devDependencies": { 22 | "babel-eslint": "^5.0.0", 23 | "babel-preset-es2015": "^6.5.0", 24 | "babelify": "^7.2.0", 25 | "browserify": "^13.0.0", 26 | "eslint": "^1.10.3", 27 | "eslint-config-cycle": "^3.2.0", 28 | "eslint-plugin-cycle": "^1.0.2", 29 | "eslint-plugin-no-class": "^0.1.0", 30 | "http-server": "^0.9.0", 31 | "mkdirp": "^0.5.1", 32 | "rimraf": "^2.5.2", 33 | "superstatic": "^4.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman', 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman', 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto', 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic', 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX', 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor', 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing', 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch', 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad', 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan', 51 | }, 52 | ] 53 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/api/index.js: -------------------------------------------------------------------------------- 1 | import {create} from 'most' 2 | import friends from './friends' 3 | 4 | // mock api search 5 | export default function search(query = ``) { 6 | const results = friends.filter(friend => { 7 | const keys = Object.keys(friend) 8 | // faster search 9 | for (let i = 0; i < keys.length; ++i) { 10 | const val = friend[keys[i]].toString().toLowerCase() 11 | if (val.includes(query.toLowerCase())) { 12 | return true 13 | } 14 | } 15 | return false 16 | }) 17 | 18 | // use an stream for our search API so that it's actually cancellable when we dispose of our subscription 19 | return create((next) => { 20 | const timeout = setTimeout(() => { 21 | console.log(`RESOLVING search ${timeout}`) 22 | next(results) 23 | }, Math.ceil(100 + Math.random() * 500)) // make delay longer to make cancellation and loading screen obvious 24 | 25 | next('loading') // send 'loading' state 26 | console.log(`STARTING search ${timeout}`) 27 | 28 | return () => { 29 | console.log(`DISPOSING search ${timeout}`) 30 | clearTimeout(timeout) 31 | } 32 | }).startWith(friends) 33 | } 34 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/app.js: -------------------------------------------------------------------------------- 1 | import {run} from '@motorcycle/core' 2 | import {makeDOMDriver} from '@motorcycle/dom' 3 | import {makeHistoryDriver, supportsHistory} from '@motorcycle/history' 4 | import {createHistory, createHashHistory, useQueries} from 'history' 5 | 6 | import fetchDriver from './drivers/fetch' 7 | 8 | import main from './main' 9 | 10 | const history = supportsHistory() ? 11 | useQueries(createHistory)() : 12 | useQueries(createHashHistory)() 13 | 14 | run(main, { 15 | DOM: makeDOMDriver('.container'), 16 | history: makeHistoryDriver(history), 17 | fetch: fetchDriver, 18 | }) 19 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import {combineArray} from 'most' 2 | import {ul, li, div, h1, h4, span} from '@motorcycle/dom' 3 | 4 | const style = { 5 | zIndex: '0', 6 | opacity: 0, 7 | margin: '2em', 8 | transition: 'all 0.3s ease-in-out', 9 | delayed: { 10 | opacity: 1, 11 | }, 12 | remove: { 13 | opacity: 0, 14 | } 15 | } 16 | 17 | function Friend({name, username}) { 18 | return div('.friend-thumbnail', [ 19 | h4([name, span('.username', [username])]), 20 | ]) 21 | } 22 | 23 | const ulStyle = (isLoading) => ({ 24 | margin: '2em', 25 | opacity: isLoading ? '0.2' : '1', 26 | transition: 'all 0.3s ease-in-out', 27 | }) 28 | 29 | const liStyle = (i) => ({ 30 | zIndex: '-1', 31 | margin: '0 auto', 32 | textAlign: 'center', 33 | transition: 'all 0.6s ease-in-out', 34 | transform: `translateY(0px) rotateX(-90deg)`, 35 | height: '30px', 36 | opacity: 0, 37 | delayed: { 38 | opacity: 1, 39 | transform: `translateY(${i * 15}px) rotateX(0deg)` 40 | } 41 | }) 42 | 43 | function renderFriendList(friends, isLoading) { 44 | return ul('.friend-list', {static: true, style: ulStyle(isLoading)}, 45 | friends.map( 46 | (friend, i) => 47 | li({key: friend.id, style: liStyle(i)}, [ Friend(friend) ]) 48 | ) 49 | ) 50 | } 51 | 52 | function renderNoMatches() { 53 | return div({style}, [ 54 | h1('No matches could be found, try again!') 55 | ]) 56 | } 57 | 58 | const loadingStyle = Object.assign(style, { 59 | position: 'fixed', 60 | top: '0', 61 | bottom: '0', 62 | left: '0', 63 | right: '0', 64 | display: 'flex', 65 | alignItems: 'center', 66 | justifyContent: 'center', 67 | backgroundColor: 'rgba(21, 21, 21, 0.9)', 68 | color: 'white', 69 | margin: '0', 70 | transition: 'all 0.2s ease-in-out', 71 | }) 72 | 73 | function renderLoading() { 74 | return div({style: loadingStyle}, [ 75 | div('.loader') 76 | ]) 77 | } 78 | 79 | function view(friends, isLoading) { 80 | return div({style: {marginTop: '2em'}}, [ 81 | isLoading ? renderLoading() : null, 82 | friends.length > 0 ? 83 | renderFriendList(friends, isLoading) : 84 | renderNoMatches() 85 | ]) 86 | } 87 | 88 | export default friend$ => { 89 | const friendList$ = friend$.filter(Array.isArray).multicast() 90 | const isLoading$ = friend$.filter(friends => friends === 'loading') 91 | .map(() => true) 92 | .merge(friendList$.map(() => false)) 93 | .startWith(false) 94 | 95 | return combineArray(view, [friendList$, isLoading$]) 96 | } 97 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import {input} from '@motorcycle/dom' 2 | 3 | const style = { 4 | position: 'absolute', 5 | zIndex: '1000', 6 | left: '25%', 7 | width: '50%', 8 | } 9 | 10 | function view(query$) { 11 | return query$.map(value => 12 | input('#search-input', { 13 | static: true, 14 | props: {value, type: 'search'}, 15 | style, 16 | }) 17 | ) 18 | } 19 | 20 | function SearchInput({DOM, history}) { 21 | const searchValue$ = DOM.select('#search-input') 22 | .events('input') 23 | .map(evt => evt.target.value) 24 | .debounce(100) // debounce 100 milliseconds 25 | 26 | const query$ = history.map(({query: {q}}) => q || '') 27 | 28 | return { 29 | DOM: view(query$), 30 | searchValue$, 31 | } 32 | } 33 | 34 | export default SearchInput 35 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/drivers/fetch.js: -------------------------------------------------------------------------------- 1 | import search from '../api' 2 | export default query$ => query$.map(search).switch() 3 | -------------------------------------------------------------------------------- /motorcyclejs-solution/src/main.js: -------------------------------------------------------------------------------- 1 | import {combineArray} from 'most' 2 | import {div, hr} from '@motorcycle/dom' 3 | 4 | import SearchInput from './components/SearchInput' 5 | import FriendList from './components/FriendList' 6 | 7 | const style = { 8 | display: 'flex', 9 | justifyContent: 'center', 10 | alignItems: 'center', 11 | } 12 | 13 | function view(searchInput, friendList) { 14 | return div('.app', {style}, [ 15 | searchInput, 16 | friendList, 17 | ]) 18 | } 19 | 20 | function main(sources) { 21 | const searchInput = SearchInput(sources) 22 | 23 | const view$ = combineArray(view, [ 24 | searchInput.DOM, 25 | FriendList(sources.fetch) 26 | ]) 27 | 28 | return { 29 | DOM: view$, 30 | history: searchInput.searchValue$.map(q => q && {query: {q}} || '/' ), 31 | fetch: sources.history.map(({query}) => query.q), 32 | } 33 | } 34 | 35 | export default main 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "author": "Derek Cuevas", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "babel-eslint": "^4.1.8", 10 | "eslint": "^2.3.0", 11 | "eslint-config-airbnb": "^6.1.0", 12 | "eslint-plugin-react": "^4.2.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /part-observable-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /part-observable-solution/README.md: -------------------------------------------------------------------------------- 1 | # Part "observable" approach: 2 | This solution takes advantage of redux's implementation of the 'observer pattern' and makes fetching results from the API (via dispatching the fetchFriends action) a side effect of updating the query. 3 | 4 | Logic for dispatching these side effects are managed with redux's store.subscribe(). 5 | 6 | ```javascript 7 | store.subscribe((() => { 8 | let prevState = undefined; 9 | 10 | return () => { 11 | const state = store.getState(); 12 | 13 | if (!prevState || (prevState.query !== state.query)) { 14 | store.dispatch(fetchFriends()); 15 | } 16 | 17 | prevState = state; 18 | }; 19 | })()); 20 | ``` 21 | (see - [index.js](index.js#L15)) 22 | 23 | Refer to \*/index.js and \*/containers/FriendSearchView.js for the difference in approach between this example, the [imperative-solution](../imperative-solution), and the [better-observable-solution](../better-observable-solution). 24 | 25 | ## To run 26 | ```sh 27 | npm install 28 | npm start 29 | ``` 30 | -------------------------------------------------------------------------------- /part-observable-solution/actions/index.js: -------------------------------------------------------------------------------- 1 | import { browserHistory } from 'react-router'; 2 | import * as types from '../constants/actionTypes'; 3 | import search from '../api'; 4 | 5 | export function setQuery(query = '') { 6 | return { 7 | type: types.SET_QUERY, 8 | query, 9 | }; 10 | } 11 | 12 | export function setFriends(friends = []) { 13 | return { 14 | type: types.SET_FRIENDS, 15 | friends, 16 | }; 17 | } 18 | 19 | export function fetchFriends() { 20 | return (dispatch, getState) => { 21 | const { query } = getState(); 22 | 23 | browserHistory.push({ 24 | query: { q: query || undefined }, 25 | }); 26 | 27 | search(query).then(friends => { 28 | dispatch(setFriends(friends)); 29 | }); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /part-observable-solution/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman', 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman', 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto', 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic', 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX', 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor', 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing', 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch', 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad', 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan', 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /part-observable-solution/api/index.js: -------------------------------------------------------------------------------- 1 | import friends from './friends'; 2 | 3 | // mock api search 4 | export default function search(query) { 5 | const results = friends.filter(friend => { 6 | let keep = false; 7 | 8 | Object.keys(friend).forEach(key => { 9 | const val = friend[key].toString(); 10 | 11 | if (val.toLowerCase().includes(query.toLowerCase())) { 12 | keep = true; 13 | } 14 | }); 15 | 16 | return keep; 17 | }); 18 | 19 | // setting a more realistic (random) timeout 20 | return new Promise((resolve) => { 21 | setTimeout(() => resolve(results), Math.ceil(Math.random() * 250)); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /part-observable-solution/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FriendThumbnail from './FriendThumbnail'; 3 | 4 | const propTypes = { 5 | friends: PropTypes.arrayOf(PropTypes.shape({ 6 | id: PropTypes.number.isRequired, 7 | username: PropTypes.string.isRequired, 8 | name: PropTypes.string.isRequired, 9 | })), 10 | }; 11 | 12 | const defaultProps = { 13 | friends: [], 14 | }; 15 | 16 | function FriendList({ friends }) { 17 | return ( 18 | 25 | ); 26 | } 27 | 28 | FriendList.propTypes = propTypes; 29 | FriendList.defaultProps = defaultProps; 30 | 31 | export default FriendList; 32 | -------------------------------------------------------------------------------- /part-observable-solution/components/FriendThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | name: PropTypes.string, 5 | username: PropTypes.string, 6 | }; 7 | 8 | function FriendThumbnail({ name, username }) { 9 | return ( 10 |
11 |

{name} {username}

12 |
13 | ); 14 | } 15 | 16 | FriendThumbnail.propTypes = propTypes; 17 | export default FriendThumbnail; 18 | -------------------------------------------------------------------------------- /part-observable-solution/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const ENTER_KEYCODE = 13; 4 | 5 | const propTypes = { 6 | value: PropTypes.string, 7 | handleSearch: PropTypes.func.isRequired, 8 | }; 9 | 10 | const defaultProps = { 11 | value: '', 12 | }; 13 | 14 | function SearchInput(props) { 15 | const { value, handleSearch } = props; 16 | 17 | const onChange = (e) => handleSearch(e.target.value); 18 | const onKeyDown = (e) => { 19 | if (e.keyCode === ENTER_KEYCODE) { 20 | handleSearch(e.target.value); 21 | } 22 | }; 23 | 24 | return ( 25 | 33 | ); 34 | } 35 | 36 | SearchInput.propTypes = propTypes; 37 | SearchInput.defaultProps = defaultProps; 38 | 39 | export default SearchInput; 40 | -------------------------------------------------------------------------------- /part-observable-solution/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_QUERY = 'SET_QUERY'; 2 | export const SET_FRIENDS = 'SET_FRIENDS'; 3 | -------------------------------------------------------------------------------- /part-observable-solution/containers/FriendSearchView.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import SearchInput from '../components/SearchInput'; 5 | import FriendList from '../components/FriendList'; 6 | import { setQuery } from '../actions'; 7 | 8 | const propTypes = { 9 | dispatch: PropTypes.func.isRequired, 10 | location: PropTypes.object.isRequired, 11 | query: PropTypes.string, 12 | friends: PropTypes.array, 13 | }; 14 | 15 | const defaultProps = { 16 | query: '', 17 | friends: [], 18 | }; 19 | 20 | class FriendSearchView extends Component { 21 | constructor(props, context) { 22 | super(props, context); 23 | this.handleSearch = this.handleSearch.bind(this); 24 | } 25 | 26 | // set query on page load 27 | componentDidMount() { 28 | const { dispatch, location: { query } } = this.props; 29 | dispatch(setQuery(query.q)); 30 | } 31 | 32 | // needed to set query on back/forward 33 | componentWillReceiveProps({ location }) { 34 | const { dispatch } = this.props; 35 | 36 | if (location.action === 'POP') { 37 | dispatch(setQuery(location.query.q)); 38 | } 39 | } 40 | 41 | handleSearch(value) { 42 | const { dispatch } = this.props; 43 | dispatch(setQuery(value)); 44 | } 45 | 46 | render() { 47 | const { query, friends } = this.props; 48 | 49 | return ( 50 |
51 | 56 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | FriendSearchView.propTypes = propTypes; 63 | FriendSearchView.defaultProps = defaultProps; 64 | 65 | export default connect(state => state)(FriendSearchView); 66 | -------------------------------------------------------------------------------- /part-observable-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /part-observable-solution/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { Router, Route, IndexRoute, browserHistory } from 'react-router'; 6 | 7 | import configureStore from './store/configureStore'; 8 | import { fetchFriends } from './actions'; 9 | import FriendSearchView from './containers/FriendSearchView'; 10 | 11 | const store = configureStore(); 12 | 13 | store.subscribe((() => { 14 | let prevState = undefined; 15 | 16 | return () => { 17 | const state = store.getState(); 18 | 19 | if (!prevState || (prevState.query !== state.query)) { 20 | store.dispatch(fetchFriends()); 21 | } 22 | 23 | prevState = state; 24 | }; 25 | })()); 26 | 27 | ReactDOM.render( 28 | 29 | 30 | 31 | 32 | 33 | 34 | , 35 | document.getElementById('root') 36 | ); 37 | -------------------------------------------------------------------------------- /part-observable-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Derek Cuevas", 10 | "license": "MIT", 11 | "dependencies": { 12 | "babel-polyfill": "^6.7.4", 13 | "express": "^4.13.4", 14 | "react": "^15.0.1", 15 | "react-dom": "^15.0.1", 16 | "react-redux": "^4.4.4", 17 | "react-router": "^2.1.1", 18 | "redux": "^3.4.0", 19 | "redux-thunk": "^2.0.1" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.7.6", 23 | "babel-eslint": "^6.0.2", 24 | "babel-loader": "^6.2.2", 25 | "babel-preset-es2015": "^6.3.13", 26 | "babel-preset-react": "^6.3.13", 27 | "babel-preset-react-hmre": "^1.1.0", 28 | "redux-logger": "^2.5.0", 29 | "webpack": "^1.12.15", 30 | "webpack-dev-middleware": "^1.6.1", 31 | "webpack-hot-middleware": "^2.6.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /part-observable-solution/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | const initialState = { 4 | query: '', 5 | friends: [], 6 | }; 7 | 8 | export default function friendListReducer(state = initialState, action) { 9 | switch (action.type) { 10 | 11 | case types.SET_QUERY: 12 | return Object.assign({}, state, { 13 | query: action.query, 14 | }); 15 | 16 | case types.SET_FRIENDS: 17 | return Object.assign({}, state, { 18 | friends: action.friends, 19 | }); 20 | 21 | default: 22 | return state; 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /part-observable-solution/server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackDevMiddleware = require('webpack-dev-middleware'); 3 | const webpackHotMiddleware = require('webpack-hot-middleware'); 4 | const config = require('./webpack.config'); 5 | 6 | const app = new (require('express'))(); 7 | const port = 3000; 8 | const compiler = webpack(config); 9 | 10 | app.use(webpackDevMiddleware(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath 13 | })); 14 | app.use(webpackHotMiddleware(compiler)); 15 | 16 | app.get('/', (req, res) => { 17 | res.sendFile(__dirname + '/index.html'); 18 | }); 19 | 20 | app.listen(port, error => { 21 | if (error) { 22 | console.error(error); 23 | } else { 24 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /part-observable-solution/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import createLogger from 'redux-logger'; 4 | import reducer from '../reducers'; 5 | 6 | const middleware = [thunk, createLogger()]; 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore(reducer, initialState, applyMiddleware(...middleware)); 10 | 11 | if (module.hot) { 12 | module.hot.accept('../reducers', () => { 13 | const nextReducer = require('../reducers').default; 14 | store.replaceReducer(nextReducer); 15 | }); 16 | } 17 | 18 | return store; 19 | } 20 | -------------------------------------------------------------------------------- /part-observable-solution/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index', 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/', 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | ], 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.js$/, 24 | loaders: ['babel'], 25 | exclude: /node_modules/, 26 | include: __dirname, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /redux-saga-solution/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /redux-saga-solution/README.md: -------------------------------------------------------------------------------- 1 | # redux-saga approach: 2 | This approach uses the [redux-saga](https://github.com/yelouafi/redux-saga) middleware. 3 | 4 | redux-saga isolates side effects into Sagas. 5 | 6 | A Saga is a kind of a daemon process which starts with your application. It watches dispatched 7 | actions matching a specific pattern in order to decide what to do. For more infos on redux-saga see 8 | http://yelouafi.github.io/redux-saga/ 9 | 10 | The solution defines 2 Sagas (defined in the `sagas/index.js` module) 11 | 12 | - A *worker* Saga `fetchFriends` which handles the input query change logic (push into history, 13 | call Api, dispatch success action) 14 | - A *watcher* Saga `rootSaga` which observes `SET_QUERY` actions and forks a new `fetchFriends` 15 | task on each matching action. 16 | 17 | The example handles the *concurrent actions* issue. If the user changes the query input while there is 18 | still a pending request from a previous query change, the current pending request is cancelled and a new 19 | request is made. It means we always get the result of the latest request. 20 | 21 | Cancellation logic is attached to a request by defining a `CANCEL` method on the promise returned by the 22 | Api service `search` (see [`api/index.js`](api/index.js#L30-L33)). 23 | 24 | >Since the example debounces the api call by 100ms. You may need to increase the delay of the Api 25 | response ([see here](api/index.js#L27)) to make the cancellation more visible. 26 | 27 | The example also features *debouncing*. User input is denounced by 100ms : While `SET_QUERY` 28 | actions are dispatched normally, the fetch doesn't occur until the user has stopped typing 29 | after 100ms. 30 | 31 | 32 | Finally, the example feature Saga monitoring using a slightly modified version of the sagaMonitor example 33 | from the project examples repository. The monitor code can be found in the [`sagaMonitor/index`](sagaMonitor/index.js) module. You can 34 | print a log of the Sagas activity by typing `$$LogSagas()` into the console. 35 | 36 | Like in the `better-observable` solution. URL changes are managed with react-router's (history's) browserHistory.listen() method 37 | 38 | But while the above solution triggers the Side Effect from the browserHistory's callback (by dispatching a thunk). In the 39 | redux-saga solution the callback only dispatches a `SET_QUERY` action which gets handled by the Saga (see [index.js](index.js#L16-L20)). 40 | 41 | ## To run 42 | ```sh 43 | npm install 44 | npm start 45 | ``` 46 | -------------------------------------------------------------------------------- /redux-saga-solution/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | export function setQuery(query = '') { 4 | return { 5 | type: types.SET_QUERY, 6 | query 7 | }; 8 | } 9 | 10 | export function setFriends(friends = []) { 11 | return { 12 | type: types.SET_FRIENDS, 13 | friends 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /redux-saga-solution/api/friends.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 1, 4 | name: 'Bruce Wayne', 5 | username: '@Batman' 6 | }, 7 | { 8 | id: 2, 9 | name: 'Clark Kent', 10 | username: '@Superman' 11 | }, 12 | { 13 | id: 3, 14 | name: 'Maz ‘Magnus’ Eisenhardt', 15 | username: '@Magneto' 16 | }, 17 | { 18 | id: 4, 19 | name: 'Reed Richards', 20 | username: '@Mister-Fantastic' 21 | }, 22 | { 23 | id: 5, 24 | name: 'Charles Francis Xavier', 25 | username: '@ProfessorX' 26 | }, 27 | { 28 | id: 6, 29 | name: 'Lex Luthor', 30 | username: '@LexLuthor' 31 | }, 32 | { 33 | id: 7, 34 | name: 'Benjamin Grimm', 35 | username: '@Thing' 36 | }, 37 | { 38 | id: 8, 39 | name: 'Walter Langkowski', 40 | username: '@Sasquatch' 41 | }, 42 | { 43 | id: 9, 44 | name: 'Andrew Nolan', 45 | username: '@Ferro-Lad' 46 | }, 47 | { 48 | id: 10, 49 | name: 'Jonathan Osterman', 50 | username: '@Dr.Manhattan' 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /redux-saga-solution/api/index.js: -------------------------------------------------------------------------------- 1 | import { CANCEL } from 'redux-saga/utils'; 2 | import friends from './friends'; 3 | 4 | // mock api search 5 | export default function search(query, callback) { 6 | const results = friends.filter(friend => { 7 | let keep = false; 8 | 9 | Object.keys(friend).forEach(key => { 10 | const val = friend[key].toString(); 11 | 12 | if (val.toLowerCase().includes(query.toLowerCase())) { 13 | keep = true; 14 | } 15 | }); 16 | 17 | return keep; 18 | }); 19 | 20 | // setting a more realistic (random) timeout 21 | let tid 22 | console.log(`api/search: STARTING QUERY '${query}'`) 23 | const promise = new Promise((resolve) => { 24 | tid = setTimeout(() => { 25 | console.log(`api/search: RESOLVING QUERY '${query}'`) 26 | resolve(results) 27 | }, Math.ceil(Math.random() * 250)); // make the delay longer to make cancellation happen often 28 | }); 29 | 30 | promise[CANCEL] = () => { 31 | console.log(`api/search: CANCELLING QUERY '${query}'`) 32 | clearTimeout(tid) 33 | } 34 | 35 | return promise 36 | } 37 | -------------------------------------------------------------------------------- /redux-saga-solution/components/FriendList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FriendThumbnail from './FriendThumbnail'; 3 | 4 | const propTypes = { 5 | friends: PropTypes.arrayOf(PropTypes.shape({ 6 | id: PropTypes.number.isRequired, 7 | username: PropTypes.string.isRequired, 8 | name: PropTypes.string.isRequired 9 | })) 10 | }; 11 | 12 | const defaultProps = { 13 | friends: [] 14 | }; 15 | 16 | const FriendList = ({ friends }) => ( 17 | 24 | ); 25 | 26 | FriendList.propTypes = propTypes; 27 | FriendList.defaultProps = defaultProps; 28 | 29 | export default FriendList; 30 | -------------------------------------------------------------------------------- /redux-saga-solution/components/FriendThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | name: PropTypes.string, 5 | username: PropTypes.string 6 | }; 7 | 8 | const FriendThumbnail = ({ name, username }) => ( 9 |
10 |

{name} {username}

11 |
12 | ); 13 | 14 | FriendThumbnail.propTypes = propTypes; 15 | export default FriendThumbnail; 16 | -------------------------------------------------------------------------------- /redux-saga-solution/components/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | const ENTER_KEYCODE = 13; 4 | 5 | const propTypes = { 6 | value: PropTypes.string, 7 | handleSearch: PropTypes.func.isRequired 8 | }; 9 | 10 | class SearchInput extends Component { 11 | constructor(props, context) { 12 | super(props, context); 13 | 14 | this.handleValueChange = this.handleValueChange.bind(this); 15 | this.handleEnterKeyDown = this.handleEnterKeyDown.bind(this); 16 | } 17 | 18 | handleValueChange(e) { 19 | this.props.handleSearch(e.target.value); 20 | } 21 | 22 | handleEnterKeyDown(e) { 23 | if (e.keyCode === ENTER_KEYCODE) { 24 | this.props.handleSearch(e.target.value); 25 | } 26 | } 27 | 28 | render() { 29 | return ( 30 | 38 | ); 39 | } 40 | } 41 | 42 | SearchInput.propTypes = propTypes; 43 | export default SearchInput; 44 | -------------------------------------------------------------------------------- /redux-saga-solution/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_QUERY = 'SET_QUERY'; 2 | export const SET_FRIENDS = 'SET_FRIENDS'; 3 | -------------------------------------------------------------------------------- /redux-saga-solution/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const propTypes = { 4 | children: PropTypes.object.isRequired 5 | }; 6 | 7 | const App = ({ children }) => ( 8 |
{children}
9 | ); 10 | 11 | App.propTypes = propTypes; 12 | export default App; 13 | -------------------------------------------------------------------------------- /redux-saga-solution/containers/FriendSearchView.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { setQuery } from '../actions'; 5 | 6 | import SearchInput from '../components/SearchInput'; 7 | import FriendList from '../components/FriendList'; 8 | 9 | const propTypes = { 10 | dispatch: PropTypes.func.isRequired, 11 | query: PropTypes.string, 12 | friends: PropTypes.array 13 | }; 14 | 15 | const defaultProps = { 16 | query: '', 17 | friends: [] 18 | }; 19 | 20 | class FriendSearchView extends Component { 21 | constructor(props, context) { 22 | super(props, context); 23 | this.handleSearch = this.handleSearch.bind(this); 24 | } 25 | 26 | handleSearch(value) { 27 | const { dispatch } = this.props; 28 | dispatch(setQuery(value)); 29 | } 30 | 31 | render() { 32 | const { query, friends } = this.props; 33 | 34 | return ( 35 |
36 | 41 | 42 |
43 | ); 44 | } 45 | } 46 | 47 | FriendSearchView.propTypes = propTypes; 48 | FriendSearchView.defaultProps = defaultProps; 49 | 50 | export default connect(({ query, friends }) => ({ 51 | query, 52 | friends 53 | }))(FriendSearchView); 54 | -------------------------------------------------------------------------------- /redux-saga-solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Friend List 9 | 75 | 76 | 77 | 78 |
79 | Rendering... 80 |
81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /redux-saga-solution/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | import { Router, Route, IndexRoute, browserHistory } from 'react-router'; 7 | 8 | import configureStore from './store/configureStore'; 9 | 10 | import { setQuery } from './actions'; 11 | import App from './containers/App'; 12 | import FriendSearchView from './containers/FriendSearchView'; 13 | 14 | const store = configureStore(); 15 | 16 | browserHistory.listen(location => { 17 | if (location.action === 'POP') { 18 | store.dispatch(setQuery(location.query.q)); 19 | } 20 | }); 21 | 22 | // NOTE: react-router is not really needed for this example... 23 | ReactDOM.render( 24 | 25 | 26 | 27 | 28 | 29 | 30 | , 31 | document.getElementById('root') 32 | ); 33 | -------------------------------------------------------------------------------- /redux-saga-solution/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friend-list", 3 | "version": "0.0.1", 4 | "description": "A non-trivial redux, react, react-router example.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Derek Cuevas", 10 | "license": "MIT", 11 | "dependencies": { 12 | "babel-polyfill": "^6.5.0", 13 | "express": "^4.13.4", 14 | "react": "^0.14.7", 15 | "react-dom": "^0.14.7", 16 | "react-redux": "^4.4.0", 17 | "react-router": "^2.0.0", 18 | "redux": "^3.3.0", 19 | "redux-saga": "^0.8.2" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.4.5", 23 | "babel-eslint": "^4.1.8", 24 | "babel-loader": "^6.2.2", 25 | "babel-preset-es2015": "^6.3.13", 26 | "babel-preset-react": "^6.3.13", 27 | "babel-preset-react-hmre": "^1.1.0", 28 | "eslint": "^1.10.3", 29 | "eslint-config-reactjs": "^1.0.0", 30 | "eslint-plugin-objects": "^1.1.1", 31 | "eslint-plugin-react": "^3.16.1", 32 | "redux-logger": "^2.5.0", 33 | "rimraf": "^2.5.1", 34 | "webpack": "^1.12.13", 35 | "webpack-dev-middleware": "^1.5.1", 36 | "webpack-hot-middleware": "^2.6.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /redux-saga-solution/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | const initialState = { 4 | query: '', 5 | friends: [] 6 | }; 7 | 8 | export default function friendListReducer(state = initialState, action) { 9 | switch (action.type) { 10 | 11 | case types.SET_QUERY: 12 | return Object.assign({}, state, { 13 | query: action.query 14 | }); 15 | 16 | case types.SET_FRIENDS: 17 | return Object.assign({}, state, { 18 | friends: action.friends 19 | }); 20 | 21 | default: 22 | return state; 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /redux-saga-solution/sagaMonitor/index.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable no-console*/ 2 | 3 | import { SagaCancellationException } from 'redux-saga' 4 | 5 | import { 6 | monitorActions as actions, 7 | is, asEffect, 8 | MANUAL_CANCEL 9 | } from 'redux-saga/utils' 10 | 11 | const PENDING = 'PENDING' 12 | const RESOLVED = 'RESOLVED' 13 | const REJECTED = 'REJECTED' 14 | 15 | 16 | 17 | export const LOG_EFFECT = 'LOG_EFFECT' 18 | export const logEffect = (effectId = 0) => ({type: LOG_EFFECT, effectId}) 19 | 20 | const DEFAULT_STYLE = 'color: black' 21 | const LABEL_STYLE = 'font-weight: bold' 22 | const EFFECT_TYPE_STYLE = 'color: blue' 23 | const ERROR_STYLE = 'color: red' 24 | const AUTO_CANCEL_STYLE = 'color: lightgray' 25 | 26 | const time = () => performance.now() 27 | const env = process.env.NODE_ENV 28 | 29 | function checkEnv() { 30 | if (env !== 'production' && env !== 'development') { 31 | console.error('Saga Monitor cannot be used outside of NODE_ENV === \'development\'. ' + 32 | 'Consult tools such as loose-envify (https://github.com/zertosh/loose-envify) for browserify ' + 33 | 'and DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' + 34 | 'to build with proper NODE_ENV') 35 | return next(action) 36 | } 37 | } 38 | 39 | let effectsById = {} 40 | export default () => next => action => { 41 | 42 | switch (action.type) { 43 | case actions.EFFECT_TRIGGERED: 44 | effectsById[action.effectId] = Object.assign({}, 45 | action, 46 | { 47 | status: PENDING, 48 | start: time() 49 | } 50 | ) 51 | break; 52 | case actions.EFFECT_RESOLVED: 53 | resolveEffect(action.effectId, action.result) 54 | break; 55 | case actions.EFFECT_REJECTED: 56 | rejectEffect(action.effectId, action.error) 57 | break; 58 | case LOG_EFFECT: 59 | checkEnv() 60 | logEffectTree(action.effectId || 0) 61 | break; 62 | default: 63 | return next(action) 64 | } 65 | } 66 | 67 | window.$$LogSagas = () => logEffectTree(0) 68 | 69 | function resolveEffect(effectId, result) { 70 | const effect = effectsById[effectId] 71 | const now = time() 72 | 73 | if(is.task(result)) { 74 | result.done.then( 75 | taskResult => resolveEffect(effectId, taskResult), 76 | taskError => rejectEffect(effectId, taskError) 77 | ) 78 | } else { 79 | effectsById[effectId] = Object.assign({}, 80 | effect, 81 | { 82 | result: result, 83 | status: RESOLVED, 84 | end: now, 85 | duration: now - effect.start 86 | } 87 | ) 88 | if(effect && asEffect.race(effect.effect)) 89 | setRaceWinner(effectId, result) 90 | } 91 | } 92 | 93 | function rejectEffect(effectId, error) { 94 | const effect = effectsById[effectId] 95 | const now = time() 96 | effectsById[effectId] = Object.assign({}, 97 | effect, 98 | { 99 | error: error, 100 | status: REJECTED, 101 | end: now, 102 | duration: now - effect.start 103 | } 104 | ) 105 | if(effect && asEffect.race(effect.effect)) 106 | setRaceWinner(effectId, error) 107 | } 108 | 109 | function setRaceWinner(raceEffectId, result) { 110 | const winnerLabel = Object.keys(result)[0] 111 | const children = getChildEffects(raceEffectId) 112 | for (var i = 0; i < children.length; i++) { 113 | const childEffect = effectsById[ children[i] ] 114 | if(childEffect.label === winnerLabel) 115 | childEffect.winner = true 116 | } 117 | } 118 | 119 | function getChildEffects(parentEffectId) { 120 | return Object.keys(effectsById) 121 | .filter(effectId => effectsById[effectId].parentEffectId === parentEffectId) 122 | .map(effectId => +effectId) 123 | } 124 | 125 | function logEffectTree(effectId) { 126 | const effect = effectsById[effectId] 127 | if(effectId === undefined) { 128 | console.log('Saga monitor: No effect data for', effectId) 129 | return 130 | } 131 | const childEffects = getChildEffects(effectId) 132 | 133 | if(!childEffects.length) 134 | logSimpleEffect(effect) 135 | else { 136 | if(effect) { 137 | const {formatter} = getEffectLog(effect) 138 | console.group(...formatter.getLog()) 139 | } else 140 | console.group('root') 141 | childEffects.forEach(logEffectTree) 142 | console.groupEnd() 143 | } 144 | } 145 | 146 | function logSimpleEffect(effect) { 147 | const {method, formatter} = getEffectLog(effect) 148 | console[method](...formatter.getLog()) 149 | } 150 | 151 | /*eslint-disable no-cond-assign*/ 152 | function getEffectLog(effect) { 153 | let data, log 154 | 155 | if(data = asEffect.take(effect.effect)) { 156 | log = getLogPrefix('take', effect) 157 | log.formatter.addValue(data) 158 | logResult(effect, log.formatter) 159 | } 160 | 161 | else if(data = asEffect.put(effect.effect)) { 162 | log = getLogPrefix('put', effect) 163 | logResult(Object.assign({}, effect, { result: data }), log.formatter) 164 | } 165 | 166 | else if(data = asEffect.call(effect.effect)) { 167 | log = getLogPrefix('call', effect) 168 | log.formatter.addCall(data.fn.name, data.args) 169 | logResult(effect, log.formatter) 170 | } 171 | 172 | else if(data = asEffect.cps(effect.effect)) { 173 | log = getLogPrefix('cps', effect) 174 | log.formatter.addCall(data.fn.name, data.args) 175 | logResult(effect, log.formatter) 176 | } 177 | 178 | else if(data = asEffect.fork(effect.effect)) { 179 | log = getLogPrefix('', effect) 180 | log.formatter.addCall(data.fn.name, data.args) 181 | logResult(effect, log.formatter) 182 | } 183 | 184 | else if(data = asEffect.join(effect.effect)) { 185 | log = getLogPrefix('join', effect) 186 | logResult(effect, log.formatter) 187 | } 188 | 189 | else if(data = asEffect.race(effect.effect)) { 190 | log = getLogPrefix('race', effect) 191 | logResult(effect, log.formatter, true) 192 | } 193 | 194 | else if(data = asEffect.cancel(effect.effect)) { 195 | log = getLogPrefix('cancel', effect) 196 | log.formatter.appendData(data.name) 197 | } 198 | 199 | else if(data = is.array(effect.effect)) { 200 | log = getLogPrefix('parallel', effect) 201 | logResult(effect, log.formatter, true) 202 | } 203 | 204 | else { 205 | log = getLogPrefix('unkown', effect) 206 | logResult(effect, log.formatter) 207 | } 208 | 209 | return log 210 | } 211 | 212 | 213 | function getLogPrefix(type, effect) { 214 | 215 | const autoCancel = isAutoCancel(effect.error) 216 | const isError = effect && effect.status === REJECTED && !autoCancel 217 | const method = isError ? 'error' : 'log' 218 | const winnerInd = effect && effect.winner 219 | ? ( isError ? '✘' : '✓' ) 220 | : '' 221 | 222 | const style = s => 223 | autoCancel ? AUTO_CANCEL_STYLE 224 | : isError ? ERROR_STYLE 225 | : s 226 | 227 | const formatter = logFormatter() 228 | 229 | if(winnerInd) 230 | formatter.add(`%c ${winnerInd}`, style(LABEL_STYLE)) 231 | 232 | if(effect && effect.label) 233 | formatter.add(`%c ${effect.label}: `, style(LABEL_STYLE)) 234 | 235 | if(type) 236 | formatter.add(`%c ${type} `, style(EFFECT_TYPE_STYLE)) 237 | 238 | formatter.add('%c', style(DEFAULT_STYLE)) 239 | 240 | return { 241 | method, 242 | formatter 243 | } 244 | } 245 | 246 | function argToString(arg) { 247 | return ( 248 | typeof arg === 'function' ? `${arg.name}` 249 | : typeof arg === 'string' ? `'${arg}'` 250 | : arg 251 | ) 252 | } 253 | 254 | function logResult({status, result, error, duration}, formatter, ignoreResult) { 255 | 256 | if(status === RESOLVED && !ignoreResult) { 257 | if( is.array(result) ) { 258 | formatter.addValue(' → ') 259 | formatter.addValue(result) 260 | } else 261 | formatter.appendData('→',result) 262 | } 263 | 264 | else if(status === REJECTED) { 265 | if(isAutoCancel(error)) 266 | return 267 | 268 | if(isManualCancel(error)) 269 | formatter.appendData('→ ⚠', 'Cancelled!') 270 | else 271 | formatter.appendData('→ ⚠', error) 272 | } 273 | 274 | else if(status === PENDING) 275 | formatter.appendData('⌛') 276 | 277 | if(status !== PENDING) 278 | formatter.appendData(`(${duration.toFixed(2)}ms)`) 279 | } 280 | 281 | function isAutoCancel(error) { 282 | return error instanceof SagaCancellationException && error.type !== MANUAL_CANCEL 283 | } 284 | 285 | function isManualCancel(error) { 286 | return error instanceof SagaCancellationException && error.type === MANUAL_CANCEL 287 | } 288 | 289 | function isPrimitive(val) { 290 | return typeof val === 'string' || 291 | typeof val === 'number' || 292 | typeof val === 'boolean' || 293 | typeof val === 'symbol' || 294 | val === null || 295 | val === undefined; 296 | } 297 | 298 | function logFormatter() { 299 | const logs = [] 300 | let suffix = [] 301 | 302 | function add(msg, ...args) { 303 | logs.push({msg, args}) 304 | } 305 | 306 | function appendData(...data) { 307 | suffix = suffix.concat(data) 308 | } 309 | 310 | function addValue(value) { 311 | if(isPrimitive(value)) 312 | add(value) 313 | else 314 | add('%O', value) 315 | } 316 | 317 | function addCall(name, args) { 318 | if(!args.length) 319 | add( `${name}()` ) 320 | else { 321 | add(name) 322 | add('(') 323 | args.forEach( (arg, i) => { 324 | addValue( argToString(arg) ) 325 | addValue( i === args.length - 1 ? ')' : ', ') 326 | }) 327 | } 328 | } 329 | 330 | function getLog() { 331 | let msgs = [], msgsArgs = [] 332 | for (var i = 0; i < logs.length; i++) { 333 | msgs.push(logs[i].msg) 334 | msgsArgs = msgsArgs.concat(logs[i].args) 335 | } 336 | return [msgs.join('')].concat(msgsArgs).concat(suffix) 337 | } 338 | 339 | return { 340 | add, addValue, addCall, appendData, getLog 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /redux-saga-solution/sagas/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-constant-condition */ 2 | 3 | import { browserHistory } from 'react-router'; 4 | import { take, put, call, fork, cancel } from 'redux-saga/effects'; 5 | import { isCancelError } from 'redux-saga'; 6 | 7 | import search from '../api'; 8 | import { SET_QUERY } from '../constants/actionTypes'; 9 | import { setFriends } from '../actions'; 10 | 11 | // utility function to 'sleep' some time 12 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) 13 | 14 | function* fetchFriends(query) { 15 | try { 16 | // debounce 17 | yield call(delay, 100) 18 | yield call(browserHistory.push, { 19 | query: { q: query || undefined } 20 | }); 21 | const friends = yield call(search, query); 22 | yield put(setFriends(friends)); 23 | } catch(error) { 24 | if(!isCancelError(error)) { 25 | // handle error 26 | } 27 | } 28 | 29 | } 30 | 31 | export default function* rootSaga() { 32 | let previousQuery, task; 33 | while(true) { 34 | const {query} = yield take(SET_QUERY); 35 | if(query !== previousQuery) { 36 | if(task) 37 | yield cancel(task); 38 | 39 | task = yield fork(fetchFriends, query); 40 | previousQuery = query; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /redux-saga-solution/server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackDevMiddleware = require('webpack-dev-middleware'); 3 | const webpackHotMiddleware = require('webpack-hot-middleware'); 4 | const config = require('./webpack.config'); 5 | 6 | const app = new (require('express'))(); 7 | const port = 3000; 8 | const compiler = webpack(config); 9 | 10 | app.use(webpackDevMiddleware(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath 13 | })); 14 | app.use(webpackHotMiddleware(compiler)); 15 | 16 | app.get('/', (req, res) => { 17 | res.sendFile(__dirname + '/index.html'); 18 | }); 19 | 20 | app.listen(port, error => { 21 | if (error) { 22 | console.error(error); 23 | } else { 24 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /redux-saga-solution/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createLogger from 'redux-logger'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import reducer from '../reducers'; 5 | import saga from '../sagas'; 6 | import sagaMonitor from '../sagaMonitor' 7 | 8 | const createStoreWithMiddleware = applyMiddleware( 9 | sagaMonitor, 10 | createLogger(), 11 | createSagaMiddleware(saga) 12 | )(createStore); 13 | 14 | export default function configureStore(initialState) { 15 | const store = createStoreWithMiddleware(reducer, initialState); 16 | 17 | if (module.hot) { 18 | module.hot.accept('../reducers', () => { 19 | const nextReducer = require('../reducers').default; 20 | store.replaceReducer(nextReducer); 21 | }); 22 | } 23 | return store; 24 | } 25 | -------------------------------------------------------------------------------- /redux-saga-solution/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index' 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/' 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | new webpack.DefinePlugin({ 20 | 'process.env.NODE_ENV': JSON.stringify('development') 21 | }) 22 | ], 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.js$/, 27 | loaders: [ 'babel' ], 28 | exclude: /node_modules/, 29 | include: __dirname 30 | } 31 | ] 32 | } 33 | }; 34 | --------------------------------------------------------------------------------