Created
January 24, 2017 13:25
-
-
Save jptissot/4fc8a75402dccdbffafd966e3785d78b to your computer and use it in GitHub Desktop.
Api get method handler using redux, redux-sagas, reselect.
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 { PropTypes } from 'react'; | |
import { fromJS } from 'immutable'; | |
import { call, put, takeLatest } from 'redux-saga/effects'; | |
import { createSelector, createStructuredSelector } from 'reselect'; | |
import api from './request'; | |
// PropTypes declaration to use with makeCompleteSelector | |
export const propTypes = { | |
data: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired, | |
loading: PropTypes.bool.isRequired, | |
error: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired, | |
}; | |
/** | |
* Generic function that hides all the boilerplate required to use redux and redux-sagas for get api calls | |
* | |
* @export | |
* @param {string} name of the data under the redux mount point. Also used for action names | |
* @param {string} mountPoint on the redux state tree | |
* @param {string|function} url of the api to use. Can also be a function that formats a url. ex: (action) => `/surveys/${action.surveyId}/questionnaires/` | |
* @returns an object with the mountPoint, reducer, saga, actions, selectors | |
*/ | |
export default function generateApiGet(name, mountPoint, url) { | |
// The action string constants | |
const actionStrings = { | |
loadAction: `app/api/LOAD_${name.toUpperCase()}`, | |
successAction: `app/api/LOAD_${name.toUpperCase()}_SUCCESS`, | |
errorAction: `app/api/LOAD_${name.toUpperCase()}_ERROR`, | |
}; | |
// Action creator function that create the redux actions. To be used by dispatch | |
const actions = { | |
load(params) { | |
return { | |
...params, | |
type: actionStrings.loadAction, | |
}; | |
}, | |
loaded(data) { | |
return { | |
data, | |
type: actionStrings.successAction, | |
}; | |
}, | |
error(error) { | |
return { | |
type: actionStrings.errorAction, | |
error, | |
}; | |
}, | |
}; | |
// The initial state of the reducer | |
let initialState = { | |
loading: false, | |
error: false, | |
}; | |
// dynamic name awesomeness | |
initialState[name] = false; | |
initialState = fromJS(initialState); | |
// The actual redux reducer that will set the data to the state | |
const reducer = (state = initialState, action) => { | |
switch (action.type) { | |
case actionStrings.loadAction: | |
return state | |
.set('loading', true) | |
.set('error', false); | |
// .set(name, false); | |
case actionStrings.successAction: | |
return state | |
.set(name, action.data) | |
.set('loading', false) | |
.set('error', false); | |
case actionStrings.errorAction: | |
return state | |
.set('error', action.error) | |
.set('loading', false); | |
// .set(name, false); | |
default: | |
return state; | |
} | |
}; | |
// function that returns an array containing the watchFetch saga. | |
const makeSaga = () => { | |
function* fetchApi(action) { | |
try { | |
const data = yield call(api.get, (typeof url === 'function') ? url(action) : url); | |
if (!data.err) { | |
yield put(actions.loaded(data.data)); | |
} else { | |
yield put(actions.error(data.err)); | |
} | |
} catch (err) { | |
yield put(actions.error(err)); | |
} | |
} | |
function* watchFetch() { | |
yield takeLatest(actionStrings.loadAction, fetchApi); | |
} | |
return [watchFetch]; | |
}; | |
// Function that creates the selectors for this api call | |
const makeSelectors = () => { | |
// root selector for this data. | |
const selectDomain = (state) => state.get(mountPoint); | |
// selector that returns the root of the data | |
const makeSelectData = () => createSelector( | |
selectDomain, | |
(domainState) => domainState.get(name) | |
); | |
// error selector | |
const makeSelectError = () => createSelector( | |
selectDomain, | |
(domainState) => domainState.get('error') | |
); | |
// loading selector | |
const makeSelectLoading = () => createSelector( | |
selectDomain, | |
(domainState) => domainState.get('loading') | |
); | |
// export the selectors | |
return { | |
makeCompleteSelector: (extra) => createStructuredSelector({ | |
data: makeSelectData(), | |
error: makeSelectError(), | |
loading: makeSelectLoading(), | |
...extra, | |
}), | |
makeSelectData, | |
makeSelectError, | |
makeSelectLoading, | |
}; | |
}; | |
return { | |
mountPoint, | |
reducer, | |
saga: makeSaga(), | |
actions, | |
selectors: makeSelectors(), | |
propTypes, | |
}; | |
} |
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 { getAsyncInjectors } from 'utils/asyncInjectors'; | |
import apiGetHelper from './apiGetHelper'; | |
// Helper method to register the reducer and saga to the store | |
const register = ({ mountPoint, reducer, saga }, { injectReducer, injectSagas }) => { | |
injectReducer(mountPoint, reducer); | |
injectSagas(saga); | |
}; | |
const getApiCalls = { | |
getUser: apiGetHelper('user', 'data.user', 'user'), | |
}; | |
// Called from system/app to register the sagas and reducer in the store on app start | |
export function registerApi(store) { | |
const asyncInjectors = getAsyncInjectors(store); | |
// iterate through all api calls and register them to the store | |
Object.keys(getApiCalls).map((e) => register(getApiCalls[e], asyncInjectors)); | |
} | |
// Allow other places in the app to use the exported methods | |
export default getApiCalls; |
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
//taken from react-boilerplate and modified | |
import 'whatwg-fetch'; | |
import config from 'system/config'; | |
/** | |
* Parses the JSON returned by a network request | |
* | |
* @param {object} response A response from a network request | |
* | |
* @return {object} The parsed JSON from the request | |
*/ | |
function parseJSON(response) { | |
return response.json(); | |
} | |
/** | |
* Checks if a network request came back fine, and throws an error if not | |
* | |
* @param {object} response A response from a network request | |
* | |
* @return {object|undefined} Returns either the response, or throws an error | |
*/ | |
function checkStatus(response) { | |
if (response.status >= 200 && response.status < 300) { | |
return response; | |
} | |
const error = new Error(response.statusText); | |
error.response = response; | |
throw error; | |
} | |
/** | |
* Requests a URL, returning a promise | |
* | |
* @param {string} url The URL we want to request | |
* @param {object} [options] The options we want to pass to "fetch" | |
* | |
* @return {object} An object containing either "data" or "err" | |
*/ | |
function request(url, options) { | |
let myOptions = options; | |
if (config.UseCredentials) { | |
myOptions = { ...options, credentials: 'include' }; | |
} | |
return fetch(`${config.ApiUrl}/${url}`, myOptions) | |
.then(checkStatus) | |
.then(parseJSON) | |
.then((data) => ({ data })) | |
.catch((err) => ({ err })); | |
} | |
/** | |
* Performs a GET request | |
* | |
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint | |
* @param {object} options The options we want to pass to "fetch" | |
* @return {object} An object containing either "data" or "err" | |
*/ | |
function get(url, options) { | |
return request(url, { ...options, method: 'GET' }); | |
} | |
/** | |
* Performs a DELETE request | |
* | |
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint | |
* @param {object} options The options we want to pass to "fetch" | |
* @return {object} An object containing either "data" or "err" | |
*/ | |
function del(url, options) { | |
return request(url, { ...options, method: 'DELETE' }); | |
} | |
/** | |
* Performs a POST request | |
* | |
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint | |
* @param {object} options The options we want to pass to "fetch" | |
* @return {object} An object containing either "data" or "err" | |
*/ | |
function post(url, options) { | |
return request(url, { ...options, method: 'POST' }); | |
} | |
export default { | |
get, | |
post, | |
request, | |
del, | |
}; |
DataLoader component that can be used with the above API abstraction.
Usage: const DataLoadedComponent = makeDataLoader(Component)(api.getUser);
// external desp
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { makeSelectLocale } from 'containers/LanguageProvider/redux';
import { propTypes } from 'api/apiGetHelper';
// Component deps
import LoadingIndicator from 'ui/components/LoadingIndicator';
import ErrorMessage from 'ui/components/ErrorMessage';
/**
* Higher order component that handles DataFetching.
*
* @export
* @param {React.Component} Component The component that needs rendering when data is available.
* @param {object} The second parameter is an object containing overridable properties
* @returns a component that handles DataFetching
*/
export default function makeConnectedDataLoader(Component, { afterDataLoaded = () => {}, LoadingComponent = LoadingIndicator, ErrorComponent = ErrorMessage } = {}) {
const DataLoader = class extends React.PureComponent {
static propTypes = {
fetchData: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
...propTypes,
}
static defaultProps = {
data: false,
}
componentDidMount() {
// load the component's data.
this.props.fetchData(this.props);
}
componentDidUpdate() {
if (this.props.data) {
// Hook that allows callers to inject business logic in this component.
afterDataLoaded(this.props.data, this.props.dispatch);
}
}
render() {
const { error, loading, data } = this.props;
// if we have previous data in the store, show it immediatly
if (data) {
// show a loading indicator when we are looking to refresh the data
if (loading) {
return <div><LoadingComponent /> <Component {...this.props} /> </div>;
}
// if we have data in the state but an error occurred loading new data.
if (error) {
return <div><ErrorComponent error={error} /> <Component {...this.props} /> </div>;
}
return <Component {...this.props} />;
}
// Show an error if there is one
if (error) {
return <ErrorComponent error={error} />;
}
// Loading indicator by default, even for the initial state of data=false, loading = false, error = false
return <LoadingComponent />;
}
};
/**
* Function that connects the DataLoader wrapped component to the redux state and dispatch.
*
* @param {object} apiLayer The apiGetHelper object that needs to be loaded.
* @param {function} [extraMapStateToProps object that is spread under the MapStateToProps object
* @param {function} [extraMapDispatchToProps=(dispatch, ownProps)=>{}] Expects the function to return an object that is spread under the MapDispatchToProps object
*/
return function connectDataLoaderToRedux(apiLayer, { mapStateToProps = () => {}, mapDispatchToProps = () => {} } = {}) {
// Connect the api result to the component
return connect(
// mapStateToProps
(state) => {
return {
...apiLayer.selectors.makeCompleteSelector({ locale: makeSelectLocale() })(state),
...mapStateToProps(state),
};
},
// mapDispatchToProps
(dispatch, ownProps) => {
return {
// This is overrideable if required by specifying the same key to extraMapDispatchToProps
fetchData: () => dispatch(apiLayer.actions.load(ownProps)),
...mapDispatchToProps(dispatch, ownProps),
dispatch,
};
}
)(DataLoader);
};
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
big props to react-boilerplate for the base https://github.com/mxstbr/react-boilerplate