Skip to content

Instantly share code, notes, and snippets.

@mrgenixus
Last active September 6, 2022 21:39
Show Gist options
  • Save mrgenixus/4017d52fd60f789a9d7c1f873a5ce993 to your computer and use it in GitHub Desktop.
Save mrgenixus/4017d52fd60f789a9d7c1f873a5ce993 to your computer and use it in GitHub Desktop.
reducer-helpers
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;

Expectations:

  • 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);
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;
}
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')
)
*/
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