diff --git a/README.md b/README.md index bf5fdd8..8b7e647 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Helpers for scaling and abstracting redux by co-locating actions, reducers and s [![Build Status](https://travis-ci.org/thomasdashney/redux-modular.svg?branch=master)](https://travis-ci.org/thomasdashney/redux-modular) [![Test Coverage](https://codeclimate.com/github/thomasdashney/redux-modular/badges/coverage.svg)](https://codeclimate.com/github/thomasdashney/redux-modular/coverage) [![Code Climate](https://codeclimate.com/github/thomasdashney/redux-modular/badges/gpa.svg)](https://codeclimate.com/github/thomasdashney/redux-modular) + ## Install ``` @@ -16,7 +17,189 @@ or $ yarn add redux-modular ``` -## Usage +## Usage Guide + +* [Defining actions](#defining-actions) +* [Defining reducers](#defining-reducers) +* [Defining selectors](#defining-selectors) +* [Defining reusable redux logic](#defining-reusable-redux-logic) +* [Writing tests](#writing-tests) + +### Defining actions + +#### `createType(String|Array pathToState)` + +Creates a **type creator** - a helper for creating action types under a namespace: + +```js +import { createType } from 'redux-modular' + +const COUNTER_TYPE = createType('counter') +COUNTER_TYPE('increment') // 'increment (counter)' + +const COUNTER_TYPE = createType(['path', 'to', 'counter']) +COUNTER_TYPE('increment') // 'increment (path.to.counter)' +``` + +#### `createAction(String|Array type, [Function payloadCreator])` + +Creates an [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creator: + +```js +import { createAction } from 'redux-modular' + +const increment = createAction('increment') +increment() // { type: 'increment' } + +const setValue = createAction('setValue', value => ({ value }) +setValue(value) // { type: 'setValue', payload: { value } } +``` + +`actionCreator.toString()` returns the action type: + +```js +increment.toString() // 'increment' +``` + +#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` + +Creates an object of action creators using the key as the action `type`: + +```js +import { createActions } from 'redux-modular' + +const counterActions = createActions({ + increment: null, + decrement: null, + setValue: value => ({ value }) +}) + +counterActions.increment() // { type: 'increment' } +``` + +If you would like to namespace the actions via `createType`, you can pass a second parameter: + +```js +const counterActions = createActions({ + increment: null, + decrement: null, + setValue: value => ({ value }) +}, 'counter') + +counterActions.increment() // { type: 'increment (counter)' } +``` + +### Defining reducers + +#### `createReducer(Any initialState, Object actionTypesToReducers)` + +Given an initial state and mapping of action types to reducer functions, will return a new reducer: + +```js +import { createReducer } from 'redux-modular' + +const counterReducer = createReducer(0, { + increment: state => state + 1, + decrement: state => state - 1, + setValue: (state, payload) => payload.value +}) + +counterReducer(undefined, { type: '@@INIT' }) // 0 (initial state) +counterReducer(0, { type: 'increment' }) // 1 +``` + +This is very useful in conjunction with actions created using `createAction` or `createActions`: + +```js +const counterReducer = createReducer(0, { + [counterActions.increment]: state => state + 1, + [counterActions.decrement]: state => state - 1, + [counterActions.setValue]: (state, payload) => payload.value +}) + +counterReducer(0, counterActions.increment()) // 1 +counterReducer(0, counterActions.setValue(5)) // 5 +``` + +### Defining selectors + +Rather than having to select data directly from the redux state tree, you can define "selector" functions. These help to increase code maintainability by reducing access to redux state to these functions, serving as a public API to the state tree. + +#### `createSelectors(Object selectorFunctions, [String|Array pathToState])` + +This function can be used to create an object of selector functions. Given an object of selector functions, as well as a path to the state, it will return a new object of selector functions. + +Suppose we want our counter logic to live at `state.myCounter`. We can set up our `counter` reducer in our root reducer via `combineReducers`. Using `createSelectors`, we can create selector functions that select directly from our `counter` state given the full redux state as an argument: + +```js +import { combineReducers } from 'redux' +import { createSelectors } from 'redux-modular' + +// create selectors + +const counterSelectors = createSelectors({ + value: state => state.value +}, 'myCounter') + +// create root reducer and state + +const rootReducer = combineReducers({ + myCounter: counterReducer +}) + +const state = rootReducer(undefined, counterActions.setValue(5)) + +// select the counter value from state + +counterSelectors.value(state) // 5 +``` + +If the counter lives multiple levels deep in the redux state, you can use [`lodash.get`](https://lodash.com/docs/4.17.10#get) syntax to pass an array or string path to the state: + +```js +const rootReducer = combineReducers({ + nested: combineReducers({ + myCounter: counterReducer + }) +}) + +const counterSelectors = createSelectors({ + value: state => state.value +}, 'nested.myCounter') + +const state = rootReducer(undefined, counterActions.setValue(5)) + +counterSelectors.value(state) // 5 +``` + +The [`reselect`](https://github.com/reduxjs/reselect) library can be helpful to create memoized, computed selector functions: + +```js +import { createSelectors } from 'redux-modular' +import { reselect } from 'redux-modular' + +const counterSelectors = createSelectors({ + asPercentageOfOneHundred: createSelector( + state => state.value, + value => { + return value / 100.0 + } + ) +}, 'counter1') + +counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } }) // 0.05 +``` + +### Defining reusable redux logic + +#### createLogic(Object, String|Array pathToState) + +You can define related actions, selectors and reducer logic in an object. The `createLogic` function is an abstraction over `createActions` and `createSelectors`, allowing you to minimally define related actions, selectors and a reducer. This is useful for reducing boilerplate for a set of redux logic, but also making easy it easy to include the logic in multiple places. + +As parameters, `createLogic` takes a redux state path, and an object of the following: +* `actions` is an object that will be run through `createActions` +* `reducer` is a function which, given the actions returned by `createActions`, returns a reducer. +* `selectors` is an object that will be run through `createSelectors`

@@ -24,7 +207,7 @@ $ yarn add redux-modular ```js import { combineReducers, createStore } from 'redux' -import { mount, createReducer } from 'redux-modular' +import { createLogic, createReducer } from 'redux-modular' /* Create an object containing the logic (actions, reducer, selectors) */ @@ -33,27 +216,27 @@ const counter = { actions: { increment: null, decrement: null, - set: (value) => ({ value }) + setValue: (value) => ({ value }) }, // function mapping actions to reducers reducer: actions => createReducer(0, { [actions.increment]: state => state + 1, [actions.decrement]: state => state - 1, - [actions.set]: (state, payload) => payload.value + [actions.setValue]: (state, payload) => payload.value }), // function mapping local state selector to your selectors - selectors: localStateSelector => ({ - counterValue: state => localStateSelector(state) - }) + selectors: { + value: state => state + } } -/* Instantiate the counter logic by mounting to redux paths */ +/* Instantiate the counter logic to a given redux path */ -const counter1 = mount('counter1', counter) -const counter2 = mount('counter2', counter) -const counter3 = mount(['nested', 'counter3'], counter) +const counter1 = createLogic(counter, 'counter1') +const counter2 = createLogic(counter, 'counter2') +const counter3 = createLogic(counter, ['nested', 'counter3']) /* Add the reducers to your root reducer */ @@ -71,41 +254,36 @@ const store = createStore(rootReducer) const { actions, selectors } = counter1 -console.log(selectors.counterValue(store.getState())) // prints `0` +selectors.value(store.getState()) // 0 store.dispatch(actions.increment()) -console.log(selectors.counterValue(store.getState())) // prints `1` +selectors.value(store.getState()) // 1 store.dispatch(actions.decrement()) -console.log(selectors.counterValue(store.getState())) // prints `0` +selectors.value(store.getState()) // 0 -store.dispatch(actions.set(5)) -console.log(selectors.counterValue(store.getState())) // prints `5` +store.dispatch(actions.setValue(5)) +selectors.value(store.getState()) // 5 ``` -## Writing Tests +### Writing Tests -If you `mount` your logic to a path of `null`, you can test your state logic without any assumption of where it sits in your redux state. +An easy, minimal way to test your logic is by running `actions` through the `reducer`, and making assertions about the return value of `selectors`. ```js /* eslint-env jest */ - -const counter = require('./counter') - -const { actions, reducer, selectors } = mount(null, counter) - it('can increment', () => { const state = reducer(0, actions.increment()) - expect(selectors.counterValue(state)).toEqual(1) + expect(selectors.value(state)).toEqual(1) }) it('can decrement', () => { const state = reducer(0, actions.decrement()) - expect(selectors.counterValue(state)).toEqual(-1) + expect(selectors.value(state)).toEqual(-1) }) it('can be set to a number', () => { - const state = reducer(0, actions.set(5)) - expect(selectors.counterValue(state)).toEqual(5) + const state = reducer(0, actions.setValue(5)) + expect(selectors.value(state)).toEqual(5) }) ``` diff --git a/src/action-helpers/create-action.js b/src/action-helpers/create-action.js new file mode 100644 index 0000000..f9bdd6b --- /dev/null +++ b/src/action-helpers/create-action.js @@ -0,0 +1,19 @@ +export default function createAction (type, payloadCreator) { + const actionCreator = (...params) => { + const action = { type } + + if (payloadCreator) { + action.payload = payloadCreator(...params) + + if (action.payload instanceof Error) { + action.error = true + } + } + + return action + } + + actionCreator.toString = () => type + + return actionCreator +} diff --git a/src/action-helpers/create-actions.js b/src/action-helpers/create-actions.js new file mode 100644 index 0000000..3db041a --- /dev/null +++ b/src/action-helpers/create-actions.js @@ -0,0 +1,14 @@ +import createAction from './create-action' +import createType from './create-type' + +export default function createActions (actions, pathToState) { + const TYPE = pathToState === undefined || pathToState === null + ? type => type + : createType(pathToState) + + return Object.keys(actions).reduce((prev, key) => { + return Object.assign({}, prev, { + [key]: createAction(TYPE(key), actions[key]) + }) + }, {}) +} diff --git a/src/action-helpers/create-type.js b/src/action-helpers/create-type.js new file mode 100644 index 0000000..d9e96cf --- /dev/null +++ b/src/action-helpers/create-type.js @@ -0,0 +1,28 @@ +import { isString, isArray } from '../utils' + +export default function createType (pathToState) { + validateArgument(pathToState) + + if (isArray(pathToState)) { + pathToState = pathToState.join('.') + } + + return type => `${type} (${pathToState})` +} + +const validArgumentTests = [ + isString, + pathToState => isArray(pathToState) && pathToState.every(isString) +] + +function validateArgument (pathToState) { + if (validArgumentTests.every(test => !test(pathToState))) { + throw new InvalidArgError() + } +} + +class InvalidArgError extends Error { + constructor () { + super('path must be a string or array of strings') + } +} diff --git a/src/create-action.js b/src/create-action.js deleted file mode 100644 index 39ef6cd..0000000 --- a/src/create-action.js +++ /dev/null @@ -1,20 +0,0 @@ -export default function createAction (type, payloadCreator) { - if (!payloadCreator) { - payloadCreator = () => null - } - - const actionCreator = (...params) => { - const payload = payloadCreator(...params) - const action = { type, payload } - - if (payload instanceof Error) { - action.error = true - } - - return action - } - - actionCreator.toString = () => type - - return actionCreator -} diff --git a/src/create-logic.js b/src/create-logic.js new file mode 100644 index 0000000..804b30f --- /dev/null +++ b/src/create-logic.js @@ -0,0 +1,28 @@ +import createActions from './action-helpers/create-actions' +import createSelectors from './selector-helpers/create-selectors' + +export default function (logic, pathToState) { + if (!logic) { + throw new Error('logic must be passed to create-logic') + } + + let { actions, reducer, selectors } = logic + + if (actions) { + actions = createActions(actions, pathToState) + } + + if (actions && reducer) { + reducer = reducer(actions) + } + + if (selectors) { + selectors = createSelectors(selectors, pathToState) + } + + return { + actions, + reducer, + selectors + } +} diff --git a/src/globalize-actions.js b/src/globalize-actions.js deleted file mode 100644 index 3da3836..0000000 --- a/src/globalize-actions.js +++ /dev/null @@ -1,24 +0,0 @@ -import createAction from './create-action' - -const isArray = value => { - return typeof value === 'object' && - value !== null && - value.constructor === Array -} -const isString = value => typeof value === 'string' - -export default function globalizeActions (pathToState, actions) { - if (isArray(pathToState)) { - pathToState = pathToState.join('.') - } else if (pathToState !== null && !isString(pathToState)) { - throw new Error('path must be a string or array') - } - - return Object.keys(actions).reduce((prev, key) => { - const type = pathToState ? `${key} (${pathToState})` : key - - return Object.assign({}, prev, { - [key]: createAction(type, actions[key]) - }) - }, {}) -} diff --git a/src/index.js b/src/index.js index a2a084e..8319bd3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,6 @@ -export { default as mount } from './mount' -export { default as createReducer } from './create-reducer' -export { default as createAction } from './create-action' +export { default as createType } from './action-helpers/create-type' +export { default as createAction } from './action-helpers/create-action' +export { default as createActions } from './action-helpers/create-actions' +export { default as createReducer } from './reducer-helpers/create-reducer' +export { default as createSelectors } from './selector-helpers/create-selectors' +export { default as createLogic } from './create-logic' diff --git a/src/mount.js b/src/mount.js deleted file mode 100644 index 6d91c61..0000000 --- a/src/mount.js +++ /dev/null @@ -1,32 +0,0 @@ -import get from 'lodash.get' -import globalizeActions from './globalize-actions' - -export default function (pathToState, logic) { - if (!logic) { - throw new Error('logic must be passed to mount') - } - - let { actions, reducer, selectors } = logic - - if (actions) { - actions = globalizeActions(pathToState, actions) - } - - if (actions && reducer) { - reducer = reducer(actions) - } - - if (selectors) { - const localStateSelector = pathToState - ? state => get(state, pathToState) - : state => state - - selectors = selectors(localStateSelector) - } - - return { - actions, - reducer, - selectors - } -} diff --git a/src/create-reducer.js b/src/reducer-helpers/create-reducer.js similarity index 59% rename from src/create-reducer.js rename to src/reducer-helpers/create-reducer.js index 2b72422..1fc885e 100644 --- a/src/create-reducer.js +++ b/src/reducer-helpers/create-reducer.js @@ -1,4 +1,10 @@ +import { isObject } from '../utils' + export default function createReducer (initialState, reducersByAction) { + if (!isObject(reducersByAction)) { + throw new Error('createReducer requires an object as its second argument') + } + return (state = initialState, action) => { const reducer = reducersByAction[action.type] return reducer ? reducer(state, action.payload) : state diff --git a/src/selector-helpers/create-selectors.js b/src/selector-helpers/create-selectors.js new file mode 100644 index 0000000..7d765ff --- /dev/null +++ b/src/selector-helpers/create-selectors.js @@ -0,0 +1,18 @@ +import get from 'lodash.get' + +// TODO see if this is necessary + +export default function createSelectors (selectors, pathToState) { + return Object.keys(selectors).reduce((prev, key) => { + const selector = selectors[key] + return Object.assign(prev, { + [key]: pathToState + ? globalizeSelector(selector, pathToState) + : selector + }) + }, {}) +} + +function globalizeSelector (selector, pathToState) { + return state => selector(get(state, pathToState)) +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..f9e3875 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,13 @@ +export function isArray (value) { + return typeof value === 'object' && + value !== null && + value.constructor === Array +} + +export function isString (value) { + return typeof value === 'string' +} + +export function isObject (value) { + return typeof value === 'object' && !isArray(value) && value !== null +} diff --git a/test/create-action.test.js b/test/create-action.test.js index a71ab36..206d398 100644 --- a/test/create-action.test.js +++ b/test/create-action.test.js @@ -1,22 +1,20 @@ /* eslint-env jest */ -import createAction from '../src/create-action' +import createAction from '../src/action-helpers/create-action' it('creates an FSA-compliant action creator', () => { const someAction = createAction('SOME_TYPE') expect(someAction()).toEqual({ type: 'SOME_TYPE', - payload: null + payload: undefined }) -}) -it('allows null to be passed as the payload creator', () => { - const someAction = createAction('SOME_TYPE', null) + const nullPayloadCreator = createAction('SOME_TYPE') - expect(someAction()).toEqual({ + expect(nullPayloadCreator()).toEqual({ type: 'SOME_TYPE', - payload: null + payload: undefined }) }) diff --git a/test/globalize-actions.test.js b/test/create-actions.test.js similarity index 63% rename from test/globalize-actions.test.js rename to test/create-actions.test.js index a15a7f7..3f47b5b 100644 --- a/test/globalize-actions.test.js +++ b/test/create-actions.test.js @@ -1,24 +1,24 @@ /* eslint-env jest */ -import globalizeActions from '../src/globalize-actions' +import createActions from '../src/action-helpers/create-actions' it('creates actions with types including the state path', () => { - let actions = globalizeActions('path.to.state', { + let actions = createActions({ increment: null - }) + }, 'path.to.state') expect(actions).toHaveProperty('increment') expect(actions.increment.toString()).toEqual('increment (path.to.state)') - actions = globalizeActions(['path', 'to', 'state'], { + actions = createActions({ increment: null - }) + }, ['path', 'to', 'state']) expect(actions.increment.toString()).toEqual('increment (path.to.state)') }) it('creates actions with payload creators', () => { - const actions = globalizeActions('path.to.state', { + const actions = createActions({ increment: (param1, param2) => ({ param1, param2 }) - }) + }, 'path.to.state') const action = actions.increment('test1', 'test2') expect(action).toHaveProperty('payload') @@ -28,17 +28,13 @@ it('creates actions with payload creators', () => { }) }) -it('does not include the state path if pathToState is null', () => { - let actions = globalizeActions(null, { - increment: null - }) +it('allows you to skip the second parameter', () => { + let actions = createActions({ increment: null }) expect(actions).toHaveProperty('increment') expect(actions.increment.toString()).toEqual('increment') }) it('throws an error if pathToState is invalid', () => { const actions = { increment: () => null } - - expect(() => globalizeActions(5, actions)).toThrow() - expect(() => globalizeActions({}, actions)).toThrow() + expect(() => createActions(actions, 5)).toThrow() }) diff --git a/test/mount.test.js b/test/create-logic.test.js similarity index 61% rename from test/mount.test.js rename to test/create-logic.test.js index 2abe514..b28f37f 100644 --- a/test/mount.test.js +++ b/test/create-logic.test.js @@ -1,15 +1,15 @@ /* eslint-env jest */ import { combineReducers } from 'redux' -import createReducer from '../src/create-reducer' -import mount from '../src/mount' +import createReducer from '../src/reducer-helpers/create-reducer' +import createLogic from '../src/create-logic' -it('mounts redux path to action types', () => { - const logic = mount('path.to.module', { +it('createLogics redux path to action types', () => { + const logic = createLogic({ actions: { increment: () => null } - }) + }, 'path.to.module') expect(logic).toHaveProperty('actions') expect(logic.actions).toHaveProperty('increment') @@ -18,8 +18,8 @@ it('mounts redux path to action types', () => { ).toEqual('increment (path.to.module)') }) -it('configures the reducer with the mounted actions', () => { - const logic = mount('path.to.module', { +it('configures the reducer with the createLogiced actions', () => { + const logic = createLogic({ actions: { increment: () => null }, @@ -31,7 +31,7 @@ it('configures the reducer with the mounted actions', () => { }) }) } - }) + }, 'path.to.module') expect(logic).toHaveProperty('reducer') const { reducer, actions } = logic @@ -41,29 +41,29 @@ it('configures the reducer with the mounted actions', () => { }) it('creates selectors using the correct state selector', () => { - ['nested.path', ['nested', 'path'],].forEach(pathString => { - const logic = mount(pathString, { - selectors: localSelector => ({ - mySelector: state => localSelector(state) - }) - }) + ['nested.path', ['nested', 'path']].forEach(pathString => { + const logic = createLogic({ + selectors: { + mySelector: state => state.key + } + }, pathString) expect(logic).toHaveProperty('selectors') expect(logic.selectors).toHaveProperty('mySelector') const state = { nested: { - path: { some: 'state' } + path: { key: 'value' } } } - expect(logic.selectors.mySelector(state)).toEqual({ some: 'state' }) + expect(logic.selectors.mySelector(state)).toEqual('value') }) }) -it('can create selectors with pathToState of null', () => { - const logic = mount(null, { - selectors: localSelector => ({ - mySelector: state => localSelector(state).value - }) +it('can create selectors with no pathToState', () => { + const logic = createLogic({ + selectors: { + mySelector: state => state.value + } }) expect(logic).toHaveProperty('selectors') @@ -75,5 +75,5 @@ it('can create selectors with pathToState of null', () => { }) it('throws an error if no params are passed', () => { - expect(mount).toThrow(Error) + expect(createLogic).toThrow(Error) }) diff --git a/test/create-reducer.test.js b/test/create-reducer.test.js index 3d19501..2f2a95f 100644 --- a/test/create-reducer.test.js +++ b/test/create-reducer.test.js @@ -1,7 +1,7 @@ /* eslint-env jest */ -import createReducer from '../src/create-reducer' -import createAction from '../src/create-action' +import createReducer from '../src/reducer-helpers/create-reducer' +import createAction from '../src/action-helpers/create-action' it('sets initial state', () => { const initialState = { initial: 'state' } @@ -44,3 +44,7 @@ it('returns the previous state given an unknown action', () => { expect(reduce(1, { type: 'UNEXPECTED' })).toEqual(1) }) + +it('throws an error if the second argument is not an object', () => { + expect(() => createReducer(0)).toThrow() +}) diff --git a/test/create-selectors.test.js b/test/create-selectors.test.js new file mode 100644 index 0000000..dbc06ff --- /dev/null +++ b/test/create-selectors.test.js @@ -0,0 +1,18 @@ +/* eslint-env jest */ + +import createSelectors from '../src/selector-helpers/create-selectors' + +it('creates an object of selectors', () => { + const selectorObj = { + testSelector: state => state.key + } + + let selectors = createSelectors(selectorObj) + expect(selectors.testSelector({ key: 'value' })).toEqual('value') + + selectors = createSelectors(selectorObj, 'nested.path') + expect(selectors.testSelector({ nested: { path: { key: 'value' } } })) + + selectors = createSelectors(selectorObj, ['nested', 'path']) + expect(selectors.testSelector({ nested: { path: { key: 'value' } } })) +}) diff --git a/test/create-type.test.js b/test/create-type.test.js new file mode 100644 index 0000000..dea6f5f --- /dev/null +++ b/test/create-type.test.js @@ -0,0 +1,18 @@ +/* eslint-env jest */ + +import createType from '../src/action-helpers/create-type' + +it('given a string or array, returns a function that can be use to create namespaced types', () => { + const testType = createType('path') + expect(testType('action')).toEqual('action (path)') + + const testType2 = createType(['nested', 'path']) + expect(testType2('action')).toEqual('action (nested.path)') +}) + +it('throws an error if the argument is invalid', () => { + expect(() => createType()).toThrow() + expect(() => createType(null)).toThrow() + expect(() => createType({})).toThrow() + expect(() => createType([{}])).toThrow() +}) diff --git a/test/index.test.js b/test/index.test.js index 3a960b4..ce7377d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,7 +3,10 @@ import * as reduxModular from '../src/index' it('exports modularize and createReducer', () => { - expect(reduxModular).toHaveProperty('mount') - expect(reduxModular).toHaveProperty('createReducer') + expect(reduxModular).toHaveProperty('createLogic') + expect(reduxModular).toHaveProperty('createType') expect(reduxModular).toHaveProperty('createAction') + expect(reduxModular).toHaveProperty('createActions') + expect(reduxModular).toHaveProperty('createReducer') + expect(reduxModular).toHaveProperty('createSelectors') })