Merge branch 'selectors'
This commit is contained in:
commit
4665ee39f7
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
**/*.orig
|
||||
|
@ -1,5 +1,6 @@
|
||||
<!-- docs/_sidebar.md -->
|
||||
|
||||
* [Home](/)
|
||||
* [Concepts](concepts.md)
|
||||
* API Reference
|
||||
* [Updux](updux.md)
|
||||
|
30
docs/concepts.md
Normal file
30
docs/concepts.md
Normal 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 ) );
|
||||
}
|
||||
)
|
||||
```
|
@ -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).
|
||||
|
||||
|
||||
|
@ -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>) => {
|
||||
|
26
src/buildSelectors/index.ts
Normal file
26
src/buildSelectors/index.ts
Normal 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()
|
||||
);
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import Updux, { actionCreator } from ".";
|
||||
import u from "updeep";
|
||||
import Updux, { actionCreator } from '.';
|
||||
import u from 'updeep';
|
||||
import mwUpdux from './middleware_aux';
|
||||
|
||||
test("simple effect", () => {
|
||||
test('simple effect', () => {
|
||||
const tracer = jest.fn();
|
||||
|
||||
const store = new Updux({
|
||||
@ -9,13 +10,13 @@ test("simple effect", () => {
|
||||
foo: (api: any) => (next: any) => (action: any) => {
|
||||
tracer();
|
||||
next(action);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}).createStore();
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
|
||||
store.dispatch({ type: "bar" });
|
||||
store.dispatch({ type: 'bar' });
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
|
||||
@ -24,7 +25,7 @@ test("simple effect", () => {
|
||||
expect(tracer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("effect and sub-effect", () => {
|
||||
test('effect and sub-effect', () => {
|
||||
const tracer = jest.fn();
|
||||
|
||||
const tracerEffect = (signature: string) => (api: any) => (next: any) => (
|
||||
@ -36,49 +37,81 @@ test("effect and sub-effect", () => {
|
||||
|
||||
const store = new Updux({
|
||||
effects: {
|
||||
foo: tracerEffect("root")
|
||||
foo: tracerEffect('root'),
|
||||
},
|
||||
subduxes: {
|
||||
zzz: {
|
||||
effects: {
|
||||
foo: tracerEffect("child")
|
||||
}
|
||||
}
|
||||
}
|
||||
foo: tracerEffect('child'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}).createStore();
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
|
||||
store.dispatch({ type: "bar" });
|
||||
store.dispatch({ type: 'bar' });
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
|
||||
store.dispatch.foo();
|
||||
|
||||
expect(tracer).toHaveBeenNthCalledWith(1, "root");
|
||||
expect(tracer).toHaveBeenNthCalledWith(2, "child");
|
||||
expect(tracer).toHaveBeenNthCalledWith(1, 'root');
|
||||
expect(tracer).toHaveBeenNthCalledWith(2, 'child');
|
||||
});
|
||||
|
||||
test('"*" effect', () => {
|
||||
describe('"*" effect', () => {
|
||||
test('from the constructor', () => {
|
||||
const tracer = jest.fn();
|
||||
|
||||
const store = new Updux({
|
||||
effects: {
|
||||
"*": api => next => action => {
|
||||
'*': api => next => action => {
|
||||
tracer();
|
||||
next(action);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}).createStore();
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
|
||||
store.dispatch({ type: "bar" });
|
||||
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 () => {
|
||||
test('async effect', async () => {
|
||||
function timeout(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -91,8 +124,8 @@ test("async effect", async () => {
|
||||
next(action);
|
||||
await timeout(1000);
|
||||
tracer();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}).createStore();
|
||||
|
||||
expect(tracer).not.toHaveBeenCalled();
|
||||
@ -106,7 +139,7 @@ test("async effect", async () => {
|
||||
expect(tracer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("getState is local", () => {
|
||||
test('getState is local', () => {
|
||||
let childState;
|
||||
let rootState;
|
||||
let rootFromChild;
|
||||
@ -118,8 +151,8 @@ test("getState is local", () => {
|
||||
childState = getState();
|
||||
rootFromChild = getRootState();
|
||||
next(action);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = new Updux({
|
||||
@ -129,8 +162,8 @@ test("getState is local", () => {
|
||||
doIt: ({ getState }) => next => action => {
|
||||
rootState = getState();
|
||||
next(action);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const store = root.createStore();
|
||||
@ -141,78 +174,94 @@ test("getState is local", () => {
|
||||
expect(childState).toEqual({ alpha: 12 });
|
||||
});
|
||||
|
||||
test("middleware as map", () => {
|
||||
test('middleware as map', () => {
|
||||
let childState;
|
||||
let rootState;
|
||||
let rootFromChild;
|
||||
|
||||
const doIt = actionCreator("doIt");
|
||||
const doIt = actionCreator('doIt');
|
||||
|
||||
const child = new Updux({
|
||||
initial: "",
|
||||
initial: '',
|
||||
effects: [
|
||||
[
|
||||
doIt,
|
||||
() => 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({
|
||||
initial: { message: "" },
|
||||
initial: { message: '' },
|
||||
subduxes: { child },
|
||||
effects: [
|
||||
[
|
||||
"^",
|
||||
'^',
|
||||
() => next => action => {
|
||||
next(u({ payload: (p: string) => p + "Pre" }, action) as any);
|
||||
}
|
||||
next(
|
||||
u({ payload: (p: string) => p + 'Pre' }, action) as any
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
doIt,
|
||||
() => 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(u({ payload: (p: string) => p + "After" }, action) as any);
|
||||
}
|
||||
next(
|
||||
u(
|
||||
{ payload: (p: string) => p + 'After' },
|
||||
action
|
||||
) as any
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
"$",
|
||||
'$',
|
||||
() => 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();
|
||||
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({
|
||||
initial: 0,
|
||||
mutations: [["doIt", payload => () => payload]],
|
||||
mutations: [['doIt', payload => () => payload]],
|
||||
effects: [
|
||||
[
|
||||
"doIt",
|
||||
'doIt',
|
||||
() => {
|
||||
let i = 0;
|
||||
return () => (next: any) => (action: any) =>
|
||||
next({ ...action, payload: ++i });
|
||||
},
|
||||
true
|
||||
]
|
||||
]
|
||||
true,
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const store1 = updux.createStore();
|
||||
|
13
src/middleware_aux.ts
Normal file
13
src/middleware_aux.ts
Normal 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
55
src/selectors.test.ts
Normal 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' });
|
||||
});
|
@ -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;
|
||||
|
38
src/updux.ts
38
src/updux.ts
@ -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,12 +46,15 @@ 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> = {};
|
||||
@ -61,9 +66,14 @@ export class Updux<S = any> {
|
||||
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(
|
||||
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user