src/immutablejs.js
import Immutable from 'immutable';
import { createAction, handleActions } from 'redux-actions';
import { bindActionCreators } from 'redux';
import makeDebug from 'debug';
/**
* Build a Redux compatible wrapper around a Feathers service.
*
* Instead of using a feathers-client service directly
* app.services('messages').create({ name: 'John' }, (err, data) => {...});
* you first wrap the feathers service to expose Redux action creators and a reducer
* messages = reduxifyService(app, 'messages');
* You can thereafter use the service in a standard Redux manner
* store.dispatch(messages.create({ name: 'John' }));
* with async action creators being dispatched to a reducer which manages state.
*
* @param {Object} app the configured Feathers app, e.g. require('feathers-client')().configure(...)
* @param {String} route is the Feathers' route for the service.
* @param {String} name is the serviceName by which the service is known on client. Default route.
* @param {Object} options
* @returns {{find: *, get: *, create: *, update: *, patch: *, remove: *, on: *, reducer: *}}
*
* You will usually use on the client
* const users = reduxifyService(app, 'users');
* However you may sometimes have awkward REST paths on the server like
* app.use('app.use('/verifyReset/:action/:value', ...);
* You are then best of to use on the client
* const buildings = reduxifyService(app, '/verifyReset/:action/:value', 'verifyReset');
* since you can thereafter use
* store.dispatch(verifyReset.create(...));
*
* Action creators for service calls are returned as { find, get, create, update, patch, remove }.
* They expect the same parameters as their Feathers service methods, e.g. (id, data, params).
*
* Should you wish to write additional action creators, the { reducer } export expects action types
* 'SERVICES_${SERVICE_NAME}_${METHOD}_PENDING', ...FULFILLED and ...REJECTED
* where SERVICE_NAME is serviceName in upper case; METHOD is FIND, GET, ...
*
* Pro tip: You can implement optimistic updates within ...PENDING, finalizing them in ...FULFILL.
*
* The reducer's JS state (not immutable) is {
* isError: String|null,
* isLoading: Boolean,
* isSaving: Boolean,
* isFinished: Boolean,
* data: Object|null,
* queryResult: Object|null
* }.
* The find service call stores Feathers' query payload in queryResult. Other methods store in data.
*
* isError is Feathers' error payload { message, name, code, className, errors }.
* If the feathers server response did not specify an error message, then the message property will
* be feathers default of 'Error'.
*
* Options may change the state property names and the reducer action type names.
*
* Each service also gets a reset service call which re-initializes that service's state.
* This may be used, for example, to remove isError in order to no longer render error messages.
* store.dispatch(messages.reset(true)) will leave queryResult as is during initialization.
*
* An action creator for listening on service events is returned as { on } and could be used like:
* import feathersApp, { services } from './feathers';
* feathersApp.service('messages').on('created', data => { store.dispatch(
* services.messages.on('created', data, (event, data, dispatch, getState) => {
* // handle data change
* })
* ); });
*/
const reduxifyService = (app, route, name = route, options = {}) => {
const debug = makeDebug(`reducer:${name}`);
debug(`route ${route}`);
const defaults = {
idField: 'id',
isError: 'isError',
isLoading: 'isLoading',
isSaving: 'isSaving',
isFinished: 'isFinished',
data: 'data',
queryResult: 'queryResult',
store: 'store',
PENDING: 'PENDING',
FULFILLED: 'FULFILLED',
REJECTED: 'REJECTED'
};
const pendingDefaults = {
// individual pending/loading depending on the dispatched action
createPending: 'createPending',
findPending: 'findPending',
getPending: 'getPending',
updatePending: 'updatePending',
patchPending: 'patchPending',
removePending: 'removePending'
};
const queryResultDefaults = {
total: 0,
limit: 0,
skip: 0,
data: []
};
const opts = Object.assign({}, defaults, pendingDefaults, options);
const getPendingDefaults = (slicedActionType) => {
let result = {};
for (let key in pendingDefaults) {
if (`${slicedActionType}Pending` === pendingDefaults[key]) {
result[key] = true;
} else {
result[key] = false;
}
}
return result;
};
const SERVICE_NAME = `SERVICES_${name.toUpperCase()}_`;
const service = app.service(route);
if (!service) {
debug(`redux: Feathers service '${route} does not exist.`);
throw Error(`Feathers service '${route} does not exist.`);
}
const reducerForServiceMethod = (actionType, ifLoading, isFind) => {
const slicedActionType = actionType.slice(SERVICE_NAME.length, actionType.length).toLowerCase(); // returns find/create/update/patch (etc.)
const pendingResults = getPendingDefaults(slicedActionType);
return {
// promise has been started
[`${actionType}_${opts.PENDING}`]: (state, action) => {
debug(`redux:${actionType}_${opts.PENDING}`, action);
return state
.merge(makeImmutable(pendingResults))
.set(opts.isError, null)
.set(opts.isError, null)
.set(opts.isLoading, ifLoading)
.set(opts.isSaving, !ifLoading)
.set(opts.isFinished, false)
.set(opts.data, state.get(opts.data) || null)
.set(opts.queryResult, state.get(opts.queryResult) || null); // leave previous to reduce redraw
},
// promise resolved
[`${actionType}_${opts.FULFILLED}`]: (state, action) => {
debug(`redux:${actionType}_${opts.FULFILLED}`, action);
return state
.set(opts.isError, null)
.set(opts.isLoading, false)
.set(opts.isSaving, false)
.set(opts.isFinished, true)
.set(opts.data, !isFind ? makeImmutable(action.payload) : null)
.set(opts.queryResult, isFind ? makeImmutable(action.payload) : (state.get(opts.queryResult) || null))
.set(opts[slicedActionType + 'Pending'], false);
},
// promise rejected
[`${actionType}_${opts.REJECTED}`]: (state, action) => {
debug(`redux:${actionType}_${opts.REJECTED}`, action);
return state
// action.payload = { name: "NotFound", message: "No record found for id 'G6HJ45'",
// code:404, className: "not-found" }
.set(opts.isError, makeImmutable(action.payload))
.set(opts.isLoading, false)
.set(opts.isSaving, false)
.set(opts.isFinished, true)
.set(opts.data, null)
.set(opts.queryResult, isFind ? null : (state.get(opts.queryResult) || null))
.set(opts[slicedActionType + 'Pending'], false);
}
};
};
// ACTION TYPES
const FIND = `${SERVICE_NAME}FIND`;
const GET = `${SERVICE_NAME}GET`;
const CREATE = `${SERVICE_NAME}CREATE`;
const UPDATE = `${SERVICE_NAME}UPDATE`;
const PATCH = `${SERVICE_NAME}PATCH`;
const REMOVE = `${SERVICE_NAME}REMOVE`;
const RESET = `${SERVICE_NAME}RESET`;
const STORE = `${SERVICE_NAME}STORE`;
// FEATHERS EVENT LISTENER ACTION TYPES
const ON_CREATED = `${SERVICE_NAME}ON_CREATED`;
const ON_UPDATED = `${SERVICE_NAME}ON_UPDATED`;
const ON_PATCHED = `${SERVICE_NAME}ON_PATCHED`;
const ON_REMOVED = `${SERVICE_NAME}ON_REMOVED`;
const actionTypesForServiceMethod = (actionType) => ({
[`${actionType}`]: `${actionType}`,
[`${actionType}_${opts.PENDING}`]: `${actionType}_${opts.PENDING}`,
[`${actionType}_${opts.FULFILLED}`]: `${actionType}_${opts.FULFILLED}`,
[`${actionType}_${opts.REJECTED}`]: `${actionType}_${opts.REJECTED}`
});
return {
// ACTION CREATORS
// Note: action.payload in reducer will have the value of .data below
find: createAction(FIND, (p) => ({ promise: service.find(p), data: undefined })),
get: createAction(GET, (id, p) => ({ promise: service.get(id, p) })),
create: createAction(CREATE, (d, p) => ({ promise: service.create(makeMutable(d), p) })),
update: createAction(UPDATE, (id, d, p) => ({ promise: service.update(id, makeMutable(d), p) })),
patch: createAction(PATCH, (id, d, p) => ({ promise: service.patch(id, makeMutable(d), p) })),
remove: createAction(REMOVE, (id, p) => ({ promise: service.remove(id, p) })),
reset: createAction(RESET),
store: createAction(STORE, store => store),
on: (event, data, fcn) => (dispatch, getState) => { fcn(event, data, dispatch, getState); },
onCreated: createAction(ON_CREATED, (payload) => ({ data: payload })),
onUpdated: createAction(ON_UPDATED, (payload) => ({ data: payload })),
onPatched: createAction(ON_PATCHED, (payload) => ({ data: payload })),
onRemoved: createAction(ON_REMOVED, (payload) => ({ data: payload })),
// ACTION TYPES
types: {
...actionTypesForServiceMethod(FIND),
...actionTypesForServiceMethod(GET),
...actionTypesForServiceMethod(CREATE),
...actionTypesForServiceMethod(UPDATE),
...actionTypesForServiceMethod(PATCH),
...actionTypesForServiceMethod(REMOVE),
RESET,
STORE,
...actionTypesForServiceMethod(ON_CREATED),
...actionTypesForServiceMethod(ON_UPDATED),
...actionTypesForServiceMethod(ON_PATCHED),
...actionTypesForServiceMethod(ON_REMOVED)
},
// REDUCER
reducer: handleActions(
Object.assign({},
reducerForServiceMethod(FIND, true, true),
reducerForServiceMethod(GET, true),
reducerForServiceMethod(CREATE, false),
reducerForServiceMethod(UPDATE, false),
reducerForServiceMethod(PATCH, false),
reducerForServiceMethod(REMOVE, false),
{ [ON_CREATED]: (state, action) => {
debug(`redux:${ON_CREATED}`, action);
return state
.setIn([opts.queryResult, 'data'], state.getIn([opts.queryResult, 'data']).concat(makeImmutable(action.payload.data)))
.setIn([opts.queryResult, 'total'], state.getIn([opts.queryResult, 'total']) + 1);
} },
{ [ON_UPDATED]: (state, action) => {
debug(`redux:${ON_UPDATED}`, action);
const idx = state.getIn([opts.queryResult, 'data']).findIndex(item => {
return item.get(opts.idField) === action.payload.data[opts.idField];
});
console.log('ON_UPDATED: %o', idx);
return state
.setIn([opts.queryResult, 'data', idx], makeImmutable(action.payload.data));
} },
{ [ON_PATCHED]: (state, action) => {
debug(`redux:${ON_PATCHED}`, action);
const idx = state.getIn([opts.queryResult, 'data']).findIndex(item => {
return item.get(opts.idField) === action.payload.data[opts.idField];
});
console.log('ON_PATCHED: %o', idx);
return state
.setIn([opts.queryResult, 'data', idx], makeImmutable(action.payload.data));
} },
{ [ON_REMOVED]: (state, action) => {
debug(`redux:${ON_REMOVED}`, action);
const idx = state.getIn([opts.queryResult, 'data']).findIndex(item => {
return item.get(opts.idField) === action.payload.data[opts.idField];
});
return state
.deleteIn([opts.queryResult, 'data'], idx)
.setIn([opts.queryResult, 'total'], state.getIn([opts.queryResult, 'total']) - 1);
} },
// reset status if no promise is pending
{ [RESET]: (state, action) => {
debug(`redux:${RESET}`, action);
if (state.get(opts.isLoading) || state.get(opts.isSaving)) {
return state;
}
return state
.set(opts.isError, null)
.set(opts.isLoading, false)
.set(opts.isSaving, false)
.set(opts.isFinished, false)
.set(opts.data, null)
.set(opts.queryResult, action.payload ? state.get(opts.queryResult) : makeImmutable(queryResultDefaults))
.set(opts.store, null);
} },
// update store
{ [STORE]: (state, action) => {
debug(`redux:${STORE}`, action);
return state
.set(opts.store, makeImmutable(action.payload));
} }
),
fromJS(
{
[opts.isError]: null,
[opts.isLoading]: false,
[opts.isSaving]: false,
[opts.isFinished]: false,
[opts.data]: null,
[opts.queryResult]: queryResultDefaults,
[opts.store]: null,
[opts.createPending]: false,
[opts.findPending]: false,
[opts.getPending]: false,
[opts.updatePending]: false,
[opts.patchPending]: false,
[opts.removePending]: false
}
)
)
};
};
/**
* Convenience method to build wrappers for multiple services. You should this not reduxifyService.
*
* @param {Object} app - See reduxifyService
* @param {Object|Array|String} routeNameMap - The feathers services to reduxify. See below.
* @param {Object} options - See reduxifyService
* @returns {Object} Each services' action creators. See reduxifyService.
*
* If the feathers server has:
* app.use('users', ...);
* app.use('/buildings/:buildingid', ...);
* then you can do
* services = reduxifyServices(app, { users: 'users', '/buildings/:buildingid': 'buildings' });
* ...
* store.dispatch(users.create(...));
* store.dispatch(users.create(...));
*
* A routeNameMap of ['users', 'members'] is the same as { users: 'users', members: 'members' }.
* A routeNameMao of 'users' is the same as { users: 'users' }.
*/
export default (app, routeNameMap, options) => {
const services = {};
let routeNames = {};
if (typeof routeNameMap === 'string') {
routeNames = { [routeNameMap]: routeNameMap };
} else if (Array.isArray(routeNameMap)) {
routeNameMap.forEach(name => { routeNames[name] = name; });
} else if (typeof routeNameMap === 'object') {
routeNames = routeNameMap;
}
Object.keys(routeNames).forEach(route => {
services[routeNames[route]] = reduxifyService(app, route, routeNames[route], options);
});
return services;
};
/**
* Get a status to display as a summary of all Feathers services.
*
* The services are checked in serviceNames order.
* The first service with an error message, returns that as the status.
* Otherwise the first service loading or saving returns its status.
*
* @param {Object} servicesState - the slice of state containing the states for the services.
* state[name] has the JS state (not immutable) for service 'name'.
* @param {Array|String} serviceNames
* @returns {{message: string, className: string, serviceName: string}}
* message is the English language status text.
* You can create your own internationalized messages with serviceName and className.
* className will be isLoading, isSaving or it will be Feathers' error's className.
*/
export const getServicesStatus = (servicesState, serviceNames) => {
const status = {
message: '',
className: '',
serviceName: ''
};
serviceNames =
Array.isArray(serviceNames) ? serviceNames : [serviceNames];
// Find an error with an err.message. 'Error' is what feather returns when there is no msg text.
const done = serviceNames.some(name => {
const state = servicesState.get(name);
const isError = state.get('isError');
const errorMessage = state.getIn(['isError', 'message']);
const className = state.getIn(['isError', 'className']);
if (state && isError && errorMessage && errorMessage !== 'Error') {
status.message = `${name}: ${errorMessage}`;
status.className = className;
status.serviceName = name;
return true;
}
return false;
});
if (done) { return fromJS(status); }
serviceNames.some(name => {
const state = servicesState.get(name);
if (state && !state.get('isError') && (state.get('isLoading') || state.get('isSaving'))) {
status.message = `${name} is ${state.get('isLoading') ? 'loading' : 'saving'}`;
status.className = state.get('isLoading') ? 'isLoading' : 'isSaving';
status.serviceName = name;
return true;
}
return false;
});
return fromJS(status);
};
/**
* Method to bind a given dispatch function with the passed services.
*
* This helps with not having to pass down store.dispatch as a prop everywhere
* Read More: http://redux.js.org/docs/api/bindActionCreators.html
*
* @param {Object} services - using the default reduxifyService method
* @param {Function} dispatch - the relevant store.dispatch function which is to be bounded to actionCreators
* @param {Array=} targetActions - list of action names to be targeted for binding
* @returns {Object} boundServices - returns the new services object with the bounded action creators
*/
export const bindWithDispatch = (dispatch, services, targetActions) => {
targetActions = targetActions || [
// default targets from feathers-redux
'find',
'get',
'create',
'update',
'patch',
'remove',
'reset',
'store',
// couple more optional ones in case feathers-reduxify-authentication is being used
'authenticate',
'logout'
];
const _serviceNames = Object.keys(services);
// map over the services object to get every service
_serviceNames.forEach(_name => {
const _methodNames = Object.keys(services[_name]);
// map over every method in the service
_methodNames.forEach(_method => {
// if method is in targeted actions then replace it with bounded method
if (targetActions.includes(_method)) {
services[_name][_method] = bindActionCreators(
services[_name][_method],
dispatch
);
}
});
});
return services;
};
// ##########################################################
// Immutable specific helpers
// ##########################################################
export const fromJS = (js) => {
return typeof js !== 'object' || js === null ? js
: Array.isArray(js)
? Immutable.Seq(js).map(fromJS).toList()
: Immutable.Seq(js).map(fromJS).toMap();
};
export const makeImmutable = (obj) => {
if (Immutable.Iterable.isIterable(obj)) {
return obj;
}
return fromJS(obj);
};
export const makeMutable = (obj) => {
if (Immutable.Iterable.isIterable(obj)) {
return obj.toJS();
}
return obj;
};