diff --git a/README.md b/README.md new file mode 100644 index 0000000..c855e28 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ + +# What's Updux? + +So, I'm a fan of [Redux][]. Two days ago I discovered +[rematch][] alonside a few other frameworks built atop Redux. + +It has a couple of pretty good ideas that removes some of the +boilerplate. Keeping mutations and asynchronous effects close to the +reducer definition, à la [VueX][]? Nice. Automatically infering the +actions from the said mutations and effects? Genius! + +But it also enforces a flat hierarchy of reducers -- where +is the fun in that? And I'm also having a strong love for +[Updeep][], so I want reducer state updates to leverage the heck out of it. + +All that to say, I had some fun yesterday and hacked a proto-lovechild +of `Rematch` and `Updeep`, with a dash of [VueX][] inspiration. +I call it... `Updux`. + +## Example + +#### battle.js + +``` +import { updux } from 'updux'; + +import game from './game'; +import log from './log'; +import bogeys from './bogeys'; + +const { createStore } = updux({ + reducers: { game, log, bogeys } +}) + +export default createStore; +``` + +#### game.js + + +``` +import { updux } from 'updux'; +import _ from 'lodash'; +import u from 'updeep'; + +import { calculateMovement } from 'game/rules'; + + +export default updux({ + initial: { game: "", players: [], turn: 0, }, + mutations: { + init_game: ({game: { name, players }}) => {name, players}, + play_turn: () => u({ turn: x => x+1 }), + }, + effects: { + play_turn: ({getState,dispatch}) => next => action => { + + const bogeys = api.getState().bogeys; + + // only allow the turn to be played if + // all ships have their orders in + if( bogeys.any( bogey => ! bogey.orders ) ) return; + + bogeys.forEach( bogey => { + dispatch.move( calculateMovement(bogey) ) + } ); + + next(action); + }, + } +}); +``` + + +#### log.js + + +``` +import { updux } from 'updux'; + +export default updux({ + initial: [], + actions: { + '*': (payload,action) => state => [ ...state, action ], + }, +}); +``` + +#### bogeys.js + +``` +import { updux } from 'updux'; +import _ from 'lodash'; + +export default updux({ + initial: [], + mutations: { + init_game: ({bogeys}) => () => _.keyBy( bogeys, 'id' ), + move: ({position}) => u({ position }), + }, +}); +``` + + +#### myGame.js + +``` +import Battle from './battle'; + +const battle = Battle(); + +battle.dispatch.init_game({ + name: 'Gemini Prime', + players: [ 'yenzie' ], + bogeys: [ { id: 'Enkidu' } ] +}); + +battle.dispatch.play_game(); + +.... +``` + + +[Redux]: https://redux.js.org +[rematch]: https://rematch.github.io +[Updeep]: https://github.com/substantial/updeep +[VueX]: https://vuex.vuejs.org/ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..f037a1a --- /dev/null +++ b/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: 'current', + }, + }, + ], + ], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..de6431b --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "updux", + "version": "0.0.1", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.6.4", + "@babel/preset-env": "^7.6.3", + "babel-jest": "^24.9.0", + "jest": "^24.9.0", + "lodash": "^4.17.15", + "redux": "^4.0.4", + "updeep": "^1.2.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c02266f --- /dev/null +++ b/src/index.js @@ -0,0 +1,138 @@ +import fp from 'lodash/fp'; +import u from 'updeep'; + +import { createStore, applyMiddleware } from 'redux'; + +function actionFor(type) { + return (payload = null, meta = null) => { + return fp.pickBy(v => v !== null)({type, payload, meta}); + }; +} + +function buildInitial({initial = {}, reducers = {}}) { + let state = initial; + + if (fp.isPlainObject(initial)) { + initial = fp.mergeAll([ + fp.mapValues(fp.getOr({}, 'initial'), reducers), + initial, + ]); + } + + return initial; +} + +function buildActions({mutations = {}, reducers = {}}) { + let actions = fp.mergeAll(fp.map(fp.getOr({}, 'actions'), reducers)) || {}; + + Object.keys(mutations).forEach(type => { + if (!actions[type]) { + actions[type] = actionFor(type); + } + }); + + return actions; +} + +function buildMutations({mutations = {}, reducers = {}}) { + let subMut = {}; + + for (let slice in reducers) { + for (let mutation in reducers[slice].mutations) { + subMut = u( + { + [mutation]: { + [slice]: u.constant(reducers[slice].mutations[mutation]), + }, + }, + subMut, + ); + } + } + + subMut = fp.mapValues(updates => action => + u(fp.mapValues(f => f(action))(updates)), + )(subMut); + + for (let name in mutations) { + if (subMut[name]) { + const pre = subMut[name]; + + subMut[name] = action => state => + mutations[name](action)(pre(action)(state)); + } else { + subMut[name] = mutations[name]; + } + } + + return subMut; +} + +function buildMiddleware({effects={},reducers={}},{actions}) { + return api => { + + for ( let type in actions ) { + api.dispatch[type] = (...args) => api.dispatch( actions[type](...args) ); + } + + return original_next => { + return [ + ...fp.toPairs(effects).map(([type,effect])=> { + return api => next => action => { + console.log(action); + + if( action.type !== type ) return next(action); + + return effect(api)(next)(action); + }; + }), + ...fp.map( 'middleware', reducers ) + ].filter(x=>x).reduceRight( (next,mw) => mw(api)(next), original_next ) + }} +} + +function updux(config) { + const dux = {}; + + dux.actions = buildActions(config); + + dux.initial = buildInitial(config); + + dux.mutations = buildMutations(config); + + dux.upreducer = action => state => { + if (state === null) state = dux.initial; + + const a = + dux.mutations[action.type] || + dux.mutations['*'] || + (() => state => state); + + return a(action.payload, action)(state); + }; + + dux.reducer = (state, action) => { + return dux.upreducer(action)(state); + }; + + dux.middleware = buildMiddleware(config,dux); + + dux.createStore = () => { + const store = createStore( dux.reducer, dux.initial, + applyMiddleware( dux.middleware) + ); + for ( let a in dux.actions ) { + store.dispatch[a] = (...args) => { + store.dispatch(dux.actions[a](...args)) + }; + } + + return store; + } + + + + return dux; +} + +export default updux; diff --git a/src/test.js b/src/test.js new file mode 100644 index 0000000..a0c3552 --- /dev/null +++ b/src/test.js @@ -0,0 +1,178 @@ +import updux from '.'; + +test('actions from mutations', () => { + const { + actions: {foo, bar}, + } = updux({ + mutations: { + foo: () => x => x, + }, + }); + + expect(foo()).toEqual({type: 'foo'}); + + expect(foo(true)).toEqual({type: 'foo', payload: true}); + + expect(foo({bar: 2}, {timestamp: 613})).toEqual({ + type: 'foo', + payload: {bar: 2}, + meta: {timestamp: 613}, + }); +}); + +test('reducer', () => { + const {actions, reducer} = updux({ + initial: {counter: 1}, + mutations: { + inc: () => ({counter}) => ({counter: counter + 1}), + }, + }); + + let state = reducer(null, {}); + + expect(state).toEqual({counter: 1}); + + state = reducer(state, actions.inc()); + + expect(state).toEqual({counter: 2}); +}); + +test( 'sub reducers', () => { + const foo = updux({ + initial: 1, + mutations: { + doFoo: () => (x) => x + 1, + doAll: () => x => x + 10, + }, + }); + + const bar = updux({ + initial: 'a', + mutations: { + doBar: () => x => x + 'a', + doAll: () => x => x + 'b', + } + }); + + const { initial, actions, reducer } = updux({ + reducers: { + foo, bar + } + }); + + expect(initial).toEqual({ foo: 1, bar: 'a' }); + + expect(Object.keys(actions)).toHaveLength(3); + + let state = reducer(null,{}); + + expect(state).toEqual({ foo: 1, bar: 'a' }); + + state = reducer(state, actions.doFoo() ); + + expect(state).toEqual({ foo: 2, bar: 'a' }); + + state = reducer(state, actions.doBar() ); + + expect(state).toEqual({ foo: 2, bar: 'aa' }); + + state = reducer(state, actions.doAll() ); + + expect(state).toEqual({ foo: 12, bar: 'aab' }); + +}); + +test('precedence between root and sub-reducers', () => { + const { + initial, + reducer, + actions, + } = updux({ + initial: { + foo: { bar: 4 }, + }, + mutations: { + inc: () => state => { + return { + ...state, + surprise: state.foo.bar + } + } + }, + reducers: { + foo: updux({ + initial: { + bar: 2, + quux: 3, + }, + mutations: { + inc: () => state => ({...state, bar: state.bar + 1 }) + }, + }), + } + }); + + expect(initial).toEqual({ + foo: { bar: 4, quux: 3 } + }); + + expect( reducer(null,actions.inc() ) ).toEqual({ + foo: { bar: 5, quux: 3 }, surprise: 5 + }); + +}); + +function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +test( 'middleware', async () => { + const { + middleware, + createStore + } = updux({ + initial: "", + mutations: { + inc: (addition) => state => state + addition, + doEeet: () => state => { + console.log("up", state); + + return state + 'Z'; + }, + }, + effects: { + doEeet: api => next => async action => { + console.log(api); + api.dispatch.inc('a'); + next(action); + await timeout(1000); + api.dispatch.inc('c'); + } + }, + reducers: { + foo: updux({ + effects: { + doEeet: (api) => next => action => { + console.log("in foo",api); + + api.dispatch({ type: 'inc', payload: 'b'}); + next(action); + } + } + }), + } + }); + + const store = createStore(); + + console.log(store); + + store.dispatch.doEeet(); + + expect(store.getState()).toEqual( 'abZ' ); + + await timeout(1000); + + expect(store.getState()).toEqual( 'abZc' ); + +});