├── .bowerrc ├── .gitignore ├── README.md ├── app.py ├── bower.json ├── requirements.txt ├── static ├── css │ └── main.css ├── index.html └── jsx │ ├── components │ ├── AddTaskForm.js │ ├── App.js │ ├── Task.js │ ├── TasksBox.js │ └── TasksList.js │ └── main.js └── test_app.py /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "static/libs", 3 | "json": "bower.json" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | static/js 3 | static/libs 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo app 2 | Sample application build with flask and facebook react 3 | 4 | ## Installation 5 | Inspired from https://github.com/abhiomkar/flask-react 6 | 7 | * install python dependencies 8 | 9 | pip install -r requirements.txt 10 | 11 | * install js dependencies 12 | 13 | bower install 14 | 15 | * compile jsx files using [React tool](http://facebook.github.io/react/docs/tooling-integration.html#productionizing-precompiled-jsx) for development purpose 16 | 17 | jsx --watch static/jsx static/js 18 | 19 | * run flask server 20 | 21 | python app.py 22 | 23 | * locations: 24 | - tasks app: http://127.0.0.1:5000 25 | - swagger.json: http://127.0.0.1:5000/swagger.json 26 | - api docs: http://127.0.0.1:5000/doc 27 | 28 | ## React components notes 29 | - TasksBox - main component. In charge with hooking frontend components with the backend api 30 | - AddTaskForm - allows creating new tasks. Exposes submit callback to allow main component to hock into backend api 31 | - TasksLists - sortable component via jquery-ui.sortable. Exposes callbacks for tasks updates 32 | - Task - representation of one task item 33 | 34 | ## Outstanding items 35 | - task sort is not natural. On drag and drop, only source and destination positions are swapped. 36 | In order to support a natural sort, all items in between must shifted. This might be a good reason 37 | to refactor tasks update api to accept multiple tasks updates 38 | - add tasks model abstraction behind flask rest api -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, Blueprint, url_for 2 | from flask.ext.restplus import Api, Resource, apidoc 3 | 4 | # required for task id generation 5 | import uuid 6 | 7 | app = Flask(__name__, static_folder='static') 8 | 9 | # swagger init 10 | blueprint = Blueprint('api', __name__) 11 | api = Api(blueprint, title='Tasks API', ui=False) 12 | 13 | 14 | # tasks persisted in the app scope 15 | TASKS = dict() 16 | 17 | 18 | # create parser initialization 19 | createParser = api.parser() 20 | createParser.add_argument('label', help='Task label', type=str, location='form', required=True) 21 | 22 | 23 | @api.route('/tasks') 24 | class TaskList(Resource): 25 | def get(self): 26 | """ 27 | Return all tasks 28 | """ 29 | return TASKS.values() 30 | 31 | @api.doc(parser=createParser) 32 | def post(self): 33 | """ 34 | Create new task 35 | """ 36 | args = createParser.parse_args() 37 | task_id = str(uuid.uuid4()) 38 | TASKS[task_id] = {'task_id': task_id, 'label': args['label'], 'position': len(TASKS), 'completed': 0} 39 | return TASKS[task_id], 200 40 | 41 | 42 | # update parser initialization 43 | updateParser = api.parser() 44 | updateParser.add_argument('position', help='Task position', type=int, location='form') 45 | updateParser.add_argument('completed', help='Task status', type=int, location='form') 46 | 47 | 48 | @api.route('/tasks/') 49 | class Task(Resource): 50 | @api.doc(parser=updateParser, responses={ 51 | '200': 'Success', 52 | '404': 'Task does not exist' 53 | }) 54 | def patch(self, task_id): 55 | """ 56 | Update task's position or completed fields 57 | """ 58 | args = updateParser.parse_args() 59 | if task_id not in TASKS: 60 | api.abort(404, message="Task {} doesn't exist".format(task_id)) 61 | 62 | task = TASKS[task_id] 63 | if args['position'] is not None: 64 | task['position'] = args['position'] 65 | 66 | if args['completed'] is not None: 67 | task['completed'] = args['completed'] 68 | 69 | return task, 200 70 | 71 | 72 | @app.route('/') 73 | def index(): 74 | return app.send_static_file('index.html') 75 | 76 | 77 | @blueprint.route('/doc/') 78 | def swagger_ui(): 79 | return apidoc.ui_for(api) 80 | 81 | 82 | app.register_blueprint(blueprint) 83 | app.register_blueprint(apidoc.apidoc) 84 | 85 | 86 | if __name__ == '__main__': 87 | app.run(debug=True) -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flask-todo-app", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "bootstrap": "*", 6 | "jquery": "*", 7 | "jquery-ui": "*", 8 | "requirejs": "*", 9 | "react": "*", 10 | "async": "*" 11 | } 12 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | Flask-RestPlus 3 | Flask-Testing 4 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | @import '../libs/bootstrap/dist/css/bootstrap.min.css'; 2 | @import '../libs/jquery-ui/themes/smoothness/jquery-ui.min.css'; 3 | 4 | body { 5 | background-color: rgb(246, 245, 245); 6 | font-family: "Trebuchet MS", "Helvetica", "Arial", "Verdana", "sans-serif"; 7 | } 8 | 9 | form { 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | .completed label{ 15 | text-decoration: line-through; 16 | color: rgb(203, 203, 203); 17 | } 18 | 19 | .app { 20 | margin: 0 auto; 21 | width: 500px; 22 | } 23 | 24 | .tasksBox { 25 | margin-top:50px; 26 | } 27 | 28 | .tasksBox .title { 29 | padding: 25px 0px 25px 0px; 30 | border-width: 0 0 1px 0; 31 | border-style: dashed; 32 | border-color: rgb(228, 228, 228); 33 | } 34 | 35 | .tasksBox .addTaskForm { 36 | padding: 10px 30px 0px 5px; 37 | } 38 | 39 | .tasksBox .tasksBoxBar { 40 | color: rgb(203, 203, 203); 41 | padding: 20px; 42 | 43 | border-width: 1px 0 0 0; 44 | border-style: dashed; 45 | border-color: rgb(228, 228, 228); 46 | } 47 | 48 | .tasksBox aside { 49 | float: right; 50 | } 51 | 52 | .taskList li { 53 | color: rgb(122, 123, 124); 54 | border-width: 0; 55 | } 56 | .taskList li:hover .ui-icon { 57 | visibility: visible; 58 | } 59 | 60 | .taskList .ui-icon { 61 | float: right; 62 | visibility: hidden; 63 | } 64 | 65 | .taskList .taskLabel { 66 | border-width: 0; 67 | padding-left: 7px; 68 | font-weight: 500; 69 | } 70 | 71 | .taskList li:nth-child(odd) { 72 | background-color: rgb(244, 247, 250); 73 | } 74 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | -------------------------------------------------------------------------------- /static/jsx/components/AddTaskForm.js: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | define([ 4 | 'react' 5 | ], function (React) { 6 | 7 | var AddTaskForm = React.createClass({ 8 | handleSubmit: function (e) { 9 | e.preventDefault(); 10 | var label = this.refs.label.getDOMNode().value.trim(); 11 | if (!label) { 12 | return; 13 | } 14 | 15 | this.props.onTaskSubmit({label: label}); 16 | this.refs.label.getDOMNode().value = ''; 17 | return; 18 | }, 19 | 20 | render: function () { 21 | return ( 22 |
23 |
24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 |
32 | ); 33 | } 34 | }); 35 | 36 | return AddTaskForm; 37 | }); -------------------------------------------------------------------------------- /static/jsx/components/App.js: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | define([ 4 | 'react', 5 | 'components/TasksBox' 6 | ], function (React, TasksBox) { 7 | 8 | var App = React.createClass({ 9 | render: function () { 10 | return (); 11 | } 12 | }); 13 | 14 | return App; 15 | }); -------------------------------------------------------------------------------- /static/jsx/components/Task.js: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | define([ 4 | 'react' 5 | ], function (React) { 6 | 7 | var Task = React.createClass({ 8 | handleChange: function (event) { 9 | event.preventDefault(); 10 | this.props.task.completed = (this.props.task.completed === 0) ? 1 : 0; 11 | this.props.onTaskUpdate(this.props.task); 12 | }, 13 | 14 | render: function () { 15 | var classes = 'task'; 16 | if (this.props.task.completed === 1) { 17 | classes += ' completed disabled'; 18 | } 19 | 20 | return ( 21 |
22 |
23 | 27 | 28 |
29 |
30 | ); 31 | } 32 | }); 33 | 34 | return Task; 35 | }); -------------------------------------------------------------------------------- /static/jsx/components/TasksBox.js: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | define([ 4 | 'react', 5 | 'async', 6 | 'jquery', 7 | 'components/AddTaskForm', 8 | 'components/TasksList' 9 | ], function (React, async, jquery, AddTaskForm, TasksList) { 10 | 11 | var TasksBox = React.createClass({ 12 | loadTaskList: function () { 13 | $.ajax({ 14 | url: this.props.url, 15 | dataType: 'json', 16 | success: function (data) { 17 | this.setState({ 18 | data: data.sort(function (a, b) { 19 | return a.position - b.position; 20 | }) 21 | }); 22 | }.bind(this), 23 | error: function (xhr, status, err) { 24 | console.error(this.props.url, status, err.toString()); 25 | }.bind(this) 26 | }); 27 | }, 28 | 29 | handleTaskSubmit: function (task) { 30 | $.ajax({ 31 | type: "POST", 32 | url: this.props.url, 33 | data: task, 34 | success: this.loadTaskList, 35 | error: function (xhr, status, err) { 36 | console.error(this.props.url, status, err.toString()); 37 | }.bind(this) 38 | }); 39 | }, 40 | 41 | handleTaskUpdate: function (task, callback) { 42 | $.ajax({ 43 | type: "PATCH", 44 | url: this.props.url + '/' + task.task_id, 45 | data: { 46 | completed: task.completed, 47 | position: task.position 48 | }, 49 | success: function (data) { 50 | if (!callback) { 51 | this.loadTaskList(); 52 | } 53 | else callback(); 54 | }.bind(this), 55 | error: function (xhr, status, err) { 56 | console.error(this.props.url, status, err.toString()); 57 | }.bind(this) 58 | }); 59 | }, 60 | 61 | getInitialState: function () { 62 | return {data: []}; 63 | }, 64 | 65 | getTasksLeft: function () { 66 | return this.state.data.filter(function (task) { 67 | return task.completed === 0; 68 | }); 69 | }, 70 | 71 | handleMarkAll: function (e) { 72 | e.preventDefault(); 73 | async.eachSeries(this.getTasksLeft(), function (task, callback) { 74 | task.completed = 1; 75 | this.handleTaskUpdate(task, callback); 76 | }.bind(this), function (err) { 77 | if (err) { 78 | console.error(err); 79 | } 80 | 81 | this.loadTaskList(); 82 | }.bind(this)); 83 | }, 84 | 85 | handleSort: function(fromTask, toTask) { 86 | // swap positions 87 | var position = fromTask.position; 88 | fromTask.position = toTask.position; 89 | toTask.position = position; 90 | 91 | async.eachSeries([fromTask, toTask], function (task, callback) { 92 | this.handleTaskUpdate(task, callback); 93 | }.bind(this), function (err) { 94 | if (err) { 95 | console.error(err); 96 | } 97 | 98 | this.loadTaskList(); 99 | }.bind(this)); 100 | }, 101 | 102 | componentDidMount: function () { 103 | this.loadTaskList(); 104 | setInterval(this.loadTaskList, this.props.pollInterval); 105 | }, 106 | 107 | render: function () { 108 | var itemsLeft = this.getTasksLeft().length; 109 | 110 | return ( 111 |
112 |
113 |

Todos

114 |
115 | 116 |
117 | 118 |
119 | 120 |
121 | 122 |
123 | 124 |
125 | {itemsLeft} items left 126 | 127 |
128 |
129 | ); 130 | } 131 | }); 132 | 133 | return TasksBox; 134 | }); -------------------------------------------------------------------------------- /static/jsx/components/TasksList.js: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | define([ 4 | 'react', 5 | 'jquery', 6 | 'jquery-ui', 7 | 'components/Task' 8 | ], function (React, jquery, jqueryUi, Task) { 9 | 10 | var TasksList = React.createClass({ 11 | handleTaskUpdate: function (task) { 12 | this.props.onTaskUpdate(task); 13 | }, 14 | 15 | componentDidMount: function(){ 16 | $(this.getDOMNode()).sortable({start: this.handleStart, stop: this.handleDrop}); 17 | }, 18 | 19 | handleStart: function (e, ui) { 20 | // used by sorting functionality to find updated items 21 | ui.item.fromPosition = ui.item.index(); 22 | }, 23 | 24 | handleDrop: function(e, ui) { 25 | // @todo shift all items between to and from positions, in place of just swapping old and new elements. 26 | 27 | e.preventDefault(); 28 | var toPosition = ui.item.index(), 29 | fromPosition = ui.item.fromPosition; 30 | 31 | var fromTask = this.props.data[toPosition]; 32 | var toTask = this.props.data[fromPosition]; 33 | 34 | this.props.onSort(fromTask, toTask); 35 | }, 36 | 37 | render: function () { 38 | var tasks = this.props.data.map(function (task, i) { 39 | return ( 40 |
  • 41 | 42 |
  • 43 | ); 44 | }.bind(this)); 45 | 46 | return ( 47 | 50 | ); 51 | } 52 | }); 53 | 54 | return TasksList; 55 | }); -------------------------------------------------------------------------------- /static/jsx/main.js: -------------------------------------------------------------------------------- 1 | require.config({ 2 | paths: { 3 | react: '../libs/react/react', 4 | jquery: '../libs/jquery/dist/jquery.min', 5 | 'jquery-ui': '../libs/jquery-ui/jquery-ui.min', 6 | bootstrap: '../libs/bootstrap/dist/js/bootstrap.min', 7 | async: '../libs/async/lib/async' 8 | }, 9 | 10 | shim: { 11 | react: { 12 | exports: 'React' 13 | }, 14 | 15 | jquery: { 16 | exports: '$' 17 | }, 18 | 19 | bootstrap: { 20 | deps: ['jquery'] 21 | } 22 | } 23 | }); 24 | 25 | require([ 26 | 'react', 27 | 'components/App' 28 | ], 29 | function(React, App) { 30 | React.render(App(), document.getElementById('app')); 31 | }); -------------------------------------------------------------------------------- /test_app.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import app 3 | from flask.ext.testing import TestCase 4 | 5 | from operator import itemgetter 6 | 7 | 8 | class TasksTestCase(TestCase): 9 | def create_app(self): 10 | return app.app 11 | 12 | def setUp(self): 13 | app.TASKS = dict() 14 | 15 | def sorted(self, tasks): 16 | return sorted(tasks, key=itemgetter('position')) 17 | 18 | def filterOut(self, tasks, task_id): 19 | return filter(lambda task: task['task_id'] != task_id, tasks) 20 | 21 | def test_add_task(self): 22 | """ 23 | Test add and list tasks 24 | """ 25 | task1 = self.client.post('/tasks', data=dict(label='Task 1')).json 26 | task2 = self.client.post('/tasks', data=dict(label='Task 2')).json 27 | task3 = self.client.post('/tasks', data=dict(label='Task 3')).json 28 | 29 | # load all tasks. Sort result by position 30 | tasks = self.sorted(self.client.get('/tasks').json) 31 | 32 | self.assertEqual([task1, task2, task3], tasks) 33 | 34 | 35 | def test_mark_task_as_completed(self): 36 | """ 37 | Test mark task as completed 38 | """ 39 | self.client.post('/tasks', data=dict(label='Task 1')) 40 | self.client.post('/tasks', data=dict(label='Task 2')) 41 | task3 = self.client.post('/tasks', data=dict(label='Task 3')).json 42 | self.client.post('/tasks', data=dict(label='Task 4')) 43 | 44 | originalTasks = self.sorted(self.client.get('/tasks').json) 45 | 46 | # mark task3 as completed 47 | self.client.patch('/tasks/' + task3['task_id'], data=dict(completed=1)) 48 | 49 | # load all tasks 50 | tasks = self.sorted(self.client.get('/tasks').json) 51 | 52 | # tasks list to dict. task_id is used as key 53 | tasksDict = dict([(task['task_id'], task) for task in tasks]) 54 | 55 | # test task3 state 56 | patchedTask3 = tasksDict[task3['task_id']] 57 | assert patchedTask3['completed'] == 1 58 | 59 | # test the rest of tasks were not changed 60 | self.assertEqual(self.filterOut(originalTasks, task3['task_id']), 61 | self.filterOut(tasks, task3['task_id'])) 62 | 63 | 64 | def test_update_position(self): 65 | """ 66 | Test update task position 67 | """ 68 | self.client.post('/tasks', data=dict(label='Task 1')) 69 | self.client.post('/tasks', data=dict(label='Task 2')) 70 | task3 = self.client.post('/tasks', data=dict(label='Task 3')).json 71 | self.client.post('/tasks', data=dict(label='Task 4')) 72 | 73 | originalTasks = self.sorted(self.client.get('/tasks').json) 74 | 75 | # update task3 position 76 | self.client.patch('/tasks/' + task3['task_id'], data=dict(position=0)) 77 | 78 | # load all tasks 79 | tasks = self.sorted(self.client.get('/tasks').json) 80 | 81 | # tasks list to dict. task_id is used as key 82 | tasksDict = dict([(task['task_id'], task) for task in tasks]) 83 | 84 | # test task3 state 85 | patchedTask3 = tasksDict[task3['task_id']] 86 | assert patchedTask3['position'] == 0 87 | 88 | # test the rest of tasks were not changed 89 | self.assertEqual(self.filterOut(originalTasks, task3['task_id']), 90 | self.filterOut(tasks, task3['task_id'])) 91 | 92 | 93 | if __name__ == '__main__': 94 | unittest.main() 95 | 96 | 97 | --------------------------------------------------------------------------------