diff --git a/.gitignore b/.gitignore index 5148e52..fac7ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ jspm_packages # Optional REPL history .node_repl_history + +typings diff --git a/README.md b/README.md index 1565a72..778fd78 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ # easy-reducer Easier reducers with less boilerplate. + +# Using easy reducers + +```js +import easyReducerCreator from '../reducerCreator' +import { createStore, applyMiddleware, combineReducers } from 'redux' +import thunk from 'redux-thunk' + +const defaultState { + test: 'data' +} +const syncActions = { + // previous state is always the last argument. + testDataPlusNum (num, state) { + return {...state, test: 'data' + num} + }, + testDataPlusTwoNum (num1, num2, state) { + return {...state, test: 'data' + num1 + '' + num2} + } +} + +// async actions are handled differently, and are meant to be used with redux-thunk +const asyncActions = { + // syncActions, dispatch, getState (from thunk), are always the last arguments. + asyncStateModifyer (num, syncActions, dispatch, getState) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(dispatch(syncActions.testDataPlusNum(num))) + }, 20) + }) + }, +} + +// Test reducer now has all the action creators you would use for your methods, with the types TR1/methodName +export const testReducer1 = reducerCreator(defaultState, syncActions, asyncActions)('TR1') +// Reducers are reusable with different ID's +export const testReducer2 = reducerCreator(defaultState, syncActions, asyncActions)('TR2') + +export const store = createStore(combineReducers({ + // a reducer property is attached, which serves as the actual reducer. + testReducer1: testReducer1.reducer, + testReducer2: testReducer2.reducer +}), undefined, applyMiddleware(thunk)) + +store.dispatch(testReducer1.testDataPlusNum(1)) +store.dispatch(testReducer1.asyncStateModifyer(2)) +.then(() => store.dispatch(testReducer2.testDataPlusTwoNum(3, 4))) + +``` diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..f4cf473 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,3 @@ +import createReducer from './reducerCreator'; +export * from './reducerCreator'; +export default createReducer; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..6fb5666 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,9 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +var reducerCreator_1 = require('./reducerCreator'); +__export(require('./reducerCreator')); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = reducerCreator_1.default; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/index.js.map b/lib/index.js.map new file mode 100644 index 0000000..a3267c8 --- /dev/null +++ b/lib/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;AAAC,+BAA0B,kBAC1B,CAAC,CAD2C;AAC5C,iBAAc,kBACd,CAAC,EAD+B;AAChC;kBAAe,wBAAa,CAAA"} \ No newline at end of file diff --git a/lib/reducerCreator.d.ts b/lib/reducerCreator.d.ts new file mode 100644 index 0000000..89eed6b --- /dev/null +++ b/lib/reducerCreator.d.ts @@ -0,0 +1,4 @@ +export interface IReducer { + reducer: Function; +} +export default function makeReducer(defaultState: ISubState, Actions?: T, AsyncActions?: V): (ID: string) => V & T & IReducer; diff --git a/lib/reducerCreator.js b/lib/reducerCreator.js new file mode 100644 index 0000000..561db21 --- /dev/null +++ b/lib/reducerCreator.js @@ -0,0 +1,60 @@ +"use strict"; +var lodash_1 = require('lodash'); +function makeReducer(defaultState, Actions, AsyncActions) { + return function (ID) { + if (!ID || typeof ID === 'undefined') { + throw new Error('Reducers must have an ID'); + } + if (typeof defaultState === 'undefined') { + throw new Error('Reducers must have a default state'); + } + var newSyncActions = lodash_1.merge({}, Actions); + var newAsyncActions = lodash_1.merge({}, AsyncActions); + // Actions are now functions that auto return types + Object.keys(Actions).forEach(function (key) { + (newSyncActions[key]) = function () { + var payload = []; + for (var _i = 0; _i < arguments.length; _i++) { + payload[_i - 0] = arguments[_i]; + } + return { type: ID + "/" + key, payload: payload }; + }; + }); + if (AsyncActions) { + // async actions have dispatch and the payload injected into them. + Object.keys(AsyncActions).forEach(function (key) { + if (Actions[key]) { + throw new Error('You cannot have a Action and Async Action with the same name: ' + key); + } + newAsyncActions[key] = function () { + var payload = []; + for (var _i = 0; _i < arguments.length; _i++) { + payload[_i - 0] = arguments[_i]; + } + return function (dispatch, getState) { + return AsyncActions[key].apply(AsyncActions, payload.concat([newSyncActions, dispatch, getState])); + }; + }; + }); + } + var baseReducer = { + reducer: function (state, action) { + state = state || defaultState; + /* tslint:disable */ + // Linting is disabled because there is no other way to do this + var _a = action.type.split('/'), ActionID = _a[0], actionMethod = _a[1]; + if (ActionID === ID) { + if (newSyncActions[actionMethod]) { + return Actions[actionMethod].apply(Actions, action.payload.concat([state])); + } + } + return state; + /* tslint:enable */ + } + }; + return lodash_1.merge(baseReducer, newSyncActions, newAsyncActions); + }; +} +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = makeReducer; +//# sourceMappingURL=reducerCreator.js.map \ No newline at end of file diff --git a/lib/reducerCreator.js.map b/lib/reducerCreator.js.map new file mode 100644 index 0000000..28f8cb6 --- /dev/null +++ b/lib/reducerCreator.js.map @@ -0,0 +1 @@ +{"version":3,"file":"reducerCreator.js","sourceRoot":"","sources":["../src/reducerCreator.ts"],"names":[],"mappings":";AAAA,uBAAoB,QACpB,CAAC,CAD2B;AAK5B,qBAEC,YAAuB,EAAE,OAAW,EAAE,YAAgB;IACrD,MAAM,CAAC,UAAC,EAAU;QAChB,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,OAAO,EAAE,KAAK,WAAW,CAAC,CAAC,CAAC;YACrC,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;QAC7C,CAAC;QACD,EAAE,CAAC,CAAC,OAAO,YAAY,KAAK,WAAW,CAAC,CAAC,CAAC;YACxC,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAA;QACvD,CAAC;QAED,IAAM,cAAc,GAAiB,cAAK,CAAE,EAAe,EAAE,OAAO,CAAC,CAAA;QACrE,IAAM,eAAe,GAAM,cAAK,CAAC,EAAE,EAAE,YAAY,CAAC,CAAA;QAElD,mDAAmD;QACnD,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,UAAC,GAAG;YAC/B,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,GAAG;gBAAC,iBAAiB;qBAAjB,WAAiB,CAAjB,sBAAiB,CAAjB,IAAiB;oBAAjB,gCAAiB;;gBACxC,MAAM,CAAC,EAAE,IAAI,EAAK,EAAE,SAAI,GAAK,EAAE,SAAA,OAAO,EAAE,CAAA;YAC1C,CAAC,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC;YACjB,kEAAkE;YAClE,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,UAAC,GAAG;gBACpC,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;oBACjB,MAAM,IAAI,KAAK,CAAC,gEAAgE,GAAG,GAAG,CAAC,CAAA;gBACzF,CAAC;gBACD,eAAe,CAAC,GAAG,CAAC,GAAG;oBAAC,iBAAiB;yBAAjB,WAAiB,CAAjB,sBAAiB,CAAjB,IAAiB;wBAAjB,gCAAiB;;oBACvC,MAAM,CAAC,UAAC,QAAkB,EAAE,QAAkB;wBAC5C,OAAA,YAAY,CAAC,GAAG,QAAhB,YAAY,EAAS,OAAO,SAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,GAAC;oBAAjE,CAAiE,CAAA;gBACrE,CAAC,CAAA;YACH,CAAC,CAAC,CAAA;QACJ,CAAC;QACD,IAAM,WAAW,GAAG;YAClB,OAAO,EAAE,UAAC,KAAgB,EAAE,MAAqC;gBAC/D,KAAK,GAAG,KAAK,IAAI,YAAY,CAAA;gBAC7B,oBAAoB;gBACpB,+DAA+D;gBAC/D,IAAA,2BAAuD,EAAhD,gBAAQ,EAAE,oBAAY,CAA0B;gBACvD,EAAE,CAAC,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC;oBACpB,EAAE,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;wBACjC,MAAM,CAAC,OAAO,CAAC,YAAY,QAApB,OAAO,EAAkB,MAAM,CAAC,OAAO,SAAE,KAAK,GAAC,CAAA;oBACxD,CAAC;gBACH,CAAC;gBACD,MAAM,CAAC,KAAK,CAAA;gBACZ,mBAAmB;YACrB,CAAC;SACF,CAAA;QACD,MAAM,CAAC,cAAK,CAAC,WAAW,EAAE,cAAc,EAAE,eAAe,CAAC,CAAA;IAC5D,CAAC,CAAA;AACH,CAAC;AAlDD;6BAkDC,CAAA"} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..5b9c231 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "easy-reducer", + "version": "0.1.0", + "description": "Easy reducers with less boilerplate", + "main": "lib/", + "scripts": { + "prepublish": "rm -rf lib && typings install && npm run test && tsc", + "test": "jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ericwooley/easy-reducer.git" + }, + "keywords": [ + "redux", + "react", + "reducer", + "elm", + "frp", + "thunk" + ], + "author": "Eric Wooley ", + "license": "MIT", + "bugs": { + "url": "https://github.com/ericwooley/easy-reducer/issues" + }, + "homepage": "https://github.com/ericwooley/easy-reducer#readme", + "devDependencies": { + "jest-cli": "^15.1.1", + "redux": "^3.6.0", + "redux-thunk": "^2.1.0", + "typescript": "^1.8.10", + "typings": "^1.3.3" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "tsx", + "js" + ], + "scriptPreprocessor": "/preprocessor.js", + "testRegex": "src/__tests__/.*\\.(ts|tsx|js)$" + }, + "dependencies": { + "lodash": "^4.15.0", + "lodash.merge": "^4.6.0", + "object-assign": "^4.1.0" + } +} diff --git a/preprocessor.js b/preprocessor.js new file mode 100644 index 0000000..40d6992 --- /dev/null +++ b/preprocessor.js @@ -0,0 +1,19 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +const tsc = require('typescript'); + +module.exports = { + process(src, path) { + if (path.endsWith('.ts')) { + return tsc.transpile( + src, + { + module: tsc.ModuleKind.CommonJS + }, + path, + [] + ); + } + return src; + }, +}; diff --git a/src/__tests__/reducerCreator.test.ts b/src/__tests__/reducerCreator.test.ts new file mode 100644 index 0000000..67e9a46 --- /dev/null +++ b/src/__tests__/reducerCreator.test.ts @@ -0,0 +1,152 @@ + +import reducerCreator from '../reducerCreator' +import { createStore, applyMiddleware, combineReducers } from 'redux' +import thunk from 'redux-thunk' +import {merge} from 'lodash' + +describe('reducerCreator', () => { + it('should exist', () => { + expect(reducerCreator).not.toBe(undefined) + }) + it('should throw an error if there is no id', () => { + expect(() => (reducerCreator as any)()()).toThrowError('Reducers must have an ID') + }) + it('should throw an error if there is no default state', () => { + expect(() => (reducerCreator as any)()('ID')).toThrowError('Reducers must have a default state') + }) + describe('sync actions', () => { + let testReducer + let defaultState = { + test: 'data' + } + let syncActions + beforeEach(() => { + syncActions = { + testDataPlus1: jest.fn(), + testDataPlusNumber: jest.fn(), + testDataPlusManyNumbers: jest.fn() + } + testReducer = reducerCreator(defaultState, syncActions)('ID') + }) + it('should auto create actions based on method names', () => { + expect(testReducer.testDataPlus1).toBeTruthy() + expect(testReducer.testDataPlus1()).toEqual({type: 'ID/testDataPlus1', payload: []}) + expect(testReducer.testDataPlusNumber()).toEqual({type: 'ID/testDataPlusNumber', payload: []}) + }) + it('should keep arguments as an array', () => { + expect(testReducer.testDataPlusManyNumbers(1, 2, 3, 4)) + .toEqual({type: 'ID/testDataPlusManyNumbers', payload: [1, 2, 3, 4]}) + }) + }) + describe('reducing', () => { + let testReducer + let defaultState = { + test: 'data' + } + let syncActions + beforeEach(() => { + syncActions = { + testDataPlus1 (state) { + return merge({}, state, {test: 'data1'}) + }, + testSpy: jest.fn(), + testDataPlusANumber (num, state) { + return merge({}, state, {test: state.test + num}) + } + } + testReducer = reducerCreator(defaultState, syncActions)('ID') + }) + it('should have a reducer method', () => { + expect(testReducer.reducer).toBeTruthy() + }) + it('should call actions', () => { + // the original funciton is replaced by an action + testReducer.reducer(defaultState, testReducer.testSpy()) + expect(syncActions.testSpy).toBeCalled() + }) + + it('should pass state as the first argument when there are no arguments', () => { + testReducer.reducer(defaultState, testReducer.testSpy()) + expect(syncActions.testSpy).toBeCalledWith(defaultState) + }) + + it('should pass in arugments before state', () => { + testReducer.reducer(defaultState, testReducer.testSpy(1)) + expect(syncActions.testSpy).toBeCalledWith(1, defaultState) + testReducer.reducer(defaultState, testReducer.testSpy(1, 2)) + expect(syncActions.testSpy).toBeCalledWith(1, 2, defaultState) + }) + it('should not modify the state for actions it does not own', () => { + let newState = testReducer.reducer(defaultState, {type: 'some other action'}) + expect(newState).toBe(defaultState) + }) + it('should modify state', () => { + let newState = testReducer.reducer(defaultState, testReducer.testDataPlus1()) + expect(newState).toEqual({test: 'data1'}) + expect(newState).not.toEqual(defaultState) + let newState2 = testReducer.reducer(newState, testReducer.testDataPlusANumber(2)) + expect(newState2).toEqual({test: 'data12'}) + }) + }) + describe('async actions', () => { + let testStore + let testReducer + let defaultState = { + test: 'data' + } + let syncActions, asyncActions, spy + + beforeEach(() => { + spy = jest.fn() + asyncActions = { + asyncTestSpy: jest.fn(), + asyncTestSpyCaller (num, syncActions, dispatch, getState) { + dispatch(syncActions.testSpy(num)) + }, + asyncStateModifyer (num, syncActions, dispatch, getState) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(dispatch(syncActions.testDataPlusNum(num))) + }, 20) + }) + }, + asyncTimeoutTest (syncActions, dispatch, getState) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(dispatch(syncActions.testSpy())) + }, 20) + }) + }, + asyncTestDataPlus1 (syncActions, dispatch, getState) { + dispatch(syncActions.testSpy()) + } + } + syncActions = { + testDataPlusNum (num, state) { + return merge({}, state, {test: 'data' + num}) + }, + testSpy: (state) => spy() || merge({}, state), + } + testReducer = reducerCreator(defaultState, syncActions, asyncActions)('ID') + testStore = createStore(combineReducers({testReducer: testReducer.reducer}), undefined, applyMiddleware(thunk)) + }) + it('should call the async spy', () => { + testStore.dispatch(testReducer.asyncTestSpy()) + expect(asyncActions.asyncTestSpy).toBeCalled() + }) + it('should call sync test spy', () => { + testStore.dispatch(testReducer.asyncTestSpyCaller(1)) + expect(spy).toBeCalled() + }) + it('it should work asynchronously', function () { + return testStore.dispatch(testReducer.asyncTimeoutTest()) + .then(() => expect(spy).toBeCalled()) + + }) + it('it should modify state asynchronously', function () { + return testStore.dispatch(testReducer.asyncStateModifyer(1)) + .then(() => expect(testStore.getState()).toEqual({testReducer: {test: 'data1'}})) + + }) + }) +}) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ff5c47d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ + import createReducer from './reducerCreator' + export * from './reducerCreator' + export default createReducer diff --git a/src/reducerCreator.ts b/src/reducerCreator.ts new file mode 100644 index 0000000..c08a43c --- /dev/null +++ b/src/reducerCreator.ts @@ -0,0 +1,56 @@ +import {merge} from 'lodash' +export interface IReducer { + reducer: Function +} + +export default function makeReducer + +(defaultState: ISubState, Actions?: T, AsyncActions?: V): (ID: string) => V & T & IReducer { + return (ID: string) => { + if (!ID || typeof ID === 'undefined') { + throw new Error('Reducers must have an ID') + } + if (typeof defaultState === 'undefined') { + throw new Error('Reducers must have a default state') + } + + const newSyncActions: T & IReducer = merge(({} as IReducer), Actions) + const newAsyncActions: V = merge({}, AsyncActions) + + // Actions are now functions that auto return types + Object.keys(Actions).forEach((key) => { + (newSyncActions[key]) = (...payload: any[]) => { + return { type: `${ID}/${key}`, payload } + } + }) + + if (AsyncActions) { + // async actions have dispatch and the payload injected into them. + Object.keys(AsyncActions).forEach((key) => { + if (Actions[key]) { + throw new Error('You cannot have a Action and Async Action with the same name: ' + key) + } + newAsyncActions[key] = (...payload: any[]) => { + return (dispatch: Function, getState: Function) => + AsyncActions[key](...payload, newSyncActions, dispatch, getState) + } + }) + } + const baseReducer = { + reducer: (state: ISubState, action: {type: string, payload?: any}) => { + state = state || defaultState + /* tslint:disable */ + // Linting is disabled because there is no other way to do this + const [ActionID, actionMethod] = action.type.split('/') + if (ActionID === ID) { + if (newSyncActions[actionMethod]) { + return Actions[actionMethod](...action.payload, state) + } + } + return state + /* tslint:enable */ + } + } + return merge(baseReducer, newSyncActions, newAsyncActions) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fb205a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "declaration": true, + "rootDir": "src", + "outDir": "lib", + "sourceMap": true + }, + "compileOnSave": false, + "filesGlob": ["src/**/*"], + "exclude": [ + "node_modules", + "src/__tests__" + ] +} diff --git a/typings.json b/typings.json new file mode 100644 index 0000000..ebf9ae5 --- /dev/null +++ b/typings.json @@ -0,0 +1,10 @@ +{ + "name": "easy-reducer", + "dependencies": { + "lodash": "registry:npm/lodash#4.0.0+20160723033700", + "redux-thunk": "registry:npm/redux-thunk#2.0.0+20160525185520" + }, + "globalDependencies": { + "jest": "registry:dt/jest#0.9.0+20160914121351" + } +}