Explorar el Código

Merged in feature/basic-frontend (pull request #8)

Feature/basic frontend

Approved-by: Fela Maslen <fela.maslen@mubaloo.com>
Fela Maslen hace 7 años
padre
commit
6d429c95be

+ 89 - 0
package-lock.json

@@ -1658,6 +1658,18 @@
         "es-abstract": "^1.7.0"
       }
     },
+    "array-map": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz",
+      "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=",
+      "dev": true
+    },
+    "array-reduce": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz",
+      "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=",
+      "dev": true
+    },
     "array-unique": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
@@ -2543,6 +2555,11 @@
         }
       }
     },
+    "classnames": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
+      "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
+    },
     "clean-css": {
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
@@ -4380,6 +4397,12 @@
         "for-in": "^1.0.1"
       }
     },
+    "foreach": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.4.tgz",
+      "integrity": "sha1-zF0NiuHUbMmlVcJoL5EJd4WZNd8=",
+      "dev": true
+    },
     "forever-agent": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@@ -5035,6 +5058,12 @@
         }
       }
     },
+    "fsm-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/fsm-iterator/-/fsm-iterator-1.1.0.tgz",
+      "integrity": "sha1-M33kXeGesgV4jPAuOpVewgZ2Dew=",
+      "dev": true
+    },
     "fstream": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz",
@@ -6243,6 +6272,12 @@
       "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
       "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
     },
+    "json3": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.0.tgz",
+      "integrity": "sha1-Dp5/bF0nC3WJKa9Nb+/chL1m4lk=",
+      "dev": true
+    },
     "json5": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz",
@@ -6417,6 +6452,12 @@
       "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
       "dev": true
     },
+    "lodash.ismatch": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz",
+      "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=",
+      "dev": true
+    },
     "lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -9395,6 +9436,20 @@
       "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-0.16.2.tgz",
       "integrity": "sha512-iIjKnRThI5sKPEASpUvySemjzwqwI13e3qP7oLub+FycCRDysLSAOwt958niZW6LhxfmS6Qm1BzbU70w/Koc4w=="
     },
+    "redux-saga-test-plan": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/redux-saga-test-plan/-/redux-saga-test-plan-3.7.0.tgz",
+      "integrity": "sha512-et9kCnME01kjoKXFfSk4FkozgOPPvllt9TlpL6A7ZYIS/WgoEFMLXk/UYww8KWXbmk5Qo2IF6xCc/IS1KmvP6A==",
+      "dev": true,
+      "requires": {
+        "core-js": "^2.4.1",
+        "fsm-iterator": "^1.1.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.ismatch": "^4.4.0",
+        "object-assign": "^4.1.0",
+        "util-inspect": "^0.1.8"
+      }
+    },
     "regenerate": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -11152,6 +11207,11 @@
       "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=",
       "dev": true
     },
+    "uniqid": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-5.0.3.tgz",
+      "integrity": "sha512-R2qx3X/LYWSdGRaluio4dYrPXAJACTqyUjuyXHoJLBUOIfmMcnYOyY2d6Y4clZcIz5lK6ZaI0Zzmm0cPfsIqzQ=="
+    },
     "unique-filename": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
@@ -11295,6 +11355,35 @@
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
       "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
     },
+    "util-inspect": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/util-inspect/-/util-inspect-0.1.8.tgz",
+      "integrity": "sha1-KznbzS2SHy2EMJI8r/QPS1zqXbE=",
+      "dev": true,
+      "requires": {
+        "array-map": "0.0.0",
+        "array-reduce": "0.0.0",
+        "foreach": "2.0.4",
+        "indexof": "0.0.1",
+        "isarray": "0.0.1",
+        "json3": "3.3.0",
+        "object-keys": "0.5.0"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "0.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
+          "dev": true
+        },
+        "object-keys": {
+          "version": "0.5.0",
+          "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.5.0.tgz",
+          "integrity": "sha1-CeIR8+ADGK/E9ZLjbnzcENmtcpM=",
+          "dev": true
+        }
+      }
+    },
     "util.promisify": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",

+ 3 - 0
package.json

@@ -61,6 +61,7 @@
     "postcss-loader": "^3.0.0",
     "react-hot-loader": "^4.6.3",
     "redux-logger": "^3.0.6",
+    "redux-saga-test-plan": "^3.7.0",
     "sass-loader": "^7.1.0",
     "style-loader": "^0.23.1",
     "webpack": "^4.28.4",
@@ -72,6 +73,7 @@
     "@babel/polyfill": "^7.2.5",
     "@slack/client": "^4.8.0",
     "body-parser": "^1.18.3",
+    "classnames": "^2.2.6",
     "create-reducer-object": "^1.1.0",
     "express": "^4.16.4",
     "express-async-errors": "^3.1.1",
@@ -88,6 +90,7 @@
     "request-promise": "^4.2.2",
     "reselect": "^4.0.0",
     "soap": "^0.25.0",
+    "uniqid": "^5.0.3",
     "winston": "^3.1.0"
   }
 }

+ 3 - 0
src/components/App/index.js

@@ -2,6 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Provider } from 'react-redux';
 
+import EmployeeAdmin from 'components/EmployeeAdmin';
+
 import './style.scss';
 
 export default function App({ store }) {
@@ -9,6 +11,7 @@ export default function App({ store }) {
         <div className="gurubot2">
             <Provider store={store}>
                 <h1>{'GuruBot2 Admin Panel'}</h1>
+                <EmployeeAdmin />
             </Provider>
         </div>
     );

+ 66 - 0
src/components/CrudDocument/index.js

@@ -0,0 +1,66 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import CrudField from 'components/CrudField';
+
+export default class CrudDocument extends Component {
+    static propTypes = {
+        id: PropTypes.string.isRequired,
+        pending: PropTypes.bool,
+        fields: PropTypes.object.isRequired,
+        docFields: PropTypes.object.isRequired,
+        onUpdate: PropTypes.func.isRequired,
+        onDelete: PropTypes.func.isRequired
+    };
+
+    constructor(props) {
+        super(props);
+
+        this.state = {
+            editing: false
+        };
+    }
+
+    onEditStart = () => {
+        this.setState({ editing: true });
+    };
+
+    onEditEnd = (key, value) => {
+        this.setState({ editing: false });
+
+        this.props.onUpdate(this.props.id, { [key]: value });
+    };
+
+    render() {
+        const {
+            pending,
+            fields,
+            docFields
+        } = this.props;
+
+        const fieldsList = Object.keys(fields).map(key => (
+            <CrudField key={key}
+                field={key}
+                pending={pending}
+                type={docFields[key].type}
+                value={fields[key]}
+                onEditStart={this.onEditStart}
+                onEditEnd={this.onEditEnd}
+            />
+        ));
+
+        const className = classNames('crud-document-wrapper', {
+            pending
+        });
+
+        return (
+            <div className={className}>
+                <div className="fields-list">
+                    {fieldsList}
+                </div>
+            </div>
+        );
+    }
+}
+

+ 84 - 0
src/components/CrudField/index.js

@@ -0,0 +1,84 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import './style.scss';
+
+export default class CrudField extends Component {
+    static propTypes = {
+        field: PropTypes.string.isRequired,
+        pending: PropTypes.bool,
+        type: PropTypes.string.isRequired,
+        value: PropTypes.string.isRequired,
+        onEditStart: PropTypes.func.isRequired,
+        onEditEnd: PropTypes.func.isRequired
+    };
+
+    constructor(props) {
+        super(props);
+
+        this.state = {
+            editing: false,
+            editValue: props.value
+        };
+    }
+
+    onChange = event => {
+        this.setState({
+            editValue: event.target.value
+        });
+    };
+
+    onEditStart = () => {
+        this.setState({
+            editing: true
+        });
+    };
+
+    onEditEnd = () => {
+        this.props.onEditEnd(this.props.field, this.state.editValue);
+
+        this.setState({
+            editing: false
+        });
+    };
+
+    componentDidUpdate(prevProps) {
+        if (prevProps.value !== this.props.value) {
+            this.setState({
+                editing: false,
+                editValue: this.props.value
+            });
+        }
+    }
+
+    render() {
+        const {
+            pending,
+            type,
+            value
+        } = this.props;
+
+        if (this.state.editing) {
+            return (
+                <input className="crud-field editing"
+                    disabled={pending}
+                    type={type}
+                    value={this.state.editValue}
+                    onChange={this.onChange}
+                    onBlur={this.onEditEnd}
+                />
+            );
+        }
+
+        const className = classNames('crud-field', { pending });
+
+        return (
+            <span className={className}
+                onClick={this.onEditStart}
+
+            >{value}</span>
+        );
+    }
+}
+

+ 0 - 0
src/components/CrudField/style.scss


+ 23 - 0
src/components/EmployeeAdmin/index.js

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import CrudList from 'containers/CrudList';
+
+const EMPLOYEE_FIELDS = {
+    name: {
+        type: 'text'
+    },
+    email: {
+        type: 'text'
+    }
+};
+
+export default function EmployeeAdmin() {
+    return (
+        <CrudList
+            title="User whitelist"
+            route="employees"
+            docFields={EMPLOYEE_FIELDS}
+        />
+    );
+}
+

+ 98 - 0
src/containers/CrudList/index.js

@@ -0,0 +1,98 @@
+import { connect } from 'react-redux';
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import uniqid from 'uniqid';
+
+import { getDocs } from 'selectors/crud';
+
+import {
+    docCreated,
+    docRead,
+    docUpdated,
+    docDeleted
+} from 'actions/crud';
+
+import CrudDocument from 'components/CrudDocument';
+
+const mapStateToProps = (state, { route }) => ({
+    items: getDocs(state, route)
+});
+
+const mapDispatchToProps = dispatch => ({
+    onCreate: (route, fields) => {
+        const pendingId = uniqid();
+
+        dispatch(docCreated(route, pendingId, fields));
+    },
+    onRead: route => dispatch(docRead(route)),
+    onUpdate: (route, id, fields) => dispatch(docUpdated(route, id, fields)),
+    onDelete: (route, id) => dispatch(docDeleted(route, id))
+});
+
+export default
+@connect(mapStateToProps, mapDispatchToProps)
+class CrudList extends Component {
+    static propTypes = {
+        title: PropTypes.string.isRequired,
+        route: PropTypes.string.isRequired,
+        docFields: PropTypes.object.isRequired,
+        items: PropTypes.array.isRequired,
+        onCreate: PropTypes.func.isRequired,
+        onRead: PropTypes.func.isRequired,
+        onUpdate: PropTypes.func.isRequired,
+        onDelete: PropTypes.func.isRequired
+    };
+
+    onRefresh = () => {
+        this.props.onRead(this.props.route);
+    };
+
+    onUpdate = (id, fields) => {
+        this.props.onUpdate(this.props.route, id, fields);
+    };
+
+    onDelete = id => {
+        this.props.onDelete(this.props.route, id);
+    };
+
+    componentDidMount() {
+        this.onRefresh();
+    }
+
+    render() {
+        const {
+            title,
+            items,
+            docFields
+        } = this.props;
+
+        const docsList = items.map(({ id, pending, ...fields }) => (
+            <CrudDocument key={id}
+                id={id}
+                pending={pending}
+                fields={fields}
+                docFields={docFields}
+                onUpdate={this.onUpdate}
+                onDelete={this.onDelete}
+            />
+        ));
+
+        return (
+            <div className="crud-list-wrapper">
+                <div className="head">
+                    <h3 className="title">{title}</h3>
+                    <div className="meta">
+                        <button className="button-refresh"
+                            onClick={this.onRefresh}>
+                            {'Refresh'}
+                        </button>
+                    </div>
+                </div>
+                <div className="body">
+                    {docsList}
+                </div>
+            </div>
+        );
+    }
+}
+

+ 1 - 2
src/reducers/crud.js

@@ -30,7 +30,7 @@ function updateDoc(routeDocs, index, id, pending, fields = {}) {
     }
 
     return [
-        ...routeDocs.slice(0, index - 1),
+        ...routeDocs.slice(0, index),
         {
             ...routeDocs[index],
             id,
@@ -154,7 +154,6 @@ function onUpdate(state, { route, id, fields }) {
 
     return {
         [route]: {
-            loading: true,
             items: updateDoc(routeDocs, index, id, true, fields)
         }
     };

+ 86 - 0
src/sagas/crud.js

@@ -0,0 +1,86 @@
+import { take, fork, call, put } from 'redux-saga/effects';
+import axios from 'axios';
+
+import {
+    DOC_CREATED,
+    DOC_READ,
+    DOC_UPDATED,
+    DOC_DELETED
+} from 'constants/actions';
+
+import {
+    docCreateResponded,
+    docReadResponded,
+    docUpdateResponded,
+    docDeleteResponded
+} from 'actions/crud';
+
+export function *apiRequest(method, route, params = [], data = null) {
+    let path = `/api1/${route}`;
+    if (params.length) {
+        path = `${path}/${params.join('/')}`;
+    }
+
+    const args = [[axios, method], path];
+
+    if (['post', 'put'].includes(method)) {
+        args.push(data);
+    }
+
+    try {
+        const response = yield call(...args);
+
+        return { response, err: null };
+
+    } catch (err) {
+        return { response: null, err };
+    }
+}
+
+export function *createListener() {
+    while (true) {
+        const { route, pendingId, fields } = yield take(DOC_CREATED);
+
+        const { response, err } = yield call(apiRequest, 'post', route, [], fields);
+
+        yield put(docCreateResponded(route, pendingId, err, response));
+    }
+}
+
+export function *readListener() {
+    while (true) {
+        const { route } = yield take(DOC_READ);
+
+        const { response, err } = yield call(apiRequest, 'get', route);
+
+        yield put(docReadResponded(route, err, response));
+    }
+}
+
+export function *updateListener() {
+    while (true) {
+        const { route, id, fields } = yield take(DOC_UPDATED);
+
+        const { response, err } = yield call(apiRequest, 'put', route, [id], fields);
+
+        yield put(docUpdateResponded(route, id, err, response));
+    }
+}
+
+export function *deleteListener() {
+    while (true) {
+        const { route, id } = yield take(DOC_DELETED);
+
+        const { err } = yield call(apiRequest, 'delete', route, [id]);
+
+        yield put(docDeleteResponded(route, id, err));
+    }
+}
+
+export function *crudSaga() {
+    yield fork(createListener);
+    yield fork(readListener);
+    yield fork(updateListener);
+    yield fork(deleteListener);
+}
+

+ 5 - 1
src/sagas/index.js

@@ -1,4 +1,8 @@
+import { fork } from 'redux-saga/effects';
+
+import { crudSaga } from 'sagas/crud';
+
 export function *rootSaga() {
-    yield 1;
+    yield fork(crudSaga);
 }
 

+ 181 - 0
test/sagas/crud.spec.js

@@ -0,0 +1,181 @@
+/* eslint-disable prefer-reflect */
+import { testSaga } from 'redux-saga-test-plan';
+import axios from 'axios';
+
+import {
+    apiRequest,
+    createListener,
+    readListener,
+    updateListener,
+    deleteListener,
+    crudSaga
+} from 'sagas/crud';
+
+import {
+    DOC_CREATED,
+    DOC_READ,
+    DOC_UPDATED,
+    DOC_DELETED
+} from 'constants/actions';
+
+import {
+    docCreated,
+    docCreateResponded,
+    docRead,
+    docReadResponded,
+    docUpdated,
+    docUpdateResponded,
+    docDeleted,
+    docDeleteResponded
+} from 'actions/crud';
+
+describe('crudSaga', () => {
+    describe('apiRequest', () => {
+        const route = 'employees';
+        const fields = {
+            name: 'John Doe',
+            email: 'john.doe@mubaloo.com'
+        };
+
+        it('should make an API POST request', () => {
+            const response = { isResponse: true };
+
+            testSaga(apiRequest, 'post', route, [], fields)
+                .next()
+                .call([axios, 'post'], '/api1/employees', fields)
+                .next(response)
+                .returns({ response, err: null });
+        });
+
+        it('should handle errors', () => {
+            const err = new Error('something bad happened');
+
+            testSaga(apiRequest, 'post', route, [], fields)
+                .next()
+                .call([axios, 'post'], '/api1/employees', fields)
+                .throw(err)
+                .returns({ response: null, err });
+        });
+
+        it('should make an API GET request', () => {
+            const response = { isResponse: true };
+
+            testSaga(apiRequest, 'get', route, ['a0b'])
+                .next()
+                .call([axios, 'get'], '/api1/employees/a0b')
+                .next(response)
+                .returns({ response, err: null });
+        });
+    });
+
+    describe('createListener', () => {
+        const route = 'employees';
+        const pendingId = '<pendingId>';
+        const fields = {
+            name: 'John Doe',
+            email: 'john.doe@mubaloo.com'
+        };
+
+        it('should listen to DOC_CREATED and make API calls', () => {
+            const response = { isResponse: true };
+
+            testSaga(createListener)
+                .next()
+                .take(DOC_CREATED)
+                .next(docCreated(route, pendingId, fields))
+                .call(apiRequest, 'post', route, [], fields)
+                .next({ response, err: null })
+                .put(docCreateResponded(route, pendingId, null, response))
+                .next()
+                .take(DOC_CREATED);
+        });
+
+        it('should handle errors', () => {
+            const err = new Error('something bad happened');
+
+            testSaga(createListener)
+                .next()
+                .take(DOC_CREATED)
+                .next(docCreated(route, pendingId, fields))
+                .call(apiRequest, 'post', route, [], fields)
+                .next({ response: null, err })
+                .put(docCreateResponded(route, pendingId, err, null))
+                .next()
+                .take(DOC_CREATED);
+        });
+    });
+
+    describe('readListener', () => {
+        it('should listen to DOC_READ and make API calls', () => {
+            const route = 'employees';
+
+            const response = { isResponse: true };
+
+            testSaga(readListener)
+                .next()
+                .take(DOC_READ)
+                .next(docRead(route))
+                .call(apiRequest, 'get', route)
+                .next({ response, err: null })
+                .put(docReadResponded(route, null, response))
+                .next()
+                .take(DOC_READ);
+        });
+    });
+
+    describe('updateListener', () => {
+        const route = 'employees';
+        const actualId = '<actualId>';
+        const fields = {
+            name: 'Jack Doe'
+        };
+
+        it('should listen to DOC_UPDATED and make API calls', () => {
+            const response = { isResponse: true };
+
+            testSaga(updateListener)
+                .next()
+                .take(DOC_UPDATED)
+                .next(docUpdated(route, actualId, fields))
+                .call(apiRequest, 'put', route, [actualId], fields)
+                .next({ response, err: null })
+                .put(docUpdateResponded(route, actualId, null, response))
+                .next()
+                .take(DOC_UPDATED);
+        });
+    });
+
+    describe('deleteListener', () => {
+        const route = 'employees';
+        const actualId = '<actualId>';
+
+        it('should listen to DOC_DELETED and make API calls', () => {
+            const response = { isResponse: true };
+
+            testSaga(deleteListener)
+                .next()
+                .take(DOC_DELETED)
+                .next(docDeleted(route, actualId))
+                .call(apiRequest, 'delete', route, [actualId])
+                .next({ response, err: null })
+                .put(docDeleteResponded(route, actualId, null))
+                .next()
+                .take(DOC_DELETED);
+        });
+    });
+
+    it('should fork listeners for CRUD actions', () => {
+        testSaga(crudSaga)
+            .next()
+            .fork(createListener)
+            .next()
+            .fork(readListener)
+            .next()
+            .fork(updateListener)
+            .next()
+            .fork(deleteListener)
+            .next()
+            .isDone();
+    });
+});
+

+ 20 - 0
test/sagas/index.spec.js

@@ -0,0 +1,20 @@
+import { testSaga } from 'redux-saga-test-plan';
+
+import {
+    rootSaga
+} from 'sagas';
+
+import {
+    crudSaga
+} from 'sagas/crud';
+
+describe('Root saga', () => {
+    it('should fork other sagas', () => {
+        testSaga(rootSaga)
+            .next()
+            .fork(crudSaga)
+            .next()
+            .isDone();
+    });
+});
+