From 80a356ff1a05025f6da36a2c9dabab357a815a73 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Sun, 28 Aug 2022 19:29:31 -0400 Subject: [PATCH] add effects --- docs/tutorial-effects.test.js | 44 +++++++++++++++++++ docs/tutorial.md | 51 ++++++++++++++++++++++ src/Updux.js | 11 ++--- src/actions.js | 4 ++ src/index.js | 1 + src/middleware.js | 82 +++++++++++++++++++++++++++++++++++ src/middleware.test.js | 48 ++++++++++++++++++++ 7 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 docs/tutorial-effects.test.js create mode 100644 src/middleware.js create mode 100644 src/middleware.test.js diff --git a/docs/tutorial-effects.test.js b/docs/tutorial-effects.test.js new file mode 100644 index 0000000..4f361a4 --- /dev/null +++ b/docs/tutorial-effects.test.js @@ -0,0 +1,44 @@ +import { test, expect } from 'vitest'; + +import u from 'updeep'; +import { action, Updux } from '../src/index.js'; + +const addTodoWithId = action('addTodoWithId'); +const incNextId = action('incNextId'); +const addTodo = action('addTodo'); + +const addTodoEffect = ({ getState, dispatch }) => next => action => { + const id = getState.nextId(); + + dispatch.incNextId(); + + next(action); + + dispatch.addTodoWithId({ description: action.payload, id }); +} + +const todosDux = new Updux({ + initial: { nextId: 1, todos: [] }, + actions: { addTodo, incNextId, addTodoWithId }, + selectors: { + nextId: ({nextId}) => nextId, + }, + mutations: { + addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }), + incNextId: () => u({ nextId: id => id+1 }), + }, + effects: { + 'addTodo': addTodoEffect + } +}); + +const store = todosDux.createStore(); + +test( "tutorial example", async () => { + store.dispatch.addTodo('Do the thing'); + + expect( store.getState() ).toMatchObject({ + nextId:2, todos: [ 'Do the thing' ] + }) + +}); diff --git a/docs/tutorial.md b/docs/tutorial.md index e1a9e3e..aaa6eb7 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -72,3 +72,54 @@ todosDux.setMutation( 'addTodo', description => ({next_id: id, todos}) => ({ todos: [...todos, { description, id, done: false }] })); ``` +## Effects + +In addition to mutations, Updux also provides action-specific middleware, here +called effects. + +Effects use the usual Redux middleware signature, plus a few goodies. +The `getState` and `dispatch` functions are augmented with the dux selectors, +and actions, respectively. The selectors and actions are also available +from the api object. + +```js +import u from 'updeep'; +import { action, Updux } from 'updux'; + +// we want to decouple the increment of next_id and the creation of +// a new todo. So let's use a new version of the action 'addTodo'. + +const addTodoWithId = action('addTodoWithId'); +const incNextId = action('incNextId'); +const addTodo = action('addTodo'); + +const addTodoEffect = ({ getState, dispatch }) => next => action => { + const id = getState.nextId(); + + dispatch.incNextId(); + + next(action); + + dispatch.addTodoWithId({ description: action.payload, id }); +} + +const todosDux = new Updux({ + initial: { nextId: 1, todos: [] }, + actions: { addTodo, incNextId, addTodoWithId }, + selectors: { + nextId: ({nextId}) => nextId, + }, + mutations: { + addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }), + incNextId: () => u({ nextId: id => id+1 }), + }, + effects: { + 'addTodo': addTodoEffect + } +}); + +const store = todosDux.createStore(); + +store.dispatch.addTodo('Do the thing'); +``` + diff --git a/src/Updux.js b/src/Updux.js index f88334d..d17041b 100644 --- a/src/Updux.js +++ b/src/Updux.js @@ -4,11 +4,7 @@ import { createStore as reduxCreateStore } from 'redux'; import { buildSelectors } from './selectors.js'; import { buildUpreducer } from './upreducer.js'; -import { action } from './actions.js'; - -function isActionGen(action) { - return typeof action === 'function' && action.type; -} +import { action, isActionGen } from './actions.js'; /** * Updux configuration object @@ -23,6 +19,7 @@ export class Updux { #mutations = {}; #config = {}; #selectors = {}; + #effects = {}; constructor(config = {}) { this.#config = config; @@ -36,6 +33,10 @@ export class Updux { this.#addSubduxActions(slice, sub), ); + Object.entries(config.mutations ?? {}).forEach((args) => + this.setMutation(...args), + ); + this.#selectors = buildSelectors( config.selectors, config.splatSelectors, diff --git a/src/actions.js b/src/actions.js index e5a838b..db1f965 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,3 +1,7 @@ +function isActionGen(action) { + return typeof action === 'function' && action.type; +} + export function action(type, payloadFunction, transformer) { let generator = function (...payloadArg) { const result = { type }; diff --git a/src/index.js b/src/index.js index 36ef6f4..573e5e5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,2 @@ export { Updux, dux } from './Updux.js'; +export { action } from './actions.js'; diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..de4d598 --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,82 @@ +import R from 'remeda'; + +const composeMw = (mws) => (api) => (original_next) => + mws.reduceRight((next, mw) => mw(api)(next), original_next); + +export function augmentMiddlewareApi(api, actions, selectors) { + const getState = () => api.getState(); + const dispatch = (action) => api.dispatch(action); + + Object.assign( + getState, + R.mapValues(selectors, (selector) => { + return (...args) => { + let result = selector(api.getState()); + + if (typeof result === 'function') return result(...args); + + return result; + }; + }), + ); + + Object.assign( + dispatch, + R.mapValues(actions, (action) => { + return (...args) => api.dispatch(action(...args)); + }), + ); + + return { + ...api, + getState, + dispatch, + actions, + selectors, + }; +} + +const sliceMw = (slice, mw) => (api) => { + const getSliceState = () => get(api.getState(), slice); + return mw({ ...api, getState: getSliceState }); +}; + +const middlewareFor = (type, middleware) => (api) => (next) => (action) => { + if (type !== '*' && action.type !== type) return next(action); + + return middleware(api)(next)(action); +}; + +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)); +}; + +export function buildMiddleware( + effects = [], + actions = {}, + selectors = {}, + subduxes = {}, +) { + let inner = R.compact( + Object.entries(subduxes).map((slice, [{ middleware }]) => + slice !== '*' && middleware ? sliceMw(slice, middleware) : null, + ), + ); + + const local = effects.map((effect) => + effectToMiddleware(effect, actions, selectors), + ); + + let mws = [...local, ...inner]; + + return composeMw(mws); +} diff --git a/src/middleware.test.js b/src/middleware.test.js new file mode 100644 index 0000000..8e11cfa --- /dev/null +++ b/src/middleware.test.js @@ -0,0 +1,48 @@ +import { test, expect, vi } from 'vitest'; + +import { buildMiddleware } from './middleware.js'; +import { action } from './actions.js'; + +test('buildMiddleware, effects', async () => { + const effectMock = vi.fn(); + + const mw = buildMiddleware([ + ['*', (api) => (next) => (action) => effectMock()], + ]); + + mw({})(() => {})({}); + + expect(effectMock).toHaveBeenCalledOnce(); +}); + +test('buildMiddleware, augmented api', async () => { + const myAction = action('myAction'); + + const mw = buildMiddleware( + [ + [ + '*', + (api) => (next) => (action) => { + expect(api.getState.mySelector()).toEqual(13); + api.dispatch(myAction()); + next(); + }, + ], + ], + { + myAction, + }, + { + mySelector: (state) => state?.selected, + }, + ); + + const dispatch = vi.fn(); + const getState = vi.fn(() => ({ selected: 13 })); + const next = vi.fn(); + + mw({ dispatch, getState })(next)(myAction()); + + expect(next).toHaveBeenCalledOnce(); + expect(dispatch).toHaveBeenCalledWith(myAction()); +});