This commit is contained in:
Nathan Bierema 2021-10-28 16:17:09 -04:00
parent 39b0fe377e
commit e972239059
74 changed files with 1381 additions and 1034 deletions

View File

@ -3,5 +3,5 @@ module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
}
},
};

View File

@ -1,5 +1,4 @@
Remote Redux DevTools
=====================
# Remote Redux DevTools
![Demo](demo.gif)
@ -18,60 +17,79 @@ There are 2 ways of usage depending if you're using other store enhancers (middl
#### Add DevTools enhancer to your store
If you have a basic [store](http://redux.js.org/docs/api/createStore.html) as described in the official [redux-docs](http://redux.js.org/index.html), simply replace:
```javascript
import { createStore } from 'redux';
const store = createStore(reducer);
```
with
```javascript
import { createStore } from 'redux';
import devToolsEnhancer from '@redux-devtools/remote';
const store = createStore(reducer, devToolsEnhancer());
// or const store = createStore(reducer, preloadedState, devToolsEnhancer());
```
```javascript
import { createStore } from 'redux';
const store = createStore(reducer);
```
with
```javascript
import { createStore } from 'redux';
import devToolsEnhancer from '@redux-devtools/remote';
const store = createStore(reducer, devToolsEnhancer());
// or const store = createStore(reducer, preloadedState, devToolsEnhancer());
```
> Note: passing enhancer as last argument requires redux@>=3.1.0
#### When to use DevTools compose helper
If you setup your store with [middlewares and enhancers](http://redux.js.org/docs/api/applyMiddleware.html) like [redux-saga](https://github.com/redux-saga/redux-saga) and similar, it is crucial to use `composeWithDevTools` export. Otherwise, actions dispatched from Redux DevTools will not flow to your middlewares.
If you setup your store with [middlewares and enhancers](http://redux.js.org/docs/api/applyMiddleware.html) like [redux-saga](https://github.com/redux-saga/redux-saga) and similar, it is crucial to use `composeWithDevTools` export. Otherwise, actions dispatched from Redux DevTools will not flow to your middlewares.
In that case change this:
```javascript
import { createStore, applyMiddleware, compose } from 'redux';
In that case change this:
const store = createStore(reducer, preloadedState, compose(
applyMiddleware(...middleware),
```javascript
import { createStore, applyMiddleware, compose } from 'redux';
const store = createStore(
reducer,
preloadedState,
compose(
applyMiddleware(...middleware)
// other store enhancers if any
));
```
to:
```javascript
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/remote';
)
);
```
const store = createStore(reducer, /* preloadedState, */ composeWithDevTools(
applyMiddleware(...middleware),
// other store enhancers if any
));
```
or with devTools' options:
```javascript
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/remote';
to:
const composeEnhancers = composeWithDevTools({ realtime: true, port: 8000 });
const store = createStore(reducer, /* preloadedState, */ composeEnhancers(
applyMiddleware(...middleware),
```javascript
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/remote';
const store = createStore(
reducer,
/* preloadedState, */ composeWithDevTools(
applyMiddleware(...middleware)
// other store enhancers if any
));
```
)
);
```
or with devTools' options:
```javascript
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/remote';
const composeEnhancers = composeWithDevTools({ realtime: true, port: 8000 });
const store = createStore(
reducer,
/* preloadedState, */ composeEnhancers(
applyMiddleware(...middleware)
// other store enhancers if any
)
);
```
### Enabling
In order not to allow it in production by default, the enhancer will have effect only when `process.env.NODE_ENV === 'development'`.
For Webpack you should add it as following (`webpack.config.dev.js`):
```js
// ...
plugins: [
@ -91,11 +109,12 @@ const store = createStore(reducer, devToolsEnhancer({ realtime: true }));
### Monitoring
Use one of our monitor apps to inspect and dispatch actions:
* [redux-devtools-extension](https://github.com/reduxjs/redux-devtools/tree/main/extension) - Click "Remote" button (or press [`Cmd+Ctrl+Arrow up`](https://github.com/zalmoxisus/redux-devtools-extension#keyboard-shortcuts)) to open remote monitoring.
* [remotedev-rn-debugger](https://github.com/jhen0409/remotedev-rn-debugger) - Used in React Native debugger as a dock monitor.
* [atom-redux-devtools](https://github.com/zalmoxisus/atom-redux-devtools) - Used in Atom editor.
* [redux-dispatch-cli](https://github.com/jhen0409/redux-dispatch-cli) - A CLI tool for Redux remote dispatch.
* [vscode-redux-devtools](https://github.com/jkzing/vscode-redux-devtools) - Used in Visual Studio Code.
- [redux-devtools-extension](https://github.com/reduxjs/redux-devtools/tree/main/extension) - Click "Remote" button (or press [`Cmd+Ctrl+Arrow up`](https://github.com/zalmoxisus/redux-devtools-extension#keyboard-shortcuts)) to open remote monitoring.
- [remotedev-rn-debugger](https://github.com/jhen0409/remotedev-rn-debugger) - Used in React Native debugger as a dock monitor.
- [atom-redux-devtools](https://github.com/zalmoxisus/atom-redux-devtools) - Used in Atom editor.
- [redux-dispatch-cli](https://github.com/jhen0409/redux-dispatch-cli) - A CLI tool for Redux remote dispatch.
- [vscode-redux-devtools](https://github.com/jkzing/vscode-redux-devtools) - Used in Visual Studio Code.
Use [@redux-devtools/app](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-app) to create your own monitor app.
@ -103,57 +122,63 @@ Use [@redux-devtools/app](https://github.com/reduxjs/redux-devtools/tree/main/pa
Use [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli).
You can import it in your `server.js` script and start remotedev server together with your development server:
```js
var reduxDevTools = require('@redux-devtools/cli');
reduxDevTools({ hostname: 'localhost', port: 8000 });
```
See [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli) for more details.
For React Native you can use [remotedev-rn-debugger](https://github.com/jhen0409/remotedev-rn-debugger), which already include `@redux-devtools/cli`.
### Parameters
Name | Description
------------- | -------------
`name` | *String* representing the instance name to be shown on the remote monitor.
`realtime` | *Boolean* specifies whether to allow remote monitoring. By default is `process.env.NODE_ENV === 'development'`.
`hostname` | *String* used to specify host for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli). If `port` is specified, default value is `localhost`.
`port` | *Number* used to specify host's port for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli).
`secure` | *Boolean* specifies whether to use `https` protocol for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli).
`maxAge` | *Number* of maximum allowed actions to be stored on the history tree, the oldest actions are removed once maxAge is reached. Default is `30`.
`actionsBlacklist` | *array* of actions to be hidden in DevTools. Overwrites corresponding global setting in the options page. See the example bellow.
`actionsWhitelist` | *array* of actions to be shown. All other actions will be hidden in DevTools.
`actionSanitizer` | *Function* which takes action object and id number as arguments, and should return action object back. See the example bellow.
`stateSanitizer` | *Function* which takes state object and index as arguments, and should return state object back. See the example bellow.
`startOn` | *String* or *Array of strings* indicating an action or a list of actions, which should start remote monitoring (when `realtime` is `false`).
`stopOn` | *String* or *Array of strings* indicating an action or a list of actions, which should stop remote monitoring.
`sendOn` | *String* or *Array of strings* indicating an action or a list of actions, which should trigger sending the history to the monitor (without starting it). *Note*: when using it, add a `fetch` polyfill if needed.
`sendOnError` | *Numeric* code: `0` - disabled (default), `1` - send all uncaught exception messages, `2` - send only reducers error messages.
`sendTo` | *String* url of the monitor to send the history when `sendOn` is triggered. By default is `${secure ? 'https' : 'http'}://${hostname}:${port}`.
`actionCreators` | *Array* or *Object* of action creators to dispatch remotely. See [the example](https://github.com/zalmoxisus/remote-redux-devtools/commit/b54652930dfd4e057991df8471c343957fd7bff7).
`shouldHotReload` | *Boolean* - if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Default to `true`.
`shouldRecordChanges` | *Boolean* - if specified as `false`, it will not record the changes till clicked on "Start recording" button on the monitor app. Default is `true`.
`shouldStartLocked` | *Boolean* - if specified as `true`, it will not allow any non-monitor actions to be dispatched till `lockChanges(false)` is dispatched. Default is `false`.
`id` | *String* to identify the instance when sending the history triggered by `sendOn`. You can use, for example, user id here, to know who sent the data.
`suppressConnectErrors` | *Boolean* - if set to `false`, all socket errors thrown while trying to connect will be printed to the console, regardless of if they've been thrown before. This is primarily for suppressing `SocketProtocolError` errors, which get repeatedly thrown when trying to make a connection. Default is `true`.
| Name | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | _String_ representing the instance name to be shown on the remote monitor. |
| `realtime` | _Boolean_ specifies whether to allow remote monitoring. By default is `process.env.NODE_ENV === 'development'`. |
| `hostname` | _String_ used to specify host for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli). If `port` is specified, default value is `localhost`. |
| `port` | _Number_ used to specify host's port for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli). |
| `secure` | _Boolean_ specifies whether to use `https` protocol for [@redux-devtools/cli](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-cli). |
| `maxAge` | _Number_ of maximum allowed actions to be stored on the history tree, the oldest actions are removed once maxAge is reached. Default is `30`. |
| `actionsBlacklist` | _array_ of actions to be hidden in DevTools. Overwrites corresponding global setting in the options page. See the example bellow. |
| `actionsWhitelist` | _array_ of actions to be shown. All other actions will be hidden in DevTools. |
| `actionSanitizer` | _Function_ which takes action object and id number as arguments, and should return action object back. See the example bellow. |
| `stateSanitizer` | _Function_ which takes state object and index as arguments, and should return state object back. See the example bellow. |
| `startOn` | _String_ or _Array of strings_ indicating an action or a list of actions, which should start remote monitoring (when `realtime` is `false`). |
| `stopOn` | _String_ or _Array of strings_ indicating an action or a list of actions, which should stop remote monitoring. |
| `sendOn` | _String_ or _Array of strings_ indicating an action or a list of actions, which should trigger sending the history to the monitor (without starting it). _Note_: when using it, add a `fetch` polyfill if needed. |
| `sendOnError` | _Numeric_ code: `0` - disabled (default), `1` - send all uncaught exception messages, `2` - send only reducers error messages. |
| `sendTo` | _String_ url of the monitor to send the history when `sendOn` is triggered. By default is `${secure ? 'https' : 'http'}://${hostname}:${port}`. |
| `actionCreators` | _Array_ or _Object_ of action creators to dispatch remotely. See [the example](https://github.com/zalmoxisus/remote-redux-devtools/commit/b54652930dfd4e057991df8471c343957fd7bff7). |
| `shouldHotReload` | _Boolean_ - if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Default to `true`. |
| `shouldRecordChanges` | _Boolean_ - if specified as `false`, it will not record the changes till clicked on "Start recording" button on the monitor app. Default is `true`. |
| `shouldStartLocked` | _Boolean_ - if specified as `true`, it will not allow any non-monitor actions to be dispatched till `lockChanges(false)` is dispatched. Default is `false`. |
| `id` | _String_ to identify the instance when sending the history triggered by `sendOn`. You can use, for example, user id here, to know who sent the data. |
| `suppressConnectErrors` | _Boolean_ - if set to `false`, all socket errors thrown while trying to connect will be printed to the console, regardless of if they've been thrown before. This is primarily for suppressing `SocketProtocolError` errors, which get repeatedly thrown when trying to make a connection. Default is `true`. |
All parameters are optional. You have to provide at least `port` property to use `localhost` instead of `remotedev.io` server.
Example:
```js
export default function configureStore(preloadedState) {
const store = createStore(
reducer,
preloadedState,
devToolsEnhancer({
name: 'Android app', realtime: true,
hostname: 'localhost', port: 8000,
maxAge: 30, actionsBlacklist: ['EFFECT_RESOLVED'],
actionSanitizer: (action) => (
action.type === 'FILE_DOWNLOAD_SUCCESS' && action.data ?
{ ...action, data: '<<LONG_BLOB>>' } : action
),
stateSanitizer: (state) => state.data ? { ...state, data: '<<LONG_BLOB>>' } : state
name: 'Android app',
realtime: true,
hostname: 'localhost',
port: 8000,
maxAge: 30,
actionsBlacklist: ['EFFECT_RESOLVED'],
actionSanitizer: (action) =>
action.type === 'FILE_DOWNLOAD_SUCCESS' && action.data
? { ...action, data: '<<LONG_BLOB>>' }
: action,
stateSanitizer: (state) =>
state.data ? { ...state, data: '<<LONG_BLOB>>' } : state,
})
);
return store;
@ -161,9 +186,11 @@ export default function configureStore(preloadedState) {
```
### Demo
- [Toggle monitoring](http://zalmoxisus.github.io/monitoring/)
### Examples
- [Web](https://github.com/reduxjs/redux-devtools/tree/main/packages/redux-devtools-remote/examples)
- [React Native](https://github.com/chentsulin/react-native-counter-ios-android)

View File

@ -13,7 +13,7 @@ var exampleDirs = fs.readdirSync(__dirname).filter((file) => {
// Ordering is important here. `npm install` must come first.
var cmdArgs = [
{ cmd: 'npm', args: ['install'] },
{ cmd: 'webpack', args: ['index.js'] }
{ cmd: 'webpack', args: ['index.js'] },
];
for (const dir of exampleDirs) {
@ -21,7 +21,7 @@ for (const dir of exampleDirs) {
// declare opts in this scope to avoid https://github.com/joyent/node/issues/9158
const opts = {
cwd: path.join(__dirname, dir),
stdio: 'inherit'
stdio: 'inherit',
};
let result = {};
if (process.platform === 'win32') {

View File

@ -1,3 +1,3 @@
{
"presets": [ "es2015", "stage-0", "react" ]
"presets": ["es2015", "stage-0", "react"]
}

View File

@ -3,13 +3,13 @@ export const DECREMENT_COUNTER = 'DECREMENT_COUNTER';
export function increment() {
return {
type: INCREMENT_COUNTER
type: INCREMENT_COUNTER,
};
}
export function decrement() {
return {
type: DECREMENT_COUNTER
type: DECREMENT_COUNTER,
};
}
@ -26,7 +26,7 @@ export function incrementIfOdd() {
}
export function incrementAsync(delay = 1000) {
return dispatch => {
return (dispatch) => {
setTimeout(() => {
dispatch(increment());
}, delay);

View File

@ -2,17 +2,13 @@ import React, { Component, PropTypes } from 'react';
class Counter extends Component {
render() {
const { increment, incrementIfOdd, incrementAsync, decrement, counter } = this.props;
const { increment, incrementIfOdd, incrementAsync, decrement, counter } =
this.props;
return (
<p>
Clicked: {counter} times
{' '}
<button onClick={increment}>+</button>
{' '}
<button onClick={decrement}>-</button>
{' '}
<button onClick={incrementIfOdd}>Increment if odd</button>
{' '}
Clicked: {counter} times <button onClick={increment}>+</button>{' '}
<button onClick={decrement}>-</button>{' '}
<button onClick={incrementIfOdd}>Increment if odd</button>{' '}
<button onClick={() => incrementAsync()}>Increment async</button>
</p>
);
@ -24,7 +20,7 @@ Counter.propTypes = {
incrementIfOdd: PropTypes.func.isRequired,
incrementAsync: PropTypes.func.isRequired,
decrement: PropTypes.func.isRequired,
counter: PropTypes.number.isRequired
counter: PropTypes.number.isRequired,
};
export default Counter;

View File

@ -5,7 +5,7 @@ import * as CounterActions from '../actions/counter';
function mapStateToProps(state) {
return {
counter: state.counter
counter: state.counter,
};
}

View File

@ -4,8 +4,7 @@
<title>Redux counter example</title>
</head>
<body>
<div id="root">
</div>
<div id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

View File

@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import counter from './counter';
const rootReducer = combineReducers({
counter
counter,
});
export default rootReducer;

View File

@ -7,17 +7,26 @@ var app = new require('express')();
var port = 4001;
var compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
app.use(
webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath,
})
);
app.use(webpackHotMiddleware(compiler));
app.get("/", function(req, res) {
app.get('/', function (req, res) {
res.sendFile(__dirname + '/index.html');
});
app.listen(port, function(error) {
app.listen(port, function (error) {
if (error) {
console.error(error);
} else {
console.info("==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port);
console.info(
'==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.',
port,
port
);
}
});

View File

@ -6,11 +6,16 @@ import reducer from '../reducers';
import * as actionCreators from '../actions/counter';
export default function configureStore(initialState) {
const composeEnhancers = composeWithDevTools({ realtime: true, actionCreators, trace: true });
const store = createStore(reducer, initialState, composeEnhancers(
applyMiddleware(invariant(), thunk)
));
const composeEnhancers = composeWithDevTools({
realtime: true,
actionCreators,
trace: true,
});
const store = createStore(
reducer,
initialState,
composeEnhancers(applyMiddleware(invariant(), thunk))
);
if (module.hot) {
// Enable Webpack hot module replacement for reducers

View File

@ -12,16 +12,17 @@ function mockStore(getState, expectedActions, onLastAction) {
if (!Array.isArray(expectedActions)) {
throw new Error('expectedActions should be an array of expected actions.');
}
if (typeof onLastAction !== 'undefined' && typeof onLastAction !== 'function') {
if (
typeof onLastAction !== 'undefined' &&
typeof onLastAction !== 'function'
) {
throw new Error('onLastAction should either be undefined or function.');
}
function mockStoreWithoutMiddleware() {
return {
getState() {
return typeof getState === 'function' ?
getState() :
getState;
return typeof getState === 'function' ? getState() : getState;
},
dispatch(action) {
@ -31,13 +32,13 @@ function mockStore(getState, expectedActions, onLastAction) {
onLastAction();
}
return action;
}
},
};
}
const mockStoreWithMiddleware = applyMiddleware(
...middlewares
)(mockStoreWithoutMiddleware);
const mockStoreWithMiddleware = applyMiddleware(...middlewares)(
mockStoreWithoutMiddleware
);
return mockStoreWithMiddleware();
}
@ -52,9 +53,7 @@ describe('actions', () => {
});
it('incrementIfOdd should create increment action', (done) => {
const expectedActions = [
{ type: actions.INCREMENT_COUNTER }
];
const expectedActions = [{ type: actions.INCREMENT_COUNTER }];
const store = mockStore({ counter: 1 }, expectedActions, done);
store.dispatch(actions.incrementIfOdd());
});
@ -67,9 +66,7 @@ describe('actions', () => {
});
it('incrementAsync should create increment action', (done) => {
const expectedActions = [
{ type: actions.INCREMENT_COUNTER }
];
const expectedActions = [{ type: actions.INCREMENT_COUNTER }];
const store = mockStore({ counter: 0 }, expectedActions, done);
store.dispatch(actions.incrementAsync(100));
});

View File

@ -8,14 +8,16 @@ function setup() {
increment: expect.createSpy(),
incrementIfOdd: expect.createSpy(),
incrementAsync: expect.createSpy(),
decrement: expect.createSpy()
decrement: expect.createSpy(),
};
const component = TestUtils.renderIntoDocument(<Counter counter={1} {...actions} />);
const component = TestUtils.renderIntoDocument(
<Counter counter={1} {...actions} />
);
return {
component: component,
actions: actions,
buttons: TestUtils.scryRenderedDOMComponentsWithTag(component, 'button'),
p: TestUtils.findRenderedDOMComponentWithTag(component, 'p')
p: TestUtils.findRenderedDOMComponentWithTag(component, 'p'),
};
}

View File

@ -15,7 +15,7 @@ function setup(initialState) {
return {
app: app,
buttons: TestUtils.scryRenderedDOMComponentsWithTag(app, 'button'),
p: TestUtils.findRenderedDOMComponentWithTag(app, 'p')
p: TestUtils.findRenderedDOMComponentWithTag(app, 'p'),
};
}

View File

@ -3,28 +3,27 @@ var webpack = require('webpack');
module.exports = {
devtool: 'source-map',
entry: [
'webpack-hot-middleware/client',
'./index'
],
entry: ['webpack-hot-middleware/client', './index'],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
publicPath: '/static/',
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
new webpack.NoErrorsPlugin(),
],
module: {
loaders: [{
loaders: [
{
test: /\.js$/,
loaders: ['babel'],
exclude: /node_modules/,
include: __dirname
}]
}
include: __dirname,
},
],
},
};
var src = path.join(__dirname, '..', '..', 'src');
@ -37,6 +36,6 @@ if (fs.existsSync(src) && fs.existsSync(nodeModules)) {
module.exports.module.loaders.push({
test: /\.js$/,
loaders: ['babel'],
include: src
include: src,
});
}

View File

@ -1,26 +1,28 @@
var createStore = require('redux').createStore
var devTools = require('remote-redux-devtools').default
var createStore = require('redux').createStore;
var devTools = require('remote-redux-devtools').default;
function counter(state, action) {
if (state === undefined) state = 0
if (state === undefined) state = 0;
switch (action.type) {
case 'INCREMENT':
return state + 1
return state + 1;
case 'DECREMENT':
return state - 1
return state - 1;
default:
return state
return state;
}
}
var store = createStore(counter, devTools({realtime: true}))
store.subscribe(function() { console.log(store.getState()) })
var store = createStore(counter, devTools({ realtime: true }));
store.subscribe(function () {
console.log(store.getState());
});
function incrementer() {
setTimeout(function() {
store.dispatch({ type: 'INCREMENT' })
incrementer()
}, 1000)
setTimeout(function () {
store.dispatch({ type: 'INCREMENT' });
incrementer();
}, 1000);
}
incrementer()
incrementer();

View File

@ -1,4 +1,4 @@
{
"presets": [ "es2015", "stage-0", "react" ],
"plugins": [ "transform-decorators-legacy" ]
"presets": ["es2015", "stage-0", "react"],
"plugins": ["transform-decorators-legacy"]
}

View File

@ -1,11 +1,15 @@
import React, { PropTypes, Component } from 'react';
import classnames from 'classnames';
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters';
import {
SHOW_ALL,
SHOW_COMPLETED,
SHOW_ACTIVE,
} from '../constants/TodoFilters';
const FILTER_TITLES = {
[SHOW_ALL]: 'All',
[SHOW_ACTIVE]: 'Active',
[SHOW_COMPLETED]: 'Completed'
[SHOW_COMPLETED]: 'Completed',
};
class Footer extends Component {
@ -25,9 +29,11 @@ class Footer extends Component {
const { filter: selectedFilter, onShow } = this.props;
return (
<a className={classnames({ selected: filter === selectedFilter })}
<a
className={classnames({ selected: filter === selectedFilter })}
style={{ cursor: 'pointer' }}
onClick={() => onShow(filter)}>
onClick={() => onShow(filter)}
>
{title}
</a>
);
@ -37,8 +43,7 @@ class Footer extends Component {
const { completedCount, onClearCompleted } = this.props;
if (completedCount > 0) {
return (
<button className="clear-completed"
onClick={onClearCompleted} >
<button className="clear-completed" onClick={onClearCompleted}>
Clear completed
</button>
);
@ -50,11 +55,9 @@ class Footer extends Component {
<footer className="footer">
{this.renderTodoCount()}
<ul className="filters">
{[SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED].map(filter =>
<li key={filter}>
{this.renderFilterLink(filter)}
</li>
)}
{[SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED].map((filter) => (
<li key={filter}>{this.renderFilterLink(filter)}</li>
))}
</ul>
{this.renderClearButton()}
</footer>
@ -67,7 +70,7 @@ Footer.propTypes = {
activeCount: PropTypes.number.isRequired,
filter: PropTypes.string.isRequired,
onClearCompleted: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired
onShow: PropTypes.func.isRequired,
};
export default Footer;

View File

@ -13,16 +13,18 @@ class Header extends Component {
return (
<header className="header">
<h1 style={{ fontSize: 80 }}>{path}</h1>
<TodoTextInput newTodo
<TodoTextInput
newTodo
onSave={this.handleSave.bind(this)}
placeholder="What needs to be done?" />
placeholder="What needs to be done?"
/>
</header>
);
}
}
Header.propTypes = {
addTodo: PropTypes.func.isRequired
addTodo: PropTypes.func.isRequired,
};
export default Header;

View File

@ -1,12 +1,16 @@
import React, { Component, PropTypes } from 'react';
import TodoItem from './TodoItem';
import Footer from './Footer';
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters';
import {
SHOW_ALL,
SHOW_COMPLETED,
SHOW_ACTIVE,
} from '../constants/TodoFilters';
const TODO_FILTERS = {
[SHOW_ALL]: () => true,
[SHOW_ACTIVE]: todo => !todo.completed,
[SHOW_COMPLETED]: todo => todo.completed
[SHOW_ACTIVE]: (todo) => !todo.completed,
[SHOW_COMPLETED]: (todo) => todo.completed,
};
class MainSection extends Component {
@ -16,7 +20,7 @@ class MainSection extends Component {
}
handleClearCompleted() {
const atLeastOneCompleted = this.props.todos.some(todo => todo.completed);
const atLeastOneCompleted = this.props.todos.some((todo) => todo.completed);
if (atLeastOneCompleted) {
this.props.actions.clearCompleted();
}
@ -30,10 +34,12 @@ class MainSection extends Component {
const { todos, actions } = this.props;
if (todos.length > 0) {
return (
<input className="toggle-all"
<input
className="toggle-all"
type="checkbox"
checked={completedCount === todos.length}
onChange={actions.completeAll} />
onChange={actions.completeAll}
/>
);
}
}
@ -45,11 +51,13 @@ class MainSection extends Component {
if (todos.length) {
return (
<Footer completedCount={completedCount}
<Footer
completedCount={completedCount}
activeCount={activeCount}
filter={filter}
onClearCompleted={this.handleClearCompleted.bind(this)}
onShow={this.handleShow.bind(this)} />
onShow={this.handleShow.bind(this)}
/>
);
}
}
@ -59,8 +67,8 @@ class MainSection extends Component {
const { filter } = this.state;
const filteredTodos = todos.filter(TODO_FILTERS[filter]);
const completedCount = todos.reduce((count, todo) =>
todo.completed ? count + 1 : count,
const completedCount = todos.reduce(
(count, todo) => (todo.completed ? count + 1 : count),
0
);
@ -68,9 +76,9 @@ class MainSection extends Component {
<section className="main">
{this.renderToggleAll(completedCount)}
<ul className="todo-list">
{filteredTodos.map(todo =>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} {...actions} />
)}
))}
</ul>
{this.renderFooter(completedCount)}
</section>
@ -80,7 +88,7 @@ class MainSection extends Component {
MainSection.propTypes = {
todos: PropTypes.array.isRequired,
actions: PropTypes.object.isRequired
actions: PropTypes.object.isRequired,
};
export default MainSection;

View File

@ -6,7 +6,7 @@ class TodoItem extends Component {
constructor(props, context) {
super(props, context);
this.state = {
editing: false
editing: false,
};
}
@ -24,36 +24,41 @@ class TodoItem extends Component {
}
render() {
const {todo, completeTodo, deleteTodo} = this.props;
const { todo, completeTodo, deleteTodo } = this.props;
let element;
if (this.state.editing) {
element = (
<TodoTextInput text={todo.text}
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={(text) => this.handleSave(todo.id, text)} />
onSave={(text) => this.handleSave(todo.id, text)}
/>
);
} else {
element = (
<div className="view">
<input className="toggle"
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo(todo.id)} />
onChange={() => completeTodo(todo.id)}
/>
<label onDoubleClick={this.handleDoubleClick.bind(this)}>
{todo.text}
</label>
<button className="destroy"
onClick={() => deleteTodo(todo.id)} />
<button className="destroy" onClick={() => deleteTodo(todo.id)} />
</div>
);
}
return (
<li className={classnames({
<li
className={classnames({
completed: todo.completed,
editing: this.state.editing
})}>
editing: this.state.editing,
})}
>
{element}
</li>
);
@ -64,7 +69,7 @@ TodoItem.propTypes = {
todo: PropTypes.object.isRequired,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired,
completeTodo: PropTypes.func.isRequired
completeTodo: PropTypes.func.isRequired,
};
export default TodoItem;

View File

@ -5,7 +5,7 @@ class TodoTextInput extends Component {
constructor(props, context) {
super(props, context);
this.state = {
text: this.props.text || ''
text: this.props.text || '',
};
}
@ -31,10 +31,10 @@ class TodoTextInput extends Component {
render() {
return (
<input className={
classnames({
<input
className={classnames({
edit: this.props.editing,
'new-todo': this.props.newTodo
'new-todo': this.props.newTodo,
})}
type="text"
placeholder={this.props.placeholder}
@ -42,7 +42,8 @@ class TodoTextInput extends Component {
value={this.state.text}
onBlur={this.handleBlur.bind(this)}
onChange={this.handleChange.bind(this)}
onKeyDown={this.handleSubmit.bind(this)} />
onKeyDown={this.handleSubmit.bind(this)}
/>
);
}
}
@ -52,7 +53,7 @@ TodoTextInput.propTypes = {
text: PropTypes.string,
placeholder: PropTypes.string,
editing: PropTypes.bool,
newTodo: PropTypes.bool
newTodo: PropTypes.bool,
};
export default TodoTextInput;

View File

@ -19,23 +19,20 @@ class App extends Component {
App.propTypes = {
todos: PropTypes.array.isRequired,
actions: PropTypes.object.isRequired
actions: PropTypes.object.isRequired,
};
function mapStateToProps(state) {
return {
todos: state.todos,
path: state.router.location.pathname
path: state.router.location.pathname,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(TodoActions, dispatch)
actions: bindActionCreators(TodoActions, dispatch),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@ -9,9 +9,9 @@ class Root extends Component {
render() {
return (
<ReduxRouter>
<Redirect from="/" to="Standard Todo"/>
<Redirect from="/" to="Standard Todo" />
<Route path="/" component={Wrapper}>
<Route path="/:id" component={App}/>
<Route path="/:id" component={App} />
</Route>
</ReduxRouter>
);

View File

@ -8,14 +8,14 @@ import * as TodoActions from '../actions/todos';
function mapDispatchToProps(dispatch) {
return {
pushState: bindActionCreators(pushState, dispatch),
actions: bindActionCreators(TodoActions, dispatch)
actions: bindActionCreators(TodoActions, dispatch),
};
}
@connect((state) => ({}), mapDispatchToProps)
class Wrapper extends Component {
static propTypes = {
children: PropTypes.node
children: PropTypes.node,
};
constructor(props) {
@ -41,11 +41,23 @@ class Wrapper extends Component {
}
render() {
return (
<div>
<div style={{ padding: 20, backgroundColor: '#eee', fontWeight: 'bold', textAlign: 'center' }}>
<a href="#" onClick={this.handleClick}>Standard Todo</a> | <a href="#" onClick={this.handleClick}>AutoTodo</a>
<div
style={{
padding: 20,
backgroundColor: '#eee',
fontWeight: 'bold',
textAlign: 'center',
}}
>
<a href="#" onClick={this.handleClick}>
Standard Todo
</a>{' '}
|{' '}
<a href="#" onClick={this.handleClick}>
AutoTodo
</a>
</div>
{this.props.children}
</div>

View File

@ -4,8 +4,7 @@
<title>Redux TodoMVC example</title>
</head>
<body>
<div class="todoapp" id="root">
</div>
<div class="todoapp" id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@ import todos from './todos';
const rootReducer = combineReducers({
todos,
router: routerStateReducer
router: routerStateReducer,
});
export default rootReducer;

View File

@ -1,47 +1,59 @@
import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED } from '../constants/ActionTypes';
import {
ADD_TODO,
DELETE_TODO,
EDIT_TODO,
COMPLETE_TODO,
COMPLETE_ALL,
CLEAR_COMPLETED,
} from '../constants/ActionTypes';
const initialState = [{
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}];
id: 0,
},
];
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [{
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
}, ...state];
text: action.text,
},
...state,
];
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
);
return state.filter((todo) => todo.id !== action.id);
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
Object.assign({}, todo, { text: action.text }) :
todo
return state.map((todo) =>
todo.id === action.id
? Object.assign({}, todo, { text: action.text })
: todo
);
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
Object.assign({}, todo, { completed: !todo.completed }) :
todo
return state.map((todo) =>
todo.id === action.id
? Object.assign({}, todo, { completed: !todo.completed })
: todo
);
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed);
return state.map(todo => Object.assign({}, todo, {
completed: !areAllMarked
}));
const areAllMarked = state.every((todo) => todo.completed);
return state.map((todo) =>
Object.assign({}, todo, {
completed: !areAllMarked,
})
);
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false);
return state.filter((todo) => todo.completed === false);
default:
return state;

View File

@ -7,17 +7,26 @@ var app = new require('express')();
var port = 4002;
var compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
app.use(
webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath,
})
);
app.use(webpackHotMiddleware(compiler));
app.get("/", function(req, res) {
app.get('/', function (req, res) {
res.sendFile(__dirname + '/index.html');
});
app.listen(port, function(error) {
app.listen(port, function (error) {
if (error) {
console.error(error);
} else {
console.info("==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port);
console.info(
'==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.',
port,
port
);
}
});

View File

@ -1,5 +1,9 @@
import { createStore, compose } from 'redux';
import { reduxReactRouter, routerStateReducer, ReduxRouter } from 'redux-router';
import {
reduxReactRouter,
routerStateReducer,
ReduxRouter,
} from 'redux-router';
//import createHistory from 'history/lib/createBrowserHistory';
import devTools from 'remote-redux-devtools';
import createHistory from 'history/lib/createHashHistory';

View File

@ -6,14 +6,14 @@ describe('todo actions', () => {
it('addTodo should create ADD_TODO action', () => {
expect(actions.addTodo('Use Redux')).toEqual({
type: types.ADD_TODO,
text: 'Use Redux'
text: 'Use Redux',
});
});
it('deleteTodo should create DELETE_TODO action', () => {
expect(actions.deleteTodo(1)).toEqual({
type: types.DELETE_TODO,
id: 1
id: 1,
});
});
@ -21,26 +21,26 @@ describe('todo actions', () => {
expect(actions.editTodo(1, 'Use Redux everywhere')).toEqual({
type: types.EDIT_TODO,
id: 1,
text: 'Use Redux everywhere'
text: 'Use Redux everywhere',
});
});
it('completeTodo should create COMPLETE_TODO action', () => {
expect(actions.completeTodo(1)).toEqual({
type: types.COMPLETE_TODO,
id: 1
id: 1,
});
});
it('completeAll should create COMPLETE_ALL action', () => {
expect(actions.completeAll()).toEqual({
type: types.COMPLETE_ALL
type: types.COMPLETE_ALL,
});
});
it('clearCompleted should create CLEAR_COMPLETED action', () => {
expect(actions.clearCompleted('Use Redux')).toEqual({
type: types.CLEAR_COMPLETED
type: types.CLEAR_COMPLETED,
});
});
});

View File

@ -5,13 +5,16 @@ import Footer from '../../components/Footer';
import { SHOW_ALL, SHOW_ACTIVE } from '../../constants/TodoFilters';
function setup(propOverrides) {
const props = Object.assign({
const props = Object.assign(
{
completedCount: 0,
activeCount: 0,
filter: SHOW_ALL,
onClearCompleted: expect.createSpy(),
onShow: expect.createSpy()
}, propOverrides);
onShow: expect.createSpy(),
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(<Footer {...props} />);
@ -19,13 +22,14 @@ function setup(propOverrides) {
return {
props: props,
output: output
output: output,
};
}
function getTextContent(elem) {
const children = Array.isArray(elem.props.children) ?
elem.props.children : [elem.props.children];
const children = Array.isArray(elem.props.children)
? elem.props.children
: [elem.props.children];
return children.reduce(function concatText(out, child) {
// Children are either elements or text strings
@ -63,11 +67,13 @@ describe('components', () => {
expect(filter.type).toBe('li');
const a = filter.props.children;
expect(a.props.className).toBe(i === 0 ? 'selected' : '');
expect(a.props.children).toBe({
expect(a.props.children).toBe(
{
0: 'All',
1: 'Active',
2: 'Completed'
}[i]);
2: 'Completed',
}[i]
);
});
});
@ -81,20 +87,20 @@ describe('components', () => {
it('shouldnt show clear button when no completed todos', () => {
const { output } = setup({ completedCount: 0 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
expect(clear).toBe(undefined);
});
it('should render clear button when completed todos', () => {
const { output } = setup({ completedCount: 1 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
expect(clear.type).toBe('button');
expect(clear.props.children).toBe('Clear completed');
});
it('should call onClearCompleted on clear button click', () => {
const { output, props } = setup({ completedCount: 1 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
clear.props.onClick({});
expect(props.onClearCompleted).toHaveBeenCalled();
});

View File

@ -6,7 +6,7 @@ import TodoTextInput from '../../components/TodoTextInput';
function setup() {
const props = {
addTodo: expect.createSpy()
addTodo: expect.createSpy(),
};
const renderer = TestUtils.createRenderer();
@ -16,7 +16,7 @@ function setup() {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}

View File

@ -7,24 +7,30 @@ import Footer from '../../components/Footer';
import { SHOW_ALL, SHOW_COMPLETED } from '../../constants/TodoFilters';
function setup(propOverrides) {
const props = Object.assign({
todos: [{
const props = Object.assign(
{
todos: [
{
text: 'Use Redux',
completed: false,
id: 0
}, {
id: 0,
},
{
text: 'Run the tests',
completed: true,
id: 1
}],
id: 1,
},
],
actions: {
editTodo: expect.createSpy(),
deleteTodo: expect.createSpy(),
completeTodo: expect.createSpy(),
completeAll: expect.createSpy(),
clearCompleted: expect.createSpy()
}
}, propOverrides);
clearCompleted: expect.createSpy(),
},
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(<MainSection {...props} />);
@ -33,7 +39,7 @@ function setup(propOverrides) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}
@ -55,11 +61,15 @@ describe('components', () => {
});
it('should be checked if all todos completed', () => {
const { output } = setup({ todos: [{
const { output } = setup({
todos: [
{
text: 'Use Redux',
completed: true,
id: 0
}]});
id: 0,
},
],
});
const [toggle] = output.props.children;
expect(toggle.props.checked).toBe(true);
});
@ -75,7 +85,7 @@ describe('components', () => {
describe('footer', () => {
it('should render', () => {
const { output } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
expect(footer.type).toBe(Footer);
expect(footer.props.completedCount).toBe(1);
expect(footer.props.activeCount).toBe(1);
@ -84,27 +94,31 @@ describe('components', () => {
it('onShow should set the filter', () => {
const { output, renderer } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onShow(SHOW_COMPLETED);
const updated = renderer.getRenderOutput();
const [,, updatedFooter] = updated.props.children;
const [, , updatedFooter] = updated.props.children;
expect(updatedFooter.props.filter).toBe(SHOW_COMPLETED);
});
it('onClearCompleted should call clearCompleted', () => {
const { output, props } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onClearCompleted();
expect(props.actions.clearCompleted).toHaveBeenCalled();
});
it('onClearCompleted shouldnt call clearCompleted if no todos completed', () => {
const { output, props } = setup({ todos: [{
const { output, props } = setup({
todos: [
{
text: 'Use Redux',
completed: false,
id: 0
}]});
const [,, footer] = output.props.children;
id: 0,
},
],
});
const [, , footer] = output.props.children;
footer.props.onClearCompleted();
expect(props.actions.clearCompleted.calls.length).toBe(0);
});
@ -124,7 +138,7 @@ describe('components', () => {
it('should filter items', () => {
const { output, renderer, props } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onShow(SHOW_COMPLETED);
const updated = renderer.getRenderOutput();
const [, updatedList] = updated.props.children;

View File

@ -4,23 +4,21 @@ import TestUtils from 'react-addons-test-utils';
import TodoItem from '../../components/TodoItem';
import TodoTextInput from '../../components/TodoTextInput';
function setup( editing = false ) {
function setup(editing = false) {
const props = {
todo: {
id: 0,
text: 'Use Redux',
completed: false
completed: false,
},
editTodo: expect.createSpy(),
deleteTodo: expect.createSpy(),
completeTodo: expect.createSpy()
completeTodo: expect.createSpy(),
};
const renderer = TestUtils.createRenderer();
renderer.render(
<TodoItem {...props} />
);
renderer.render(<TodoItem {...props} />);
let output = renderer.getRenderOutput();
@ -33,7 +31,7 @@ function setup( editing = false ) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}

View File

@ -4,19 +4,20 @@ import TestUtils from 'react-addons-test-utils';
import TodoTextInput from '../../components/TodoTextInput';
function setup(propOverrides) {
const props = Object.assign({
const props = Object.assign(
{
onSave: expect.createSpy(),
text: 'Use Redux',
placeholder: 'What needs to be done?',
editing: false,
newTodo: false
}, propOverrides);
newTodo: false,
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(
<TodoTextInput {...props} />
);
renderer.render(<TodoTextInput {...props} />);
let output = renderer.getRenderOutput();
@ -25,7 +26,7 @@ function setup(propOverrides) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}
@ -50,33 +51,33 @@ describe('components', () => {
it('should update value on change', () => {
const { output, renderer } = setup();
output.props.onChange({ target: { value: 'Use Radox' }});
output.props.onChange({ target: { value: 'Use Radox' } });
const updated = renderer.getRenderOutput();
expect(updated.props.value).toEqual('Use Radox');
});
it('should call onSave on return key press', () => {
const { output, props } = setup();
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' }});
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } });
expect(props.onSave).toHaveBeenCalledWith('Use Redux');
});
it('should reset state on return key press if newTodo', () => {
const { output, renderer } = setup({ newTodo: true });
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' }});
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } });
const updated = renderer.getRenderOutput();
expect(updated.props.value).toEqual('');
});
it('should call onSave on blur', () => {
const { output, props } = setup();
output.props.onBlur({ target: { value: 'Use Redux' }});
output.props.onBlur({ target: { value: 'Use Redux' } });
expect(props.onSave).toHaveBeenCalledWith('Use Redux');
});
it('shouldnt call onSave on blur if newTodo', () => {
const { output, props } = setup({ newTodo: true });
output.props.onBlur({ target: { value: 'Use Redux' }});
output.props.onBlur({ target: { value: 'Use Redux' } });
expect(props.onSave.calls.length).toBe(0);
});
});

View File

@ -4,240 +4,322 @@ import * as types from '../../constants/ActionTypes';
describe('todos reducer', () => {
it('should handle initial state', () => {
expect(
todos(undefined, {})
).toEqual([{
expect(todos(undefined, {})).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle ADD_TODO', () => {
expect(
todos([], {
type: types.ADD_TODO,
text: 'Run the tests'
text: 'Run the tests',
})
).toEqual([{
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 0
}]);
id: 0,
},
]);
expect(
todos([{
todos(
[
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.ADD_TODO,
text: 'Run the tests'
})
).toEqual([{
text: 'Run the tests',
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.ADD_TODO,
text: 'Fix the tests'
})
).toEqual([{
text: 'Fix the tests',
}
)
).toEqual([
{
text: 'Fix the tests',
completed: false,
id: 2
}, {
id: 2,
},
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle DELETE_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.DELETE_TODO,
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle EDIT_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.EDIT_TODO,
text: 'Fix the tests',
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Fix the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle COMPLETE_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.COMPLETE_TODO,
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle COMPLETE_ALL', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
type: types.COMPLETE_ALL
})
).toEqual([{
id: 0,
},
],
{
type: types.COMPLETE_ALL,
}
)
).toEqual([
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: true,
id: 0
}]);
id: 0,
},
]);
// Unmark if all todos are currently completed
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: true,
id: 0
}], {
type: types.COMPLETE_ALL
})
).toEqual([{
id: 0,
},
],
{
type: types.COMPLETE_ALL,
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle CLEAR_COMPLETED', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
type: types.CLEAR_COMPLETED
})
).toEqual([{
id: 0,
},
],
{
type: types.CLEAR_COMPLETED,
}
)
).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should not generate duplicate ids after CLEAR_COMPLETED', () => {
expect(
[{
[
{
type: types.COMPLETE_TODO,
id: 0
}, {
type: types.CLEAR_COMPLETED
}, {
id: 0,
},
{
type: types.CLEAR_COMPLETED,
},
{
type: types.ADD_TODO,
text: 'Write more tests'
}].reduce(todos, [{
text: 'Write more tests',
},
].reduce(todos, [
{
id: 0,
completed: false,
text: 'Use Redux'
}, {
text: 'Use Redux',
},
{
id: 1,
completed: false,
text: 'Write tests'
}])
).toEqual([{
text: 'Write tests',
},
])
).toEqual([
{
text: 'Write more tests',
completed: false,
id: 2
}, {
id: 2,
},
{
text: 'Write tests',
completed: false,
id: 1
}]);
id: 1,
},
]);
});
});

View File

@ -3,32 +3,32 @@ var webpack = require('webpack');
module.exports = {
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client',
'./index'
],
entry: ['webpack-hot-middleware/client', './index'],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
publicPath: '/static/',
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
new webpack.NoErrorsPlugin(),
],
module: {
loaders: [{
loaders: [
{
test: /\.js$/,
loaders: ['babel'],
exclude: /node_modules/,
include: __dirname
}, {
include: __dirname,
},
{
test: /\.css?$/,
loaders: ['style', 'raw'],
include: __dirname
}]
}
include: __dirname,
},
],
},
};
var src = path.join(__dirname, '..', '..', 'src');
@ -41,6 +41,6 @@ if (fs.existsSync(src) && fs.existsSync(nodeModules)) {
module.exports.module.loaders.push({
test: /\.js$/,
loaders: ['babel'],
include: src
include: src,
});
}

View File

@ -1,3 +1,3 @@
{
"presets": [ "es2015", "stage-0", "react" ]
"presets": ["es2015", "stage-0", "react"]
}

View File

@ -1,11 +1,15 @@
import React, { PropTypes, Component } from 'react';
import classnames from 'classnames';
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters';
import {
SHOW_ALL,
SHOW_COMPLETED,
SHOW_ACTIVE,
} from '../constants/TodoFilters';
const FILTER_TITLES = {
[SHOW_ALL]: 'All',
[SHOW_ACTIVE]: 'Active',
[SHOW_COMPLETED]: 'Completed'
[SHOW_COMPLETED]: 'Completed',
};
class Footer extends Component {
@ -25,9 +29,11 @@ class Footer extends Component {
const { filter: selectedFilter, onShow } = this.props;
return (
<a className={classnames({ selected: filter === selectedFilter })}
<a
className={classnames({ selected: filter === selectedFilter })}
style={{ cursor: 'pointer' }}
onClick={() => onShow(filter)}>
onClick={() => onShow(filter)}
>
{title}
</a>
);
@ -37,8 +43,7 @@ class Footer extends Component {
const { completedCount, onClearCompleted } = this.props;
if (completedCount > 0) {
return (
<button className="clear-completed"
onClick={onClearCompleted} >
<button className="clear-completed" onClick={onClearCompleted}>
Clear completed
</button>
);
@ -50,11 +55,9 @@ class Footer extends Component {
<footer className="footer">
{this.renderTodoCount()}
<ul className="filters">
{[SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED].map(filter =>
<li key={filter}>
{this.renderFilterLink(filter)}
</li>
)}
{[SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED].map((filter) => (
<li key={filter}>{this.renderFilterLink(filter)}</li>
))}
</ul>
{this.renderClearButton()}
</footer>
@ -67,7 +70,7 @@ Footer.propTypes = {
activeCount: PropTypes.number.isRequired,
filter: PropTypes.string.isRequired,
onClearCompleted: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired
onShow: PropTypes.func.isRequired,
};
export default Footer;

View File

@ -12,16 +12,18 @@ class Header extends Component {
return (
<header className="header">
<h1>todos</h1>
<TodoTextInput newTodo
<TodoTextInput
newTodo
onSave={this.handleSave.bind(this)}
placeholder="What needs to be done?" />
placeholder="What needs to be done?"
/>
</header>
);
}
}
Header.propTypes = {
addTodo: PropTypes.func.isRequired
addTodo: PropTypes.func.isRequired,
};
export default Header;

View File

@ -1,12 +1,16 @@
import React, { Component, PropTypes } from 'react';
import TodoItem from './TodoItem';
import Footer from './Footer';
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters';
import {
SHOW_ALL,
SHOW_COMPLETED,
SHOW_ACTIVE,
} from '../constants/TodoFilters';
const TODO_FILTERS = {
[SHOW_ALL]: () => true,
[SHOW_ACTIVE]: todo => !todo.completed,
[SHOW_COMPLETED]: todo => todo.completed
[SHOW_ACTIVE]: (todo) => !todo.completed,
[SHOW_COMPLETED]: (todo) => todo.completed,
};
class MainSection extends Component {
@ -16,7 +20,7 @@ class MainSection extends Component {
}
handleClearCompleted() {
const atLeastOneCompleted = this.props.todos.some(todo => todo.completed);
const atLeastOneCompleted = this.props.todos.some((todo) => todo.completed);
if (atLeastOneCompleted) {
this.props.actions.clearCompleted();
}
@ -30,10 +34,12 @@ class MainSection extends Component {
const { todos, actions } = this.props;
if (todos.length > 0) {
return (
<input className="toggle-all"
<input
className="toggle-all"
type="checkbox"
checked={completedCount === todos.length}
onChange={actions.completeAll} />
onChange={actions.completeAll}
/>
);
}
}
@ -45,11 +51,13 @@ class MainSection extends Component {
if (todos.length) {
return (
<Footer completedCount={completedCount}
<Footer
completedCount={completedCount}
activeCount={activeCount}
filter={filter}
onClearCompleted={this.handleClearCompleted.bind(this)}
onShow={this.handleShow.bind(this)} />
onShow={this.handleShow.bind(this)}
/>
);
}
}
@ -59,8 +67,8 @@ class MainSection extends Component {
const { filter } = this.state;
const filteredTodos = todos.filter(TODO_FILTERS[filter]);
const completedCount = todos.reduce((count, todo) =>
todo.completed ? count + 1 : count,
const completedCount = todos.reduce(
(count, todo) => (todo.completed ? count + 1 : count),
0
);
@ -68,9 +76,9 @@ class MainSection extends Component {
<section className="main">
{this.renderToggleAll(completedCount)}
<ul className="todo-list">
{filteredTodos.map(todo =>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} {...actions} />
)}
))}
</ul>
{this.renderFooter(completedCount)}
</section>
@ -80,7 +88,7 @@ class MainSection extends Component {
MainSection.propTypes = {
todos: PropTypes.array.isRequired,
actions: PropTypes.object.isRequired
actions: PropTypes.object.isRequired,
};
export default MainSection;

View File

@ -6,7 +6,7 @@ class TodoItem extends Component {
constructor(props, context) {
super(props, context);
this.state = {
editing: false
editing: false,
};
}
@ -24,36 +24,41 @@ class TodoItem extends Component {
}
render() {
const {todo, completeTodo, deleteTodo} = this.props;
const { todo, completeTodo, deleteTodo } = this.props;
let element;
if (this.state.editing) {
element = (
<TodoTextInput text={todo.text}
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={(text) => this.handleSave(todo.id, text)} />
onSave={(text) => this.handleSave(todo.id, text)}
/>
);
} else {
element = (
<div className="view">
<input className="toggle"
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo(todo.id)} />
onChange={() => completeTodo(todo.id)}
/>
<label onDoubleClick={this.handleDoubleClick.bind(this)}>
{todo.text}
</label>
<button className="destroy"
onClick={() => deleteTodo(todo.id)} />
<button className="destroy" onClick={() => deleteTodo(todo.id)} />
</div>
);
}
return (
<li className={classnames({
<li
className={classnames({
completed: todo.completed,
editing: this.state.editing
})}>
editing: this.state.editing,
})}
>
{element}
</li>
);
@ -64,7 +69,7 @@ TodoItem.propTypes = {
todo: PropTypes.object.isRequired,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired,
completeTodo: PropTypes.func.isRequired
completeTodo: PropTypes.func.isRequired,
};
export default TodoItem;

View File

@ -5,7 +5,7 @@ class TodoTextInput extends Component {
constructor(props, context) {
super(props, context);
this.state = {
text: this.props.text || ''
text: this.props.text || '',
};
}
@ -31,10 +31,10 @@ class TodoTextInput extends Component {
render() {
return (
<input className={
classnames({
<input
className={classnames({
edit: this.props.editing,
'new-todo': this.props.newTodo
'new-todo': this.props.newTodo,
})}
type="text"
placeholder={this.props.placeholder}
@ -42,7 +42,8 @@ class TodoTextInput extends Component {
value={this.state.text}
onBlur={this.handleBlur.bind(this)}
onChange={this.handleChange.bind(this)}
onKeyDown={this.handleSubmit.bind(this)} />
onKeyDown={this.handleSubmit.bind(this)}
/>
);
}
}
@ -52,7 +53,7 @@ TodoTextInput.propTypes = {
text: PropTypes.string,
placeholder: PropTypes.string,
editing: PropTypes.bool,
newTodo: PropTypes.bool
newTodo: PropTypes.bool,
};
export default TodoTextInput;

View File

@ -19,22 +19,19 @@ class App extends Component {
App.propTypes = {
todos: PropTypes.array.isRequired,
actions: PropTypes.object.isRequired
actions: PropTypes.object.isRequired,
};
function mapStateToProps(state) {
return {
todos: state.todos
todos: state.todos,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(TodoActions, dispatch)
actions: bindActionCreators(TodoActions, dispatch),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@ -4,8 +4,7 @@
<title>Redux TodoMVC example</title>
</head>
<body>
<div class="todoapp" id="root">
</div>
<div class="todoapp" id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

View File

@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import todos from './todos';
const rootReducer = combineReducers({
todos
todos,
});
export default rootReducer;

View File

@ -1,47 +1,59 @@
import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED } from '../constants/ActionTypes';
import {
ADD_TODO,
DELETE_TODO,
EDIT_TODO,
COMPLETE_TODO,
COMPLETE_ALL,
CLEAR_COMPLETED,
} from '../constants/ActionTypes';
const initialState = [{
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}];
id: 0,
},
];
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [{
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
}, ...state];
text: action.text,
},
...state,
];
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
);
return state.filter((todo) => todo.id !== action.id);
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
Object.assign({}, todo, { text: action.text }) :
todo
return state.map((todo) =>
todo.id === action.id
? Object.assign({}, todo, { text: action.text })
: todo
);
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
Object.assign({}, todo, { completed: !todo.completed }) :
todo
return state.map((todo) =>
todo.id === action.id
? Object.assign({}, todo, { completed: !todo.completed })
: todo
);
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed);
return state.map(todo => Object.assign({}, todo, {
completed: !areAllMarked
}));
const areAllMarked = state.every((todo) => todo.completed);
return state.map((todo) =>
Object.assign({}, todo, {
completed: !areAllMarked,
})
);
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false);
return state.filter((todo) => todo.completed === false);
default:
return state;

View File

@ -7,17 +7,26 @@ var app = new require('express')();
var port = 4002;
var compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
app.use(
webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath,
})
);
app.use(webpackHotMiddleware(compiler));
app.get("/", function(req, res) {
app.get('/', function (req, res) {
res.sendFile(__dirname + '/index.html');
});
app.listen(port, function(error) {
app.listen(port, function (error) {
if (error) {
console.error(error);
} else {
console.info("==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port);
console.info(
'==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.',
port,
port
);
}
});

View File

@ -4,9 +4,14 @@ import rootReducer from '../reducers';
import * as actionCreators from '../actions/todos';
export default function configureStore(initialState) {
const store = createStore(rootReducer, initialState, devTools({
realtime: true, actionCreators
}));
const store = createStore(
rootReducer,
initialState,
devTools({
realtime: true,
actionCreators,
})
);
if (module.hot) {
// Enable Webpack hot module replacement for reducers

View File

@ -6,14 +6,14 @@ describe('todo actions', () => {
it('addTodo should create ADD_TODO action', () => {
expect(actions.addTodo('Use Redux')).toEqual({
type: types.ADD_TODO,
text: 'Use Redux'
text: 'Use Redux',
});
});
it('deleteTodo should create DELETE_TODO action', () => {
expect(actions.deleteTodo(1)).toEqual({
type: types.DELETE_TODO,
id: 1
id: 1,
});
});
@ -21,26 +21,26 @@ describe('todo actions', () => {
expect(actions.editTodo(1, 'Use Redux everywhere')).toEqual({
type: types.EDIT_TODO,
id: 1,
text: 'Use Redux everywhere'
text: 'Use Redux everywhere',
});
});
it('completeTodo should create COMPLETE_TODO action', () => {
expect(actions.completeTodo(1)).toEqual({
type: types.COMPLETE_TODO,
id: 1
id: 1,
});
});
it('completeAll should create COMPLETE_ALL action', () => {
expect(actions.completeAll()).toEqual({
type: types.COMPLETE_ALL
type: types.COMPLETE_ALL,
});
});
it('clearCompleted should create CLEAR_COMPLETED action', () => {
expect(actions.clearCompleted('Use Redux')).toEqual({
type: types.CLEAR_COMPLETED
type: types.CLEAR_COMPLETED,
});
});
});

View File

@ -5,13 +5,16 @@ import Footer from '../../components/Footer';
import { SHOW_ALL, SHOW_ACTIVE } from '../../constants/TodoFilters';
function setup(propOverrides) {
const props = Object.assign({
const props = Object.assign(
{
completedCount: 0,
activeCount: 0,
filter: SHOW_ALL,
onClearCompleted: expect.createSpy(),
onShow: expect.createSpy()
}, propOverrides);
onShow: expect.createSpy(),
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(<Footer {...props} />);
@ -19,13 +22,14 @@ function setup(propOverrides) {
return {
props: props,
output: output
output: output,
};
}
function getTextContent(elem) {
const children = Array.isArray(elem.props.children) ?
elem.props.children : [elem.props.children];
const children = Array.isArray(elem.props.children)
? elem.props.children
: [elem.props.children];
return children.reduce(function concatText(out, child) {
// Children are either elements or text strings
@ -63,11 +67,13 @@ describe('components', () => {
expect(filter.type).toBe('li');
const a = filter.props.children;
expect(a.props.className).toBe(i === 0 ? 'selected' : '');
expect(a.props.children).toBe({
expect(a.props.children).toBe(
{
0: 'All',
1: 'Active',
2: 'Completed'
}[i]);
2: 'Completed',
}[i]
);
});
});
@ -81,20 +87,20 @@ describe('components', () => {
it('shouldnt show clear button when no completed todos', () => {
const { output } = setup({ completedCount: 0 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
expect(clear).toBe(undefined);
});
it('should render clear button when completed todos', () => {
const { output } = setup({ completedCount: 1 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
expect(clear.type).toBe('button');
expect(clear.props.children).toBe('Clear completed');
});
it('should call onClearCompleted on clear button click', () => {
const { output, props } = setup({ completedCount: 1 });
const [,, clear] = output.props.children;
const [, , clear] = output.props.children;
clear.props.onClick({});
expect(props.onClearCompleted).toHaveBeenCalled();
});

View File

@ -6,7 +6,7 @@ import TodoTextInput from '../../components/TodoTextInput';
function setup() {
const props = {
addTodo: expect.createSpy()
addTodo: expect.createSpy(),
};
const renderer = TestUtils.createRenderer();
@ -16,7 +16,7 @@ function setup() {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}

View File

@ -7,24 +7,30 @@ import Footer from '../../components/Footer';
import { SHOW_ALL, SHOW_COMPLETED } from '../../constants/TodoFilters';
function setup(propOverrides) {
const props = Object.assign({
todos: [{
const props = Object.assign(
{
todos: [
{
text: 'Use Redux',
completed: false,
id: 0
}, {
id: 0,
},
{
text: 'Run the tests',
completed: true,
id: 1
}],
id: 1,
},
],
actions: {
editTodo: expect.createSpy(),
deleteTodo: expect.createSpy(),
completeTodo: expect.createSpy(),
completeAll: expect.createSpy(),
clearCompleted: expect.createSpy()
}
}, propOverrides);
clearCompleted: expect.createSpy(),
},
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(<MainSection {...props} />);
@ -33,7 +39,7 @@ function setup(propOverrides) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}
@ -55,11 +61,15 @@ describe('components', () => {
});
it('should be checked if all todos completed', () => {
const { output } = setup({ todos: [{
const { output } = setup({
todos: [
{
text: 'Use Redux',
completed: true,
id: 0
}]});
id: 0,
},
],
});
const [toggle] = output.props.children;
expect(toggle.props.checked).toBe(true);
});
@ -75,7 +85,7 @@ describe('components', () => {
describe('footer', () => {
it('should render', () => {
const { output } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
expect(footer.type).toBe(Footer);
expect(footer.props.completedCount).toBe(1);
expect(footer.props.activeCount).toBe(1);
@ -84,27 +94,31 @@ describe('components', () => {
it('onShow should set the filter', () => {
const { output, renderer } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onShow(SHOW_COMPLETED);
const updated = renderer.getRenderOutput();
const [,, updatedFooter] = updated.props.children;
const [, , updatedFooter] = updated.props.children;
expect(updatedFooter.props.filter).toBe(SHOW_COMPLETED);
});
it('onClearCompleted should call clearCompleted', () => {
const { output, props } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onClearCompleted();
expect(props.actions.clearCompleted).toHaveBeenCalled();
});
it('onClearCompleted shouldnt call clearCompleted if no todos completed', () => {
const { output, props } = setup({ todos: [{
const { output, props } = setup({
todos: [
{
text: 'Use Redux',
completed: false,
id: 0
}]});
const [,, footer] = output.props.children;
id: 0,
},
],
});
const [, , footer] = output.props.children;
footer.props.onClearCompleted();
expect(props.actions.clearCompleted.calls.length).toBe(0);
});
@ -124,7 +138,7 @@ describe('components', () => {
it('should filter items', () => {
const { output, renderer, props } = setup();
const [,, footer] = output.props.children;
const [, , footer] = output.props.children;
footer.props.onShow(SHOW_COMPLETED);
const updated = renderer.getRenderOutput();
const [, updatedList] = updated.props.children;

View File

@ -4,23 +4,21 @@ import TestUtils from 'react-addons-test-utils';
import TodoItem from '../../components/TodoItem';
import TodoTextInput from '../../components/TodoTextInput';
function setup( editing = false ) {
function setup(editing = false) {
const props = {
todo: {
id: 0,
text: 'Use Redux',
completed: false
completed: false,
},
editTodo: expect.createSpy(),
deleteTodo: expect.createSpy(),
completeTodo: expect.createSpy()
completeTodo: expect.createSpy(),
};
const renderer = TestUtils.createRenderer();
renderer.render(
<TodoItem {...props} />
);
renderer.render(<TodoItem {...props} />);
let output = renderer.getRenderOutput();
@ -33,7 +31,7 @@ function setup( editing = false ) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}

View File

@ -4,19 +4,20 @@ import TestUtils from 'react-addons-test-utils';
import TodoTextInput from '../../components/TodoTextInput';
function setup(propOverrides) {
const props = Object.assign({
const props = Object.assign(
{
onSave: expect.createSpy(),
text: 'Use Redux',
placeholder: 'What needs to be done?',
editing: false,
newTodo: false
}, propOverrides);
newTodo: false,
},
propOverrides
);
const renderer = TestUtils.createRenderer();
renderer.render(
<TodoTextInput {...props} />
);
renderer.render(<TodoTextInput {...props} />);
let output = renderer.getRenderOutput();
@ -25,7 +26,7 @@ function setup(propOverrides) {
return {
props: props,
output: output,
renderer: renderer
renderer: renderer,
};
}
@ -50,33 +51,33 @@ describe('components', () => {
it('should update value on change', () => {
const { output, renderer } = setup();
output.props.onChange({ target: { value: 'Use Radox' }});
output.props.onChange({ target: { value: 'Use Radox' } });
const updated = renderer.getRenderOutput();
expect(updated.props.value).toEqual('Use Radox');
});
it('should call onSave on return key press', () => {
const { output, props } = setup();
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' }});
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } });
expect(props.onSave).toHaveBeenCalledWith('Use Redux');
});
it('should reset state on return key press if newTodo', () => {
const { output, renderer } = setup({ newTodo: true });
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' }});
output.props.onKeyDown({ which: 13, target: { value: 'Use Redux' } });
const updated = renderer.getRenderOutput();
expect(updated.props.value).toEqual('');
});
it('should call onSave on blur', () => {
const { output, props } = setup();
output.props.onBlur({ target: { value: 'Use Redux' }});
output.props.onBlur({ target: { value: 'Use Redux' } });
expect(props.onSave).toHaveBeenCalledWith('Use Redux');
});
it('shouldnt call onSave on blur if newTodo', () => {
const { output, props } = setup({ newTodo: true });
output.props.onBlur({ target: { value: 'Use Redux' }});
output.props.onBlur({ target: { value: 'Use Redux' } });
expect(props.onSave.calls.length).toBe(0);
});
});

View File

@ -4,240 +4,322 @@ import * as types from '../../constants/ActionTypes';
describe('todos reducer', () => {
it('should handle initial state', () => {
expect(
todos(undefined, {})
).toEqual([{
expect(todos(undefined, {})).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle ADD_TODO', () => {
expect(
todos([], {
type: types.ADD_TODO,
text: 'Run the tests'
text: 'Run the tests',
})
).toEqual([{
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 0
}]);
id: 0,
},
]);
expect(
todos([{
todos(
[
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.ADD_TODO,
text: 'Run the tests'
})
).toEqual([{
text: 'Run the tests',
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.ADD_TODO,
text: 'Fix the tests'
})
).toEqual([{
text: 'Fix the tests',
}
)
).toEqual([
{
text: 'Fix the tests',
completed: false,
id: 2
}, {
id: 2,
},
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle DELETE_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.DELETE_TODO,
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle EDIT_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.EDIT_TODO,
text: 'Fix the tests',
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Fix the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle COMPLETE_TODO', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
id: 0,
},
],
{
type: types.COMPLETE_TODO,
id: 1
})
).toEqual([{
id: 1,
}
)
).toEqual([
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle COMPLETE_ALL', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
type: types.COMPLETE_ALL
})
).toEqual([{
id: 0,
},
],
{
type: types.COMPLETE_ALL,
}
)
).toEqual([
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: true,
id: 0
}]);
id: 0,
},
]);
// Unmark if all todos are currently completed
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: true,
id: 0
}], {
type: types.COMPLETE_ALL
})
).toEqual([{
id: 0,
},
],
{
type: types.COMPLETE_ALL,
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should handle CLEAR_COMPLETED', () => {
expect(
todos([{
todos(
[
{
text: 'Run the tests',
completed: true,
id: 1
}, {
id: 1,
},
{
text: 'Use Redux',
completed: false,
id: 0
}], {
type: types.CLEAR_COMPLETED
})
).toEqual([{
id: 0,
},
],
{
type: types.CLEAR_COMPLETED,
}
)
).toEqual([
{
text: 'Use Redux',
completed: false,
id: 0
}]);
id: 0,
},
]);
});
it('should not generate duplicate ids after CLEAR_COMPLETED', () => {
expect(
[{
[
{
type: types.COMPLETE_TODO,
id: 0
}, {
type: types.CLEAR_COMPLETED
}, {
id: 0,
},
{
type: types.CLEAR_COMPLETED,
},
{
type: types.ADD_TODO,
text: 'Write more tests'
}].reduce(todos, [{
text: 'Write more tests',
},
].reduce(todos, [
{
id: 0,
completed: false,
text: 'Use Redux'
}, {
text: 'Use Redux',
},
{
id: 1,
completed: false,
text: 'Write tests'
}])
).toEqual([{
text: 'Write tests',
},
])
).toEqual([
{
text: 'Write more tests',
completed: false,
id: 2
}, {
id: 2,
},
{
text: 'Write tests',
completed: false,
id: 1
}]);
id: 1,
},
]);
});
});

View File

@ -3,32 +3,32 @@ var webpack = require('webpack');
module.exports = {
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client',
'./index'
],
entry: ['webpack-hot-middleware/client', './index'],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
publicPath: '/static/',
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
new webpack.NoErrorsPlugin(),
],
module: {
loaders: [{
loaders: [
{
test: /\.js$/,
loaders: ['babel'],
exclude: /node_modules/,
include: __dirname
}, {
include: __dirname,
},
{
test: /\.css?$/,
loaders: ['style', 'raw'],
include: __dirname
}]
}
include: __dirname,
},
],
},
};
var src = path.join(__dirname, '..', '..', 'src');
@ -41,6 +41,6 @@ if (fs.existsSync(src) && fs.existsSync(nodeModules)) {
module.exports.module.loaders.push({
test: /\.js$/,
loaders: ['babel'],
include: src
include: src,
});
}

View File

@ -1,3 +1,3 @@
{
"presets": [ "es2015", "stage-0", "react" ]
"presets": ["es2015", "stage-0", "react"]
}

View File

@ -3,13 +3,13 @@ export const DECREMENT_COUNTER = 'DECREMENT_COUNTER';
export function increment() {
return {
type: INCREMENT_COUNTER
type: INCREMENT_COUNTER,
};
}
export function decrement() {
return {
type: DECREMENT_COUNTER
type: DECREMENT_COUNTER,
};
}
@ -26,7 +26,7 @@ export function incrementIfOdd() {
}
export function incrementAsync(delay = 1000) {
return dispatch => {
return (dispatch) => {
setTimeout(() => {
dispatch(increment());
}, delay);

View File

@ -4,18 +4,18 @@ export const SEND_TO_MONITOR = 'SEND_TO_MONITOR';
export function startMonitoring() {
return {
type: START_MONITORING
type: START_MONITORING,
};
}
export function stopMonitoring() {
return {
type: STOP_MONITORING
type: STOP_MONITORING,
};
}
export function sendToMonitor() {
return {
type: SEND_TO_MONITOR
type: SEND_TO_MONITOR,
};
}

View File

@ -2,19 +2,20 @@ import React, { Component, PropTypes } from 'react';
class Counter extends Component {
render() {
const { startMonitoring, stopMonitoring, sendToMonitor, increment, decrement, counter } = this.props;
const {
startMonitoring,
stopMonitoring,
sendToMonitor,
increment,
decrement,
counter,
} = this.props;
return (
<p>
Clicked: {counter} times
{' '}
<button onClick={increment}>+</button>
{' '}
<button onClick={decrement}>-</button>
{' '}
<button onClick={startMonitoring}>Start monitoring</button>
{' '}
<button onClick={stopMonitoring}>Stop monitoring</button>
{' '}
Clicked: {counter} times <button onClick={increment}>+</button>{' '}
<button onClick={decrement}>-</button>{' '}
<button onClick={startMonitoring}>Start monitoring</button>{' '}
<button onClick={stopMonitoring}>Stop monitoring</button>{' '}
<button onClick={sendToMonitor}>Send to the monitor</button>
</p>
);
@ -27,7 +28,7 @@ Counter.propTypes = {
sendToMonitor: PropTypes.func.isRequired,
increment: PropTypes.func.isRequired,
decrement: PropTypes.func.isRequired,
counter: PropTypes.number.isRequired
counter: PropTypes.number.isRequired,
};
export default Counter;

View File

@ -6,7 +6,7 @@ import * as MonitorActions from '../actions/monitoring';
function mapStateToProps(state) {
return {
counter: state.counter
counter: state.counter,
};
}

View File

@ -3,7 +3,10 @@
<head>
<title>Redux counter example</title>
<style>
html, body, #root, #container {
html,
body,
#root,
#container {
width: 100%;
height: 100%;
margin: 0;
@ -26,8 +29,7 @@
</style>
</head>
<body>
<div id="root">
</div>
<div id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

View File

@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import counter from './counter';
const rootReducer = combineReducers({
counter
counter,
});
export default rootReducer;

View File

@ -7,17 +7,26 @@ var app = new require('express')();
var port = 4001;
var compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
app.use(
webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath,
})
);
app.use(webpackHotMiddleware(compiler));
app.get("/", function(req, res) {
app.get('/', function (req, res) {
res.sendFile(__dirname + '/index.html');
});
app.listen(port, function(error) {
app.listen(port, function (error) {
if (error) {
console.error(error);
} else {
console.info("==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port);
console.info(
'==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.',
port,
port
);
}
});

View File

@ -3,16 +3,22 @@ import thunk from 'redux-thunk';
import invariant from 'redux-immutable-state-invariant';
import devTools from 'remote-redux-devtools';
import reducer from '../reducers';
import { START_MONITORING, STOP_MONITORING, SEND_TO_MONITOR } from '../actions/monitoring';
import {
START_MONITORING,
STOP_MONITORING,
SEND_TO_MONITOR,
} from '../actions/monitoring';
export default function configureStore(initialState) {
const enhancer = compose(
applyMiddleware(invariant(), thunk),
devTools({
realtime: false,
startOn: START_MONITORING, stopOn: STOP_MONITORING,
sendOn: SEND_TO_MONITOR, sendOnError: 1,
maxAge: 30
startOn: START_MONITORING,
stopOn: STOP_MONITORING,
sendOn: SEND_TO_MONITOR,
sendOnError: 1,
maxAge: 30,
})
);

View File

@ -12,16 +12,17 @@ function mockStore(getState, expectedActions, onLastAction) {
if (!Array.isArray(expectedActions)) {
throw new Error('expectedActions should be an array of expected actions.');
}
if (typeof onLastAction !== 'undefined' && typeof onLastAction !== 'function') {
if (
typeof onLastAction !== 'undefined' &&
typeof onLastAction !== 'function'
) {
throw new Error('onLastAction should either be undefined or function.');
}
function mockStoreWithoutMiddleware() {
return {
getState() {
return typeof getState === 'function' ?
getState() :
getState;
return typeof getState === 'function' ? getState() : getState;
},
dispatch(action) {
@ -31,13 +32,13 @@ function mockStore(getState, expectedActions, onLastAction) {
onLastAction();
}
return action;
}
},
};
}
const mockStoreWithMiddleware = applyMiddleware(
...middlewares
)(mockStoreWithoutMiddleware);
const mockStoreWithMiddleware = applyMiddleware(...middlewares)(
mockStoreWithoutMiddleware
);
return mockStoreWithMiddleware();
}
@ -52,9 +53,7 @@ describe('actions', () => {
});
it('incrementIfOdd should create increment action', (done) => {
const expectedActions = [
{ type: actions.INCREMENT_COUNTER }
];
const expectedActions = [{ type: actions.INCREMENT_COUNTER }];
const store = mockStore({ counter: 1 }, expectedActions, done);
store.dispatch(actions.incrementIfOdd());
});
@ -67,9 +66,7 @@ describe('actions', () => {
});
it('incrementAsync should create increment action', (done) => {
const expectedActions = [
{ type: actions.INCREMENT_COUNTER }
];
const expectedActions = [{ type: actions.INCREMENT_COUNTER }];
const store = mockStore({ counter: 0 }, expectedActions, done);
store.dispatch(actions.incrementAsync(100));
});

View File

@ -8,14 +8,16 @@ function setup() {
increment: expect.createSpy(),
incrementIfOdd: expect.createSpy(),
incrementAsync: expect.createSpy(),
decrement: expect.createSpy()
decrement: expect.createSpy(),
};
const component = TestUtils.renderIntoDocument(<Counter counter={1} {...actions} />);
const component = TestUtils.renderIntoDocument(
<Counter counter={1} {...actions} />
);
return {
component: component,
actions: actions,
buttons: TestUtils.scryRenderedDOMComponentsWithTag(component, 'button'),
p: TestUtils.findRenderedDOMComponentWithTag(component, 'p')
p: TestUtils.findRenderedDOMComponentWithTag(component, 'p'),
};
}

View File

@ -15,7 +15,7 @@ function setup(initialState) {
return {
app: app,
buttons: TestUtils.scryRenderedDOMComponentsWithTag(app, 'button'),
p: TestUtils.findRenderedDOMComponentWithTag(app, 'p')
p: TestUtils.findRenderedDOMComponentWithTag(app, 'p'),
};
}

View File

@ -3,28 +3,27 @@ var webpack = require('webpack');
module.exports = {
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client',
'./index'
],
entry: ['webpack-hot-middleware/client', './index'],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
publicPath: '/static/',
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
new webpack.NoErrorsPlugin(),
],
module: {
loaders: [{
loaders: [
{
test: /\.js$/,
loaders: ['babel'],
exclude: /node_modules/,
include: __dirname
}]
}
include: __dirname,
},
],
},
};
var src = path.join(__dirname, '..', '..', 'src');
@ -37,6 +36,6 @@ if (fs.existsSync(src) && fs.existsSync(nodeModules)) {
module.exports.module.loaders.push({
test: /\.js$/,
loaders: ['babel'],
include: src
include: src,
});
}

View File

@ -4,6 +4,6 @@ export const defaultSocketOptions = {
port: 443,
autoReconnect: true,
autoReconnectOptions: {
randomness: 30000
}
randomness: 30000,
},
};