From e0e8928112a4f6a335364e3acfd56d151c40eb1e Mon Sep 17 00:00:00 2001 From: Lucas Lira Gomes Date: Thu, 8 Aug 2019 13:33:09 +0200 Subject: [PATCH] fix: fix a couple of bugs in models and data classes --- package-lock.json | 10 +++++ package.json | 2 + rollup.config.js | 2 +- src/asyncResolver.ts | 92 +++++++++++++++++++++++++++++++++++++++++ src/data.ts | 26 +++++++----- src/hooks/index.ts | 1 + src/hooks/useModel.ts | 9 ++-- src/hooks/useService.ts | 90 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- src/model.ts | 28 ++++++++----- src/redux.ts | 13 +++++- 11 files changed, 249 insertions(+), 28 deletions(-) create mode 100644 src/asyncResolver.ts create mode 100644 src/hooks/useService.ts diff --git a/package-lock.json b/package-lock.json index 83bf307..e805b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3147,6 +3147,16 @@ } } }, + "axios-hooks": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/axios-hooks/-/axios-hooks-1.3.0.tgz", + "integrity": "sha512-8sLrH84lx7NPumhEPdRBIS7hCyRoeziyPD5IMANIJejBJO4rDs9XBHd6hpEhqON1AK8L4ETaOXTZ0GhbmV/kYQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "lru-cache": "^5.1.1" + } + }, "axobject-query": { "version": "2.0.2", "resolved": "https://artifacts.runwaynine.com/repository/npm/axobject-query/-/axobject-query-2.0.2.tgz", diff --git a/package.json b/package.json index 20ac5a9..f2c6a73 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@typescript-eslint/eslint-plugin": "1.13.0", "@typescript-eslint/parser": "1.13.0", "axios": "0.19.0", + "axios-hooks": "1.3.0", "commitizen": "4.0.3", "conventional-changelog-cli": "2.0.23", "coveralls": "3.0.5", @@ -81,6 +82,7 @@ "@types/react-redux": ">=7.1.1", "@types/redux": ">=3.6.0", "axios": ">=0.19.0", + "axios-hooks": ">=1.3.0", "immer": ">=3.1.3", "lodash-es": ">=4.17.15", "normalizr": ">=3.4.0", diff --git a/rollup.config.js b/rollup.config.js index 7d0abbe..bf444ac 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,6 @@ import typescript from 'rollup-plugin-typescript2'; +import { terser } from 'rollup-plugin-terser'; import pkg from './package.json'; -import { terser } from "rollup-plugin-terser"; export default { input: 'src/index.ts', diff --git a/src/asyncResolver.ts b/src/asyncResolver.ts new file mode 100644 index 0000000..c77d3ca --- /dev/null +++ b/src/asyncResolver.ts @@ -0,0 +1,92 @@ +import produce from 'immer'; +import {createSelector} from 'reselect'; +import {AnyAction, Reducer} from 'redux'; +import {toPairs,} from 'lodash'; + +interface ActionTypes { + started: string; + failed: string; + succeeded: string; +} + +interface Actions { + start: Function; + fail: Function; + succeed: Function; +} + +export class AsyncResolver { + public readonly namespace: string; + + public constructor() { + this.namespace = 'async'; + + this.actionTypes = this.actionTypes.bind(this); + this.selectors = this.selectors.bind(this); + this.reducers = this.reducers.bind(this); + } + + public actionTypes(): ActionTypes { + return { + started: `${this.namespace}.started`, + failed: `${this.namespace}.failed`, + succeeded: `${this.namespace}.succeeded`, + }; + } + + public actions(): Actions { + const actionTypes = this.actionTypes(); + + return { + start: (id: string, metadata={}) => { + return { type: actionTypes.started, id, metadata }; + }, + fail: (id: string, error, metadata={}) => { + return { type: actionTypes.failed, id, payload: error, metadata }; + }, + succeed: (id: string, metadata={}) => { + return { type: actionTypes.succeeded, id, metadata }; + } + }; + } + + public selectors(id: string): SelectorFunction { + const selectorFunc = state => state[id] || {isLoading: true, error: null}; + + return createSelector([selectorFunc], data => data); + } + + public reducers(): Reducer { + const actionTypes = this.actionTypes(); + + return produce((draft: object, { + type, id, payload, metadata, + }) => { + switch (type) { + case actionTypes.started: + draft[id] = {isLoading: true, error: null, metadata}; + return; + + case actionTypes.failed: + draft[id].isLoading = false; + draft[id].error = payload; + + for (const [key, value] of toPairs(metadata)) { + draft[id].metadata[key] = value; + } + + return; + + case actionTypes.succeeded: + draft[id].isLoading = false; + draft[id].error = null; + + for (const [key, value] of toPairs(metadata)) { + draft[id].metadata[key] = value; + } + + return; + } + }, {}); + } +} diff --git a/src/data.ts b/src/data.ts index bdd4268..d3c3bfb 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,6 +1,6 @@ -import { keys, omit } from 'lodash'; +import { keys, isArray, omit } from 'lodash'; import { createSelector } from 'reselect'; -import { DispatchProp } from 'react-redux'; +import { DispatchProp, batch } from 'react-redux'; import produce from 'immer'; import { Model } from './model'; @@ -37,13 +37,14 @@ export class Data { get: (object, key) => object[key], }); - for (const view of this._viewKeys) { - const selector = this.viewSelectors(view).bind(this); - proxy[view] = () => selector(proxy); + for (const viewKey of this._viewKeys) { + const selector = this.viewSelectors(viewKey).bind(this); + proxy[viewKey] = () => isArray(data) ? proxy.map(selector) : selector(proxy); } - for (const controller of this._controllerKeys) { - proxy[controller] = this.controllers(controller).bind(this); + for (const controllerKey of this._controllerKeys) { + const controller = this.controllers(controllerKey).bind(this); + proxy[controllerKey] = controller; } return proxy; @@ -62,11 +63,16 @@ export class Data { verifyIsControllerValid(controller); const actions = this._model.actions(); - const controllerFunc = produce(this._model.controllers[controller]); + const controllerFunc = produce((...args) => { this._model.controllers[controller](...args); }); return (...args) => { - const dataWithoutViewsAndControllers = omit(this._data, [...this._viewKeys, ...this._controllerKeys]); - const payload = [controllerFunc(dataWithoutViewsAndControllers, ...args)]; + const data = isArray(this._data) ? this._data : [this._data]; + const payload = batch( + () => data.map(instance => { + const dataWithoutHelpers = omit(instance, [...this._viewKeys, ...this._controllerKeys]); + return controllerFunc(dataWithoutHelpers, ...args) + }) + ); this._dispatch(actions.set(this._scope, this._scopeId, payload)); }; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7fca57e..a5863fe 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export { useModel } from './useModel'; +export { useService } from './useService'; diff --git a/src/hooks/useModel.ts b/src/hooks/useModel.ts index f5e11b5..4cd8df0 100644 --- a/src/hooks/useModel.ts +++ b/src/hooks/useModel.ts @@ -3,20 +3,21 @@ import { useDispatch, useSelector, DispatchProp } from 'react-redux'; import { Model } from '../model'; import { Data } from '../data'; -export function useModel(model: Model): Record any> { +export function useModel(model: Model, namespace: string=''): Record any> { const dispatch: DispatchProp = useDispatch(); + const state = useSelector( + (state: Record) => namespace === '' ? state : state['models'] + ) as object; return React.useMemo(() => { const data = {}; - [model.defaultScope, ...model.scopes].forEach((scope: string) => { data[scope] = (scopeId: ScopeId) => { - const state = useSelector(state => state) as object; const reducedData = model.selectors(scope, scopeId)(state); return new Data(dispatch, model, reducedData, scope, scopeId); }; }); return data; - }, [model]); + }, [model, dispatch, state]); } diff --git a/src/hooks/useService.ts b/src/hooks/useService.ts new file mode 100644 index 0000000..e8ae0d8 --- /dev/null +++ b/src/hooks/useService.ts @@ -0,0 +1,90 @@ +import * as React from 'react'; +import * as urlComposer from 'url-composer'; +import {Model} from '../model'; +import {AsyncResolver} from '../asyncResolver'; +import {Method} from "axios"; +import axios from 'axios'; +import {useDispatch, useSelector, batch} from "react-redux"; + +interface ServiceSpec { + model: Model; + host: string; + urls: Record>; +} + +interface AxiosConfig { + data?: object | object[]; + headers?: object; + pathParams?: object; + queryParams?: object; +} + +export function useService(serviceSpec: ServiceSpec): Record any> { + const dispatch = useDispatch(); + const asyncResolver = new AsyncResolver(); + const state = useSelector( + (state: Record) => state[asyncResolver.namespace] + ) as object; + + function buildAxiousCall(scope: string, scopeId: ScopeId, method: Method) { + return (config: AxiosConfig): SelectorFunction => { + const url = urlComposer.build({ + host: serviceSpec.host, + path: serviceSpec.urls[scope], + params: config.pathParams, + query: config.queryParams, + }); + + React.useEffect(() => { + dispatch(asyncResolver.actions().start(url)); + + axios({ + ...config, + method, + url, + }).then( response => { + const data = response.data; + const responseMetadata = { + status: response.status, + headers: response.headers, + }; + + batch(() => { + if (['get', 'post', 'put'].includes(method)) { + dispatch(serviceSpec.model.actions().set(scope, scopeId, data)); + } else if (['delete'].includes(method)) { + dispatch(serviceSpec.model.actions().remove(scope, scopeId)); + } + dispatch(asyncResolver.actions().succeed(url, responseMetadata)); + }); + }).catch(error => { + const responseMetadata = error.response ? { + status: error.response.status, + headers: error.response.headers, + } : {}; + dispatch(asyncResolver.actions().fail(url, error.message, responseMetadata)); + }); + }, [url]); + + return asyncResolver.selectors(url)(state); + }; + }; + + return React.useMemo(() => { + const data = {}; + + [serviceSpec.model.defaultScope, ...serviceSpec.model.scopes].forEach((scope: string) => { + data[scope] = (scopeId: ScopeId) => ({ + get: buildAxiousCall(scope, scopeId, 'get'), + post: buildAxiousCall(scope, scopeId, 'post'), + put: buildAxiousCall(scope, scopeId, 'put'), + patch: buildAxiousCall(scope, scopeId, 'patch'), + delete: buildAxiousCall(scope, scopeId, 'delete'), + options: buildAxiousCall(scope, scopeId, 'options'), + head: buildAxiousCall(scope, scopeId, 'head'), + }); + }); + + return data; + }, [serviceSpec, state]); +} diff --git a/src/index.ts b/src/index.ts index 0b9863b..ff88b2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export { Model } from './model'; export { Data } from './data'; -export { useModel } from './hooks'; +export { AsyncResolver } from './asyncResolver'; +export { useModel } from './hooks/useModel'; +export { useService } from './hooks/useService'; export { combineModelReducers } from './redux'; diff --git a/src/model.ts b/src/model.ts index 0afaf41..f1d3722 100644 --- a/src/model.ts +++ b/src/model.ts @@ -85,10 +85,10 @@ export class Model { verifyIsScopeValid(scope); return { type: actionTypes.remove, scope, scopeId }; }, - set: (scope: string, scopeId: ScopeId, payload: object[]) => { + set: (scope: string, scopeId: ScopeId, payload: object | object[]) => { verifyIsScopeValid(scope); return { - type: actionTypes.set, scope, scopeId, payload, + type: actionTypes.set, scope, scopeId, payload: isArray(payload) ? payload : [payload], }; }, }; @@ -149,6 +149,8 @@ export class Model { // We use setWith because it mutates an existing object. In this case the draft. That is important // in order to keep unaffected objects untouched. Otherwise that could cause unnecessary re-renders // in unrelated components. + const idsForScopeId = []; + for (const instance of payload) { const normalizedData = normalize(instance, this._schema); @@ -165,14 +167,20 @@ export class Model { } } - if (scope !== this.defaultScope) { - setWith( - draft, - `${this.namespace}.${scope}.${scopeId}`, - values(normalizedData.entities[this.namespace]).map(entity => entity.id), - Object, - ); - } + idsForScopeId.push( + ...values( + normalizedData.entities[this.namespace] + ).map(entity => entity[this.defaultScopeIdField]), + ); + } + + if (scope !== this.defaultScope) { + setWith( + draft, + `${this.namespace}.${scope}.${scopeId}`, + idsForScopeId, + Object, + ); } } }, {}); diff --git a/src/redux.ts b/src/redux.ts index 88e7134..0858a01 100644 --- a/src/redux.ts +++ b/src/redux.ts @@ -1,6 +1,15 @@ -import { Reducer, AnyAction, compose } from 'redux'; +import { Reducer, AnyAction } from 'redux'; import { Model } from './model'; export function combineModelReducers(models: Model[]): Reducer { - return compose(...models.map(model => model.reducers())); + const reducers = models.map(model => model.reducers()); + return (state, action) => { + let reducedState = state; + + for (const reducer of reducers) { + reducedState = reducer(reducedState, action); + } + + return reducedState; + } }