Merge branch 'selectors'

This commit is contained in:
Yanick Champoux 2020-02-03 10:03:54 -05:00
commit 4665ee39f7
11 changed files with 465 additions and 242 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/ node_modules/
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
**/*.orig

View File

@ -1,5 +1,6 @@
<!-- docs/_sidebar.md --> <!-- docs/_sidebar.md -->
* [Home](/) * [Home](/)
* [Concepts](concepts.md)
* API Reference * API Reference
* [Updux](updux.md) * [Updux](updux.md)

30
docs/concepts.md Normal file
View File

@ -0,0 +1,30 @@
# Updux concepts
## effects
Updux effects are a superset of redux middleware. I kept that format, and the
use of `next` mostly because I wanted to give myself a way to alter
actions before they hit the reducer, something that `redux-saga` and
`rematch` don't allow.
An effect has the signature
```js
const effect = ({ getState, dispatch, getRootState, selectors})
=> next => action => { ... }
```
The first argument is like the usual redux middlewareApi, except
for the availability of selectors and of the root updux's state.
Also, the function `dispatch` is augmented to be able to be called
with the allowed actions as props. For example, assuming that the action
`complete_todo` has been declared somewhere, then it's possible to do:
```js
updux.addEffect( 'todo_bankrupcy',
({ getState, dispatch }) => next => action => {
getState.forEach( todo => dispatch.complete_todo( todo.id ) );
}
)
```

View File

@ -36,6 +36,23 @@ const { actions } = updux({
actions.foo({ x: 1, y: 2 }); // => { type: foo, payload: { x:1, y:2 } } actions.foo({ x: 1, y: 2 }); // => { type: foo, payload: { x:1, y:2 } }
actions.bar(1,2); // => { type: bar, payload: { x:1, y:2 } } actions.bar(1,2); // => { type: bar, payload: { x:1, y:2 } }
#### selectors
Dictionary of selectors for the current updux. The updux also
inherit its dubduxes' selectors.
The selectors are available via the class' getter and, for
middlewares, the middlewareApi.
```js
const todoUpdux = new Updux({
selectors: {
done: state => state.filter( ({done}) => done ),
byId: state => targetId => state.find( ({id}) => id === targetId ),
}
}
``` ```
#### mutations #### mutations
@ -332,3 +349,11 @@ baz(); // => { type: 'baz', payload: undefined }
``` ```
### selectors
Returns a dictionary of the
updux's selectors. Subduxes' selectors
are included as well (with the mapping to the sub-state already
taken care of you).

View File

@ -10,6 +10,7 @@ import {
UpduxMiddlewareAPI, UpduxMiddlewareAPI,
EffectEntry EffectEntry
} from "../types"; } from "../types";
import Updux from "..";
const MiddlewareFor = ( const MiddlewareFor = (
type: any, type: any,
@ -23,12 +24,13 @@ const MiddlewareFor = (
type Next = (action: Action) => any; type Next = (action: Action) => any;
function sliceMw(slice: string, mw: Middleware): Middleware { function sliceMw(slice: string, mw: Middleware, updux: Updux): Middleware {
return api => { return api => {
const getSliceState = const getSliceState =
slice.length > 0 ? () => fp.get(slice, api.getState()) : api.getState; slice.length > 0 ? () => fp.get(slice, api.getState()) : api.getState;
const getRootState = (api as any).getRootState || api.getState; const getRootState = (api as any).getRootState || api.getState;
return mw({ ...api, getState: getSliceState, getRootState } as any); return mw({ ...api, getState: getSliceState, getRootState,
selectors: updux.selectors } as any);
}; };
} }
@ -37,11 +39,11 @@ function buildMiddleware<S = any>(
actions: Dictionary<ActionCreator> = {} actions: Dictionary<ActionCreator> = {}
): UpduxMiddleware<S> { ): UpduxMiddleware<S> {
let mws = middlewareEntries let mws = middlewareEntries
.map(([slice, actionType, mw, isGen]: any) => .map(([updux, slice, actionType, mw, isGen]: any) =>
isGen ? [slice, actionType, mw()] : [slice, actionType, mw] isGen ? [updux, slice, actionType, mw()] : [updux, slice, actionType, mw]
) )
.map(([slice, actionType, mw]) => .map(([updux, slice, actionType, mw]) =>
MiddlewareFor(actionType, sliceMw(slice, mw)) MiddlewareFor(actionType, sliceMw(slice, mw, updux))
); );
return (api: UpduxMiddlewareAPI<S>) => { return (api: UpduxMiddlewareAPI<S>) => {

View File

@ -0,0 +1,26 @@
import fp from 'lodash/fp';
import Updux from '..';
import { Dictionary, Selector } from '../types';
function subSelectors([slice, subdux]: [string, Updux]): [string, Selector][] {
const selectors = subdux.selectors;
if (!selectors) return [];
return Object.entries(
fp.mapValues(selector => (state: any) =>
(selector as any)(state[slice])
)(selectors)
);
}
export default function buildSelectors(
localSelectors: Dictionary<Selector> = {},
subduxes: Dictionary<Updux> = {}
) {
return Object.fromEntries(
[
Object.entries(subduxes).flatMap(subSelectors),
Object.entries(localSelectors),
].flat()
);
}

View File

@ -1,7 +1,8 @@
import Updux, { actionCreator } from "."; import Updux, { actionCreator } from '.';
import u from "updeep"; import u from 'updeep';
import mwUpdux from './middleware_aux';
test("simple effect", () => { test('simple effect', () => {
const tracer = jest.fn(); const tracer = jest.fn();
const store = new Updux({ const store = new Updux({
@ -9,13 +10,13 @@ test("simple effect", () => {
foo: (api: any) => (next: any) => (action: any) => { foo: (api: any) => (next: any) => (action: any) => {
tracer(); tracer();
next(action); next(action);
} },
} },
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" }); store.dispatch({ type: 'bar' });
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
@ -24,7 +25,7 @@ test("simple effect", () => {
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test("effect and sub-effect", () => { test('effect and sub-effect', () => {
const tracer = jest.fn(); const tracer = jest.fn();
const tracerEffect = (signature: string) => (api: any) => (next: any) => ( const tracerEffect = (signature: string) => (api: any) => (next: any) => (
@ -36,49 +37,81 @@ test("effect and sub-effect", () => {
const store = new Updux({ const store = new Updux({
effects: { effects: {
foo: tracerEffect("root") foo: tracerEffect('root'),
}, },
subduxes: { subduxes: {
zzz: { zzz: {
effects: { effects: {
foo: tracerEffect("child") foo: tracerEffect('child'),
} },
} },
} },
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" }); store.dispatch({ type: 'bar' });
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo(); store.dispatch.foo();
expect(tracer).toHaveBeenNthCalledWith(1, "root"); expect(tracer).toHaveBeenNthCalledWith(1, 'root');
expect(tracer).toHaveBeenNthCalledWith(2, "child"); expect(tracer).toHaveBeenNthCalledWith(2, 'child');
}); });
test('"*" effect', () => { describe('"*" effect', () => {
test('from the constructor', () => {
const tracer = jest.fn(); const tracer = jest.fn();
const store = new Updux({ const store = new Updux({
effects: { effects: {
"*": api => next => action => { '*': api => next => action => {
tracer(); tracer();
next(action); next(action);
} },
} },
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" }); store.dispatch({ type: 'bar' });
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test("async effect", async () => { test('from addEffect', () => {
const tracer = jest.fn();
const updux = new Updux({});
updux.addEffect('*', api => next => action => {
tracer();
next(action);
});
expect(tracer).not.toHaveBeenCalled();
updux.createStore().dispatch({ type: 'bar' });
expect(tracer).toHaveBeenCalled();
});
test('action can be modified', () => {
const mw = mwUpdux.middleware;
const next = jest.fn();
mw({dispatch:{}} as any)(next as any)({type: 'bar'});
expect(next).toHaveBeenCalled();
expect(next.mock.calls[0][0]).toMatchObject({meta: 'gotcha'});
});
});
test('async effect', async () => {
function timeout(ms: number) { function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@ -91,8 +124,8 @@ test("async effect", async () => {
next(action); next(action);
await timeout(1000); await timeout(1000);
tracer(); tracer();
} },
} },
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
@ -106,7 +139,7 @@ test("async effect", async () => {
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test("getState is local", () => { test('getState is local', () => {
let childState; let childState;
let rootState; let rootState;
let rootFromChild; let rootFromChild;
@ -118,8 +151,8 @@ test("getState is local", () => {
childState = getState(); childState = getState();
rootFromChild = getRootState(); rootFromChild = getRootState();
next(action); next(action);
} },
} },
}); });
const root = new Updux({ const root = new Updux({
@ -129,8 +162,8 @@ test("getState is local", () => {
doIt: ({ getState }) => next => action => { doIt: ({ getState }) => next => action => {
rootState = getState(); rootState = getState();
next(action); next(action);
} },
} },
}); });
const store = root.createStore(); const store = root.createStore();
@ -141,78 +174,94 @@ test("getState is local", () => {
expect(childState).toEqual({ alpha: 12 }); expect(childState).toEqual({ alpha: 12 });
}); });
test("middleware as map", () => { test('middleware as map', () => {
let childState; let childState;
let rootState; let rootState;
let rootFromChild; let rootFromChild;
const doIt = actionCreator("doIt"); const doIt = actionCreator('doIt');
const child = new Updux({ const child = new Updux({
initial: "", initial: '',
effects: [ effects: [
[ [
doIt, doIt,
() => next => action => { () => next => action => {
next(u({ payload: (p: string) => p + "Child" }, action) as any); next(
} u(
] { payload: (p: string) => p + 'Child' },
] action
) as any
);
},
],
],
}); });
const root = new Updux({ const root = new Updux({
initial: { message: "" }, initial: { message: '' },
subduxes: { child }, subduxes: { child },
effects: [ effects: [
[ [
"^", '^',
() => next => action => { () => next => action => {
next(u({ payload: (p: string) => p + "Pre" }, action) as any); next(
} u({ payload: (p: string) => p + 'Pre' }, action) as any
);
},
], ],
[ [
doIt, doIt,
() => next => action => { () => next => action => {
next(u({ payload: (p: string) => p + "Root" }, action) as any); next(
} u({ payload: (p: string) => p + 'Root' }, action) as any
);
},
], ],
[ [
"*", '*',
() => next => action => { () => next => action => {
next(u({ payload: (p: string) => p + "After" }, action) as any); next(
} u(
{ payload: (p: string) => p + 'After' },
action
) as any
);
},
], ],
[ [
"$", '$',
() => next => action => { () => next => action => {
next(u({ payload: (p: string) => p + "End" }, action) as any); next(
} u({ payload: (p: string) => p + 'End' }, action) as any
] );
},
], ],
mutations: [[doIt, (message: any) => () => ({ message })]] ],
mutations: [[doIt, (message: any) => () => ({ message })]],
}); });
const store = root.createStore(); const store = root.createStore();
store.dispatch.doIt(""); store.dispatch.doIt('');
expect(store.getState()).toEqual({ message: "PreRootAfterChildEnd" }); expect(store.getState()).toEqual({ message: 'PreRootAfterChildEnd' });
}); });
test("generator", () => { test('generator', () => {
const updux = new Updux({ const updux = new Updux({
initial: 0, initial: 0,
mutations: [["doIt", payload => () => payload]], mutations: [['doIt', payload => () => payload]],
effects: [ effects: [
[ [
"doIt", 'doIt',
() => { () => {
let i = 0; let i = 0;
return () => (next: any) => (action: any) => return () => (next: any) => (action: any) =>
next({ ...action, payload: ++i }); next({ ...action, payload: ++i });
}, },
true true,
] ],
] ],
}); });
const store1 = updux.createStore(); const store1 = updux.createStore();

13
src/middleware_aux.ts Normal file
View File

@ -0,0 +1,13 @@
import Updux from '.';
const updux = new Updux({
subduxes: {
foo: { initial: "banana" }
}
});
updux.addEffect('*', api => next => action => {
next({...action, meta: "gotcha" });
});
export default updux;

55
src/selectors.test.ts Normal file
View File

@ -0,0 +1,55 @@
import Updux from '.';
test('basic selectors', () => {
const updux = new Updux({
subduxes: {
bogeys: {
selectors: {
bogey: (bogeys: any) => (id: string) => bogeys[id],
},
},
},
selectors: {
bogeys: ({ bogeys }: any) => bogeys,
},
});
const state = {
bogeys: {
foo: 1,
bar: 2,
},
};
expect(updux.selectors.bogeys(state)).toEqual({ foo: 1, bar: 2 });
expect((updux.selectors.bogey(state) as any)('foo')).toEqual(1);
});
test('available in the middleware', () => {
const updux = new Updux({
subduxes: {
bogeys: {
initial: { enkidu: 'foo' },
selectors: {
bogey: (bogeys: any) => (id: string) => bogeys[id],
},
},
},
effects: {
doIt: ({ selectors: { bogey }, getState }) => next => action => {
next({
...action,
payload: bogey(getState())('enkidu'),
});
},
},
mutations: {
doIt: payload => state => ({ ...state, payload }),
},
});
const store = updux.createStore();
store.dispatch.doIt();
expect(store.getState()).toMatchObject({ payload: 'foo' });
});

View File

@ -98,6 +98,8 @@ export type UpduxConfig<S = any> = {
[type: string]: ActionCreator; [type: string]: ActionCreator;
}; };
selectors?: Dictionary<Selector>;
/** /**
* Object mapping actions to the associated state mutation. * Object mapping actions to the associated state mutation.
* *
@ -198,7 +200,10 @@ export interface UpduxMiddlewareAPI<S> {
dispatch: UpduxDispatch; dispatch: UpduxDispatch;
getState(): any; getState(): any;
getRootState(): S; getRootState(): S;
selectors: Dictionary<Selector>;
} }
export type UpduxMiddleware<S = any> = ( export type UpduxMiddleware<S = any> = (
api: UpduxMiddlewareAPI<S> api: UpduxMiddlewareAPI<S>
) => (next: UpduxDispatch) => (action: Action) => any; ) => (next: UpduxDispatch) => (action: Action) => any;
export type Selector<S = any> = (state:S) => any;

View File

@ -18,10 +18,12 @@ import {
UpduxDispatch, UpduxDispatch,
UpduxMiddleware, UpduxMiddleware,
MutationEntry, MutationEntry,
EffectEntry EffectEntry,
Selector
} from "./types"; } from "./types";
import { Middleware, Store, PreloadedState } from "redux"; import { Middleware, Store, PreloadedState } from "redux";
import buildSelectors from "./buildSelectors";
export { actionCreator } from "./buildActions"; export { actionCreator } from "./buildActions";
type StoreWithDispatchActions< type StoreWithDispatchActions<
@ -44,12 +46,15 @@ export type Dux<S> = Pick<
>; >;
export class Updux<S = any> { export class Updux<S = any> {
subduxes: Dictionary<Updux>; subduxes: Dictionary<Updux> = {};
private local_selectors: Dictionary<Selector<S>> = {};
initial: S; initial: S;
groomMutations: (mutation: Mutation<S>) => Mutation<S>; groomMutations: (mutation: Mutation<S>) => Mutation<S>;
private localEffects: EffectEntry<S>[] = []; private localEffects: EffectEntry<S>[] = [];
private localActions: Dictionary<ActionCreator> = {}; private localActions: Dictionary<ActionCreator> = {};
@ -61,9 +66,14 @@ export class Updux<S = any> {
constructor(config: UpduxConfig = {}) { constructor(config: UpduxConfig = {}) {
this.groomMutations = config.groomMutations || ((x: Mutation<S>) => x); this.groomMutations = config.groomMutations || ((x: Mutation<S>) => x);
this.subduxes = fp.mapValues((value: UpduxConfig | Updux) => const selectors = fp.getOr( {}, 'selectors', config ) as Dictionary<Selector>;
fp.isPlainObject(value) ? new Updux(value) : value Object.entries(selectors).forEach( ([name,sel]: [string,Function]) => this.addSelector(name,sel as Selector) );
)(fp.getOr({}, "subduxes", config)) as Dictionary<Updux>;
Object.entries( fp.mapValues((value: UpduxConfig | Updux) =>
fp.isPlainObject(value) ? new Updux(value as any) : value
)(fp.getOr({}, "subduxes", config))).forEach(
([slice,sub]) => this.subduxes[slice] = sub as any
);
const actions = fp.getOr({}, "actions", config); const actions = fp.getOr({}, "actions", config);
Object.entries(actions).forEach(([type, payload]: [string, any]): any => Object.entries(actions).forEach(([type, payload]: [string, any]): any =>
@ -96,7 +106,6 @@ export class Updux<S = any> {
} }
get actions(): Dictionary<ActionCreator> { get actions(): Dictionary<ActionCreator> {
return buildActions([ return buildActions([
...(Object.entries(this.localActions) as any), ...(Object.entries(this.localActions) as any),
...(fp.flatten( ...(fp.flatten(
@ -191,22 +200,21 @@ export class Updux<S = any> {
get _middlewareEntries() { get _middlewareEntries() {
const groupByOrder = (mws: any) => const groupByOrder = (mws: any) =>
fp.groupBy( fp.groupBy(
([_, actionType]: any) => ([a,b, actionType]: any) =>
["^", "$"].includes(actionType) ? actionType : "middle", ["^", "$"].includes(actionType) ? actionType : "middle",
mws mws
); );
let subs = fp.flow([ let subs = fp.flow([
fp.mapValues("_middlewareEntries"),
fp.toPairs, fp.toPairs,
fp.map(([slice, entries]) => fp.map(([slice, updux]) =>
entries.map(([ps, ...args]: any) => [[slice, ...ps], ...args]) updux._middlewareEntries.map(([u, ps, ...args]: any) => [u,[slice, ...ps], ...args])
), ),
fp.flatten, fp.flatten,
groupByOrder groupByOrder
])(this.subduxes); ])(this.subduxes);
let local = groupByOrder(this.localEffects.map(x => [[], ...x])); let local = groupByOrder(this.localEffects.map(x => [this,[], ...x]));
return fp.flatten( return fp.flatten(
[ [
@ -219,6 +227,14 @@ export class Updux<S = any> {
].filter(x => x) ].filter(x => x)
); );
} }
addSelector( name: string, selector: Selector) {
this.local_selectors[name] = selector;
}
get selectors() {
return buildSelectors(this.local_selectors, this.subduxes);
}
} }
export default Updux; export default Updux;