├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── components ├── Root │ └── index.js └── Users │ └── index.js ├── context ├── __tests__ │ ├── actionRegistry.test.js │ ├── index.test.js │ └── serviceRegistry.test.js ├── actionRegistry.js ├── index.js └── serviceRegistry.js ├── index.js ├── modules ├── __tests__ │ └── extract.test.js ├── auth │ ├── __tests__ │ │ ├── actions.test.js │ │ └── reducer.test.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ └── types.js ├── extract.js ├── index.js └── user │ ├── __tests__ │ ├── actions.test.js │ └── reducer.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ └── types.js ├── services ├── AuthService │ ├── index.js │ └── middleware.js ├── HttpClient │ └── index.js ├── SessionStorage │ └── index.js ├── UserService │ ├── index.js │ └── model.js ├── UserStorage │ └── index.js └── index.js └── store.js /.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 | # Redux with services 2 | 3 | This is an architecture built ontop of the default redux data flow with an additional layer of business logic. It brings a layer to place your services at and increases testability of redux thunks/reducers **up to 100%**. 4 | 5 | ## Intent 6 | Redux does not give us a defined place to store business logic. This architecture brings a new layer for that purpose and increases testability of redux pieces (actions, reducers) by introducting dependency injection. 7 | 8 | ## Idea 9 | The business logic layer is presented by services. It's kind of similar to what we have in Angular. It's the place where components and redux actions move the logic to, in order to focus on their main purpose. 10 | 11 | ## Architecture 12 | It consists of three main concepts: modules, services and context. Let's go through all of them to get a better understanding. 13 | 14 | ### Modules 15 | Modules are a gathering of redux-related stuff. A module consists of two main pieces: actions and reducers. Each module is a result of a function, which brings a posibility to inject services into them. Modules can depend on other modules as well. 16 | 17 | There's a module entry point. 18 | ```javascript 19 | // src/modules/module/index.js 20 | import initActions from './actions'; 21 | import initReducer from './reducer'; 22 | import types from './types'; 23 | 24 | const configureAuthModule = (services) => { 25 | const actions = initActions(types, { 26 | auth: services.authService, 27 | }); 28 | 29 | const reducer = initReducer(types, { 30 | auth: services.authService, 31 | }); 32 | 33 | return { actions, reducer, types }; 34 | }; 35 | 36 | export default configureAuthModule; 37 | ``` 38 | This is a function where services get injected into actions and reducers. You can see that types are injected too. Such injection makes it easier to test actions/reducers after all as it's possible to mock everything. 39 | 40 | Actions are returned from the *initActions* function here: 41 | ```javascript 42 | // src/modules/module/actions.js 43 | const initActions = (types, services) => { 44 | // make use of services here 45 | 46 | return Object.freeze({ 47 | // include all public action creators here 48 | }); 49 | }; 50 | ``` 51 | Reducer is basically the same: 52 | ```javascript 53 | // src/modules/module/reducer.js 54 | const initReducer = (types, services) => { 55 | const INITIAL_STATE = { 56 | // may make use of services 57 | }; 58 | 59 | const reducer = (state = INITIAL_STATE, action) => { 60 | // switch over types here 61 | }; 62 | 63 | return reducer; 64 | }; 65 | 66 | export default initReducer; 67 | ``` 68 | 69 | Injection of types allows to merge the module's types with some other module's types. It's a pretty common situation when modules depend on each other. 70 | 71 | As you see it's almost the same redux you are used to. The only difference is -> actions and reducers are returned from functions. 72 | 73 | ### Services 74 | It's completely up to you when it comes to organizing services. The only thing to consider here is that you have to provide a function that returns service instances that are going to be injected in modules after all. 75 | 76 | ```javascript 77 | // src/services/index.js 78 | const configureServices = async () => { 79 | const userService = UserService(); 80 | const authService = AuthService(); 81 | 82 | return { userService, authService }; 83 | }; 84 | ``` 85 | That is what services entry point looks like. Services get initialized here and then get returned for the further usage. 86 | 87 | ### Context 88 | Context is a place where all globally available objects can be access from all over the application. Services and actions get registered to context so components can access them as they need them. 89 | 90 | Context consists of two (or more) registries. One for actions and one for services. Here's what context entry point looks like: 91 | ```javascript 92 | // src/context/index.js 93 | import actions, { registerActions } from './actionRegistry'; 94 | import services, { registerServices } from './serviceRegistry'; 95 | 96 | export { 97 | actions, services, 98 | }; 99 | 100 | export default { 101 | actions, 102 | registerActions, 103 | services, 104 | registerServices, 105 | }; 106 | ``` 107 | 108 | I used a third-party package for the registry implementation. 109 | ```javascript 110 | // src/context/actionRegistry.js 111 | import createRegistry from 'mag-service-registry'; 112 | const registry = createRegistry(); 113 | export const registerActions = registry.register; 114 | export default registry.exposeRegistered(); 115 | ``` 116 | 117 | It's pretty much the same code for services as well. 118 | 119 | Context allows components to access actions like this: 120 | ```javascript 121 | // src/components/TodoList/index.js 122 | import { actions } from '../../context'; 123 | ... 124 | class TodoList extends Component { 125 | ... 126 | } 127 | ... 128 | export default connect(..., { fetchTodos: actions.todos.fetch })(TodoList); 129 | ``` 130 | 131 | This is how services and actions get registered into context. It happens in application's entry point. 132 | 133 | ```javascript 134 | // src/index.js 135 | 136 | import context from './context'; 137 | import configureServices from './services'; 138 | import configureModules from './modules'; 139 | ... 140 | (async function init() { 141 | const services = await configureServices(); 142 | // inject services into modules here 143 | const { actions } = await configureModules(services); 144 | 145 | context.registerServices(services); 146 | context.registerActions(actions); 147 | ... 148 | })(); 149 | ``` 150 | 151 | ## Delaying DOM rendering 152 | There is something else you need to understand. Since services are configured asynchronously we have to wait until they're done before we can render anything. This is just to prevent components from using services that aren't ready yet. It's not enough just to delay the *ReactDOM.render* call. We should delay the import of the Root component. Dynamic imports is the solution here. 153 | 154 | ```javascript 155 | const loadRoot = async () => { 156 | const module = await import('./components/Root'); 157 | return module.default; 158 | }; 159 | 160 | const render = async () => { 161 | const target = document.getElementById('root'); 162 | const Root = await loadRoot(); 163 | 164 | ReactDOM.render(, target); 165 | }; 166 | 167 | (async function init() { 168 | ... 169 | render(); 170 | })(); 171 | ``` 172 | 173 | That is how I integrated services into redux flow. I am grateful for any feedback. You are welcome to contribute! 174 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arch", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "action-creator-redux": "^1.0.6", 7 | "axios": "^0.18.0", 8 | "babel-polyfill": "^6.26.0", 9 | "mag-service-registry": "^3.0.5", 10 | "prop-types": "^15.6.2", 11 | "react": "^16.6.1", 12 | "react-dom": "^16.6.1", 13 | "react-redux": "^5.1.1", 14 | "react-scripts": "2.1.1", 15 | "redux": "^4.0.1", 16 | "redux-thunk": "^2.3.0", 17 | "uniqid": "^5.0.3", 18 | "update-by-path": "^1.1.9" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "test:w": "react-scripts test --watch", 25 | "test:cov": "react-scripts test --coverage", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": [ 32 | ">0.2%", 33 | "not dead", 34 | "not ie <= 11", 35 | "not op_mini all" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dprovodnikov/complex-redux-project-architecture/f20c80752beb76fa75e414cd80c3fad999164cea/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /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 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Root/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import Users from '../Users'; 4 | 5 | const Root = (props) => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Root; 14 | -------------------------------------------------------------------------------- /src/components/Users/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { actions } from '../../context'; 4 | 5 | class Users extends Component { 6 | constructor() { 7 | super(); 8 | 9 | this.state = { 10 | name: '', 11 | }; 12 | 13 | this.fetchUsers = this.fetchUsers.bind(this); 14 | this.renderUser = this.renderUser.bind(this); 15 | this.handleInput = this.handleInput.bind(this); 16 | this.handleAddClick = this.handleAddClick.bind(this); 17 | } 18 | 19 | componentDidMount() { 20 | this.fetchUsers(); 21 | } 22 | 23 | fetchUsers() { 24 | this.props.fetchUsers(); 25 | } 26 | 27 | handleInput(event) { 28 | const { name, value } = event.target; 29 | 30 | this.setState({ [name]: value }); 31 | } 32 | 33 | handleAddClick() { 34 | const { name } = this.state; 35 | 36 | this.props.createUser(name); 37 | 38 | this.resetName(); 39 | } 40 | 41 | resetName() { 42 | this.setState({ name: '' }); 43 | } 44 | 45 | removeUser(user) { 46 | this.props.removeUser(user); 47 | } 48 | 49 | renderUser(user) { 50 | return ( 51 | // eslint-disable-next-line 52 | this.removeUser(user)}> 53 |
  • 54 | {user.name} 55 |
  • 56 |
    57 | ); 58 | } 59 | 60 | render() { 61 | const { users } = this.props; 62 | const { name } = this.state; 63 | 64 | return ( 65 |
    66 | {!users.length && 67 |
    No users found
    68 | } 69 | 70 | 73 | 74 | 80 | 81 | 84 |
    85 | ); 86 | } 87 | } 88 | 89 | const mapStateToProps = (state) => { 90 | return { 91 | users: state.user.list, 92 | }; 93 | }; 94 | 95 | const mapActionsToProps = { 96 | fetchUsers: actions.user.fetchUsers, 97 | createUser: actions.user.createUser, 98 | removeUser: actions.user.removeUser, 99 | }; 100 | 101 | export default connect(mapStateToProps, mapActionsToProps)(Users); 102 | -------------------------------------------------------------------------------- /src/context/__tests__/actionRegistry.test.js: -------------------------------------------------------------------------------- 1 | import actions, { registerActions } from '../actionRegistry'; 2 | 3 | describe('action registry test suit', () => { 4 | test('registerActions is a function', () => { 5 | expect(registerActions).toBeInstanceOf(Function); 6 | }); 7 | 8 | test('actions is an object', () => { 9 | expect(actions).toBeInstanceOf(Object); 10 | }); 11 | 12 | test('registered actions are available on the "actions" object', () => { 13 | const input = { do: () => {} }; 14 | registerActions(input); 15 | expect(actions.do).toBe(input.do); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/context/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import context, { actions, services } from '..'; 2 | 3 | describe('context entry point test suit', () => { 4 | test('exports actions as a named export', () => { 5 | expect(actions).toBeInstanceOf(Object); 6 | }); 7 | 8 | test('exports services as a named export', () => { 9 | expect(services).toBeInstanceOf(Object); 10 | }); 11 | 12 | test('exports context as default', () => { 13 | expect(context).toBeInstanceOf(Object); 14 | }); 15 | 16 | test('context contains "registerActions" method', () => { 17 | expect(context.registerActions).toBeInstanceOf(Function); 18 | }); 19 | 20 | test('context contains "registerServices" method', () => { 21 | expect(context.registerServices).toBeInstanceOf(Function); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/context/__tests__/serviceRegistry.test.js: -------------------------------------------------------------------------------- 1 | import services, { registerServices } from '../serviceRegistry'; 2 | 3 | describe('service registry test suit', () => { 4 | test('registerServices is a function', () => { 5 | expect(registerServices).toBeInstanceOf(Function); 6 | }); 7 | 8 | test('services is an object', () => { 9 | expect(services).toBeInstanceOf(Object); 10 | }); 11 | 12 | test('registered services are available on the "services" object', () => { 13 | const input = { service: () => {} }; 14 | registerServices(input); 15 | expect(services.service).toBe(input.service); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/context/actionRegistry.js: -------------------------------------------------------------------------------- 1 | import createRegistry from 'mag-service-registry'; 2 | 3 | const registry = createRegistry(); 4 | 5 | export const registerActions = registry.register; 6 | 7 | export default registry.exposeRegistered(); -------------------------------------------------------------------------------- /src/context/index.js: -------------------------------------------------------------------------------- 1 | import actions, { registerActions } from './actionRegistry'; 2 | import services, { registerServices } from './serviceRegistry'; 3 | 4 | export { 5 | actions, 6 | services, 7 | }; 8 | 9 | export default { 10 | actions, 11 | registerActions, 12 | services, 13 | registerServices, 14 | }; 15 | -------------------------------------------------------------------------------- /src/context/serviceRegistry.js: -------------------------------------------------------------------------------- 1 | import createRegistry from 'mag-service-registry'; 2 | 3 | const registry = createRegistry(); 4 | 5 | export const registerServices = registry.register; 6 | 7 | export default registry.exposeRegistered(); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import configureModules from './modules'; 4 | import configureServices from './services'; 5 | import configureStore from './store'; 6 | import context from './context'; 7 | 8 | const loadRoot = async () => { 9 | const module = await import('./components/Root'); 10 | return module.default; 11 | }; 12 | 13 | const render = async (store) => { 14 | const target = document.getElementById('root'); 15 | const Root = await loadRoot(); 16 | 17 | ReactDOM.render(, target); 18 | }; 19 | 20 | (async function init() { 21 | const services = await configureServices(); 22 | const { actions, reducers } = await configureModules(services); 23 | 24 | context.registerServices(services); 25 | context.registerActions(actions); 26 | 27 | render(configureStore(reducers)); 28 | })(); 29 | -------------------------------------------------------------------------------- /src/modules/__tests__/extract.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | extract, 3 | extractActions, 4 | extractReducers, 5 | ACTIONS_KEY, 6 | REDUCER_KEY, 7 | } from '../extract'; 8 | 9 | describe('extract test suit', () => { 10 | test('is a function', () => { 11 | expect(extract).toBeInstanceOf(Function); 12 | }); 13 | 14 | test('returns null when modules are missing', () => { 15 | expect(extract()).toBe(null); 16 | }); 17 | 18 | test('returns null when extracting key is missing', () => { 19 | expect(extract({})).toBe(null); 20 | }); 21 | 22 | test('returns null if the first arg is not an object', () => { 23 | expect(extract('')).toBe(null); 24 | }); 25 | 26 | test('returns null if key is not a string', () => { 27 | expect(extract({}, 0)).toBe(null); 28 | }); 29 | 30 | test('extracts properly by key', () => { 31 | const input = { 32 | first: { actions: {} }, 33 | second: { actions: {} }, 34 | }; 35 | 36 | const expectedOutput = { 37 | first: input.first.actions, 38 | second: input.second.actions, 39 | }; 40 | 41 | const output = extract(input, 'actions'); 42 | 43 | expect(output).toEqual(expectedOutput); 44 | }); 45 | }); 46 | 47 | describe('extractActions test suit', () => { 48 | test('is a function', () => { 49 | expect(extractActions).toBeInstanceOf(Function); 50 | }); 51 | 52 | test('extracts actions properly', () => { 53 | const input = { 54 | first: { [ACTIONS_KEY]: {} }, 55 | second: { [ACTIONS_KEY]: {} }, 56 | }; 57 | 58 | const expectedOutput = { 59 | first: input.first[ACTIONS_KEY], 60 | second: input.second[ACTIONS_KEY], 61 | }; 62 | 63 | const output = extractActions(input); 64 | 65 | expect(output).toEqual(expectedOutput); 66 | }) 67 | 68 | test('extracts actions as empty objects i', () => { 69 | const input = { 70 | first: {}, 71 | second: {}, 72 | }; 73 | 74 | expect(extractActions(input)).toEqual({}); 75 | }) 76 | }); 77 | 78 | describe('extractReducers test suit', () => { 79 | test('is a function', () => { 80 | expect(extractActions).toBeInstanceOf(Function); 81 | }); 82 | 83 | test('extracts reducers properly', () => { 84 | const input = { 85 | first: { [REDUCER_KEY]: {} }, 86 | second: { [REDUCER_KEY]: {} }, 87 | }; 88 | 89 | const expectedOutput = { 90 | first: input.first[REDUCER_KEY], 91 | second: input.second[REDUCER_KEY], 92 | }; 93 | 94 | expect(extractReducers(input)).toEqual(expectedOutput); 95 | }) 96 | 97 | test('extracts actions as empty objects i', () => { 98 | const input = { 99 | first: {}, 100 | second: {}, 101 | }; 102 | 103 | expect(extractReducers(input)).toEqual({}); 104 | }) 105 | }); -------------------------------------------------------------------------------- /src/modules/auth/__tests__/actions.test.js: -------------------------------------------------------------------------------- 1 | import initActions from '../actions'; 2 | import types from '../types'; 3 | 4 | describe('auth actions test suit', () => { 5 | let actions; 6 | let services; 7 | let dispatch; 8 | 9 | beforeEach(() => { 10 | dispatch = jest.fn(); 11 | services = { 12 | auth: { 13 | login: jest.fn(() => Promise.resolve()), 14 | }, 15 | }; 16 | actions = initActions(types, services); 17 | }); 18 | 19 | test('initActions is a function', () => { 20 | expect(initActions).toBeInstanceOf(Function); 21 | }); 22 | 23 | test('initActions returns an object', () => { 24 | expect(actions).toBeInstanceOf(Object); 25 | }); 26 | 27 | test('actions contain "login" action', () => { 28 | expect(actions.login).toBeInstanceOf(Function); 29 | }); 30 | 31 | test('"login" action invokes auth.login service method', () => { 32 | let email = 'email'; 33 | actions.login(email)(dispatch); 34 | expect(services.auth.login).toHaveBeenCalledWith(email); 35 | }); 36 | 37 | test('"login" action dispatches login attempt', () => { 38 | actions.login()(dispatch); 39 | expect(dispatch).toHaveBeenCalledWith({ type: types.LOGIN_ATTEMPT }); 40 | }); 41 | 42 | test('"login" action dispatches login failure', (done) => { 43 | actions = initActions(types, { 44 | auth: { 45 | login: jest.fn(() => Promise.reject()), 46 | }, 47 | }); 48 | 49 | actions.login()(dispatch).then(() => { 50 | expect(dispatch).toHaveBeenCalledWith({ type: types.LOGIN_FAILURE }); 51 | done(); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/modules/auth/__tests__/reducer.test.js: -------------------------------------------------------------------------------- 1 | import initReducer from '../reducer'; 2 | import types from '../types'; 3 | 4 | describe('auth reducer test suit', () => { 5 | let services; 6 | let reducer; 7 | 8 | beforeEach(() => { 9 | services = { 10 | auth: { 11 | isAuthenticated: jest.fn(() => false), 12 | }, 13 | }; 14 | reducer = initReducer(types, services); 15 | }); 16 | 17 | test('initReducer is a function', () => { 18 | expect(initReducer).toBeInstanceOf(Function); 19 | }); 20 | 21 | test('reducer is a function', () => { 22 | expect(reducer).toBeInstanceOf(Function); 23 | }); 24 | 25 | test('returns an object by default', () => { 26 | const state = reducer(undefined, {}); 27 | expect(state).toBeInstanceOf(Object); 28 | }); 29 | 30 | test('uses auth service to compose initial state', () => { 31 | const state = reducer(undefined, {}); 32 | const expectedState = { 33 | isAuthenticated: services.auth.isAuthenticated(), 34 | isAuthenticating: false, 35 | error: null, 36 | }; 37 | expect(state).toEqual(expectedState); 38 | }); 39 | 40 | test('turns on progress indicator on login attempt', () => { 41 | const action = { type: types.LOGIN_ATTEMPT }; 42 | const state = reducer(undefined, action); 43 | const expectedState = { 44 | isAuthenticated: false, 45 | isAuthenticating: true, 46 | error: null, 47 | }; 48 | expect(state).toEqual(expectedState); 49 | }); 50 | 51 | test('turns off progress indicator and changes auth state to true', () => { 52 | const action = { type: types.LOGIN_SUCCESS }; 53 | const state = reducer(undefined, action); 54 | const expectedState = { 55 | isAuthenticated: true, 56 | isAuthenticating: false, 57 | error: null, 58 | }; 59 | expect(state).toEqual(expectedState); 60 | }); 61 | 62 | test('turns off progress indicator and sets error to payload', () => { 63 | const err = {}; 64 | const action = { type: types.LOGIN_FAILURE, payload: err }; 65 | const state = reducer(undefined, action); 66 | const expectedState = { 67 | isAuthenticated: false, 68 | isAuthenticating: false, 69 | error: err, 70 | }; 71 | expect(state).toEqual(expectedState); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/modules/auth/actions.js: -------------------------------------------------------------------------------- 1 | import actionCreator from 'action-creator-redux'; 2 | 3 | const initActions = (types, services) => { 4 | const loginAttempt = actionCreator(types.LOGIN_ATTEMPT); 5 | const loginSuccess = actionCreator(types.LOGIN_SUCCESS); 6 | const loginFailure = actionCreator(types.LOGIN_FAILURE); 7 | 8 | const login = email => async (dispatch) => { 9 | dispatch(loginAttempt()); 10 | 11 | try { 12 | await services.auth.login(email); 13 | dispatch(loginSuccess()); 14 | } catch (err) { 15 | dispatch(loginFailure(err)); 16 | } 17 | }; 18 | 19 | return Object.freeze({ 20 | login, 21 | }); 22 | }; 23 | 24 | export default initActions; 25 | -------------------------------------------------------------------------------- /src/modules/auth/index.js: -------------------------------------------------------------------------------- 1 | import initActions from './actions'; 2 | import initReducer from './reducer'; 3 | import types from './types'; 4 | 5 | const configureAuthModule = (services) => { 6 | const actions = initActions(types, { 7 | auth: services.authService, 8 | }); 9 | 10 | const reducer = initReducer(types, { 11 | auth: services.authService, 12 | }); 13 | 14 | return { actions, reducer, types }; 15 | }; 16 | 17 | export default configureAuthModule; 18 | -------------------------------------------------------------------------------- /src/modules/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import update from 'update-by-path'; 2 | 3 | const initReducer = (types, services) => { 4 | const INITIAL_STATE = { 5 | isAuthenticated: services.auth.isAuthenticated(), 6 | isAuthenticating: false, 7 | error: null, 8 | }; 9 | 10 | const reducer = (state = INITIAL_STATE, action) => { 11 | const { type, payload } = action; 12 | 13 | switch (type) { 14 | case types.LOGIN_ATTEMPT: 15 | return update(state, { isAuthenticating: true }); 16 | case types.LOGIN_SUCCESS: 17 | return update(state, { 18 | isAuthenticating: false, 19 | isAuthenticated: true, 20 | }); 21 | case types.LOGIN_FAILURE: 22 | return update(state, { 23 | isAuthenticating: false, 24 | error: payload, 25 | }); 26 | default: 27 | return state; 28 | } 29 | }; 30 | 31 | return reducer; 32 | }; 33 | 34 | export default initReducer; 35 | 36 | -------------------------------------------------------------------------------- /src/modules/auth/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | LOGIN_ATTEMPT: 'LOGIN_ATTEMPT', 3 | LOGIN_SUCCESS: 'LOGIN_SUCCESS', 4 | LOGIN_FAILURE: 'LOGIN_FAILURE', 5 | LOGOUT_SUCCESS: 'LOGOUT_SUCCESS', 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/extract.js: -------------------------------------------------------------------------------- 1 | export const ACTIONS_KEY = 'actions'; 2 | export const REDUCER_KEY = 'reducer'; 3 | 4 | export const validateModules = (modules) => { 5 | return modules instanceof Object; 6 | }; 7 | 8 | export const validateKey = (key) => { 9 | return typeof key === 'string'; 10 | }; 11 | 12 | export const extract = (modules, key) => { 13 | if (!validateModules(modules) || !validateKey(key)) { 14 | return null; 15 | } 16 | 17 | return Object.entries(modules) 18 | .filter(entry => !!entry[1][key]) 19 | .map((entry) => { 20 | const [moduleName, module] = entry; 21 | 22 | return { [moduleName]: module[key] }; 23 | }) 24 | .reduce((output, entry) => { 25 | return ({ ...output, ...entry }); 26 | }, {}); 27 | }; 28 | 29 | export const extractActions = modules => extract(modules, ACTIONS_KEY); 30 | export const extractReducers = modules => extract(modules, REDUCER_KEY); 31 | -------------------------------------------------------------------------------- /src/modules/index.js: -------------------------------------------------------------------------------- 1 | import configureUserModule from './user'; 2 | import configureAuthModule from './auth'; 3 | import { extractActions, extractReducers } from './extract'; 4 | 5 | const configureModules = async (services) => { 6 | const authModule = configureAuthModule(services); 7 | const userModule = configureUserModule(services, { auth: authModule }); 8 | 9 | const modules = { 10 | user: userModule, 11 | auth: authModule, 12 | }; 13 | 14 | return { 15 | actions: extractActions(modules), 16 | reducers: extractReducers(modules), 17 | }; 18 | }; 19 | 20 | export default configureModules; 21 | -------------------------------------------------------------------------------- /src/modules/user/__tests__/actions.test.js: -------------------------------------------------------------------------------- 1 | import initActions from '../actions'; 2 | import types from '../types'; 3 | 4 | describe('user actions test suit', () => { 5 | let services; 6 | let actions; 7 | let dispatch; 8 | 9 | beforeEach(() => { 10 | services = { 11 | user: { 12 | getUsers: jest.fn(() => Promise.resolve()), 13 | createUser: jest.fn(name => Promise.resolve({ id: 1, name })), 14 | removeUser:jest.fn(() => Promise.resolve()), 15 | }, 16 | }; 17 | actions = initActions(types, services); 18 | dispatch = jest.fn(); 19 | }); 20 | 21 | test('initActions is a function', () => { 22 | expect(initActions).toBeInstanceOf(Function); 23 | }); 24 | 25 | test('actions is an object', () => { 26 | expect(actions).toBeInstanceOf(Object); 27 | }); 28 | 29 | test('actions to contain fetchUsers action', () => { 30 | expect(actions.fetchUsers).toBeInstanceOf(Function); 31 | }); 32 | 33 | test('actions to contain createUser action', () => { 34 | expect(actions.createUser).toBeInstanceOf(Function); 35 | }); 36 | 37 | test('actions to contain removeUser action', () => { 38 | expect(actions.removeUser).toBeInstanceOf(Function); 39 | }); 40 | 41 | test('fetchUsers action invokes getUsers service method', () => { 42 | actions.fetchUsers()(dispatch); 43 | expect(services.user.getUsers).toHaveBeenCalled(); 44 | }); 45 | 46 | test('fetchUsers action dispatches fetch success', (done) => { 47 | actions.fetchUsers()(dispatch).then(() => { 48 | expect(dispatch).toHaveBeenCalledWith({ type: types.FETCH_USERS_SUCCESS }); 49 | done(); 50 | }); 51 | }); 52 | 53 | test('createUser action invokes createUser service method', () => { 54 | const name = 'John Doe'; 55 | 56 | actions.createUser(name)(dispatch); 57 | expect(services.user.createUser).toHaveBeenCalledWith(name); 58 | }); 59 | 60 | test('createUser action dispatches create success', (done) => { 61 | const name = 'John Doe'; 62 | 63 | actions.createUser(name)(dispatch).then(() => { 64 | const payload = { id: 1, name }; 65 | const type = types.CREATE_USER_SUCCESS; 66 | 67 | expect(dispatch).toHaveBeenCalledWith({ type, payload }); 68 | done(); 69 | }); 70 | }); 71 | 72 | test('removeUser action invokes removeUser service method', () => { 73 | const user = { id: 1 }; 74 | 75 | actions.removeUser(user)(dispatch); 76 | expect(services.user.removeUser).toHaveBeenCalledWith(user); 77 | }); 78 | 79 | test('removeUser actions dispatches remove success', (done) => { 80 | const user = { id: 1 }; 81 | 82 | actions.removeUser(user)(dispatch).then(() => { 83 | const payload = user.id; 84 | const type = types.REMOVE_USER_SUCCESS; 85 | 86 | expect(dispatch).toHaveBeenCalledWith({ type, payload }); 87 | done(); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/modules/user/__tests__/reducer.js: -------------------------------------------------------------------------------- 1 | import initReducer from '../reducer'; 2 | import userTypes from '../types'; 3 | import authTypes from '../../auth/types'; 4 | 5 | describe('user reducer test suit', () => { 6 | const types = { 7 | ...userTypes, 8 | LOGOUT_SUCCESS: authTypes.LOGOUT_SUCCESS, 9 | }; 10 | let reducer; 11 | 12 | beforeEach(() => { 13 | reducer = initReducer(types); 14 | }); 15 | 16 | test('initReducer is a function', () => { 17 | expect(initReducer).toBeInstanceOf(Function); 18 | }); 19 | 20 | test('initReducer returns an function', () => { 21 | expect(reducer).toBeInstanceOf(Function); 22 | }); 23 | 24 | test('produces initial state by default', () => { 25 | const state = reducer(undefined, {}); 26 | const expectedState = { list: [] }; 27 | 28 | expect(state).toEqual(expectedState); 29 | }); 30 | 31 | test('sets list to payload on fetch success', () => { 32 | const list = []; 33 | const action = { type: types.FETCH_USERS_SUCCESS, payload: list }; 34 | const state = reducer(undefined, action); 35 | const expectedState = { list }; 36 | 37 | expect(state).toEqual(expectedState); 38 | }); 39 | 40 | test('filters out the list on remove success', () => { 41 | let id = 1; 42 | const initialState = { list: [{ id }] }; 43 | const action = { 44 | type: types.REMOVE_USER_SUCCESS, 45 | payload: id, 46 | }; 47 | const expectedState = { list: [] }; 48 | 49 | expect(reducer(initialState, action)).toEqual(expectedState); 50 | }); 51 | 52 | test('appends user on create success', () => { 53 | const user = { id: 1 }; 54 | const action = { 55 | type: types.CREATE_USER_SUCCESS, 56 | payload: user, 57 | }; 58 | const expectedState = { list: [user] }; 59 | const state = reducer(undefined, action); 60 | 61 | expect(state).toEqual(expectedState); 62 | }); 63 | 64 | test('resets list on logout success', () => { 65 | const action = { type: types.LOGOUT_SUCCESS }; 66 | const initialState = { list: [{ id: 1 }] }; 67 | const expectedState = { list: [] }; 68 | const state = reducer(initialState, action); 69 | 70 | expect(state).toEqual(expectedState); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/modules/user/actions.js: -------------------------------------------------------------------------------- 1 | import actionCreator from 'action-creator-redux'; 2 | 3 | const initActions = function (types, services) { 4 | const fetchSuccess = actionCreator(types.FETCH_USERS_SUCCESS); 5 | const createSuccess = actionCreator(types.CREATE_USER_SUCCESS); 6 | const removeSuccess = actionCreator(types.REMOVE_USER_SUCCESS); 7 | 8 | const fetchUsers = () => async (dispatch) => { 9 | const users = await services.user.getUsers(); 10 | dispatch(fetchSuccess(users)); 11 | }; 12 | 13 | const createUser = name => async (dispatch) => { 14 | const user = await services.user.createUser(name); 15 | dispatch(createSuccess(user)); 16 | }; 17 | 18 | const removeUser = user => async (dispatch) => { 19 | await services.user.removeUser(user); 20 | dispatch(removeSuccess(user.id)); 21 | }; 22 | 23 | return { fetchUsers, createUser, removeUser }; 24 | }; 25 | 26 | export default initActions; 27 | -------------------------------------------------------------------------------- /src/modules/user/index.js: -------------------------------------------------------------------------------- 1 | import initActions from './actions'; 2 | import initReducer from './reducer'; 3 | import types from './types'; 4 | 5 | const configureUserModule = (services, modules) => { 6 | const actions = initActions(types, { 7 | user: services.userService, 8 | }); 9 | 10 | // merge user types with some other module's types 11 | const reducerTypes = { 12 | ...types, 13 | LOGOUT_SUCCESS: modules.auth.types.LOGOUT_SUCCESS, 14 | }; 15 | 16 | const reducer = initReducer(reducerTypes); 17 | 18 | return { actions, reducer, types }; 19 | }; 20 | 21 | export default configureUserModule; 22 | -------------------------------------------------------------------------------- /src/modules/user/reducer.js: -------------------------------------------------------------------------------- 1 | import update from 'update-by-path'; 2 | 3 | const initReducer = (types) => { 4 | const INITIAL_STATE = { 5 | list: [], 6 | }; 7 | 8 | const reducer = (state = INITIAL_STATE, action) => { 9 | const { type, payload } = action; 10 | 11 | switch (type) { 12 | case types.FETCH_USERS_SUCCESS: 13 | return update(state, { list: payload }); 14 | case types.CREATE_USER_SUCCESS: 15 | return update(state, { 16 | list: list => list.concat(payload), 17 | }); 18 | case types.REMOVE_USER_SUCCESS: 19 | return update(state, { 20 | list: list => list.filter(u => u.id !== payload), 21 | }); 22 | case types.LOGOUT_SUCCESS: 23 | return INITIAL_STATE 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | return reducer; 30 | }; 31 | 32 | export default initReducer; -------------------------------------------------------------------------------- /src/modules/user/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | FETCH_USERS_SUCCESS: 'FETCH_USERS_SUCCESS', 3 | CREATE_USER_SUCCESS: 'CREATE_USER_SUCCESS', 4 | REMOVE_USER_SUCCESS: 'REMOVE_USER_SUCCESS', 5 | }; 6 | -------------------------------------------------------------------------------- /src/services/AuthService/index.js: -------------------------------------------------------------------------------- 1 | import authSignatureMiddleware from "./middleware"; 2 | 3 | const AuthService = function (httpClient, sessionStorage) { 4 | const baseUrl = ``; 5 | 6 | const login = async (email) => { 7 | const url = `${baseUrl}/login`; 8 | const { token } = await httpClient.post(url, { email }); 9 | 10 | sessionStorage.storeToken(token); 11 | }; 12 | 13 | const getToken = () => { 14 | return sessionStorage.getToken(); 15 | }; 16 | 17 | const isAuthenticated = () => { 18 | return !!getToken(); 19 | }; 20 | 21 | httpClient.addMiddleware(authSignatureMiddleware(getToken)); 22 | 23 | return Object.freeze({ 24 | login, 25 | getToken, 26 | isAuthenticated, 27 | }); 28 | }; 29 | 30 | export default AuthService; 31 | -------------------------------------------------------------------------------- /src/services/AuthService/middleware.js: -------------------------------------------------------------------------------- 1 | import update from 'update-by-path'; 2 | 3 | const authSignatureMiddleware = getToken => async (request) => { 4 | const token = await getToken(); 5 | return update(request, 'headers.X-Auth-Token', token); 6 | }; 7 | 8 | export default authSignatureMiddleware; 9 | -------------------------------------------------------------------------------- /src/services/HttpClient/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const Http = function () { 4 | const middlewares = []; 5 | 6 | const applyMiddlewares = (initialRequest = {}) => { 7 | return middlewares.reduce((request, applyChanges) => { 8 | return request.then(applyChanges); 9 | }, Promise.resolve(initialRequest)); 10 | }; 11 | 12 | const get = async (url, request) => { 13 | const result = await applyMiddlewares(request); 14 | return axios.get(url, result); 15 | }; 16 | 17 | const post = async (url, body, request) => { 18 | const result = await applyMiddlewares(request); 19 | return axios.post(url, body, result); 20 | }; 21 | 22 | const put = async (url, body, request) => { 23 | const result = await applyMiddlewares(request); 24 | return axios.put(url, body, result); 25 | }; 26 | 27 | const _delete = async (url, request) => { 28 | const result = await applyMiddlewares(request); 29 | return axios.delete(url, result); 30 | }; 31 | 32 | const addMiddleware = (middleware) => { 33 | middlewares.push(middleware); 34 | }; 35 | 36 | return Object.freeze({ 37 | get, 38 | post, 39 | put, 40 | addMiddleware, 41 | delete: _delete, 42 | }); 43 | }; 44 | 45 | export default Http; 46 | -------------------------------------------------------------------------------- /src/services/SessionStorage/index.js: -------------------------------------------------------------------------------- 1 | const Storage = function (config) { 2 | const getToken = () => { 3 | return localStorage.getItem(config.key); 4 | } 5 | 6 | const storeToken = (token) => { 7 | return localStorage.setItem(config.key, token); 8 | } 9 | 10 | const clear = () => { 11 | return localStorage.removeItem(config.key); 12 | } 13 | 14 | return Object.freeze({ 15 | getToken, 16 | storeToken, 17 | clear, 18 | }); 19 | }; 20 | 21 | export default Storage; 22 | -------------------------------------------------------------------------------- /src/services/UserService/index.js: -------------------------------------------------------------------------------- 1 | import User from './model'; 2 | 3 | const UserService = function (storage) { 4 | const STORAGE_KEY = 'user_storage_key'; 5 | 6 | const getUsers = async () => { 7 | return storage.getList(STORAGE_KEY); 8 | }; 9 | 10 | const createUser = async (name) => { 11 | const user = User(name); 12 | 13 | storage.append(STORAGE_KEY, user); 14 | 15 | return user; 16 | }; 17 | 18 | const removeUser = async (user) => { 19 | storage.removeById(STORAGE_KEY, user.id); 20 | }; 21 | 22 | return Object.freeze({ 23 | getUsers, createUser, removeUser, 24 | }); 25 | }; 26 | 27 | export default UserService; 28 | -------------------------------------------------------------------------------- /src/services/UserService/model.js: -------------------------------------------------------------------------------- 1 | import uniqid from 'uniqid'; 2 | 3 | const User = function (name) { 4 | const id = uniqid(); 5 | 6 | return Object.freeze({ 7 | id, name, 8 | }); 9 | } 10 | 11 | export default User; 12 | -------------------------------------------------------------------------------- /src/services/UserStorage/index.js: -------------------------------------------------------------------------------- 1 | const UserStorage = function () { 2 | const getList = (key) => { 3 | const list = JSON.parse(localStorage.getItem(key)); 4 | 5 | return list || []; 6 | }; 7 | 8 | const setList = (key, list) => { 9 | if (!Array.isArray(list)) { 10 | return; 11 | } 12 | 13 | localStorage.setItem(key, JSON.stringify(list)); 14 | }; 15 | 16 | const append = (key, subList) => { 17 | const nextList = getList(key).concat(subList); 18 | 19 | setList(key, nextList); 20 | }; 21 | 22 | const removeById = (key, id) => { 23 | const nextList = getList(key).filter(u => u.id !== id); 24 | 25 | setList(key, nextList); 26 | }; 27 | 28 | const getById = (key, id) => { 29 | const unit = getList(key).find(u => u.id === id); 30 | 31 | return unit || null; 32 | }; 33 | 34 | return Object.freeze({ 35 | append, getList, setList, getById, removeById, 36 | }); 37 | }; 38 | 39 | export default UserStorage; 40 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | import UserService from './UserService'; 2 | import UserStorage from './UserStorage'; 3 | import HttpClient from './HttpClient'; 4 | import SessionStorage from './SessionStorage'; 5 | import AuthService from './AuthService'; 6 | 7 | const configureUserService = () => { 8 | const storage = UserStorage(); 9 | 10 | return UserService(storage); 11 | }; 12 | 13 | const configureAuthService = () => { 14 | const httpClient = HttpClient(); 15 | const sessionStorage = SessionStorage({ key: 'AUTH_SESSION_STORAGE '}); 16 | 17 | return AuthService(httpClient, sessionStorage); 18 | }; 19 | 20 | const configureServices = async () => { 21 | const userService = configureUserService(); 22 | const authService = configureAuthService(); 23 | 24 | return { userService, authService }; 25 | }; 26 | 27 | export default configureServices; 28 | 29 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, createStore, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | 4 | const configureStore = (reducers) => { 5 | const rootReducer = combineReducers(reducers); 6 | const middleware = applyMiddleware(thunkMiddleware); 7 | 8 | return createStore(rootReducer, middleware); 9 | }; 10 | 11 | export default configureStore; 12 | --------------------------------------------------------------------------------