Преглед на файлове

Added actions and reducers for CRUD operations on frontend

Fela Maslen преди 7 години
родител
ревизия
0083e205ee
променени са 8 файла, в които са добавени 873 реда и са изтрити 6 реда
  1. 3 3
      package-lock.json
  2. 1 1
      package.json
  3. 66 0
      src/actions/crud.js
  4. 9 0
      src/constants/actions.js
  5. 250 0
      src/reducers/crud.js
  6. 5 2
      src/reducers/index.js
  7. 180 0
      test/actions/crud.spec.js
  8. 359 0
      test/reducers/crud.spec.js

+ 3 - 3
package-lock.json

@@ -2943,9 +2943,9 @@
       }
     },
     "create-reducer-object": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/create-reducer-object/-/create-reducer-object-1.0.0.tgz",
-      "integrity": "sha512-MUItFIRc+HucazizTpwCBiQCPjfYZIxCIJVnUEZWD6KS5G7boBBbQ4QFowKE+O3bOhBNu8vdFeJQrYFFglXl2A=="
+      "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",

+ 1 - 1
package.json

@@ -72,7 +72,7 @@
     "@babel/polyfill": "^7.2.5",
     "@slack/client": "^4.8.0",
     "body-parser": "^1.18.3",
-    "create-reducer-object": "^1.0.0",
+    "create-reducer-object": "^1.1.0",
     "express": "^4.16.4",
     "express-async-errors": "^3.1.1",
     "express-basic-auth": "^1.1.6",

+ 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
+});
+

+ 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';
+

+ 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);
+

+ 5 - 2
src/reducers/index.js

@@ -1,5 +1,8 @@
-import { createReducerObject } from 'create-reducer-object';
+import { combineReducers } from 'redux';
 
-export const rootReducer = createReducerObject({
+import { crud } from 'reducers/crud';
+
+export const rootReducer = combineReducers({
+    crud
 });
 

+ 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
+            });
+        });
+    });
+});
+

+ 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);
+        });
+    });
+});
+