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",