diff --git a/.eslintrc b/.eslintrc index 5cf245ac59..c501c8bf4c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,10 +6,16 @@ "node": true }, "rules": { + "valid-jsdoc": 2, + "react/jsx-uses-react": 2, "react/jsx-uses-vars": 2, "react/react-in-jsx-scope": 2, + // Disable until Flow supports let and const + "no-var": 0, + "vars-on-top": 0, + //Temporarirly disabled due to a possible bug in babel-eslint (todomvc example) "block-scoped-var": 0, // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..369768b7ad --- /dev/null +++ b/.flowconfig @@ -0,0 +1,9 @@ +[ignore] +.*/lib +.*/test + +[include] + +[libs] + +[options] diff --git a/.gitignore b/.gitignore index 37d08f874a..ecb01c5ef8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -node_modules -npm-debug.log .DS_Store +*.log +node_modules dist lib coverage -react.js -react-native.js diff --git a/.npmignore b/.npmignore index 9458ec8184..f5c0a99ca5 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,5 @@ -src +.DS_Store +*.log examples +test +coverage diff --git a/.travis.yml b/.travis.yml index c42701fff0..fe6105c03b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,7 @@ language: node_js node_js: - - "iojs" + - "iojs-2" +script: + - npm test + - npm run build:examples + diff --git a/examples/async/.babelrc b/examples/async/.babelrc new file mode 100644 index 0000000000..cab5d10d92 --- /dev/null +++ b/examples/async/.babelrc @@ -0,0 +1,3 @@ +{ + "stage": 2 +} diff --git a/examples/async/actions/index.js b/examples/async/actions/index.js new file mode 100644 index 0000000000..c954848e1d --- /dev/null +++ b/examples/async/actions/index.js @@ -0,0 +1,64 @@ +import fetch from 'isomorphic-fetch'; + +export const REQUEST_POSTS = 'REQUEST_POSTS'; +export const RECEIVE_POSTS = 'RECEIVE_POSTS'; +export const SELECT_REDDIT = 'SELECT_REDDIT'; +export const INVALIDATE_REDDIT = 'INVALIDATE_REDDIT'; + +export function selectReddit(reddit) { + return { + type: SELECT_REDDIT, + reddit + }; +} + +export function invalidateReddit(reddit) { + return { + type: INVALIDATE_REDDIT, + reddit + }; +} + +function requestPosts(reddit) { + return { + type: REQUEST_POSTS, + reddit + }; +} + +function receivePosts(reddit, json) { + return { + type: RECEIVE_POSTS, + reddit: reddit, + posts: json.data.children, + receivedAt: Date.now() + }; +} + +function fetchPosts(reddit) { + return dispatch => { + dispatch(requestPosts(reddit)); + return fetch(`http://www.reddit.com/r/${reddit}.json`) + .then(req => req.json()) + .then(json => dispatch(receivePosts(reddit, json))); + } +} + +function shouldFetchPosts(state, reddit) { + const posts = state.postsByReddit[reddit]; + if (!posts) { + return true; + } else if (posts.isFetching) { + return false; + } else { + return posts.didInvalidate; + } +} + +export function fetchPostsIfNeeded(reddit) { + return (dispatch, getState) => { + if (shouldFetchPosts(getState(), reddit)) { + return dispatch(fetchPosts(reddit)); + } + }; +} diff --git a/examples/async/components/Picker.js b/examples/async/components/Picker.js new file mode 100644 index 0000000000..8b6827df58 --- /dev/null +++ b/examples/async/components/Picker.js @@ -0,0 +1,29 @@ +import React, { Component, PropTypes } from 'react'; + +export default class Picker extends Component { + render () { + const { value, onChange, options } = this.props; + + return ( + +

{value}

+ +
+ ); + } +} + +Picker.propTypes = { + options: PropTypes.arrayOf( + PropTypes.string.isRequired + ).isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; \ No newline at end of file diff --git a/examples/async/components/Posts.js b/examples/async/components/Posts.js new file mode 100644 index 0000000000..5b11bcd54b --- /dev/null +++ b/examples/async/components/Posts.js @@ -0,0 +1,17 @@ +import React, { PropTypes, Component } from 'react'; + +export default class Posts extends Component { + render () { + return ( + + ); + } +} + +Posts.propTypes = { + posts: PropTypes.array.isRequired +}; diff --git a/examples/async/containers/AsyncApp.js b/examples/async/containers/AsyncApp.js new file mode 100644 index 0000000000..72ce518573 --- /dev/null +++ b/examples/async/containers/AsyncApp.js @@ -0,0 +1,102 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { selectReddit, fetchPostsIfNeeded, invalidateReddit } from '../actions'; +import Picker from '../components/Picker'; +import Posts from '../components/Posts'; + +class AsyncApp extends Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleRefreshClick = this.handleRefreshClick.bind(this); + } + + componentDidMount() { + const { dispatch, selectedReddit } = this.props; + dispatch(fetchPostsIfNeeded(selectedReddit)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.selectedReddit !== this.props.selectedReddit) { + const { dispatch, selectedReddit } = nextProps; + dispatch(fetchPostsIfNeeded(selectedReddit)); + } + } + + handleChange(nextReddit) { + this.props.dispatch(selectReddit(nextReddit)); + } + + handleRefreshClick(e) { + e.preventDefault(); + + const { dispatch, selectedReddit } = this.props; + dispatch(invalidateReddit(selectedReddit)); + dispatch(fetchPostsIfNeeded(selectedReddit)); + } + + render () { + const { selectedReddit, posts, isFetching, lastUpdated } = this.props; + return ( +
+ +

+ {lastUpdated && + + Last updated at {new Date(lastUpdated).toLocaleTimeString()}. + {' '} + + } + {!isFetching && + + Refresh + + } +

+ {isFetching && posts.length === 0 && +

Loading...

+ } + {!isFetching && posts.length === 0 && +

Empty.

+ } + {posts.length > 0 && +
+ +
+ } +
+ ); + } +} + +AsyncApp.propTypes = { + selectedReddit: PropTypes.string.isRequired, + posts: PropTypes.array.isRequired, + isFetching: PropTypes.bool.isRequired, + lastUpdated: PropTypes.number, + dispatch: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + const { selectedReddit, postsByReddit } = state; + const { + isFetching, + lastUpdated, + items: posts + } = postsByReddit[selectedReddit] || { + isFetching: true, + items: [] + }; + + return { + selectedReddit, + posts, + isFetching, + lastUpdated + }; +} + +export default connect(mapStateToProps)(AsyncApp); diff --git a/examples/async/containers/Root.js b/examples/async/containers/Root.js new file mode 100644 index 0000000000..50c34afe76 --- /dev/null +++ b/examples/async/containers/Root.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../store/configureStore'; +import AsyncApp from './AsyncApp'; + +const store = configureStore(); + +export default class Root extends Component { + render() { + return ( + + {() => } + + ); + } +} diff --git a/examples/async/index.html b/examples/async/index.html new file mode 100644 index 0000000000..bb1a57c47c --- /dev/null +++ b/examples/async/index.html @@ -0,0 +1,10 @@ + + + Redux async example + + +
+
+ + + diff --git a/examples/async/index.js b/examples/async/index.js new file mode 100644 index 0000000000..3d60c6a5c5 --- /dev/null +++ b/examples/async/index.js @@ -0,0 +1,9 @@ +import 'babel-core/polyfill'; + +import React from 'react'; +import Root from './containers/Root'; + +React.render( + , + document.getElementById('root') +); diff --git a/examples/async/package.json b/examples/async/package.json new file mode 100644 index 0000000000..d1567f614b --- /dev/null +++ b/examples/async/package.json @@ -0,0 +1,49 @@ +{ + "name": "redux-async-example", + "version": "0.0.0", + "description": "Redux async example", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/gaearon/redux.git" + }, + "keywords": [ + "react", + "reactjs", + "hot", + "reload", + "hmr", + "live", + "edit", + "webpack", + "flux" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/gaearon/redux/issues" + }, + "homepage": "https://github.com/gaearon/redux#readme", + "dependencies": { + "isomorphic-fetch": "^2.1.1", + "react": "^0.13.3", + "react-redux": "^0.8.0", + "redux": "^1.0.0-rc", + "redux-logger": "0.0.3", + "redux-thunk": "^0.1.0" + }, + "devDependencies": { + "babel-core": "^5.6.18", + "babel-loader": "^5.1.4", + "expect": "^1.6.0", + "jsdom": "^5.6.1", + "mocha": "^2.2.5", + "mocha-jsdom": "^1.0.0", + "node-libs-browser": "^0.5.2", + "react-hot-loader": "^1.2.8", + "webpack": "^1.9.11", + "webpack-dev-server": "^1.9.0" + } +} diff --git a/examples/async/reducers/index.js b/examples/async/reducers/index.js new file mode 100644 index 0000000000..7600fbea27 --- /dev/null +++ b/examples/async/reducers/index.js @@ -0,0 +1,61 @@ +import { combineReducers } from 'redux'; +import { + SELECT_REDDIT, INVALIDATE_REDDIT, + REQUEST_POSTS, RECEIVE_POSTS +} from '../actions'; + +function selectedReddit(state = 'reactjs', action) { + switch (action.type) { + case SELECT_REDDIT: + return action.reddit; + default: + return state; + } +} + +function posts(state = { + isFetching: false, + didInvalidate: false, + items: [] +}, action) { + switch (action.type) { + case INVALIDATE_REDDIT: + return Object.assign({}, state, { + didInvalidate: true + }); + case REQUEST_POSTS: + return Object.assign({}, state, { + isFetching: true, + didInvalidate: false + }); + case RECEIVE_POSTS: + return Object.assign({}, state, { + isFetching: false, + didInvalidate: false, + items: action.posts, + lastUpdated: action.receivedAt + }); + default: + return state; + } +} + +function postsByReddit(state = { }, action) { + switch (action.type) { + case INVALIDATE_REDDIT: + case RECEIVE_POSTS: + case REQUEST_POSTS: + return Object.assign({}, state, { + [action.reddit]: posts(state[action.reddit], action) + }); + default: + return state; + } +} + +const rootReducer = combineReducers({ + postsByReddit, + selectedReddit +}); + +export default rootReducer; \ No newline at end of file diff --git a/examples/async/server.js b/examples/async/server.js new file mode 100644 index 0000000000..ff92aa06b6 --- /dev/null +++ b/examples/async/server.js @@ -0,0 +1,18 @@ +var webpack = require('webpack'); +var WebpackDevServer = require('webpack-dev-server'); +var config = require('./webpack.config'); + +new WebpackDevServer(webpack(config), { + publicPath: config.output.publicPath, + hot: true, + historyApiFallback: true, + stats: { + colors: true + } +}).listen(3000, 'localhost', function (err) { + if (err) { + console.log(err); + } + + console.log('Listening at localhost:3000'); +}); diff --git a/examples/async/store/configureStore.js b/examples/async/store/configureStore.js new file mode 100644 index 0000000000..7520405a4b --- /dev/null +++ b/examples/async/store/configureStore.js @@ -0,0 +1,13 @@ +import { createStore, applyMiddleware, combineReducers } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import loggerMiddleware from 'redux-logger'; +import rootReducer from '../reducers'; + +const createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, + loggerMiddleware +)(createStore); + +export default function configureStore(initialState) { + return createStoreWithMiddleware(rootReducer, initialState); +} diff --git a/examples/async/webpack.config.js b/examples/async/webpack.config.js new file mode 100644 index 0000000000..2061e19a48 --- /dev/null +++ b/examples/async/webpack.config.js @@ -0,0 +1,38 @@ +var path = require('path'); +var webpack = require('webpack'); + +module.exports = { + devtool: 'eval', + entry: [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + './index' + ], + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/' + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ], + resolve: { + alias: { + 'redux': path.join(__dirname, '..', '..', 'src') + }, + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['react-hot', 'babel'], + exclude: /node_modules/, + include: __dirname + }, { + test: /\.js$/, + loaders: ['babel'], + include: path.join(__dirname, '..', '..', 'src') + }] + } +}; diff --git a/examples/buildAll.js b/examples/buildAll.js new file mode 100644 index 0000000000..5873a137b3 --- /dev/null +++ b/examples/buildAll.js @@ -0,0 +1,34 @@ +/** + * Runs an ordered set of commands within each of the build directories. + */ + +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +var exampleDirs = fs.readdirSync(__dirname).filter((file) => { + return fs.statSync(path.join(__dirname, file)).isDirectory(); +}); + +// Ordering is important here. `npm install` must come first. +var cmdArgs = [ + { cmd: 'npm', args: ['install'] }, + { cmd: 'webpack', args: ['index.js'] }, + { cmd: 'npm', args: ['test'] } +]; + +for (let dir of exampleDirs) { + + for (let cmdArg of cmdArgs) { + // delcare opts in this scope to avoid https://github.com/joyent/node/issues/9158 + let opts = { + cwd: path.join(__dirname, dir), + stdio: 'inherit' + }; + + let result = spawnSync(cmdArg.cmd, cmdArg.args, opts); + if (result.status !== 0) { + throw new Error('Building examples exited with non-zero'); + } + } +} diff --git a/examples/counter/.babelrc b/examples/counter/.babelrc index b0b9a96ef0..cab5d10d92 100644 --- a/examples/counter/.babelrc +++ b/examples/counter/.babelrc @@ -1,3 +1,3 @@ { - "stage": 0 + "stage": 2 } diff --git a/examples/counter/actions/CounterActions.js b/examples/counter/actions/counter.js similarity index 71% rename from examples/counter/actions/CounterActions.js rename to examples/counter/actions/counter.js index 9ddaa8351d..214092e1e2 100644 --- a/examples/counter/actions/CounterActions.js +++ b/examples/counter/actions/counter.js @@ -1,4 +1,5 @@ -import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../constants/ActionTypes'; +export const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; +export const DECREMENT_COUNTER = 'DECREMENT_COUNTER'; export function increment() { return { @@ -24,10 +25,10 @@ export function incrementIfOdd() { }; } -export function incrementAsync() { +export function incrementAsync(delay = 1000) { return dispatch => { setTimeout(() => { dispatch(increment()); - }, 1000); + }, delay); }; } diff --git a/examples/counter/components/Counter.js b/examples/counter/components/Counter.js index c58dc31626..9114f9c888 100644 --- a/examples/counter/components/Counter.js +++ b/examples/counter/components/Counter.js @@ -1,15 +1,8 @@ import React, { Component, PropTypes } from 'react'; -export default class Counter extends Component { - static propTypes = { - increment: PropTypes.func.isRequired, - incrementIfOdd: PropTypes.func.isRequired, - decrement: PropTypes.func.isRequired, - counter: PropTypes.number.isRequired - }; - +class Counter extends Component { render() { - const { increment, incrementIfOdd, decrement, counter } = this.props; + const { increment, incrementIfOdd, incrementAsync, decrement, counter } = this.props; return (

Clicked: {counter} times @@ -19,7 +12,19 @@ export default class Counter extends Component { {' '} + {' '} +

); } } + +Counter.propTypes = { + increment: PropTypes.func.isRequired, + incrementIfOdd: PropTypes.func.isRequired, + incrementAsync: PropTypes.func.isRequired, + decrement: PropTypes.func.isRequired, + counter: PropTypes.number.isRequired +}; + +export default Counter; diff --git a/examples/counter/constants/ActionTypes.js b/examples/counter/constants/ActionTypes.js deleted file mode 100644 index c01b8a984f..0000000000 --- a/examples/counter/constants/ActionTypes.js +++ /dev/null @@ -1,2 +0,0 @@ -export const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; -export const DECREMENT_COUNTER = 'DECREMENT_COUNTER'; diff --git a/examples/counter/containers/App.js b/examples/counter/containers/App.js deleted file mode 100644 index 42385f44ce..0000000000 --- a/examples/counter/containers/App.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, { Component } from 'react'; -import CounterApp from './CounterApp'; -import { createRedux } from 'redux'; -import { Provider } from 'redux/react'; -import * as stores from '../stores'; - -const redux = createRedux(stores); - -export default class App extends Component { - render() { - return ( - - {() => } - - ); - } -} diff --git a/examples/counter/containers/CounterApp.js b/examples/counter/containers/CounterApp.js index 705657c78d..39801da70a 100644 --- a/examples/counter/containers/CounterApp.js +++ b/examples/counter/containers/CounterApp.js @@ -1,18 +1,16 @@ -import React, { Component } from 'react'; import { bindActionCreators } from 'redux'; -import { connect } from 'redux/react'; +import { connect } from 'react-redux'; import Counter from '../components/Counter'; -import * as CounterActions from '../actions/CounterActions'; +import * as CounterActions from '../actions/counter'; -@connect(state => ({ - counter: state.counter -})) -export default class CounterApp extends Component { - render() { - const { counter, dispatch } = this.props; - return ( - - ); +function mapStateToProps(state) { + return { + counter: state.counter } } + +function mapDispatchToProps(dispatch) { + return bindActionCreators(CounterActions, dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Counter); diff --git a/examples/counter/containers/Root.js b/examples/counter/containers/Root.js new file mode 100644 index 0000000000..f86fa26aa4 --- /dev/null +++ b/examples/counter/containers/Root.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import { Provider } from 'react-redux'; +import CounterApp from './CounterApp'; +import configureStore from '../store/configureStore'; + +const store = configureStore(); + +export default class Root extends Component { + render() { + return ( + + {() => } + + ); + } +} diff --git a/examples/counter/index.html b/examples/counter/index.html index 696101a536..0f72d6a041 100644 --- a/examples/counter/index.html +++ b/examples/counter/index.html @@ -1,6 +1,6 @@ - Redux Counter Example + Redux counter example
diff --git a/examples/counter/index.js b/examples/counter/index.js index 2a18864184..6f19211f76 100644 --- a/examples/counter/index.js +++ b/examples/counter/index.js @@ -1,7 +1,7 @@ import React from 'react'; -import App from './containers/App'; +import Root from './containers/Root'; React.render( - , + , document.getElementById('root') ); diff --git a/examples/counter/package.json b/examples/counter/package.json index 4e5c64f72c..59599191f3 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -1,10 +1,12 @@ { - "name": "counter-redux", + "name": "redux-counter-example", "version": "0.0.0", - "description": "Counter example for redux", + "description": "Redux counter example", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "mocha --recursive --compilers js:babel/register", + "test:watch": "npm test -- --watch" }, "repository": { "type": "git", @@ -28,11 +30,17 @@ "homepage": "https://github.com/gaearon/redux#readme", "dependencies": { "react": "^0.13.3", - "redux": "^0.12.0" + "react-redux": "^0.8.0", + "redux": "^1.0.0-rc", + "redux-thunk": "^0.1.0" }, "devDependencies": { - "babel-core": "^5.5.8", + "babel-core": "^5.6.18", "babel-loader": "^5.1.4", + "expect": "^1.6.0", + "jsdom": "^5.6.1", + "mocha": "^2.2.5", + "mocha-jsdom": "^1.0.0", "node-libs-browser": "^0.5.2", "react-hot-loader": "^1.2.7", "webpack": "^1.9.11", diff --git a/examples/counter/stores/counter.js b/examples/counter/reducers/counter.js similarity index 72% rename from examples/counter/stores/counter.js rename to examples/counter/reducers/counter.js index 94b6dfa07e..84e9615322 100644 --- a/examples/counter/stores/counter.js +++ b/examples/counter/reducers/counter.js @@ -1,4 +1,4 @@ -import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../constants/ActionTypes'; +import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../actions/counter'; export default function counter(state = 0, action) { switch (action.type) { diff --git a/examples/counter/reducers/index.js b/examples/counter/reducers/index.js new file mode 100644 index 0000000000..41aac188f5 --- /dev/null +++ b/examples/counter/reducers/index.js @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import counter from './counter'; + +const rootReducer = combineReducers({ + counter +}); + +export default rootReducer; \ No newline at end of file diff --git a/examples/counter/store/configureStore.js b/examples/counter/store/configureStore.js new file mode 100644 index 0000000000..4b1076d44f --- /dev/null +++ b/examples/counter/store/configureStore.js @@ -0,0 +1,11 @@ +import { createStore, applyMiddleware, combineReducers } from 'redux'; +import thunk from 'redux-thunk'; +import rootReducer from '../reducers'; + +const createStoreWithMiddleware = applyMiddleware( + thunk +)(createStore); + +export default function configureStore(initialState) { + return createStoreWithMiddleware(rootReducer, initialState); +} diff --git a/examples/counter/stores/index.js b/examples/counter/stores/index.js deleted file mode 100644 index d6a1f1d139..0000000000 --- a/examples/counter/stores/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as counter } from './counter'; diff --git a/examples/counter/test/actions/counter.spec.js b/examples/counter/test/actions/counter.spec.js new file mode 100644 index 0000000000..ee2a4cc59b --- /dev/null +++ b/examples/counter/test/actions/counter.spec.js @@ -0,0 +1,43 @@ +import expect from 'expect'; +import * as actions from '../../actions/counter'; + +describe('actions', () => { + + it('increment should create increment action', () => { + expect(actions.increment()).toEqual({ type: actions.INCREMENT_COUNTER }); + }); + + it('decrement should create decrement action', () => { + expect(actions.decrement()).toEqual({ type: actions.DECREMENT_COUNTER }); + }); + + it('incrementIfOdd should create increment action', () => { + let fn = actions.incrementIfOdd(); + expect(fn).toBeA('function'); + let dispatch = expect.createSpy(); + let getState = () => ({ counter: 1 }); + fn(dispatch, getState); + expect(dispatch).toHaveBeenCalledWith({ type: actions.INCREMENT_COUNTER }); + }); + + it('incrementIfOdd shouldnt create increment action if counter is even', () => { + let fn = actions.incrementIfOdd(); + let dispatch = expect.createSpy(); + let getState = () => ({ counter: 2 }); + fn(dispatch, getState); + expect(dispatch.calls.length).toBe(0); + }); + + // There's no nice way to test this at the moment... + it('incrementAsync', (done) => { + let fn = actions.incrementAsync(1); + expect(fn).toBeA('function'); + let dispatch = expect.createSpy(); + fn(dispatch); + setTimeout(() => { + expect(dispatch).toHaveBeenCalledWith({ type: actions.INCREMENT_COUNTER }); + done(); + }, 5); + }); +}); + diff --git a/examples/counter/test/components/Counter.spec.js b/examples/counter/test/components/Counter.spec.js new file mode 100644 index 0000000000..66f985b28f --- /dev/null +++ b/examples/counter/test/components/Counter.spec.js @@ -0,0 +1,57 @@ +import expect from 'expect'; +import jsdomReact from '../jsdomReact'; +import React from 'react/addons'; +import Counter from '../../components/Counter'; + +const { TestUtils } = React.addons; + +function setup() { + const actions = { + increment: expect.createSpy(), + incrementIfOdd: expect.createSpy(), + incrementAsync: expect.createSpy(), + decrement: expect.createSpy() + }; + const component = TestUtils.renderIntoDocument(); + return { + component: component, + actions: actions, + buttons: TestUtils.scryRenderedDOMComponentsWithTag(component, 'button').map(button => { + return button.getDOMNode(); + }), + p: TestUtils.findRenderedDOMComponentWithTag(component, 'p').getDOMNode() + }; +} + +describe('Counter component', () => { + jsdomReact(); + + it('should display count', () => { + const { p } = setup(); + expect(p.textContent).toMatch(/^Clicked: 1 times/); + }); + + it('first button should call increment', () => { + const { buttons, actions } = setup(); + TestUtils.Simulate.click(buttons[0]); + expect(actions.increment).toHaveBeenCalled(); + }); + + it('second button should call decrement', () => { + const { buttons, actions } = setup(); + TestUtils.Simulate.click(buttons[1]); + expect(actions.decrement).toHaveBeenCalled(); + }); + + it('third button should call incrementIfOdd', () => { + const { buttons, actions } = setup(); + TestUtils.Simulate.click(buttons[2]); + expect(actions.incrementIfOdd).toHaveBeenCalled(); + }); + + it('fourth button should call incrementAsync', () => { + const { buttons, actions } = setup(); + TestUtils.Simulate.click(buttons[3]); + expect(actions.incrementAsync).toHaveBeenCalled(); + }); +}); diff --git a/examples/counter/test/containers/CounterApp.spec.js b/examples/counter/test/containers/CounterApp.spec.js new file mode 100644 index 0000000000..4278c57667 --- /dev/null +++ b/examples/counter/test/containers/CounterApp.spec.js @@ -0,0 +1,60 @@ +import expect from 'expect'; +import jsdomReact from '../jsdomReact'; +import React from 'react/addons'; +import { Provider } from 'react-redux'; +import CounterApp from '../../containers/CounterApp'; +import configureStore from '../../store/configureStore'; + +const { TestUtils } = React.addons; + +function setup(initialState) { + const store = configureStore(initialState); + const app = TestUtils.renderIntoDocument( + + {() => } + + ); + return { + app: app, + buttons: TestUtils.scryRenderedDOMComponentsWithTag(app, 'button').map(button => { + return button.getDOMNode(); + }), + p: TestUtils.findRenderedDOMComponentWithTag(app, 'p').getDOMNode() + }; +} + +describe('containers', () => { + jsdomReact(); + + describe('App', () => { + + it('should display initial count', () => { + const { p } = setup(); + expect(p.textContent).toMatch(/^Clicked: 0 times/); + }); + + it('should display updated count after increment button click', () => { + const { buttons, p } = setup(); + TestUtils.Simulate.click(buttons[0]); + expect(p.textContent).toMatch(/^Clicked: 1 times/); + }); + + it('should display updated count after descrement button click', () => { + const { buttons, p } = setup(); + TestUtils.Simulate.click(buttons[1]); + expect(p.textContent).toMatch(/^Clicked: -1 times/); + }); + + it('shouldnt change if even and if odd button clicked', () => { + const { buttons, p } = setup(); + TestUtils.Simulate.click(buttons[2]); + expect(p.textContent).toMatch(/^Clicked: 0 times/); + }); + + it('should change if odd and if odd button clicked', () => { + const { buttons, p } = setup({ counter: 1 }); + TestUtils.Simulate.click(buttons[2]); + expect(p.textContent).toMatch(/^Clicked: 2 times/); + }); + }); +}); diff --git a/test/components/jsdomReact.js b/examples/counter/test/jsdomReact.js similarity index 100% rename from test/components/jsdomReact.js rename to examples/counter/test/jsdomReact.js diff --git a/examples/counter/test/reducers/counter.spec.js b/examples/counter/test/reducers/counter.spec.js new file mode 100644 index 0000000000..cc85aa38be --- /dev/null +++ b/examples/counter/test/reducers/counter.spec.js @@ -0,0 +1,24 @@ +import expect from 'expect'; +import counter from '../../reducers/counter'; +import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../../actions/counter'; + +describe('reducers', () => { + describe('counter', () => { + + it('should handle initial state', () => { + expect(counter(undefined, {})).toBe(0); + }); + + it('should handle INCREMENT_COUNTER', () => { + expect(counter(1, { type: INCREMENT_COUNTER })).toBe(2); + }); + + it('should handle DECREMENT_COUNTER', () => { + expect(counter(1, { type: DECREMENT_COUNTER })).toBe(0); + }); + + it('should handle unknown action type', () => { + expect(counter(1, { type: 'unknown' })).toBe(1); + }); + }); +}); diff --git a/examples/counter/webpack.config.js b/examples/counter/webpack.config.js index 032d620fd2..2061e19a48 100644 --- a/examples/counter/webpack.config.js +++ b/examples/counter/webpack.config.js @@ -18,13 +18,21 @@ module.exports = { new webpack.NoErrorsPlugin() ], resolve: { + alias: { + 'redux': path.join(__dirname, '..', '..', 'src') + }, extensions: ['', '.js'] }, module: { loaders: [{ test: /\.js$/, loaders: ['react-hot', 'babel'], - exclude: /node_modules/ + exclude: /node_modules/, + include: __dirname + }, { + test: /\.js$/, + loaders: ['babel'], + include: path.join(__dirname, '..', '..', 'src') }] } }; diff --git a/examples/real-world/.babelrc b/examples/real-world/.babelrc new file mode 100644 index 0000000000..cab5d10d92 --- /dev/null +++ b/examples/real-world/.babelrc @@ -0,0 +1,3 @@ +{ + "stage": 2 +} diff --git a/examples/real-world/actions/index.js b/examples/real-world/actions/index.js new file mode 100644 index 0000000000..fc2a1c6386 --- /dev/null +++ b/examples/real-world/actions/index.js @@ -0,0 +1,148 @@ +import { CALL_API, Schemas } from '../middleware/api'; + +export const USER_REQUEST = 'USER_REQUEST'; +export const USER_SUCCESS = 'USER_SUCCESS'; +export const USER_FAILURE = 'USER_FAILURE'; +/** + * Fetches a single user from Github API. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchUser(login) { + return { + [CALL_API]: { + types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE], + endpoint: `users/${login}`, + schema: Schemas.USER + } + }; +} +/** + * Fetches a single user from Github API unless it is cached. + * Relies on Redux Thunk middleware. + */ +export function loadUser(login, requiredFields = []) { + return (dispatch, getState) => { + const user = getState().entities.users[login]; + if (user && requiredFields.every(key => user.hasOwnProperty(key))) { + return null; + } + + return dispatch(fetchUser(login)); + }; +} + +export const REPO_REQUEST = 'REPO_REQUEST'; +export const REPO_SUCCESS = 'REPO_SUCCESS'; +export const REPO_FAILURE = 'REPO_FAILURE'; +/** + * Fetches a single repository from Github API. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchRepo(fullName) { + return { + [CALL_API]: { + types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE], + endpoint: `repos/${fullName}`, + schema: Schemas.REPO + } + }; +} +/** + * Fetches a single repository from Github API unless it is cached. + * Relies on Redux Thunk middleware. + */ +export function loadRepo(fullName, requiredFields = []) { + return (dispatch, getState) => { + const repo = getState().entities.repos[fullName]; + if (repo && requiredFields.every(key => repo.hasOwnProperty(key))) { + return null; + } + + return dispatch(fetchRepo(fullName)); + }; +} + +export const STARRED_REQUEST = 'STARRED_REQUEST'; +export const STARRED_SUCCESS = 'STARRED_SUCCESS'; +export const STARRED_FAILURE = 'STARRED_FAILURE'; +/** + * Fetches a page of starred repos by a particular user. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchStarred(login, nextPageUrl) { + return { + login, + [CALL_API]: { + types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY + } + }; +} +/** + * Fetches a page of starred repos by a particular user. + * Bails out if page is cached and user didn’t specifically request next page. + * Relies on Redux Thunk middleware. + */ +export function loadStarred(login, nextPage) { + return (dispatch, getState) => { + const { + nextPageUrl = `users/${login}/starred`, + pageCount = 0 + } = getState().pagination.starredByUser[login] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStarred(login, nextPageUrl)); + }; +} + + +export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; +export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; +export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; +/** + * Fetches a page of stargazers for a particular repo. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchStargazers(fullName, nextPageUrl) { + return { + fullName, + [CALL_API]: { + types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY + } + }; +} +/** + * Fetches a page of stargazers for a particular repo. + * Bails out if page is cached and user didn’t specifically request next page. + * Relies on Redux Thunk middleware. + */ +export function loadStargazers(fullName, nextPage) { + return (dispatch, getState) => { + const { + nextPageUrl = `repos/${fullName}/stargazers`, + pageCount = 0 + } = getState().pagination.stargazersByRepo[fullName] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStargazers(fullName, nextPageUrl)); + }; +} + +export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; +/** + * Resets the currently visible error message. + */ +export function resetErrorMessage() { + return { + type: RESET_ERROR_MESSAGE + }; +} diff --git a/examples/real-world/components/Explore.js b/examples/real-world/components/Explore.js new file mode 100644 index 0000000000..7787aa5d7c --- /dev/null +++ b/examples/real-world/components/Explore.js @@ -0,0 +1,61 @@ +import React, { Component, PropTypes, findDOMNode } from 'react'; + +const GITHUB_REPO = 'https://github.com/gaearon/redux'; + +export default class Explore extends Component { + constructor(props) { + super(props); + this.handleKeyUp = this.handleKeyUp.bind(this); + this.handleGoClick = this.handleGoClick.bind(this); + } + + getInputValue() { + return findDOMNode(this.refs.input).value; + } + + setInputValue(val) { + // Generally mutating DOM is a bad idea in React components, + // but doing this for a single uncontrolled field is less fuss + // than making it controlled and maintaining a state for it. + findDOMNode(this.refs.input).value = val; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.value !== this.props.value) { + this.setInputValue(nextProps.value); + } + } + + render() { + return ( +
+

Type a username or repo full name and hit 'Go':

+ + +

+ Code on Github. +

+
+ ); + } + + handleKeyUp(e) { + if (e.keyCode === 13) { + this.handleGoClick(); + } + } + + handleGoClick() { + this.props.onChange(this.getInputValue()) + } +} + +Explore.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; diff --git a/examples/real-world/components/List.js b/examples/real-world/components/List.js new file mode 100644 index 0000000000..83a218870e --- /dev/null +++ b/examples/real-world/components/List.js @@ -0,0 +1,50 @@ +import React, { Component, PropTypes } from 'react'; + +export default class List extends Component { + render() { + const { + isFetching, nextPageUrl, pageCount, + items, renderItem, loadingLabel + } = this.props; + + const isEmpty = items.length === 0; + if (isEmpty && isFetching) { + return

{loadingLabel}

; + } + + const isLastPage = !nextPageUrl; + if (isEmpty && isLastPage) { + return

Nothing here!

; + } + + return ( +
+ {items.map(renderItem)} + {pageCount > 0 && !isLastPage && this.renderLoadMore()} +
+ ); + } + + renderLoadMore() { + const { isFetching, onLoadMoreClick } = this.props; + return ( + + ); + } +} + +List.propTypes = { + loadingLabel: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + onLoadMoreClick: PropTypes.func.isRequired, + nextPageUrl: PropTypes.string +}; + +List.defaultProps = { + isFetching: true, + loadingLabel: 'Loading...' +}; diff --git a/examples/real-world/components/Repo.js b/examples/real-world/components/Repo.js new file mode 100644 index 0000000000..06e0605e38 --- /dev/null +++ b/examples/real-world/components/Repo.js @@ -0,0 +1,38 @@ +import React, { Component, PropTypes } from 'react'; +import { Link } from 'react-router'; + +export default class Repo extends Component { + + render() { + const { repo, owner } = this.props; + const { login } = owner; + const { name, description } = repo; + + return ( +
+

+ + {name} + + {' by '} + + {login} + +

+ {description && +

{description}

+ } +
+ ); + } +} + +Repo.propTypes = { + repo: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string + }).isRequired, + owner: PropTypes.shape({ + login: PropTypes.string.isRequired + }).isRequired +}; diff --git a/examples/real-world/components/User.js b/examples/real-world/components/User.js new file mode 100644 index 0000000000..995926738a --- /dev/null +++ b/examples/real-world/components/User.js @@ -0,0 +1,27 @@ +import React, { Component, PropTypes } from 'react'; +import { Link } from 'react-router'; + +export default class User extends Component { + render() { + const { login, avatarUrl, name } = this.props.user; + + return ( +
+ + +

+ {login} {name && ({name})} +

+ +
+ ); + } +} + +User.propTypes = { + user: PropTypes.shape({ + login: PropTypes.string.isRequired, + avatarUrl: PropTypes.string.isRequired, + name: PropTypes.string + }).isRequired +}; diff --git a/examples/real-world/containers/App.js b/examples/real-world/containers/App.js new file mode 100644 index 0000000000..654c978f8d --- /dev/null +++ b/examples/real-world/containers/App.js @@ -0,0 +1,84 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import Explore from '../components/Explore'; +import { resetErrorMessage } from '../actions'; + +class App extends Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleDismissClick = this.handleDismissClick.bind(this); + } + + render() { + // Injected by React Router + const { location, children } = this.props; + const { pathname } = location; + const value = pathname.substring(1); + + return ( +
+ +
+ {this.renderErrorMessage()} + {children} +
+ ); + } + + renderErrorMessage() { + const { errorMessage } = this.props; + if (!errorMessage) { + return null; + } + + return ( +

+ {errorMessage} + {' '} + ( + Dismiss + ) +

+ ); + } + + handleDismissClick(e) { + this.props.resetErrorMessage(); + e.preventDefault(); + } + + handleChange(nextValue) { + // Available thanks to contextTypes below + const { router } = this.context; + router.transitionTo(`/${nextValue}`); + } +} + +App.propTypes = { + errorMessage: PropTypes.string, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired + }), + params: PropTypes.shape({ + userLogin: PropTypes.string, + repoName: PropTypes.string + }).isRequired +}; + +App.contextTypes = { + router: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + errorMessage: state.errorMessage + }; +} + +export default connect( + mapStateToProps, + { resetErrorMessage } +)(App); diff --git a/examples/real-world/containers/RepoPage.js b/examples/real-world/containers/RepoPage.js new file mode 100644 index 0000000000..3499cffbaa --- /dev/null +++ b/examples/real-world/containers/RepoPage.js @@ -0,0 +1,106 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { loadRepo, loadStargazers } from '../actions'; +import Repo from '../components/Repo'; +import User from '../components/User'; +import List from '../components/List'; + +function loadData(props) { + const { fullName } = props; + props.loadRepo(fullName, ['description']); + props.loadStargazers(fullName); +} + +class RepoPage extends Component { + constructor(props) { + super(props); + this.renderUser = this.renderUser.bind(this); + this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); + } + + componentWillMount() { + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fullName !== this.props.fullName) { + loadData(nextProps); + } + } + + render() { + const { repo, owner, name } = this.props; + if (!repo || !owner) { + return

Loading {name} details...

; + } + + const { stargazers, stargazersPagination } = this.props; + return ( +
+ +
+ +
+ ); + } + + renderUser(user) { + return ( + + ); + } + + handleLoadMoreClick() { + this.props.loadStargazers(this.props.fullName, true); + } +} + +RepoPage.propTypes = { + repo: PropTypes.object, + fullName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + stargazers: PropTypes.array.isRequired, + stargazersPagination: PropTypes.object, + loadRepo: PropTypes.func.isRequired, + loadStargazers: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { + entities: state.entities, + stargazersByRepo: state.pagination.stargazersByRepo + }; +} + +function mergeProps(stateProps, dispatchProps, ownProps) { + const { entities, stargazersByRepo } = stateProps; + const { login, name } = ownProps.params; + + const fullName = `${login}/${name}`; + const repo = entities.repos[fullName]; + const owner = entities.users[login]; + + const stargazersPagination = stargazersByRepo[fullName] || { ids: [] }; + const stargazers = stargazersPagination.ids.map(id => entities.users[id]); + + return Object.assign({}, dispatchProps, { + fullName, + name, + repo, + owner, + stargazers, + stargazersPagination + }); +} + +export default connect( + mapStateToProps, + { loadRepo, loadStargazers }, + mergeProps +)(RepoPage); diff --git a/examples/real-world/containers/Root.js b/examples/real-world/containers/Root.js new file mode 100644 index 0000000000..bce978dc6b --- /dev/null +++ b/examples/real-world/containers/Root.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Provider } from 'react-redux'; +import { Router, Route } from 'react-router'; +import configureStore from '../store/configureStore'; +import App from './App'; +import UserPage from './UserPage'; +import RepoPage from './RepoPage'; + +const store = configureStore(); + +export default class Root extends Component { + render() { + return ( +
+ + {() => + + + + + + + } + +
+ ); + } +} diff --git a/examples/real-world/containers/UserPage.js b/examples/real-world/containers/UserPage.js new file mode 100644 index 0000000000..4b333a8012 --- /dev/null +++ b/examples/real-world/containers/UserPage.js @@ -0,0 +1,104 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { loadUser, loadStarred } from '../actions'; +import User from '../components/User'; +import Repo from '../components/Repo'; +import List from '../components/List'; +import zip from 'lodash/array/zip'; + +function loadData(props) { + const { login } = props; + props.loadUser(login, ['name']); + props.loadStarred(login); +} + +class UserPage extends Component { + constructor(props) { + super(props); + this.renderRepo = this.renderRepo.bind(this); + this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); + } + + componentWillMount() { + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.login !== this.props.login) { + loadData(nextProps); + } + } + + render() { + const { user, login } = this.props; + if (!user) { + return

Loading {login}’s profile...

; + } + + const { starredRepos, starredRepoOwners, starredPagination } = this.props; + return ( +
+ +
+ +
+ ); + } + + renderRepo([repo, owner]) { + return ( + + ); + } + + handleLoadMoreClick() { + this.props.loadStarred(this.props.login, true); + } +} + +UserPage.propTypes = { + login: PropTypes.string.isRequired, + user: PropTypes.object, + starredPagination: PropTypes.object, + starredRepos: PropTypes.array.isRequired, + starredRepoOwners: PropTypes.array.isRequired, + loadUser: PropTypes.func.isRequired, + loadStarred: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { + entities: state.entities, + starredByUser: state.pagination.starredByUser + }; +} + +function mergeProps(stateProps, dispatchProps, ownProps) { + const { entities, starredByUser } = stateProps; + const { login } = ownProps.params; + + const user = entities.users[login]; + const starredPagination = starredByUser[login] || { ids: [] }; + const starredRepos = starredPagination.ids.map(id => entities.repos[id]); + const starredRepoOwners = starredRepos.map(repo => entities.users[repo.owner]); + + return Object.assign({}, dispatchProps, { + login, + user, + starredPagination, + starredRepos, + starredRepoOwners + }); +} + +export default connect( + mapStateToProps, + { loadUser, loadStarred }, + mergeProps +)(UserPage); diff --git a/examples/real-world/index.html b/examples/real-world/index.html new file mode 100644 index 0000000000..c26e4dad44 --- /dev/null +++ b/examples/real-world/index.html @@ -0,0 +1,10 @@ + + + Redux real-world example + + +
+
+ + + diff --git a/examples/real-world/index.js b/examples/real-world/index.js new file mode 100644 index 0000000000..7127e34fa7 --- /dev/null +++ b/examples/real-world/index.js @@ -0,0 +1,9 @@ +import 'babel-core/polyfill'; +import React from 'react'; +import Root from './containers/Root'; +import BrowserHistory from 'react-router/lib/BrowserHistory'; + +React.render( + , + document.getElementById('root') +); diff --git a/examples/real-world/middleware/api.js b/examples/real-world/middleware/api.js new file mode 100644 index 0000000000..1f5569e200 --- /dev/null +++ b/examples/real-world/middleware/api.js @@ -0,0 +1,142 @@ +import { Schema, arrayOf, normalize } from 'normalizr'; +import { camelizeKeys } from 'humps'; +import 'isomorphic-fetch'; + +/** + * Extracts the next page URL from Github API response. + */ +function getNextPageUrl(response) { + const link = response.headers.get('link'); + if (!link) { + return null; + } + + const nextLink = link.split(',').filter(s => s.indexOf('rel="next"') > -1)[0]; + if (!nextLink) { + return null; + } + + return nextLink.split(';')[0].slice(1, -1); +} + +const API_ROOT = 'https://api.github.com/'; + +/** + * Fetches an API response and normalizes the result JSON according to schema. + * This makes every API response have the same shape, regardless of how nested it was. + */ +function callApi(endpoint, schema) { + if (endpoint.indexOf(API_ROOT) === -1) { + endpoint = API_ROOT + endpoint; + } + + return fetch(endpoint) + .then(response => + response.json().then(json => ({ json, response})) + ).then(({ json, response }) => { + if (!response.ok) { + return Promise.reject(json); + } + + const camelizedJson = camelizeKeys(json); + const nextPageUrl = getNextPageUrl(response) || undefined; + + return Object.assign({}, + normalize(camelizedJson, schema), + { nextPageUrl } + ); + }); +} + +// We use this Normalizr schemas to transform API responses from a nested form +// to a flat form where repos and users are placed in `entities`, and nested +// JSON objects are replaced with their IDs. This is very convenient for +// consumption by reducers, because we can easily build a normalized tree +// and keep it updated as we fetch more data. + +// Read more about Normalizr: https://github.com/gaearon/normalizr + +const userSchema = new Schema('users', { + idAttribute: 'login' +}); + +const repoSchema = new Schema('repos', { + idAttribute: 'fullName' +}); + +repoSchema.define({ + owner: userSchema +}); + +/** + * Schemas for Github API responses. + */ +export const Schemas = { + USER: userSchema, + USER_ARRAY: arrayOf(userSchema), + REPO: repoSchema, + REPO_ARRAY: arrayOf(repoSchema) +}; + +/** + * Action key that carries API call info interpreted by this Redux middleware. + */ +export const CALL_API = Symbol('Call API'); + +/** + * A Redux middleware that interprets actions with CALL_API info specified. + * Performs the call and promises when such actions are dispatched. + */ +export default store => next => action => { + const callAPI = action[CALL_API]; + if (typeof callAPI === 'undefined') { + return next(action); + } + + let { endpoint } = callAPI; + const { schema, types, bailout } = callAPI; + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()); + } + + if (typeof endpoint !== 'string') { + throw new Error('Specify a string endpoint URL.'); + } + if (!schema) { + throw new Error('Specify one of the exported Schemas.'); + } + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected an array of three action types.'); + } + if (!types.every(type => typeof type === 'string')) { + throw new Error('Expected action types to be strings.'); + } + if (typeof bailout !== 'undefined' && typeof bailout !== 'function') { + throw new Error('Expected bailout to either be undefined or a function.'); + } + + if (bailout && bailout(store.getState())) { + return Promise.resolve(); + } + + function actionWith(data) { + const finalAction = Object.assign({}, action, data); + delete finalAction[CALL_API]; + return finalAction; + } + + const [requestType, successType, failureType] = types; + next(actionWith({ type: requestType })); + + return callApi(endpoint, schema).then( + response => next(actionWith({ + response, + type: successType + })), + error => next(actionWith({ + type: failureType, + error: error.message || 'Something bad happened' + })) + ); +}; diff --git a/examples/real-world/package.json b/examples/real-world/package.json new file mode 100644 index 0000000000..23ccd4f2af --- /dev/null +++ b/examples/real-world/package.json @@ -0,0 +1,48 @@ +{ + "name": "redux-real-world-example", + "version": "0.0.0", + "description": "Redux real-world example", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/gaearon/redux.git" + }, + "keywords": [ + "react", + "reactjs", + "hot", + "reload", + "hmr", + "live", + "edit", + "webpack", + "flux" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/gaearon/redux/issues" + }, + "homepage": "https://github.com/gaearon/redux#readme", + "dependencies": { + "humps": "^0.6.0", + "isomorphic-fetch": "^2.1.1", + "lodash": "^3.10.1", + "normalizr": "^0.1.3", + "react": "^0.13.3", + "react-redux": "^0.8.0", + "react-router": "^1.0.0-beta3", + "redux": "^1.0.0-rc", + "redux-logger": "0.0.1", + "redux-thunk": "^0.1.0" + }, + "devDependencies": { + "babel-core": "^5.6.18", + "babel-loader": "^5.1.4", + "react-hot-loader": "^1.2.7", + "webpack": "^1.9.11", + "webpack-dev-server": "^1.9.0" + } +} diff --git a/examples/real-world/reducers/index.js b/examples/real-world/reducers/index.js new file mode 100644 index 0000000000..125a0f5c4e --- /dev/null +++ b/examples/real-world/reducers/index.js @@ -0,0 +1,52 @@ +import * as ActionTypes from '../actions'; +import merge from 'lodash/object/merge'; +import paginate from './paginate'; +import { combineReducers } from 'redux'; + +/** + * Updates an entity cache in response to any action with response.entities. + */ +export function entities(state = { users: {}, repos: {} }, action) { + if (action.response && action.response.entities) { + return merge({}, state, action.response.entities); + } + + return state; +} + +/** + * Updates error message to notify about the failed fetches. + */ +export function errorMessage(state = null, action) { + const { type, error } = action; + + if (type === ActionTypes.RESET_ERROR_MESSAGE) { + return null; + } else if (error) { + return action.error; + } + + return state; +} + +/** + * Updates the pagination data for different actions. + */ +export const pagination = combineReducers({ + starredByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.STARRED_REQUEST, + ActionTypes.STARRED_SUCCESS, + ActionTypes.STARRED_FAILURE + ] + }), + stargazersByRepo: paginate({ + mapActionToKey: action => action.fullName, + types: [ + ActionTypes.STARGAZERS_REQUEST, + ActionTypes.STARGAZERS_SUCCESS, + ActionTypes.STARGAZERS_FAILURE + ] + }) +}); diff --git a/examples/real-world/reducers/paginate.js b/examples/real-world/reducers/paginate.js new file mode 100644 index 0000000000..05c682edd5 --- /dev/null +++ b/examples/real-world/reducers/paginate.js @@ -0,0 +1,64 @@ +import merge from 'lodash/object/merge'; +import union from 'lodash/array/union'; + +/** + * Creates a reducer managing pagination, given the action types to handle, + * and a function telling how to extract the key from an action. + */ +export default function paginate({ types, mapActionToKey }) { + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected types to be an array of three elements.'); + } + if (!types.every(t => typeof t === 'string')) { + throw new Error('Expected types to be strings.'); + } + if (typeof mapActionToKey !== 'function') { + throw new Error('Expected mapActionToKey to be a function.'); + } + + const [requestType, successType, failureType] = types; + + function updatePagination(state = { + isFetching: false, + nextPageUrl: undefined, + pageCount: 0, + ids: [] + }, action) { + switch (action.type) { + case requestType: + return merge({}, state, { + isFetching: true + }); + case successType: + return merge({}, state, { + isFetching: false, + ids: union(state.ids, action.response.result), + nextPageUrl: action.response.nextPageUrl, + pageCount: state.pageCount + 1 + }); + case failureType: + return merge({}, state, { + isFetching: false + }); + default: + return state; + } + } + + return function updatePaginationByKey(state = {}, action) { + switch (action.type) { + case requestType: + case successType: + case failureType: + const key = mapActionToKey(action); + if (typeof key !== 'string') { + throw new Error('Expected key to be a string.'); + } + return merge({}, state, { + [key]: updatePagination(state[key], action) + }); + default: + return state; + } + }; +} diff --git a/examples/real-world/server.js b/examples/real-world/server.js new file mode 100644 index 0000000000..ff92aa06b6 --- /dev/null +++ b/examples/real-world/server.js @@ -0,0 +1,18 @@ +var webpack = require('webpack'); +var WebpackDevServer = require('webpack-dev-server'); +var config = require('./webpack.config'); + +new WebpackDevServer(webpack(config), { + publicPath: config.output.publicPath, + hot: true, + historyApiFallback: true, + stats: { + colors: true + } +}).listen(3000, 'localhost', function (err) { + if (err) { + console.log(err); + } + + console.log('Listening at localhost:3000'); +}); diff --git a/examples/real-world/store/configureStore.js b/examples/real-world/store/configureStore.js new file mode 100644 index 0000000000..8ab6f80b10 --- /dev/null +++ b/examples/real-world/store/configureStore.js @@ -0,0 +1,19 @@ +import { createStore, applyMiddleware, combineReducers } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import apiMiddleware from '../middleware/api'; +import loggerMiddleware from 'redux-logger'; +import * as reducers from '../reducers'; + +const reducer = combineReducers(reducers); +const createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, + apiMiddleware, + loggerMiddleware +)(createStore); + +/** + * Creates a preconfigured store for this example. + */ +export default function configureStore(initialState) { + return createStoreWithMiddleware(reducer, initialState); +} diff --git a/examples/real-world/webpack.config.js b/examples/real-world/webpack.config.js new file mode 100644 index 0000000000..2061e19a48 --- /dev/null +++ b/examples/real-world/webpack.config.js @@ -0,0 +1,38 @@ +var path = require('path'); +var webpack = require('webpack'); + +module.exports = { + devtool: 'eval', + entry: [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + './index' + ], + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/' + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ], + resolve: { + alias: { + 'redux': path.join(__dirname, '..', '..', 'src') + }, + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['react-hot', 'babel'], + exclude: /node_modules/, + include: __dirname + }, { + test: /\.js$/, + loaders: ['babel'], + include: path.join(__dirname, '..', '..', 'src') + }] + } +}; diff --git a/examples/todomvc/.babelrc b/examples/todomvc/.babelrc index b0b9a96ef0..cab5d10d92 100644 --- a/examples/todomvc/.babelrc +++ b/examples/todomvc/.babelrc @@ -1,3 +1,3 @@ { - "stage": 0 + "stage": 2 } diff --git a/examples/todomvc/actions/TodoActions.js b/examples/todomvc/actions/TodoActions.js deleted file mode 100644 index f8cfb289f3..0000000000 --- a/examples/todomvc/actions/TodoActions.js +++ /dev/null @@ -1,42 +0,0 @@ -import * as types from '../constants/ActionTypes'; - -export function addTodo(text) { - return { - type: types.ADD_TODO, - text - }; -} - -export function deleteTodo(id) { - return { - type: types.DELETE_TODO, - id - }; -} - -export function editTodo(id, text) { - return { - type: types.EDIT_TODO, - id, - text - }; -} - -export function markTodo(id) { - return { - type: types.MARK_TODO, - id - }; -} - -export function markAll() { - return { - type: types.MARK_ALL - }; -} - -export function clearMarked() { - return { - type: types.CLEAR_MARKED - }; -} diff --git a/examples/todomvc/actions/todos.js b/examples/todomvc/actions/todos.js new file mode 100644 index 0000000000..6424722129 --- /dev/null +++ b/examples/todomvc/actions/todos.js @@ -0,0 +1,25 @@ +import * as types from '../constants/ActionTypes'; + +export function addTodo(text) { + return { type: types.ADD_TODO, text }; +} + +export function deleteTodo(id) { + return { type: types.DELETE_TODO, id }; +} + +export function editTodo(id, text) { + return { type: types.EDIT_TODO, id, text }; +} + +export function completeTodo(id) { + return { type: types.COMPLETE_TODO, id }; +} + +export function completeAll() { + return { type: types.COMPLETE_ALL }; +} + +export function clearCompleted() { + return { type: types.CLEAR_COMPLETED }; +} diff --git a/examples/todomvc/components/Footer.js b/examples/todomvc/components/Footer.js index b8feaa92c0..b52fabae10 100644 --- a/examples/todomvc/components/Footer.js +++ b/examples/todomvc/components/Footer.js @@ -1,28 +1,20 @@ import React, { PropTypes, Component } from 'react'; import classnames from 'classnames'; -import { SHOW_ALL, SHOW_MARKED, SHOW_UNMARKED } from '../constants/TodoFilters'; +import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'; const FILTER_TITLES = { [SHOW_ALL]: 'All', - [SHOW_UNMARKED]: 'Active', - [SHOW_MARKED]: 'Completed' + [SHOW_ACTIVE]: 'Active', + [SHOW_COMPLETED]: 'Completed' }; -export default class Footer extends Component { - static propTypes = { - markedCount: PropTypes.number.isRequired, - unmarkedCount: PropTypes.number.isRequired, - filter: PropTypes.string.isRequired, - onClearMarked: PropTypes.func.isRequired, - onShow: PropTypes.func.isRequired - } - +class Footer extends Component { render() { return (