diff --git a/.babelrc b/.babelrc
index d0962f5..c91e0a0 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,5 +1,6 @@
{
"presets": ["es2015", "react"],
+ "plugins": ["transform-object-rest-spread"],
"env": {
"development": {
"presets": ["react-hmre"]
diff --git a/.gitignore b/.gitignore
index 6df1c4e..b0b8746 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,3 @@
-dist/
+dist/*.js
npm-debug.log
node_modules/
diff --git a/actions/index.js b/actions/index.js
index 2e52a7c..42508aa 100644
--- a/actions/index.js
+++ b/actions/index.js
@@ -1,7 +1,8 @@
import * as types from '../constants/ActionTypes'
+let nextId = 0;
export function addTodo(text) {
- return { type: types.ADD_TODO, text }
+ return { type: types.ADD_TODO, text, id: (nextId++).toString() }
}
export function deleteTodo(id) {
@@ -23,3 +24,7 @@ export function completeAll() {
export function clearCompleted() {
return { type: types.CLEAR_COMPLETED }
}
+
+export function setFilter(filter) {
+ return { type: types.SET_FILTER, filter }
+}
diff --git a/components/App.js b/components/App.js
new file mode 100644
index 0000000..5a141d6
--- /dev/null
+++ b/components/App.js
@@ -0,0 +1,13 @@
+import React, { PropTypes } from 'react'
+import Header from '../components/Header'
+import MainSection from '../components/MainSection'
+import * as TodoActions from '../actions'
+
+const App = () => (
+
+
+
+
+)
+
+export default App
diff --git a/components/FilterLink.js b/components/FilterLink.js
new file mode 100644
index 0000000..dd2ad1b
--- /dev/null
+++ b/components/FilterLink.js
@@ -0,0 +1,32 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import classnames from 'classnames'
+import { setFilter } from '../actions'
+import { SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED } from '../constants/TodoFilters'
+
+const FILTER_TITLES = {
+ [SHOW_ALL]: 'All',
+ [SHOW_ACTIVE]: 'Active',
+ [SHOW_COMPLETED]: 'Completed'
+}
+
+const FilterLink = ({ filter, selected, onClick }) => (
+
+ {FILTER_TITLES[filter]}
+
+)
+
+const mapStateToProps = (state, ownProps) => ({
+ selected: state.filter === ownProps.filter
+})
+
+const mapDispatchToProps = (dispatch, ownProps) => ({
+ onClick: () => dispatch(setFilter(ownProps.filter))
+})
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(FilterLink)
diff --git a/components/Footer.js b/components/Footer.js
index 8ae9c59..f8063b2 100644
--- a/components/Footer.js
+++ b/components/Footer.js
@@ -1,73 +1,60 @@
-import React, { PropTypes, Component } from 'react'
-import classnames from 'classnames'
+import React, { PropTypes } from 'react'
+import { connect } from 'react-redux'
+import FilterLink from './FilterLink'
+import { getCompletedCount, getListedCount } from '../reducers'
+import { clearCompleted } from '../actions'
import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'
-const FILTER_TITLES = {
- [SHOW_ALL]: 'All',
- [SHOW_ACTIVE]: 'Active',
- [SHOW_COMPLETED]: 'Completed'
-}
-
-class Footer extends Component {
- renderTodoCount() {
- const { activeCount } = this.props
- const itemWord = activeCount === 1 ? 'item' : 'items'
-
- return (
-
- {activeCount || 'No'} {itemWord} left
-
- )
- }
-
- renderFilterLink(filter) {
- const title = FILTER_TITLES[filter]
- const { filter: selectedFilter, onShow } = this.props
-
- return (
- onShow(filter)}>
- {title}
-
- )
- }
-
- renderClearButton() {
- const { completedCount, onClearCompleted } = this.props
- if (completedCount > 0) {
- return (
-
- Clear completed
-
- )
- }
- }
-
- render() {
- return (
-
- {this.renderTodoCount()}
-
- {[ SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED ].map(filter =>
-
- {this.renderFilterLink(filter)}
-
- )}
-
- {this.renderClearButton()}
-
- )
- }
-}
-
+const TodoCount = ({ activeCount }) => (
+
+ {activeCount || 'No'}
+ {' '}
+ {activeCount === 1 ? 'item' : 'items'} left
+
+)
+
+const ClearButton = ({ completedCount, clearCompleted }) => (
+
+ Clear completed
+
+)
+
+const Footer = ({ filter, completedCount, listedCount, clearCompleted }) => (
+ listedCount ? (
+
+
+
+ {[ SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED ].map(filter =>
+
+
+
+ )}
+
+ {completedCount > 0 &&
+
+ }
+
+ ) : (
+
+ )
+)
Footer.propTypes = {
- completedCount: PropTypes.number.isRequired,
- activeCount: PropTypes.number.isRequired,
filter: PropTypes.string.isRequired,
- onClearCompleted: PropTypes.func.isRequired,
- onShow: PropTypes.func.isRequired
+ listedCount: PropTypes.number.isRequired,
+ completedCount: PropTypes.number.isRequired,
}
-export default Footer
+const mapStateToProps = (state) => ({
+ filter: state.filter,
+ listedCount: getListedCount(state),
+ completedCount: getCompletedCount(state),
+})
+
+export default connect(
+ mapStateToProps,
+ { clearCompleted }
+)(Footer)
diff --git a/components/Header.js b/components/Header.js
index aeb8303..2dfa552 100644
--- a/components/Header.js
+++ b/components/Header.js
@@ -1,27 +1,27 @@
-import React, { PropTypes, Component } from 'react'
+import React, { PropTypes } from 'react'
+import { connect } from 'react-redux'
import TodoTextInput from './TodoTextInput'
+import { addTodo } from '../actions'
-class Header extends Component {
- handleSave(text) {
- if (text.length !== 0) {
- this.props.addTodo(text)
- }
- }
-
- render() {
- return (
-
- )
- }
-}
-
+const Header = ({ addTodo }) => (
+
+ todos
+ {
+ if (text.length !== 0) {
+ addTodo(text)
+ }
+ }}
+ />
+
+)
Header.propTypes = {
addTodo: PropTypes.func.isRequired
}
-export default Header
+export default connect(
+ null,
+ { addTodo }
+)(Header)
diff --git a/components/MainSection.js b/components/MainSection.js
index f5c2804..468fb57 100644
--- a/components/MainSection.js
+++ b/components/MainSection.js
@@ -1,83 +1,26 @@
-import React, { Component, PropTypes } from 'react'
+import React, { PropTypes } from 'react'
+import { connect } from 'react-redux'
import TodoItem from './TodoItem'
+import ToggleAll from './ToggleAll'
import Footer from './Footer'
-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
-}
-
-class MainSection extends Component {
- constructor(props, context) {
- super(props, context)
- this.state = { filter: SHOW_ALL }
- }
-
- handleClearCompleted() {
- this.props.actions.clearCompleted()
- }
-
- handleShow(filter) {
- this.setState({ filter })
- }
-
- renderToggleAll(completedCount) {
- const { todos, actions } = this.props
- if (todos.length > 0) {
- return (
-
- )
- }
- }
-
- renderFooter(completedCount) {
- const { todos } = this.props
- const { filter } = this.state
- const activeCount = todos.length - completedCount
-
- if (todos.length) {
- return (
-
- )
- }
- }
-
- render() {
- const { todos, actions } = this.props
- const { filter } = this.state
-
- const filteredTodos = todos.filter(TODO_FILTERS[filter])
- const completedCount = todos.reduce((count, todo) =>
- todo.completed ? count + 1 : count,
- 0
- )
-
- return (
-
- {this.renderToggleAll(completedCount)}
-
- {filteredTodos.map(todo =>
-
- )}
-
- {this.renderFooter(completedCount)}
-
- )
- }
-}
-
-MainSection.propTypes = {
- todos: PropTypes.array.isRequired,
- actions: PropTypes.object.isRequired
-}
-
-export default MainSection
+import { getVisibleTodoIds } from '../reducers'
+
+const MainSection = ({ visibleIds }) => (
+
+
+
+ {visibleIds.map(id =>
+
+ )}
+
+
+
+)
+
+const mapStateToProps = (state) => ({
+ visibleIds: getVisibleTodoIds(state)
+})
+
+export default connect(
+ mapStateToProps
+)(MainSection)
diff --git a/components/TodoItem.js b/components/TodoItem.js
index 8ffd0cb..e013c1b 100644
--- a/components/TodoItem.js
+++ b/components/TodoItem.js
@@ -1,15 +1,12 @@
import React, { Component, PropTypes } from 'react'
import classnames from 'classnames'
-import TodoTextInput from './TodoTextInput'
-import { PureRenderMixin } from 'pure-render-mixin'
-import { createSelector } from 'reselect'
import { connect } from 'react-redux'
+import TodoTextInput from './TodoTextInput'
+import { completeTodo, editTodo, deleteTodo } from '../actions'
class TodoItem extends Component {
constructor(props, context) {
super(props, context)
- // MWE: all props are immutable objects. So let's apply pure render mxixin for a big performance gain!
- this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this)
this.state = {
editing: false
}
@@ -29,39 +26,44 @@ class TodoItem extends Component {
}
render() {
- /**
- * MWE: "other" is a reference (id of) to an arbitrarily other todo item.
- * In a real app this denotes a reference to something like a user, tags
- * or something else that lives in some other part of the state tree
- */
- const { todo, completeTodo, deleteTodo, other } = this.props
+ const {
+ todo,
+ isCompleted,
+ isRelatedTodoCompleted,
+ completeTodo,
+ deleteTodo,
+ } = this.props
let element
if (this.state.editing) {
element = (
- this.handleSave(todo.id, text)} />
+ this.handleSave(todo.id, text)}
+ />
)
} else {
element = (
- completeTodo(todo.id)} />
+ completeTodo(todo.id)} />
- {todo.text} {other && other.completed ? "Yes!" : " . "}
+ {todo.text} {isRelatedTodoCompleted ? "(+)" : "(-)"}
- deleteTodo(todo.id)} />
+ deleteTodo(todo.id)} />
)
}
return (
{element}
@@ -69,30 +71,30 @@ class TodoItem extends Component {
)
}
}
-
TodoItem.propTypes = {
todo: PropTypes.object.isRequired,
+ isCompleted: PropTypes.bool,
+ isRelatedTodoCompleted: PropTypes.bool,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired,
completeTodo: PropTypes.func.isRequired
}
-const relatedTodoSelectorFactory = () => createSelector(
- [
- state => state.todos,
- (_, ownProps) => ownProps.todo.other
- ],
- (todos, otherId) => ({ other: otherId === null ? null : todos[otherId] })
-)
-
-const makeMapStateToProps = () => relatedTodoSelectorFactory();
+const makeMapStateToProps = (initialState, initialProps) => {
+ const { id } = initialProps
+ const mapStateToProps = (state) => {
+ const { todos } = state
+ const todo = todos.byId[id]
+ return {
+ todo,
+ isCompleted: todos.isCompletedById[id],
+ isRelatedTodoCompleted: todos.isCompletedById[todo.relatedId]
+ }
+ }
+ return mapStateToProps
+}
-const ConnectedTodoItem = connect(
- makeMapStateToProps
+export default connect(
+ makeMapStateToProps,
+ { completeTodo, editTodo, deleteTodo }
)(TodoItem)
-
-// MWE: export TodoItem for the plain scenario,
-// export ConnectedTodoItem for the scenario with 1 selector
-// export default TodoItem
-
-export default ConnectedTodoItem
diff --git a/components/TodoTextInput.js b/components/TodoTextInput.js
index bfdbf7d..fb3eabb 100644
--- a/components/TodoTextInput.js
+++ b/components/TodoTextInput.js
@@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react'
import classnames from 'classnames'
-class TodoTextInput extends Component {
+export default class TodoTextInput extends Component {
constructor(props, context) {
super(props, context)
this.state = {
@@ -46,7 +46,6 @@ class TodoTextInput extends Component {
)
}
}
-
TodoTextInput.propTypes = {
onSave: PropTypes.func.isRequired,
text: PropTypes.string,
@@ -54,5 +53,3 @@ TodoTextInput.propTypes = {
editing: PropTypes.bool,
newTodo: PropTypes.bool
}
-
-export default TodoTextInput
diff --git a/components/ToggleAll.js b/components/ToggleAll.js
new file mode 100644
index 0000000..af88b5e
--- /dev/null
+++ b/components/ToggleAll.js
@@ -0,0 +1,27 @@
+import React, { PropTypes } from 'react'
+import { connect } from 'react-redux'
+import { getListedCount, getCompletedCount } from '../reducers'
+import { completeAll } from '../actions'
+
+const ToggleAll = ({ completedCount, listedCount, completeAll }) => (
+ listedCount ? (
+
+ ) : (
+
+ )
+)
+
+const mapStateToProps = (state) => ({
+ listedCount: getListedCount(state),
+ completedCount: getCompletedCount(state)
+})
+
+export default connect(
+ mapStateToProps,
+ { completeAll }
+)(ToggleAll)
diff --git a/constants/ActionTypes.js b/constants/ActionTypes.js
index a7cdd02..55e5303 100644
--- a/constants/ActionTypes.js
+++ b/constants/ActionTypes.js
@@ -4,3 +4,4 @@ export const EDIT_TODO = 'EDIT_TODO'
export const COMPLETE_TODO = 'COMPLETE_TODO'
export const COMPLETE_ALL = 'COMPLETE_ALL'
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'
+export const SET_FILTER = 'SET_FILTER'
diff --git a/containers/App.js b/containers/App.js
deleted file mode 100644
index dffbb91..0000000
--- a/containers/App.js
+++ /dev/null
@@ -1,40 +0,0 @@
-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/dist/index.html b/dist/index.html
new file mode 100644
index 0000000..010c1ed
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Redux TodoMVC example
+
+
+
+
+
+
+
diff --git a/index.js b/index.js
index a5d61b7..c03cc74 100644
--- a/index.js
+++ b/index.js
@@ -2,7 +2,7 @@ import 'babel-polyfill'
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
-import App from './containers/App'
+import App from './components/App'
import configureStore from './store/configureStore'
import 'todomvc-app-css/index.css'
import * as Perf from 'react-addons-perf';
diff --git a/package.json b/package.json
index 8f44890..1e6f1d1 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"description": "Redux TodoMVC example",
"scripts": {
"start": "node server.js",
+ "build": "NODE_ENV=production webpack --config webpack.config.production.js",
"test": "cross-env NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./test/setup.js",
"test:watch": "npm test -- --watch"
},
@@ -22,13 +23,14 @@
"pure-render-mixin": "^1.0.2",
"react": "^0.14.7",
"react-dom": "^0.14.7",
- "react-redux": "^4.4.1",
+ "react-redux": "^4.4.4",
"redux": "^3.3.1",
"reselect": "^2.3.0"
},
"devDependencies": {
"babel-core": "^6.3.15",
"babel-loader": "^6.2.0",
+ "babel-plugin-transform-object-rest-spread": "^6.6.5",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-react-hmre": "^1.1.1",
diff --git a/reducers/filter.js b/reducers/filter.js
new file mode 100644
index 0000000..d8e0717
--- /dev/null
+++ b/reducers/filter.js
@@ -0,0 +1,11 @@
+import { SHOW_ALL } from '../constants/TodoFilters'
+import { SET_FILTER } from '../constants/ActionTypes'
+
+export default function filter(state = SHOW_ALL, action) {
+ switch (action.type) {
+ case SET_FILTER:
+ return action.filter
+ default:
+ return state
+ }
+}
diff --git a/reducers/index.js b/reducers/index.js
index a94ace3..cdb3b2d 100644
--- a/reducers/index.js
+++ b/reducers/index.js
@@ -1,8 +1,42 @@
import { combineReducers } from 'redux'
+import { createSelector } from 'reselect'
+import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters'
import todos from './todos'
+import filter from './filter'
-const rootReducer = combineReducers({
- todos
+export default combineReducers({
+ todos,
+ filter
})
-export default rootReducer
+export const getVisibleTodoIds = createSelector(
+ [
+ state => state.todos.listedIds,
+ state => state.todos.isCompletedById,
+ state => state.filter,
+ ],
+ (listedIds, isCompletedById, filter) => {
+ switch (filter) {
+ case SHOW_ALL:
+ return listedIds
+ case SHOW_COMPLETED:
+ return listedIds.filter(id => isCompletedById[id])
+ case SHOW_ACTIVE:
+ return listedIds.filter(id => !isCompletedById[id])
+ }
+ }
+)
+
+export const getListedCount = createSelector(
+ state => state.todos.listedIds,
+ ids => ids.length
+)
+
+export const getCompletedCount = createSelector(
+ [
+ state => state.todos.listedIds,
+ state => state.todos.isCompletedById
+ ],
+ (listedIds, isCompletedById) =>
+ listedIds.filter(id => isCompletedById[id]).length
+)
diff --git a/reducers/todos.js b/reducers/todos.js
index 475fd1a..6f95f94 100644
--- a/reducers/todos.js
+++ b/reducers/todos.js
@@ -1,66 +1,86 @@
import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED } from '../constants/ActionTypes'
+import { combineReducers } from 'redux'
-// MWE: For testing:
-const STORE_SIZE = 10000;
-
-const initialState = [
-]
+let initialById = {}
+let initialListedIds = []
-for (var i = 0; i < STORE_SIZE; i++) {
- initialState.push({
+const STORE_SIZE = 10000;
+for (let i = 0; i < STORE_SIZE; i++) {
+ let nextId = 'prefilled-' + i
+ initialListedIds.push(nextId)
+ initialById[nextId] = {
text: 'Item' + i,
- completed: false,
- id: i,
- // array index as reference to some other object in the state tree.
- // in the real world todos would probably be a map and 'initialState[i - 1].id' would be used..
- // but let's not make this test unecessary inefficient by filtering for the correct todo
- other: i > 0
- ? i - 1
- : null
- });
+ id: nextId,
+ relatedId: i > 0 ? 'prefilled-' + (i - 1) : null
+ };
}
-export default function todos(state = initialState, action) {
+function todo(state, 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
- )
-
+ return {
+ text: action.text,
+ id: action.id,
+ relatedId: null
+ }
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
- )
+ return {
+ ...state,
+ text: action.text
+ }
+ }
+}
- case COMPLETE_ALL:
- const areAllMarked = state.every(todo => todo.completed)
- return state.map(todo => Object.assign({}, todo, {
- completed: !areAllMarked
- }))
+function byId(state = initialById, action) {
+ switch (action.type) {
+ case ADD_TODO:
+ case EDIT_TODO:
+ return {
+ ...state,
+ [action.id]: todo(state[action.id], action)
+ }
+ default:
+ return state
+ }
+}
+function listedIds(state = initialListedIds, action, {isCompletedById}) {
+ switch (action.type) {
+ case ADD_TODO:
+ return [action.id, ...state]
+ case DELETE_TODO:
+ return state.filter(todoId => todoId !== action.id)
case CLEAR_COMPLETED:
- return state.filter(todo => todo.completed === false)
-
+ return state.filter(id => !isCompletedById[id])
default:
return state
}
}
+
+function isCompletedById(state = {}, action, {listedIds}) {
+ switch (action.type) {
+ case COMPLETE_TODO:
+ return {
+ ...state,
+ [action.id]: !state[action.id]
+ }
+ case COMPLETE_ALL:
+ const areAllCompleted = listedIds.every(id => state[id])
+ if (areAllCompleted) {
+ return {}
+ }
+ let nextState = {}
+ listedIds.forEach(id => nextState[id] = true)
+ return nextState
+ default:
+ return state
+ }
+}
+
+export default function todos(state = {}, action) {
+ return {
+ byId: byId(state.byId, action),
+ listedIds: listedIds(state.listedIds, action, state),
+ isCompletedById: isCompletedById(state.isCompletedById, action, state)
+ }
+}
diff --git a/webpack.config.production.js b/webpack.config.production.js
new file mode 100644
index 0000000..6f6fc99
--- /dev/null
+++ b/webpack.config.production.js
@@ -0,0 +1,33 @@
+var path = require('path')
+var webpack = require('webpack')
+
+module.exports = {
+ entry: './index',
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'bundle.js',
+ publicPath: '/static/'
+ },
+ plugins: [
+ new webpack.optimize.OccurenceOrderPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"production"'
+ }),
+ new webpack.optimize.UglifyJsPlugin()
+ ],
+ module: {
+ loaders: [
+ {
+ test: /\.js$/,
+ loaders: [ 'babel' ],
+ exclude: /node_modules/,
+ include: __dirname
+ },
+ {
+ test: /\.css?$/,
+ loaders: [ 'style', 'raw' ],
+ include: __dirname
+ }
+ ]
+ }
+}