Merge branch 'selectors'

typescript
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/
tsconfig.tsbuildinfo
**/*.orig

View File

@ -1,5 +1,6 @@
<!-- docs/_sidebar.md -->
* [Home](/)
* [Concepts](concepts.md)
* API Reference
* [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.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
@ -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,
EffectEntry
} from "../types";
import Updux from "..";
const MiddlewareFor = (
type: any,
@ -23,12 +24,13 @@ const MiddlewareFor = (
type Next = (action: Action) => any;
function sliceMw(slice: string, mw: Middleware): Middleware {
function sliceMw(slice: string, mw: Middleware, updux: Updux): Middleware {
return api => {
const getSliceState =
slice.length > 0 ? () => fp.get(slice, api.getState()) : 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> = {}
): UpduxMiddleware<S> {
let mws = middlewareEntries
.map(([slice, actionType, mw, isGen]: any) =>
isGen ? [slice, actionType, mw()] : [slice, actionType, mw]
.map(([updux, slice, actionType, mw, isGen]: any) =>
isGen ? [updux, slice, actionType, mw()] : [updux, slice, actionType, mw]
)
.map(([slice, actionType, mw]) =>
MiddlewareFor(actionType, sliceMw(slice, mw))
.map(([updux, slice, actionType, mw]) =>
MiddlewareFor(actionType, sliceMw(slice, mw, updux))
);
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,228 +1,277 @@
import Updux, { actionCreator } from ".";
import u from "updeep";
import Updux, { actionCreator } from '.';
import u from 'updeep';
import mwUpdux from './middleware_aux';
test("simple effect", () => {
const tracer = jest.fn();
test('simple effect', () => {
const tracer = jest.fn();
const store = new Updux({
effects: {
foo: (api: any) => (next: any) => (action: any) => {
tracer();
next(action);
}
}
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" });
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).toHaveBeenCalled();
});
test("effect and sub-effect", () => {
const tracer = jest.fn();
const tracerEffect = (signature: string) => (api: any) => (next: any) => (
action: any
) => {
tracer(signature);
next(action);
};
const store = new Updux({
effects: {
foo: tracerEffect("root")
},
subduxes: {
zzz: {
const store = new Updux({
effects: {
foo: tracerEffect("child")
}
}
}
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" });
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).toHaveBeenNthCalledWith(1, "root");
expect(tracer).toHaveBeenNthCalledWith(2, "child");
});
test('"*" effect', () => {
const tracer = jest.fn();
const store = new Updux({
effects: {
"*": api => next => action => {
tracer();
next(action);
}
}
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: "bar" });
expect(tracer).toHaveBeenCalled();
});
test("async effect", async () => {
function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const tracer = jest.fn();
const store = new Updux({
effects: {
foo: api => next => async action => {
next(action);
await timeout(1000);
tracer();
}
}
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).not.toHaveBeenCalled();
await timeout(1000);
expect(tracer).toHaveBeenCalled();
});
test("getState is local", () => {
let childState;
let rootState;
let rootFromChild;
const child = new Updux({
initial: { alpha: 12 },
effects: {
doIt: ({ getState, getRootState }) => next => action => {
childState = getState();
rootFromChild = getRootState();
next(action);
}
}
});
const root = new Updux({
initial: { beta: 24 },
subduxes: { child },
effects: {
doIt: ({ getState }) => next => action => {
rootState = getState();
next(action);
}
}
});
const store = root.createStore();
store.dispatch.doIt();
expect(rootState).toEqual({ beta: 24, child: { alpha: 12 } });
expect(rootFromChild).toEqual({ beta: 24, child: { alpha: 12 } });
expect(childState).toEqual({ alpha: 12 });
});
test("middleware as map", () => {
let childState;
let rootState;
let rootFromChild;
const doIt = actionCreator("doIt");
const child = new Updux({
initial: "",
effects: [
[
doIt,
() => next => action => {
next(u({ payload: (p: string) => p + "Child" }, action) as any);
}
]
]
});
const root = new Updux({
initial: { message: "" },
subduxes: { child },
effects: [
[
"^",
() => next => action => {
next(u({ payload: (p: string) => p + "Pre" }, action) as any);
}
],
[
doIt,
() => next => action => {
next(u({ payload: (p: string) => p + "Root" }, action) as any);
}
],
[
"*",
() => next => action => {
next(u({ payload: (p: string) => p + "After" }, action) as any);
}
],
[
"$",
() => next => action => {
next(u({ payload: (p: string) => p + "End" }, action) as any);
}
]
],
mutations: [[doIt, (message: any) => () => ({ message })]]
});
const store = root.createStore();
store.dispatch.doIt("");
expect(store.getState()).toEqual({ message: "PreRootAfterChildEnd" });
});
test("generator", () => {
const updux = new Updux({
initial: 0,
mutations: [["doIt", payload => () => payload]],
effects: [
[
"doIt",
() => {
let i = 0;
return () => (next: any) => (action: any) =>
next({ ...action, payload: ++i });
foo: (api: any) => (next: any) => (action: any) => {
tracer();
next(action);
},
},
true
]
]
});
}).createStore();
const store1 = updux.createStore();
store1.dispatch.doIt();
expect(store1.getState()).toEqual(1);
store1.dispatch.doIt();
expect(store1.getState()).toEqual(2);
updux.actions;
expect(tracer).not.toHaveBeenCalled();
const store2 = updux.createStore();
store2.dispatch.doIt();
expect(store2.getState()).toEqual(1);
store.dispatch({ type: 'bar' });
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).toHaveBeenCalled();
});
test('effect and sub-effect', () => {
const tracer = jest.fn();
const tracerEffect = (signature: string) => (api: any) => (next: any) => (
action: any
) => {
tracer(signature);
next(action);
};
const store = new Updux({
effects: {
foo: tracerEffect('root'),
},
subduxes: {
zzz: {
effects: {
foo: tracerEffect('child'),
},
},
},
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: 'bar' });
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).toHaveBeenNthCalledWith(1, 'root');
expect(tracer).toHaveBeenNthCalledWith(2, 'child');
});
describe('"*" effect', () => {
test('from the constructor', () => {
const tracer = jest.fn();
const store = new Updux({
effects: {
'*': api => next => action => {
tracer();
next(action);
},
},
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch({ type: 'bar' });
expect(tracer).toHaveBeenCalled();
});
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) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const tracer = jest.fn();
const store = new Updux({
effects: {
foo: api => next => async action => {
next(action);
await timeout(1000);
tracer();
},
},
}).createStore();
expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo();
expect(tracer).not.toHaveBeenCalled();
await timeout(1000);
expect(tracer).toHaveBeenCalled();
});
test('getState is local', () => {
let childState;
let rootState;
let rootFromChild;
const child = new Updux({
initial: { alpha: 12 },
effects: {
doIt: ({ getState, getRootState }) => next => action => {
childState = getState();
rootFromChild = getRootState();
next(action);
},
},
});
const root = new Updux({
initial: { beta: 24 },
subduxes: { child },
effects: {
doIt: ({ getState }) => next => action => {
rootState = getState();
next(action);
},
},
});
const store = root.createStore();
store.dispatch.doIt();
expect(rootState).toEqual({ beta: 24, child: { alpha: 12 } });
expect(rootFromChild).toEqual({ beta: 24, child: { alpha: 12 } });
expect(childState).toEqual({ alpha: 12 });
});
test('middleware as map', () => {
let childState;
let rootState;
let rootFromChild;
const doIt = actionCreator('doIt');
const child = new Updux({
initial: '',
effects: [
[
doIt,
() => next => action => {
next(
u(
{ payload: (p: string) => p + 'Child' },
action
) as any
);
},
],
],
});
const root = new Updux({
initial: { message: '' },
subduxes: { child },
effects: [
[
'^',
() => next => action => {
next(
u({ payload: (p: string) => p + 'Pre' }, action) as any
);
},
],
[
doIt,
() => next => action => {
next(
u({ payload: (p: string) => p + 'Root' }, action) as any
);
},
],
[
'*',
() => next => action => {
next(
u(
{ payload: (p: string) => p + 'After' },
action
) as any
);
},
],
[
'$',
() => next => action => {
next(
u({ payload: (p: string) => p + 'End' }, action) as any
);
},
],
],
mutations: [[doIt, (message: any) => () => ({ message })]],
});
const store = root.createStore();
store.dispatch.doIt('');
expect(store.getState()).toEqual({ message: 'PreRootAfterChildEnd' });
});
test('generator', () => {
const updux = new Updux({
initial: 0,
mutations: [['doIt', payload => () => payload]],
effects: [
[
'doIt',
() => {
let i = 0;
return () => (next: any) => (action: any) =>
next({ ...action, payload: ++i });
},
true,
],
],
});
const store1 = updux.createStore();
store1.dispatch.doIt();
expect(store1.getState()).toEqual(1);
store1.dispatch.doIt();
expect(store1.getState()).toEqual(2);
updux.actions;
const store2 = updux.createStore();
store2.dispatch.doIt();
expect(store2.getState()).toEqual(1);
});

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;
};
selectors?: Dictionary<Selector>;
/**
* Object mapping actions to the associated state mutation.
*
@ -198,7 +200,10 @@ export interface UpduxMiddlewareAPI<S> {
dispatch: UpduxDispatch;
getState(): any;
getRootState(): S;
selectors: Dictionary<Selector>;
}
export type UpduxMiddleware<S = any> = (
api: UpduxMiddlewareAPI<S>
) => (next: UpduxDispatch) => (action: Action) => any;
export type Selector<S = any> = (state:S) => any;

View File

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