From a2f467df124034aab75e0c1d4bad47af5a6008cb Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Sun, 24 Jun 2018 22:24:02 -0400 Subject: [PATCH 1/9] documentation for more helpers --- README.md | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 172 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bf5fdd8..01fd679 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,166 @@ or $ yarn add redux-modular ``` -## Usage +## Usage Guide + +This guide illustrates how to use each helper function using a `counter` redux store. You can dispatch `increment`, `decrement`, and `setValue` actions to a reducer that creates the store state. + +* [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)` + +`createType` allows you to create a **type creator**, allowing you to easily create types under a unique namespace: + +```js +import { createType } from 'redux-modular' + +const COUNTER_TYPE = createType('counter') + +const INCREMENT_TYPE = COUNTER_TYPE('increment') +console.log(INCREMENT_TYPE) // prints `increment (counter)` +``` + +#### `createAction(String|Array pathToState, [Function payloadCreator])` + +defines [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creators using `createAction`: + +```js +import { createAction } from 'redux-modular' + +const increment = createAction('increment') +console.log(increment()) // prints `{ type: 'INCREMENT' }` + +const setValue = createAction('setValue', value => ({ value }) +console.log(setValue()) // prints `{ type: 'SET_VALUE', payload: { } }` +``` + +The action type for a given action creator can be provided by calling `toString()` on the action creator: + +```js +console.log(increment.toString()) // prints `increment` +``` + +#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` + +Combined with `createAction`, you can quickly generate an object of action creators: + +```js +import { createType, createAction } from 'redux-modular' + +const COUNTER_TYPE = createType('counter') +const counterActions = { + increment: createAction(COUNTER_TYPE('increment')), + decrement: createAction(COUNTER_TYPE('decrement')), + setValue: createAction(COUNTER_TYPE('setValue'), value => ({ value })) +} +``` + +`createActions` can be used to simplify the above: + +```js +import { createActions } from 'redux-modular' + +const counterActions = createActions({ + increment: null, + decrement: null, + setValue: value => ({ value }) +}, 'counter') +``` + +### Defining reducers + +`createReducer(Any initialState, Object actionTypesToReducerHandlers)` + +This function will return a reducer that maps action types to handlers, so that whenever this reducer is called with an action of a given type, the corresponding sub-reducer will be called: + +```js +import { createReducer } from 'redux-modular' + +const counterReducer = createReducer(0, { + 'increment': state => state + 1, + 'decrement': state => state - 1, + 'setValue': (state, payload) => payload.value +}) + +console.log(counterReducer(undefined, { type: '@@INIT' })) // prints initial state of `0` +console.log(counterReducer(0, { type: 'increment' })) // prints `1` +``` + +This is very useful if used conjunction with actions created using our 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 +}) + +console.log(counterReducer(0, counterActions.increment())) // prints `1` +console.log(counterReducer(0, counterActions.setValue(5))) // prints `5` +``` + +### Defining selectors + +Rather than having to select data directly from the redux state tree, you can define "selector" functions. These can 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 a path to the state, which will be run through [`lodash.get`](https://lodash.com/docs/4.17.10#get), and an object of selector functions, it will return a new object of selector functions. The returned selector functions will first run `lodash.get(state, 'some.path')`, and then pass this value to your provided selector function. This can be useful for easily defining selectors. + +Suppose we want our counter logic to live at `state.counter1`, we can define a "value" selector as shown: + +```js +import { createSelectors } from 'redux-modular' + +const counterSelectors = createSelectors({ + value: state => state.value +}, 'counter1') + +console.log(counterSelectors.value({ counter1: { value: 5 } })) // prints `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 counterSelectors = createSelectors({ + value: state => state.value +}, 'nested.counter3') + +console.log(counterSelectors.value({ nested: { counter3: { value: 5 } } })) // prints `5` +``` + +This can also be used with the [`reselect`](https://github.com/reduxjs/reselect) library value 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') + +console.log(counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } })) // prints `0.05` +``` + +### Defining reusable redux logic + +You can define related actions, selectors and reducer logic in an object. The `mount` function can be used to convert these into corresponding actions, selectors and reducer into usable versions. This is useful if you want to define logic once, and use it multiple places. It is also usable for reducing the boilerplate of defining a related group of redux elements. + +As parameters, `mount` 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`

@@ -33,14 +193,14 @@ 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 @@ -79,20 +239,21 @@ console.log(selectors.counterValue(store.getState())) // prints `1` store.dispatch(actions.decrement()) console.log(selectors.counterValue(store.getState())) // prints `0` -store.dispatch(actions.set(5)) +store.dispatch(actions.setValue(5)) console.log(selectors.counterValue(store.getState())) // prints `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. +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. Using these `actions`, `selectors` and `reducer`, you can test the logic 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) +const { + counterActions, + counterReducer, + counterSelectors +} = require('./counter-logic') it('can increment', () => { const state = reducer(0, actions.increment()) @@ -105,7 +266,7 @@ it('can decrement', () => { }) it('can be set to a number', () => { - const state = reducer(0, actions.set(5)) + const state = reducer(0, actions.setValue(5)) expect(selectors.counterValue(state)).toEqual(5) }) ``` From f3a6a4d9bc76f284cce6fca428193152894fd489 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 12:20:10 -0400 Subject: [PATCH 2/9] re-implement mount and add more helper functions --- src/action-helpers/create-action.js | 19 ++++++++++ src/action-helpers/create-actions.js | 14 ++++++++ src/action-helpers/create-type.js | 23 ++++++++++++ src/create-action.js | 20 ----------- src/globalize-actions.js | 24 ------------- src/index.js | 7 ++-- src/mount.js | 14 +++----- src/{ => reducer-helpers}/create-reducer.js | 6 ++++ src/selector-helpers/create-selectors.js | 18 ++++++++++ src/utils.js | 13 +++++++ test/create-action.test.js | 12 +++---- ...actions.test.js => create-actions.test.js} | 24 ++++++------- test/create-reducer.test.js | 8 +++-- test/create-selectors.test.js | 18 ++++++++++ test/create-type.test.js | 18 ++++++++++ test/index.test.js | 5 ++- test/mount.test.js | 36 +++++++++---------- 17 files changed, 182 insertions(+), 97 deletions(-) create mode 100644 src/action-helpers/create-action.js create mode 100644 src/action-helpers/create-actions.js create mode 100644 src/action-helpers/create-type.js delete mode 100644 src/create-action.js delete mode 100644 src/globalize-actions.js rename src/{ => reducer-helpers}/create-reducer.js (59%) create mode 100644 src/selector-helpers/create-selectors.js create mode 100644 src/utils.js rename test/{globalize-actions.test.js => create-actions.test.js} (63%) create mode 100644 test/create-selectors.test.js create mode 100644 test/create-type.test.js 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..cb79dca --- /dev/null +++ b/src/action-helpers/create-type.js @@ -0,0 +1,23 @@ +import { isString, isArray } from '../utils' + +export default function createType (pathToState) { + if (!pathToState) { + throw new InvalidArgError() + } else if (isArray(pathToState)) { + if (!pathToState.every(isString)) { + throw new InvalidArgError() + } + + pathToState = pathToState.join('.') + } else if (pathToState !== null && !isString(pathToState)) { + throw new InvalidArgError() + } + + return type => `${type} (${pathToState})` +} + +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/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..d52894f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,6 @@ +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 mount } from './mount' -export { default as createReducer } from './create-reducer' -export { default as createAction } from './create-action' diff --git a/src/mount.js b/src/mount.js index 6d91c61..4a7c382 100644 --- a/src/mount.js +++ b/src/mount.js @@ -1,7 +1,7 @@ -import get from 'lodash.get' -import globalizeActions from './globalize-actions' +import createActions from './action-helpers/create-actions' +import createSelectors from './selector-helpers/create-selectors' -export default function (pathToState, logic) { +export default function (logic, pathToState) { if (!logic) { throw new Error('logic must be passed to mount') } @@ -9,7 +9,7 @@ export default function (pathToState, logic) { let { actions, reducer, selectors } = logic if (actions) { - actions = globalizeActions(pathToState, actions) + actions = createActions(actions, pathToState) } if (actions && reducer) { @@ -17,11 +17,7 @@ export default function (pathToState, logic) { } if (selectors) { - const localStateSelector = pathToState - ? state => get(state, pathToState) - : state => state - - selectors = selectors(localStateSelector) + selectors = createSelectors(selectors, pathToState) } return { 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/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..35b817e 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -4,6 +4,9 @@ import * as reduxModular from '../src/index' it('exports modularize and createReducer', () => { expect(reduxModular).toHaveProperty('mount') - expect(reduxModular).toHaveProperty('createReducer') + expect(reduxModular).toHaveProperty('createType') expect(reduxModular).toHaveProperty('createAction') + expect(reduxModular).toHaveProperty('createActions') + expect(reduxModular).toHaveProperty('createReducer') + expect(reduxModular).toHaveProperty('createSelectors') }) diff --git a/test/mount.test.js b/test/mount.test.js index 2abe514..8c58682 100644 --- a/test/mount.test.js +++ b/test/mount.test.js @@ -1,15 +1,15 @@ /* eslint-env jest */ import { combineReducers } from 'redux' -import createReducer from '../src/create-reducer' +import createReducer from '../src/reducer-helpers/create-reducer' import mount from '../src/mount' it('mounts redux path to action types', () => { - const logic = mount('path.to.module', { + const logic = mount({ actions: { increment: () => null } - }) + }, 'path.to.module') expect(logic).toHaveProperty('actions') expect(logic.actions).toHaveProperty('increment') @@ -19,7 +19,7 @@ it('mounts redux path to action types', () => { }) it('configures the reducer with the mounted actions', () => { - const logic = mount('path.to.module', { + const logic = mount({ 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 = mount({ + 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 = mount({ + selectors: { + mySelector: state => state.value + } }) expect(logic).toHaveProperty('selectors') From 5c7dfdee23edcd468791e7905e035ab8b4589125 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 12:26:43 -0400 Subject: [PATCH 3/9] update readme print statements --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 01fd679..a9cc9b3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ import { createType } from 'redux-modular' const COUNTER_TYPE = createType('counter') const INCREMENT_TYPE = COUNTER_TYPE('increment') -console.log(INCREMENT_TYPE) // prints `increment (counter)` +console.log(INCREMENT_TYPE) // increment (counter) ``` #### `createAction(String|Array pathToState, [Function payloadCreator])` @@ -50,16 +50,16 @@ defines [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) import { createAction } from 'redux-modular' const increment = createAction('increment') -console.log(increment()) // prints `{ type: 'INCREMENT' }` +console.log(increment()) // { type: 'INCREMENT' } const setValue = createAction('setValue', value => ({ value }) -console.log(setValue()) // prints `{ type: 'SET_VALUE', payload: { } }` +console.log(setValue()) // { type: 'SET_VALUE', payload: { } } ``` The action type for a given action creator can be provided by calling `toString()` on the action creator: ```js -console.log(increment.toString()) // prints `increment` +console.log(increment.toString()) // increment ``` #### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` @@ -104,8 +104,8 @@ const counterReducer = createReducer(0, { 'setValue': (state, payload) => payload.value }) -console.log(counterReducer(undefined, { type: '@@INIT' })) // prints initial state of `0` -console.log(counterReducer(0, { type: 'increment' })) // prints `1` +console.log(counterReducer(undefined, { type: '@@INIT' })) // 0 (initial state) +console.log(counterReducer(0, { type: 'increment' })) // 1 ``` This is very useful if used conjunction with actions created using our actions created using `createAction` or `createActions`: @@ -117,8 +117,8 @@ const counterReducer = createReducer(0, { [counterActions.setValue]: (state, payload) => payload.value }) -console.log(counterReducer(0, counterActions.increment())) // prints `1` -console.log(counterReducer(0, counterActions.setValue(5))) // prints `5` +console.log(counterReducer(0, counterActions.increment())) // 1 +console.log(counterReducer(0, counterActions.setValue(5))) // 5 ``` ### Defining selectors @@ -138,7 +138,7 @@ const counterSelectors = createSelectors({ value: state => state.value }, 'counter1') -console.log(counterSelectors.value({ counter1: { value: 5 } })) // prints `5` +console.log(counterSelectors.value({ counter1: { value: 5 } })) // 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: @@ -148,7 +148,7 @@ const counterSelectors = createSelectors({ value: state => state.value }, 'nested.counter3') -console.log(counterSelectors.value({ nested: { counter3: { value: 5 } } })) // prints `5` +console.log(counterSelectors.value({ nested: { counter3: { value: 5 } } })) // 5 ``` This can also be used with the [`reselect`](https://github.com/reduxjs/reselect) library value to create memoized, computed selector functions: @@ -166,7 +166,7 @@ const counterSelectors = createSelectors({ ) }, 'counter1') -console.log(counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } })) // prints `0.05` +console.log(counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } })) // 0.05 ``` ### Defining reusable redux logic @@ -231,16 +231,16 @@ const store = createStore(rootReducer) const { actions, selectors } = counter1 -console.log(selectors.counterValue(store.getState())) // prints `0` +console.log(selectors.counterValue(store.getState())) // 0 store.dispatch(actions.increment()) -console.log(selectors.counterValue(store.getState())) // prints `1` +console.log(selectors.counterValue(store.getState())) // 1 store.dispatch(actions.decrement()) -console.log(selectors.counterValue(store.getState())) // prints `0` +console.log(selectors.counterValue(store.getState())) // 0 store.dispatch(actions.setValue(5)) -console.log(selectors.counterValue(store.getState())) // prints `5` +console.log(selectors.counterValue(store.getState())) // 5 ``` ### Writing Tests From 246638d416c46f020fce0fde29fb49c8a19a65b8 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 12:27:56 -0400 Subject: [PATCH 4/9] simplify createActions docs --- README.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/README.md b/README.md index a9cc9b3..2c899c3 100644 --- a/README.md +++ b/README.md @@ -64,20 +64,7 @@ console.log(increment.toString()) // increment #### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` -Combined with `createAction`, you can quickly generate an object of action creators: - -```js -import { createType, createAction } from 'redux-modular' - -const COUNTER_TYPE = createType('counter') -const counterActions = { - increment: createAction(COUNTER_TYPE('increment')), - decrement: createAction(COUNTER_TYPE('decrement')), - setValue: createAction(COUNTER_TYPE('setValue'), value => ({ value })) -} -``` - -`createActions` can be used to simplify the above: +`createActions` combines `createAction` and `createType`, allowing you to quickly generate an object of action creators: ```js import { createActions } from 'redux-modular' From 09d42c6b774c7386ec39a32ff9a95391105f7b34 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 12:38:11 -0400 Subject: [PATCH 5/9] update mount docs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c899c3..8388493 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,9 @@ const counter = { }), // function mapping local state selector to your selectors - selectors: localStateSelector => ({ - counterValue: state => localStateSelector(state) - }) + selectors: { + counterValue: state => state + } } /* Instantiate the counter logic by mounting to redux paths */ From 707416a69279f2ca8eaf3ef3bfedd698318788b1 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 16:04:04 -0400 Subject: [PATCH 6/9] more README fixes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8388493..49967ba 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ defines [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) import { createAction } from 'redux-modular' const increment = createAction('increment') -console.log(increment()) // { type: 'INCREMENT' } +console.log(increment()) // { type: 'increment' } const setValue = createAction('setValue', value => ({ value }) -console.log(setValue()) // { type: 'SET_VALUE', payload: { } } +console.log(setValue()) // { type: 'setValue', payload: { } } ``` The action type for a given action creator can be provided by calling `toString()` on the action creator: From fab1e371059fce117ece0dd5055c70c2a5150d93 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 16:04:43 -0400 Subject: [PATCH 7/9] fix pathToState arg signature --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 49967ba..626e0f1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ This guide illustrates how to use each helper function using a `counter` redux s ### Defining actions -#### `createType(String|Array pathToState)` +#### `createType(String|Array pathToState)` `createType` allows you to create a **type creator**, allowing you to easily create types under a unique namespace: @@ -42,7 +42,7 @@ const INCREMENT_TYPE = COUNTER_TYPE('increment') console.log(INCREMENT_TYPE) // increment (counter) ``` -#### `createAction(String|Array pathToState, [Function payloadCreator])` +#### `createAction(String|Array pathToState, [Function payloadCreator])` defines [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creators using `createAction`: @@ -62,7 +62,7 @@ The action type for a given action creator can be provided by calling `toString( console.log(increment.toString()) // increment ``` -#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` +#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` `createActions` combines `createAction` and `createType`, allowing you to quickly generate an object of action creators: @@ -112,7 +112,7 @@ console.log(counterReducer(0, counterActions.setValue(5))) // 5 Rather than having to select data directly from the redux state tree, you can define "selector" functions. These can 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])` +`createSelectors(Object selectorFunctions, [String|Array pathToState])` This function can be used to create an object of selector functions. Given a path to the state, which will be run through [`lodash.get`](https://lodash.com/docs/4.17.10#get), and an object of selector functions, it will return a new object of selector functions. The returned selector functions will first run `lodash.get(state, 'some.path')`, and then pass this value to your provided selector function. This can be useful for easily defining selectors. From c232bb15f7344bcab0c09baf71bae5e66542713f Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 20:31:07 -0400 Subject: [PATCH 8/9] second pass at README --- README.md | 140 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 85 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 626e0f1..8b7e647 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ $ yarn add redux-modular ## Usage Guide -This guide illustrates how to use each helper function using a `counter` redux store. You can dispatch `increment`, `decrement`, and `setValue` actions to a reducer that creates the store state. - * [Defining actions](#defining-actions) * [Defining reducers](#defining-reducers) * [Defining selectors](#defining-selectors) @@ -31,71 +29,86 @@ This guide illustrates how to use each helper function using a `counter` redux s #### `createType(String|Array pathToState)` -`createType` allows you to create a **type creator**, allowing you to easily create types under a unique namespace: +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 INCREMENT_TYPE = COUNTER_TYPE('increment') -console.log(INCREMENT_TYPE) // increment (counter) +const COUNTER_TYPE = createType(['path', 'to', 'counter']) +COUNTER_TYPE('increment') // 'increment (path.to.counter)' ``` -#### `createAction(String|Array pathToState, [Function payloadCreator])` +#### `createAction(String|Array type, [Function payloadCreator])` -defines [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creators using `createAction`: +Creates an [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creator: ```js import { createAction } from 'redux-modular' const increment = createAction('increment') -console.log(increment()) // { type: 'increment' } +increment() // { type: 'increment' } const setValue = createAction('setValue', value => ({ value }) -console.log(setValue()) // { type: 'setValue', payload: { } } +setValue(value) // { type: 'setValue', payload: { value } } ``` -The action type for a given action creator can be provided by calling `toString()` on the action creator: +`actionCreator.toString()` returns the action type: ```js -console.log(increment.toString()) // increment +increment.toString() // 'increment' ``` -#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` +#### `createActions(Object actionsToPayloadCreators, [String|Array pathToState])` -`createActions` combines `createAction` and `createType`, allowing you to quickly generate an object of action creators: +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 actionTypesToReducerHandlers)` +#### `createReducer(Any initialState, Object actionTypesToReducers)` -This function will return a reducer that maps action types to handlers, so that whenever this reducer is called with an action of a given type, the corresponding sub-reducer will be called: +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 + increment: state => state + 1, + decrement: state => state - 1, + setValue: (state, payload) => payload.value }) -console.log(counterReducer(undefined, { type: '@@INIT' })) // 0 (initial state) -console.log(counterReducer(0, { type: 'increment' })) // 1 +counterReducer(undefined, { type: '@@INIT' }) // 0 (initial state) +counterReducer(0, { type: 'increment' }) // 1 ``` -This is very useful if used conjunction with actions created using our actions created using `createAction` or `createActions`: +This is very useful in conjunction with actions created using `createAction` or `createActions`: ```js const counterReducer = createReducer(0, { @@ -104,41 +117,62 @@ const counterReducer = createReducer(0, { [counterActions.setValue]: (state, payload) => payload.value }) -console.log(counterReducer(0, counterActions.increment())) // 1 -console.log(counterReducer(0, counterActions.setValue(5))) // 5 +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 can help to increase code maintainability by reducing access to redux state to these functions, serving as a public API to the state tree. +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])` +#### `createSelectors(Object selectorFunctions, [String|Array pathToState])` -This function can be used to create an object of selector functions. Given a path to the state, which will be run through [`lodash.get`](https://lodash.com/docs/4.17.10#get), and an object of selector functions, it will return a new object of selector functions. The returned selector functions will first run `lodash.get(state, 'some.path')`, and then pass this value to your provided selector function. This can be useful for easily defining selectors. +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.counter1`, we can define a "value" selector as shown: +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 -}, 'counter1') +}, 'myCounter') -console.log(counterSelectors.value({ counter1: { value: 5 } })) // 5 +// 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.counter3') +}, 'nested.myCounter') -console.log(counterSelectors.value({ nested: { counter3: { value: 5 } } })) // 5 +const state = rootReducer(undefined, counterActions.setValue(5)) + +counterSelectors.value(state) // 5 ``` -This can also be used with the [`reselect`](https://github.com/reduxjs/reselect) library value to create memoized, computed selector functions: +The [`reselect`](https://github.com/reduxjs/reselect) library can be helpful to create memoized, computed selector functions: ```js import { createSelectors } from 'redux-modular' @@ -153,14 +187,16 @@ const counterSelectors = createSelectors({ ) }, 'counter1') -console.log(counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } })) // 0.05 +counterSelectors.asPercentageOfOneHundred({ counter1: { value: 5 } } }) // 0.05 ``` ### Defining reusable redux logic -You can define related actions, selectors and reducer logic in an object. The `mount` function can be used to convert these into corresponding actions, selectors and reducer into usable versions. This is useful if you want to define logic once, and use it multiple places. It is also usable for reducing the boilerplate of defining a related group of redux elements. +#### 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, `mount` takes a redux state path, and an object of the following: +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` @@ -171,7 +207,7 @@ As parameters, `mount` takes a redux state path, and an object of the following: ```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) */ @@ -192,15 +228,15 @@ const counter = { // function mapping local state selector to your selectors selectors: { - counterValue: state => state + 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 */ @@ -218,42 +254,36 @@ const store = createStore(rootReducer) const { actions, selectors } = counter1 -console.log(selectors.counterValue(store.getState())) // 0 +selectors.value(store.getState()) // 0 store.dispatch(actions.increment()) -console.log(selectors.counterValue(store.getState())) // 1 +selectors.value(store.getState()) // 1 store.dispatch(actions.decrement()) -console.log(selectors.counterValue(store.getState())) // 0 +selectors.value(store.getState()) // 0 store.dispatch(actions.setValue(5)) -console.log(selectors.counterValue(store.getState())) // 5 +selectors.value(store.getState()) // 5 ``` ### 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. Using these `actions`, `selectors` and `reducer`, you can test the logic by running actions through the reducer, and making assertions about the return value of selectors. +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 { - counterActions, - counterReducer, - counterSelectors -} = require('./counter-logic') - 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.setValue(5)) - expect(selectors.counterValue(state)).toEqual(5) + expect(selectors.value(state)).toEqual(5) }) ``` From 91808c0154fcebbd9f3ad25bc49104889480fdd3 Mon Sep 17 00:00:00 2001 From: Thomas Dashney Date: Mon, 25 Jun 2018 20:39:39 -0400 Subject: [PATCH 9/9] rename mount to createLogic --- src/action-helpers/create-type.js | 21 ++++++++++++-------- src/{mount.js => create-logic.js} | 2 +- src/index.js | 2 +- test/{mount.test.js => create-logic.test.js} | 16 +++++++-------- test/index.test.js | 2 +- 5 files changed, 24 insertions(+), 19 deletions(-) rename src/{mount.js => create-logic.js} (89%) rename test/{mount.test.js => create-logic.test.js} (84%) diff --git a/src/action-helpers/create-type.js b/src/action-helpers/create-type.js index cb79dca..d9e96cf 100644 --- a/src/action-helpers/create-type.js +++ b/src/action-helpers/create-type.js @@ -1,21 +1,26 @@ import { isString, isArray } from '../utils' export default function createType (pathToState) { - if (!pathToState) { - throw new InvalidArgError() - } else if (isArray(pathToState)) { - if (!pathToState.every(isString)) { - throw new InvalidArgError() - } + validateArgument(pathToState) + if (isArray(pathToState)) { pathToState = pathToState.join('.') - } else if (pathToState !== null && !isString(pathToState)) { - throw new InvalidArgError() } 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/mount.js b/src/create-logic.js similarity index 89% rename from src/mount.js rename to src/create-logic.js index 4a7c382..804b30f 100644 --- a/src/mount.js +++ b/src/create-logic.js @@ -3,7 +3,7 @@ import createSelectors from './selector-helpers/create-selectors' export default function (logic, pathToState) { if (!logic) { - throw new Error('logic must be passed to mount') + throw new Error('logic must be passed to create-logic') } let { actions, reducer, selectors } = logic diff --git a/src/index.js b/src/index.js index d52894f..8319bd3 100644 --- a/src/index.js +++ b/src/index.js @@ -3,4 +3,4 @@ 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 mount } from './mount' +export { default as createLogic } from './create-logic' diff --git a/test/mount.test.js b/test/create-logic.test.js similarity index 84% rename from test/mount.test.js rename to test/create-logic.test.js index 8c58682..b28f37f 100644 --- a/test/mount.test.js +++ b/test/create-logic.test.js @@ -2,10 +2,10 @@ import { combineReducers } from 'redux' import createReducer from '../src/reducer-helpers/create-reducer' -import mount from '../src/mount' +import createLogic from '../src/create-logic' -it('mounts redux path to action types', () => { - const logic = mount({ +it('createLogics redux path to action types', () => { + const logic = createLogic({ actions: { increment: () => null } @@ -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({ +it('configures the reducer with the createLogiced actions', () => { + const logic = createLogic({ actions: { increment: () => null }, @@ -42,7 +42,7 @@ 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({ + const logic = createLogic({ selectors: { mySelector: state => state.key } @@ -60,7 +60,7 @@ it('creates selectors using the correct state selector', () => { }) it('can create selectors with no pathToState', () => { - const logic = mount({ + const logic = createLogic({ selectors: { mySelector: state => state.value } @@ -75,5 +75,5 @@ it('can create selectors with no pathToState', () => { }) it('throws an error if no params are passed', () => { - expect(mount).toThrow(Error) + expect(createLogic).toThrow(Error) }) diff --git a/test/index.test.js b/test/index.test.js index 35b817e..ce7377d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,7 +3,7 @@ import * as reduxModular from '../src/index' it('exports modularize and createReducer', () => { - expect(reduxModular).toHaveProperty('mount') + expect(reduxModular).toHaveProperty('createLogic') expect(reduxModular).toHaveProperty('createType') expect(reduxModular).toHaveProperty('createAction') expect(reduxModular).toHaveProperty('createActions')