this.handleSave(todo.id, text)} />
+ )
+ } else {
+ element = (
+
+ completeTodo(todo.id)} />
+
+
+ )
+ }
+
+ return (
+
+ {element}
+
+ )
+ }
+}
+
+TodoItem.propTypes = {
+ todo: PropTypes.object.isRequired,
+ editTodo: PropTypes.func.isRequired,
+ deleteTodo: PropTypes.func.isRequired,
+ completeTodo: PropTypes.func.isRequired
+}
+
+export default TodoItem
diff --git a/components/TodoTextInput.js b/components/TodoTextInput.js
new file mode 100644
index 0000000..bfdbf7d
--- /dev/null
+++ b/components/TodoTextInput.js
@@ -0,0 +1,58 @@
+import React, { Component, PropTypes } from 'react'
+import classnames from 'classnames'
+
+class TodoTextInput extends Component {
+ constructor(props, context) {
+ super(props, context)
+ this.state = {
+ text: this.props.text || ''
+ }
+ }
+
+ handleSubmit(e) {
+ const text = e.target.value.trim()
+ if (e.which === 13) {
+ this.props.onSave(text)
+ if (this.props.newTodo) {
+ this.setState({ text: '' })
+ }
+ }
+ }
+
+ handleChange(e) {
+ this.setState({ text: e.target.value })
+ }
+
+ handleBlur(e) {
+ if (!this.props.newTodo) {
+ this.props.onSave(e.target.value)
+ }
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+TodoTextInput.propTypes = {
+ onSave: PropTypes.func.isRequired,
+ text: PropTypes.string,
+ placeholder: PropTypes.string,
+ editing: PropTypes.bool,
+ newTodo: PropTypes.bool
+}
+
+export default TodoTextInput
diff --git a/components/stories/header.js b/components/stories/header.js
new file mode 100644
index 0000000..7fbbb5f
--- /dev/null
+++ b/components/stories/header.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import Header from '../header';
+import {storiesOf, action} from 'react-storybook';
+
+storiesOf('Header', module)
+ .add('default view', () => {
+ return (
+
+
+
+ );
+ })
+ .add('nothing', () => (Hello Man
))
diff --git a/components/stories/index.js b/components/stories/index.js
new file mode 100644
index 0000000..4e86877
--- /dev/null
+++ b/components/stories/index.js
@@ -0,0 +1 @@
+import './header'
diff --git a/constants/ActionTypes.js b/constants/ActionTypes.js
new file mode 100644
index 0000000..a7cdd02
--- /dev/null
+++ b/constants/ActionTypes.js
@@ -0,0 +1,6 @@
+export const ADD_TODO = 'ADD_TODO'
+export const DELETE_TODO = 'DELETE_TODO'
+export const EDIT_TODO = 'EDIT_TODO'
+export const COMPLETE_TODO = 'COMPLETE_TODO'
+export const COMPLETE_ALL = 'COMPLETE_ALL'
+export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'
diff --git a/constants/TodoFilters.js b/constants/TodoFilters.js
new file mode 100644
index 0000000..7268785
--- /dev/null
+++ b/constants/TodoFilters.js
@@ -0,0 +1,3 @@
+export const SHOW_ALL = 'show_all'
+export const SHOW_COMPLETED = 'show_completed'
+export const SHOW_ACTIVE = 'show_active'
diff --git a/containers/App.js b/containers/App.js
new file mode 100644
index 0000000..dffbb91
--- /dev/null
+++ b/containers/App.js
@@ -0,0 +1,40 @@
+import React, { Component, PropTypes } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import Header from '../components/Header'
+import MainSection from '../components/MainSection'
+import * as TodoActions from '../actions'
+
+class App extends Component {
+ render() {
+ const { todos, actions } = this.props
+ return (
+
+
+
+
+ )
+ }
+}
+
+App.propTypes = {
+ todos: PropTypes.array.isRequired,
+ actions: PropTypes.object.isRequired
+}
+
+function mapStateToProps(state) {
+ return {
+ todos: state.todos
+ }
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(TodoActions, dispatch)
+ }
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(App)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d956cdb
--- /dev/null
+++ b/index.html
@@ -0,0 +1,11 @@
+
+
+
+ Redux TodoMVC example
+
+
+
+
+
+
+
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..988cc99
--- /dev/null
+++ b/index.js
@@ -0,0 +1,16 @@
+import 'babel-polyfill'
+import React from 'react'
+import { render } from 'react-dom'
+import { Provider } from 'react-redux'
+import App from './containers/App'
+import configureStore from './store/configureStore'
+import 'todomvc-app-css/index.css'
+
+const store = configureStore()
+
+render(
+
+
+ ,
+ document.getElementById('root')
+)
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c90f6aa
--- /dev/null
+++ b/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "redux-todomvc-example",
+ "version": "0.0.0",
+ "description": "Redux TodoMVC example",
+ "scripts": {
+ "start": "node server.js",
+ "test": "cross-env NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./test/setup.js",
+ "test:watch": "npm test -- --watch"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/reactjs/redux.git"
+ },
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/reactjs/redux/issues"
+ },
+ "homepage": "http://redux.js.org",
+ "dependencies": {
+ "babel-polyfill": "^6.3.14",
+ "classnames": "^2.1.2",
+ "react": "^0.14.7",
+ "react-dom": "^0.14.7",
+ "react-redux": "^4.2.1",
+ "redux": "^3.2.1"
+ },
+ "devDependencies": {
+ "babel-core": "^6.3.15",
+ "babel-loader": "^6.2.0",
+ "babel-preset-es2015": "^6.3.13",
+ "babel-preset-react": "^6.3.13",
+ "babel-register": "^6.3.13",
+ "cross-env": "^1.0.7",
+ "expect": "^1.8.0",
+ "express": "^4.13.3",
+ "jsdom": "^5.6.1",
+ "mocha": "^2.2.5",
+ "node-libs-browser": "^0.5.2",
+ "raw-loader": "^0.5.1",
+ "react-addons-test-utils": "^0.14.7",
+ "style-loader": "^0.12.3",
+ "todomvc-app-css": "^2.0.1",
+ "webpack": "^1.9.11",
+ "webpack-dev-middleware": "^1.2.0",
+ "webpack-hot-middleware": "^2.9.1"
+ }
+}
diff --git a/reducers/index.js b/reducers/index.js
new file mode 100644
index 0000000..a94ace3
--- /dev/null
+++ b/reducers/index.js
@@ -0,0 +1,8 @@
+import { combineReducers } from 'redux'
+import todos from './todos'
+
+const rootReducer = combineReducers({
+ todos
+})
+
+export default rootReducer
diff --git a/reducers/todos.js b/reducers/todos.js
new file mode 100644
index 0000000..1655999
--- /dev/null
+++ b/reducers/todos.js
@@ -0,0 +1,54 @@
+import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED } from '../constants/ActionTypes'
+
+const initialState = [
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+]
+
+export default function todos(state = initialState, action) {
+ switch (action.type) {
+ case ADD_TODO:
+ return [
+ {
+ id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
+ completed: false,
+ text: action.text
+ },
+ ...state
+ ]
+
+ case DELETE_TODO:
+ 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
+ )
+
+ case COMPLETE_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
+ }))
+
+ case CLEAR_COMPLETED:
+ return state.filter(todo => todo.completed === false)
+
+ default:
+ return state
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d655597
--- /dev/null
+++ b/server.js
@@ -0,0 +1,23 @@
+var webpack = require('webpack')
+var webpackDevMiddleware = require('webpack-dev-middleware')
+var webpackHotMiddleware = require('webpack-hot-middleware')
+var config = require('./webpack.config')
+
+var app = new (require('express'))()
+var port = 3000
+
+var compiler = webpack(config)
+app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }))
+app.use(webpackHotMiddleware(compiler))
+
+app.get("/", function(req, res) {
+ res.sendFile(__dirname + '/index.html')
+})
+
+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)
+ }
+})
diff --git a/store/configureStore.js b/store/configureStore.js
new file mode 100644
index 0000000..900708b
--- /dev/null
+++ b/store/configureStore.js
@@ -0,0 +1,16 @@
+import { createStore } from 'redux'
+import rootReducer from '../reducers'
+
+export default function configureStore(initialState) {
+ const store = createStore(rootReducer, initialState)
+
+ if (module.hot) {
+ // Enable Webpack hot module replacement for reducers
+ module.hot.accept('../reducers', () => {
+ const nextReducer = require('../reducers').default
+ store.replaceReducer(nextReducer)
+ })
+ }
+
+ return store
+}
diff --git a/test/.eslintrc b/test/.eslintrc
new file mode 100644
index 0000000..7eeefc3
--- /dev/null
+++ b/test/.eslintrc
@@ -0,0 +1,5 @@
+{
+ "env": {
+ "mocha": true
+ }
+}
diff --git a/test/actions/todos.spec.js b/test/actions/todos.spec.js
new file mode 100644
index 0000000..0feb331
--- /dev/null
+++ b/test/actions/todos.spec.js
@@ -0,0 +1,46 @@
+import expect from 'expect'
+import * as types from '../../constants/ActionTypes'
+import * as actions from '../../actions'
+
+describe('todo actions', () => {
+ it('addTodo should create ADD_TODO action', () => {
+ expect(actions.addTodo('Use Redux')).toEqual({
+ type: types.ADD_TODO,
+ text: 'Use Redux'
+ })
+ })
+
+ it('deleteTodo should create DELETE_TODO action', () => {
+ expect(actions.deleteTodo(1)).toEqual({
+ type: types.DELETE_TODO,
+ id: 1
+ })
+ })
+
+ it('editTodo should create EDIT_TODO action', () => {
+ expect(actions.editTodo(1, 'Use Redux everywhere')).toEqual({
+ type: types.EDIT_TODO,
+ id: 1,
+ text: 'Use Redux everywhere'
+ })
+ })
+
+ it('completeTodo should create COMPLETE_TODO action', () => {
+ expect(actions.completeTodo(1)).toEqual({
+ type: types.COMPLETE_TODO,
+ id: 1
+ })
+ })
+
+ it('completeAll should create COMPLETE_ALL action', () => {
+ expect(actions.completeAll()).toEqual({
+ type: types.COMPLETE_ALL
+ })
+ })
+
+ it('clearCompleted should create CLEAR_COMPLETED action', () => {
+ expect(actions.clearCompleted()).toEqual({
+ type: types.CLEAR_COMPLETED
+ })
+ })
+})
diff --git a/test/components/Footer.spec.js b/test/components/Footer.spec.js
new file mode 100644
index 0000000..b482a2d
--- /dev/null
+++ b/test/components/Footer.spec.js
@@ -0,0 +1,102 @@
+import expect from 'expect'
+import React from 'react'
+import TestUtils from 'react-addons-test-utils'
+import Footer from '../../components/Footer'
+import { SHOW_ALL, SHOW_ACTIVE } from '../../constants/TodoFilters'
+
+function setup(propOverrides) {
+ const props = Object.assign({
+ completedCount: 0,
+ activeCount: 0,
+ filter: SHOW_ALL,
+ onClearCompleted: expect.createSpy(),
+ onShow: expect.createSpy()
+ }, propOverrides)
+
+ const renderer = TestUtils.createRenderer()
+ renderer.render()
+ const output = renderer.getRenderOutput()
+
+ return {
+ props: props,
+ output: output
+ }
+}
+
+function getTextContent(elem) {
+ 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
+ return out + (child.props ? getTextContent(child) : child)
+ }, '')
+}
+
+describe('components', () => {
+ describe('Footer', () => {
+ it('should render container', () => {
+ const { output } = setup()
+ expect(output.type).toBe('footer')
+ expect(output.props.className).toBe('footer')
+ })
+
+ it('should display active count when 0', () => {
+ const { output } = setup({ activeCount: 0 })
+ const [ count ] = output.props.children
+ expect(getTextContent(count)).toBe('No items left')
+ })
+
+ it('should display active count when above 0', () => {
+ const { output } = setup({ activeCount: 1 })
+ const [ count ] = output.props.children
+ expect(getTextContent(count)).toBe('1 item left')
+ })
+
+ it('should render filters', () => {
+ const { output } = setup()
+ const [ , filters ] = output.props.children
+ expect(filters.type).toBe('ul')
+ expect(filters.props.className).toBe('filters')
+ expect(filters.props.children.length).toBe(3)
+ filters.props.children.forEach(function checkFilter(filter, i) {
+ expect(filter.type).toBe('li')
+ const a = filter.props.children
+ expect(a.props.className).toBe(i === 0 ? 'selected' : '')
+ expect(a.props.children).toBe({
+ 0: 'All',
+ 1: 'Active',
+ 2: 'Completed'
+ }[i])
+ })
+ })
+
+ it('should call onShow when a filter is clicked', () => {
+ const { output, props } = setup()
+ const [ , filters ] = output.props.children
+ const filterLink = filters.props.children[1].props.children
+ filterLink.props.onClick({})
+ expect(props.onShow).toHaveBeenCalledWith(SHOW_ACTIVE)
+ })
+
+ it('shouldnt show clear button when no completed todos', () => {
+ const { output } = setup({ completedCount: 0 })
+ 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
+ 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
+ clear.props.onClick({})
+ expect(props.onClearCompleted).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/test/components/Header.spec.js b/test/components/Header.spec.js
new file mode 100644
index 0000000..474a768
--- /dev/null
+++ b/test/components/Header.spec.js
@@ -0,0 +1,50 @@
+import expect from 'expect'
+import React from 'react'
+import TestUtils from 'react-addons-test-utils'
+import Header from '../../components/Header'
+import TodoTextInput from '../../components/TodoTextInput'
+
+function setup() {
+ const props = {
+ addTodo: expect.createSpy()
+ }
+
+ const renderer = TestUtils.createRenderer()
+ renderer.render()
+ const output = renderer.getRenderOutput()
+
+ return {
+ props: props,
+ output: output,
+ renderer: renderer
+ }
+}
+
+describe('components', () => {
+ describe('Header', () => {
+ it('should render correctly', () => {
+ const { output } = setup()
+
+ expect(output.type).toBe('header')
+ expect(output.props.className).toBe('header')
+
+ const [ h1, input ] = output.props.children
+
+ expect(h1.type).toBe('h1')
+ expect(h1.props.children).toBe('todos')
+
+ expect(input.type).toBe(TodoTextInput)
+ expect(input.props.newTodo).toBe(true)
+ expect(input.props.placeholder).toBe('What needs to be done?')
+ })
+
+ it('should call addTodo if length of text is greater than 0', () => {
+ const { output, props } = setup()
+ const input = output.props.children[1]
+ input.props.onSave('')
+ expect(props.addTodo.calls.length).toBe(0)
+ input.props.onSave('Use Redux')
+ expect(props.addTodo.calls.length).toBe(1)
+ })
+ })
+})
diff --git a/test/components/MainSection.spec.js b/test/components/MainSection.spec.js
new file mode 100644
index 0000000..e3e980a
--- /dev/null
+++ b/test/components/MainSection.spec.js
@@ -0,0 +1,130 @@
+import expect from 'expect'
+import React from 'react'
+import TestUtils from 'react-addons-test-utils'
+import MainSection from '../../components/MainSection'
+import TodoItem from '../../components/TodoItem'
+import Footer from '../../components/Footer'
+import { SHOW_ALL, SHOW_COMPLETED } from '../../constants/TodoFilters'
+
+function setup(propOverrides) {
+ const props = Object.assign({
+ todos: [
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }, {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }
+ ],
+ actions: {
+ editTodo: expect.createSpy(),
+ deleteTodo: expect.createSpy(),
+ completeTodo: expect.createSpy(),
+ completeAll: expect.createSpy(),
+ clearCompleted: expect.createSpy()
+ }
+ }, propOverrides)
+
+ const renderer = TestUtils.createRenderer()
+ renderer.render()
+ const output = renderer.getRenderOutput()
+
+ return {
+ props: props,
+ output: output,
+ renderer: renderer
+ }
+}
+
+describe('components', () => {
+ describe('MainSection', () => {
+ it('should render container', () => {
+ const { output } = setup()
+ expect(output.type).toBe('section')
+ expect(output.props.className).toBe('main')
+ })
+
+ describe('toggle all input', () => {
+ it('should render', () => {
+ const { output } = setup()
+ const [ toggle ] = output.props.children
+ expect(toggle.type).toBe('input')
+ expect(toggle.props.type).toBe('checkbox')
+ expect(toggle.props.checked).toBe(false)
+ })
+
+ it('should be checked if all todos completed', () => {
+ const { output } = setup({ todos: [
+ {
+ text: 'Use Redux',
+ completed: true,
+ id: 0
+ }
+ ]
+ })
+ const [ toggle ] = output.props.children
+ expect(toggle.props.checked).toBe(true)
+ })
+
+ it('should call completeAll on change', () => {
+ const { output, props } = setup()
+ const [ toggle ] = output.props.children
+ toggle.props.onChange({})
+ expect(props.actions.completeAll).toHaveBeenCalled()
+ })
+ })
+
+ describe('footer', () => {
+ it('should render', () => {
+ const { output } = setup()
+ const [ , , footer ] = output.props.children
+ expect(footer.type).toBe(Footer)
+ expect(footer.props.completedCount).toBe(1)
+ expect(footer.props.activeCount).toBe(1)
+ expect(footer.props.filter).toBe(SHOW_ALL)
+ })
+
+ it('onShow should set the filter', () => {
+ const { output, renderer } = setup()
+ const [ , , footer ] = output.props.children
+ footer.props.onShow(SHOW_COMPLETED)
+ const updated = renderer.getRenderOutput()
+ 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
+ footer.props.onClearCompleted()
+ expect(props.actions.clearCompleted).toHaveBeenCalled()
+ })
+ })
+
+ describe('todo list', () => {
+ it('should render', () => {
+ const { output, props } = setup()
+ const [ , list ] = output.props.children
+ expect(list.type).toBe('ul')
+ expect(list.props.children.length).toBe(2)
+ list.props.children.forEach((item, i) => {
+ expect(item.type).toBe(TodoItem)
+ expect(item.props.todo).toBe(props.todos[i])
+ })
+ })
+
+ it('should filter items', () => {
+ const { output, renderer, props } = setup()
+ const [ , , footer ] = output.props.children
+ footer.props.onShow(SHOW_COMPLETED)
+ const updated = renderer.getRenderOutput()
+ const [ , updatedList ] = updated.props.children
+ expect(updatedList.props.children.length).toBe(1)
+ expect(updatedList.props.children[0].props.todo).toBe(props.todos[1])
+ })
+ })
+ })
+})
diff --git a/test/components/TodoItem.spec.js b/test/components/TodoItem.spec.js
new file mode 100644
index 0000000..abbe22b
--- /dev/null
+++ b/test/components/TodoItem.spec.js
@@ -0,0 +1,120 @@
+import expect from 'expect'
+import React from 'react'
+import TestUtils from 'react-addons-test-utils'
+import TodoItem from '../../components/TodoItem'
+import TodoTextInput from '../../components/TodoTextInput'
+
+function setup( editing = false ) {
+ const props = {
+ todo: {
+ id: 0,
+ text: 'Use Redux',
+ completed: false
+ },
+ editTodo: expect.createSpy(),
+ deleteTodo: expect.createSpy(),
+ completeTodo: expect.createSpy()
+ }
+
+ const renderer = TestUtils.createRenderer()
+
+ renderer.render(
+
+ )
+
+ let output = renderer.getRenderOutput()
+
+ if (editing) {
+ const label = output.props.children.props.children[1]
+ label.props.onDoubleClick({})
+ output = renderer.getRenderOutput()
+ }
+
+ return {
+ props: props,
+ output: output,
+ renderer: renderer
+ }
+}
+
+describe('components', () => {
+ describe('TodoItem', () => {
+ it('initial render', () => {
+ const { output } = setup()
+
+ expect(output.type).toBe('li')
+ expect(output.props.className).toBe('')
+
+ const div = output.props.children
+
+ expect(div.type).toBe('div')
+ expect(div.props.className).toBe('view')
+
+ const [ input, label, button ] = div.props.children
+
+ expect(input.type).toBe('input')
+ expect(input.props.checked).toBe(false)
+
+ expect(label.type).toBe('label')
+ expect(label.props.children).toBe('Use Redux')
+
+ expect(button.type).toBe('button')
+ expect(button.props.className).toBe('destroy')
+ })
+
+ it('input onChange should call completeTodo', () => {
+ const { output, props } = setup()
+ const input = output.props.children.props.children[0]
+ input.props.onChange({})
+ expect(props.completeTodo).toHaveBeenCalledWith(0)
+ })
+
+ it('button onClick should call deleteTodo', () => {
+ const { output, props } = setup()
+ const button = output.props.children.props.children[2]
+ button.props.onClick({})
+ expect(props.deleteTodo).toHaveBeenCalledWith(0)
+ })
+
+ it('label onDoubleClick should put component in edit state', () => {
+ const { output, renderer } = setup()
+ const label = output.props.children.props.children[1]
+ label.props.onDoubleClick({})
+ const updated = renderer.getRenderOutput()
+ expect(updated.type).toBe('li')
+ expect(updated.props.className).toBe('editing')
+ })
+
+ it('edit state render', () => {
+ const { output } = setup(true)
+
+ expect(output.type).toBe('li')
+ expect(output.props.className).toBe('editing')
+
+ const input = output.props.children
+ expect(input.type).toBe(TodoTextInput)
+ expect(input.props.text).toBe('Use Redux')
+ expect(input.props.editing).toBe(true)
+ })
+
+ it('TodoTextInput onSave should call editTodo', () => {
+ const { output, props } = setup(true)
+ output.props.children.props.onSave('Use Redux')
+ expect(props.editTodo).toHaveBeenCalledWith(0, 'Use Redux')
+ })
+
+ it('TodoTextInput onSave should call deleteTodo if text is empty', () => {
+ const { output, props } = setup(true)
+ output.props.children.props.onSave('')
+ expect(props.deleteTodo).toHaveBeenCalledWith(0)
+ })
+
+ it('TodoTextInput onSave should exit component from edit state', () => {
+ const { output, renderer } = setup(true)
+ output.props.children.props.onSave('Use Redux')
+ const updated = renderer.getRenderOutput()
+ expect(updated.type).toBe('li')
+ expect(updated.props.className).toBe('')
+ })
+ })
+})
diff --git a/test/components/TodoTextInput.spec.js b/test/components/TodoTextInput.spec.js
new file mode 100644
index 0000000..bdcfb6d
--- /dev/null
+++ b/test/components/TodoTextInput.spec.js
@@ -0,0 +1,83 @@
+import expect from 'expect'
+import React from 'react'
+import TestUtils from 'react-addons-test-utils'
+import TodoTextInput from '../../components/TodoTextInput'
+
+function setup(propOverrides) {
+ const props = Object.assign({
+ onSave: expect.createSpy(),
+ text: 'Use Redux',
+ placeholder: 'What needs to be done?',
+ editing: false,
+ newTodo: false
+ }, propOverrides)
+
+ const renderer = TestUtils.createRenderer()
+
+ renderer.render(
+
+ )
+
+ let output = renderer.getRenderOutput()
+
+ output = renderer.getRenderOutput()
+
+ return {
+ props: props,
+ output: output,
+ renderer: renderer
+ }
+}
+
+describe('components', () => {
+ describe('TodoTextInput', () => {
+ it('should render correctly', () => {
+ const { output } = setup()
+ expect(output.props.placeholder).toEqual('What needs to be done?')
+ expect(output.props.value).toEqual('Use Redux')
+ expect(output.props.className).toEqual('')
+ })
+
+ it('should render correctly when editing=true', () => {
+ const { output } = setup({ editing: true })
+ expect(output.props.className).toEqual('edit')
+ })
+
+ it('should render correctly when newTodo=true', () => {
+ const { output } = setup({ newTodo: true })
+ expect(output.props.className).toEqual('new-todo')
+ })
+
+ it('should update value on change', () => {
+ const { output, renderer } = setup()
+ 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' } })
+ 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' } })
+ 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' } })
+ 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' } })
+ expect(props.onSave.calls.length).toBe(0)
+ })
+ })
+})
diff --git a/test/reducers/todos.spec.js b/test/reducers/todos.spec.js
new file mode 100644
index 0000000..c7e887c
--- /dev/null
+++ b/test/reducers/todos.spec.js
@@ -0,0 +1,285 @@
+import expect from 'expect'
+import todos from '../../reducers/todos'
+import * as types from '../../constants/ActionTypes'
+
+describe('todos reducer', () => {
+ it('should handle initial state', () => {
+ expect(
+ todos(undefined, {})
+ ).toEqual([
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle ADD_TODO', () => {
+ expect(
+ todos([], {
+ type: types.ADD_TODO,
+ text: 'Run the tests'
+ })
+ ).toEqual([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 0
+ }
+ ])
+
+ expect(
+ todos([
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.ADD_TODO,
+ text: 'Run the tests'
+ })
+ ).toEqual([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.ADD_TODO,
+ text: 'Fix the tests'
+ })
+ ).toEqual([
+ {
+ text: 'Fix the tests',
+ completed: false,
+ id: 2
+ }, {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle DELETE_TODO', () => {
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.DELETE_TODO,
+ id: 1
+ })
+ ).toEqual([
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle EDIT_TODO', () => {
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.EDIT_TODO,
+ text: 'Fix the tests',
+ id: 1
+ })
+ ).toEqual([
+ {
+ text: 'Fix the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle COMPLETE_TODO', () => {
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.COMPLETE_TODO,
+ id: 1
+ })
+ ).toEqual([
+ {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle COMPLETE_ALL', () => {
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.COMPLETE_ALL
+ })
+ ).toEqual([
+ {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: true,
+ id: 0
+ }
+ ])
+
+ // Unmark if all todos are currently completed
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: true,
+ id: 0
+ }
+ ], {
+ type: types.COMPLETE_ALL
+ })
+ ).toEqual([
+ {
+ text: 'Run the tests',
+ completed: false,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should handle CLEAR_COMPLETED', () => {
+ expect(
+ todos([
+ {
+ text: 'Run the tests',
+ completed: true,
+ id: 1
+ }, {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ], {
+ type: types.CLEAR_COMPLETED
+ })
+ ).toEqual([
+ {
+ text: 'Use Redux',
+ completed: false,
+ id: 0
+ }
+ ])
+ })
+
+ it('should not generate duplicate ids after CLEAR_COMPLETED', () => {
+ expect(
+ [
+ {
+ type: types.COMPLETE_TODO,
+ id: 0
+ }, {
+ type: types.CLEAR_COMPLETED
+ }, {
+ type: types.ADD_TODO,
+ text: 'Write more tests'
+ }
+ ].reduce(todos, [
+ {
+ id: 0,
+ completed: false,
+ text: 'Use Redux'
+ }, {
+ id: 1,
+ completed: false,
+ text: 'Write tests'
+ }
+ ])
+ ).toEqual([
+ {
+ text: 'Write more tests',
+ completed: false,
+ id: 2
+ }, {
+ text: 'Write tests',
+ completed: false,
+ id: 1
+ }
+ ])
+ })
+})
diff --git a/test/setup.js b/test/setup.js
new file mode 100644
index 0000000..c2e0f0c
--- /dev/null
+++ b/test/setup.js
@@ -0,0 +1,5 @@
+import { jsdom } from 'jsdom'
+
+global.document = jsdom('')
+global.window = document.defaultView
+global.navigator = global.window.navigator
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..ef2f5cc
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,34 @@
+var path = require('path')
+var webpack = require('webpack')
+
+module.exports = {
+ devtool: 'cheap-module-eval-source-map',
+ entry: [
+ 'webpack-hot-middleware/client',
+ './index'
+ ],
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'bundle.js',
+ publicPath: '/static/'
+ },
+ plugins: [
+ new webpack.optimize.OccurenceOrderPlugin(),
+ new webpack.HotModuleReplacementPlugin()
+ ],
+ module: {
+ loaders: [
+ {
+ test: /\.js$/,
+ loaders: [ 'babel' ],
+ exclude: /node_modules/,
+ include: __dirname
+ },
+ {
+ test: /\.css?$/,
+ loaders: [ 'style', 'raw' ],
+ include: __dirname
+ }
+ ]
+ }
+}