Просмотр исходного кода

Merged in feature/frontend-state (pull request #7)

Feature/frontend state

Approved-by: Fela Maslen <fela.maslen@mubaloo.com>
Fela Maslen 7 лет назад
Родитель
Сommit
5ccda99b93

+ 44 - 0
package-lock.json

@@ -2942,6 +2942,11 @@
         "sha.js": "^2.4.8"
       }
     },
+    "create-reducer-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/create-reducer-object/-/create-reducer-object-1.1.0.tgz",
+      "integrity": "sha512-nZFK0q0NocNDTFdu4AmowX5wr31BhefWZg2vQI94V+6GsmycQlLEqWIGgiZ9HFnPiPXKwpo+H7pW/Oou6DZg6A=="
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -3184,6 +3189,12 @@
       "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
       "dev": true
     },
+    "deep-diff": {
+      "version": "0.3.8",
+      "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz",
+      "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=",
+      "dev": true
+    },
     "deep-eql": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
@@ -9361,6 +9372,29 @@
         }
       }
     },
+    "redux": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.1.tgz",
+      "integrity": "sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==",
+      "requires": {
+        "loose-envify": "^1.4.0",
+        "symbol-observable": "^1.2.0"
+      }
+    },
+    "redux-logger": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz",
+      "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=",
+      "dev": true,
+      "requires": {
+        "deep-diff": "^0.3.5"
+      }
+    },
+    "redux-saga": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-0.16.2.tgz",
+      "integrity": "sha512-iIjKnRThI5sKPEASpUvySemjzwqwI13e3qP7oLub+FycCRDysLSAOwt958niZW6LhxfmS6Qm1BzbU70w/Koc4w=="
+    },
     "regenerate": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -9667,6 +9701,11 @@
         }
       }
     },
+    "reselect": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
+      "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
+    },
     "resolve": {
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.9.0.tgz",
@@ -10620,6 +10659,11 @@
         "has-flag": "^3.0.0"
       }
     },
+    "symbol-observable": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
+      "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
+    },
     "symbol-tree": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz",

+ 5 - 0
package.json

@@ -60,6 +60,7 @@
     "postcss-import": "^12.0.1",
     "postcss-loader": "^3.0.0",
     "react-hot-loader": "^4.6.3",
+    "redux-logger": "^3.0.6",
     "sass-loader": "^7.1.0",
     "style-loader": "^0.23.1",
     "webpack": "^4.28.4",
@@ -71,6 +72,7 @@
     "@babel/polyfill": "^7.2.5",
     "@slack/client": "^4.8.0",
     "body-parser": "^1.18.3",
+    "create-reducer-object": "^1.1.0",
     "express": "^4.16.4",
     "express-async-errors": "^3.1.1",
     "express-basic-auth": "^1.1.6",
@@ -81,7 +83,10 @@
     "react": "^16.7.0",
     "react-dom": "^16.7.0",
     "react-redux": "^6.0.0",
+    "redux": "^4.0.1",
+    "redux-saga": "^0.16.2",
     "request-promise": "^4.2.2",
+    "reselect": "^4.0.0",
     "soap": "^0.25.0",
     "winston": "^3.1.0"
   }

+ 66 - 0
src/actions/crud.js

@@ -0,0 +1,66 @@
+import {
+    DOC_CREATED,
+    DOC_CREATE_RESPONDED,
+    DOC_READ,
+    DOC_READ_RESPONDED,
+    DOC_UPDATED,
+    DOC_UPDATE_RESPONDED,
+    DOC_DELETED,
+    DOC_DELETE_RESPONDED
+} from 'constants/actions';
+
+export const docCreated = (route, pendingId, fields) => ({
+    type: DOC_CREATED,
+    route,
+    pendingId,
+    fields
+});
+
+export const docCreateResponded = (route, pendingId, err, response) => ({
+    type: DOC_CREATE_RESPONDED,
+    route,
+    pendingId,
+    err,
+    response
+});
+
+export const docRead = route => ({
+    type: DOC_READ,
+    route
+});
+
+export const docReadResponded = (route, err, response) => ({
+    type: DOC_READ_RESPONDED,
+    route,
+    err,
+    response
+});
+
+export const docUpdated = (route, id, fields) => ({
+    type: DOC_UPDATED,
+    route,
+    id,
+    fields
+});
+
+export const docUpdateResponded = (route, id, err, response) => ({
+    type: DOC_UPDATE_RESPONDED,
+    route,
+    id,
+    err,
+    response
+});
+
+export const docDeleted = (route, id) => ({
+    type: DOC_DELETED,
+    route,
+    id
+});
+
+export const docDeleteResponded = (route, id, err) => ({
+    type: DOC_DELETE_RESPONDED,
+    route,
+    id,
+    err
+});
+

+ 12 - 2
src/components/App/index.js

@@ -1,10 +1,20 @@
 import React from 'react';
+import PropTypes from 'prop-types';
+import { Provider } from 'react-redux';
 
 import './style.scss';
 
-export default function App() {
+export default function App({ store }) {
     return (
-        <h1>{'Hello world!'}</h1>
+        <div className="gurubot2">
+            <Provider store={store}>
+                <h1>{'GuruBot2 Admin Panel'}</h1>
+            </Provider>
+        </div>
     );
 }
 
+App.propTypes = {
+    store: PropTypes.object.isRequired
+};
+

+ 9 - 0
src/constants/actions.js

@@ -0,0 +1,9 @@
+export const DOC_CREATED = 'DOC_CREATED';
+export const DOC_CREATE_RESPONDED = 'DOC_CREATE_RESPONDED';
+export const DOC_READ = 'DOC_READ';
+export const DOC_READ_RESPONDED = 'DOC_READ_RESPONDED';
+export const DOC_UPDATED = 'DOC_UPDATED';
+export const DOC_UPDATE_RESPONDED = 'DOC_UPDATE_RESPONDED';
+export const DOC_DELETED = 'DOC_DELETED';
+export const DOC_DELETE_RESPONDED = 'DOC_DELETE_RESPONDED';
+

+ 5 - 1
src/index.js

@@ -3,10 +3,14 @@ import '@babel/polyfill';
 import React from 'react';
 import { render } from 'react-dom';
 
+import { createStore } from './store';
+
 import App from 'components/App';
 
+const store = createStore();
+
 function renderApp(AppComponent = App) {
-    render(<AppComponent />, document.getElementById('root'));
+    render(<AppComponent store={store} />, document.getElementById('root'));
 }
 
 if (process.env.NODE_ENV !== 'test') {

+ 250 - 0
src/reducers/crud.js

@@ -0,0 +1,250 @@
+import { createReducerObject } from 'create-reducer-object';
+
+import {
+    DOC_CREATED,
+    DOC_CREATE_RESPONDED,
+    DOC_READ,
+    DOC_READ_RESPONDED,
+    DOC_UPDATED,
+    DOC_UPDATE_RESPONDED,
+    DOC_DELETED,
+    DOC_DELETE_RESPONDED
+} from 'constants/actions';
+
+export const initialState = {};
+
+function insertDoc(routeDocs, id, pending, fields) {
+    return [
+        ...routeDocs,
+        {
+            id,
+            pending,
+            ...fields
+        }
+    ];
+}
+
+function updateDoc(routeDocs, index, id, pending, fields = {}) {
+    if (index === -1) {
+        return insertDoc(routeDocs, id, false, fields);
+    }
+
+    return [
+        ...routeDocs.slice(0, index - 1),
+        {
+            ...routeDocs[index],
+            id,
+            pending,
+            ...fields
+        },
+        ...routeDocs.slice(index + 1)
+    ];
+}
+
+function deleteDoc(docs, index) {
+    return docs.slice(0, index - 1)
+        .concat(docs.slice(index + 1));
+}
+
+function getRouteDocs(state, route) {
+    if (state[route]) {
+        return state[route].items;
+    }
+
+    return [];
+}
+
+function onCreate(state, { route, pendingId, fields }) {
+    if (state[route] && state[route].loading) {
+        return {};
+    }
+
+    const routeDocs = getRouteDocs(state, route);
+
+    return {
+        [route]: {
+            loading: true,
+            items: insertDoc(routeDocs, pendingId, true, fields)
+        }
+    };
+}
+
+function onCreateResponse(state, { route, pendingId, err, response }) {
+    const routeDocs = getRouteDocs(state, route);
+
+    const index = routeDocs.findIndex(({ id }) => id === pendingId);
+
+    if (err) {
+        const newRoute = {
+            ...(state[route] || {}),
+            error: true
+        };
+
+        if (index === -1) {
+            return {
+                [route]: newRoute
+            };
+        }
+
+        return {
+            [route]: {
+                ...newRoute,
+                items: deleteDoc(routeDocs, index)
+            }
+        };
+    }
+
+    const { id: actualId, ...fields } = response.data;
+
+    return {
+        [route]: {
+            ...state[route],
+            items: updateDoc(routeDocs, index, actualId, false, fields)
+        }
+    };
+}
+
+function onRead(state, { route }) {
+    if (state[route]) {
+        return {
+            [route]: {
+                ...state[route],
+                loading: true
+            }
+        };
+    }
+
+    return {
+        [route]: {
+            loading: true,
+            items: []
+        }
+    };
+}
+
+function onReadResponse(state, { route, err, response }) {
+    if (err) {
+        return {
+            [route]: {
+                ...(state[route] || {}),
+                loading: false,
+                error: true
+            }
+        };
+    }
+
+    return {
+        [route]: {
+            ...(state[route] || {}),
+            loading: false,
+            error: false,
+            items: response.data[route]
+        }
+    };
+}
+
+function onUpdate(state, { route, id, fields }) {
+    if (state[route] && state[route].loading) {
+        return {};
+    }
+
+    const routeDocs = getRouteDocs(state, route);
+
+    const index = routeDocs.findIndex(({ id: docId }) => docId === id);
+
+    return {
+        [route]: {
+            loading: true,
+            items: updateDoc(routeDocs, index, id, true, fields)
+        }
+    };
+
+}
+
+function onUpdateResponse(state, { route, id, err }) {
+    const routeDocs = getRouteDocs(state, route);
+
+    const index = routeDocs.findIndex(({ id: docId }) => docId === id);
+
+    const newItems = updateDoc(routeDocs, index, id, false);
+
+    if (err) {
+        const newRoute = {
+            ...(state[route] || {}),
+            error: true
+        };
+
+        if (index === -1) {
+            return {
+                [route]: newRoute
+            };
+        }
+
+        return {
+            [route]: {
+                ...newRoute,
+                items: newItems
+            }
+        };
+    }
+
+    return {
+        [route]: {
+            ...state[route],
+            error: false,
+            items: newItems
+        }
+    };
+}
+
+function onDelete(state, { route, id }) {
+    const routeDocs = getRouteDocs(state, route);
+
+    const index = routeDocs.findIndex(({ id: docId }) => docId === id);
+
+    return {
+        [route]: {
+            ...state[route],
+            error: false,
+            items: updateDoc(routeDocs, index, id, true)
+        }
+    };
+}
+
+function onDeleteResponse(state, { route, id, err }) {
+    const routeDocs = getRouteDocs(state, route);
+
+    const index = routeDocs.findIndex(({ id: docId }) => docId === id);
+
+    if (err) {
+        return {
+            [route]: {
+                ...state[route],
+                error: true,
+                items: updateDoc(routeDocs, index, id, false)
+            }
+        };
+    }
+
+    return {
+        [route]: {
+            ...state[route],
+            error: false,
+            items: deleteDoc(routeDocs, index)
+        }
+    };
+}
+
+const reducerMap = {
+    [DOC_CREATED]: onCreate,
+    [DOC_CREATE_RESPONDED]: onCreateResponse,
+    [DOC_READ]: onRead,
+    [DOC_READ_RESPONDED]: onReadResponse,
+    [DOC_UPDATED]: onUpdate,
+    [DOC_UPDATE_RESPONDED]: onUpdateResponse,
+    [DOC_DELETED]: onDelete,
+    [DOC_DELETE_RESPONDED]: onDeleteResponse
+};
+
+export const crud = createReducerObject(reducerMap, initialState);
+

+ 8 - 0
src/reducers/index.js

@@ -0,0 +1,8 @@
+import { combineReducers } from 'redux';
+
+import { crud } from 'reducers/crud';
+
+export const rootReducer = combineReducers({
+    crud
+});
+

+ 4 - 0
src/sagas/index.js

@@ -0,0 +1,4 @@
+export function *rootSaga() {
+    yield 1;
+}
+

+ 14 - 0
src/selectors/crud.js

@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+
+const routeExists = (state, route) => route in state.crud;
+
+function getRouteDocs(state, route) {
+    if (state.crud[route]) {
+        return state.crud[route].items;
+    }
+
+    return [];
+}
+
+export const getDocs = createSelector([routeExists, getRouteDocs], (exists, docs) => docs);
+

+ 7 - 1
src/server/modules/crud.js

@@ -54,7 +54,13 @@ function makeInsertDoc(collection, schema) {
 
         const dbCollection = await db.collection(collection);
 
-        const result = await dbCollection.insertOne(value);
+        let result = null;
+
+        if (process.env.NODE_ENV === 'test') {
+            result = await dbCollection.insert(value);
+        } else {
+            result = await dbCollection.insertOne(value);
+        }
 
         try {
             const { _id, ...dbResult } = result.ops[0];

+ 80 - 0
src/store.js

@@ -0,0 +1,80 @@
+import { applyMiddleware, createStore } from 'redux';
+import createSagaMiddleware from 'redux-saga';
+import { createLogger } from 'redux-logger';
+
+import { rootReducer } from 'reducers';
+import { rootSaga } from 'sagas';
+
+const SKIP_LOG_ACTIONS = (process.env.SKIP_LOG_ACTIONS || '').split(',');
+
+export function getStoreMiddleware(__DEV__) {
+    const middleware = [rootReducer];
+
+    if (__DEV__) {
+        const devToolsMiddleware = window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__({
+            actionsBlacklist: SKIP_LOG_ACTIONS
+        });
+
+        middleware.push(devToolsMiddleware);
+    }
+
+    return middleware;
+}
+
+export function makeCreateStoreWithMiddleware(__DEV__, sagaMiddleware) {
+    const middleware = [sagaMiddleware];
+
+    if (__DEV__) {
+        const logger = createLogger({
+            collapsed: true,
+            predicate: (getState, action) => !SKIP_LOG_ACTIONS.includes(action.type)
+        });
+
+        middleware.push(logger);
+    }
+
+    return applyMiddleware(...middleware)(createStore);
+}
+
+function createCustomStore() {
+    const __DEV__ = process.env.NODE_ENV === 'development';
+    const __TEST__ = process.env.NODE_ENV === 'test';
+
+    if (__TEST__) {
+        return createStore(rootReducer);
+    }
+
+    const sagaMiddleware = createSagaMiddleware();
+
+    const createStoreWithMiddleware = makeCreateStoreWithMiddleware(__DEV__, sagaMiddleware);
+
+    const store = createStoreWithMiddleware(...getStoreMiddleware(__DEV__));
+
+    let sagaTask = sagaMiddleware.run(function *getSagas() {
+        yield rootSaga();
+    });
+
+    if (__DEV__ && module.hot) {
+        module.hot.accept('./reducers', () => {
+            // eslint-disable-next-line global-require
+            store.replaceReducer(require('./reducers').default);
+        });
+
+        module.hot.accept('./sagas', () => {
+            // eslint-disable-next-line global-require
+            const newRootSaga = require('./sagas').rootSaga;
+
+            sagaTask.cancel();
+            sagaTask.done.then(() => {
+                sagaTask = sagaMiddleware.run(function *replacedSaga() {
+                    yield newRootSaga();
+                });
+            });
+        });
+    }
+
+    return store;
+}
+
+export { createCustomStore as createStore };
+

+ 180 - 0
test/actions/crud.spec.js

@@ -0,0 +1,180 @@
+import { expect } from 'chai';
+
+import {
+    DOC_CREATED,
+    DOC_CREATE_RESPONDED,
+    DOC_READ,
+    DOC_READ_RESPONDED,
+    DOC_UPDATED,
+    DOC_UPDATE_RESPONDED,
+    DOC_DELETED,
+    DOC_DELETE_RESPONDED
+} from 'constants/actions';
+
+import {
+    docCreated,
+    docCreateResponded,
+    docRead,
+    docReadResponded,
+    docUpdated,
+    docUpdateResponded,
+    docDeleted,
+    docDeleteResponded
+} from 'actions/crud';
+
+describe('CRUD actions', () => {
+    describe('docCreated', () => {
+        it('should return DOC_CREATED with fields', () => {
+            const route = 'employees';
+
+            const pendingId = 'mypendingid';
+
+            const fields = {
+                name: 'Foo',
+                email: 'foo@mubaloo.com'
+            };
+
+            const action = docCreated(route, pendingId, fields);
+
+            expect(action).to.deep.equal({
+                type: DOC_CREATED,
+                route,
+                pendingId,
+                fields
+            });
+        });
+    });
+
+    describe('docCreateResponded', () => {
+        it('should return DOC_CREATE_RESPONDED with error / response', () => {
+            const route = 'employees';
+
+            const pendingId = 'mypendingid';
+
+            const error = { error: true };
+
+            const response = { response: true };
+
+            const action = docCreateResponded(route, pendingId, error, response);
+
+            expect(action).to.deep.equal({
+                type: DOC_CREATE_RESPONDED,
+                route,
+                pendingId,
+                err: error,
+                response
+            });
+        });
+    });
+
+    describe('docRead', () => {
+        it('should return DOC_READ', () => {
+            const route = 'employees';
+
+            const action = docRead(route);
+
+            expect(action).to.deep.equal({
+                type: DOC_READ,
+                route
+            });
+        });
+    });
+
+    describe('docReadResponded', () => {
+        it('should return DOC_READ_RESPONDED with error / response', () => {
+            const route = 'employees';
+
+            const error = { error: true };
+
+            const response = { response: true };
+
+            const action = docReadResponded(route, error, response);
+
+            expect(action).to.deep.equal({
+                type: DOC_READ_RESPONDED,
+                route,
+                err: error,
+                response
+            });
+        });
+    });
+
+    describe('docUpdated', () => {
+        it('should return DOC_UPDATED', () => {
+            const route = 'employees';
+
+            const id = 'a8b8ce23';
+
+            const fields = {
+                name: 'Bar'
+            };
+
+            const action = docUpdated(route, id, fields);
+
+            expect(action).to.deep.equal({
+                type: DOC_UPDATED,
+                route,
+                id,
+                fields
+            });
+        });
+    });
+
+    describe('docUpdateResponded', () => {
+        it('should return DOC_UPDATE_RESPONDED with error / response', () => {
+            const route = 'employees';
+
+            const id = 'a0fb56';
+
+            const error = { error: true };
+
+            const response = { response: true };
+
+            const action = docUpdateResponded(route, id, error, response);
+
+            expect(action).to.deep.equal({
+                type: DOC_UPDATE_RESPONDED,
+                route,
+                id,
+                err: error,
+                response
+            });
+        });
+    });
+
+    describe('docDeleted', () => {
+        it('should return DOC_DELETED', () => {
+            const route = 'employees';
+
+            const id = 'a8b8ce23';
+
+            const action = docDeleted(route, id);
+
+            expect(action).to.deep.equal({
+                type: DOC_DELETED,
+                route,
+                id
+            });
+        });
+    });
+
+    describe('docDeleteResponded', () => {
+        it('should return DOC_DELETE_RESPONDED with error', () => {
+            const route = 'employees';
+
+            const id = 'a0fb56';
+
+            const error = { error: true };
+
+            const action = docDeleteResponded(route, id, error);
+
+            expect(action).to.deep.equal({
+                type: DOC_DELETE_RESPONDED,
+                route,
+                id,
+                err: error
+            });
+        });
+    });
+});
+

+ 0 - 15
test/components/App/index.spec.js

@@ -1,15 +0,0 @@
-import '../../browser';
-import { shallow } from 'enzyme';
-import { expect } from 'chai';
-import React from 'react';
-import App from 'components/App';
-
-describe('<App />', () => {
-    const wrapper = shallow(<App />);
-
-    it('should render a hello world message', () => {
-        expect(wrapper.is('h1')).to.equal(true);
-        expect(wrapper.text()).to.equal('Hello world!');
-    });
-});
-

+ 359 - 0
test/reducers/crud.spec.js

@@ -0,0 +1,359 @@
+import { expect } from 'chai';
+
+import { initialState, crud } from 'reducers/crud';
+
+import {
+    docCreated,
+    docCreateResponded,
+    docRead,
+    docReadResponded,
+    docUpdated,
+    docUpdateResponded,
+    docDeleted,
+    docDeleteResponded
+} from 'actions/crud';
+
+describe('CRUD reducer', () => {
+    it('should return the initial state by default', () => {
+        // eslint-disable-next-line no-undefined
+        expect(crud(undefined, null)).to.equal(initialState);
+    });
+
+    describe('DOC_CREATED', () => {
+        const route = 'employees';
+
+        const pendingId = 'foo19123afvj';
+
+        const action = docCreated(route, pendingId, {
+            name: 'John Doe',
+            email: 'john.doe@mubaloo.com'
+        });
+
+        it('should create an optimistic update (pending) in state', () => {
+            const state = {};
+
+            const result = crud(state, action);
+
+            expect(result).to.have.property(route);
+
+            expect(result[route]).to.be.an('object');
+
+            expect(result[route]).to.have.property('items');
+
+            expect(result[route].items).to.be.an('array').of.length(1);
+
+            expect(result[route].items[0]).to.be.an('object');
+            expect(result[route].items[0]).to.deep.equal({
+                id: pendingId,
+                pending: true,
+                name: 'John Doe',
+                email: 'john.doe@mubaloo.com'
+            });
+        });
+
+        it('should not do anything if the state is loading', () => {
+            const state = {
+                [route]: {
+                    loading: true,
+                    items: []
+                }
+            };
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(0);
+        });
+    });
+
+    describe('DOC_CREATE_RESPONDED', () => {
+        const route = 'employees';
+
+        const pendingId = 'foo19123afvj';
+
+        const state = {
+            [route]: {
+                items: [
+                    {
+                        id: pendingId,
+                        pending: true,
+                        name: 'John Doe',
+                        email: 'john.doe@mubaloo.com'
+                    }
+                ]
+            }
+        };
+
+        const actualId = 'a0b444511123';
+
+        const response = {
+            data: {
+                id: actualId,
+                name: 'John Doe',
+                email: 'john.doe@mubaloo.com'
+            }
+        };
+
+        const actionSuccess = docCreateResponded(route, pendingId, null, response);
+
+        it('should mark an optimistically created document as successful, if the response was success', () => {
+            const result = crud(state, actionSuccess);
+
+            expect(result[route].items).to.have.length(1);
+            expect(result[route].items[0]).to.have.property('id', actualId);
+            expect(result[route].items[0]).to.have.property('pending', false);
+        });
+
+        it('should remove the pending item and set an error status if an error occurred', () => {
+            const error = new Error('something bad happened');
+
+            const action = docCreateResponded(route, pendingId, error, null);
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(0);
+            expect(result[route]).to.have.property('error', true);
+        });
+    });
+
+    describe('DOC_READ', () => {
+        it('should set the route to loading', () => {
+            const state = {};
+
+            const route = 'employees';
+
+            const action = docRead(route);
+
+            const result = crud(state, action);
+
+            expect(result).to.have.property(route);
+            expect(result[route]).to.be.an('object');
+
+            expect(result[route]).to.have.property('loading', true);
+            expect(result[route]).to.have.property('items');
+            expect(result[route].items).to.be.an('array').of.length(0);
+        });
+    });
+
+    describe('DOC_READ_RESPONDED', () => {
+        const route = 'employees';
+
+        const state = {
+            [route]: {
+                loading: true,
+                items: []
+            }
+        };
+
+        const actualId = 'a0b444511123';
+
+        const response = {
+            data: {
+                [route]: [
+                    {
+                        id: actualId,
+                        name: 'John Doe',
+                        email: 'john.doe@mubaloo.com'
+                    }
+                ]
+            }
+        };
+
+        const actionSuccess = docReadResponded(route, null, response);
+
+        const resultSuccess = crud(state, actionSuccess);
+
+        it('should add the response to state', () => {
+            expect(resultSuccess[route].items).to.have.length(1);
+            expect(resultSuccess[route].items[0]).to.have.property('id', actualId);
+            expect(resultSuccess[route].items[0]).to.have.property('name', 'John Doe');
+            expect(resultSuccess[route].items[0]).to.have.property('email', 'john.doe@mubaloo.com');
+        });
+
+        it('should set loading to false', () => {
+            expect(resultSuccess[route].loading).to.equal(false);
+        });
+
+        it('should set an error status if an error occurred', () => {
+            const error = new Error('something bad happened');
+
+            const action = docReadResponded(route, error, null);
+
+            const resultError = crud(state, action);
+
+            expect(resultError[route].items).to.have.length(0);
+            expect(resultError[route].error).to.equal(true);
+            expect(resultError[route].loading).to.equal(false);
+        });
+    });
+
+    describe('DOC_UPDATED', () => {
+        const route = 'employees';
+
+        const actualId = 'a0b444511123';
+
+        const action = docUpdated(route, actualId, {
+            name: 'John Doe',
+            email: 'john.doe@mubaloo.com'
+        });
+
+        it('should optimistically update the item (pending) in state', () => {
+            const state = {
+                [route]: {
+                    items: [
+                        {
+                            id: actualId,
+                            name: 'Jack',
+                            email: 'jack@mubaloo.com'
+                        }
+                    ]
+                }
+            };
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(1);
+
+            expect(result[route].items[0]).to.have.property('pending', true);
+            expect(result[route].items[0]).to.deep.equal({
+                id: actualId,
+                pending: true,
+                name: 'John Doe',
+                email: 'john.doe@mubaloo.com'
+            });
+        });
+
+        it('should not do anything if the state is loading', () => {
+            const state = {
+                [route]: {
+                    loading: true,
+                    items: []
+                }
+            };
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(0);
+        });
+    });
+
+    describe('DOC_UPDATE_RESPONDED', () => {
+        const route = 'employees';
+
+        const actualId = 'a0b444511123';
+
+        const state = {
+            [route]: {
+                items: [
+                    {
+                        id: actualId,
+                        pending: true,
+                        name: 'John Doe',
+                        email: 'john.doe@mubaloo.com'
+                    }
+                ]
+            }
+        };
+
+        const response = {
+            data: {
+                ok: true
+            }
+        };
+
+        const actionSuccess = docUpdateResponded(route, actualId, null, response);
+
+        it('should mark an optimistically updated document as successful, if the response was success', () => {
+            const result = crud(state, actionSuccess);
+
+            expect(result[route].items).to.have.length(1);
+            expect(result[route].items[0]).to.have.property('pending', false);
+        });
+
+        it('should set an error status if an error occurred', () => {
+            const error = new Error('something bad happened');
+
+            const action = docUpdateResponded(route, actualId, error, null);
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(1);
+            expect(result[route]).to.have.property('error', true);
+
+            expect(result[route].items[0]).to.have.property('pending', false);
+        });
+    });
+
+    describe('DOC_DELETED', () => {
+        const route = 'employees';
+
+        const actualId = 'a0b444511123';
+
+        it('should mark the document as pending', () => {
+            const state = {
+                [route]: {
+                    items: [
+                        {
+                            id: actualId,
+                            pending: false,
+                            name: 'John Doe',
+                            email: 'john.doe@mubaloo.com'
+                        }
+                    ]
+                }
+            };
+
+            const action = docDeleted(route, actualId);
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(1);
+            expect(result[route].items[0]).to.have.property('pending', true);
+        });
+    });
+
+    describe('DOC_DELETE_RESPONDED', () => {
+        const route = 'employees';
+
+        const actualId = 'a0b444511123';
+
+        const state = {
+            [route]: {
+                items: [
+                    {
+                        id: actualId,
+                        pending: true,
+                        name: 'John Doe',
+                        email: 'john.doe@mubaloo.com'
+                    }
+                ]
+            }
+        };
+
+        it('should remove the document if the response was success', () => {
+            const response = {
+                data: {
+                    ok: true
+                }
+            };
+
+            const action = docDeleteResponded(route, actualId, null, response);
+
+            const result = crud(state, action);
+
+            expect(result[route].items).to.have.length(0);
+        });
+
+        it('should set an error status if an error occurred', () => {
+            const error = new Error('something bad happened');
+
+            const action = docDeleteResponded(route, actualId, error, null);
+
+            const result = crud(state, action);
+
+            expect(result[route]).to.have.property('error', true);
+            expect(result[route].items).to.have.length(1);
+            expect(result[route].items[0]).to.have.property('pending', false);
+        });
+    });
+});
+

+ 53 - 0
test/selectors/crud.spec.js

@@ -0,0 +1,53 @@
+import { expect } from 'chai';
+
+import {
+    getDocs
+} from 'selectors/crud';
+
+describe('CRUD selectors', () => {
+    describe('getDocs', () => {
+        const route = 'employees';
+
+        it('should return the list of docs', () => {
+            const state = {
+                crud: {
+                    [route]: {
+                        items: [
+                            {
+                                id: '<actualId>',
+                                name: 'John Doe',
+                                email: 'john.doe@mubaloo.com'
+                            }
+                        ]
+                    }
+                }
+            };
+
+            const result = getDocs(state, route);
+
+            expect(result).to.deep.equal(state.crud[route].items);
+        });
+
+        it('should return an empty array by default', () => {
+            const state = {
+                crud: {}
+            };
+
+            const result = getDocs(state, route);
+
+            expect(result).to.deep.equal([]);
+        });
+
+        it('should memoise default results', () => {
+            const state = {
+                crud: {}
+            };
+
+            const resultA = getDocs(state, route);
+            const resultB = getDocs(state, route);
+
+            expect(resultA).to.equal(resultB);
+        });
+    });
+});
+

+ 2 - 0
webpack.config.js

@@ -157,6 +157,8 @@ module.exports = {
             constants: path.resolve(__dirname, 'src/constants'),
             containers: path.resolve(__dirname, 'src/containers'),
             reducers: path.resolve(__dirname, 'src/reducers'),
+            sagas: path.resolve(__dirname, 'src/sagas'),
+            selectors: path.resolve(__dirname, 'src/selectors'),
             sprite: path.resolve(__dirname, 'src/images/sprite/sprite.scss')
         }
     },