/* eslint-disable max-len */
import {
allPass,
always,
and,
compose,
cond,
converge,
curry,
defaultTo,
equals,
flip,
gte,
has,
identity,
ifElse,
is,
isArrayLike,
isEmpty,
isNil,
lensPath,
lt,
merge,
mergeAll,
nthArg,
objOf,
of,
or,
path,
prop,
propEq,
propOr,
set,
T,
type,
view,
} from 'ramda';
export { actionTestSuite } from './actionTest';
const isNilOrEmpty = or(isNil, isEmpty);
const orEmptyObject = defaultTo({});
const emptyObject = always({});
const getPropOrEmptyObjectFunction = propOr(emptyObject);
const getPropOrEmptyString = propOr('');
const secondArgument = nthArg(1);
/**
* Type checker with array support
* Use like `typeIs('Object', valToCheck)`
*
* @ignore
* @param {String} typeName name of type to test for
* @param {String} typeName name of type to test for
* @return {Boolean} Expects a final value to test for type match
*/
const typeIs = typeName => compose(equals(typeName), type, nthArg(0));
/**
* Merges state with reducer result in case of an object type
* otherwise just returns the reducer result.
*
* If a different strategy is required for a type such as `Array`, an
* additional pair may be added with like [typeIs('Array'), handler]
* the handler will be passed the (state, reducerResult) signature
* upon its predicate evaluating to true
*
* @ignore
* @function
* @param {*} state initial state value of any type
* @param {*} handlerResult result of an action handler
* @return {*} merged object Or handler result
*/
const applyHandlerByType = cond([
[typeIs('Object'), merge],
[T, secondArgument],
]);
/** @module reducers */
/**
* Given a list of one or more action map objects, return a reducer function
* to satisfy the reducer signature expected by redux core
*
* @see [tests]{@link module:test~createReducer}
* @param {Object} defaultState will be passed to the resulting reducer
* function the first time it is run
* @param {...Object} actionMap objects in which each key is an action
* types, and its value is an action handler
* functions that takes (state, action) as
* ordered arguments
* @return {Function} A reducer function that handles each action
* type specified as a key in its action map
*
* @example
* const defaultState = {
* people: 1,
* beasts: 1,
* }
*
* const VANQUISH_BEAST = '@@/actionTypes/vanquishBeast';
* function vanquishBeastHandler(state, action) {
* return {
* ...state,
* beasts: state.beasts - 1,
* weaponUsed: action.payload.weapon,
* }
* }
*
* const SUCCUMB_TO_BEAST = '@@/actionTypes/succumbToBeast';
* function succumbToBeastHandler(state, action) {
* return {
* ...state,
* people: state.people - 1,
* lastWords: action.payload.lastWords,
* }
* }
*
* const reducer = createReducer(defaultState, {
* [VANQUISH_BEAST]: vanquishBeastHandler,
* [SUCCUMB_TO_BEAST]: succumbToBeastHandler,
* })
*
* const vanquishBeast = createAction(VANQUISH_BEAST);
* reducer({}, vanquishBeast({ weapon: 'broom' }))
* //=> { people: 1, beasts: 0, weapon: 'broom' }
*
* const succumbToBeast = createAction(SUCCUMB_TO_BEAST);
* reducer({}, succumbToBeast({ lastWords: 'tell my mom...' }))
* //=> { people: 0, beasts: 1, lastWords: 'tell my mom...' }
*/
export function createReducer(defaultState, ...actionMaps) {
const actionMap = mergeAll(actionMaps);
return (state = defaultState, action) => {
const actionType = getPropOrEmptyString('type', action);
const actionTypeHandler = getPropOrEmptyObjectFunction(actionType, actionMap);
return applyHandlerByType(state, actionTypeHandler(state, action));
};
}
/**
* Creates a single reducer from an n length list of reducers
*
* @see [tests]{@link module:test~reduceReducers}
* @param {...Function} reducers any number of reducer functions that take
* (state, action) as ordered arguments
* @return {Function} A reducer function contstructed by merging
* all given reducer functions
*
* @example
* function reducerA(state, action) {
* return {
* ..state,
* a: action.payload,
* }
* }
*
* function reducerB(state, action) {
* return {
* ..state,
* b: action.payload,
* }
* }
*
* const combined = reduceReducers(reducerA, reducerB)
* const defaultState = { sandwich: 'grilled cheese' }
* const action = { payload: 'apply me' }
*
* combined(defaultState, action)
* //=> { sandwich: 'grilled cheese', a: 'apply me', b: 'apply me' }
*/
export function reduceReducers(...reducers) {
return (previous, current) =>
reducers.reduce((p, r) => r(p, current), previous);
}
/** @module actions */
/**
* Takes a type, optional message, optional payload value, and an optional meta value
* and returns a standard redux action object descriptive of a redux action
*
* @function
* @param {String} actionType type string for action
* @param {*} [payload] data relevant to error
* @param {*} [meta] data to describe the payload
* @returns {Object} standard action object
*/
export const returnActionResult = (actionType, payload = {}, meta = {}) => ({
type: actionType,
payload: orEmptyObject(payload),
meta: orEmptyObject(meta),
});
/**
* Given the specified type, return a function that creates an object with a
* specified type, and assign its arguments to a payload object
*
* @function
* @see [tests]{@link module:test~createAction}
* @param {String} type redux action type name
* @return {actionCreator} [Action creator]{@link module:actions~actionCreator}
* function that applys a payload and returns an object
* of the given action type with the given payload
*
* @example
* const BEGIN_GOOD_TIMES = '@@/actionTypes/gootTimes'
* const beginGoodTimes = createAction(BEGIN_GOOD_TIMES);
*/
export const createAction = actionType =>
(payload, meta) => returnActionResult(actionType, payload, meta);
export const createThunk = actionType =>
(payload, meta) => dispatch =>
Promise.resolve(dispatch(
returnActionResult(actionType, payload, meta)
));
/**
* Given any string key name, returns a function that takes a state and action and
* returns a copy of the action's payload property renamed as the
* specified key
*
* @function
* @see [tests]{@link module:test~createHandler}
* @param {String} key name of payloads state destination key
* @return {handlerFunction} A function that ignores current state and
* returns a copy of the action's payload
* property renamed as the specified key
*
* @example
* const testHandler = createHandler('bananas');
* const state = {};
* const action = { payload: { list: [1, 2, 3, 4] } }
*
* testHandler(state, action)
* //=> { bananas: [1, 2, 3, 4] }
*/
export const createHandler = key =>
(state, { payload }) => ({ [key]: payload });
/**
* Often a handler will have no need for the `state` value that is passed by
* convention as the first arg to a handler function, or any key on the
* action object other than `payload`. This function simply returns
* the `payload` key of the second arg that is passed to it.
*
* @function
* @param {Object} state current state of app (always ignored)
* @param {Object} action the action being handled
* @return {*} the `payload` key of `action`
*
* @example
* const uppercasePayload = compose(toUpper, getPayload)
*
* const action = { payload: 'i drink coffee please', type: 'COFFEE_ACTION' }
* const state = { a: 1, b: 2, c: 3 }
* uppercasePayload(state, action) //=> 'I DRINK COFFEE PLEASE'
*/
export const getPayload = compose(prop('payload'), nthArg(1));
/**
* Function with a standard reducer signature of (state, action) and returns
* a renamed copy of the action's payload property
*
* @name handlerFunction
* @function
* @param {Object} state current state
* @param {Object} action current action
* @returns {Object} renamed action.payload
*/
/**
* Takes an optional payload and meta object and returns an object
* that describes a redux action to be dispatched
*
* A empty object default functino is used for both meta and
* payload to handle cases where a null is passed as either
* rather than an undefined
*
* @name actionCreator
* @function
* @param {Object} [payload] payload data this action
* @param {Object} [meta] meta data for this action
* @returns {Object} includes a type string, and optional payload and meta objects
*
* @example
*
* const payload = { soundTrack: 'Jurrasic Park' }
* beginGoodTimes(payload)
* //=> {
* // type: '@@/actionTypes/gootTimes',
* // payload: { soundTrack: 'Jurrasic Park' },
* // meta: {},
* //}
*
* const meta = { initiatedBy: 'Dr. Malcom' }
* beginGoodTimes(payload, meta)
* //=> {
* // type: '@@/actionTypes/gootTimes',
* // meta: { initiatedBy: 'Dr. Malcom' },
* // payload: { soundTrack: 'Jurrasic Park' },
* //}
*/
/**
* Takes a type, optional message, optional payload value, and an optional meta value
* and returns a standard redux action object descriptive of a problem in execution
*
* @function
* @param {String} actionType type string for action
* @param {String} [message] description of the error
* @param {*} [payload] data relevant to error
* @param {*} [meta] data to describe the payload
* @returns {Object} standard action object
*/
export const returnErrorResult =
(actionType, message = 'An error occurred', payload = {}, meta = {}) => ({
type: actionType,
error: true,
message,
payload: orEmptyObject(payload),
meta: orEmptyObject(meta),
});
/**
* Given the specified type, and an optional custom error message, return a function
* that creates an object with a specified type, adds an error: true key to the
* top level, and assigns its arguments to a payload object
*
* @function
* @see [tests]{@link module:test~createErrorAction}
* @param {String} type redux action type name
* @param {String} [message] a messge that describes the error, if none is given a
* generic message will be used
* @return {errorActionCreator} [Action creator]{@link module:actions~actionCreator}
* function that applys a payload and returns an object
* of the given action type with the given payload
*
* @example
* const BEGIN_GOOD_TIMES = '@@/actionTypes/gootTimes'
* const beginGoodTimes = createAction(BEGIN_GOOD_TIMES);
*/
export const createErrorAction = (actionType, message) =>
(payload, meta) => returnErrorResult(actionType, message, payload, meta);
export const createErrorThunk = (actionType, message) =>
(payload, meta) => dispatch =>
Promise.reject(dispatch(
returnErrorResult(actionType, message, payload, meta)
));
/**
* Takes an optional payload and returns an object
* that describes an error redux action to be dispatched
*
* @name errorActionCreator
* @function
* @param {Object} [payload] payload data this action
* @returns {Object} includes a type string, and optional payload and meta objects
*
* @example
*
* const payload = { soundTrack: 'Jurrasic Park' }
* thisDidNotGoWell(payload)
* //=> {
* // type: '@@/actionTypes/thisDidNotGoWell',
* // message: 'well something bad happened',
* // payload: { soundTrack: 'Jurrasic Park' },
* // error: true,
* //}
*/
/**
* Given an action and a type string, returns a boolean value to indicate that
* the action has a type property equal to to the type string
*
* @function
* @see [tests]{@link module:test~actionTypeIs}
* @param {Object} action standard action object
* @param {String} type any string
* @return {Boolean} true if action.type === type
*
* @example
* const type = 'test'
* const action = { type }
*
* actionTypeIs(action, 'test')
* //=> true
*
* actionTypeIs(action, 'blah')
* //=> false
*
* const thunk = () => {}
* actionTypeIs(thunk, 'test')
* //=> false
*
* actionTypeIs({}, 'test')
* //=> false
*/
export const actionTypeIs = curry(
(action, actionType) => compose(equals(actionType), path(['type']))(action)
);
/** @module lenses */
/**
* Wraps ramda's [lensProp]{@link http://ramdajs.com/0.21.0/docs/#lensProp} and
* [lensPath]{@link http://ramdajs.com/0.21.0/docs/#lensPath} to return a lens
* that focuses on a top level property if passed a string, and a deep property
* if passed an array of strings for propPath
*
* @function
* @see [tests]{@link module:test~getLens}
* @param {(String|String[])} path an array for deep prop, or string for top level
* @return {Function}
*
* @example
* import { view, set } from 'ramda'
*
* const aLens = getLens('a')
* const simpleObj = { a: 'rumble in the bronx', b: 'I like turtles' }
* view(aLens, simpleObj) //=> 'rumble in the bronx'
*
* const cLens = getLens(['a', 'b', 'c'])
* const nestedObj = { a: { b: { c: 'I am bored' } } }
* set(cLens, 'it is party time', nestedObj)
* //=> { a: { b: { c: 'it is party time' } } }
*/
export const getLens = ifElse(isArrayLike, lensPath, compose(lensPath, of));
/**
* Create a [memoized]{@link https://en.wikipedia.org/wiki/Memoization} function
* that finds and returns specified property of an object. Uses ramda
* [lensPath]{@link http://ramdajs.com/0.21.0/docs/#lensPath},
* and [view]{@link http://ramdajs.com/0.21.0/docs/#view} internally
*
* Pass a single string propName for top level key,
* or an array of propNames for deep nested keys
*
* @function
* @see [tests]{@link module:test~createSelector}
* @param {(String|String[])} path an array for deep prop, or string for top level
* @return {Function} function that returns the value of a property
* at the specified path
*
* @example
* const getA = createSelector('a')
* const simpleObj = { a: 'rumble in the bronx', b: 'I like turtles' }
* getA(simpleObj) //=> 'rumble in the bronx'
*
* const getC = createSelector(['a', 'b', 'c'])
* const nestedObj = { a: { b: { c: 'rumble in the bronx' } } }
* getC(nestedObj) //=> 'rumble in the bronx'
*/
export const createSelector = compose(view, getLens);
/**
* Create a [memoized]{@link https://en.wikipedia.org/wiki/Memoization}
* function that returns a shallow copy of a given object, plus an
* altered value according the property it is focused to. Uses ramda
* [lensPath]{@link http://ramdajs.com/0.21.0/docs/#lensPath},
* and [set]{@link http://ramdajs.com/0.21.0/docs/#set} internally
*
* Pass a single string propName for top level key,
* or an array of propNames for deep nested keys
*
* @function
* @see [tests]{@link module:test~createSetter}
* @param {(String|String[])} path an array of strings for a deep property, or
* string for top level property
* @return {Function} function that returns a clone of an object with a new
* value set to the property at the specified path
* @example
* const newVal = 'it is party time'
*
* const setA = createSetter('a')
* const obj = { a: 'I am bored', b: 'I like turtles' }
* setA(newVal, obj) //=> { a: 'it is party time', b: 'I like turtles' }
*
* const setC = createSetter(['a', 'b', 'c'])
* const obj = { a: { b: { c: 'I am bored' } } }
* setC(newVal, obj) //=> { a: { b: { c: 'it is party time' } } }
*/
export const createSetter = compose(set, getLens);
/** @module fetch */
// Status Code evaluation support functions
export const statusIs = propEq('status');
export const statusCodeSatisfies = predicate => compose(predicate, prop('status'));
export const statusCodeComparator = comp => compose(statusCodeSatisfies, flip(comp));
export const statusCodeGTE = statusCodeComparator(gte);
export const statusCodeLT = statusCodeComparator(lt);
export const statusWithinRange = curry((lowestCode, hightestCode) =>
and(statusCodeGTE(lowestCode), statusCodeLT(hightestCode))
);
// Response handling support functions
export const parse = x => JSON.parse(isEmpty(x) ? '{}' : x);
export const parseIfString = ifElse(typeIs('String'), parse, identity);
export const encodeResponse = compose(parseIfString, prop('value'));
export const getHeaders = data => data.headers.get('location');
export const getRedirect = compose(objOf('redirect_to'), getHeaders);
export const statusFilter = cond([
[isNilOrEmpty, emptyObject],
[statusIs(401), getRedirect],
[statusWithinRange(200, 300), encodeResponse],
]);
export const actionCreatorOrNew = ifElse(is(Function), identity, createAction);
const isResponseObj = allPass([typeIs('Object'), has('data')]);
const safeData = ifElse(isResponseObj, path(['data']), identity);
const safeMeta = propOr({}, 'meta');
/**
* Returns a function that passes the value.data property expected fetch result
* to the given callback function
*
* @function
* @see [tests]{@link module:test~fetchCallback}
* @param {Function} func the callback to pass data to
* @return {Function} a function handles status codes appropriately and
* delivers the value.data property to its callback
*
* @example
* import { sum } from 'ramda'
*
* const apiResponse = {
* status: 200,
* value: {
* data: {
* numbers: [1, 2, 3]
* },
* meta: {
* numbers: [1, 2, 3]
* },
* },
* }
*
* const addNumbers = data => sum(data.numbers)
* const addNumbersCallback = fetchCallback(addNumbers)
*
* addNumbersCallback(apiResponse)
* //=> 6
*
* const addNumbersWithMeta = (data, meta) => sum([...data.numbers, ...meta.numbers])
* const addNumbersWithMetaCallback = fetchCallback(addNumbersWithMeta)
*
* addNumbersWithMetaCallback(apiResponse)
* //=> 12
*/
export const fetchCallback = func => compose(
converge(actionCreatorOrNew(func), [safeData, safeMeta]),
statusFilter,
);
/**
* A standard fetch request params object
*
* @typedef {Object} ParamsObject
* @property {Object} params.body data used in post or put request
* @property {String} params.method rest verb 'GET', 'POST' etc...
* @property {Object} params.headers headers object
*/
/**
* Redux action object compatible with [redux-effects-fetch]{@link https://goo.gl/bG7PO0}
* with a type of 'EFFECT_FETCH', and a payload that describes a
* [fetch]{@link https://goo.gl/DeFc1M} call.
*
* @typedef {Object} FetchAction
* @property {String} url the request url for fetch call
* @property {ParamsObject} params the request params for fetch call
*/
export default {
returnActionResult,
createAction,
createThunk,
returnErrorResult,
createErrorAction,
createErrorThunk,
getLens,
createSelector,
createSetter,
statusIs,
statusCodeSatisfies,
statusCodeComparator,
statusCodeGTE,
statusCodeLT,
statusWithinRange,
parse,
parseIfString,
encodeResponse,
getHeaders,
getRedirect,
statusFilter,
fetchCallback,
};