diff --git a/packages/ae-redux/README.md b/packages/ae-redux/README.md new file mode 100644 index 0000000..abeb426 --- /dev/null +++ b/packages/ae-redux/README.md @@ -0,0 +1,3 @@ +# @algebraic-effects/effects +A collection of effects to use while writing your program with algebraic effects in javascript + diff --git a/packages/ae-redux/build/Store.js b/packages/ae-redux/build/Store.js new file mode 100644 index 0000000..8721c9f --- /dev/null +++ b/packages/ae-redux/build/Store.js @@ -0,0 +1,59 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _core = require("@algebraic-effects/core"); + +var _utils = require("@algebraic-effects/utils"); + +var _utils2 = require("./utils"); + +var Store = (0, _core.createEffect)('Store', { + dispatch: (0, _core.func)(['action']), + getState: (0, _core.func)([], 'state'), + selectState: (0, _core.func)(['?state -> a'], 'a'), + getAction: (0, _core.func)([], 'action'), + waitFor: (0, _core.func)(['actionType | Action -> Boolean']) +}); + +Store.of = function (_ref) { + var _ref$store = _ref.store, + _dispatch = _ref$store.dispatch, + _getState = _ref$store.getState, + action = _ref.action; + return Store.handler({ + dispatch: function dispatch(_ref2) { + var resume = _ref2.resume; + return (0, _utils.compose)(resume, _dispatch, _utils2.decorateAction); + }, + getState: function getState(_ref3) { + var resume = _ref3.resume; + return (0, _utils.compose)(resume, _getState); + }, + selectState: function selectState(_ref4) { + var resume = _ref4.resume; + return function (fn) { + return (0, _utils.compose)(resume, fn || _utils.identity, _getState)(); + }; + }, + getAction: function getAction(_ref5) { + var resume = _ref5.resume; + return function () { + return resume(action); + }; + }, + waitFor: function waitFor(_ref6) { + var resume = _ref6.resume, + end = _ref6.end; + return function (filter) { + return (0, _utils2.filterAction)(filter, action) ? resume(action) : end(action); + }; + } + }); +}; + +var _default = Store; +exports.default = _default; \ No newline at end of file diff --git a/packages/ae-redux/build/index.js b/packages/ae-redux/build/index.js new file mode 100644 index 0000000..60da76b --- /dev/null +++ b/packages/ae-redux/build/index.js @@ -0,0 +1,35 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +Object.defineProperty(exports, "Store", { + enumerable: true, + get: function get() { + return _Store.default; + } +}); +exports.createEffectsMiddleware = void 0; + +var _Store = _interopRequireDefault(require("./Store")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var createEffectsMiddleware = function createEffectsMiddleware(program, handler) { + var middleware = function middleware(store) { + return function (next) { + return function (action) { + _Store.default.of({ + store: store, + action: action + }).with(handler).run(program).fork(console.error, console.log); + + return next(action); + }; + }; + }; + + return middleware; +}; + +exports.createEffectsMiddleware = createEffectsMiddleware; \ No newline at end of file diff --git a/packages/ae-redux/build/utils.js b/packages/ae-redux/build/utils.js new file mode 100644 index 0000000..3bcc0a5 --- /dev/null +++ b/packages/ae-redux/build/utils.js @@ -0,0 +1,34 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.filterAction = exports.isEffectfulAction = exports.decorateAction = exports.AE_REDUX_ACTION = void 0; + +var _utils = require("@algebraic-effects/utils"); + +var AE_REDUX_ACTION = (0, _utils.createSymbol)('algebraic-effects/redux-action'); +exports.AE_REDUX_ACTION = AE_REDUX_ACTION; + +var decorateAction = function decorateAction(action) { + action && (action.$$type = AE_REDUX_ACTION); + return action; +}; + +exports.decorateAction = decorateAction; + +var isEffectfulAction = function isEffectfulAction(action) { + return action ? action.$$type === AE_REDUX_ACTION : false; +}; + +exports.isEffectfulAction = isEffectfulAction; + +var filterAction = function filterAction(filter, action) { + if (!action) return false; + if (isEffectfulAction(action)) return false; + if (typeof filter === 'string') return filter === action.type; + if (typeof filter === 'function') return filter(action); + return false; +}; + +exports.filterAction = filterAction; \ No newline at end of file diff --git a/packages/ae-redux/index.js b/packages/ae-redux/index.js new file mode 100644 index 0000000..e1c0647 --- /dev/null +++ b/packages/ae-redux/index.js @@ -0,0 +1 @@ +module.exports = require('./build/index'); \ No newline at end of file diff --git a/packages/ae-redux/package.json b/packages/ae-redux/package.json new file mode 100644 index 0000000..3ae0270 --- /dev/null +++ b/packages/ae-redux/package.json @@ -0,0 +1,26 @@ +{ + "name": "@algebraic-effects/redux", + "version": "0.0.2", + "description": "Redux middleware to allow using algebraic effects to write your actions", + "main": "index.js", + "isBuildTarget": true, + "repository": "https://github.com/phenax/algebraic-effects", + "author": "Akshay Nair ", + "license": "MIT", + "dependencies": { + "@algebraic-effects/core": "0.0.2" + }, + "keywords": [ + "functional", + "algebraic", + "effects", + "generators", + "fp", + "redux", + "saga", + "middleware" + ], + "peerDependencies": { + "redux": "^4.0.1" + } +} diff --git a/packages/ae-redux/src/Store.js b/packages/ae-redux/src/Store.js new file mode 100644 index 0000000..01fd39a --- /dev/null +++ b/packages/ae-redux/src/Store.js @@ -0,0 +1,21 @@ +import { createEffect, func } from '@algebraic-effects/core'; +import { compose, identity } from '@algebraic-effects/utils'; +import { decorateAction, filterAction } from './utils'; + +const Store = createEffect('Store', { + dispatch: func(['action']), + getState: func([], 'state'), + selectState: func(['?state -> a'], 'a'), + getAction: func([], 'action'), + waitFor: func(['actionType | Action -> Boolean']), +}); + +Store.of = ({ store: { dispatch, getState }, action }) => Store.handler({ + dispatch: ({ resume }) => compose(resume, dispatch, decorateAction), + getState: ({ resume }) => compose(resume, getState), + selectState: ({ resume }) => fn => compose(resume, fn || identity, getState)(), + getAction: ({ resume }) => () => resume(action), + waitFor: ({ resume, end }) => filter => filterAction(filter, action) ? resume(action) : end(action), +}); + +export default Store; diff --git a/packages/ae-redux/src/index.js b/packages/ae-redux/src/index.js new file mode 100644 index 0000000..698bd94 --- /dev/null +++ b/packages/ae-redux/src/index.js @@ -0,0 +1,15 @@ +import Store from './Store'; + +export { Store }; + +export const createEffectsMiddleware = (program, handler) => { + const middleware = store => next => action => { + Store.of({ store, action }) + .with(handler) + .run(program) + .fork(console.error, console.log); + return next(action); + }; + + return middleware; +}; diff --git a/packages/ae-redux/src/utils.js b/packages/ae-redux/src/utils.js new file mode 100644 index 0000000..c01cd7a --- /dev/null +++ b/packages/ae-redux/src/utils.js @@ -0,0 +1,19 @@ + +import { createSymbol } from '@algebraic-effects/utils'; + +export const AE_REDUX_ACTION = createSymbol('algebraic-effects/redux-action'); + +export const decorateAction = action => { + action && (action.$$type = AE_REDUX_ACTION); + return action; +}; + +export const isEffectfulAction = action => action ? action.$$type === AE_REDUX_ACTION : false; + +export const filterAction = (filter, action) => { + if (!action) return false; + if (isEffectfulAction(action)) return false; + if (typeof filter === 'string') return filter === action.type; + if (typeof filter === 'function') return filter(action); + return false; +}; diff --git a/packages/ae-redux/test/Store.test.js b/packages/ae-redux/test/Store.test.js new file mode 100644 index 0000000..2bd2d0f --- /dev/null +++ b/packages/ae-redux/test/Store.test.js @@ -0,0 +1,204 @@ +import { createStore } from 'redux'; +import Store from '../src/Store'; + +describe('Store effect', () => { + + beforeEach(() => { + global.jasmine.DEFAULT_TIMEOUT_INTERVAL = 500; + }); + + describe('.dispatch', () => { + + it('should dispatch action passed to it', done => { + function *program() { + yield Store.dispatch({ type: 'Yo', payload: { a: 'b' } }); + yield Store.dispatch({ type: 'Broo', payload: { c: 'd' } }); + yield Store.dispatch({ type: 'Done' }); + } + + const reducer = (state, action) => { + switch(action.type) { + case 'Yo': + return expect(action.payload).toEqual({ a: 'b' }); + case 'Bro': + return expect(action.payload).toEqual({ c: 'd' }); + case 'Done': + return done(); + } + }; + + Store.of({ store: createStore(reducer) }) + .run(program) + .fork(done, () => {}); + }); + }); + + describe('.getState', () => { + + it('should get the state from the redux store', done => { + function *program() { + const before = yield Store.getState(); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '--' }); + const after = yield Store.getState(); + yield Store.dispatch({ type: 'Done', payload: { before, after } }); + } + + const reducer = (state = 0, action) => { + switch(action.type) { + case '++': return state + 1; + case '--': return state - 1; + case 'Done': + expect(state).toBe(3); + expect(action.payload).toEqual({ before: 0, after: 3 }); + return done(); + default: return state; + } + }; + + Store.of({ store: createStore(reducer) }) + .run(program) + .fork(done, () => {}); + }); + }); + + describe('.getAction', () => { + + it('should select the required value from the state', done => { + function *program() { + const action = yield Store.getAction(); + yield Store.dispatch({ type: 'Done', payload: action }); + } + + const reducer = (state, action) => { + switch(action.type) { + case 'Done': + expect(action.payload.type).toEqual('Hello world'); + return done(); + default: return state; + } + }; + + const dispatch = action => + Store.of({ store: createStore(reducer), action }) + .run(program) + .fork(done, () => {}); + + dispatch({ type: 'Hello world' }); + }); + }); + + describe('.selectState', () => { + + it('should select the required value from the state', done => { + function *program() { + const before = yield Store.selectState(state => state.count); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '++' }); + yield Store.dispatch({ type: '--' }); + const after = yield Store.selectState(); + yield Store.dispatch({ type: 'Done', payload: { before, after } }); + } + + const reducer = (state = { count: 0 }, action) => { + switch(action.type) { + case '++': return { count: state.count + 1 }; + case '--': return { count: state.count - 1 }; + case 'Done': + expect(state).toEqual({ count: 3 }); + expect(action.payload).toEqual({ before: 0, after: { count: 3 } }); + return done(); + default: return state; + } + }; + + Store.of({ store: createStore(reducer) }) + .run(program) + .fork(done, () => {}); + }); + }); + + describe('.waitFor', () => { + + function *program() { + yield Store.waitFor('Hello'); + yield Store.dispatch({ type: 'Done', payload: 'World' }); + } + + const getStore = (done) => { + const reducer = (state = 0, action) => { + switch(action.type) { + case 'Hello': return state; + case 'Done': + expect(action.payload).toBe('World'); + return done(); + default: return state; + } + }; + + return createStore(reducer); + }; + + it('should only execute the rest of the program if the action type matches', done => { + const store = getStore(done); + const dispatch = action => { + Store.of({ store, action }) + .run(program) + .fork(done, () => {}); + }; + + dispatch({ type: 'Hello' }); + }); + + it('should skip the rest of the program if action type doesnt match', done => { + const store = getStore(done); + const dispatch = action => { + Store.of({ store, action }) + .run(program) + .fork(done, x => { + expect(x.type).toBe('NotHello'); + done(); + }); + }; + + dispatch({ type: 'NotHello' }); + }); + + describe('with function filter', () => { + function *program() { + yield Store.waitFor(x => x.type === 'Hello'); + yield Store.dispatch({ type: 'Done', payload: 'World' }); + } + + it('should only execute the rest of the program if the action type matches', done => { + const store = getStore(done); + const dispatch = action => { + Store.of({ store, action }) + .run(program) + .fork(done, () => {}); + }; + + dispatch({ type: 'Hello' }); + }); + + it('should skip the rest of the program if action type doesnt match', done => { + const store = getStore(done); + const dispatch = action => { + Store.of({ store, action }) + .run(program) + .fork(done, x => { + expect(x.type).toBe('NotHello'); + done(); + }); + }; + + dispatch({ type: 'NotHello' }); + }); + }); + }); +}); diff --git a/packages/ae-redux/test/index.test.js b/packages/ae-redux/test/index.test.js new file mode 100644 index 0000000..8588ae5 --- /dev/null +++ b/packages/ae-redux/test/index.test.js @@ -0,0 +1,2 @@ + +it('dummy', () => {}); diff --git a/packages/ae-redux/test/utils.test.js b/packages/ae-redux/test/utils.test.js new file mode 100644 index 0000000..a38ac6b --- /dev/null +++ b/packages/ae-redux/test/utils.test.js @@ -0,0 +1,33 @@ + +import { decorateAction, AE_REDUX_ACTION, isEffectfulAction} from '../src/utils'; + +describe('decorateAction', () => { + it('should attach a $$type property to given action', () => { + + const action = decorateAction({ type: 'wow', payload: { wow: 'wow' } }); + expect(action.$$type).toBe(AE_REDUX_ACTION); + }); + + it('should attach a $$type property for null or undefined cases, return as is', () => { + expect(decorateAction(null)).toBe(null); + expect(decorateAction(undefined)).toBe(undefined); + expect(decorateAction(0)).toBe(0); + expect(decorateAction('')).toBe(''); + }); +}); + +describe('isEffectfulAction', () => { + it('should return true for decorated actions, else false', () => { + const action = { type: 'Hello', payload: 'World' }; + expect(isEffectfulAction(action)).toBe(false); + expect(isEffectfulAction(decorateAction(action))).toBe(true); + }); + + it('should return false for empty values', () => { + expect(isEffectfulAction(null)).toBe(false); + expect(isEffectfulAction(undefined)).toBe(false); + expect(isEffectfulAction(false)).toBe(false); + expect(isEffectfulAction('')).toBe(false); + }); +}); + diff --git a/yarn.lock b/yarn.lock index ad29db9..e913750 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4925,7 +4925,7 @@ lodash@^4.13.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -6206,6 +6206,14 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +redux@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5" + integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-7.0.0.tgz#107405afcc4a190ec5ed450ecaa00ed0cafa7a4c" @@ -7002,6 +7010,11 @@ swap-case@^1.1.0: lower-case "^1.1.1" upper-case "^1.1.1" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"