├── .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 | 
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 |
21 | {friends.map(friend => (
22 | -
23 |
24 |
25 | ))}
26 |
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 |
18 | {friends.map(friend => (
19 | -
20 |
21 |
22 | ))}
23 |
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 |
19 | {friends.map(friend => (
20 | -
21 |
22 |
23 | ))}
24 |
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 |
19 | {friends.map(friend => (
20 | -
21 |
22 |
23 | ))}
24 |
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 |
18 | {friends.map(friend => (
19 | -
20 |
21 |
22 | ))}
23 |
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 |
--------------------------------------------------------------------------------