Skip to content

Commit

Permalink
Remove the API request from the embed block, use withSelect instead
Browse files Browse the repository at this point in the history
  • Loading branch information
notnownikki committed May 10, 2018
1 parent a528611 commit 9e1e74d
Show file tree
Hide file tree
Showing 11 changed files with 306 additions and 56 deletions.
137 changes: 81 additions & 56 deletions core-blocks/embed/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
*/
import { parse } from 'url';
import { includes, kebabCase, toLower } from 'lodash';
import { stringify } from 'querystring';
import memoize from 'memize';
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component, Fragment, renderToString } from '@wordpress/element';
import { Component, compose, Fragment, renderToString } from '@wordpress/element';
import { Button, Placeholder, Spinner, SandBox } from '@wordpress/components';
import { createBlock } from '@wordpress/blocks';
import { withSelect } from '@wordpress/data';
import {
BlockControls,
BlockAlignmentToolbar,
Expand All @@ -29,9 +28,6 @@ import './editor.scss';
// These embeds do not work in sandboxes
const HOSTS_NO_PREVIEWS = [ 'facebook.com' ];

// Caches the embed API calls, so if blocks get transformed, or deleted and added again, we don't spam the API.
const wpEmbedAPI = memoize( ( url ) => wp.apiRequest( { path: `/oembed/1.0/proxy?${ stringify( { url } ) }` } ) );

const matchesPatterns = ( url, patterns = [] ) => {
return patterns.some( ( pattern ) => {
return url.match( pattern );
Expand Down Expand Up @@ -86,43 +82,85 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
}
},

edit: class extends Component {
edit: compose(
withSelect( ( select, ownProps ) => {
const { url } = ownProps.attributes;
// Preview is undefined if we don't know the status of it, false if it failed,
// otherwise it will be an object containing the embed response.
const preview = url ? select( 'core/blocks' ).getPreview( url ) : undefined;
return {
preview,
};
} )
)( class extends Component {
constructor() {
super( ...arguments );

this.doServerSideRender = this.doServerSideRender.bind( this );

this.setUrl = this.setUrl.bind( this );
this.processPreview = this.processPreview.bind( this );
this.state = {
html: '',
type: '',
error: false,
fetching: false,
providerName: '',
url: '',
};
}

componentWillMount() {
this.doServerSideRender();
if ( this.props.attributes.url ) {
// Loading from a saved block, set the state up and display the fetching UI.
this.setState( { fetching: true, url: this.props.attributes.url } );
if ( this.props.preview ) {
// Preview must have already been fetched prior to loading this block, so process it.
this.processPreview( this.props.preview, this.props.attributes.url );
}
}
}

componentWillUnmount() {
// can't abort the fetch promise, so let it know we will unmount
this.unmounting = true;
}

componentWillReceiveProps( nextProps ) {
const hasPreview = undefined !== nextProps.preview;
if ( hasPreview ) {
this.processPreview( nextProps.preview, nextProps.attributes.url );
}
}

getPhotoHtml( photo ) {
// 100% width for the preview so it fits nicely into the document, some "thumbnails" are
// acually the full size photo.
const photoPreview = <p><img src={ photo.thumbnail_url } alt={ photo.title } width="100%" /></p>;
return renderToString( photoPreview );
}

doServerSideRender( event ) {
setUrl( event ) {
if ( event ) {
event.preventDefault();
}
const { url } = this.props.attributes;
const { url } = this.state;
const { setAttributes } = this.props;
if ( url === this.props.attributes.url ) {
// Don't change anything, otherwise we go into the 'fetching' state but never
// get new props, because the url has not changed.
return;
}
setAttributes( { url } );
this.setState( { fetching: true, error: false } );
}

processPreview( obj, url ) {
const { setAttributes } = this.props;

if ( false === obj ) {
// If the preview is false (not falsey, but actually false) then the embed request failed,
// so we cannot embed it.
this.setState( { fetching: false, error: true } );
return;
}

if ( undefined === url ) {
return;
Expand All @@ -140,49 +178,36 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
}
}

this.setState( { error: false, fetching: true } );
wpEmbedAPI( url )
.then(
( obj ) => {
if ( this.unmounting ) {
return;
}
// Some plugins only return HTML with no type info, so default this to 'rich'.
let { type = 'rich' } = obj;
// If we got a provider name from the API, use it for the slug, otherwise we use the title,
// because not all embed code gives us a provider name.
const { html, provider_name: providerName } = obj;
const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) );
// This indicates it's a WordPress embed, there aren't a set of URL patterns we can use to match WordPress URLs.
if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) {
type = 'wp-embed';
// If this is not the WordPress embed block, transform it into one.
if ( this.props.name !== 'core-embed/wordpress' ) {
this.props.onReplace( createBlock( 'core-embed/wordpress', { url } ) );
return;
}
}
if ( html ) {
this.setState( { html, type, providerNameSlug } );
setAttributes( { type, providerNameSlug } );
} else if ( 'photo' === type ) {
this.setState( { html: this.getPhotoHtml( obj ), type, providerNameSlug } );
setAttributes( { type, providerNameSlug } );
} else {
// No html, no custom type that we support, so show the error state.
this.setState( { error: true } );
}
this.setState( { fetching: false } );
},
() => {
this.setState( { fetching: false, error: true } );
}
);
// Some plugins only return HTML with no type info, so default this to 'rich'.
let { type = 'rich' } = obj;
// If we got a provider name from the API, use it for the slug, otherwise we use the title,
// because not all embed code gives us a provider name.
const { html, provider_name: providerName } = obj;
const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) );

// This indicates it's a WordPress embed, there aren't a set of URL patterns we can use to match WordPress URLs.
if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) {
type = 'wp-embed';
// If this is not the WordPress embed block, transform it into one.
if ( this.props.name !== 'core-embed/wordpress' ) {
this.props.onReplace( createBlock( 'core-embed/wordpress', { url } ) );
return;
}
}

if ( html ) {
this.setState( { html, type, providerNameSlug } );
setAttributes( { type, providerNameSlug } );
} else if ( 'photo' === type ) {
this.setState( { html: this.getPhotoHtml( obj ), type, providerNameSlug } );
setAttributes( { type, providerNameSlug } );
}
this.setState( { fetching: false } );
}

render() {
const { html, type, error, fetching } = this.state;
const { align, url, caption } = this.props.attributes;
const { html, type, error, fetching, url } = this.state;
const { align, caption } = this.props.attributes;
const { setAttributes, isSelected, className } = this.props;
const updateAlignment = ( nextAlign ) => setAttributes( { align: nextAlign } );

Expand Down Expand Up @@ -214,14 +239,14 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
<Fragment>
{ controls }
<Placeholder icon={ icon } label={ label } className="wp-block-embed">
<form onSubmit={ this.doServerSideRender }>
<form onSubmit={ this.setUrl }>
<input
type="url"
value={ url || '' }
className="components-placeholder__input"
aria-label={ label }
placeholder={ __( 'Enter URL to embed here…' ) }
onChange={ ( event ) => setAttributes( { url: event.target.value } ) } />
onChange={ ( event ) => this.setState( { url: event.target.value } ) } />
<Button
isLarge
type="submit">
Expand Down Expand Up @@ -275,7 +300,7 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
</Fragment>
);
}
},
} ),

save( { attributes } ) {
const { url, caption, align, type, providerNameSlug } = attributes;
Expand Down
1 change: 1 addition & 0 deletions core-blocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { deprecated } from '@wordpress/utils';
* Internal dependencies
*/
import './style.scss';
import './store';
import * as paragraph from './paragraph';
import * as image from './image';
import * as heading from './heading';
Expand Down
16 changes: 16 additions & 0 deletions core-blocks/store/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Returns an action object used in signalling that the preview data for
* a given URl has been received.
*
* @param {string} url URL to preview the embed for.
* @param {Mixed} preview Preview data.
*
* @return {Object} Action object.
*/
export function receivePreview( url, preview ) {
return {
type: 'RECEIVE_EMBED_PREVIEW',
url,
preview,
};
}
21 changes: 21 additions & 0 deletions core-blocks/store/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* WordPress Dependencies
*/
import { registerStore } from '@wordpress/data';

/**
* Internal dependencies
*/
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';

const store = registerStore( 'core/blocks', {
reducer,
actions,
selectors,
resolvers,
} );

export default store;
20 changes: 20 additions & 0 deletions core-blocks/store/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* WordPress dependencies
*/
import { combineReducers } from '@wordpress/data';

export function embedPreviews( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_EMBED_PREVIEW':
const { url, preview } = action;
return {
...state,
[ url ]: preview,
};
}
return state;
}

export default combineReducers( {
embedPreviews,
} );
32 changes: 32 additions & 0 deletions core-blocks/store/resolvers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* WordPress dependencies
*/
import apiRequest from '@wordpress/api-request';

/**
* External dependencies
*/
import { stringify } from 'querystring';

/**
* Internal dependencies
*/
import {
receivePreview,
} from './actions';

/**
* Requests a preview from the from the Embed API.
*
* @param {Object} state State tree
* @param {string} url URL to get the preview for.
*/
export async function* getPreview( state, url ) {
try {
const embedProxyResponse = await apiRequest( { path: `/oembed/1.0/proxy?${ stringify( { url } ) }` } );
yield receivePreview( url, embedProxyResponse );
} catch ( error ) {
// Embed API 404s if the URL cannot be embedded, so we have to catch the error from the apiRequest here.
yield receivePreview( url, false );
}
}
26 changes: 26 additions & 0 deletions core-blocks/store/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Returns the embed for the given URL.
*
* @param {Object} state Data state.
* @param {string} url Embedded URL.
*
* @return {Mixed} Undefined if the preview has not been fetched, false if the URL cannot be embedded, array of embed preview data if the preview has been fetched.
*/
export function getPreview( state, url ) {
const preview = state.embedPreviews[ url ];

if ( ! preview ) {
return preview;
}

// this won't execute any scripts, so it's safe to check if the returned preview is just a single link
const linkCheck = document.createElement( 'div' );
linkCheck.innerHTML = preview.html;

if ( 1 === linkCheck.children.length && 'A' === linkCheck.children[ 0 ].nodeName && 0 === linkCheck.children[ 0 ].children.length ) {
// single link, no children, it's oEmbed being helpful and creating a link for us, not actually embedding content
return false;
}

return preview;
}
30 changes: 30 additions & 0 deletions core-blocks/store/test/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';

/**
* Internal dependencies
*/
import { embedPreviews } from '../reducer';

describe( 'embedPreviews()', () => {
it( 'returns an empty object by default', () => {
const state = embedPreviews( undefined, {} );

expect( state ).toEqual( {} );
} );

it( 'returns with received preview', () => {
const originalState = deepFreeze( {} );
const state = embedPreviews( originalState, {
type: 'RECEIVE_EMBED_PREVIEW',
url: 'http://twitter.com/notnownikki',
preview: { data: 42 },
} );

expect( state ).toEqual( {
'http://twitter.com/notnownikki': { data: 42 },
} );
} );
} );
Loading

0 comments on commit 9e1e74d

Please sign in to comment.