├── 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 |
11 |
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 |
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 |
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 |
18 | )
19 |
20 | return {
21 | component: component,
22 | actions: actions,
23 | todoCount: component.find('.todo-count'),
24 | filters: component.find('.filters'),
25 | clearCompleted: component.find('.clear-completed')
26 | }
27 | }
28 |
29 | describe('', () => {
30 | it('should render a todo count for items not done', () => {
31 | const { todoCount } = setup(2, 0, TodoFilters.SHOW_ALL)
32 | expect(todoCount.text()).toMatch(/^2 items left/)
33 | })
34 |
35 | it('should render a todo count for items done', () => {
36 | const { todoCount } = setup(0, 2, TodoFilters.SHOW_ALL)
37 | expect(todoCount.text()).toMatch(/^No items left/)
38 | })
39 |
40 | it('should render three Link components', () => {
41 | const { filters } = setup(2, 0, TodoFilters.SHOW_ALL)
42 | expect(filters.find('Link').length).toEqual(3)
43 | })
44 |
45 | it('should render an all Link', () => {
46 | const { filters } = setup(2, 0, TodoFilters.SHOW_ALL)
47 | const Link = filters.find('Link').at(0).render()
48 | expect(Link.text()).toMatch(/^All/)
49 | })
50 |
51 | it('should render an active Link', () => {
52 | const { filters } = setup(2, 0, TodoFilters.SHOW_ALL)
53 | const Link = filters.find('Link').at(1).render()
54 | expect(Link.text()).toMatch(/^Active/)
55 | })
56 |
57 | it('should render a completed Link', () => {
58 | const { filters } = setup(2, 0, TodoFilters.SHOW_ALL)
59 | const Link = filters.find('Link').at(2).render()
60 | expect(Link.text()).toMatch(/^Completed/)
61 | })
62 |
63 | it('should call onShow on click of each Link', () => {
64 | const { filters, actions } = setup(2, 0, TodoFilters.SHOW_ALL)
65 | filters.find('Link').at(0).simulate('click')
66 | expect(actions.onShow).toHaveBeenCalled()
67 | })
68 |
69 | it('should not show a clear completed button if todos are completed', () => {
70 | const { clearCompleted } = setup(2, 0, TodoFilters.SHOW_ALL)
71 | expect(clearCompleted.length).toEqual(0)
72 | })
73 |
74 | it('should show a clear completed button if todos are completed', () => {
75 | const { clearCompleted } = setup(2, 2, TodoFilters.SHOW_ALL)
76 | expect(clearCompleted.length).toEqual(1)
77 | })
78 |
79 | it('should render clear completed button with Clear Completed text', () => {
80 | const { clearCompleted } = setup(2, 2, TodoFilters.SHOW_ALL)
81 | expect(clearCompleted.text()).toEqual('Clear completed')
82 | })
83 |
84 | it('should call onClearCompleted with clear completed button clicked', () => {
85 | const { clearCompleted, actions } = setup(2, 2, TodoFilters.SHOW_ALL)
86 | clearCompleted.simulate('click')
87 | expect(actions.onClearCompleted).toHaveBeenCalled()
88 | })
89 |
90 | })
91 |
--------------------------------------------------------------------------------
/test/reducers/todos.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect'
2 | import todos from '../../reducers/todos'
3 | import * as ActionTypes from '../../constants/ActionTypes'
4 |
5 | describe('todos reducer', () => {
6 | it('should handle initial state', () => {
7 | const initialState = { id: 0, text: 'Use Redux', complete: false }
8 | expect(
9 | todos(initialState, {})
10 | ).toEqual(initialState)
11 | })
12 |
13 | it('should handle ADD_TODO', () => {
14 | expect(
15 | todos([], {
16 | type: ActionTypes.ADD_TODO_SUCCEEDED,
17 | text: 'Run the tests',
18 | id: 0
19 | })
20 | ).toEqual([
21 | {
22 | id: 0,
23 | text: 'Run the tests',
24 | completed: false
25 | }
26 | ])
27 |
28 | expect(
29 | todos([
30 | {
31 | text: 'Run the tests',
32 | completed: false,
33 | id: 0
34 | }
35 | ], {
36 | type: ActionTypes.ADD_TODO_SUCCEEDED,
37 | text: 'Use Redux',
38 | id: 1
39 | })
40 | ).toEqual([
41 | {
42 | text: 'Run the tests',
43 | completed: false,
44 | id: 0
45 | }, {
46 | text: 'Use Redux',
47 | completed: false,
48 | id: 1
49 | }
50 | ])
51 |
52 | expect(
53 | todos([
54 | {
55 | text: 'Run the tests',
56 | completed: false,
57 | id: 0
58 | }, {
59 | text: 'Use Redux',
60 | completed: false,
61 | id: 1
62 | }
63 | ], {
64 | type: ActionTypes.ADD_TODO_SUCCEEDED,
65 | text: 'Fix the tests',
66 | id: 2
67 | })
68 | ).toEqual([
69 | {
70 | text: 'Run the tests',
71 | completed: false,
72 | id: 0
73 | }, {
74 | text: 'Use Redux',
75 | completed: false,
76 | id: 1
77 | }, {
78 | text: 'Fix the tests',
79 | completed: false,
80 | id: 2
81 | }
82 | ])
83 | })
84 |
85 | it('should handle DELETE_TODO', () => {
86 | expect(
87 | todos([
88 | {
89 | text: 'Run the tests',
90 | completed: false,
91 | id: 1
92 | }, {
93 | text: 'Use Redux',
94 | completed: false,
95 | id: 0
96 | }
97 | ], {
98 | type: ActionTypes.DELETE_TODO_REQUESTED,
99 | id: 1
100 | })
101 | ).toEqual([
102 | {
103 | text: 'Use Redux',
104 | completed: false,
105 | id: 0
106 | }
107 | ])
108 | })
109 |
110 | it('should handle EDIT_TODO', () => {
111 | expect(
112 | todos([
113 | {
114 | text: 'Run the tests',
115 | completed: false,
116 | id: 1
117 | }, {
118 | text: 'Use Redux',
119 | completed: false,
120 | id: 0
121 | }
122 | ], {
123 | type: ActionTypes.EDIT_TODO_REQUESTED,
124 | id: 0,
125 | text: 'Use Redux!'
126 | })
127 | ).toEqual([
128 | {
129 | text: 'Run the tests',
130 | completed: false,
131 | id: 1
132 | },
133 | {
134 | text: 'Use Redux!',
135 | completed: false,
136 | id: 0
137 | }
138 | ])
139 | })
140 |
141 | it('should handle COMPLETE_TODO', () => {
142 | expect(
143 | todos([
144 | {
145 | text: 'Run the tests',
146 | completed: false,
147 | id: 1
148 | }, {
149 | text: 'Use Redux',
150 | completed: false,
151 | id: 0
152 | }
153 | ], {
154 | type: ActionTypes.COMPLETE_TODO_REQUESTED,
155 | id: 1
156 | })
157 | ).toEqual([
158 | {
159 | text: 'Run the tests',
160 | completed: true,
161 | id: 1
162 | },
163 | {
164 | text: 'Use Redux',
165 | completed: false,
166 | id: 0
167 | }
168 | ])
169 | })
170 |
171 | it('should handle CLEAR_COMPLETED', () => {
172 | expect(
173 | todos([
174 | {
175 | text: 'Run the tests',
176 | completed: true,
177 | id: 1
178 | }, {
179 | text: 'Use Redux',
180 | completed: true,
181 | id: 0
182 | }
183 | ], {
184 | type: ActionTypes.CLEAR_COMPLETED_REQUESTED
185 | })
186 | ).toEqual([])
187 | })
188 |
189 | it('should handle COMPLETE_ALL', () => {
190 | expect(
191 | todos([
192 | {
193 | text: 'Run the tests',
194 | completed: true,
195 | id: 1
196 | }, {
197 | text: 'Use Redux',
198 | completed: true,
199 | id: 0
200 | }
201 | ], {
202 | type: ActionTypes.COMPLETE_ALL_REQUESTED
203 | })
204 | ).toEqual([
205 | {
206 | text: 'Run the tests',
207 | completed: false,
208 | id: 1
209 | }, {
210 | text: 'Use Redux',
211 | completed: false,
212 | id: 0
213 | }
214 | ])
215 |
216 | expect(
217 | todos([
218 | {
219 | text: 'Run the tests',
220 | completed: false,
221 | id: 1
222 | }, {
223 | text: 'Use Redux',
224 | completed: true,
225 | id: 0
226 | }
227 | ], {
228 | type: ActionTypes.COMPLETE_ALL_REQUESTED
229 | })
230 | ).toEqual([
231 | {
232 | text: 'Run the tests',
233 | completed: true,
234 | id: 1
235 | }, {
236 | text: 'Use Redux',
237 | completed: true,
238 | id: 0
239 | }
240 | ])
241 |
242 | expect(
243 | todos([
244 | {
245 | text: 'Run the tests',
246 | completed: false,
247 | id: 1
248 | }, {
249 | text: 'Use Redux',
250 | completed: false,
251 | id: 0
252 | }
253 | ], {
254 | type: ActionTypes.COMPLETE_ALL_REQUESTED
255 | })
256 | ).toEqual([
257 | {
258 | text: 'Run the tests',
259 | completed: true,
260 | id: 1
261 | }, {
262 | text: 'Use Redux',
263 | completed: true,
264 | id: 0
265 | }
266 | ])
267 | })
268 |
269 | })
270 |
--------------------------------------------------------------------------------