Skip to content

Instantly share code, notes, and snippets.

@mehiel
Created February 10, 2017 09:50
Show Gist options
  • Save mehiel/166a4a6dd27c7767147b17532836b2cf to your computer and use it in GitHub Desktop.
Save mehiel/166a4a6dd27c7767147b17532836b2cf to your computer and use it in GitHub Desktop.
CRUD helpers for redux
import createAction from 'redux-actions/lib/createAction'
import { create, read, update, destroy, params } from 'utils/xhr'
import always from 'ramda/src/always'
import assoc from 'ramda/src/assoc'
import compose from 'ramda/src/compose'
import concat from 'ramda/src/concat'
import dissoc from 'ramda/src/dissoc'
import flip from 'ramda/src/flip'
import identity from 'ramda/src/identity'
import ifElse from 'ramda/src/ifElse'
import isArrayLike from 'ramda/src/isArrayLike'
import lensPath from 'ramda/src/lensPath'
import lensProp from 'ramda/src/lensProp'
import merge from 'ramda/src/merge'
import not from 'ramda/src/not'
import of from 'ramda/src/of'
import over from 'ramda/src/over'
import pathOr from 'ramda/src/pathOr'
import propOr from 'ramda/src/propOr'
import prop from 'ramda/src/prop'
import uniq from 'ramda/src/uniq'
import view from 'ramda/src/view'
import without from 'ramda/src/without'
import _debug from 'debug'
const debug = _debug('lib:redux-utils:crud') // eslint-disable-line no-unused-vars
const getTokenFromState = pathOr(null, ['auth', 'token'])
const NEW_MODEL_STATE_KEY = 'newmodel'
const INITIAL_STATE = {
pagination: { total: 0, offset: 0, limit: 5 },
filter: { visible: false },
loading: false,
errors: null,
data: [],
selected: [], // selected key indicates which entries are selected in a table with multi selection enabled
// it can be an array of ids or a string 'all' to indicate that all items in all pages are selected
singles: {}, // a Map with model id as key and objects shaped like EMPTY_SINGLE_MODEL below as values
}
const EMPTY_SINGLE_MODEL = () => ({
loading: false,
saving: false,
status: 'OPEN', // examples: 'OPEN', 'CREATED', 'UPDATED', 'DELETED'
errors: null,
data: {},
})
export const constants = key => {
return {
// action types to read many models
READ_ALL_PENDING: `${key}/READ_ALL_PENDING`,
READ_ALL_FAILURE: `${key}/READ_ALL_FAILURE`,
READ_ALL_SUCCESS: `${key}/READ_ALL_SUCCESS`,
// action types to clear loaded models
CLEAR_ALL: `${key}/CLEAR_ALL`,
// action types to read one model
READ_PENDING: `${key}/READ_PENDING`,
READ_FAILURE: `${key}/READ_FAILURE`,
READ_SUCCESS: `${key}/READ_SUCCESS`,
// action types to create a model
CREATE_PENDING: `${key}/CREATE_PENDING`,
CREATE_FAILURE: `${key}/CREATE_FAILURE`,
CREATE_SUCCESS: `${key}/CREATE_SUCCESS`,
// action types to update a model
UPDATE_PENDING: `${key}/UPDATE_PENDING`,
UPDATE_FAILURE: `${key}/UPDATE_FAILURE`,
UPDATE_SUCCESS: `${key}/UPDATE_SUCCESS`,
// action types to delete a model
DELETE_PENDING: `${key}/DELETE_PENDING`,
DELETE_FAILURE: `${key}/DELETE_FAILURE`,
DELETE_SUCCESS: `${key}/DELETE_SUCCESS`,
// actions to activate/deactivate entry
// they apply to all user and structure models so they
// are common enough to have them here
ACTIVATION_PENDING: `${key}/ACTIVATION_PENDING`,
ACTIVATION_FAILURE: `${key}/ACTIVATION_FAILURE`,
ACTIVATION_SUCCESS: `${key}/ACTIVATION_SUCCESS`,
// actions for table's selection model
// every model that can be displayed in a list with multi-selection enabled
// should use these actions
SELECTION_ADD: `${key}/SELECTION_ADD`,
SELECTION_REMOVE: `${key}/SELECTION_REMOVE`,
SELECTION_TOGGLE: `${key}/SELECTION_TOGGLE`,
// actions for miscelaneous tasks like setting or cleaning a single model
// to edit as well as reset its edit status
SET: `${key}/SET`,
CLEAN: `${key}/CLEAN`,
RESET_STATUS: `${key}/RESET_STATUS`,
}
}
export const actions = (endpoint, constants) => {
const readAllPending = createAction(constants.READ_ALL_PENDING)
const readAllFailure = createAction(constants.READ_ALL_FAILURE)
const readAllSuccess = createAction(constants.READ_ALL_SUCCESS)
const readAll = (offset, limit, filters) => {
return (dispatch, getState) => {
dispatch(readAllPending())
const token = getTokenFromState(getState())
const urlParams = { offset, limit, ...filters }
return read(`${endpoint}?${params(urlParams)}`, token)
.then(response => dispatch(readAllSuccess(response)))
.catch(error => dispatch(readAllFailure(error)))
}
}
const readOnePending = createAction(constants.READ_PENDING, modelId => ({}), modelId => ({ modelId }))
const readOneFailure = createAction(constants.READ_FAILURE, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const readOneSuccess = createAction(constants.READ_SUCCESS, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const readOne = (modelId) => {
return (dispatch, getState) => {
dispatch(readOnePending(modelId))
const token = getTokenFromState(getState())
return read(`${endpoint}/${modelId}`, token)
.then(response => dispatch(readOneSuccess(modelId, response)))
.catch(error => dispatch(readOneFailure(modelId, error)))
}
}
const createOnePending = createAction(constants.CREATE_PENDING, modelId => ({}), modelId => ({ modelId }))
const createOneFailure = createAction(constants.CREATE_FAILURE, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const createOneSuccess = createAction(constants.CREATE_SUCCESS, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const createOne = (model) => {
return (dispatch, getState) => {
dispatch(createOnePending())
const token = getTokenFromState(getState())
return create(endpoint, model, token)
.then(response => dispatch(createOneSuccess(response.id, response)))
.catch(error => dispatch(createOneFailure(error)))
}
}
const updateOnePending = createAction(constants.UPDATE_PENDING, modelId => ({}), modelId => ({ modelId }))
const updateOneFailure = createAction(constants.UPDATE_FAILURE, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const updateOneSuccess = createAction(constants.UPDATE_SUCCESS, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const updateOne = (model) => {
return (dispatch, getState) => {
dispatch(updateOnePending(model.id))
const token = getTokenFromState(getState())
return update(`${endpoint}/${model.id}`, model, token)
.then(response => dispatch(updateOneSuccess(model.id, response)))
.catch(error => dispatch(updateOneFailure(model.id, error)))
}
}
const deleteOnePending = createAction(constants.DELETE_PENDING, modelId => ({}), modelId => ({ modelId }))
const deleteOneFailure = createAction(constants.DELETE_FAILURE, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const deleteOneSuccess = createAction(constants.DELETE_SUCCESS, modelId => ({}), modelId => ({ modelId }))
const deleteOne = (model) => {
return (dispatch, getState) => {
const modelId = model.id
debug('deleteOne :: ', modelId)
dispatch(deleteOnePending(modelId))
const token = getTokenFromState(getState())
return destroy(`${endpoint}/${model.id}`, token)
.then(response => dispatch(deleteOneSuccess(modelId)))
.catch(error => dispatch(deleteOneFailure(modelId, error)))
}
}
const activateOnePending = createAction(constants.ACTIVATION_PENDING, modelId => ({}), modelId => ({ modelId }))
const activateOneFailure = createAction(constants.ACTIVATION_FAILURE, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const activateOneSuccess = createAction(constants.ACTIVATION_SUCCESS, (modelId, payload) => payload, (modelId, payload) => ({ modelId }))
const activateOne = (model, activate) => {
return (dispatch, getState) => {
const modelId = model.id
debug('activateOne :: ', modelId)
dispatch(activateOnePending(modelId))
const token = getTokenFromState(getState())
const _endpoint = `${endpoint}/${modelId}/${activate ? 'activate' : 'deactivate'}`
return update(_endpoint, null, token)
.then(response => dispatch(activateOneSuccess(modelId, response)))
.catch(error => dispatch(activateOneFailure(modelId, error)))
}
}
const selectionAdd = createAction(constants.SELECTION_ADD)
const selectionRemove = createAction(constants.SELECTION_REMOVE)
const selectionToggle = createAction(constants.SELECTION_TOGGLE, (payload, multi) => payload, (payload, multi) => ({ multi }))
const clearAll = createAction(constants.CLEAR_ALL)
const saveOne = model => model.id ? updateOne(model) : createOne(model)
const setOne = createAction(constants.SET)
const resetOneStatus = createAction(constants.RESET_STATUS, modelId => ({}), modelId => ({ modelId }))
const cleanOne = createAction(constants.CLEAN, model => ({}), model => ({ modelId: model ? model.id : null }))
return {
readAll,
clearAll,
readOne,
createOne,
updateOne,
saveOne,
deleteOne,
activateOne,
selectionAdd,
selectionRemove,
selectionToggle,
cleanOne,
setOne,
resetOneStatus,
}
}
// handler helpers
const fmerge = flip(merge)
const singlesLens = lensProp('singles')
const singleModelLens = (id) => lensPath(['singles', id])
const setSingleModel = (id, model) => ifElse(always(!id), identity, over(singlesLens, assoc(id, model)))
const mergeSingleModel = (id, model) => ifElse(always(!id), identity, over(singleModelLens(id), fmerge(model)))
const selectionHandler = (selType, items, state) => {
if (!items) return state
const selected = prop('selected', state)
let allVal
let associator
switch (selType) {
case 'ADD': allVal = 'all'; associator = compose(uniq, concat); break
case 'REMOVE': allVal = []; associator = without; break
case 'TOGGLE': allVal = selected === 'all' ? [] : 'all'; associator = flip(without); break
}
if (items === 'all' || selected === 'all') {
return assoc('selected', allVal, state)
}
items = ifElse(isArrayLike, identity, of)(items) // make payload array
return assoc('selected', associator(items, selected))
}
export const handlers = (constants) => {
return {
[constants.READ_ALL_PENDING]: (state) => assoc('loading', true, state),
[constants.READ_ALL_FAILURE]: (state, { payload }) => merge(state, { loading: false, errors: payload, data: [] }),
[constants.READ_ALL_SUCCESS]: (state, { payload }) => merge(state, { loading: false, errors: null, ...payload }),
[constants.CLEAR_ALL]: (state, { meta }) => merge(state, { loading: false, errors: null, offset: 0, data: [] }),
[constants.READ_PENDING]: (state, { meta }) => setSingleModel(meta.modelId, { loading: true }, state),
[constants.READ_FAILURE]: (state, { payload, meta }) => setSingleModel(meta.modelId, { loading: false, errors: payload, data: null }, state),
[constants.READ_SUCCESS]: (state, { payload, meta }) => setSingleModel(meta.modelId, { loading: false, errors: null, data: payload }, state),
// NOTE: only ONE entity can be in create mode because we give it a FIXED id in the state
[constants.CREATE_PENDING]: (state) => mergeSingleModel(NEW_MODEL_STATE_KEY, { status: 'OPEN', saving: true }, state),
[constants.CREATE_FAILURE]: (state, { payload }) => mergeSingleModel(NEW_MODEL_STATE_KEY, { status: 'CREATE_FAILED', saving: false, errors: payload }, state), // eslint-disable-line max-len
[constants.CREATE_SUCCESS]: (state, { payload }) => mergeSingleModel(NEW_MODEL_STATE_KEY, { status: 'CREATED', errors: null, data: payload }, state),
[constants.UPDATE_PENDING]: (state, { meta }) => mergeSingleModel(meta.modelId, { status: 'OPEN', saving: true }, state),
[constants.UPDATE_FAILURE]: (state, { payload, meta }) => mergeSingleModel(meta.modelId, { status: 'UPDATE_FAILED', saving: false, errors: payload }, state), // eslint-disable-line max-len
[constants.UPDATE_SUCCESS]: (state, { payload, meta }) => mergeSingleModel(meta.modelId, { status: 'UPDATED', saving: false, errors: null, data: payload }, state), // eslint-disable-line max-len
[constants.DELETE_PENDING]: (state, { meta }) => mergeSingleModel(meta.modelId, { status: 'OPEN', saving: true }, state),
[constants.DELETE_FAILURE]: (state, { payload, meta }) => mergeSingleModel(meta.modelId, { status: 'DELETE_FAILED', saving: false, errors: payload }, state), // eslint-disable-line max-len
[constants.DELETE_SUCCESS]: (state, { meta }) => mergeSingleModel(meta.modelId, { status: 'DELETED', saving: false, errors: null }, state),
[constants.ACTIVATION_PENDING]: (state) => mergeSingleModel(NEW_MODEL_STATE_KEY, { status: 'OPEN', saving: true }, state),
[constants.ACTIVATION_FAILURE]: (state, { payload, meta }) => mergeSingleModel(meta.modelId, { status: 'UPDATE_FAILED', saving: false, errors: payload }, state), // eslint-disable-line max-len
[constants.ACTIVATION_SUCCESS]: (state, { payload, meta }) => mergeSingleModel(meta.modelId, { status: 'UPDATED', saving: false, errors: null, data: payload }, state), // eslint-disable-line max-len
[constants.SELECTION_ADD]: (state, { payload }) => selectionHandler('ADD', payload, state),
[constants.SELECTION_REMOVE]: (state, { payload }) => selectionHandler('REMOVE', payload, state),
[constants.SELECTION_TOGGLE]: (state, { payload }) => selectionHandler('TOGGLE', payload, state),
[constants.SET]: (state, { payload }) => ifElse(
always(not(payload)),
identity,
setSingleModel(propOr(NEW_MODEL_STATE_KEY, 'id', payload), { status: 'OPEN', loading: false, saving: false, errors: null, data: payload })
)(state),
[constants.CLEAN]: (state, { meta }) => over(singlesLens, dissoc(propOr(NEW_MODEL_STATE_KEY, 'modelId', meta))),
[constants.RESET_STATUS]: (state, { meta = {} }) => ifElse(
view(singleModelLens(prop('modelId', meta))),
mergeSingleModel(prop('modelId', meta), { status: 'OPEN' }),
identity,
)(state),
}
}
const all = (key, endpoint) => {
endpoint = endpoint || `/api/${key}`
const c = constants(key)
return {
initialState: INITIAL_STATE,
emptySingleModel: EMPTY_SINGLE_MODEL,
newModelStateKey: NEW_MODEL_STATE_KEY,
constants: c,
actions: actions(endpoint, c),
handlers: handlers(c),
}
}
export default all
@mehiel
Copy link
Author

mehiel commented Feb 10, 2017

Then for any resource you can create the actions, constants, reducer like:

import handleActions from 'redux-actions/lib/handleActions'
import crud from 'lib/redux-utils/crud'

import Debug from 'debug'
const debug = Debug('app:users:state') // eslint-disable-line no-unused-vars

const { constants: c, actions: a, handlers: h, initialState: i } = crud('users', '/um/users')

export const constants = c
export const actions = a
export default handleActions(h, i)

and of course you can extend with more actions etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment