├── .babelrc
├── .eslintrc
├── .gitignore
├── LICENSE
├── README.md
├── css
└── index.scss
├── db.json
├── index.html
├── js
├── actions
│ ├── TodoActions.js
│ └── index.js
├── components
│ ├── Footer.jsx
│ ├── Header.jsx
│ ├── TextInput.jsx
│ ├── Todo.jsx
│ ├── TodoList.jsx
│ └── index.js
├── constants
│ ├── Actions.js
│ ├── Items.js
│ └── index.js
├── containers
│ ├── App.jsx
│ ├── DevTools.jsx
│ ├── Root.dev.jsx
│ ├── Root.prod.jsx
│ └── index.js
├── index.jsx
├── reducers
│ ├── index.js
│ └── todos.js
├── routes.jsx
└── store
│ ├── configureStore.dev.js
│ ├── configureStore.prod.js
│ └── index.js
├── package.json
├── server.js
├── test
├── .eslintrc
├── actions
│ └── TodoActions.spec.js
├── components
│ ├── Footer.spec.js
│ ├── Header.spec.js
│ └── TextInput.spec.js
├── helpers
│ ├── componentSetup.js
│ ├── jsdom.js
│ └── mockStore.js
└── reducers
│ └── todos.spec.js
├── webpack.config.dev.babel.js
└── webpack.config.prod.babel.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-1", "react"],
3 | "plugins": ["transform-decorators-legacy", "transform-runtime"],
4 | "env": {
5 | "development": {
6 | "plugins": [
7 | ["react-transform", {
8 | "transforms": [{
9 | "transform": "react-transform-hmr",
10 | "imports": ["react"],
11 | "locals": ["module"]
12 | }, {
13 | "transform": "react-transform-catch-errors",
14 | "imports": ["react", "redbox-react"]
15 | }]
16 | }]
17 | ]
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | "plugins": ["babel"],
5 | "rules": {
6 | "new-cap": 0,
7 | "babel/new-cap": 2,
8 | "react/prefer-stateless-function": 0
9 | },
10 | "globals": {
11 | "__DEVELOPMENT__": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | assets/
3 | coverage/
4 | db.json
5 | node_modules/
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Griffin Yourick
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React/Redux Demo
2 | Using:
3 | - [immutable.js](https://github.com/facebook/immutable-js/)
4 | - [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch)
5 | - [json-server](https://github.com/typicode/json-server)
6 | - [react-router](https://github.com/rackt/react-router)
7 | - [react](https://github.com/facebook/react)
8 | - [redux](https://github.com/rackt/redux)
9 | - [webpack](http://webpack.github.io/)
10 | - and more!
11 |
12 | Credit to:
13 | - [TodoMVC](https://github.com/tastejs/todomvc)
14 |
15 | ## Development
16 | `npm start` and navigate to [localhost:8080](http://localhost:8080)
17 |
--------------------------------------------------------------------------------
/css/index.scss:
--------------------------------------------------------------------------------
1 | // Main styles
2 | @import "~todomvc-app-css/index";
3 |
4 | // Variables
5 | $white: #fff;
6 | $green: #5dc2af;
7 |
8 | // Use pointer cursor for buttons
9 | .destroy,
10 | .toggle,
11 | .toggle-all {
12 | cursor: pointer;
13 | }
14 |
15 | // Click and Drag Styles
16 | .footer {
17 | position: relative;
18 | z-index: 2;
19 |
20 | &.over {
21 | margin-top: -1px;
22 | }
23 | }
24 |
25 | .over {
26 | border-top: 2px solid $green;
27 | }
28 |
29 | .todo-list {
30 | position: relative;
31 | z-index: 0;
32 |
33 | > li {
34 | background: $white;
35 | position: relative;
36 | z-index: 1;
37 |
38 | &.dragging {
39 | opacity: .25;
40 | }
41 |
42 | &.over {
43 | margin-top: -2px;
44 | }
45 |
46 | label {
47 | cursor: move;
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos": [
3 | {
4 | "id": 1,
5 | "isComplete": true,
6 | "label": "Be awesome"
7 | },
8 | {
9 | "id": 2,
10 | "isComplete": false,
11 | "label": "Rule the web"
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | React + Redux + ReactRouter
7 |
8 |
9 |
10 |
11 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/js/actions/TodoActions.js:
--------------------------------------------------------------------------------
1 | import 'isomorphic-fetch';
2 | import checkStatus from 'fetch-check-http-status';
3 |
4 | import { Actions } from '../constants';
5 |
6 | const SERVER_URL = '/api';
7 |
8 | /**
9 | * Parse the response's JSON.
10 | */
11 | function parse(response) {
12 | return response.json();
13 | }
14 |
15 | export default {
16 | addTodo(label) {
17 | return dispatch =>
18 | fetch(`${SERVER_URL}/todos`, {
19 | method: 'POST',
20 | headers: {
21 | Accept: 'application/json',
22 | 'Content-Type': 'application/json',
23 | },
24 | body: JSON.stringify({
25 | isComplete: false,
26 | label,
27 | }),
28 | }).then(checkStatus)
29 | .then(parse)
30 | .then(todo => dispatch({
31 | type: Actions.ADD_TODO,
32 | payload: { todo },
33 | }))
34 | .catch(err => dispatch({
35 | type: Actions.ADD_TODO,
36 | payload: err,
37 | error: true,
38 | }));
39 | },
40 |
41 | // FIXME: this is just a stub--does nothing on the server.
42 | clearCompleteTodos() {
43 | return {
44 | type: Actions.CLEAR_COMPLETE_TODOS,
45 | };
46 | },
47 |
48 | deleteTodo(id) {
49 | return dispatch =>
50 | fetch(`${SERVER_URL}/todos/${id}`, {
51 | method: 'DELETE',
52 | }).then(checkStatus)
53 | .then(() => dispatch({
54 | type: Actions.DELETE_TODO,
55 | payload: { id },
56 | }))
57 | .catch(err => dispatch({
58 | type: Actions.DELETE_TODO,
59 | payload: err,
60 | error: true,
61 | }));
62 | },
63 |
64 | editTodo(id, label) {
65 | return dispatch =>
66 | fetch(`${SERVER_URL}/todos/${id}`, {
67 | method: 'PATCH',
68 | headers: {
69 | Accept: 'application/json',
70 | 'Content-Type': 'application/json',
71 | },
72 | body: JSON.stringify({ label }),
73 | }).then(checkStatus)
74 | .then(parse)
75 | .then(todo => dispatch({
76 | type: Actions.EDIT_TODO,
77 | payload: {
78 | id: todo.id,
79 | label: todo.label,
80 | },
81 | }))
82 | .catch(err => dispatch({
83 | type: Actions.EDIT_TODO,
84 | payload: err,
85 | error: true,
86 | }));
87 | },
88 |
89 | fetchAllTodos() {
90 | return dispatch =>
91 | fetch(`${SERVER_URL}/todos`, {
92 | method: 'GET',
93 | }).then(checkStatus)
94 | .then(parse)
95 | .then(todos => dispatch({
96 | type: Actions.FETCH_ALL_TODOS,
97 | payload: { todos },
98 | }))
99 | .catch(err => dispatch({
100 | type: Actions.FETCH_ALL_TODOS,
101 | payload: err,
102 | error: true,
103 | }));
104 | },
105 |
106 | markTodo(id, isComplete) {
107 | return dispatch =>
108 | fetch(`${SERVER_URL}/todos/${id}`, {
109 | method: 'PATCH',
110 | headers: {
111 | Accept: 'application/json',
112 | 'Content-Type': 'application/json',
113 | },
114 | body: JSON.stringify({ isComplete }),
115 | }).then(checkStatus)
116 | .then(parse)
117 | .then(todo => dispatch({
118 | type: Actions.MARK_TODO,
119 | payload: {
120 | id: todo.id,
121 | isComplete: todo.isComplete,
122 | },
123 | }))
124 | .catch(err => dispatch({
125 | type: Actions.MARK_TODO,
126 | payload: err,
127 | error: true,
128 | }));
129 | },
130 |
131 | // FIXME: this is just a stub--does nothing on the server.
132 | markAllTodos(isComplete) {
133 | return {
134 | type: Actions.MARK_ALL_TODOS,
135 | payload: { isComplete },
136 | };
137 | },
138 |
139 | // FIXME: this is just a stub--does nothing on the server.
140 | moveTodo(at, to) {
141 | return {
142 | type: Actions.MOVE_TODO,
143 | payload: { at, to },
144 | };
145 | },
146 | };
147 |
--------------------------------------------------------------------------------
/js/actions/index.js:
--------------------------------------------------------------------------------
1 | export TodoActions from './TodoActions';
2 |
--------------------------------------------------------------------------------
/js/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React, { Component, PropTypes } from 'react';
3 | import { DropTarget } from 'react-dnd';
4 | import { Link } from 'react-router';
5 |
6 | import { Items } from '../constants';
7 |
8 | const target = {
9 | canDrop(props, monitor) {
10 | return monitor.getItem().index < props.maxIndex;
11 | },
12 |
13 | drop(props, monitor) {
14 | const { moveTodo, maxIndex } = props;
15 | moveTodo(monitor.getItem().index, maxIndex + 1);
16 | },
17 | };
18 |
19 | function collect(connect, monitor) {
20 | return {
21 | canDrop: monitor.canDrop(),
22 | connectDropTarget: connect.dropTarget(),
23 | isOver: monitor.isOver(),
24 | };
25 | }
26 |
27 | /**
28 | * Manages routing using ReactRouter.Link, as well as renders a
29 | * 'Clear complete' button and complete tasks counter.
30 | *
31 | * @note: we pass `filter` to this component to trigger a re-render when the
32 | * filter changes. This allows `Link`'s `activeClassName` to work correctly.
33 | */
34 | @DropTarget(Items.TODO, target, collect)
35 | export default class Footer extends Component {
36 | static propTypes = {
37 | canDrop: PropTypes.bool.isRequired,
38 | clearCompleteTodos: PropTypes.func.isRequired,
39 | completeCount: PropTypes.number.isRequired,
40 | connectDropTarget: PropTypes.func.isRequired,
41 | filter: PropTypes.oneOf(['all', 'active', 'completed']).isRequired,
42 | incompleteCount: PropTypes.number.isRequired,
43 | isOver: PropTypes.bool.isRequired,
44 | maxIndex: PropTypes.number.isRequired,
45 | moveTodo: PropTypes.func.isRequired,
46 | };
47 |
48 | onRemoveCompleted = () => {
49 | this.props.clearCompleteTodos();
50 | };
51 |
52 | renderClearButton() {
53 | if (!this.props.completeCount) return null;
54 |
55 | return (
56 |
62 | );
63 | }
64 |
65 | renderTodoCount() {
66 | const { incompleteCount } = this.props;
67 | const incompleteWord = incompleteCount || 'No';
68 | const itemWord = (incompleteCount === 1) ? 'task' : 'tasks';
69 |
70 | return (
71 |
72 | {incompleteWord} {itemWord} remaining
73 |
74 | );
75 | }
76 |
77 | render() {
78 | const { canDrop, isOver, connectDropTarget } = this.props;
79 | const classes = classnames('footer', {
80 | over: isOver && canDrop,
81 | });
82 |
83 | return connectDropTarget(
84 |
93 | );
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/js/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | import { TextInput } from '.';
4 |
5 | /**
6 | * Wrapper component rendering header text as well as the new Todo input
7 | * component.
8 | */
9 | export default class Header extends Component {
10 | static propTypes = {
11 | addTodo: PropTypes.func.isRequired,
12 | fetchAllTodos: PropTypes.func.isRequired,
13 | };
14 |
15 | onSave = (label) => {
16 | if (!label.length) return;
17 |
18 | this.props.addTodo(label);
19 | };
20 |
21 | render() {
22 | return (
23 |
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/js/components/TextInput.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | const ENTER_KEY_CODE = 13;
4 |
5 | /**
6 | * General purpose text input component.
7 | */
8 | export default class TextInput extends Component {
9 | static propTypes = {
10 | className: PropTypes.string.isRequired,
11 | onSave: PropTypes.func.isRequired,
12 | placeholder: PropTypes.string,
13 | value: PropTypes.string,
14 | };
15 |
16 | state = {
17 | value: this.props.value || '',
18 | };
19 |
20 | onBlur = () => {
21 | this.props.onSave(this.state.value.trim());
22 | this.setState({
23 | value: '',
24 | });
25 | };
26 |
27 | onChange = (evt) => {
28 | this.setState({
29 | value: evt.target.value,
30 | });
31 | };
32 |
33 | onKeyDown = (evt) => {
34 | if (evt.keyCode !== ENTER_KEY_CODE) return;
35 | this.onBlur();
36 | };
37 |
38 | render() {
39 | const { className, placeholder } = this.props;
40 | const { value } = this.state;
41 |
42 | return (
43 |
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/js/components/Todo.jsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React, { Component, PropTypes } from 'react';
3 | import { DragSource, DropTarget } from 'react-dnd';
4 |
5 | import { TextInput } from '.';
6 | import { Items } from '../constants';
7 |
8 | const target = {
9 | canDrop(props, monitor) {
10 | const { index } = props;
11 | const draggedIndex = monitor.getItem().index;
12 |
13 | return draggedIndex !== index && draggedIndex !== index - 1;
14 | },
15 |
16 | drop(props, monitor) {
17 | const { index, moveTodo } = props;
18 | moveTodo(monitor.getItem().index, index);
19 | },
20 | };
21 |
22 | const source = {
23 | beginDrag(props) {
24 | return { index: props.index };
25 | },
26 | };
27 |
28 | function targetCollect(connect, monitor) {
29 | return {
30 | canDrop: monitor.canDrop(),
31 | connectDropTarget: connect.dropTarget(),
32 | isOver: monitor.isOver(),
33 | };
34 | }
35 |
36 | function sourceCollect(connect, monitor) {
37 | return {
38 | connectDragSource: connect.dragSource(),
39 | isDragging: monitor.isDragging(),
40 | };
41 | }
42 |
43 | /**
44 | * Represents a single todo item in a todo list.
45 | */
46 | @DropTarget(Items.TODO, target, targetCollect)
47 | @DragSource(Items.TODO, source, sourceCollect)
48 | export default class Todo extends Component {
49 | static propTypes = {
50 | canDrop: PropTypes.bool.isRequired,
51 | connectDragSource: PropTypes.func.isRequired,
52 | connectDropTarget: PropTypes.func.isRequired,
53 | deleteTodo: PropTypes.func.isRequired,
54 | editTodo: PropTypes.func.isRequired,
55 | id: PropTypes.number.isRequired,
56 | index: PropTypes.number.isRequired,
57 | isComplete: PropTypes.bool.isRequired,
58 | isDragging: PropTypes.bool.isRequired,
59 | isOver: PropTypes.bool.isRequired,
60 | label: PropTypes.string.isRequired,
61 | markTodo: PropTypes.func.isRequired,
62 | moveTodo: PropTypes.func.isRequired,
63 | };
64 |
65 | state = {
66 | isEditing: false,
67 | };
68 |
69 | onDestroy = () => {
70 | const { deleteTodo, id } = this.props;
71 |
72 | deleteTodo(id);
73 | };
74 |
75 | onEdit = () => {
76 | this.setState({
77 | isEditing: true,
78 | });
79 | };
80 |
81 | onSave = (newLabel) => {
82 | const { deleteTodo, editTodo, id, label } = this.props;
83 |
84 | if (newLabel.length) {
85 | if (newLabel !== label) editTodo(id, newLabel);
86 | } else {
87 | deleteTodo(id);
88 | }
89 |
90 | this.setState({
91 | isEditing: false,
92 | });
93 | };
94 |
95 | onToggle = () => {
96 | const { id, isComplete, markTodo } = this.props;
97 |
98 | markTodo(id, !isComplete);
99 | };
100 |
101 | renderInput() {
102 | if (!this.state.isEditing) return null;
103 |
104 | return (
105 |
110 | );
111 | }
112 |
113 | render() {
114 | const {
115 | canDrop, connectDragSource, connectDropTarget, isComplete, isDragging,
116 | isOver, label,
117 | } = this.props;
118 |
119 | const classes = classnames({
120 | completed: isComplete,
121 | dragging: isDragging,
122 | over: isOver && canDrop,
123 | editing: this.state.isEditing,
124 | });
125 |
126 | return connectDragSource(connectDropTarget(
127 |
128 |
129 |
135 |
138 |
139 |
140 | {this.renderInput()}
141 |
142 | ));
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/js/components/TodoList.jsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable';
2 | import React, { Component, PropTypes } from 'react';
3 |
4 | import { Footer, Todo } from '.';
5 |
6 | const FILTERS = {
7 | all: () => true,
8 | active: todo => !todo.isComplete,
9 | completed: todo => todo.isComplete,
10 | };
11 |
12 | /**
13 | * Displays the list of todos, as well as the toggle all checkbox.
14 | */
15 | export default class TodoList extends Component {
16 | static propTypes = {
17 | actions: PropTypes.objectOf(PropTypes.func.isRequired).isRequired,
18 | filter: PropTypes.oneOf(['all', 'active', 'completed']).isRequired,
19 | todos: PropTypes.instanceOf(List).isRequired,
20 | };
21 |
22 | onToggle = (evt) => {
23 | this.props.actions.markAllTodos(evt.target.checked);
24 | };
25 |
26 | renderFooter(completeCount) {
27 | const { actions, filter, todos } = this.props;
28 | const { clearCompleteTodos, moveTodo } = actions;
29 | const { size } = todos;
30 |
31 | if (!size) return null;
32 |
33 | const incompleteCount = size - completeCount;
34 | const maxIndex = todos.reduce((max, { index }) =>
35 | (index > max) ? index : max
36 | , 0);
37 |
38 | return (
39 |
47 | );
48 | }
49 |
50 | renderListItems() {
51 | const { filter, todos } = this.props;
52 |
53 | return todos.toSeq()
54 | .filter(FILTERS[filter])
55 | .sortBy(todo => todo.index)
56 | .map(this.renderTodo)
57 | .toArray();
58 | }
59 |
60 | renderTodo = (todo) => {
61 | const { deleteTodo, editTodo, markTodo, moveTodo } = this.props.actions;
62 | const todoObj = todo.toJS();
63 |
64 | return (
65 |
73 | );
74 | };
75 |
76 | renderToggle(completeCount) {
77 | return (
78 |
84 | );
85 | }
86 |
87 | render() {
88 | const { todos } = this.props;
89 | const completeCount = todos.reduce((count, { isComplete }) =>
90 | (isComplete) ? count + 1 : count
91 | , 0);
92 |
93 | return (
94 |
95 | {this.renderToggle(completeCount)}
96 |
97 | {this.renderListItems()}
98 |
99 | {this.renderFooter(completeCount)}
100 |
101 | );
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/js/components/index.js:
--------------------------------------------------------------------------------
1 | export Footer from './Footer';
2 | export Header from './Header';
3 | export TextInput from './TextInput';
4 | export Todo from './Todo';
5 | export TodoList from './TodoList';
6 |
--------------------------------------------------------------------------------
/js/constants/Actions.js:
--------------------------------------------------------------------------------
1 | import constantMirror from 'constant-mirror';
2 |
3 | export default constantMirror(
4 | 'ADD_TODO',
5 | 'CLEAR_COMPLETE_TODOS',
6 | 'DELETE_TODO',
7 | 'EDIT_TODO',
8 | 'FETCH_ALL_TODOS',
9 | 'MARK_ALL_TODOS',
10 | 'MARK_TODO',
11 | 'MOVE_TODO'
12 | );
13 |
--------------------------------------------------------------------------------
/js/constants/Items.js:
--------------------------------------------------------------------------------
1 | import constantMirror from 'constant-mirror';
2 |
3 | export default constantMirror(
4 | 'TODO'
5 | );
6 |
--------------------------------------------------------------------------------
/js/constants/index.js:
--------------------------------------------------------------------------------
1 | export Actions from './Actions';
2 | export Items from './Items';
3 |
--------------------------------------------------------------------------------
/js/containers/App.jsx:
--------------------------------------------------------------------------------
1 | import { List } from 'immutable';
2 | import React, { Component, PropTypes } from 'react';
3 | import { DragDropContext } from 'react-dnd';
4 | import HTML5Backend from 'react-dnd-html5-backend';
5 | import { connect } from 'react-redux';
6 | import { bindActionCreators } from 'redux';
7 |
8 | import { TodoActions } from '../actions';
9 | import { Header, TodoList } from '../components';
10 |
11 | function mapStateToProps(state) {
12 | return {
13 | todos: state.todos,
14 | };
15 | }
16 |
17 | function mapDispatchToProps(dispatch) {
18 | return {
19 | actions: bindActionCreators(TodoActions, dispatch),
20 | };
21 | }
22 |
23 | /**
24 | * Top-level application component. Connects to the Redux `Provider` stores,
25 | * passing their state through as props, as well as receives props from the
26 | * router.
27 | */
28 | @DragDropContext(HTML5Backend)
29 | @connect(mapStateToProps, mapDispatchToProps)
30 | export default class App extends Component {
31 | static propTypes = {
32 | actions: PropTypes.object.isRequired,
33 | location: PropTypes.object.isRequired,
34 | todos: PropTypes.instanceOf(List).isRequired,
35 | };
36 |
37 | componentWillMount() {
38 | this.props.actions.fetchAllTodos();
39 | }
40 |
41 | render() {
42 | const { actions, location, todos } = this.props;
43 | const { addTodo, fetchAllTodos } = actions;
44 | const filter = location.pathname.replace('/', '');
45 |
46 | return (
47 |
48 |
52 |
57 |
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/js/containers/DevTools.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createDevTools } from 'redux-devtools';
3 | import DockMonitor from 'redux-devtools-dock-monitor';
4 | import LogMonitor from 'redux-devtools-log-monitor';
5 |
6 | export default createDevTools(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/js/containers/Root.dev.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Provider } from 'react-redux';
3 | import { browserHistory, Router } from 'react-router';
4 |
5 | import DevTools from './DevTools';
6 | import routes from '../routes';
7 |
8 | export default class Root extends Component {
9 | static propTypes = {
10 | store: PropTypes.object.isRequired,
11 | };
12 |
13 | render() {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/js/containers/Root.prod.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Provider } from 'react-redux';
3 | import { browserHistory, Router } from 'react-router';
4 |
5 | import routes from '../routes';
6 |
7 | export default class Root extends Component {
8 | static propTypes = {
9 | store: PropTypes.object.isRequired,
10 | };
11 |
12 | render() {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/js/containers/index.js:
--------------------------------------------------------------------------------
1 | export App from './App';
2 | export DevTools from './DevTools';
3 |
4 | let Root;
5 |
6 | if (__DEVELOPMENT__) {
7 | Root = require('./Root.dev').default;
8 | } else {
9 | Root = require('./Root.prod').default;
10 | }
11 |
12 | export { Root };
13 |
--------------------------------------------------------------------------------
/js/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 |
4 | import { Root } from './containers';
5 | import configureStore from './store';
6 |
7 | render(, document.getElementById('app'));
8 |
--------------------------------------------------------------------------------
/js/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import todos from './todos';
4 |
5 | export default combineReducers({
6 | todos,
7 | });
8 |
--------------------------------------------------------------------------------
/js/reducers/todos.js:
--------------------------------------------------------------------------------
1 | import camelCase from 'camel-case';
2 | import { List, Record, Seq } from 'immutable';
3 |
4 | const Todo = new Record({
5 | id: 0,
6 | index: 0,
7 | isComplete: false,
8 | label: 'todo',
9 | });
10 |
11 | const ACTIONS_MAP = {
12 | addTodo(state, { todo }) {
13 | return state.push(new Todo({ index: todo.id, ...todo }));
14 | },
15 |
16 | clearCompleteTodos(state) {
17 | return state.filter(todo => !todo.get('isComplete'));
18 | },
19 |
20 | deleteTodo(state, { id }) {
21 | return state.filter(todo => todo.get('id') !== id);
22 | },
23 |
24 | editTodo(state, { id, label }) {
25 | return state.map(todo =>
26 | (todo.get('id') === id)
27 | ? todo.set('label', label)
28 | : todo
29 | );
30 | },
31 |
32 | fetchAllTodos(state, { todos: allTodos }) {
33 | return new Seq(allTodos)
34 | .map(todo => new Todo({ index: todo.id, ...todo }))
35 | .toList();
36 | },
37 |
38 | markAllTodos(state, { isComplete }) {
39 | return state.map(todo => todo.set('isComplete', isComplete));
40 | },
41 |
42 | markTodo(state, { id, isComplete }) {
43 | return state.map(todo =>
44 | (todo.get('id') === id)
45 | ? todo.set('isComplete', isComplete)
46 | : todo
47 | );
48 | },
49 |
50 | moveTodo(state, { at, to }) {
51 | return state.map(todo => {
52 | let newTodo = todo;
53 |
54 | if (todo.get('index') === at) {
55 | newTodo = todo.set('index', to);
56 | } else if (todo.get('index') >= to) {
57 | newTodo = todo.update('index', index => index + 1);
58 | }
59 |
60 | return newTodo;
61 | });
62 | },
63 | };
64 |
65 | const initialState = new List();
66 |
67 | /**
68 | * If the action type corresponds to a handler in ACTIONS_MAP, return a
69 | * reduction of the state. If no corresponding action is found, simply pass
70 | * the state through.
71 | */
72 | export default function todos(state = initialState, { type, payload }) {
73 | const reducer = ACTIONS_MAP[camelCase(type)];
74 |
75 | return (reducer) ? reducer(state, payload) : state;
76 | }
77 |
--------------------------------------------------------------------------------
/js/routes.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Redirect, Route } from 'react-router';
3 |
4 | import { App } from './containers';
5 |
6 | export default (
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/js/store/configureStore.dev.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, compose, createStore } from 'redux';
2 | import { persistState } from 'redux-devtools';
3 | import thunk from 'redux-thunk';
4 |
5 | import { DevTools } from '../containers';
6 | import rootReducer from '../reducers';
7 |
8 | function getDebugSessionKey() {
9 | const matches = window.location.href.match(/[?&]debug_session=([^&]+)\b/);
10 | return (matches && matches.length > 0) ? matches[1] : null;
11 | }
12 |
13 | export default function configureStore(initialState) {
14 | const store = createStore(
15 | rootReducer,
16 | initialState,
17 | compose(
18 | applyMiddleware(thunk),
19 | DevTools.instrument(),
20 | persistState(getDebugSessionKey()),
21 | ),
22 | );
23 |
24 | // Enable Webpack hot module replacement for reducers
25 | if (module.hot) {
26 | module.hot.accept('../reducers', () => {
27 | const nextRootReducer = require('../reducers').default;
28 | store.replaceReducer(nextRootReducer);
29 | });
30 | }
31 |
32 | return store;
33 | }
34 |
--------------------------------------------------------------------------------
/js/store/configureStore.prod.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore } from 'redux';
2 | import thunk from 'redux-thunk';
3 |
4 | import rootReducer from '../reducers';
5 |
6 | export default function configureStore(initialState) {
7 | return createStore(
8 | rootReducer,
9 | initialState,
10 | applyMiddleware(thunk),
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/js/store/index.js:
--------------------------------------------------------------------------------
1 | let configureStore;
2 |
3 | if (__DEVELOPMENT__) {
4 | configureStore = require('./configureStore.dev').default;
5 | } else {
6 | configureStore = require('./configureStore.prod').default;
7 | }
8 |
9 | export default configureStore;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-react-router-todomvc",
3 | "version": "1.0.0",
4 | "description": "An implementation of TodoMVC using React, Redux, React-Router, and more!",
5 | "repository": "tough-griff/redux-react-router-todomvc",
6 | "author": "Griffin Yourick",
7 | "license": "MIT",
8 | "scripts": {
9 | "build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.babel.js",
10 | "build": "npm run clean && npm run build:webpack",
11 | "check": "npm run lint && npm run test",
12 | "clean": "rimraf assets coverage",
13 | "lint": "eslint --ext=.js,.jsx js/ test/ *.js",
14 | "start": "babel-node server.js",
15 | "test:cov": "NODE_ENV=test babel-node $(npm bin)/isparta cover $(npm bin)/_mocha -- --recursive",
16 | "test:watch": "NODE_ENV=test npm run test -- --watch --growl",
17 | "test": "NODE_ENV=test mocha --recursive --compilers=js:babel-register --require=./test/helpers/jsdom.js"
18 | },
19 | "engines": {
20 | "node": "5.x",
21 | "npm": "3.x"
22 | },
23 | "dependencies": {
24 | "babel-polyfill": "^6.6.1",
25 | "camel-case": "^1.2.2",
26 | "classnames": "^2.2.3",
27 | "constant-mirror": "^1.0.6",
28 | "fetch-check-http-status": "^1.0.0",
29 | "immutable": "^3.7.6",
30 | "isomorphic-fetch": "^2.2.1",
31 | "react": "^0.14.7",
32 | "react-dnd": "^2.1.2",
33 | "react-dnd-html5-backend": "^2.1.2",
34 | "react-dom": "^0.14.7",
35 | "react-redux": "^4.4.0",
36 | "react-router": "2.0.0",
37 | "redux": "^3.3.1",
38 | "redux-thunk": "^2.0.1",
39 | "todomvc-app-css": "^2.0.4"
40 | },
41 | "devDependencies": {
42 | "babel-cli": "^6.6.5",
43 | "babel-eslint": "^5.0.0",
44 | "babel-loader": "^6.2.4",
45 | "babel-plugin-react-transform": "^2.0.2",
46 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
47 | "babel-plugin-transform-runtime": "^6.6.0",
48 | "babel-preset-es2015": "^6.6.0",
49 | "babel-preset-react": "^6.5.0",
50 | "babel-preset-stage-1": "^6.5.0",
51 | "babel-register": "^6.6.5",
52 | "css-loader": "^0.23.1",
53 | "eslint": "2.2.0",
54 | "eslint-config-airbnb": "^6.1.0",
55 | "eslint-loader": "^1.3.0",
56 | "eslint-plugin-babel": "^3.1.0",
57 | "eslint-plugin-react": "^4.2.0",
58 | "expect.js": "^0.3.1",
59 | "express": "^4.13.4",
60 | "fetch-mock": "^4.1.1",
61 | "isparta": "^4.0.0",
62 | "jsdom": "^8.1.0",
63 | "json-server": "^0.8.8",
64 | "mocha": "^2.4.5",
65 | "morgan": "^1.7.0",
66 | "node-sass": "^3.4.2",
67 | "react-addons-test-utils": "^0.14.7",
68 | "react-transform-catch-errors": "^1.0.2",
69 | "react-transform-hmr": "^1.0.4",
70 | "redbox-react": "^1.2.2",
71 | "redux-devtools": "^3.1.1",
72 | "redux-devtools-dock-monitor": "^1.1.0",
73 | "redux-devtools-log-monitor": "^1.0.5",
74 | "redux-mock-store": "^0.0.6",
75 | "rimraf": "^2.5.2",
76 | "sass-loader": "^3.1.2",
77 | "sinon": "^1.17.3",
78 | "style-loader": "^0.13.0",
79 | "webpack": "^1.12.14",
80 | "webpack-dev-middleware": "^1.5.1",
81 | "webpack-hot-middleware": "^2.9.1"
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import jsonServer from 'json-server';
3 | import logger from 'morgan';
4 | import path from 'path';
5 | import webpack from 'webpack';
6 | import webpackDevMiddleware from 'webpack-dev-middleware';
7 | import webpackHotMiddleware from 'webpack-hot-middleware';
8 |
9 | import config from './webpack.config.dev.babel';
10 |
11 | const app = express();
12 | const compiler = webpack(config);
13 | const dbRouter = jsonServer.router('db.json');
14 | const port = process.env.PORT || '8080';
15 | const nodeEnv = process.env.NODE_ENV || 'development';
16 |
17 | // Configure the logger
18 | app.use(logger('dev', {
19 | skip(req) {
20 | return nodeEnv === 'test' || req.path === '/favicon.ico';
21 | },
22 | }));
23 |
24 | // === Configure Webpack middleware ===
25 | if (nodeEnv === 'development') {
26 | app.use(webpackDevMiddleware(compiler, {
27 | noInfo: true,
28 | publicPath: config.output.publicPath,
29 | }));
30 |
31 | app.use(webpackHotMiddleware(compiler));
32 | }
33 |
34 | // Serve static files and index.html
35 | if (nodeEnv === 'production') {
36 | app.use('/assets', express.static('assets'));
37 | }
38 |
39 | // Host the json server under /api
40 | app.use('/api', dbRouter);
41 |
42 | // Serve index.html from all URL's, allowing use of React Router.
43 | app.get('*', (req, res) => {
44 | res.sendFile(path.join(__dirname, 'index.html'));
45 | });
46 |
47 | /* eslint-disable no-console */
48 | app.listen(port, 'localhost', err => {
49 | if (err) {
50 | console.log(err);
51 | return;
52 | }
53 |
54 | console.log(`Server listening at http://localhost:${port} in ${nodeEnv} mode.`);
55 | });
56 | /* eslint-enable no-console */
57 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "mocha": true
4 | },
5 | "rules": {
6 | "func-names": 0,
7 | "prefer-arrow-callback": 0
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/test/actions/TodoActions.spec.js:
--------------------------------------------------------------------------------
1 | import 'isomorphic-fetch';
2 | import expect from 'expect.js';
3 | import fetchMock from 'fetch-mock';
4 |
5 | import mockStore from '../helpers/mockStore';
6 |
7 | import TodoActions from '../../js/actions/TodoActions';
8 |
9 | describe('TodoActions', function () {
10 | afterEach(function () {
11 | fetchMock.reset();
12 | });
13 |
14 | it('exposes an object', function () {
15 | expect(TodoActions).to.be.an('object');
16 | });
17 |
18 | describe('.addTodo()', function () {
19 | const label = 'fake todo';
20 | const subject = TodoActions.addTodo(label);
21 | const action = {
22 | type: 'ADD_TODO',
23 | payload: { todo: { label } },
24 | };
25 |
26 | before(function () {
27 | fetchMock.mock('/api/todos', 'POST', { label });
28 | });
29 |
30 | after(function () {
31 | fetchMock.restore();
32 | });
33 |
34 | it('returns a thunk', function () {
35 | expect(subject).to.be.a('function');
36 | });
37 |
38 | it('makes the correct web request', function () {
39 | subject();
40 | expect(fetchMock.called('/api/todos')).to.be(true);
41 | });
42 |
43 | it('dispatches the correct action', function (done) {
44 | mockStore({}, [action], done).dispatch(subject);
45 | });
46 | });
47 |
48 | describe('.clearCompleteTodos()', function () {
49 | const subject = TodoActions.clearCompleteTodos();
50 | const action = {
51 | type: 'CLEAR_COMPLETE_TODOS',
52 | };
53 |
54 | it('creates the correct action', function () {
55 | expect(subject).to.eql(action);
56 | });
57 | });
58 |
59 | describe('.deleteTodo()', function () {
60 | const id = 5;
61 | const subject = TodoActions.deleteTodo(id);
62 | const action = {
63 | type: 'DELETE_TODO',
64 | payload: { id },
65 | };
66 |
67 | before(function () {
68 | fetchMock.mock(`/api/todos/${id}`, 'DELETE', {});
69 | });
70 |
71 | after(function () {
72 | fetchMock.restore();
73 | });
74 |
75 | it('returns a thunk', function () {
76 | expect(subject).to.be.a('function');
77 | });
78 |
79 | it('makes the correct web request', function () {
80 | subject();
81 | expect(fetchMock.called(`/api/todos/${id}`)).to.be(true);
82 | });
83 |
84 | it('dispatches the correct action', function (done) {
85 | mockStore({}, [action], done).dispatch(subject);
86 | });
87 | });
88 |
89 | describe('.editTodo()', function () {
90 | const id = 5;
91 | const label = 'fake todo';
92 | const subject = TodoActions.editTodo(id, label);
93 | const action = {
94 | type: 'EDIT_TODO',
95 | payload: { id, label },
96 | };
97 |
98 | before(function () {
99 | fetchMock.mock(`/api/todos/${id}`, 'PATCH', { id, label });
100 | });
101 |
102 | after(function () {
103 | fetchMock.restore();
104 | });
105 |
106 | it('returns a thunk', function () {
107 | expect(subject).to.be.a('function');
108 | });
109 |
110 | it('makes the correct web request', function () {
111 | subject();
112 | expect(fetchMock.called(`/api/todos/${id}`)).to.be(true);
113 | });
114 |
115 | it('dispatches the correct action', function (done) {
116 | mockStore({}, [action], done).dispatch(subject);
117 | });
118 | });
119 |
120 | describe('.fetchAllTodos()', function () {
121 | const todos = [{ label: 'fake1' }, { label: 'fake2' }];
122 | const subject = TodoActions.fetchAllTodos();
123 | const action = {
124 | type: 'FETCH_ALL_TODOS',
125 | payload: { todos },
126 | };
127 |
128 | before(function () {
129 | fetchMock.mock('/api/todos', 'GET', todos);
130 | });
131 |
132 | after(function () {
133 | fetchMock.restore();
134 | });
135 |
136 | it('returns a thunk', function () {
137 | expect(subject).to.be.a('function');
138 | });
139 |
140 | it('makes the correct web request', function () {
141 | subject();
142 | expect(fetchMock.called('/api/todos')).to.be(true);
143 | });
144 |
145 | it('dispatches the correct action', function (done) {
146 | mockStore({}, [action], done).dispatch(subject);
147 | });
148 | });
149 |
150 | describe('.markTodo()', function () {
151 | const id = 5;
152 | const isComplete = true;
153 | const subject = TodoActions.markTodo(id, isComplete);
154 | const action = {
155 | type: 'MARK_TODO',
156 | payload: { id, isComplete },
157 | };
158 |
159 | before(function () {
160 | fetchMock.mock(`/api/todos/${id}`, 'PATCH', { id, isComplete });
161 | });
162 |
163 | after(function () {
164 | fetchMock.restore();
165 | });
166 |
167 | it('returns a thunk', function () {
168 | expect(subject).to.be.a('function');
169 | });
170 |
171 | it('makes the correct web request', function () {
172 | subject();
173 | expect(fetchMock.called(`/api/todos/${id}`)).to.be(true);
174 | });
175 |
176 | it('dispatches the correct action', function (done) {
177 | mockStore({}, [action], done).dispatch(subject);
178 | });
179 | });
180 |
181 | describe('.markAllTodos()', function () {
182 | const isComplete = true;
183 | const subject = TodoActions.markAllTodos(isComplete);
184 | const action = {
185 | type: 'MARK_ALL_TODOS',
186 | payload: { isComplete },
187 | };
188 |
189 | it('creates the correct action', function () {
190 | expect(subject).to.eql(action);
191 | });
192 | });
193 |
194 | describe('.moveTodo()', function () {
195 | const at = 5;
196 | const to = 8;
197 | const subject = TodoActions.moveTodo(at, to);
198 | const action = {
199 | type: 'MOVE_TODO',
200 | payload: { at, to },
201 | };
202 |
203 | it('creates the correct action', function () {
204 | expect(subject).to.eql(action);
205 | });
206 | });
207 | });
208 |
--------------------------------------------------------------------------------
/test/components/Footer.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect.js';
2 | import sinon from 'sinon';
3 |
4 | import setup from '../helpers/componentSetup';
5 |
6 | import Footer from '../../js/components/Footer';
7 |
8 | describe('', function () {
9 | const clearCompleteTodos = sinon.spy();
10 | const props = {
11 | canDrop: false,
12 | clearCompleteTodos,
13 | connectDropTarget: (el) => el,
14 | completeCount: 0,
15 | filter: 'all',
16 | incompleteCount: 0,
17 | isOver: false,
18 | maxIndex: 0,
19 | moveTodo: sinon.stub(),
20 | };
21 |
22 | const { output } = setup(Footer.DecoratedComponent, props);
23 | const list = output.props.children[1];
24 |
25 | it('renders correctly', function () {
26 | expect(output.type).to.equal('footer');
27 | expect(output.props.className).to.equal('footer');
28 |
29 | expect(list.type).to.equal('ul');
30 | expect(list.props.className).to.equal('filters');
31 | expect(list.props.children.length).to.equal(3);
32 | });
33 |
34 | context('with a zero complete count', function () {
35 | const button = output.props.children[2];
36 |
37 | it('does not render the clear complete button', function () {
38 | expect(button).to.be(null);
39 | });
40 | });
41 |
42 | context('with a nonzero complete count', function () {
43 | const { output: completeOutput } = setup(Footer.DecoratedComponent, {
44 | ...props, completeCount: 2,
45 | });
46 | const button = completeOutput.props.children[2];
47 |
48 | it('renders the clear complete button', function () {
49 | expect(button.type).to.equal('button');
50 | expect(button.props.className).to.equal('clear-completed');
51 | expect(button.props.onClick).to.be.a('function');
52 | expect(button.props.children).to.equal('Clear complete');
53 | });
54 |
55 | describe('#onRemoveCompleted()', function () {
56 | it('calls clearCompleteTodos', function () {
57 | expect(clearCompleteTodos.called).to.be(false);
58 | button.props.onClick();
59 | expect(clearCompleteTodos.called).to.be(true);
60 | });
61 | });
62 | });
63 |
64 | context('with a zero incomplete count', function () {
65 | const count = output.props.children[0];
66 |
67 | it('renders the text "No"', function () {
68 | const countText = count.props.children[0].props.children;
69 |
70 | expect(countText).to.equal('No');
71 | });
72 | });
73 |
74 | context('with a nonzero incomplete count', function () {
75 | const incompleteCount = 2;
76 | const { output: incompleteOutput } = setup(Footer.DecoratedComponent, {
77 | ...props, incompleteCount,
78 | });
79 | const count = incompleteOutput.props.children[0];
80 |
81 | it('renders the incomplete count', function () {
82 | const countText = count.props.children[0].props.children;
83 |
84 | expect(countText).to.equal(incompleteCount);
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/test/components/Header.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect.js';
2 | import sinon from 'sinon';
3 |
4 | import setup from '../helpers/componentSetup';
5 |
6 | import Header from '../../js/components/Header';
7 | import { TextInput } from '../../js/components';
8 |
9 | describe('', function () {
10 | const addTodo = sinon.spy();
11 | const fetchAllTodos = sinon.stub();
12 | const { output } = setup(Header, { addTodo, fetchAllTodos });
13 | const [h1, textInput] = output.props.children;
14 |
15 | it('renders correctly', function () {
16 | expect(output.type).to.equal('header');
17 | expect(output.props.className).to.equal('header');
18 |
19 | expect(h1.type).to.equal('h1');
20 | expect(h1.props.onDoubleClick).to.equal(fetchAllTodos);
21 | expect(h1.props.children).to.equal('Todos');
22 |
23 | expect(textInput.type).to.equal(TextInput);
24 | expect(textInput.props.className).to.equal('new-todo');
25 | expect(textInput.props.onSave).to.be.a('function');
26 | expect(textInput.props.placeholder).to.equal('What needs to be done?');
27 | });
28 |
29 | describe('#onSave()', function () {
30 | it('calls addTodo correctly', function () {
31 | textInput.props.onSave('');
32 | expect(addTodo.called).to.be(false);
33 | textInput.props.onSave('Example');
34 | expect(addTodo.called).to.be(true);
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/test/components/TextInput.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect.js';
2 | import sinon from 'sinon';
3 |
4 | import setup from '../helpers/componentSetup';
5 |
6 | import TextInput from '../../js/components/TextInput';
7 |
8 | describe('', function () {
9 | const className = 'className';
10 | const onSave = sinon.spy();
11 | const placeholder = 'placeholder';
12 | const value = 'value';
13 | const { output, renderer } = setup(TextInput, { className, onSave, placeholder, value });
14 |
15 | beforeEach(function () {
16 | onSave.reset();
17 | });
18 |
19 | it('renders correctly', function () {
20 | expect(output.type).to.equal('input');
21 | expect(output.props.autoFocus).to.be(true);
22 | expect(output.props.className).to.equal(className);
23 | expect(output.props.onBlur).to.be.a('function');
24 | expect(output.props.onChange).to.be.a('function');
25 | expect(output.props.onKeyDown).to.be.a('function');
26 | expect(output.props.placeholder).to.equal(placeholder);
27 | expect(output.props.type).to.equal('text');
28 | expect(output.props.value).to.equal(value);
29 | });
30 |
31 | describe('#onBlur()', function () {
32 | beforeEach(function () {
33 | output.props.onBlur();
34 | });
35 |
36 | it('calls onSave correctly', function () {
37 | expect(onSave.called).to.be(true);
38 | });
39 |
40 | it('sets the state correctly', function () {
41 | const newOutput = renderer.getRenderOutput();
42 | expect(newOutput.props.value).to.equal('');
43 | });
44 | });
45 |
46 | describe('#onChange()', function () {
47 | it('sets state correctly', function () {
48 | output.props.onChange({ target: { value: 'newValue' } });
49 | const newOutput = renderer.getRenderOutput();
50 | expect(newOutput.props.value).to.equal('newValue');
51 | });
52 | });
53 |
54 | describe('#onKeyDown()', function () {
55 | it('calls onSave correctly', function () {
56 | output.props.onKeyDown({ keyCode: 10 });
57 | expect(onSave.called).to.be(false);
58 | output.props.onKeyDown({ keyCode: 13 });
59 | expect(onSave.called).to.be(true);
60 | });
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/test/helpers/componentSetup.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TestUtils from 'react-addons-test-utils';
3 |
4 | export default function componentSetup(component, props) {
5 | const renderer = TestUtils.createRenderer();
6 | renderer.render(React.createElement(component, props));
7 | const output = renderer.getRenderOutput();
8 |
9 | return { props, output, renderer };
10 | }
11 |
--------------------------------------------------------------------------------
/test/helpers/jsdom.js:
--------------------------------------------------------------------------------
1 | import { jsdom } from 'jsdom';
2 |
3 | global.document = jsdom('');
4 | global.window = document.defaultView;
5 | global.navigator = global.window.navigator;
6 |
--------------------------------------------------------------------------------
/test/helpers/mockStore.js:
--------------------------------------------------------------------------------
1 | import configureMockStore from 'redux-mock-store';
2 | import thunk from 'redux-thunk';
3 |
4 | export default configureMockStore([thunk]);
5 |
--------------------------------------------------------------------------------
/test/reducers/todos.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect.js';
2 | import { List, Record } from 'immutable';
3 |
4 | import todos from '../../js/reducers/todos';
5 |
6 | describe('todos()', function () {
7 | const Todo = new Record({
8 | id: 0,
9 | index: 0,
10 | isComplete: false,
11 | label: 'new todo',
12 | });
13 |
14 | const state = new List([
15 | new Todo({
16 | id: 1,
17 | index: 1,
18 | isComplete: true,
19 | label: 'Hello',
20 | }),
21 | new Todo({
22 | id: 2,
23 | index: 2,
24 | isComplete: false,
25 | label: 'World',
26 | }),
27 | ]);
28 |
29 | it('exposes a function', function () {
30 | expect(todos).to.be.a('function');
31 | });
32 |
33 | it('returns the initial state', function () {
34 | expect(todos(undefined, {})).to.eql(new List());
35 | });
36 |
37 | it('passes state through with no appropriate action reducer', function () {
38 | expect(todos(state, { type: 'NONSENSE' })).to.equal(state);
39 | });
40 |
41 | context('action.type = ADD_TODO', function () {
42 | const action = {
43 | type: 'ADD_TODO',
44 | payload: {
45 | todo: {
46 | id: 3,
47 | label: 'New',
48 | isComplete: false,
49 | },
50 | },
51 | };
52 |
53 | it('appends a new todo', function () {
54 | const subject = todos(state, action);
55 | expect(subject.size).to.equal(3);
56 | expect(subject.last().get('label')).to.equal('New');
57 | });
58 | });
59 |
60 | context('action.type = CLEAR_COMPLETE_TODOS', function () {
61 | const action = {
62 | type: 'CLEAR_COMPLETE_TODOS',
63 | };
64 |
65 | it('removes todos where isComplete = true', function () {
66 | const subject = todos(state, action);
67 | expect(subject.size).to.equal(1);
68 | expect(subject.every(todo => !todo.get('isComplete'))).to.be(true);
69 | });
70 | });
71 |
72 | context('action.type = DELETE_TODO', function () {
73 | const action = {
74 | type: 'DELETE_TODO',
75 | payload: {
76 | id: 2,
77 | },
78 | };
79 |
80 | it('removes the correct todo', function () {
81 | const subject = todos(state, action);
82 | expect(subject.size).to.equal(1);
83 | expect(subject.every(todo => todo.get('id') !== 2)).to.be(true);
84 | });
85 | });
86 |
87 | context('action.type = EDIT_TODO', function () {
88 | const action = {
89 | type: 'EDIT_TODO',
90 | payload: {
91 | id: 2,
92 | label: 'New label',
93 | },
94 | };
95 |
96 | it('modifies the correct todo', function () {
97 | const subject = todos(state, action)
98 |
99 | .find(todo => todo.get('id') === 2);
100 |
101 | expect(subject.get('label')).to.equal('New label');
102 | });
103 | });
104 |
105 |
106 | context('action.type = FETCH_ALL_TODOS', function () {
107 | const action = {
108 | type: 'FETCH_ALL_TODOS',
109 | payload: {
110 | id: 2,
111 | todos: [{
112 | id: 1,
113 | label: 'A couple',
114 | isComplete: true,
115 | }, {
116 | id: 2,
117 | label: 'of new',
118 | isComplete: true,
119 | }, {
120 | id: 3,
121 | label: 'todos',
122 | isComplete: true,
123 | }, {
124 | id: 4,
125 | label: 'all completed',
126 | isComplete: true,
127 | }],
128 | },
129 | };
130 |
131 | it('sets todoList to the new fetched todos', function () {
132 | const subject = todos(state, action);
133 | expect(subject.size).to.equal(4);
134 | expect(subject.every(todo => todo.get('isComplete'))).to.be(true);
135 | });
136 | });
137 |
138 | context('action.type = MARK_ALL_TODOS', function () {
139 | const action = {
140 | type: 'MARK_ALL_TODOS',
141 | payload: {
142 | id: 2,
143 | isComplete: true,
144 | },
145 | };
146 |
147 | it('modifies all todos', function () {
148 | const subject = todos(state, action);
149 | expect(subject.every(todo => todo.get('isComplete'))).to.be(true);
150 | });
151 | });
152 |
153 | context('action.type = MARK_TODO', function () {
154 | const action = {
155 | type: 'MARK_TODO',
156 | payload: {
157 | id: 2,
158 | isComplete: true,
159 | },
160 | };
161 |
162 | it('modifies the correct todo', function () {
163 | const subject = todos(state, action)
164 | .find(todo => todo.get('id') === 2);
165 |
166 | expect(subject.get('isComplete')).to.be(true);
167 | });
168 | });
169 |
170 | context('action.type = MOVE_TODO', function () {
171 | const action = {
172 | type: 'MOVE_TODO',
173 | payload: {
174 | at: 2,
175 | to: 1,
176 | },
177 | };
178 |
179 | // Adds a third todo with `index` 0 to ensure indices below `to` do not increment.
180 | const newState = state.push(new Todo());
181 |
182 | it('modifies the todo list indices correctly', function () {
183 | const subject = todos(newState, action);
184 |
185 | expect(subject.get(0).get('index')).to.equal(2);
186 | expect(subject.get(1).get('index')).to.equal(1);
187 | expect(subject.get(2).get('index')).to.equal(0);
188 | });
189 | });
190 | });
191 |
--------------------------------------------------------------------------------
/webpack.config.dev.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import webpack from 'webpack';
3 |
4 | export default {
5 | context: __dirname,
6 |
7 | entry: {
8 | app: [
9 | 'webpack-hot-middleware/client',
10 | 'babel-polyfill',
11 | './js/index',
12 | ],
13 | style: [
14 | 'webpack-hot-middleware/client',
15 | './css/index.scss',
16 | ],
17 | },
18 |
19 | output: {
20 | path: path.join(__dirname, 'assets'),
21 | filename: '[name].bundle.js',
22 | publicPath: '/assets/',
23 | },
24 |
25 | module: {
26 | preLoaders: [{
27 | loader: 'eslint',
28 | test: /\.jsx?$/,
29 | exclude: /node_modules/,
30 | }],
31 | loaders: [{
32 | loader: 'babel',
33 | test: /\.jsx?$/,
34 | exclude: /node_modules/,
35 | }, {
36 | loaders: ['style', 'css?sourceMap', 'sass?sourceMap'],
37 | test: /\.scss$/,
38 | }],
39 | },
40 |
41 | resolve: {
42 | extensions: ['', '.js', '.jsx'],
43 | modulesDirectories: ['node_modules'],
44 | },
45 |
46 | debug: true,
47 |
48 | devtool: 'cheap-module-eval-source-map',
49 |
50 | plugins: [
51 | new webpack.optimize.OccurenceOrderPlugin(),
52 | new webpack.HotModuleReplacementPlugin(),
53 | new webpack.NoErrorsPlugin(),
54 | new webpack.DefinePlugin({
55 | __DEVELOPMENT__: true,
56 | 'process.env': {
57 | NODE_ENV: JSON.stringify('development'),
58 | },
59 | }),
60 | ],
61 | };
62 |
--------------------------------------------------------------------------------
/webpack.config.prod.babel.js:
--------------------------------------------------------------------------------
1 | 'use-strict';
2 |
3 | import path from 'path';
4 | import webpack from 'webpack';
5 |
6 | export default {
7 | context: __dirname,
8 |
9 | entry: {
10 | app: [
11 | 'babel-polyfill',
12 | './js/index',
13 | ],
14 | style: './css/index.scss',
15 | },
16 |
17 | output: {
18 | path: path.join(__dirname, 'assets'),
19 | filename: '[name].bundle.js',
20 | publicPath: '/assets/',
21 | },
22 |
23 | module: {
24 | preLoaders: [{
25 | loader: 'eslint',
26 | test: /\.jsx?$/,
27 | exclude: /node_modules/,
28 | }],
29 | loaders: [{
30 | loader: 'babel',
31 | test: /\.jsx?$/,
32 | exclude: /node_modules/,
33 | }, {
34 | loaders: ['style', 'css', 'sass'],
35 | test: /\.scss$/,
36 | }],
37 | },
38 |
39 | resolve: {
40 | extensions: ['', '.js', '.jsx'],
41 | modulesDirectories: ['node_modules'],
42 | },
43 |
44 | devtool: 'source-map',
45 |
46 | plugins: [
47 | new webpack.optimize.OccurenceOrderPlugin(),
48 | new webpack.DefinePlugin({
49 | __DEVELOPMENT__: false,
50 | 'process.env': {
51 | NODE_ENV: JSON.stringify('production'),
52 | },
53 | }),
54 | new webpack.optimize.UglifyJsPlugin({
55 | compressor: {
56 | warnings: false,
57 | },
58 | output: {
59 | comments: false,
60 | },
61 | }),
62 | ],
63 | };
64 |
--------------------------------------------------------------------------------