├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── application │ ├── actions │ │ ├── todos.js │ │ └── ui.js │ ├── middleware │ │ ├── index.js │ │ ├── todos.js │ │ ├── todos.test.js │ │ ├── ui.js │ │ └── ui.test.js │ ├── reducers │ │ ├── index.js │ │ ├── todos.js │ │ └── ui.js │ ├── selectors │ │ ├── todos.js │ │ └── ui.js │ └── store.js ├── index.js ├── infrastructure │ └── services │ │ ├── api │ │ ├── index.js │ │ └── todos │ │ │ └── index.js │ │ ├── index.js │ │ └── logger │ │ ├── console.js │ │ └── elastic-search.js └── views │ └── index.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Essential Todos 2 | A demo project for the YouTube video: Clean Architecture in React. 3 | 4 | Link to the video: https://youtu.be/qOH2X5hciiA 5 | 6 | ## Built with Clean Architecture 7 | - Application - Redux 8 | - Views - React 9 | - Infrastructure - Axios 10 | 11 | ## Other Features 12 | - All business logic resides in Redux Middleware 13 | - Fetches dummy todo data from `https://jsonplaceholder.typicode.com` 14 | - Boostraped with `create-react-app` 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "essential-todos", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "axios": "^0.20.0", 10 | "react": "^16.13.1", 11 | "react-dom": "^16.13.1", 12 | "react-redux": "^7.2.1", 13 | "react-scripts": "3.4.3", 14 | "redux": "^4.0.5" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progressive-dev/essential-todos/b95166826deb1bdafc97f794e181d8eaa3914287/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progressive-dev/essential-todos/b95166826deb1bdafc97f794e181d8eaa3914287/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progressive-dev/essential-todos/b95166826deb1bdafc97f794e181d8eaa3914287/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/application/actions/todos.js: -------------------------------------------------------------------------------- 1 | export const LOAD_TODOS = '[todos] load'; 2 | export const LOAD_TODOS_SUCCESS = '[todos] load success'; 3 | export const LOAD_TODOS_FAILURE = '[todos] load failure'; 4 | export const SET_TODOS = '[todos] set'; 5 | export const PUT_TODO = '[todos] put'; 6 | 7 | export const loadTodos = { 8 | type: LOAD_TODOS, 9 | }; 10 | 11 | export const loadTodosSuccess = todos => ({ 12 | type: LOAD_TODOS_SUCCESS, 13 | payload: todos, 14 | }); 15 | 16 | export const loadTodosFailure = error => ({ 17 | type: LOAD_TODOS_FAILURE, 18 | payload: error, 19 | }); 20 | 21 | export const setTodos = todos => ({ 22 | type: SET_TODOS, 23 | payload: todos, 24 | }); 25 | 26 | export const putTodo = todo => ({ 27 | type: PUT_TODO, 28 | payload: todo, 29 | }); -------------------------------------------------------------------------------- /src/application/actions/ui.js: -------------------------------------------------------------------------------- 1 | export const PAGE_LOADED = '[ui] page loaded'; 2 | export const SET_LOADING_ON = '[ui] set loading on'; 3 | export const SET_LOADING_OFF = '[ui] set loading off'; 4 | 5 | export const pageLoaded = { 6 | type: PAGE_LOADED 7 | }; 8 | 9 | export const setLoading = isLoading => ({ 10 | type: isLoading ? SET_LOADING_ON : SET_LOADING_OFF, 11 | payload: isLoading, 12 | }); -------------------------------------------------------------------------------- /src/application/middleware/index.js: -------------------------------------------------------------------------------- 1 | import ui from './ui'; 2 | import todos from './todos'; 3 | 4 | export default [ 5 | ...ui, 6 | ...todos, 7 | ] -------------------------------------------------------------------------------- /src/application/middleware/todos.js: -------------------------------------------------------------------------------- 1 | import { loadTodosFailure, loadTodosSuccess, LOAD_TODOS, PUT_TODO, setTodos } from "../actions/todos"; 2 | import * as uiActions from '../actions/ui'; 3 | 4 | const loadTodosFlow = ({ api }) => ({ dispatch }) => next => async (action) => { 5 | next(action); 6 | 7 | if (action.type === LOAD_TODOS) { 8 | try { 9 | dispatch(uiActions.setLoading(true)); 10 | const todos = await api.todos.getAll(); 11 | dispatch(loadTodosSuccess(todos)); 12 | dispatch(uiActions.setLoading(false)); 13 | } catch (error) { 14 | dispatch(loadTodosFailure(error)); 15 | } 16 | } 17 | } 18 | 19 | const putTodoFlow = () => ({ dispatch, getState }) => next => action => { 20 | 21 | if (action.type === PUT_TODO) { 22 | const oldTodosClone = getState().todos.allTodos.map(todo => ({...todo})); 23 | 24 | const newTodo = action.payload; 25 | 26 | const index = oldTodosClone.findIndex(todo => todo.id === newTodo.id); 27 | 28 | oldTodosClone[index] = newTodo; 29 | 30 | dispatch(setTodos(oldTodosClone)); 31 | } 32 | 33 | next(action); 34 | } 35 | 36 | export default [ 37 | loadTodosFlow, 38 | putTodoFlow, 39 | ] -------------------------------------------------------------------------------- /src/application/middleware/todos.test.js: -------------------------------------------------------------------------------- 1 | import { loadTodosSuccess, LOAD_TODOS } from '../actions/todos'; 2 | import todosMiddleware from './todos'; 3 | 4 | describe('todos middleware', () => { 5 | describe('load todos flow', () => { 6 | const [ loadTodosFlow ] = todosMiddleware; 7 | 8 | const dummyTodo = { 9 | id: '1', 10 | title: 'Dummy todo', 11 | completed: true, 12 | }; 13 | const api = { 14 | todos: { 15 | getAll: jest.fn().mockImplementationOnce(() => new Promise((resolve) => resolve([dummyTodo]))) 16 | } 17 | } 18 | const dispatch = jest.fn(); 19 | const next = jest.fn(); 20 | const action = { 21 | type: LOAD_TODOS 22 | } 23 | 24 | 25 | it('passes action to next middleware', async () => { 26 | await loadTodosFlow({ api })({ dispatch })(next)(action); 27 | 28 | expect(next).toHaveBeenCalledWith(action); 29 | }); 30 | 31 | it('calls api.todos.getAll at least once', async () => { 32 | await loadTodosFlow({ api })({ dispatch })(next)(action); 33 | 34 | expect(api.todos.getAll).toHaveBeenCalled(); 35 | }); 36 | 37 | it('calls api.todos.getAll at least once', async () => { 38 | await loadTodosFlow({ api })({ dispatch })(next)(action); 39 | 40 | expect(dispatch).toHaveBeenCalledWith(loadTodosSuccess([dummyTodo])); 41 | }); 42 | }); 43 | }); -------------------------------------------------------------------------------- /src/application/middleware/ui.js: -------------------------------------------------------------------------------- 1 | import { PAGE_LOADED } from "../actions/ui"; 2 | import * as todosActions from '../actions/todos'; 3 | 4 | const pageLoadedFlow = ({ log }) => ({ dispatch }) => next => action => { 5 | next(action); 6 | 7 | if (action.type === PAGE_LOADED) { 8 | log('page loaded'); 9 | dispatch(todosActions.loadTodos); 10 | } 11 | } 12 | 13 | export default [ 14 | pageLoadedFlow 15 | ] -------------------------------------------------------------------------------- /src/application/middleware/ui.test.js: -------------------------------------------------------------------------------- 1 | import { PAGE_LOADED } from '../actions/ui'; 2 | import uiMiddleware from './ui'; 3 | 4 | describe('ui middleware', () => { 5 | describe('page loaded flow', () => { 6 | const [ pageLoadedFlow ] = uiMiddleware; 7 | 8 | const log = jest.fn(); 9 | const dispatch = jest.fn(); 10 | const next = jest.fn(); 11 | const action = { 12 | type: PAGE_LOADED 13 | } 14 | 15 | it('passes action to next middleware', () => { 16 | pageLoadedFlow({ log })({ dispatch })(next)(action); 17 | 18 | expect(next).toHaveBeenCalledWith(action); 19 | }); 20 | 21 | it('calls log with correct argument', () => { 22 | pageLoadedFlow({ log })({ dispatch })(next)(action); 23 | 24 | expect(log).toHaveBeenCalledWith('page loaded'); 25 | }); 26 | }); 27 | }) 28 | -------------------------------------------------------------------------------- /src/application/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import ui from './ui'; 3 | import todos from './todos'; 4 | 5 | export default combineReducers({ 6 | ui, 7 | todos, 8 | }) -------------------------------------------------------------------------------- /src/application/reducers/todos.js: -------------------------------------------------------------------------------- 1 | import { LOAD_TODOS_SUCCESS, SET_TODOS } from "../actions/todos"; 2 | 3 | const initialState = { 4 | allTodos: [], 5 | error: null 6 | }; 7 | 8 | const reducer = (state = initialState, action) => { 9 | switch (action.type) { 10 | case LOAD_TODOS_SUCCESS: 11 | return { allTodos: action.payload, error: null }; 12 | case SET_TODOS: 13 | return { allTodos: action.payload, error: null }; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | export default reducer; -------------------------------------------------------------------------------- /src/application/reducers/ui.js: -------------------------------------------------------------------------------- 1 | import * as uiActions from '../actions/ui'; 2 | 3 | const initialState = { 4 | loading: true 5 | } 6 | 7 | export default (state = initialState, action) => { 8 | switch (action.type) { 9 | case (uiActions.SET_LOADING_ON): 10 | case (uiActions.SET_LOADING_OFF): 11 | return { ...state, loading: action.payload }; 12 | default: 13 | return state; 14 | } 15 | } -------------------------------------------------------------------------------- /src/application/selectors/todos.js: -------------------------------------------------------------------------------- 1 | export const getTodos = state => state.todos.allTodos; -------------------------------------------------------------------------------- /src/application/selectors/ui.js: -------------------------------------------------------------------------------- 1 | export const getLoading = state => state.ui.loading; -------------------------------------------------------------------------------- /src/application/store.js: -------------------------------------------------------------------------------- 1 | import { compose, applyMiddleware, createStore } from "redux"; 2 | import reducers from './reducers'; 3 | import middleware from './middleware'; 4 | 5 | // dev tool 6 | const composeEnhancers = 7 | (process.env.NODE_ENV === 'development' && 8 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || 9 | compose; 10 | 11 | 12 | export const configureStore = (services) => createStore( 13 | reducers, 14 | composeEnhancers(applyMiddleware(...middleware.map(f => f(services)))) 15 | ); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { configureStore } from './application/store'; 5 | import services from './infrastructure/services'; 6 | import App from './views'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); -------------------------------------------------------------------------------- /src/infrastructure/services/api/index.js: -------------------------------------------------------------------------------- 1 | import todos from './todos'; 2 | 3 | export default { 4 | todos, 5 | }; -------------------------------------------------------------------------------- /src/infrastructure/services/api/todos/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default { 4 | getAll: async () => { 5 | const response = await axios.get('https://jsonplaceholder.typicode.com/todos'); 6 | 7 | return response.data 8 | } 9 | } -------------------------------------------------------------------------------- /src/infrastructure/services/index.js: -------------------------------------------------------------------------------- 1 | import consoleLogger from './logger/console'; 2 | import elasticSearchLogger from './logger/elastic-search'; 3 | import api from './api'; 4 | 5 | const env = 'production'; /* = process.NODE_ENV */ 6 | 7 | const services = { 8 | log: env === 'development' ? consoleLogger : elasticSearchLogger, 9 | api, 10 | } 11 | 12 | export default services; -------------------------------------------------------------------------------- /src/infrastructure/services/logger/console.js: -------------------------------------------------------------------------------- 1 | const log = console.log; 2 | 3 | export default log; -------------------------------------------------------------------------------- /src/infrastructure/services/logger/elastic-search.js: -------------------------------------------------------------------------------- 1 | const log = message => { 2 | console.log('Sending to Elastic Search: ', message); 3 | } 4 | 5 | export default log; -------------------------------------------------------------------------------- /src/views/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { getTodos } from '../application/selectors/todos'; 4 | import { pageLoaded } from '../application/actions/ui'; 5 | import { putTodo } from '../application/actions/todos'; 6 | import { getLoading } from '../application/selectors/ui'; 7 | 8 | export default () => { 9 | const dispatch = useDispatch(); 10 | const todos = useSelector(getTodos); 11 | const loading = useSelector(getLoading); 12 | useEffect(() => { 13 | dispatch(pageLoaded); 14 | }, [dispatch]); 15 | return ( 16 | <> 17 |

Essential Todos

18 | {loading ? 'Loading todos...' : ( 19 | 33 | )} 34 | 35 | ) 36 | } --------------------------------------------------------------------------------