Merge branch 'effects' into rewrite

typescript
Yanick Champoux 2022-08-29 10:58:40 -04:00
commit c5ca566c12
7 changed files with 297 additions and 13 deletions

View File

@ -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' ]);
} )

View File

@ -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);
});

View File

@ -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);

View File

@ -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 };

View File

@ -1 +1,2 @@
export { Updux, dux } from './Updux.js';
export { action } from './actions.js';

82
src/middleware.js Normal file
View File

@ -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);
}

48
src/middleware.test.js Normal file
View File

@ -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());
});