├── .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 |
13 | { props.todos.map(renderTodoItem) }
14 |
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 ;
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 | }
--------------------------------------------------------------------------------