├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── components ├── TodoContainer.jsx ├── TodoList.css ├── TodoList.jsx ├── TodoListItem.css ├── TodoListItem.jsx ├── TodoSearch.css └── TodoSearch.jsx ├── css └── base.css ├── dist ├── index.html ├── libs.js ├── scripts.js └── styles.css ├── gateways ├── TodoGateway.js └── UserGateway.js ├── gulpfile.js ├── index.html ├── main.js ├── package-lock.json ├── package.json └── stores ├── TodoStore.js ├── TodoStore.test.js └── UserStore.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "plugins": ["transform-es2015-modules-commonjs"] 5 | }, 6 | "test": { 7 | "plugins": ["transform-es2015-modules-commonjs"] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended" 6 | ], 7 | "parserOptions": { 8 | "ecmaVersion": 6, 9 | "sourceType": "module", 10 | "ecmaFeatures": { 11 | "jsx": true 12 | } 13 | }, 14 | "env": { 15 | "browser": true, 16 | "es6": true, 17 | "jquery": true, 18 | "jest": true 19 | }, 20 | "rules": { 21 | "semi": "error", 22 | "max-params": ["error", 4], 23 | "eqeqeq" : "error", 24 | "no-loop-func": "error", 25 | "no-new-wrappers": "error" 26 | }, 27 | "plugins": [ 28 | "react" 29 | ], 30 | "settings": { 31 | "react": { 32 | "version": "16.0" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to create a three layer application with React 2 | 3 | Splitting a Single Page Application into layers has a set of advantages: 4 | 5 | * a better separation of concerns 6 | * the layer implementation can be replaced 7 | * the UI layer can be hard to test. By moving the logic to other layers, it becomes easier to test. 8 | 9 | # Files 10 | Open the ./dist/index.html. 11 | All bundles are already generated, there is no need to run any extra tasks. 12 | 13 | Execute `npm install` from command prompt to install all dependecies. 14 | 15 | # Gulp 16 | Gulp is used to run tasks. 17 | Execute from command prompt `npm install -g gulp-cli` to make Gulp available. 18 | 19 | - To run Gulp, execute from command prompt: 20 | `gulp` 21 | - To start the watch, execute from command prompt: 22 | `gulp watch` 23 | 24 | # Tests 25 | Jest is used for testing. 26 | Execute `npm test` to run the tests. 27 | -------------------------------------------------------------------------------- /components/TodoContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from 'prop-types'; 3 | import TodoList from "./TodoList.jsx"; 4 | import TodoSearch from "./TodoSearch.jsx"; 5 | 6 | export default class TodoContainer extends React.Component { 7 | 8 | constructor(props){ 9 | super(props); 10 | this.todoStore = props.stores.todoStore; 11 | this.search = this.search.bind(this); 12 | this.reload = this.reload.bind(this); 13 | 14 | this.query = null; 15 | this.state = { 16 | todos: [] 17 | }; 18 | } 19 | 20 | componentDidMount(){ 21 | this.todoStore.onChange(this.reload); 22 | this.todoStore.fetch(); 23 | } 24 | 25 | reload(){ 26 | const todos = this.todoStore.getBy(this.query); 27 | this.setState({ todos }); 28 | } 29 | 30 | search(query){ 31 | this.query = query; 32 | this.reload(); 33 | } 34 | 35 | render() { 36 | return
37 | 38 | 39 |
; 40 | } 41 | } 42 | 43 | TodoContainer.propTypes = { 44 | stores: PropTypes.object 45 | }; -------------------------------------------------------------------------------- /components/TodoList.css: -------------------------------------------------------------------------------- 1 | .todo-list .top-bar { 2 | display: flex; 3 | flex-direction: row-reverse; 4 | } 5 | 6 | .todo-list { 7 | width: 70%; 8 | } 9 | 10 | .todo-list ul { 11 | list-style: none; 12 | padding: 5px; 13 | } -------------------------------------------------------------------------------- /components/TodoList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from 'prop-types'; 3 | import TodoListItem from "./TodoListItem.jsx"; 4 | 5 | export default function TodoList(props) { 6 | 7 | function renderTodoItem(todo){ 8 | return ; 9 | } 10 | 11 | return
12 | 15 |
; 16 | } 17 | 18 | TodoList.propTypes = { 19 | todos: PropTypes.array 20 | }; -------------------------------------------------------------------------------- /components/TodoListItem.css: -------------------------------------------------------------------------------- 1 | .todo-list ul li { 2 | background-color: #2C83A9; 3 | color: #FFF; 4 | padding: 15px; 5 | margin-bottom: 5px; 6 | } -------------------------------------------------------------------------------- /components/TodoListItem.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from 'prop-types'; 3 | export default function TodoListItem(props){ 4 | return
  • 5 |
    { props.todo.title}
    6 |
    { props.todo.userName }
    7 |
  • ; 8 | } 9 | 10 | TodoListItem.propTypes = { 11 | todo: PropTypes.object 12 | }; -------------------------------------------------------------------------------- /components/TodoSearch.css: -------------------------------------------------------------------------------- 1 | .search-form { 2 | margin : 30px; 3 | border : 0; 4 | } 5 | 6 | .search-form .btn { 7 | margin: 0 1px; 8 | } -------------------------------------------------------------------------------- /components/TodoSearch.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from 'prop-types'; 3 | export default class TodoSearch extends React.Component { 4 | constructor(props){ 5 | super(props); 6 | this.search = this.search.bind(this); 7 | this.handleChange = this.handleChange.bind(this); 8 | this.handleKeyPress = this.handleKeyPress.bind(this); 9 | 10 | this.state = { 11 | text: "" 12 | }; 13 | } 14 | 15 | search(){ 16 | const query = Object.freeze({ text: this.state.text }); 17 | if(this.props.onSearch) 18 | this.props.onSearch(query); 19 | } 20 | 21 | handleChange(event) { 22 | this.setState({text: event.target.value}); 23 | } 24 | 25 | handleKeyPress(event) { 26 | if (event.key === 'Enter') { 27 | this.search(); 28 | } 29 | } 30 | 31 | handleSubmit(event){ 32 | event.preventDefault(); 33 | } 34 | 35 | render() { 36 | return
    37 | 38 | 39 |
    ; 40 | } 41 | } 42 | 43 | TodoSearch.propTypes = { 44 | onSearch: PropTypes.func 45 | }; 46 | -------------------------------------------------------------------------------- /css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | width : 80%; 3 | margin : 0 auto; 4 | } 5 | 6 | .input { 7 | font-size: 16px; 8 | border: 1px solid #2C83A9; 9 | padding: 10px; 10 | } 11 | 12 | .btn { 13 | font-size: 16px; 14 | background-color: #2C83A9; 15 | color: #FFF; 16 | border: 1px solid #2C83A9; 17 | cursor : pointer; 18 | padding: 10px; 19 | } 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A todo app with React and Gulp 5 | 6 | 7 |
    8 |
    9 | 10 | 11 | -------------------------------------------------------------------------------- /dist/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | width : 80%; 3 | margin : 0 auto; 4 | } 5 | 6 | .input { 7 | font-size: 16px; 8 | border: 1px solid #2C83A9; 9 | padding: 10px; 10 | } 11 | 12 | .btn { 13 | font-size: 16px; 14 | background-color: #2C83A9; 15 | color: #FFF; 16 | border: 1px solid #2C83A9; 17 | cursor : pointer; 18 | padding: 10px; 19 | } 20 | 21 | 22 | 23 | 24 | .todo-list .top-bar { 25 | display: flex; 26 | flex-direction: row-reverse; 27 | } 28 | 29 | .todo-list { 30 | width: 70%; 31 | } 32 | 33 | .todo-list ul { 34 | list-style: none; 35 | padding: 5px; 36 | } 37 | .todo-list ul li { 38 | background-color: #2C83A9; 39 | color: #FFF; 40 | padding: 15px; 41 | margin-bottom: 5px; 42 | } 43 | .search-form { 44 | margin : 30px; 45 | border : 0; 46 | } 47 | 48 | .search-form .btn { 49 | margin: 0 1px; 50 | } -------------------------------------------------------------------------------- /gateways/TodoGateway.js: -------------------------------------------------------------------------------- 1 | export default function TodoGateway(){ 2 | const url = "https://jsonplaceholder.typicode.com/todos"; 3 | 4 | function toJson(response){ 5 | return response.json(); 6 | } 7 | 8 | function get() { 9 | return fetch(url).then(toJson); 10 | } 11 | 12 | function add(todo) { 13 | return fetch(url, { 14 | method: "POST", 15 | body: JSON.stringify(todo), 16 | }).then(toJson); 17 | } 18 | 19 | return Object.freeze({ 20 | get, 21 | add 22 | }); 23 | } -------------------------------------------------------------------------------- /gateways/UserGateway.js: -------------------------------------------------------------------------------- 1 | export default function UserGateway(){ 2 | const url = "https://jsonplaceholder.typicode.com/users"; 3 | 4 | function get() { 5 | return fetch(url).then(toJson); 6 | } 7 | 8 | function toJson(response){ 9 | return response.json(); 10 | } 11 | 12 | return Object.freeze({ 13 | get 14 | }); 15 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | // including plugins 2 | var gulp = require('gulp') 3 | var concat = require("gulp-concat"); 4 | var eslint = require('gulp-eslint'); 5 | var babelify = require('babelify'); 6 | var browserify = require('browserify'); 7 | var source = require('vinyl-source-stream'); 8 | var cachebust = require('gulp-cache-bust'); 9 | var distFolder = "./dist"; 10 | 11 | // task 12 | gulp.task('concat:css', function () { 13 | gulp.src(["css/*.css", "components/*.css"]) 14 | .pipe(concat('styles.css')) 15 | .pipe(gulp.dest(distFolder)); 16 | }); 17 | 18 | gulp.task('eslint', function () { 19 | gulp.src(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "main.js"]) 20 | .pipe(eslint()) 21 | .pipe(eslint.format()); 22 | }); 23 | 24 | gulp.task('scripts', function () { 25 | return browserify({ 26 | entries: 'main.js' 27 | }) 28 | .transform(babelify.configure({ 29 | presets : ["es2015", "react"] 30 | })) 31 | .bundle() 32 | .pipe(source('scripts.js')) 33 | .pipe(gulp.dest(distFolder)); 34 | }); 35 | 36 | gulp.task('cachebust', function () { 37 | gulp.src('index.html') 38 | .pipe(cachebust({ 39 | type: 'timestamp' 40 | })) 41 | .pipe(gulp.dest(distFolder)); 42 | }); 43 | 44 | gulp.task('watch', function () { 45 | gulp.watch(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "main.js"], [ "eslint", "scripts", "cachebust" ]); 46 | gulp.watch(["css/*.css"], [ "concat:css" ]); 47 | }); 48 | 49 | gulp.task( 'default', [ "eslint", "scripts", "concat:css", "cachebust" ] ) -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A todo app with React and Gulp 5 | 6 | 7 |
    8 |
    9 | 10 | 11 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from 'react-dom'; 3 | import TodoGateway from "./gateways/TodoGateway"; 4 | import UserGateway from "./gateways/UserGateway"; 5 | import TodoStore from "./stores/TodoStore"; 6 | import UserStore from "./stores/UserStore"; 7 | import TodoContainer from "./components/TodoContainer.jsx"; 8 | 9 | (function startApplication(){ 10 | const userGateway = UserGateway(); 11 | const todoGateway = TodoGateway(); 12 | const userStore = UserStore(userGateway); 13 | const todoStore = TodoStore(todoGateway, userStore); 14 | 15 | const stores = { 16 | todoStore, 17 | userStore 18 | }; 19 | 20 | function loadStaticData(){ 21 | return Promise.all([userStore.fetch()]); 22 | } 23 | 24 | function mountPage(){ 25 | ReactDOM.render( 26 | , 27 | document.getElementById('root')); 28 | } 29 | 30 | loadStaticData().then(mountPage); 31 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-search-react", 3 | "version": "1.0.0", 4 | "description": "A todo app prototyped with React and Gulp", 5 | "author": "Cristi Salcescu", 6 | "license": "UNLICENSED", 7 | "devDependencies": { 8 | "babel-cli": "^6.26.0", 9 | "babel-core": "^6.26.3", 10 | "babel-eslint": "^10.0.1", 11 | "babel-jest": "^23.6.0", 12 | "babel-preset-es2015": "^6.24.1", 13 | "babel-preset-react": "^6.24.1", 14 | "babelify": "^8.0.0", 15 | "browserify": "^16.2.3", 16 | "eslint-plugin-react": "^7.11.1", 17 | "gulp": "^3.9.1", 18 | "gulp-cache-bust": "^1.4.0", 19 | "gulp-concat": "^2.6.1", 20 | "gulp-eslint": "^5.0.0", 21 | "jest": "^23.6.0", 22 | "micro-emitter": "1.1.15", 23 | "vinyl-source-stream": "^2.0.0" 24 | }, 25 | "dependencies": { 26 | "lodash": "^4.17.11", 27 | "react": "^16.5.2", 28 | "react-dom": "^16.5.2" 29 | }, 30 | "scripts": { 31 | "test": "jest" 32 | }, 33 | "jest": { 34 | "verbose": true, 35 | "moduleDirectories": [ 36 | "node_modules" 37 | ], 38 | "moduleFileExtensions": [ 39 | "js", 40 | "jsx" 41 | ], 42 | "transform": { 43 | "^.+\\.js$": "babel-jest" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /stores/TodoStore.js: -------------------------------------------------------------------------------- 1 | import MicroEmitter from 'micro-emitter'; 2 | import partial from "lodash/partial"; 3 | 4 | export default function TodoStore(gateway, userStore){ 5 | let todos = []; 6 | const eventEmitter = new MicroEmitter(); 7 | const CHANGE_EVENT = "change"; 8 | 9 | function fetch() { 10 | return gateway.get().then(setLocalTodos); 11 | } 12 | 13 | function setLocalTodos(newTodos){ 14 | todos = newTodos; 15 | eventEmitter.emit(CHANGE_EVENT); 16 | } 17 | 18 | function onChange(handler){ 19 | eventEmitter.on(CHANGE_EVENT, handler); 20 | } 21 | 22 | function toTodoView(todo){ 23 | return Object.freeze({ 24 | id : todo.id, 25 | title : todo.title, 26 | userName : userStore.getById(todo.userId).name 27 | }); 28 | } 29 | 30 | function descById(todo1, todo2){ 31 | return parseInt(todo2.id) - parseInt(todo1.id); 32 | } 33 | 34 | function queryContainsTodo(query, todo){ 35 | if(query && query.text){ 36 | return todo.title.includes(query.text); 37 | } 38 | return true; 39 | } 40 | 41 | function getBy(query) { 42 | const top = 25; 43 | return todos.filter(partial(queryContainsTodo, query)) 44 | .map(toTodoView) 45 | .sort(descById).slice(0, top); 46 | } 47 | 48 | return Object.freeze({ 49 | fetch, 50 | getBy, 51 | onChange 52 | }); 53 | } -------------------------------------------------------------------------------- /stores/TodoStore.test.js: -------------------------------------------------------------------------------- 1 | import TodoStore from "../stores/TodoStore"; 2 | 3 | test("TodoStore can filter by title text", function() { 4 | //arrage 5 | const allTodos = [ 6 | { id: 1, title : "title 1" }, 7 | { id: 2, title : "title 2" }, 8 | { id: 3, title : "title 3" } 9 | ]; 10 | const todoGatewayFake = { 11 | get : function(){ 12 | return Promise.resolve(allTodos); 13 | } 14 | }; 15 | const userStoreFake = { 16 | getById : function(){ 17 | return { 18 | name : "Test" 19 | }; 20 | } 21 | }; 22 | const todoStore = TodoStore(todoGatewayFake, userStoreFake); 23 | const query = { text: "title 1" }; 24 | const expectedOutputTodos = [ 25 | { id: 1, title : "title 1" , userName : "Test"} 26 | ]; 27 | 28 | //act 29 | todoStore.fetch().then(function makeAssertions(){ 30 | //assert 31 | expect(expectedOutputTodos).toEqual(todoStore.getBy(query)); 32 | }); 33 | }); -------------------------------------------------------------------------------- /stores/UserStore.js: -------------------------------------------------------------------------------- 1 | import MicroEmitter from 'micro-emitter'; 2 | 3 | export default function UserStore(gateway){ 4 | let usersMap = []; 5 | const eventEmitter = new MicroEmitter(); 6 | const CHANGE_EVENT = "change"; 7 | 8 | function fetch() { 9 | return gateway.get().then(setLocalUsers); 10 | } 11 | 12 | function setLocalUsers(newUsers){ 13 | usersMap = createMapFrom(newUsers); 14 | eventEmitter.emit(CHANGE_EVENT); 15 | } 16 | 17 | function getById(id){ 18 | return usersMap[id]; 19 | } 20 | 21 | function asMapById(map, value){ 22 | map[value.id] = value; 23 | return map; 24 | } 25 | 26 | function createMapFrom(list){ 27 | return list.reduce(asMapById, Object.create(null)); 28 | } 29 | 30 | function onChange(handler){ 31 | eventEmitter.on(CHANGE_EVENT, handler); 32 | } 33 | 34 | return Object.freeze({ 35 | fetch, 36 | getById, 37 | onChange 38 | }); 39 | } --------------------------------------------------------------------------------