├── Procfile ├── .eslintignore ├── test ├── .eslintrc ├── setup.js ├── components │ ├── TodoList.js │ ├── Link.js │ ├── Todo.js │ ├── TodoTextInput.js │ └── Footer.js ├── actions │ └── todos.spec.js └── reducers │ └── todos.spec.js ├── .babelrc ├── constants ├── TodoFilters.js └── ActionTypes.js ├── sagas ├── bootstrap.js ├── api.js ├── fetch-todos.js ├── clear-completed.js ├── index.js ├── add-todo.js ├── complete-all-todos.js ├── complete-todo.js ├── delete-todo.js └── edit-todo.js ├── index.html ├── reducers ├── index.js ├── visibilityFilter.js └── todos.js ├── .eslintrc ├── components ├── App.js ├── Link.js ├── TodoList.js ├── TodoTextInput.js ├── Footer.js └── Todo.js ├── index.js ├── containers ├── Header.js ├── VisibleTodoList.js └── FooterNav.js ├── README.md ├── store └── configureStore.js ├── webpack.config.js ├── selectors └── index.js ├── actions └── index.js ├── package.json └── server.js /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config*.js 2 | server.js -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /constants/TodoFilters.js: -------------------------------------------------------------------------------- 1 | export const SHOW_ALL = 'SHOW_ALL' 2 | export const SHOW_COMPLETED = 'SHOW_COMPLETED' 3 | export const SHOW_ACTIVE = 'SHOW_ACTIVE' 4 | -------------------------------------------------------------------------------- /sagas/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { put } from 'redux-saga/effects' 2 | import { fetchTodos } from '../actions' 3 | 4 | export default function* bootstrap() { 5 | yield put(fetchTodos()) 6 | } -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { jsdom } from 'jsdom' 2 | 3 | global.document = jsdom('') 4 | global.window = document.defaultView 5 | global.navigator = global.window.navigator -------------------------------------------------------------------------------- /sagas/api.js: -------------------------------------------------------------------------------- 1 | export default function api(url, opts) { 2 | return fetch(url, opts) 3 | .then(function (resp) { 4 | return resp.json() 5 | }) 6 | .then(function (resp) { 7 | return resp 8 | }) 9 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TodoMVC Sagas Example 5 | 6 | 7 |
8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import todos from './todos' 3 | import visibilityFilter from './visibilityFilter' 4 | 5 | const todoApp = combineReducers({ 6 | todos, 7 | visibilityFilter 8 | }) 9 | 10 | export default todoApp 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rackt", 3 | "rules": { 4 | "valid-jsdoc": 2, 5 | // Disable until Flow supports let and const 6 | "no-var": 0, 7 | "react/jsx-uses-react": 1, 8 | "react/jsx-no-undef": 2, 9 | "react/wrap-multilines": 2 10 | }, 11 | "plugins": [ 12 | "react" 13 | ] 14 | } -------------------------------------------------------------------------------- /components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Header from '../containers/Header' 3 | import VisibleTodoList from '../containers/VisibleTodoList' 4 | import Footer from '../containers/FooterNav' 5 | 6 | const App = () => ( 7 |
8 |
9 | 10 |
12 | ) 13 | 14 | export default App 15 | -------------------------------------------------------------------------------- /reducers/visibilityFilter.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import * as TodoFilters from '../constants/TodoFilters' 3 | 4 | const visibilityFilter = (state = TodoFilters.SHOW_ALL, action) => { 5 | switch (action.type) { 6 | case ActionTypes.SET_VISIBILITY_FILTER: 7 | return action.filter 8 | default: 9 | return state 10 | } 11 | } 12 | 13 | export default visibilityFilter 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import React from 'react' 3 | import { render } from 'react-dom' 4 | import { Provider } from 'react-redux' 5 | import App from './components/App' 6 | import configureStore from './store/configureStore' 7 | import 'todomvc-app-css/index.css' 8 | 9 | const store = configureStore() 10 | 11 | render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ) 17 | -------------------------------------------------------------------------------- /containers/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { addTodo } from '../actions' 4 | import TodoTextInput from '../components/TodoTextInput' 5 | 6 | let Header = ({ dispatch }) => ( 7 |
8 |

todos

9 | dispatch(addTodo(value))} 11 | placeholder="What needs to be done?" /> 12 |
13 | ) 14 | 15 | Header = connect()(Header) 16 | 17 | export default Header 18 | -------------------------------------------------------------------------------- /components/Link.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import classnames from 'classnames' 3 | 4 | const Link = ({ active, children, onClick }) => ( 5 |
  • 6 | { 10 | e.preventDefault() 11 | onClick() 12 | }} 13 | > 14 | {children} 15 | 16 |
  • 17 | ) 18 | 19 | Link.propTypes = { 20 | active: PropTypes.bool.isRequired, 21 | children: PropTypes.node.isRequired, 22 | onClick: PropTypes.func.isRequired 23 | } 24 | 25 | export default Link 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todomvc-sagas-example 2 | 3 | This is an example of a TodoMVC app which uses [redux-saga](https://github.com/yelouafi/redux-saga) to do the asynchronous fetching. 4 | 5 | On the server it is using [nedb](https://github.com/louischatriot/nedb) which is a lighweight, npm/node friendly mongo-like data store. It's using their in-memory DataStore to do basic create, read, update, delete operations. 6 | 7 | ## Install and Run 8 | 9 | ```bash 10 | $ npm install && npm run start 11 | ``` 12 | 13 | ## TODO 14 | 15 | - [ ] handling failed actions in reducers 16 | - [ ] adding tests for sagas 17 | 18 | ## CONTRIBUTING 19 | 20 | All contributions are welcome. 21 | 22 | ## LICENSE 23 | 24 | MIT -------------------------------------------------------------------------------- /containers/VisibleTodoList.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux' 2 | import { connect } from 'react-redux' 3 | import * as TodoActions from '../actions' 4 | import { getCompletedCount, getVisibleTodos } from '../selectors' 5 | import TodoList from '../components/TodoList' 6 | 7 | const mapStateToProps = (state) => { 8 | return { 9 | completedCount: getCompletedCount(state), 10 | todos: getVisibleTodos(state) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = (dispatch) => { 15 | return bindActionCreators(TodoActions, dispatch) 16 | } 17 | 18 | const VisibleTodoList = connect( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(TodoList) 22 | 23 | export default VisibleTodoList 24 | -------------------------------------------------------------------------------- /store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import createSagaMiddleware from 'redux-saga' 3 | 4 | import rootReducer from '../reducers' 5 | import sagas from '../sagas' 6 | 7 | export default function configureStore(initialState) { 8 | const sagaMiddleware = createSagaMiddleware() 9 | 10 | const store = createStore( 11 | rootReducer, 12 | initialState, 13 | applyMiddleware(sagaMiddleware) 14 | ) 15 | sagaMiddleware.run(sagas) 16 | 17 | if (module.hot) { 18 | // Enable Webpack hot module replacement for reducers 19 | module.hot.accept('../reducers', () => { 20 | const nextReducer = require('../reducers').default 21 | store.replaceReducer(nextReducer) 22 | }) 23 | } 24 | 25 | return store 26 | } 27 | -------------------------------------------------------------------------------- /containers/FooterNav.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { clearCompleted, setVisibilityFilter } from '../actions' 3 | import { getActiveCount, getCompletedCount } from '../selectors' 4 | import Footer from '../components/Footer' 5 | 6 | const mapStateToProps = (state) => { 7 | return { 8 | activeCount: getActiveCount(state), 9 | completedCount: getCompletedCount(state), 10 | visibilityFilter: state.visibilityFilter 11 | } 12 | } 13 | 14 | const mapDispatchToProps = (dispatch) => { 15 | return { 16 | onShow: (id) => { 17 | dispatch(setVisibilityFilter(id)) 18 | }, 19 | onClearCompleted: () => { 20 | dispatch(clearCompleted()) 21 | } 22 | } 23 | } 24 | 25 | const FooterNav = connect( 26 | mapStateToProps, 27 | mapDispatchToProps 28 | )(Footer) 29 | 30 | export default FooterNav 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | 4 | module.exports = { 5 | devtool: '#cheap-module-eval-source-map', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | './index' 9 | ], 10 | output: { 11 | path: path.join(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/' 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin() 18 | ], 19 | module: { 20 | loaders: [ 21 | { 22 | test: /\.js$/, 23 | loaders: [ 'babel' ], 24 | exclude: /node_modules/, 25 | include: __dirname 26 | }, 27 | { 28 | test: /\.css?$/, 29 | loaders: [ 'style', 'raw' ], 30 | include: __dirname 31 | } 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sagas/fetch-todos.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* fetchAllTodos(action) { 7 | try { 8 | const todos = yield call( 9 | api, 10 | '/fetch-todos', 11 | { 12 | method: 'POST', 13 | headers: { 14 | 'Accept': 'application/json', 15 | 'Content-Type': 'application/json' 16 | }, 17 | body: '' 18 | } 19 | ) 20 | 21 | yield put({ type: ActionTypes.FETCH_TODOS_SUCCEEDED, todos }) 22 | } catch (e) { 23 | yield put({ type: ActionTypes.FETCH_TODOS_FAILED, message: e.message }) 24 | } 25 | } 26 | 27 | export default function* watchFetchTodos() { 28 | yield* takeLatest(ActionTypes.FETCH_TODOS_REQUESTED, fetchAllTodos) 29 | } 30 | -------------------------------------------------------------------------------- /sagas/clear-completed.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* clearCompletedTodos(action) { 7 | try { 8 | const todo = yield call( 9 | api, 10 | '/clear-completed', 11 | { 12 | method: 'POST', 13 | headers: { 14 | 'Accept': 'application/json', 15 | 'Content-Type': 'application/json' 16 | }, 17 | body: '' 18 | } 19 | ) 20 | 21 | yield put({ type: ActionTypes.CLEAR_COMPLETED_SUCCEEDED }) 22 | } catch (e) { 23 | yield put({ type: ActionTypes.CLEAR_COMPLETED_FAILED }) 24 | } 25 | } 26 | 27 | export default function* watchClearCompletedTodos() { 28 | yield* takeLatest(ActionTypes.CLEAR_COMPLETED_REQUESTED, clearCompletedTodos) 29 | } 30 | -------------------------------------------------------------------------------- /sagas/index.js: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga' 2 | import { call, fork, put } from 'redux-saga/effects' 3 | import * as ActionTypes from '../constants/ActionTypes' 4 | import watchAddTodo from './add-todo' 5 | import watchEditTodo from './edit-todo' 6 | import watchDeleteTodo from './delete-todo' 7 | import watchFetchTodos from './fetch-todos' 8 | import watchCompleteTodo from './complete-todo' 9 | import watchCompleteAllTodos from './complete-all-todos' 10 | import watchClearCompletedTodos from './clear-completed' 11 | import bootstrap from './bootstrap' 12 | 13 | export default function* watchMany() { 14 | yield [ 15 | fork(watchAddTodo), 16 | fork(watchEditTodo), 17 | fork(watchFetchTodos), 18 | fork(watchDeleteTodo), 19 | fork(watchCompleteTodo), 20 | fork(watchCompleteAllTodos), 21 | fork(watchClearCompletedTodos), 22 | fork(bootstrap) 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /sagas/add-todo.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* addTodo(action) { 7 | const { text: t } = action 8 | 9 | try { 10 | const todo = yield call( 11 | api, 12 | '/add-todo', 13 | { 14 | method: 'POST', 15 | headers: { 16 | 'Accept': 'application/json', 17 | 'Content-Type': 'application/json' 18 | }, 19 | body: JSON.stringify({ text: t }) 20 | } 21 | ) 22 | 23 | const { _id: id, text } = todo 24 | 25 | yield put({ type: ActionTypes.ADD_TODO_SUCCEEDED, id, text }) 26 | } catch (e) { 27 | yield put({ type: ActionTypes.ADD_TODO_FAILED, message: e.message }) 28 | } 29 | } 30 | 31 | export default function* watchAddTodo() { 32 | yield* takeLatest(ActionTypes.ADD_TODO_REQUESTED, addTodo) 33 | } 34 | -------------------------------------------------------------------------------- /sagas/complete-all-todos.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* completeAllTodos(action) { 7 | // todo pass all todos 8 | const { id, completed: c } = action 9 | 10 | try { 11 | const todo = yield call( 12 | api, 13 | '/complete-all', 14 | { 15 | method: 'POST', 16 | headers: { 17 | 'Accept': 'application/json', 18 | 'Content-Type': 'application/json' 19 | }, 20 | body: '' 21 | } 22 | ) 23 | 24 | yield put({ type: ActionTypes.COMPLETE_ALL_SUCCEEDED }) 25 | } catch (e) { 26 | yield put({ type: ActionTypes.COMPLETE_ALL_FAILED, id, c }) 27 | } 28 | } 29 | 30 | export default function* watchCompleteAllTodos() { 31 | yield* takeLatest(ActionTypes.COMPLETE_ALL_REQUESTED, completeAllTodos) 32 | } 33 | -------------------------------------------------------------------------------- /selectors/index.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | import * as TodoFilters from '../constants/TodoFilters' 3 | 4 | const getVisibilityFilter = (state) => state.visibilityFilter 5 | const getTodos = (state) => state.todos 6 | 7 | export const getVisibleTodos = createSelector( 8 | [ getVisibilityFilter, getTodos ], 9 | (visibilityFilter, todos) => { 10 | switch (visibilityFilter) { 11 | case TodoFilters.SHOW_ALL: 12 | return todos 13 | case TodoFilters.SHOW_COMPLETED: 14 | return todos.filter(t => t.completed) 15 | case TodoFilters.SHOW_ACTIVE: 16 | return todos.filter(t => !t.completed) 17 | } 18 | } 19 | ) 20 | 21 | export const getActiveCount = createSelector( 22 | [ getTodos ], 23 | (todos) => todos.filter(todo => !todo.completed).length 24 | ) 25 | 26 | export const getCompletedCount = createSelector( 27 | [ getTodos ], 28 | (todos) => todos.filter(todo => todo.completed).length 29 | ) 30 | -------------------------------------------------------------------------------- /sagas/complete-todo.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* completeTodo(action) { 7 | const { id, completed: c } = action 8 | 9 | try { 10 | const todo = yield call( 11 | api, 12 | '/complete-todo', 13 | { 14 | method: 'POST', 15 | headers: { 16 | 'Accept': 'application/json', 17 | 'Content-Type': 'application/json' 18 | }, 19 | body: JSON.stringify({ id }) 20 | } 21 | ) 22 | 23 | const { completed } = todo 24 | 25 | yield put({ type: ActionTypes.COMPLETE_TODO_SUCCEEDED, id, completed }) 26 | } catch (e) { 27 | yield put({ type: ActionTypes.COMPLETE_TODO_FAILED, id, c }) 28 | } 29 | } 30 | 31 | export default function* completeDeleteTodo() { 32 | yield* takeLatest(ActionTypes.COMPLETE_TODO_REQUESTED, completeTodo) 33 | } 34 | -------------------------------------------------------------------------------- /sagas/delete-todo.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* deleteTodo(action) { 7 | const { id, text } = action 8 | 9 | // why does this get called on init? 10 | if (!id) return 11 | 12 | try { 13 | const todo = yield call( 14 | api, 15 | '/delete-todo', 16 | { 17 | method: 'POST', 18 | headers: { 19 | 'Accept': 'application/json', 20 | 'Content-Type': 'application/json' 21 | }, 22 | body: JSON.stringify({ id }) 23 | } 24 | ) 25 | 26 | yield put({ type: ActionTypes.DELETE_TODO_SUCCEEDED, id }) 27 | } catch (e) { 28 | yield put({ type: ActionTypes.DELETE_TODO_FAILED, id, text }) 29 | } 30 | } 31 | 32 | export default function* watchDeleteTodo() { 33 | yield* takeLatest(ActionTypes.DELETE_TODO_REQUESTED, deleteTodo) 34 | } 35 | -------------------------------------------------------------------------------- /sagas/edit-todo.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | import api from './api' 3 | import { call, put } from 'redux-saga/effects' 4 | import { takeLatest } from 'redux-saga' 5 | 6 | function* editTodo(action) { 7 | const { id, text } = action 8 | 9 | // why does this get called on init? 10 | if (!id) return 11 | 12 | try { 13 | const todo = yield call( 14 | api, 15 | '/edit-todo', 16 | { 17 | method: 'POST', 18 | headers: { 19 | 'Accept': 'application/json', 20 | 'Content-Type': 'application/json' 21 | }, 22 | body: JSON.stringify({ id, text }) 23 | } 24 | ) 25 | 26 | yield put({ type: ActionTypes.EDIT_TODO_SUCCEEDED, payload: todo }) 27 | } catch (e) { 28 | yield put({ type: ActionTypes.EDIT_TODO_FAILED, id, text }) 29 | } 30 | } 31 | 32 | export default function* watchEditTodo() { 33 | yield* takeLatest(ActionTypes.EDIT_TODO_REQUESTED, editTodo) 34 | } 35 | -------------------------------------------------------------------------------- /actions/index.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | 3 | export const fetchTodos = () => { 4 | return { 5 | type: ActionTypes.FETCH_TODOS_REQUESTED 6 | } 7 | } 8 | export const addTodo = (text) => { 9 | return { 10 | type: ActionTypes.ADD_TODO_REQUESTED, 11 | text 12 | } 13 | } 14 | 15 | export const setVisibilityFilter = (filter) => { 16 | return { 17 | type: ActionTypes.SET_VISIBILITY_FILTER, 18 | filter 19 | } 20 | } 21 | 22 | export const deleteTodo = (id, text) => { 23 | return { 24 | type: ActionTypes.DELETE_TODO_REQUESTED, 25 | id, 26 | text 27 | } 28 | } 29 | 30 | export const editTodo = (id, text) => { 31 | return { 32 | type: ActionTypes.EDIT_TODO_REQUESTED, 33 | id, 34 | text 35 | } 36 | } 37 | 38 | export const completeTodo = (id, completed) => { 39 | return { 40 | type: ActionTypes.COMPLETE_TODO_REQUESTED, 41 | id, 42 | completed 43 | } 44 | } 45 | 46 | export const clearCompleted = () => { 47 | return { 48 | type: ActionTypes.CLEAR_COMPLETED_REQUESTED 49 | } 50 | } 51 | 52 | export const completeAll = () => { 53 | return { 54 | type: ActionTypes.COMPLETE_ALL_REQUESTED 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /components/TodoList.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Todo from './Todo' 3 | 4 | const TodoList = ({ 5 | completedCount, 6 | todos, 7 | deleteTodo, 8 | editTodo, 9 | completeTodo, 10 | completeAll 11 | }) => ( 12 |
    13 | completeAll()} /> 17 | 18 | 29 |
    30 | ) 31 | 32 | TodoList.propTypes = { 33 | todos: PropTypes.arrayOf(PropTypes.shape({ 34 | id: PropTypes.string.isRequired, 35 | completed: PropTypes.bool.isRequired, 36 | text: PropTypes.string.isRequired 37 | }).isRequired).isRequired, 38 | deleteTodo: PropTypes.func.isRequired, 39 | editTodo: PropTypes.func.isRequired, 40 | completeTodo: PropTypes.func.isRequired, 41 | completeAll: PropTypes.func.isRequired 42 | } 43 | 44 | export default TodoList -------------------------------------------------------------------------------- /constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const FETCH_TODOS_REQUESTED = 'FETCH_TODOS_REQUESTED' 2 | export const FETCH_TODOS_SUCCEEDED = 'FETCH_TODOS_SUCCEEDED' 3 | export const FETCH_TODOS_FAILED = 'FETCH_TODOS_FAILED' 4 | export const ADD_TODO_REQUESTED = 'ADD_TODO_REQUESTED' 5 | export const ADD_TODO_SUCCEEDED = 'ADD_TODO_SUCCEEDED' 6 | export const ADD_TODO_FAILED = 'ADD_TODO_FAILED' 7 | export const DELETE_TODO_REQUESTED = 'DELETE_TODO_REQUESTED' 8 | export const DELETE_TODO_SUCCEEDED = 'DELETE_TODO_SUCCEEDED' 9 | export const DELETE_TODO_FAILED = 'DELETE_TODO_FAILED' 10 | export const EDIT_TODO_REQUESTED = 'EDIT_TODO_REQUESTED' 11 | export const EDIT_TODO_SUCCEEDED = 'EDIT_TODO_SUCCEEDED' 12 | export const EDIT_TODO_FAILED = 'EDIT_TODO_FAILED' 13 | export const COMPLETE_TODO_REQUESTED = 'COMPLETE_TODO_REQUESTED' 14 | export const COMPLETE_TODO_SUCCEEDED = 'COMPLETE_TODO_SUCCEEDED' 15 | export const COMPLETE_TODO_FAILED = 'COMPLETE_TODO_FAILED' 16 | export const COMPLETE_ALL_REQUESTED = 'COMPLETE_ALL_REQUESTED' 17 | export const COMPLETE_ALL_SUCCEEDED = 'COMPLETE_ALL_SUCCEEDED' 18 | export const COMPLETE_ALL_FAILED = 'COMPLETE_ALL_FAILED' 19 | export const CLEAR_COMPLETED_REQUESTED = 'CLEAR_COMPLETED_REQUESTED' 20 | export const CLEAR_COMPLETED_SUCCEEDED = 'CLEAR_COMPLETED_SUCCEEDED' 21 | export const CLEAR_COMPLETED_FAILED = 'CLEAR_COMPLETED_FAILED' 22 | export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER' 23 | -------------------------------------------------------------------------------- /components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import classnames from 'classnames' 3 | 4 | class TodoTextInput extends Component { 5 | constructor(props, context) { 6 | super(props, context) 7 | this.state = { 8 | text: this.props.text || '' 9 | } 10 | } 11 | 12 | handleSubmit(e) { 13 | const text = e.target.value.trim() 14 | if (e.which === 13) { 15 | this.props.onSave(text) 16 | if (this.props.newTodo) { 17 | this.setState({ text: '' }) 18 | } 19 | } 20 | } 21 | 22 | handleChange(e) { 23 | this.setState({ text: e.target.value }) 24 | } 25 | 26 | handleBlur(e) { 27 | if (!this.props.newTodo) { 28 | this.props.onSave(e.target.value) 29 | } 30 | } 31 | 32 | render() { 33 | return ( 34 | 46 | ) 47 | } 48 | } 49 | 50 | TodoTextInput.propTypes = { 51 | onSave: PropTypes.func.isRequired, 52 | text: PropTypes.string, 53 | placeholder: PropTypes.string, 54 | editing: PropTypes.bool, 55 | newTodo: PropTypes.bool 56 | } 57 | 58 | export default TodoTextInput 59 | -------------------------------------------------------------------------------- /test/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import { shallow } from 'enzyme' 4 | import TodoList from '../../components/TodoList' 5 | 6 | function setup() { 7 | const actions = { 8 | editTodo: expect.createSpy(), 9 | deleteTodo: expect.createSpy(), 10 | completeTodo: expect.createSpy(), 11 | completeAll: expect.createSpy() 12 | } 13 | 14 | const todos = [ 15 | { 16 | id: 0, 17 | text: 'Use Redux', 18 | completed: false 19 | } 20 | ] 21 | 22 | const component = shallow( 23 | 24 | ) 25 | 26 | return { 27 | component: component, 28 | actions: actions, 29 | toggleAll: component.find('.toggle-all'), 30 | todoList: component.find('.todo-list') 31 | } 32 | } 33 | 34 | describe('', () => { 35 | it('should render a toggle all input', () => { 36 | const { toggleAll } = setup() 37 | expect(toggleAll.length).toEqual(1) 38 | }) 39 | 40 | it('should call completeAll when toggle all input changed', () => { 41 | const { actions, toggleAll } = setup() 42 | toggleAll.simulate('change') 43 | expect(actions.completeAll).toHaveBeenCalled() 44 | }) 45 | 46 | it('should render a todo list', () => { 47 | const { todoList } = setup() 48 | expect(todoList.length).toEqual(1) 49 | }) 50 | 51 | it('should render a list of todos', () => { 52 | const { todoList } = setup() 53 | expect(todoList.find('Todo').at(0).length).toEqual(1) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/components/Link.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import { shallow } from 'enzyme' 4 | import Link from '../../components/Link' 5 | 6 | function setup(active, displayName) { 7 | const actions = { 8 | onClick: expect.createSpy() 9 | } 10 | 11 | const component = shallow( 12 | 13 | {displayName} 14 | 15 | ) 16 | 17 | return { 18 | component: component, 19 | actions: actions, 20 | listItem: component.find('li'), 21 | anchor: component.find('a') 22 | } 23 | } 24 | 25 | describe('', () => { 26 | it('should render a list item', () => { 27 | const { listItem } = setup(true, 'All') 28 | expect(listItem.length).toEqual(1) 29 | }) 30 | 31 | it('should render the specified text for the text', () => { 32 | const { anchor } = setup(true, 'All') 33 | expect(anchor.text()).toEqual('All') 34 | }) 35 | 36 | it('should render selected class if was passed active as true', () => { 37 | const { listItem } = setup(true, 'All') 38 | expect(listItem.find('.selected').length).toEqual(1) 39 | }) 40 | 41 | it('should not render selected class if was passed active as false', () => { 42 | const { listItem } = setup(false, 'All') 43 | expect(listItem.find('.selected').length).toEqual(0) 44 | }) 45 | 46 | it('should call onClick when clicked', () => { 47 | const { actions, anchor } = setup(true, 'All') 48 | anchor.props().onClick({ preventDefault: () => {}, target: { value: '' } }) 49 | expect(actions.onClick).toHaveBeenCalled() 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import * as TodoFilters from '../constants/TodoFilters' 3 | import Link from './Link' 4 | 5 | const Footer = ({ 6 | activeCount, 7 | completedCount, 8 | onShow, 9 | onClearCompleted, 10 | visibilityFilter 11 | }) => { 12 | const itemWord = activeCount === 1 ? 'item' : 'items' 13 | 14 | const links = [ 15 | { id: TodoFilters.SHOW_ALL, displayName: 'All' }, 16 | { id: TodoFilters.SHOW_ACTIVE, displayName: 'Active' }, 17 | { id: TodoFilters.SHOW_COMPLETED, displayName: 'Completed' } 18 | ] 19 | 20 | return ( 21 |
    22 | 23 | {activeCount || 'No'} {itemWord} left 24 | 25 | 26 |
      27 | { 28 | links.map(link => 29 | { onShow(link.id) }} 33 | > 34 | {link.displayName} 35 | 36 | ) 37 | } 38 |
    39 | 40 | { (completedCount > 0) ? 41 | 45 | : null 46 | } 47 |
    48 | ) 49 | } 50 | 51 | Footer.propTypes = { 52 | activeCount: PropTypes.number.isRequired, 53 | completedCount: PropTypes.number.isRequired, 54 | onShow: PropTypes.func.isRequired, 55 | onClearCompleted: PropTypes.func.isRequired, 56 | visibilityFilter: PropTypes.string.isRequired 57 | } 58 | 59 | export default Footer 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todomvc-sagas-example", 3 | "version": "0.0.0", 4 | "description": "TodoMVC Redux Sagas Example", 5 | "scripts": { 6 | "lint": "eslint .", 7 | "start": "node server.js", 8 | "test": "cross-env NODE_ENV=test mocha --recursive --compilers js:babel-register", 9 | "test:watch": "npm test -- --watch" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mjw56/todomvc-sagas-example.git" 14 | }, 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/mjw56/todomvc-sagas-example/issues" 18 | }, 19 | "dependencies": { 20 | "babel-polyfill": "^6.3.14", 21 | "classnames": "^2.2.3", 22 | "nedb": "^1.8.0", 23 | "react": "^15.0.1", 24 | "react-dom": "^15.0.1", 25 | "react-redux": "^4.4.1", 26 | "redux": "^3.3.1", 27 | "redux-saga": "^0.10.0", 28 | "reselect": "^2.2.1", 29 | "todomvc-app-css": "^2.0.4" 30 | }, 31 | "devDependencies": { 32 | "babel-core": "^6.3.15", 33 | "babel-eslint": "6.0.0-beta.3", 34 | "babel-loader": "^6.2.0", 35 | "babel-preset-es2015": "^6.3.13", 36 | "babel-preset-react": "^6.3.13", 37 | "babel-preset-react-hmre": "^1.1.1", 38 | "babel-register": "^6.3.13", 39 | "body-parser": "^1.15.0", 40 | "cross-env": "^1.0.7", 41 | "enzyme": "^2.1.0", 42 | "eslint": "^2.4.0", 43 | "eslint-config-rackt": "^1.1.1", 44 | "eslint-plugin-react": "^4.2.1", 45 | "expect": "^1.14.0", 46 | "express": "^4.13.3", 47 | "mocha": "^2.2.5", 48 | "node-libs-browser": "^0.5.2", 49 | "raw-loader": "^0.5.1", 50 | "react-addons-test-utils": "^0.14.7", 51 | "style-loader": "^0.13.0", 52 | "webpack": "^1.9.11", 53 | "webpack-dev-middleware": "^1.5.1", 54 | "webpack-hot-middleware": "^2.9.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/actions/todos.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import * as actions from '../../actions' 3 | import * as ActionTypes from '../../constants/ActionTypes' 4 | 5 | describe('todo actions', () => { 6 | it('addTodo should create ADD_TODO_REQUESTED action', () => { 7 | expect(actions.addTodo('Use Redux')).toEqual({ 8 | type: ActionTypes.ADD_TODO_REQUESTED, 9 | text: 'Use Redux' 10 | }) 11 | }) 12 | 13 | it('setVisibilityFilter should create SET_VISIBILITY_FILTER action', () => { 14 | expect(actions.setVisibilityFilter('active')).toEqual({ 15 | type: ActionTypes.SET_VISIBILITY_FILTER, 16 | filter: 'active' 17 | }) 18 | }) 19 | 20 | it('deleteTodo should create DELETE_TODO_REQUESTED action', () => { 21 | expect(actions.deleteTodo(1, 'Use Redux')).toEqual({ 22 | type: ActionTypes.DELETE_TODO_REQUESTED, 23 | id: 1, 24 | text: 'Use Redux' 25 | }) 26 | }) 27 | 28 | it('editTodo should create EDIT_TODO_REQUESTED action', () => { 29 | expect(actions.editTodo(1, 'Use Redux')).toEqual({ 30 | type: ActionTypes.EDIT_TODO_REQUESTED, 31 | id: 1, 32 | text: 'Use Redux' 33 | }) 34 | }) 35 | 36 | it('completeTodo should create COMPLETE_TODO_REQUESTED action', () => { 37 | expect(actions.completeTodo(1, false)).toEqual({ 38 | type: ActionTypes.COMPLETE_TODO_REQUESTED, 39 | id: 1, 40 | completed: false 41 | }) 42 | }) 43 | 44 | it('clearCompleted should create CLEAR_COMPLETED_REQUESTED action', () => { 45 | expect(actions.clearCompleted()).toEqual({ 46 | type: ActionTypes.CLEAR_COMPLETED_REQUESTED 47 | }) 48 | }) 49 | 50 | it('completeAll should create COMPLETE_ALL_REQUESTED action', () => { 51 | expect(actions.completeAll()).toEqual({ 52 | type: ActionTypes.COMPLETE_ALL_REQUESTED 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /components/Todo.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import classnames from 'classnames' 3 | import TodoTextInput from './TodoTextInput' 4 | 5 | class TodoItem extends Component { 6 | constructor(props, context) { 7 | super(props, context) 8 | this.state = { 9 | editing: false 10 | } 11 | } 12 | 13 | handleDoubleClick() { 14 | this.setState({ editing: true }) 15 | } 16 | 17 | handleSave(id, text) { 18 | if (text.length === 0) { 19 | this.props.deleteTodo(id) 20 | } else { 21 | this.props.editTodo(text) 22 | } 23 | this.setState({ editing: false }) 24 | } 25 | 26 | render() { 27 | const { todo, completeTodo, deleteTodo } = this.props 28 | 29 | let element 30 | if (this.state.editing) { 31 | element = ( 32 | this.handleSave(todo.id, text)} /> 35 | ) 36 | } else { 37 | element = ( 38 |
    39 | completeTodo(todo.id)} /> 43 | 46 |
    49 | ) 50 | } 51 | 52 | return ( 53 |
  • 57 | {element} 58 |
  • 59 | ) 60 | } 61 | } 62 | 63 | TodoItem.propTypes = { 64 | todo: PropTypes.object.isRequired, 65 | editTodo: PropTypes.func.isRequired, 66 | deleteTodo: PropTypes.func.isRequired, 67 | completeTodo: PropTypes.func.isRequired 68 | } 69 | 70 | export default TodoItem 71 | -------------------------------------------------------------------------------- /reducers/todos.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../constants/ActionTypes' 2 | 3 | const todo = (state, action) => { 4 | switch (action.type) { 5 | case ActionTypes.FETCH_TODOS_SUCCEEDED: 6 | return { 7 | id: state.id, 8 | text: state.text, 9 | completed: false 10 | } 11 | case ActionTypes.ADD_TODO_SUCCEEDED: 12 | return { 13 | id: action.id, 14 | text: action.text, 15 | completed: false 16 | } 17 | case ActionTypes.EDIT_TODO_REQUESTED: 18 | return Object.assign({}, 19 | state, 20 | { text: action.text } 21 | ) 22 | case ActionTypes.COMPLETE_TODO_REQUESTED: 23 | return Object.assign({}, 24 | state, 25 | { completed: !state.completed } 26 | ) 27 | case ActionTypes.COMPLETE_ALL_REQUESTED: 28 | return Object.assign({}, 29 | state, 30 | { completed: action.completed } 31 | ) 32 | default: 33 | return state 34 | } 35 | } 36 | 37 | const initialState = [ ] 38 | 39 | const todos = (state = initialState, action) => { 40 | switch (action.type) { 41 | case ActionTypes.FETCH_TODOS_SUCCEEDED: 42 | return action.todos.map(t => 43 | todo(t, action) 44 | ) 45 | case ActionTypes.ADD_TODO_SUCCEEDED: 46 | return [ 47 | ...state, 48 | todo(state, action) 49 | ] 50 | case ActionTypes.DELETE_TODO_REQUESTED: 51 | return state.filter(todo => 52 | todo.id !== action.id 53 | ) 54 | case ActionTypes.EDIT_TODO_REQUESTED: 55 | return state.map(t => 56 | t.id === action.id ? 57 | todo(t, action) : 58 | t 59 | ) 60 | case ActionTypes.COMPLETE_TODO_REQUESTED: 61 | return state.map(t => 62 | t.id === action.id ? 63 | todo(t, action) : 64 | t 65 | ) 66 | case ActionTypes.CLEAR_COMPLETED_REQUESTED: 67 | return state.filter(todo => !todo.completed) 68 | case ActionTypes.COMPLETE_ALL_REQUESTED: 69 | const areAllMarked = state.every(todo => todo.completed) 70 | return state.map(t => 71 | todo( 72 | t, 73 | { type: action.type, completed: !areAllMarked } 74 | ) 75 | ) 76 | default: 77 | return state 78 | } 79 | } 80 | 81 | export default todos 82 | -------------------------------------------------------------------------------- /test/components/Todo.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import { shallow } from 'enzyme' 4 | import Todo from '../../components/Todo' 5 | 6 | function setup() { 7 | const actions = { 8 | editTodo: expect.createSpy(), 9 | deleteTodo: expect.createSpy(), 10 | completeTodo: expect.createSpy() 11 | } 12 | 13 | const todo = { 14 | id: 0, 15 | text: 'Use Redux', 16 | complete: false 17 | } 18 | 19 | const component = shallow( 20 | 21 | ) 22 | 23 | return { 24 | component: component, 25 | actions: actions, 26 | listItem: component.find('li') 27 | } 28 | } 29 | 30 | describe('', () => { 31 | it('should render a list item', () => { 32 | const { listItem } = setup() 33 | expect(listItem.length).toEqual(1) 34 | }) 35 | 36 | it('should render a view class when not editing', () => { 37 | const { component } = setup() 38 | component.setState({ editing: false }) 39 | const view = component.find('.view') 40 | const todoTextInput = component.find('TodoTextInput') 41 | expect(view.length).toEqual(1) 42 | expect(todoTextInput.length).toEqual(0) 43 | }) 44 | 45 | it('should render a TodoTextInput when editing', () => { 46 | const { component } = setup() 47 | component.setState({ editing: true }) 48 | const todoTextInput = component.find('TodoTextInput') 49 | const view = component.find('.view') 50 | expect(todoTextInput.length).toEqual(1) 51 | expect(view.length).toEqual(0) 52 | }) 53 | 54 | it('should render a complete todo checkbox', () => { 55 | const { component } = setup() 56 | component.setState({ editing: false }) 57 | const completeCheckbox = component.find('.toggle') 58 | expect(completeCheckbox.length).toEqual(1) 59 | }) 60 | 61 | it('should call completeTodo when todo checkbox toggled', () => { 62 | const { actions, component } = setup() 63 | component.setState({ editing: false }) 64 | const completeCheckbox = component.find('.toggle') 65 | completeCheckbox.simulate('change') 66 | expect(actions.completeTodo).toHaveBeenCalled() 67 | }) 68 | 69 | it('should call deleteTodo on click on delete button', () => { 70 | const { actions, component } = setup() 71 | component.setState({ editing: false }) 72 | const deleteButton = component.find('.destroy') 73 | deleteButton.simulate('click') 74 | expect(actions.deleteTodo).toHaveBeenCalled() 75 | }) 76 | 77 | }) 78 | -------------------------------------------------------------------------------- /test/components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import TodoTextInput from '../../components/TodoTextInput' 5 | 6 | function setup(editing, newTodo) { 7 | const actions = { 8 | onSave: expect.createSpy() 9 | } 10 | 11 | const placeholder = 'What needs to be done?' 12 | const text = 'Use Redux' 13 | 14 | const component = mount( 15 | 20 | ) 21 | 22 | return { 23 | component: component, 24 | actions: actions, 25 | input: component.find('input') 26 | } 27 | } 28 | 29 | describe('', () => { 30 | it('should render an input', () => { 31 | const { input } = setup(false, true) 32 | expect(input.length).toEqual(1) 33 | }) 34 | 35 | it('should have an edit class on input if editing', () => { 36 | const { component } = setup(true, false) 37 | expect(component.find('.edit').length).toEqual(1) 38 | }) 39 | 40 | it('should have a new todo class on input if it is new', () => { 41 | const { component } = setup(false, true) 42 | expect(component.find('.new-todo').length).toEqual(1) 43 | }) 44 | 45 | it('should render the text passed in to it', () => { 46 | const { component } = setup(false, true) 47 | component.setState({ text: 'Use Redux' }) 48 | const input = component.find('input') 49 | expect(input.props().value).toEqual('Use Redux') 50 | }) 51 | 52 | it('should call onSave on blur', () => { 53 | const { actions, input } = setup(false, false) 54 | input.props().onBlur({ target: { value: 'Use Redux' } }) 55 | expect(actions.onSave).toHaveBeenCalled() 56 | }) 57 | 58 | it('should update the text input value on change', () => { 59 | const { input } = setup(false, false) 60 | input.props().onChange({ target: { value: 'Use Redux!' } }) 61 | expect(input.props().value).toEqual('Use Redux!') 62 | }) 63 | 64 | it('should call onSave when return key is pressed if is newTodo', () => { 65 | const { actions, input } = setup() 66 | input.props().onKeyDown({ which: 13, target: { value: 'Use Redux!' } }) 67 | expect(actions.onSave).toHaveBeenCalled() 68 | }) 69 | 70 | it('should clear value when return key is pressed if is newTodo', () => { 71 | const { input } = setup(false, true) 72 | input.props().onKeyDown({ which: 13, target: { value: 'Use Redux!' } }) 73 | expect(input.props().value).toEqual('') 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var webpack = require('webpack') 3 | var webpackDevMiddleware = require('webpack-dev-middleware') 4 | var webpackHotMiddleware = require('webpack-hot-middleware') 5 | var config = require('./webpack.config') 6 | var Datastore = require('nedb') 7 | var db = new Datastore() 8 | var bodyParser = require('body-parser') 9 | 10 | var app = new express() 11 | var port = process.env.PORT || 3000 12 | 13 | var compiler = webpack(config) 14 | app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath })) 15 | app.use(webpackHotMiddleware(compiler)) 16 | 17 | app.use( bodyParser.json() ); // to support JSON-encoded bodies 18 | app.use(bodyParser.urlencoded({ // to support URL-encoded bodies 19 | extended: true 20 | })); 21 | 22 | // insert first doc to data store 23 | db.insert([{ text: 'Get to the choppah!' }]) 24 | 25 | app.get("/", function(req, res) { 26 | res.sendFile(__dirname + '/index.html') 27 | }) 28 | 29 | app.post("/fetch-todos", function(req, res) { 30 | db.find({}, function(err, docs) { 31 | res.send(docs.map(function(doc) { return { id: doc._id, text: doc.text }})) 32 | }) 33 | }) 34 | 35 | app.post("/add-todo", function(req, res) { 36 | db.insert([{ text: req.body.text }], function (err, newDocs) { 37 | res.send(newDocs[0]) 38 | }); 39 | }) 40 | 41 | app.post("/edit-todo", function(req, res) { 42 | db.update({ _id: req.body.id }, {text: req.body.text}, { returnUpdatedDocs: true }, function (err, numAffected, doc, upsert) { 43 | 44 | res.send({ text: doc.text, id: doc._id }) 45 | }); 46 | }) 47 | 48 | app.post("/complete-todo", function(req, res) { 49 | db.findOne({ _id: req.body.id }, function (err, doc) { 50 | db.update({ _id: req.body.id }, {completed: !doc.completed}, { returnUpdatedDocs: true }, function (err, numAffected, doc, upsert) { 51 | res.send({ text: doc.text, id: doc._id, completed: doc.completed }) 52 | }) 53 | }) 54 | }) 55 | 56 | app.post("/complete-all", function(req, res) { 57 | db.find({ }, function (err, docs) { 58 | const areAllMarked = docs.every(todo => todo.completed) 59 | 60 | db.update({ }, {completed: !areAllMarked}, { returnUpdatedDocs: true }, function (err, numAffected, doc, upsert) { 61 | res.send({ }) 62 | }) 63 | }) 64 | }) 65 | 66 | app.post("/clear-completed", function(req, res) { 67 | db.remove({ completed: true }, { multi: true }, function (err, numRemoved) { 68 | res.send(numRemoved); 69 | }); 70 | }) 71 | 72 | app.post("/delete-todo", function(req, res) { 73 | db.remove({ _id: req.body.id }, function (err, numRemoved) { 74 | res.send({ }) 75 | }); 76 | }) 77 | 78 | app.listen(port, function(error) { 79 | if (error) { 80 | console.error(error) 81 | } else { 82 | console.info("==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port) 83 | } 84 | }) 85 | -------------------------------------------------------------------------------- /test/components/Footer.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import { shallow } from 'enzyme' 4 | import Footer from '../../components/Footer' 5 | import * as TodoFilters from '../../constants/TodoFilters' 6 | 7 | function setup(activeCount, completedCount, visibilityFilter) { 8 | const actions = { 9 | onClearCompleted: expect.createSpy(), 10 | onShow: expect.createSpy() 11 | } 12 | 13 | const component = shallow( 14 |