Skip to content

Commit

Permalink
fix: fix a couple of bugs in models and data classes
Browse files Browse the repository at this point in the history
  • Loading branch information
x8lucas8x committed Aug 8, 2019
1 parent 0feddad commit e0e8928
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 28 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
92 changes: 92 additions & 0 deletions src/asyncResolver.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, AnyAction> {
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;
}
}, {});
}
}
26 changes: 16 additions & 10 deletions src/data.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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));
};
}
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useModel } from './useModel';
export { useService } from './useService';
9 changes: 5 additions & 4 deletions src/hooks/useModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (ScopeId) => any> {
export function useModel(model: Model, namespace: string=''): Record<string, (ScopeId) => any> {
const dispatch: DispatchProp = useDispatch();
const state = useSelector(
(state: Record<string, any>) => 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]);
}
90 changes: 90 additions & 0 deletions src/hooks/useService.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<ScopeId, string>>;
}

interface AxiosConfig {
data?: object | object[];
headers?: object;
pathParams?: object;
queryParams?: object;
}

export function useService(serviceSpec: ServiceSpec): Record<string, (ScopeId) => any> {
const dispatch = useDispatch();
const asyncResolver = new AsyncResolver();
const state = useSelector(
(state: Record<string, any>) => 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]);
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
28 changes: 18 additions & 10 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
};
},
};
Expand Down Expand Up @@ -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);

Expand All @@ -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,
);
}
}
}, {});
Expand Down
13 changes: 11 additions & 2 deletions src/redux.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, AnyAction> {
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;
}
}

0 comments on commit e0e8928

Please sign in to comment.