Skip to content

Commit

Permalink
Move embed API call out of block and into data
Browse files Browse the repository at this point in the history
  • Loading branch information
notnownikki committed Jun 28, 2018
1 parent 41be78b commit 5f26a75
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 60 deletions.
138 changes: 82 additions & 56 deletions core-blocks/embed/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,21 @@
*/
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,
RichText,
} from '@wordpress/editor';
import apiRequest from '@wordpress/api-request';

/**
* Internal dependencies
Expand All @@ -30,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 ) => apiRequest( { path: `/oembed/1.0/proxy?${ stringify( { url } ) }` } ) );

const matchesPatterns = ( url, patterns = [] ) => {
return patterns.some( ( pattern ) => {
return url.match( pattern );
Expand Down Expand Up @@ -87,44 +82,88 @@ 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' ).getEmbedPreview( 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: '',
};
}

componentDidMount() {
this.doServerSideRender();
componentWillMount() {
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 @@ -141,49 +180,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 @@ -216,14 +242,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 @@ -278,7 +304,7 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
</Fragment>
);
}
},
} ),

save( { attributes } ) {
const { url, caption, align, type, providerNameSlug } = attributes;
Expand Down
17 changes: 17 additions & 0 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,20 @@ export function receiveThemeSupportsFromIndex( index ) {
themeSupports: index.theme_supports,
};
}

/**
* 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 receiveEmbedPreview( url, preview ) {
return {
type: 'RECEIVE_EMBED_PREVIEW',
url,
preview,
};
}
21 changes: 21 additions & 0 deletions packages/core-data/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,31 @@ export const entities = ( state = {}, action ) => {
};
};

/**
* Reducer managing embed preview data.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Updated state.
*/
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( {
terms,
users,
taxonomies,
themeSupports,
entities,
embedPreviews,
} );
22 changes: 22 additions & 0 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { find } from 'lodash';
*/
import apiRequest from '@wordpress/api-request';

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

/**
* Internal dependencies
*/
Expand All @@ -16,6 +21,7 @@ import {
receiveUserQuery,
receiveEntityRecords,
receiveThemeSupportsFromIndex,
receiveEmbedPreview,
} from './actions';
import { getKindEntities } from './entities';

Expand Down Expand Up @@ -78,3 +84,19 @@ export async function* getThemeSupports() {
const index = await apiRequest( { path: '/' } );
yield receiveThemeSupportsFromIndex( index );
}

/**
* 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* getEmbedPreview( state, url ) {
try {
const embedProxyResponse = await apiRequest( { path: `/oembed/1.0/proxy?${ stringify( { url } ) }` } );
yield receiveEmbedPreview( 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 receiveEmbedPreview( url, false );
}
}
25 changes: 25 additions & 0 deletions packages/core-data/src/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,28 @@ export const getEntityRecords = createSelector(
export function getThemeSupports( state ) {
return state.themeSupports;
}

/**
* Returns the embed preview for the given URL.
*
* @param {Object} state Data state.
* @param {string} url Embedded URL.
*
* @return {*} 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 getEmbedPreview( state, url ) {
const preview = state.embedPreviews[ url ];

if ( ! preview ) {
return preview;
}

const oEmbedLinkCheck = '<a href="' + url + '">' + url + '</a>';

if ( oEmbedLinkCheck === preview.html ) {
// just a link to the url, it's oEmbed being helpful and creating a link for us, not actually embedding content
return false;
}

return preview;
}
23 changes: 22 additions & 1 deletion packages/core-data/src/test/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { filter } from 'lodash';
/**
* Internal dependencies
*/
import { terms, entities } from '../reducer';
import { terms, entities, embedPreviews } from '../reducer';

describe( 'terms()', () => {
it( 'returns an empty object by default', () => {
Expand Down Expand Up @@ -93,3 +93,24 @@ describe( 'entities', () => {
] );
} );
} );

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 5f26a75

Please sign in to comment.