├── .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 |
24 |

Todos

25 | 30 |
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 |
    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 |