diff --git a/components/higher-order/with-api-data/README.md b/components/higher-order/with-api-data/README.md
new file mode 100644
index 0000000000000..c51f88b212dac
--- /dev/null
+++ b/components/higher-order/with-api-data/README.md
@@ -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
Loading...
;
+ }
+
+ return { post.data.title.rendered }
;
+}
+
+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
diff --git a/components/higher-order/with-api-data/index.js b/components/higher-order/with-api-data/index.js
new file mode 100644
index 0000000000000..979750f4fda01
--- /dev/null
+++ b/components/higher-order/with-api-data/index.js
@@ -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 (
+
+ );
+ }
+ }
+
+ // 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;
+};
diff --git a/components/higher-order/with-api-data/provider.js b/components/higher-order/with-api-data/provider.js
new file mode 100644
index 0000000000000..ba43a7f4b59ae
--- /dev/null
+++ b/components/higher-order/with-api-data/provider.js
@@ -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,
+};
diff --git a/components/higher-order/with-api-data/request.js b/components/higher-order/with-api-data/request.js
new file mode 100644
index 0000000000000..8929d087f7665
--- /dev/null
+++ b/components/higher-order/with-api-data/request.js
@@ -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
+ ) );
+}
diff --git a/components/higher-order/with-api-data/routes.js b/components/higher-order/with-api-data/routes.js
new file mode 100644
index 0000000000000..752cb54ad68f2
--- /dev/null
+++ b/components/higher-order/with-api-data/routes.js
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { find } from 'lodash';
+import createSelector from 'rememo';
+
+/**
+ * Match PCRE named subpatterns in a string. This implementation is not strict
+ * on balanced delimiters, but assumes this would be a broken pattern anyways.
+ *
+ * See: http://www.pcre.org/original/doc/html/pcrepattern.html#SEC16
+ * See: http://php.net/manual/en/function.preg-match.php
+ *
+ * @type {RegExp}
+ */
+const RE_NAMED_SUBPATTERN = /\(\?P?[<']\w+[>'](.*?)\)/g;
+
+/**
+ * Coerces a REST route pattern to an equivalent JavaScript regular expression,
+ * replacing named subpatterns (unsupported in JavaScript), allowing trailing
+ * slash, allowing query parameters, but otherwise enforcing strict equality.
+ *
+ * @param {String} pattern PCRE regular expression string
+ * @return {RegExp} Equivalent JavaScript RegExp
+ */
+export function getNormalizedRegExp( pattern ) {
+ pattern = pattern.replace( RE_NAMED_SUBPATTERN, '($1)' );
+ pattern = '^' + pattern + '/?(\\?.*)?$';
+ return new RegExp( pattern );
+}
+
+/**
+ * Returns true if the route path pattern string matches the given path.
+ *
+ * @param {String} pattern PCRE route path pattern
+ * @param {String} path URL path
+ * @return {Boolean} Whether path is a match
+ */
+export function isRouteMatch( pattern, path ) {
+ return getNormalizedRegExp( pattern ).test( path );
+}
+
+/**
+ * Returns a REST route object for a given path, if one exists.
+ *
+ * @param {Object} schema REST schema
+ * @param {String} path URL path
+ * @return {?Object} REST route
+ */
+export const getRoute = createSelector( ( schema, path ) => {
+ return find( schema.routes, ( route, pattern ) => {
+ return isRouteMatch( pattern, path );
+ } );
+} );
diff --git a/components/higher-order/with-api-data/test/index.js b/components/higher-order/with-api-data/test/index.js
new file mode 100644
index 0000000000000..0738bd045c4e0
--- /dev/null
+++ b/components/higher-order/with-api-data/test/index.js
@@ -0,0 +1,146 @@
+/**
+ * External dependencies
+ */
+import { shallow } from 'enzyme';
+import { identity, fromPairs } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import withAPIData from '../';
+
+jest.mock( '../request', () => jest.fn( () => Promise.resolve( {} ) ) );
+
+describe( 'withAPIData()', () => {
+ const schema = {
+ routes: {
+ '/wp/v2/pages/(?P[\\d]+)/revisions': {
+ methods: [ 'GET' ],
+ },
+ '/wp/v2/pages/(?P[\\d]+)': {
+ methods: [
+ 'GET',
+ 'POST',
+ 'PUT',
+ 'PATCH',
+ 'DELETE',
+ ],
+ },
+ },
+ };
+
+ function getWrapper( mapPropsToData, props ) {
+ if ( ! mapPropsToData ) {
+ mapPropsToData = () => ( {
+ revisions: '/wp/v2/pages/5/revisions',
+ } );
+ }
+
+ const Component = withAPIData( mapPropsToData )( () => '' );
+
+ return shallow( , {
+ lifecycleExperimental: true,
+ context: {
+ getAPISchema: () => schema,
+ getAPIPostTypeRestBaseMapping: identity,
+ getAPITaxonomyRestBaseMapping: identity,
+ },
+ } );
+ }
+
+ it( 'should initialize fetchables on mount', ( done ) => {
+ const wrapper = getWrapper();
+
+ const dataProps = wrapper.state( 'dataProps' );
+ expect( Object.keys( dataProps ) ).toEqual( [ 'revisions' ] );
+ expect( Object.keys( dataProps.revisions ) ).toEqual( [
+ 'get',
+ 'isLoading',
+ 'path',
+ ] );
+ expect( dataProps.revisions.isLoading ).toBe( true );
+
+ process.nextTick( () => {
+ expect( wrapper.state( 'dataProps' ).revisions.isLoading ).toBe( false );
+ expect( wrapper.state( 'dataProps' ).revisions.data ).toEqual( {} );
+
+ done();
+ } );
+ } );
+
+ it( 'should ignore unmatched resources', () => {
+ const wrapper = getWrapper( () => ( {
+ revision: '/wp/v2/pages/5/revisions/10',
+ } ) );
+
+ expect( wrapper.state( 'dataProps' ) ).toEqual( {} );
+ } );
+
+ it( 'should include full gamut of method available properties', () => {
+ const wrapper = getWrapper( () => ( {
+ page: '/wp/v2/pages/5',
+ } ) );
+
+ const dataProps = wrapper.state( 'dataProps' );
+ expect( Object.keys( dataProps ) ).toEqual( [ 'page' ] );
+ expect( Object.keys( dataProps.page ) ).toEqual( [
+ 'get',
+ 'isLoading',
+ 'path',
+ 'create',
+ 'isCreating',
+ 'save',
+ 'isSaving',
+ 'patch',
+ 'isPatching',
+ 'delete',
+ 'isDeleting',
+ ] );
+ expect( dataProps.page.isLoading ).toBe( true );
+ expect( dataProps.page.isCreating ).toBe( false );
+ expect( dataProps.page.isSaving ).toBe( false );
+ expect( dataProps.page.isPatching ).toBe( false );
+ expect( dataProps.page.isDeleting ).toBe( false );
+ } );
+
+ it( 'should not clobber existing data when receiving new props', ( done ) => {
+ const wrapper = getWrapper(
+ ( { pages } ) => fromPairs( pages.map( ( page ) => [
+ 'page' + page,
+ '/wp/v2/pages/' + page,
+ ] ) ),
+ { pages: [ 1 ] }
+ );
+
+ process.nextTick( () => {
+ wrapper.setProps( { pages: [ 1, 2 ] } );
+
+ const dataProps = wrapper.state( 'dataProps' );
+ expect( dataProps.page1.isLoading ).toBe( false );
+ expect( dataProps.page1.data ).toEqual( {} );
+ expect( dataProps.page2.isLoading ).toBe( true );
+
+ done();
+ } );
+ } );
+
+ it( 'should remove dropped mappings', ( done ) => {
+ const wrapper = getWrapper(
+ ( { pages } ) => fromPairs( pages.map( ( page ) => [
+ 'page' + page,
+ '/wp/v2/pages/' + page,
+ ] ) ),
+ { pages: [ 1 ] }
+ );
+
+ process.nextTick( () => {
+ wrapper.setProps( { pages: [ 2 ] } );
+
+ const dataProps = wrapper.state( 'dataProps' );
+ expect( dataProps ).not.toHaveProperty( 'page1' );
+ expect( dataProps ).toHaveProperty( 'page2' );
+
+ done();
+ } );
+ } );
+} );
diff --git a/components/higher-order/with-api-data/test/request.js b/components/higher-order/with-api-data/test/request.js
new file mode 100644
index 0000000000000..8d9ba1dbeb86e
--- /dev/null
+++ b/components/higher-order/with-api-data/test/request.js
@@ -0,0 +1,120 @@
+/**
+ * Internal dependencies
+ */
+import request, {
+ cache,
+ getStablePath,
+ getResponseFromCache,
+ getResponseFromNetwork,
+ isRequestMethod,
+} from '../request';
+
+describe( 'request', () => {
+ const actualResponse = {};
+
+ let wpApiRequest;
+ beforeEach( () => {
+ getStablePath.clear();
+ for ( const key in cache ) {
+ delete cache[ key ];
+ }
+
+ wpApiRequest = wp.apiRequest;
+ wp.apiRequest = jest.fn( () => ( {
+ promise: () => Promise.resolve( actualResponse ),
+ } ) );
+ } );
+
+ afterEach( () => {
+ wp.apiRequest = wpApiRequest;
+ } );
+
+ describe( 'getResponseFromCache()', () => {
+ it( 'returns response from cache', () => {
+ cache[ getStablePath( '/wp?c=5&a=5&b=5' ) ] = actualResponse;
+ const awaitResponse = getResponseFromCache( {
+ path: '/wp?b=5&c=5&a=5',
+ } );
+
+ expect( awaitResponse ).resolves.toBe( actualResponse );
+ } );
+ } );
+
+ describe( 'getResponseFromNetwork()', () => {
+ it( 'triggers a network request', () => {
+ const awaitResponse = getResponseFromNetwork( {
+ path: '/wp?b=5&c=5&a=5',
+ } );
+
+ return awaitResponse.then( ( data ) => {
+ expect( wp.apiRequest ).toHaveBeenCalled();
+ expect( data ).toBe( actualResponse );
+ } );
+ } );
+ } );
+
+ describe( 'getStablePath()', () => {
+ it( 'should return a path without query arguments', () => {
+ const path = '/wp';
+
+ expect( getStablePath( path ) ).toBe( path );
+ } );
+
+ it( 'should return a path with sorted query arguments', () => {
+ const a = getStablePath( '/wp?c=5&a=5&b=5' );
+ const b = getStablePath( '/wp?b=5&c=5&a=5' );
+
+ expect( a ).toBe( b );
+ } );
+ } );
+
+ describe( 'isRequestMethod()', () => {
+ it( 'returns false if not method', () => {
+ expect( isRequestMethod( { method: 'POST' }, 'GET' ) ).toBe( false );
+ } );
+
+ it( 'returns true if method', () => {
+ expect( isRequestMethod( { method: 'GET' }, 'GET' ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'request()', () => {
+ it( 'should try from cache for GET', () => {
+ cache[ getStablePath( '/wp?c=5&a=5&b=5' ) ] = actualResponse;
+ const awaitResponse = request( {
+ path: '/wp?b=5&c=5&a=5',
+ method: 'GET',
+ } );
+
+ return awaitResponse.then( ( data ) => {
+ expect( wp.apiRequest ).not.toHaveBeenCalled();
+ expect( data ).toBe( actualResponse );
+ } );
+ } );
+
+ it( 'should not try from cache for non-GET', () => {
+ cache[ getStablePath( '/wp?c=5&a=5&b=5' ) ] = actualResponse;
+ const awaitResponse = request( {
+ path: '/wp?b=5&c=5&a=5',
+ method: 'POST',
+ } );
+
+ return awaitResponse.then( ( data ) => {
+ expect( wp.apiRequest ).toHaveBeenCalled();
+ expect( data ).toBe( actualResponse );
+ } );
+ } );
+
+ it( 'should fall back to network', () => {
+ const awaitResponse = request( {
+ path: '/wp?b=5&c=5&a=5',
+ method: 'GET',
+ } );
+
+ return awaitResponse.then( ( data ) => {
+ expect( wp.apiRequest ).toHaveBeenCalled();
+ expect( data ).toBe( actualResponse );
+ } );
+ } );
+ } );
+} );
diff --git a/components/higher-order/with-api-data/test/routes.js b/components/higher-order/with-api-data/test/routes.js
new file mode 100644
index 0000000000000..994575e2035dc
--- /dev/null
+++ b/components/higher-order/with-api-data/test/routes.js
@@ -0,0 +1,84 @@
+/**
+ * Internal dependencies
+ */
+import {
+ isRouteMatch,
+ getRoute,
+ getNormalizedRegExp,
+} from '../routes';
+
+describe( 'routes', () => {
+ describe( 'getNormalizedRegExp()', () => {
+ it( 'should strip named subpatterns', () => {
+ const regexp = getNormalizedRegExp( '(?P[\\d]+)' );
+
+ expect( '5' ).toMatch( regexp );
+ } );
+
+ it( 'should match with trailing slashes', () => {
+ const regexp = getNormalizedRegExp( '/wp' );
+
+ expect( '/wp/' ).toMatch( regexp );
+ } );
+
+ it( 'should match with query parameters slashes', () => {
+ const regexp = getNormalizedRegExp( '/wp' );
+
+ expect( '/wp?ok=true' ).toMatch( regexp );
+ } );
+
+ it( 'should otherwise be strictly equal', () => {
+ const regexp = getNormalizedRegExp( '/wp' );
+
+ expect( '/wp/notok' ).not.toMatch( regexp );
+ } );
+ } );
+
+ describe( 'isRouteMatch()', () => {
+ it( 'should return false for non-match', () => {
+ const isMatch = isRouteMatch(
+ '/wp/v2/pages/(?P[\\d]+)/revisions',
+ '/wp/v2/pages/1/revisions/2'
+ );
+
+ expect( isMatch ).toBe( false );
+ } );
+
+ it( 'should return true for match', () => {
+ const isMatch = isRouteMatch(
+ '/wp/v2/pages/(?P[\\d]+)/revisions/(?P[\\d]+)',
+ '/wp/v2/pages/1/revisions/2'
+ );
+
+ expect( isMatch ).toBe( true );
+ } );
+ } );
+
+ describe( 'getRoute()', () => {
+ const expected = { base: {}, nested: {} };
+ const schema = {
+ routes: {
+ '/wp/v2/pages/(?P[\\d]+)/revisions': expected.base,
+ '/wp/v2/pages/(?P[\\d]+)/revisions/(?P[\\d]+)': expected.nested,
+ },
+ };
+
+ beforeEach( () => {
+ getRoute.clear();
+ } );
+
+ it( 'should match base route with balanced match pattern', () => {
+ const path = '/wp/v2/pages/1/revisions';
+ const route = getRoute( schema, path );
+
+ expect( route ).toBe( expected.base );
+ } );
+
+ it( 'should match nested route with balanced match pattern', () => {
+ const path = '/wp/v2/pages/1/revisions/2';
+ const route = getRoute( schema, path );
+
+ expect( route ).toBe( expected.nested );
+ } );
+ } );
+} );
diff --git a/components/index.js b/components/index.js
index 90ba593b97fdb..d3c1a6ecbd01b 100644
--- a/components/index.js
+++ b/components/index.js
@@ -1,4 +1,5 @@
// Components
+export { default as APIProvider } from './higher-order/with-api-data/provider';
export { default as Button } from './button';
export { default as ClipboardButton } from './clipboard-button';
export { default as Dashicon } from './dashicon';
@@ -26,6 +27,7 @@ export { default as Toolbar } from './toolbar';
export { default as Tooltip } from './tooltip';
// Higher-Order Components
+export { default as withAPIData } from './higher-order/with-api-data';
export { default as withFocusReturn } from './higher-order/with-focus-return';
export { default as withInstanceId } from './higher-order/with-instance-id';
export { default as withSpokenMessages } from './higher-order/with-spoken-messages';
diff --git a/editor/index.js b/editor/index.js
index e10a4d8a67143..4e297aa0d8b6c 100644
--- a/editor/index.js
+++ b/editor/index.js
@@ -4,7 +4,7 @@
import { bindActionCreators } from 'redux';
import { Provider as ReduxProvider } from 'react-redux';
import { Provider as SlotFillProvider } from 'react-slot-fill';
-import { flow } from 'lodash';
+import { flow, pick } from 'lodash';
import moment from 'moment-timezone';
import 'moment-timezone/moment-timezone-utils';
@@ -13,7 +13,7 @@ import 'moment-timezone/moment-timezone-utils';
*/
import { EditableProvider } from '@wordpress/blocks';
import { createElement, render } from '@wordpress/element';
-import { PopoverProvider } from '@wordpress/components';
+import { APIProvider, PopoverProvider } from '@wordpress/components';
import { settings as dateSettings } from '@wordpress/date';
/**
@@ -119,6 +119,22 @@ export function createEditorInstance( id, post, settings ) {
PopoverProvider,
{ target },
],
+
+ // APIProvider
+ //
+ // - context.getAPISchema
+ // - context.getAPIPostTypeRestBaseMapping
+ // - context.getAPITaxonomyRestBaseMapping
+ [
+ APIProvider,
+ {
+ ...wpApiSettings,
+ ...pick( wp.api, [
+ 'postTypeRestBaseMapping',
+ 'taxonomyRestBaseMapping',
+ ] ),
+ },
+ ],
];
const createEditorElement = flow(
diff --git a/editor/sidebar/last-revision/index.js b/editor/sidebar/last-revision/index.js
index bfaf0c89b4727..bc0f0c34960c1 100644
--- a/editor/sidebar/last-revision/index.js
+++ b/editor/sidebar/last-revision/index.js
@@ -2,13 +2,13 @@
* External dependencies
*/
import { connect } from 'react-redux';
+import { flowRight, last } from 'lodash';
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
import { sprintf, _n } from '@wordpress/i18n';
-import { IconButton, PanelBody } from '@wordpress/components';
+import { IconButton, PanelBody, withAPIData } from '@wordpress/components';
/**
* Internal dependencies
@@ -22,104 +22,45 @@ import {
} from '../../selectors';
import { getWPAdminURL } from '../../utils/url';
-class LastRevision extends Component {
- constructor() {
- super( ...arguments );
- this.state = {
- revisions: [],
- loading: false,
- };
- }
-
- componentDidMount() {
- this.fetchRevisions();
- }
-
- componentDidUpdate( prevProps ) {
- if ( prevProps.postId !== this.props.postId ) {
- this.setState( { revisions: [] } );
- }
-
- if (
- ( prevProps.postId !== this.props.postId ) ||
- ( prevProps.isSaving && ! this.props.isSaving )
- ) {
- this.fetchRevisions();
- }
- }
-
- componentWillUnmount() {
- if ( this.fetchMediaRequest ) {
- this.fetchRevisionsRequest.abort();
- }
+function LastRevision( { revisions } ) {
+ const lastRevision = last( revisions.data );
+ if ( ! lastRevision ) {
+ return null;
}
- fetchRevisions() {
- const { isNew, postId, postType } = this.props;
- if ( isNew || ! postId ) {
- this.setState( { loading: false } );
- return;
- }
- this.setState( { loading: true } );
- const Collection = wp.api.getPostTypeRevisionsCollection( postType );
- if ( ! Collection ) {
- return;
- }
- this.fetchRevisionsRequest = new Collection( {}, { parent: postId } ).fetch()
- .done( ( revisions ) => {
- if ( this.props.postId !== postId ) {
- return;
- }
- this.setState( {
- loading: false,
- revisions,
- } );
- } )
- .fail( () => {
- if ( this.props.postId !== postId ) {
- return;
+ return (
+
+
+ {
+ sprintf(
+ _n( '%d Revision', '%d Revisions', revisions.data.length ),
+ revisions.data.length
+ )
}
- this.setState( {
- loading: false,
- } );
- } );
- }
-
- render() {
- const { revisions } = this.state;
-
- if ( ! revisions.length ) {
- return null;
- }
-
- const lastRevision = revisions[ 0 ];
-
- return (
-
-
- {
- sprintf(
- _n( '%d Revision', '%d Revisions', revisions.length ),
- revisions.length
- )
- }
-
-
- );
- }
+
+
+ );
}
-export default connect(
- ( state ) => {
+export default flowRight(
+ connect(
+ ( state ) => {
+ return {
+ isNew: isEditedPostNew( state ),
+ postId: getCurrentPostId( state ),
+ postType: getCurrentPostType( state ),
+ isSaving: isSavingPost( state ),
+ };
+ }
+ ),
+ withAPIData( ( props, { type } ) => {
+ const { postType, postId } = props;
return {
- isNew: isEditedPostNew( state ),
- postId: getCurrentPostId( state ),
- postType: getCurrentPostType( state ),
- isSaving: isSavingPost( state ),
+ revisions: `/wp/v2/${ type( postType ) }/${ postId }/revisions`,
};
- }
+ } )
)( LastRevision );
diff --git a/lib/client-assets.php b/lib/client-assets.php
index 47cdd0c348997..6074277950b38 100644
--- a/lib/client-assets.php
+++ b/lib/client-assets.php
@@ -129,7 +129,7 @@ function gutenberg_register_scripts_and_styles() {
wp_register_script(
'wp-components',
gutenberg_url( 'components/build/index.js' ),
- array( 'wp-element', 'wp-a11y', 'wp-i18n', 'wp-utils' ),
+ array( 'wp-element', 'wp-a11y', 'wp-i18n', 'wp-utils', 'wp-api-request' ),
filemtime( gutenberg_dir_path() . 'components/build/index.js' )
);
wp_register_script(
@@ -230,6 +230,15 @@ function gutenberg_register_vendor_scripts() {
'promise',
'https://unpkg.com/promise-polyfill/promise' . $suffix . '.js'
);
+
+ // TODO: This is only necessary so long as WordPress 4.9 is not yet stable,
+ // since we depend on the newly-introduced wp-api-request script handle.
+ //
+ // See: gutenberg_ensure_wp_api_request (compat.php).
+ gutenberg_register_vendor_script(
+ 'wp-api-request-shim',
+ 'https://rawgit.com/WordPress/wordpress-develop/master/src/wp-includes/js/api-request.js'
+ );
}
/**
diff --git a/lib/compat.php b/lib/compat.php
index a0a9fe8aab7fe..d580452aa14d7 100644
--- a/lib/compat.php
+++ b/lib/compat.php
@@ -87,3 +87,38 @@ function gutenberg_fix_jetpack_freeform_block_conflict() {
);
}
}
+
+/**
+ * Shims wp-api-request for WordPress installations not running 4.9-alpha or
+ * newer.
+ *
+ * @see https://core.trac.wordpress.org/ticket/40919
+ *
+ * @since 0.10.0
+ */
+function gutenberg_ensure_wp_api_request() {
+ if ( wp_script_is( 'wp-api-request', 'registered' ) ||
+ ! wp_script_is( 'wp-api-request-shim', 'registered' ) ) {
+ return;
+ }
+
+ global $wp_scripts;
+
+ // Define script using existing shim. We do this because we must define the
+ // vendor script in client-assets.php, but want to use consistent handle.
+ $shim = $wp_scripts->registered['wp-api-request-shim'];
+ wp_register_script(
+ 'wp-api-request',
+ $shim->src,
+ $shim->deps,
+ $shim->ver
+ );
+
+ // Localize wp-api-request using wp-api handle data (swapped in 4.9-alpha).
+ $wp_api_localized_data = $wp_scripts->get_data( 'wp-api', 'data' );
+ if ( false !== $wp_api_localized_data ) {
+ wp_add_inline_script( 'wp-api-request', $wp_api_localized_data, 'before' );
+ }
+}
+add_action( 'wp_enqueue_scripts', 'gutenberg_ensure_wp_api_request', 20 );
+add_action( 'admin_enqueue_scripts', 'gutenberg_ensure_wp_api_request', 20 );
diff --git a/package.json b/package.json
index 99f5bbcb7c4af..6053a05db558e 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"jed": "^1.1.1",
"js-beautify": "^1.6.12",
"lodash": "^4.17.4",
+ "memize": "^1.0.1",
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"mousetrap": "^1.6.1",