diff --git a/docs/tutorial-effects.test.js b/docs/tutorial-effects.test.js new file mode 100644 index 0000000..9ab10d3 --- /dev/null +++ b/docs/tutorial-effects.test.js @@ -0,0 +1,69 @@ +import { test, expect } from 'vitest'; + +import u from 'updeep'; +import { action, Updux, dux } 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: [ { description: 'Do the thing', id: 1 } ] + }) + +}); + +test( "catch-all effect", () => { + + let seen = []; + + const foo = new Updux({ + actions: { + one: null, + two: null, + }, + effects: { + '*': (api) => next => action => { + seen.push(action.type); + next(action); + } + } + } ); + + const store = foo.createStore(); + + store.dispatch.one(); + store.dispatch.two(); + + expect(seen).toEqual([ 'one', 'two' ]); +} ) diff --git a/docs/tutorial.md b/docs/tutorial.md index e1a9e3e..1167b61 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -8,7 +8,7 @@ We'll be using help with immutability and deep merging, but that's totally optional. If `updeep` is not your bag, it can easily be substitued with, say, [immer][], [lodash][], or even -plain JavaScript. +plain JavaScript. ## Definition of the state @@ -44,7 +44,7 @@ const todosDux = new Updux({ initial: { next_id: 1, todos: [], - }, + }, { addTodo: null, todoDone: null, @@ -57,7 +57,7 @@ const todosDux = new Updux({ Once an action is defined, its creator is accessible via the `actions` accessor. ```js -console.log( todosDux.actions.addTodo('write tutorial') ); +console.log( todosDux.actions.addTodo('write tutorial') ); // prints { type: 'addTodo', payload: 'write tutorial' } ``` ### Adding a mutation @@ -72,3 +72,62 @@ 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'); +``` + +### Catch-all effect + +It is possible to have an effect match all actions via the special `*` token. +``` +todosUpdux.addEffect('*', () => next => action => { + console.log( 'seeing action fly by:', action ); + next(action); +}); diff --git a/src/Updux.js b/src/Updux.js index f88334d..7577f38 100644 --- a/src/Updux.js +++ b/src/Updux.js @@ -1,14 +1,11 @@ import R from 'remeda'; import u from 'updeep'; -import { createStore as reduxCreateStore } from 'redux'; +import { createStore as reduxCreateStore, applyMiddleware } 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 { buildMiddleware } from './middleware.js'; +import { action, isActionGen } from './actions.js'; /** * Updux configuration object @@ -23,6 +20,7 @@ export class Updux { #mutations = {}; #config = {}; #selectors = {}; + #effects = []; constructor(config = {}) { this.#config = config; @@ -36,11 +34,21 @@ export class Updux { this.#addSubduxActions(slice, sub), ); + Object.entries(config.mutations ?? {}).forEach((args) => + this.setMutation(...args), + ); + this.#selectors = buildSelectors( config.selectors, config.splatSelectors, this.#subduxes, ); + + if (Array.isArray(config.effects)) { + this.#effects = config.effects; + } else if (R.isObject(config.effects)) { + this.#effects = Object.entries(config.effects); + } } #addSubduxActions(_slice, subdux) { @@ -118,15 +126,24 @@ export class Updux { return this.#mutations; } + get middleware() { + return buildMiddleware( + this.#effects, + this.actions, + this.selectors, + this.subduxes, + ); + } + createStore(initial = undefined, enhancerGenerator = undefined) { - // const enhancer = (enhancerGenerator ?? applyMiddleware)( - // this.middleware - // ); + const enhancer = (enhancerGenerator ?? applyMiddleware)( + this.middleware, + ); const store = reduxCreateStore( this.reducer, initial ?? this.initial, - //enhancer + enhancer, ); store.actions = this.actions; @@ -156,6 +173,10 @@ export class Updux { return store; } + + addEffect(action, effect) { + this.#effects = [...this.#effects, [action, effect]]; + } } export const dux = (config) => new Updux(config); diff --git a/src/actions.js b/src/actions.js index e5a838b..51ad508 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,3 +1,7 @@ +export 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()); +});