From e4eff8a113b7e1c6ef7b7324cf6b14ed28be9d0d Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Thu, 7 Oct 2021 15:08:21 -0400 Subject: [PATCH] effects --- src/Updux.js | 25 +++++++- src/Updux.test.js | 23 +++++++ src/buildMiddleware/index.js | 82 ++++++++++++++++++++++++ src/buildMiddleware/test.js | 120 +++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/buildMiddleware/index.js create mode 100644 src/buildMiddleware/test.js diff --git a/src/Updux.js b/src/Updux.js index eeaa6e8..36f2455 100644 --- a/src/Updux.js +++ b/src/Updux.js @@ -1,6 +1,6 @@ import moize from 'moize'; import u from '@yanick/updeep'; -import { createStore as reduxCreateStore } from 'redux'; +import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; import { mapValues } from 'lodash-es'; import { buildInitial } from './buildInitial/index.js'; @@ -8,6 +8,7 @@ import { buildActions } from './buildActions/index.js'; import { buildSelectors } from './buildSelectors/index.js'; import { action } from './actions.js'; import { buildUpreducer } from './buildUpreducer.js'; +import { buildMiddleware } from './buildMiddleware/index.js'; /** * @public @@ -24,6 +25,7 @@ export class Updux { #actions = {}; #selectors = {}; #mutations = {}; + #effects = []; constructor(config) { this.#initial = config.initial ?? {}; @@ -39,12 +41,26 @@ export class Updux { .forEach((action) => { throw new Error(`action '${action}' is not defined`); }); + + if (config.effects) { + this.#effects = Object.entries(config.effects); + } } #memoInitial = moize(buildInitial); #memoActions = moize(buildActions); #memoSelectors = moize(buildSelectors); #memoUpreducer = moize(buildUpreducer); + #memoMiddleware = moize(buildMiddleware); + + get middleware() { + return this.#memoMiddleware( + this.#effects, + this.actions, + this.selectors, + this.#subduxes + ); + } get initial() { return this.#memoInitial(this.#initial, this.#subduxes); @@ -101,7 +117,12 @@ export class Updux { } createStore() { - const store = reduxCreateStore(this.reducer); + + const store = reduxCreateStore( + this.reducer, + this.initial, + applyMiddleware(this.middleware) + ); store.actions = this.actions; diff --git a/src/Updux.test.js b/src/Updux.test.js index da09330..7d30994 100644 --- a/src/Updux.test.js +++ b/src/Updux.test.js @@ -1,4 +1,5 @@ import { test } from 'tap'; +import sinon from 'sinon'; import { Updux } from './Updux.js'; import { action } from './actions.js'; @@ -145,3 +146,25 @@ test('mutations', { todo: false }, async (t) => { alpha: { quux: 13 }, }); }); + +test( 'middleware', async(t) => { + const fooEffect = sinon.fake.returns(true); + + const dux = new Updux({ + effects: { + foo: () => next => action => { + fooEffect(); + next(action); + } + } + }); + + const store = dux.createStore(); + + t.notOk( fooEffect.called, 'not called yet' ); + + store.dispatch({type: 'foo'}); + + t.ok( fooEffect.called, "now it's been called" ); + +} ); diff --git a/src/buildMiddleware/index.js b/src/buildMiddleware/index.js new file mode 100644 index 0000000..3808ed8 --- /dev/null +++ b/src/buildMiddleware/index.js @@ -0,0 +1,82 @@ +import u from '@yanick/updeep'; +import { mapValues, map, get } from 'lodash-es'; +import { Updux } from '../Updux.js'; + +const middlewareFor = (type, middleware) => (api) => (next) => (action) => { + if (type !== '*' && action.type !== type) return next(action); + + return middleware(api)(next)(action); +}; + +const sliceMw = (slice, mw) => (api) => { + const getSliceState = () => get(api.getState(), slice); + return mw({ ...api, getState: getSliceState }); +}; + +function augmentMiddlewareApi(api, actions, selectors) { + const getState = () => api.getState(); + const dispatch = (action) => api.dispatch(action); + + Object.assign( + getState, + mapValues(selectors, (selector) => { + return (...args) => { + let result = selector(api.getState()); + + if (typeof result === 'function') return result(...args); + + return result; + }; + }) + ); + + Object.assign( + dispatch, + mapValues(actions, (action) => { + return (...args) => api.dispatch(action(...args)); + }) + ); + + return { + ...api, + getState, + dispatch, + actions, + selectors, + }; +} + +export const effectToMiddleware = (effect, actions, selectors) => { + let mw = effect; + let action = '*'; + + if (Array.isArray(effect)) { + action = effect[0]; + mw = effect[1]; + mw = middlewareFor(action, mw); + } + + return (api) => mw(augmentMiddlewareApi(api, actions, selectors)); +}; + +const composeMw = (mws) => (api) => (original_next) => + mws.reduceRight((next, mw) => mw(api)(next), original_next); + +export function buildMiddleware( + effects = [], + actions = {}, + selectors = {}, + sub = {} +) { + let inner = map(sub, ({ middleware }, slice) => + middleware ? sliceMw(slice, middleware) : undefined + ).filter((x) => x); + + const local = effects.map((effect) => + effectToMiddleware(effect, actions, selectors) + ); + + const mws = [...local, ...inner]; + + return composeMw(mws); +} diff --git a/src/buildMiddleware/test.js b/src/buildMiddleware/test.js new file mode 100644 index 0000000..6736e01 --- /dev/null +++ b/src/buildMiddleware/test.js @@ -0,0 +1,120 @@ +import { test } from 'tap'; +import sinon from 'sinon'; + +import { buildMiddleware } from './index.js'; +import { action } from '../actions.js'; + +test('single effect', async (t) => { + const effect = (api) => (next) => (action) => { + t.same(action, { type: 'foo' }); + t.has(api, { + actions: {}, + selectors: {}, + }); + next(); + }; + + const middleware = buildMiddleware([effect]); + + await new Promise((resolve) => { + middleware({})(resolve)({ type: 'foo' }); + }); +}); + +test('augmented api', async (t) => { + const effect = (api) => (next) => async (action) => { + await t.test('selectors', async (t) => { + t.same(api.getState.getB(), 1); + t.same(api.selectors.getB({ a: { b: 2 } }), 2); + + t.same(api.getState(), { a: { b: 1 } }); + }); + + await t.test('dispatch', async (t) => { + t.same(api.actions.actionOne(), { type: 'actionOne' }); + api.dispatch.actionOne('the payload'); + }); + + next(); + }; + + const middleware = buildMiddleware( + [effect], + { + actionOne: action('actionOne'), + }, + { + getB: (state) => state.a.b, + } + ); + + const getState = sinon.fake.returns({ a: { b: 1 } }); + const dispatch = sinon.fake.returns(); + + await new Promise((resolve) => { + middleware({ + getState, + dispatch, + })(resolve)({ type: 'foo' }); + }); + + t.ok(dispatch.calledOnce); + + t.ok(dispatch.calledWith({ type: 'actionOne', payload: 'the payload' })); +}); + +test('subduxes', async (t) => { + const effect1 = (api) => (next) => (action) => { + next({ + type: action.type, + payload: [...action.payload, 1], + }); + }; + const effect2 = (api) => (next) => (action) => { + next({ + type: action.type, + payload: [...action.payload, 2], + }); + }; + const effect3 = (api) => (next) => (action) => { + next({ + type: action.type, + payload: [...action.payload, 3], + }); + }; + + const mw = buildMiddleware( + [effect1], + {}, + {}, + { + a: { middleware: effect2 }, + b: { middleware: effect3 }, + } + ); + + mw({})(({ payload }) => { + t.same(payload, [1, 2, 3]); + })({ type: 'foo', payload: [] }); + + t.test('api for subduxes', async (t) => { + const effect = (api) => (next) => (action) => { + t.same(api.getState(), 3); + next(); + }; + + const mwInner = buildMiddleware([effect]); + const mwOuter = buildMiddleware( + [], + {}, + {}, + { alpha: { middleware: mwInner } } + ); + + await new Promise((resolve) => { + mwOuter({ + getState: () => ({ alpha: 3 }), + })(resolve)({ type: 'foo' }); + }); + }); +});