Created
February 10, 2017 09:50
-
-
Save mehiel/166a4a6dd27c7767147b17532836b2cf to your computer and use it in GitHub Desktop.
CRUD helpers for redux
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Then for any resource you can create the actions, constants, reducer like:
and of course you can extend with more actions etc.