Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create withAPIData higher-order component for managing API data #1974

Merged
merged 5 commits into from
Aug 22, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions components/higher-order/with-api-data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# withAPIData

`withAPIData` is a React [higher-order component](https://facebook.github.io/react/docs/higher-order-components.html) for orechestrating REST API data fetching. Simply compose your component using `withAPIData` and a description of your component's data needs, and the requisite network requests will be performed automatically on your behalf.

Out of the box, it includes:

- Auto-fetching when your component mounts
- Reusing cached data if request has already been made
- Provides status updates so you can render accordingly
- Trigger creation, updates, or deletes on data

## Example:

Consider a post component which displays a placeholder message while it loads, and the post's title once it becomes available:

```jsx
function MyPost( { post } ) {
if ( post.isLoading ) {
return <div>Loading...</div>;
}

return <div>{ post.data.title.rendered }</div>;
}

export default withAPIData( ( props, { type } ) => ( {
post: `/wp/v2/${ type( 'post' ) }/${ props.postId }`
} ) )( MyPost );
```

## Usage

`withAPIData` expects a function argument describing a mapping between prop keys and the REST API endpoint path. In the data mapping function, you have access to the component's incoming props, plus a few REST API helper utilities. It returns a function which can then be used in composing your component.

The REST API helpers currently include tools to retrieve the `rest_base` of a post type or taxonomy:

- `type( postType: String ): String`
- `taxonomy( taxonomy: String ): String`

Data-bound props take the shape of an object with a number of properties, depending on the methods supported for the particular endpoint:

- `GET`
- `isLoading`: Whether the resource is currently being fetched
- `data`: The resource, available once fetch succeeds
- `get`: Function to invoke a new fetch request
- `error`: The error object, if the fetch failed
- `POST`
- `isCreating`: Whether the resource is currently being created
- `createdData`: The created resource, available once create succeeds
- `create`: Function to invoke a new create request
- `createError`: The error object, if the create failed
- `PUT`
- `isSaving`: Whether the resource is currently being saved
- `savedData`: The saved resource, available once save succeeds
- `save`: Function to invoke a new save request
- `saveError`: The error object, if the save failed
- `PATCH`
- `isPatching`: Whether the resource is currently being patched
- `patchedData`: The patched resource, available once patch succeeds
- `patch`: Function to invoke a new patch request
- `patchError`: The error object, if the patch failed
- `DELETE`
- `isDeleting`: Whether the resource is currently being deleted
- `deletedData`: The deleted resource, available once delete succeeds
- `delete`: Function to invoke a new delete request
- `deleteError`: The error object, if the delete failed
212 changes: 212 additions & 0 deletions components/higher-order/with-api-data/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* External dependencies
*/
import { mapValues, reduce, forEach, noop } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from 'element';

/**
* Internal dependencies
*/
import request from './request';
import { getRoute } from './routes';

export default ( mapPropsToData ) => ( WrappedComponent ) => {
class APIDataComponent extends Component {
constructor( props, context ) {
super( ...arguments );

this.state = {
dataProps: {},
};

this.schema = context.getAPISchema();
this.routeHelpers = mapValues( {
type: context.getAPIPostTypeRestBaseMapping(),
taxonomy: context.getAPITaxonomyRestBaseMapping(),
}, ( mapping ) => ( key ) => mapping[ key ] );
}

componentWillMount() {
this.isStillMounted = true;
this.applyMapping( this.props );
}

componentDidMount() {
this.initializeFetchable( {} );
}

componentWillReceiveProps( nextProps ) {
this.applyMapping( nextProps );
}

componentDidUpdate( prevProps, prevState ) {
this.initializeFetchable( prevState.dataProps );
}

componentWillUnmount() {
this.isStillMounted = false;
}

initializeFetchable( prevDataProps ) {
const { dataProps } = this.state;

// Trigger first fetch on initial entries into state. Assumes GET
// request by presence of isLoading flag.
forEach( dataProps, ( dataProp, propName ) => {
if ( prevDataProps.hasOwnProperty( propName ) ) {
return;
}

if ( this.getPendingKey( 'GET' ) in dataProp ) {
dataProp[ this.getRequestKey( 'GET' ) ]();
}
} );
}

setIntoDataProp( propName, values ) {
if ( ! this.isStillMounted ) {
return;
}

this.setState( ( prevState ) => {
const { dataProps } = prevState;
return {
dataProps: {
...dataProps,
[ propName ]: {
...dataProps[ propName ],
...values,
},
},
};
} );
}

getRequestKey( method ) {
switch ( method ) {
case 'GET': return 'get';
case 'POST': return 'create';
case 'PUT': return 'save';
case 'PATCH': return 'patch';
case 'DELETE': return 'delete';
}
}

getPendingKey( method ) {
switch ( method ) {
case 'GET': return 'isLoading';
case 'POST': return 'isCreating';
case 'PUT': return 'isSaving';
case 'PATCH': return 'isPatching';
case 'DELETE': return 'isDeleting';
}
}

getResponseDataKey( method ) {
switch ( method ) {
case 'GET': return 'data';
case 'POST': return 'createdData';
case 'PUT': return 'savedData';
case 'PATCH': return 'patchedData';
case 'DELETE': return 'deletedData';
}
}

getErrorResponseKey( method ) {
switch ( method ) {
case 'GET': return 'error';
case 'POST': return 'createError';
case 'PUT': return 'saveError';
case 'PATCH': return 'patchError';
case 'DELETE': return 'deleteError';
}
}

request( propName, method, path ) {
this.setIntoDataProp( propName, {
[ this.getPendingKey( method ) ]: true,
} );

request( { path, method } ).then( ( data ) => {
this.setIntoDataProp( propName, {
[ this.getPendingKey( method ) ]: false,
[ this.getResponseDataKey( method ) ]: data,
} );
} ).catch( ( error ) => {
this.setIntoDataProp( propName, {
[ this.getErrorResponseKey( method ) ]: error,
} );
} );
}

applyMapping( props ) {
const { dataProps } = this.state;

const mapping = mapPropsToData( props, this.routeHelpers );
const nextDataProps = reduce( mapping, ( result, path, propName ) => {
// Skip if mapping already assigned into state data props
// Exmaple: Component updates with one new prop and other
// previously existing; previously existing should not be
// clobbered or re-trigger fetch
const dataProp = dataProps[ propName ];
if ( dataProp && dataProp.path === path ) {
result[ propName ] = dataProp;
return result;
}

const route = getRoute( this.schema, path );
if ( ! route ) {
return result;
}

result[ propName ] = route.methods.reduce( ( stateValue, method ) => {
// Add request initiater into data props
const requestKey = this.getRequestKey( method );
stateValue[ requestKey ] = this.request.bind(
this,
propName,
method,
path
);

// Initialize pending flags as explicitly false
const pendingKey = this.getPendingKey( method );
stateValue[ pendingKey ] = false;

// Track path for future map skipping
stateValue.path = path;

return stateValue;
}, {} );

return result;
}, {} );

this.setState( () => ( { dataProps: nextDataProps } ) );
}

render() {
return (
<WrappedComponent
{ ...this.props }
{ ...this.state.dataProps } />
);
}
}

// Derive display name from original component
const { displayName = WrappedComponent.name || 'Component' } = WrappedComponent;
APIDataComponent.displayName = `apiData(${ displayName })`;

APIDataComponent.contextTypes = {
getAPISchema: noop,
getAPIPostTypeRestBaseMapping: noop,
getAPITaxonomyRestBaseMapping: noop,
};

return APIDataComponent;
};
29 changes: 29 additions & 0 deletions components/higher-order/with-api-data/provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { mapValues, noop } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from 'element';

export default class APIProvider extends Component {
getChildContext() {
return mapValues( {
getAPISchema: 'schema',
getAPIPostTypeRestBaseMapping: 'postTypeRestBaseMapping',
getAPITaxonomyRestBaseMapping: 'taxonomyRestBaseMapping',
}, ( key ) => () => this.props[ key ] );
}

render() {
return this.props.children;
}
}

APIProvider.childContextTypes = {
getAPISchema: noop,
getAPIPostTypeRestBaseMapping: noop,
getAPITaxonomyRestBaseMapping: noop,
};
65 changes: 65 additions & 0 deletions components/higher-order/with-api-data/request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import memoize from 'memize';

export const cache = {};

export const getStablePath = memoize( ( path ) => {
const [ base, query ] = path.split( '?' );
if ( ! query ) {
return base;
}

return base + '?' + query
// 'b=1&c=2&a=5'

.split( '&' )
// [ 'b=1', 'c=2', 'a=5' ]

.map( ( entry ) => entry.split( '=' ) )
// [ [ 'b, '1' ], [ 'c', '2' ], [ 'a', '5' ] ]

.sort( ( a, b ) => a[ 0 ].localeCompare( b[ 0 ] ) )
// [ [ 'a', '5' ], [ 'b, '1' ], [ 'c', '2' ] ]

.map( ( pair ) => pair.join( '=' ) )
// [ 'a=5', 'b=1', 'c=2' ]

.join( '&' );
// 'a=5&b=1&c=2'
} );

export function getResponseFromCache( request ) {
const response = cache[ getStablePath( request.path ) ];
return Promise.resolve( response );
}

export function getResponseFromNetwork( request ) {
const promise = wp.apiRequest( request ).promise();

if ( isRequestMethod( request, 'GET' ) ) {
promise.then( ( data ) => {
cache[ getStablePath( request.path ) ] = data;
} );
}

return promise;
}

export function isRequestMethod( request, method ) {
return request.method === method;
}

export default function( request ) {
if ( ! isRequestMethod( request, 'GET' ) ) {
return getResponseFromNetwork( request );
}

return getResponseFromCache( request )
.then( ( response ) => (
undefined === response
? getResponseFromNetwork( request )
: response
) );
}
Loading