From a17b76ac75f506f5da6fe8adf9c36632b60d4226 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Sat, 26 Sep 2020 14:56:02 +0200 Subject: refactor to use app-rooted js imports --- frontend/app/api/crud.actions.js | 51 ++++++++++ frontend/app/api/crud.fetch.js | 105 +++++++++++++++++++++ frontend/app/api/crud.reducer.js | 196 +++++++++++++++++++++++++++++++++++++++ frontend/app/api/crud.types.js | 36 +++++++ frontend/app/api/crud.upload.js | 107 +++++++++++++++++++++ frontend/app/api/index.js | 24 +++++ 6 files changed, 519 insertions(+) create mode 100644 frontend/app/api/crud.actions.js create mode 100644 frontend/app/api/crud.fetch.js create mode 100644 frontend/app/api/crud.reducer.js create mode 100644 frontend/app/api/crud.types.js create mode 100644 frontend/app/api/crud.upload.js create mode 100644 frontend/app/api/index.js (limited to 'frontend/app/api') diff --git a/frontend/app/api/crud.actions.js b/frontend/app/api/crud.actions.js new file mode 100644 index 0000000..86c2948 --- /dev/null +++ b/frontend/app/api/crud.actions.js @@ -0,0 +1,51 @@ +import { crud_fetch } from 'app/api/crud.fetch' +import { as_type } from 'app/api/crud.types' +import { upload_action } from 'app/api/crud.upload' +import { store } from 'app/store' + +export function crud_actions(type) { + const fetch_type = crud_fetch(type) + return [ + 'index', + 'show', + 'create', + 'update', + 'destroy', + ].reduce((lookup, param) => { + lookup[param] = crud_action(type, param, (q) => fetch_type[param](q)) + return lookup + }, { + action: (method, fn) => crud_action(type, method, fn), + upload: (fd) => upload_action(type, fd), + updateOption: (key, value) => dispatch => { + dispatch({ type: as_type(type, 'update_option'), key, value }) + }, + updateOptions: opt => dispatch => { + dispatch({ type: as_type(type, 'update_options'), opt }) + }, + }) +} + +export const crud_action = (type, method, fn) => (q, load_more) => dispatch => { + return new Promise ((resolve, reject) => { + if (method === 'index') { + if (store.getState()[type].index.loading) { + return resolve({}) + } + } + dispatch({ type: as_type(type, method + '_loading'), load_more }) + fn(q).then(data => { + if (data.status === 'ok') { + dispatch({ type: as_type(type, method), data, load_more }) + resolve(data) + } else { + dispatch({ type: as_type(type, method + '_error'), error: data.error }) + reject(data) + } + }).catch(e => { + console.log(e) + dispatch({ type: as_type(type, method + '_error') }) + reject(e) + }) + }) +} diff --git a/frontend/app/api/crud.fetch.js b/frontend/app/api/crud.fetch.js new file mode 100644 index 0000000..c88225e --- /dev/null +++ b/frontend/app/api/crud.fetch.js @@ -0,0 +1,105 @@ +import fetch from 'node-fetch' + +export function crud_fetch(type, tag) { + const uri = '/api/v1/' + type + '/' + (tag || '') + return { + index: q => { + return fetch(_get_url(uri, q), _get_headers()) + .then(req => req.json()) + .catch(error) + }, + + show: id => { + let url; + if (typeof id === 'object') { + url = _get_url(uri + id[0] + '/', id[1]) + } else { + url = _get_url(uri + id + '/') + } + return fetch(url, _get_headers()) + .then(req => req.json()) + .catch(error) + }, + + create: data => { + return fetch(uri, post(data)) + .then(req => req.json()) + .catch(error) + }, + + update: data => { + return fetch(uri + data.id + '/', put(data)) + .then(req => req.json()) + .catch(error) + }, + + destroy: data => { + return fetch(uri + data.id + '/', destroy(data)) + .then(req => req.json()) + .catch(error) + }, + } +} + +function _get_url(_url, data) { + const url = new URL(window.location.origin + _url) + if (data) { + Object.keys(data).forEach(key => url.searchParams.append(key, data[key])) + } + return url +} +export function _get_headers() { + return { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + }, + } +} +export function post(data) { + return { + method: 'POST', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +export function postBody(data) { + return { + method: 'POST', + body: data, + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + }, + } +} +export function put(data) { + return { + method: 'PUT', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +export function destroy(data) { + return { + method: 'DELETE', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +function error(err) { + console.warn(err) +} diff --git a/frontend/app/api/crud.reducer.js b/frontend/app/api/crud.reducer.js new file mode 100644 index 0000000..2a7e4c4 --- /dev/null +++ b/frontend/app/api/crud.reducer.js @@ -0,0 +1,196 @@ +import * as types from 'app/types' +import { getOrderedIds, getOrderedIdsFromLookup } from 'app/utils' +import { session } from 'app/session' + +export const crudState = (type, options) => ({ + index: {}, + show: {}, + create: {}, + update: {}, + destroy: {}, + lookup: [], + ...options, +}) + +export const crudReducer = (type) => { + const crud_type = types[type] + return (state, action) => { + switch (action.type) { + // index + case crud_type.index_loading: + return { + ...state, + index: action.load_more + ? { ...state.index, loading: true } + : { loading: true }, + } + case crud_type.index: + if (action.data.res.length) { + return { + ...state, + index: { + lookup: action.data.res.reduce((a, b) => { a[b.id] = b; return a }, action.load_more ? state.index.lookup : {}), + order: getOrderedIds(action.data.res, state.options.sort, action.load_more ? state.index.order : []), + }, + } + } else { + Object.keys(action.data.res).forEach(key => { + const el = action.data.res[key] + el.key = key + el.id = el.id || key + }) + return { + ...state, + index: { + lookup: action.data.res, + order: getOrderedIdsFromLookup(action.data.res, state.options.sort), + }, + } + } + case crud_type.index_error: + return { + ...state, + index: { loading: false, error: true }, + } + case crud_type.index_sort: + return { + ...state, + index: { + ...state.index, + order: action.data.res.map(b => b.id), + }, + } + + // show + case crud_type.show_loading: + return { + ...state, + show: { loading: true }, + } + case crud_type.show: + if (!action.data) { + return { + ...state, + show: { not_found: true }, + } + } + return { + ...state, + show: action.data, + } + case crud_type.show_error: + return { + ...state, + show: { loading: false, error: true }, + } + + // + // create + case crud_type.create_loading: + return { + ...state, + create: { loading: true }, + } + case crud_type.create: + return { + ...state, + create: action.data, + index: addToIndex(state.index, action.data.res, state.options.sort), + } + case crud_type.create_error: + return { + ...state, + create: action.data, + } + + // + // update + case crud_type.update_loading: + return { + ...state, + update: { loading: true }, + } + case crud_type.update: + return { + ...state, + update: action.data, + index: addToIndex(state.index, action.data.res, state.options.sort), + show: (state.show.res && state.show.res.id === action.data.res.id) ? { + res: { + ...state.show.res, + ...action.data.res, + } + } : state.show + } + case crud_type.update_error: + return { + ...state, + update: { loading: false, error: true }, + } + + // + // destroy + case crud_type.destroy_loading: + return { + ...state, + destroy: { loading: true }, + } + case crud_type.destroy: + return { + ...state, + index: { + ...(() => { + if (!state.index.lookup) { + return {} + } + delete state.index.lookup[action.data.id] + const _id = parseInt(action.data.id) + state.index.order = state.index.order.filter(id => id !== _id) + return { ...state.index } + })() + }, + destroy: { loading: false }, + } + case crud_type.destroy_error: + return { + ...state, + destroy: { error: true }, + } + + // + // options + case crud_type.update_option: + session.set(type + "." + action.key, action.value) + return { + ...state, + options: { + ...state.options, + [action.key]: action.value, + } + } + + case crud_type.update_options: + session.setAll( + Object.keys(action.opt).reduce((a,b) => { a[type + '.' + b] = action.opt[b]; return a }, {}) + ) + return { + ...state, + options: { + ...action.opt, + } + } + + default: + return state + } + } +} + +const addToIndex = (index, data, sort) => { + const lookup = (index && index.lookup) ? { + ...index.lookup, + } : {} + lookup[data.id] = data + const order = getOrderedIdsFromLookup(lookup, sort) + return { lookup, order } +} diff --git a/frontend/app/api/crud.types.js b/frontend/app/api/crud.types.js new file mode 100644 index 0000000..7b24811 --- /dev/null +++ b/frontend/app/api/crud.types.js @@ -0,0 +1,36 @@ + +export const as_type = (a, b) => [a, b].join('_').toUpperCase() + +export const with_type = (type, actions) => + actions.reduce((a, b) => (a[b] = as_type(type, b)) && a, {}) + +export const crud_type = (type, actions=[]) => + with_type(type, actions.concat([ + 'index_loading', + 'index', + 'index_error', + 'index_sort', + 'show_loading', + 'show', + 'show_error', + 'create_loading', + 'create', + 'create_error', + 'update_loading', + 'update', + 'update_error', + 'destroy_loading', + 'destroy', + 'destroy_error', + 'upload_loading', + 'upload_progress', + 'upload_waiting', + 'upload_complete', + 'upload_error', + 'sort', + 'update_option', + 'update_options', + 'loading', + 'loaded', + 'error', + ])) diff --git a/frontend/app/api/crud.upload.js b/frontend/app/api/crud.upload.js new file mode 100644 index 0000000..8c1b265 --- /dev/null +++ b/frontend/app/api/crud.upload.js @@ -0,0 +1,107 @@ +import { as_type } from 'app/api/crud.types' + +export function crud_upload(type, data, dispatch) { + return new Promise( (resolve, reject) => { + // console.log(type, data) + const { id } = data + + const fd = new FormData() + + Object.keys(data).forEach(key => { + if (key !== 'id') { + fd.append(key, data[key]) + } + }) + + let url = id ? '/api/v1/' + type + '/' + id + '/' + : '/api/v1/' + type + '/' + // console.log(url) + + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener("progress", uploadProgress, false) + xhr.addEventListener("load", uploadComplete, false) + xhr.addEventListener("error", uploadFailed, false) + xhr.addEventListener("abort", uploadCancelled, false) + xhr.open("POST", url) + xhr.send(fd) + + dispatch && dispatch({ type: as_type(type, 'upload_loading')}) + + let complete = false + + function uploadProgress (e) { + if (e.lengthComputable) { + const percent = Math.round(e.loaded * 100 / e.total) || 0 + if (percent > 99) { + dispatch && dispatch({ + type: as_type(type, 'upload_waiting'), + percent, + [type]: id, + }) + } else { + dispatch && dispatch({ + type: as_type(type, 'upload_progress'), + percent, + [type]: id, + }) + } + } + else { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'unable to compute upload progress', + [type]: id, + }) + } + } + + function uploadComplete (e) { + let parsed; + try { + parsed = JSON.parse(e.target.responseText) + } catch (e) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload failed', + [type]: id, + }) + reject(e) + return + } + dispatch && dispatch({ + type: as_type(type, 'upload_complete'), + data: parsed, + [type]: id, + }) + if (parsed.res) { + (parsed.res.length ? parsed.res : [parsed.res]).forEach(file => { + dispatch && dispatch({ + type: as_type('upload', 'create'), + data: { res: file }, + }) + }) + } + resolve(parsed) + } + + function uploadFailed (evt) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload failed', + [type]: id, + }) + reject(evt) + } + + function uploadCancelled (evt) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload cancelled', + [type]: id, + }) + reject(evt) + } + }) +} + +export const upload_action = (type, data) => dispatch => crud_upload(type, data, dispatch) diff --git a/frontend/app/api/index.js b/frontend/app/api/index.js new file mode 100644 index 0000000..c3d0aa4 --- /dev/null +++ b/frontend/app/api/index.js @@ -0,0 +1,24 @@ +import { crud_actions } from 'app/api/crud.actions' +import * as util from 'app/api/utils' + +/* +for our crud events, create corresponding actions +the actions fire a 'loading' event, call the underlying api method, and then resolve. +so you can do ... + import { folderActions } from 'app/api' + folderActions.index({ module: 'samplernn' }) + folderActions.show(12) + folderActions.create({ module: 'samplernn', name: 'foo' }) + folderActions.update(12, { module: 'pix2pix' }) + folderActions.destroy(12, { confirm: true }) + folderActions.upload(12, form_data) +*/ + +export { util } + +export const actions = [ + 'graph', + 'page', + 'tile', + 'upload', +].reduce((a,b) => (a[b] = crud_actions(b)) && a, {}) -- cgit v1.2.3-70-g09d2 From d260e3a65bdec981fd98db8a2352caa9bef9ae55 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Sat, 26 Sep 2020 15:30:15 +0200 Subject: finalizing fixing refactor!! --- README.md | 3 +- cli/app/server/web.py | 8 +--- cli/app/settings/app_cfg.py | 5 +-- frontend/app/api/index.js | 3 -- frontend/app/app.js | 44 -------------------- frontend/app/router.js | 44 ++++++++++++++++++++ frontend/app/views/page/components/tile.new.js | 2 +- frontend/index.js | 2 +- package-lock.json | 57 ++++++++++++++++++++++++++ package.json | 1 + 10 files changed, 109 insertions(+), 60 deletions(-) delete mode 100644 frontend/app/app.js create mode 100644 frontend/app/router.js (limited to 'frontend/app/api') diff --git a/README.md b/README.md index 91bc1bd..0daf5db 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ conda activate swimmer Then build the frontend and run the Flask server: ``` -npm run build +npm run build:dev ./cli.py flask run ``` @@ -51,6 +51,7 @@ Generate a new migration if you've modified the database: ## building the site ``` +npm run build:production ./cli.py site export --graph swimmer ``` diff --git a/cli/app/server/web.py b/cli/app/server/web.py index 0436cd4..1a3b064 100644 --- a/cli/app/server/web.py +++ b/cli/app/server/web.py @@ -12,7 +12,7 @@ handler.setFormatter(formatter) logger.addHandler(handler) logging.getLogger().addHandler(logging.StreamHandler()) -from flask import Flask, Blueprint, send_from_directory, request +from flask import Flask, send_from_directory, request from app.sql.common import db, connection_url from app.settings import app_cfg @@ -25,8 +25,7 @@ def create_app(script_info=None): """ functional pattern for creating the flask app """ - logging.debug("Starting Flask app...") - + logging.debug("Starting Swimmer server...") app = Flask(__name__, static_folder=app_cfg.DIR_STATIC, static_url_path='/static') app.config['SQLALCHEMY_DATABASE_URI'] = connection_url app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False @@ -45,9 +44,6 @@ def create_app(script_info=None): @app.errorhandler(404) def page_not_found(e): return app.send_static_file(index_html), 200 - # path = os.path.join(os.path.dirname(__file__), './static/index.html') - # with open(path, "r") as f: - # return f.read(), 200 @app.route('/', methods=['GET']) def index(): diff --git a/cli/app/settings/app_cfg.py b/cli/app/settings/app_cfg.py index 0d724c7..5fc4982 100644 --- a/cli/app/settings/app_cfg.py +++ b/cli/app/settings/app_cfg.py @@ -58,10 +58,7 @@ URL_MEDIA = join(URL_DATA, 'media') URL_UPLOADS = join(URL_DATA, 'uploads') URL_EXPORTS = join(URL_DATA, 'exports') -if 'cli' in os.getcwd(): - DIR_STATIC = os.path.abspath('../static') -else: - DIR_STATIC = os.path.abspath('static') +DIR_STATIC = join(DIR_APP, 'static') HASH_TREE_DEPTH = 3 # for sha256 subdirs HASH_BRANCH_SIZE = 3 # for sha256 subdirs diff --git a/frontend/app/api/index.js b/frontend/app/api/index.js index c3d0aa4..41cf624 100644 --- a/frontend/app/api/index.js +++ b/frontend/app/api/index.js @@ -1,5 +1,4 @@ import { crud_actions } from 'app/api/crud.actions' -import * as util from 'app/api/utils' /* for our crud events, create corresponding actions @@ -14,8 +13,6 @@ so you can do ... folderActions.upload(12, form_data) */ -export { util } - export const actions = [ 'graph', 'page', diff --git a/frontend/app/app.js b/frontend/app/app.js deleted file mode 100644 index 8dbbd0f..0000000 --- a/frontend/app/app.js +++ /dev/null @@ -1,44 +0,0 @@ -import React, { Component } from 'react' -import { ConnectedRouter } from 'connected-react-router' -import { Route } from 'react-router' - -// import actions from 'app/actions' - -import * as views from 'app/views' - -const viewList = Object.keys(views).map(name => { - const view = views[name] - let path, exact = false - if (name === 'graph') { - path = '/:graph_name' - exact = true - } else if (name === 'page') { - path = '/:graph_name/:page_name' - exact = true - } else { - path = '/' + name - } - return ( - - ) -}) - -export default class App extends Component { - componentDidMount() { - // actions.modelzoo.index() - } - render() { - return ( - -
- {viewList} - { - // redirect to index!! - setTimeout(() => this.props.history.push('/index'), 10) - return null - }} /> -
-
- ) - } -} diff --git a/frontend/app/router.js b/frontend/app/router.js new file mode 100644 index 0000000..8dbbd0f --- /dev/null +++ b/frontend/app/router.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react' +import { ConnectedRouter } from 'connected-react-router' +import { Route } from 'react-router' + +// import actions from 'app/actions' + +import * as views from 'app/views' + +const viewList = Object.keys(views).map(name => { + const view = views[name] + let path, exact = false + if (name === 'graph') { + path = '/:graph_name' + exact = true + } else if (name === 'page') { + path = '/:graph_name/:page_name' + exact = true + } else { + path = '/' + name + } + return ( + + ) +}) + +export default class App extends Component { + componentDidMount() { + // actions.modelzoo.index() + } + render() { + return ( + +
+ {viewList} + { + // redirect to index!! + setTimeout(() => this.props.history.push('/index'), 10) + return null + }} /> +
+
+ ) + } +} diff --git a/frontend/app/views/page/components/tile.new.js b/frontend/app/views/page/components/tile.new.js index fb609a5..b491fdd 100644 --- a/frontend/app/views/page/components/tile.new.js +++ b/frontend/app/views/page/components/tile.new.js @@ -35,7 +35,7 @@ class TileNew extends Component { graph={this.props.graph.show.res} page={this.props.page.show.res} initialData={null} - sortOrder={this.props.page.show.res.tiles.length} + sortOrder={this.props.page.show.res.tiles ? this.props.page.show.res.tiles.length : []} onSubmit={this.handleSubmit.bind(this)} /> ) diff --git a/frontend/index.js b/frontend/index.js index 393282c..8daf531 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -4,7 +4,7 @@ import { Provider } from 'react-redux' import Router from 'app/router' -import { store, history } from '/store' +import { store, history } from 'app/store' const container = document.createElement('div') container.classList.add('container') diff --git a/package-lock.json b/package-lock.json index 315c8a5..2787cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3962,6 +3962,41 @@ "object.assign": "^4.1.0" } }, + "babel-plugin-module-resolver": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-4.0.0.tgz", + "integrity": "sha512-3pdEq3PXALilSJ6dnC4wMWr0AZixHRM4utpdpBR9g5QG7B7JwWyukQv7a9hVxkbGFl+nQbrHDqqQOIBtTXTP/Q==", + "requires": { + "find-babel-config": "^1.2.0", + "glob": "^7.1.6", + "pkg-up": "^3.1.0", + "reselect": "^4.0.0", + "resolve": "^1.13.1" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, "babel-plugin-syntax-async-functions": { "version": "6.13.0", "resolved": "http://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", @@ -6237,6 +6272,15 @@ } } }, + "find-babel-config": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-1.2.0.tgz", + "integrity": "sha512-jB2CHJeqy6a820ssiqwrKMeyC6nNdmrcgkKWJWmpoxpE8RKciYJXCcXRq1h2AzCo5I5BJeN2tkGEO3hLTuePRA==", + "requires": { + "json5": "^0.5.1", + "path-exists": "^3.0.0" + } + }, "find-cache-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", @@ -8663,6 +8707,14 @@ "find-up": "^3.0.0" } }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "requires": { + "find-up": "^3.0.0" + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -9608,6 +9660,11 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, + "reselect": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz", + "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==" + }, "resolve": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", diff --git a/package.json b/package.json index fc24c4f..c6984ef 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "aws-sdk": "^2.631.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.0.6", + "babel-plugin-module-resolver": "^4.0.0", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", "babel-plugin-transform-object-rest-spread": "^6.23.0", -- cgit v1.2.3-70-g09d2