- All Actions will use
payload
for values that would be in the body of a corresponding HTTP Post. - Actions will provide meta information as
action.meta
... which a reducer can/will store as a property of an individual record - Values that might appear in a query string, such as id will be included in the action as top-level properties (id, post_id, comment_id, etc);
Last active
September 6, 2022 21:39
-
-
Save mrgenixus/4017d52fd60f789a9d7c1f873a5ce993 to your computer and use it in GitHub Desktop.
reducer-helpers
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
const apiUrl = (url) => `/api/v1${url}`; | |
export const fetcher = (url, options={}) => fetch(apiUrl(url), { | |
...options, | |
headers: { | |
"Content-Type": 'application/json', | |
...(options.headers || {}), | |
...( Rails?.csrfToken : { 'X-CSRF-Token': Rails.csrfToken() } : {}) | |
} | |
}); | |
export const JSONFetcher = async (...args) => { | |
const result = await fetcher(...args) | |
if (!result.ok) { | |
const error = new Error("BAD Request"); | |
error.result = result; | |
throw error; | |
} | |
return await result.json(); | |
} | |
const stringifyJSONToBody = (func) => (url, {json, ...options }={}) => { | |
if (!options.body && json) { | |
options = { ...options, body: JSON.stringify(json)}; | |
} | |
return func(url, options); | |
} | |
const wrap = (wrapper, func) => (url, { json, ...options }={}) => { | |
return func(url, wrapper(options)); | |
} | |
const fetchMethod = (method, f) => (url, opts={}) => f(url, {method, ...opts}); | |
const fetchify = (obj, wrapper=_.noop) => _.transform(obj, (obj, value, key) => { | |
obj[key] = wrap(wrapper, stringifyJSONToBody(fetchMethod(value, JSONFetcher))); | |
}, {}); | |
const METHODS = {get: 'GET', post: 'POST', patch: 'PATCH', delete: 'DELETE'}; | |
const api = (() => { | |
let authentication; | |
let wrapper = _.identity; | |
const methods = fetchify(METHODS, wrapper); | |
const wrap = (wrapper) => { | |
const rewrap = fetchify(METHODS, wrapper); | |
_.keys(METHODS).forEach((method) => instance[method] = rewrap[method]); | |
} | |
const instance = { | |
authenticated: () => { | |
return !!authentication; | |
}, | |
authenticate: (username, password) => { | |
methods.post() | |
}, | |
enableCookieAuth: (enable) => { | |
authenticated = enable; | |
}, | |
wrap | |
}; | |
return instance; | |
})(); | |
export default api; |
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
const create = (collection, attrs) => { | |
return [...collection, { | |
...attrs, | |
meta: { | |
id: uuid(), | |
...(attrs.meta||{}), | |
created: new Date.getTime(), | |
last_updated: new Date.getTime() | |
} | |
}]; | |
} | |
const prepareUpdate = (item, attrs) => { | |
const persisted = attrs.meta?.persisted === true; | |
if (!attrs.meta?.last_updated || attrs.meta.last_updated < item.last_updated) return item; | |
return { | |
...item, | |
...attrs, | |
meta: { | |
...(item.meta||{}), | |
...(attrs.meta||{}), | |
persisted, | |
last_updated: new Date.getTime() | |
} | |
}; | |
} | |
const idMatch = (item, id, attrs) => item.id === id || item?.meta?.id === attrs?.meta?.id; | |
const update = (collection, id, attrs) => { | |
return _.map(collection, (item) => idMatch(item, id, attrs) ? prepareUpdate(item, attrs) : item); | |
} | |
const markForDestruction = (collection, id) => { | |
return update(collection, id, { meta: { destroyed: new Date.getTime() }}); | |
} | |
const destroy = (collection, id) => { | |
return _.reject(collection, { id }); | |
} | |
const isReplaceable = (item, update) => { | |
return (item?.meta?.updated !== false) && | |
(!update || update.last_updated > item.meta.last_updated); | |
} | |
const updateItemsIn = (collection) => (newItem) => { | |
const partitionedItems = _.partition(collection, {id: newItem.id}); | |
const oldItem = partitionedItems[0][0]; | |
collection = partitionedItems[1]; | |
if (oldItem) { | |
if (isReplaceable(oldItem, newItem)) { | |
return prepareUpdate(oldItem, newItem); | |
} | |
return oldItem; | |
} | |
return newItem; | |
} | |
const merge = (collection, new_collection) => { | |
let [createdItems, otherItems] = _.partition(collection, 'meta.created'); | |
const updatedItems = _.map(new_collection, updateItemsIn(otherItems)); | |
return [...updatedItems, ...createdItems]; | |
} | |
const arrayCrudReducer = (resourceName) => (state=[], action) { | |
switch(action.type) { | |
case `${resourceName}/LOAD`: | |
return action[resourceName]; | |
case `${resourceName}/RESET`: | |
return []; | |
case `SYNC`: | |
if (!action[resourceName]) return state; | |
return merge(state, action[resourceName]); | |
case `${resourceName}/SYNC`: | |
return merge(state, action.payload); | |
case `${resourceName}/UPDATE.START`: | |
return update(state, action.id, action.payload); | |
case `${resourceName}/UPDATE.COMPLETE`: | |
return update(state, action.id, { ...action.payload, meta: action.meta }); | |
case `${resourceName}/CREATE.START`: | |
return create(state, action.id, action.payload); | |
case `${resourceName}/CREATE.COMPLETE`: | |
return update(state, action.id, { ...action.payload, meta: action.meta }); | |
case `${resourceName}/DESTROY.START`: | |
return markForDestruction(stata, action.id); | |
case `${resourceName}/DESTROY.COMPLETE`: | |
return destroy(state, action.id); | |
case `${resourceName}/UPDATE.FAILED`: | |
case `${resourceName}/CREATE.FAILED`: | |
case `${resourceName}/DESTROY.FAILED`: | |
return update(state, action.id, { meta: action.meta }); | |
default: | |
return state; | |
} | |
} | |
// Object Collection Pattern | |
const objCreate = (collection, attrs) => { | |
const id = uuid(); | |
return {...collection, [id]: { | |
...attrs, | |
meta: { | |
id, | |
...(attrs.meta||{}), | |
created: new Date.getTime(), | |
last_updated: new Date.getTime() | |
} | |
}}; | |
} | |
const objUpdate = (collection, id, attrs) => { | |
return {...collection, [id]: undefined, [attrs.id]: prepareUpdate(collection[id], attrs)}; | |
} | |
const objMarkForDestruction = (collection, id) => { | |
return objUpdate(collection, id, { meta: { destroyed: new Date.getTime() }}); | |
} | |
const objDestroy = (collection, id) => { | |
return {...collection, [id]: undefined}; | |
} | |
const splitObject = (obj, path) => _.transform(obj, ([truthies, falsies], item, key) => { | |
(_.get(item, path) ? truthies : falsies)[key] = item; | |
}, [{}, {}]); | |
const objUpdateItemsIn = (collection) => (updatedItems, newItem) => { | |
const { id } = newItem; | |
if (collection[id] && isReplaceable(collection[id], newItem)) { | |
updatedItems[id] = prepareUpdate(collection[id], newItem); | |
} | |
updatedItems[id] = prepareUpdate({}, newItem); | |
} | |
const objMerge = (collection, new_collection) => { | |
let [createdItems, otherItems] = splitObject(collection, 'meta.created'); | |
const updatedItems = _.transform(new_collection, objUpdateItemsIn(otherItems), {}); | |
return {...updatedItems, ...createdItems}; | |
} | |
const objCrudReducer = (resourceName) => (state={}, action) { | |
switch(action.type) { | |
case `${resourceName}/LOAD`: | |
return _.transform(action[resourceName], (obj, item) => obj[item.id] = item, {}); | |
case `${resourceName}/RESET`: | |
return {}; | |
case `SYNC`: | |
if (!action[resourceName]) return state; | |
return objMerge(state, action[resourceName]); | |
case `${resourceName}/SYNC`: | |
return objmerge(state, action.payload); | |
case `${resourceName}/UPDATE.START`: | |
return objUpdate(state, action.id, action.payload); | |
case `${resourceName}/UPDATE.COMPLETE`: | |
return objUpdate(state, action.id, { ...action.payload, meta: action.meta }); | |
case `${resourceName}/CREATE.START`: | |
return objUpdate(state, action.id, action.payload); | |
case `${resourceName}/CREATE.COMPLETE`: | |
return objUpdate(state, action.id, { ...action.payload, meta: action.meta }); | |
case `${resourceName}/DESTROY.START`: | |
return objMarkForDestruction(stata, action.id); | |
case `${resourceName}/DESTROY.COMPLETE`: | |
return objDestroy(state, action.id); | |
case `${resourceName}/UPDATE.FAILED`: | |
case `${resourceName}/CREATE.FAILED`: | |
case `${resourceName}/DESTROY.FAILED`: | |
return objUpdate(state, action.id, { meta: action.meta }); | |
default: | |
return state; | |
} | |
} | |
const flagReducer = (flagName, defaultValue=false) => (state=defaultValue, action) { | |
if (action.type === `${flagName}.SET`) { | |
return !defaultValue; | |
} else if (action.type === `${flagname}.RESET`) { | |
return defaultValue; | |
} | |
return state; | |
} |
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
const compose = (...synchronizers) => (api, state, dispatch, next) => _.reduce(_.reverse(synchronizers), (top, synchronizer) => { | |
return () => synchronizer(api, state, dispatch, top); | |
}, () => next())(); | |
const create = (statePath, apiPath, resourceName) => async (api, state, dispatch, next) => { | |
const attrs = _.first(_.reject(_.filter(_.get(state, statePath), "meta.created"), 'meta.errors')); | |
if (attrs) { | |
const resourceAttrs = _.omit(attrs, 'meta'); | |
try { | |
const response = await api.post(apiPath, {body: {[resourceName]: resourceAttrs }}); | |
const responseJSON = await response.JSON(); | |
dispatch({ | |
type: `${resourceName}/CREATE.COMPLETE`, | |
meta: { created: null }, | |
payload: response[resourceName], | |
id: response[resourceName].id | |
}); | |
} catch(e) { | |
const responseJSON = await response.json(); | |
dispatch({ | |
type: `${resourceName}/CREATE.FAILED`, | |
meta: { errors: responseJson.errors } | |
}); | |
} | |
} | |
next(); | |
} | |
const update = (statePath, apiPath, resourceName) => async (api, state, dispatch, next) => { | |
const attrs = _.first(_.reject(_.filter(_.get(state, statePath), ["meta.persisted", false]), 'meta.errors')); | |
if (attrs) { | |
const resourceAttrs = _.omit(attrs, 'meta', 'id'); | |
try { | |
const response = await api.patch(`apiPath/${attrs.id}`, {body: {[resourceName]: resourceAttrs }}); | |
const responseJSON = await response.JSON(); | |
dispatch({ | |
type: `${resourceName}/UPDATE.COMPLETE`, | |
meta: { persisted: true }, | |
payload: response[resourceName], | |
id: attrs.id | |
}); | |
} catch(e) { | |
const responseJSON = await response.json(); | |
dispatch({ | |
type: `${resourceName}/UPDATE.FAILED`, | |
meta: { errors: responseJson.errors } | |
}); | |
} | |
} | |
next(); | |
} | |
const update = (statePath, apiPath, resourceName) => async (api, state, dispatch, next) => { | |
const attrs = _.first(_.reject(_.filter(_.get(state, statePath), ["meta.persisted", false]), 'meta.errors')); | |
if (attrs) { | |
const resourceAttrs = _.omit(attrs, 'meta', 'id'); | |
try { | |
const response = await api.patch(`apiPath/${attrs.id}`, {body: {[resourceName]: resourceAttrs }}); | |
const responseJSON = await response.JSON(); | |
dispatch({ | |
type: `${resourceName}/UPDATE.COMPLETE`, | |
meta: { persisted: true }, | |
payload: response[resourceName], | |
id: attrs.id | |
}); | |
} catch(e) { | |
const responseJSON = await response.json(); | |
dispatch({ | |
type: `${resourceName}/UPDATE.FAILED`, | |
meta: { errors: responseJson.errors } | |
}); | |
} | |
} | |
next(); | |
} | |
const destroy = (statePath, apiPath, resourceName) => async (api, state, dispatch, next) => { | |
const attrs = _.first(_.reject(_.filter(_.get(state, statePath), "meta.destroyed"), 'meta.errors')); | |
if (attrs) { | |
try { | |
const response = await api.delete(`apiPath/${attrs.id}`); | |
const responseJSON = await response.JSON(); | |
dispatch({ | |
type: `${resourceName}/DESTROY.COMPLETE`, | |
id: attrs.id | |
}); | |
} catch(e) { | |
const responseJSON = await response.json(); | |
dispatch({ | |
type: `${resourceName}/DESTROY.FAILED`, | |
meta: { errors: responseJson.errors } | |
}); | |
} | |
} | |
next(); | |
} | |
// usage: | |
/* | |
const postSyncronizer = compose( | |
create('posts', 'posts', 'post'), | |
update('posts', 'posts', 'post'), | |
destroy('posts', 'posts', 'post') | |
) | |
*/ |
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
const compose = (...synchronizers) => (api, state, dispatch, next) => _.reduce(_.reverse(synchronizers), (top, synchronizer) => { | |
return () => synchronizer(api, state, dispatch, top); | |
}, () => next())(); | |
let locked = false; | |
const synchronizationManager = (synchronizers, api) => { | |
const start = compose(synchronizers); | |
return (nextManager=_.noop) => (getState, storeDispatch) => { | |
if (locked) return nextManager(state, storeDispatch); | |
const dispatch = async (action) => { | |
if (locked) { | |
try { | |
await storeDispatch(action); | |
} catch(e) { | |
storeDispatch({ type: "SYNC.DISPATCH.ERROR", error: e }); | |
} | |
locked = false; | |
} | |
} | |
const next = () => { | |
locked = false; | |
nextManager(state, storeDispatch); | |
} | |
locked = true; | |
start(api, getState(), dispatch, next); | |
} | |
} | |
const createOfflineManager = (path) { | |
const offline = (storeState) => _.get(storeState, [path, 'offline']); | |
const online = _.negate(offline); | |
const offlineReducer = (state, action) => { | |
switch(action.type) { | |
case "OFFLINE": | |
return {...state, offline: true }; | |
case "ONLINE": | |
return {...state, offline: false }; | |
default: | |
{}; | |
} | |
} | |
const listeners = []; | |
const offLineManager = (offlineApi) => (nextManager=_.noop) => async (getState, storeDispatch, ) => { | |
listeners.forEach((remove) => remove()); | |
listeners.push(await offline.addListener('networkStatusChange', (connected, connectionType) => { | |
if (connected === false) { | |
storeDispatch('OFFLINE') | |
} else { | |
storeDispatch('ONLINE'); | |
} | |
})); | |
if (online(getState())) { | |
nextManager(getState, storeDispatch); | |
} | |
} | |
return { offlineReducer, offLineManager }; | |
} | |
const dispatchLock = (logger=console.warn) => (synchronizer) => (api, state, dispatch, next) => { | |
let isCalled = false; | |
const proxy = (func) => (...args) => { | |
if (isCalled) return logger(`multiple callbacks detected`); | |
isCalled = true; | |
func(...args); | |
} | |
synchronizer(api, state, proxy(dispatch), proxy(next)); | |
} | |
const transform = (pred) => { | |
if (pred instanceof Array && pred.length === 2) { | |
return (value) => _.get(value, pred[0]) === 2 | |
} else if (pred instanceof Array) { | |
return (value) => _.get(value, ...pred); | |
} else if (pred instanceof Function) { | |
return pred; | |
} else if (pred instanceof Object) { | |
return (value) => _.matchesProperty(pred)(value); | |
} else if (pred instanceof String) { | |
return (value) => _.get(value, pred) | |
} | |
} | |
const matchPred = (predicates, value) => { | |
for (predicate of predicates) { | |
if (transform(predicate)(value)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
const ONE_HOUR = 3600; | |
const INCREASE_FACTOR = 2; | |
const ONE_SECOND_MILLI = 1000; | |
const exponentialDelayOn = (...failureTypes) => (useMaxDelay = false, { defaultDelay = 1, increaseFactor=INCREASE_FACTOR }) => { | |
let delayFrom, delayTo; | |
const setDelay = () => { | |
delayFrom = (!delayFrom || !delayTo || delayTo < Date.getTime()) ? Date.getTime() : delayFrom; | |
if (delayTo) { | |
delayTo = delayTo ? Date.getTime() + defaultDelay * ONE_SECOND_MILLI; | |
} else { | |
delayTo = delayFrom + (delayTo - delayFrom) * increaseFactor; | |
} | |
} | |
const wait = () => !!delayTo && Date.getTime() <= delayTo; | |
return { | |
time: () => delayTo - delayFrom, | |
wait, | |
check: (action) => { | |
if (matchPred(failureTypes, action)) { | |
setDelay(); | |
} else { | |
delayFrom = delayTo = undefined; | |
} | |
return action; | |
} | |
} | |
} | |
const delayer = exponentialDelayOn('REQUEST_TIMEOUT') | |
const withDelay = (delayer) => (synchronizer, useMaxDelay=false) => { | |
const delay = delayer(useMaxDelay); | |
return (api, state, dispatch, next) => { | |
if (!delay.wait()) { | |
synchronizer(api, state, (action) => dispatch(delay.check(action))) | |
} | |
} | |
} | |
const authenticated = (synchronizer) => (api, state, dispatch, next) => { | |
if (!api.authenticated()) { | |
return next(); | |
} | |
return synchronizer(api, state, dispatch, next); | |
} | |
const runOnFlag = _.curry((path, synchronizer) => (api, state, dispatch, next) => { | |
if (_.get(state, path)) { | |
return synchronizer(api, state, dispatch, next); | |
} | |
return next(); | |
}); | |
const throttle = _.curry((time, synchronizer) => { | |
let runAt; | |
return (api, state, dispatch, next) => { | |
if (!runAt || runAt <= Date.getTime()) { | |
runAt = Date.getTime() + time; | |
synchronizer(api, state, dispatch, next); | |
} else { | |
next(); | |
} | |
}; | |
}); | |
const flagThrottle = _.curry((time, path, synchronizer) => { | |
let runAt; | |
return (api, state, dispatch, next) => { | |
if (_.get(state, path)) { | |
runAt = undefined; | |
synchronizer(api, state, dispatch, next); | |
} else if (!runAt || runAt <= Date.getTime()) { | |
runAt = Date.getTime() + time; | |
synchronizer(api, state, dispatch, next); | |
} else { | |
next(); | |
} | |
} | |
}); | |
const defaultHelpers = _.flow( | |
dispatchLock(), | |
withDelay(delayer), | |
authenticated | |
); | |
//usage | |
const api = { get: _.noop, post: _.noop, patch: _.noop, delete: _.noop }; | |
const synchronizers = [ | |
] | |
const { offLineManager } = createOfflineManager('connection'); | |
const offlineApi = { addListener: (eventType, cb) => _.noop } | |
const synchronize = offLineManager(offlineApi)(synchronizationManager(synchronizers, api)())(store.getState, store.dispatch); | |
const heartBeat setTimeout(synchronize, 5000); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment