diff --git a/.gitignore b/.gitignore index c05203a..03995cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ tsconfig.tsbuildinfo +**/*.orig diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 67ee16e..dfdd5c5 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,5 +1,6 @@ * [Home](/) +* [Concepts](concepts.md) * API Reference * [Updux](updux.md) diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..65cbbc6 --- /dev/null +++ b/docs/concepts.md @@ -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 ) ); + } +) +``` diff --git a/docs/updux.md b/docs/updux.md index d95ba2c..debb225 100644 --- a/docs/updux.md +++ b/docs/updux.md @@ -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). + + diff --git a/src/buildMiddleware/index.ts b/src/buildMiddleware/index.ts index 0b1d198..8421869 100644 --- a/src/buildMiddleware/index.ts +++ b/src/buildMiddleware/index.ts @@ -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( actions: Dictionary = {} ): UpduxMiddleware { 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) => { diff --git a/src/buildSelectors/index.ts b/src/buildSelectors/index.ts new file mode 100644 index 0000000..217202b --- /dev/null +++ b/src/buildSelectors/index.ts @@ -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 = {}, + subduxes: Dictionary = {} +) { + return Object.fromEntries( + [ + Object.entries(subduxes).flatMap(subSelectors), + Object.entries(localSelectors), + ].flat() + ); +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 54d02a9..e3a0a1c 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -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); }); diff --git a/src/middleware_aux.ts b/src/middleware_aux.ts new file mode 100644 index 0000000..47ab313 --- /dev/null +++ b/src/middleware_aux.ts @@ -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; diff --git a/src/selectors.test.ts b/src/selectors.test.ts new file mode 100644 index 0000000..29feabe --- /dev/null +++ b/src/selectors.test.ts @@ -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' }); +}); diff --git a/src/types.ts b/src/types.ts index ca0da78..98e2bcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,6 +98,8 @@ export type UpduxConfig = { [type: string]: ActionCreator; }; + selectors?: Dictionary; + /** * Object mapping actions to the associated state mutation. * @@ -198,7 +200,10 @@ export interface UpduxMiddlewareAPI { dispatch: UpduxDispatch; getState(): any; getRootState(): S; + selectors: Dictionary; } export type UpduxMiddleware = ( api: UpduxMiddlewareAPI ) => (next: UpduxDispatch) => (action: Action) => any; + +export type Selector = (state:S) => any; diff --git a/src/updux.ts b/src/updux.ts index 2df48c3..9f46c59 100644 --- a/src/updux.ts +++ b/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,26 +46,34 @@ export type Dux = Pick< >; export class Updux { - subduxes: Dictionary; + subduxes: Dictionary = {}; + + private local_selectors: Dictionary> = {}; initial: S; groomMutations: (mutation: Mutation) => Mutation; - private localEffects: EffectEntry[] = []; - private localActions: Dictionary = {}; + private localEffects: EffectEntry[] = []; - private localMutations: Dictionary< + private localActions: Dictionary = {}; + + private localMutations: Dictionary< Mutation | [Mutation, boolean | undefined] > = {}; constructor(config: UpduxConfig = {}) { this.groomMutations = config.groomMutations || ((x: Mutation) => x); - this.subduxes = fp.mapValues((value: UpduxConfig | Updux) => - fp.isPlainObject(value) ? new Updux(value) : value - )(fp.getOr({}, "subduxes", config)) as Dictionary; + const selectors = fp.getOr( {}, 'selectors', config ) as Dictionary; + 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 { } get actions(): Dictionary { - return buildActions([ ...(Object.entries(this.localActions) as any), ...(fp.flatten( @@ -108,7 +117,7 @@ export class Updux { ]); } - get upreducer(): Upreducer { + get upreducer(): Upreducer { return buildUpreducer(this.initial, this.mutations); } @@ -191,22 +200,21 @@ export class Updux { 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 { ].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;