diff --git a/core-blocks/embed/index.js b/core-blocks/embed/index.js
index e30a87125121a..8e5f72ff38869 100644
--- a/core-blocks/embed/index.js
+++ b/core-blocks/embed/index.js
@@ -3,19 +3,18 @@
*/
import { parse } from 'url';
import { includes, kebabCase, toLower } from 'lodash';
-import memoize from 'memize';
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
-import { Component, Fragment, renderToString } from '@wordpress/element';
+import { compose } from '@wordpress/compose';
+import { Component, renderToString } from '@wordpress/element';
import { Button, Placeholder, Spinner, SandBox, IconButton, Toolbar } from '@wordpress/components';
import { createBlock } from '@wordpress/blocks';
import { RichText, BlockControls } from '@wordpress/editor';
-import apiFetch from '@wordpress/api-fetch';
-import { addQueryArgs } from '@wordpress/url';
+import { withSelect } from '@wordpress/data';
/**
* Internal dependencies
@@ -26,11 +25,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 ) =>
- apiFetch( { path: addQueryArgs( '/oembed/1.0/proxy', { url } ) } )
-);
-
const matchesPatterns = ( url, patterns = [] ) => {
return patterns.some( ( pattern ) => {
return url.match( pattern );
@@ -46,6 +40,229 @@ const findBlock = ( url ) => {
return 'core/embed';
};
+export function getEmbedEdit( title, icon ) {
+ return class extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.switchBackToURLInput = this.switchBackToURLInput.bind( this );
+ this.setUrl = this.setUrl.bind( this );
+ this.maybeSwitchBlock = this.maybeSwitchBlock.bind( this );
+ this.setAttributesFromPreview = this.setAttributesFromPreview.bind( this );
+
+ this.state = {
+ editingURL: false,
+ url: this.props.attributes.url,
+ };
+
+ this.maybeSwitchBlock();
+ }
+
+ componentWillUnmount() {
+ // can't abort the fetch promise, so let it know we will unmount
+ this.unmounting = true;
+ }
+
+ componentDidUpdate( prevProps ) {
+ const hasPreview = undefined !== this.props.preview;
+ const hadPreview = undefined !== prevProps.preview;
+ // We had a preview, and the URL was edited, and the new URL already has a preview fetched.
+ const switchedPreview = this.props.preview && this.props.attributes.url !== prevProps.attributes.url;
+ const switchedURL = this.props.attributes.url !== prevProps.attributes.url;
+
+ if ( switchedURL && this.maybeSwitchBlock() ) {
+ return;
+ }
+
+ if ( ( hasPreview && ! hadPreview ) || switchedPreview ) {
+ if ( this.props.previewIsFallback ) {
+ this.setState( { editingURL: true } );
+ return;
+ }
+ this.setAttributesFromPreview();
+ }
+ }
+
+ getPhotoHtml( photo ) {
+ // 100% width for the preview so it fits nicely into the document, some "thumbnails" are
+ // acually the full size photo.
+ const photoPreview =
;
+ return renderToString( photoPreview );
+ }
+
+ setUrl( event ) {
+ if ( event ) {
+ event.preventDefault();
+ }
+ const { url } = this.state;
+ const { setAttributes } = this.props;
+ this.setState( { editingURL: false } );
+ setAttributes( { url } );
+ }
+
+ /***
+ * Maybe switches to a different embed block type, based on the URL
+ * and the HTML in the preview.
+ *
+ * @return {boolean} Whether the block was switched.
+ */
+ maybeSwitchBlock() {
+ const { preview } = this.props;
+ const { url } = this.props.attributes;
+
+ if ( ! url ) {
+ return false;
+ }
+
+ const matchingBlock = findBlock( url );
+
+ // WordPress blocks can work on multiple sites, and so don't have patterns,
+ // so if we're in a WordPress block, assume the user has chosen it for a WordPress URL.
+ if ( 'core-embed/wordpress' !== this.props.name && 'core/embed' !== matchingBlock ) {
+ // At this point, we have discovered a more suitable block for this url, so transform it.
+ if ( this.props.name !== matchingBlock ) {
+ this.props.onReplace( createBlock( matchingBlock, { url } ) );
+ return true;
+ }
+ }
+
+ if ( preview ) {
+ const { html } = preview;
+
+ // 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' ) ) {
+ // 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 true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /***
+ * Sets block attributes based on the preview data.
+ */
+ setAttributesFromPreview() {
+ const { setAttributes, preview } = this.props;
+
+ // Some plugins only return HTML with no type info, so default this to 'rich'.
+ let { type = 'rich' } = preview;
+ // 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 } = preview;
+ const providerNameSlug = kebabCase( toLower( '' !== providerName ? providerName : title ) );
+
+ if ( includes( html, 'class="wp-embedded-content" data-secret' ) ) {
+ type = 'wp-embed';
+ }
+
+ if ( html || 'photo' === type ) {
+ setAttributes( { type, providerNameSlug } );
+ }
+ }
+
+ switchBackToURLInput() {
+ this.setState( { editingURL: true } );
+ }
+
+ render() {
+ const { url, editingURL } = this.state;
+ const { caption, type } = this.props.attributes;
+ const { fetching, setAttributes, isSelected, className, preview, previewIsFallback } = this.props;
+ const controls = (
+
+
+ { ( preview && ! previewIsFallback && ) }
+
+
+ );
+
+ if ( fetching ) {
+ return (
+
+
+
{ __( 'Embedding…' ) }
+
+ );
+ }
+
+ if ( ! preview || previewIsFallback || editingURL ) {
+ // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists
+ const label = sprintf( __( '%s URL' ), title );
+
+ return (
+
+
+
+ );
+ }
+
+ const html = 'photo' === type ? this.getPhotoHtml( preview ) : preview.html;
+ const parsedUrl = parse( url );
+ const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) );
+ // translators: %s: host providing embed content e.g: www.youtube.com
+ const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host );
+ const embedWrapper = 'wp-embed' === type ? (
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+ );
+ }
+ };
+}
+
function getEmbedBlockSettings( { title, description, icon, category = 'embed', transforms, keywords = [] } ) {
// translators: %s: Name of service (e.g. VideoPress, YouTube)
const blockDescription = description || sprintf( __( 'Add a block that displays content pulled from other sites, like Twitter, Instagram or YouTube.' ), title );
@@ -79,199 +296,21 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed',
transforms,
- edit: class extends Component {
- constructor() {
- super( ...arguments );
-
- this.doServerSideRender = this.doServerSideRender.bind( this );
- this.switchBackToURLInput = this.switchBackToURLInput.bind( this );
-
- this.state = {
- html: '',
- type: '',
- error: false,
- fetching: false,
- providerName: '',
+ edit: compose(
+ withSelect( ( select, ownProps ) => {
+ const { url } = ownProps.attributes;
+ const core = select( 'core' );
+ const { getEmbedPreview, isPreviewEmbedFallback, isRequestingEmbedPreview } = core;
+ const preview = getEmbedPreview( url );
+ const previewIsFallback = isPreviewEmbedFallback( url );
+ const fetching = undefined !== url && isRequestingEmbedPreview( url );
+ return {
+ preview,
+ previewIsFallback,
+ fetching,
};
- }
-
- componentDidMount() {
- this.doServerSideRender();
- }
-
- componentWillUnmount() {
- // can't abort the fetch promise, so let it know we will unmount
- this.unmounting = true;
- }
-
- getPhotoHtml( photo ) {
- // 100% width for the preview so it fits nicely into the document, some "thumbnails" are
- // acually the full size photo.
- const photoPreview = ;
- return renderToString( photoPreview );
- }
-
- doServerSideRender( event ) {
- if ( event ) {
- event.preventDefault();
- }
- const { url } = this.props.attributes;
- const { setAttributes } = this.props;
-
- if ( undefined === url ) {
- return;
- }
-
- const matchingBlock = findBlock( url );
-
- // WordPress blocks can work on multiple sites, and so don't have patterns,
- // so if we're in a WordPress block, assume the user has chosen it for a WordPress URL.
- if ( 'core-embed/wordpress' !== this.props.name && 'core/embed' !== matchingBlock ) {
- // At this point, we have discovered a more suitable block for this url, so transform it.
- if ( this.props.name !== matchingBlock ) {
- this.props.onReplace( createBlock( matchingBlock, { url } ) );
- return;
- }
- }
-
- 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 } );
- }
- );
- }
-
- switchBackToURLInput() {
- this.setState( { html: undefined } );
- }
-
- render() {
- const { html, type, error, fetching } = this.state;
- const { url, caption } = this.props.attributes;
- const { setAttributes, isSelected, className } = this.props;
- const controls = (
-
-
- { ( html && ) }
-
-
- );
-
- if ( fetching ) {
- return (
-
-
-
{ __( 'Embedding…' ) }
-
- );
- }
-
- if ( ! html ) {
- // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists
- const label = sprintf( __( '%s URL' ), title );
-
- return (
-
-
-
- );
- }
-
- const parsedUrl = parse( url );
- const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) );
- // translators: %s: host providing embed content e.g: www.youtube.com
- const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host );
- const embedWrapper = 'wp-embed' === type ? (
-
- ) : (
-
-
-
- );
-
- return (
-
- { controls }
-
-
- );
- }
- },
+ } )
+ )( getEmbedEdit( title, icon ) ),
save( { attributes } ) {
const { url, caption, type, providerNameSlug } = attributes;
diff --git a/core-blocks/embed/test/index.js b/core-blocks/embed/test/index.js
index 0b8a1985b06aa..2ca6d72649755 100644
--- a/core-blocks/embed/test/index.js
+++ b/core-blocks/embed/test/index.js
@@ -1,12 +1,17 @@
+/**
+ * External dependencies
+ */
+import { render } from 'enzyme';
+
/**
* Internal dependencies
*/
-import { name, settings } from '../';
-import { blockEditRender } from '../../test/helpers';
+import { getEmbedEdit } from '../';
describe( 'core/embed', () => {
test( 'block edit matches snapshot', () => {
- const wrapper = blockEditRender( name, settings );
+ const EmbedEdit = getEmbedEdit( 'Embed', 'embed-generic' );
+ const wrapper = render( );
expect( wrapper ).toMatchSnapshot();
} );
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index 3353f48865940..06afd03354f97 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -96,3 +96,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,
+ };
+}
diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js
index cc02fe1d47019..9a8d6a37d5cf0 100644
--- a/packages/core-data/src/reducer.js
+++ b/packages/core-data/src/reducer.js
@@ -197,10 +197,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,
} );
diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js
index d1594daf60c60..7d939de0fac4a 100644
--- a/packages/core-data/src/resolvers.js
+++ b/packages/core-data/src/resolvers.js
@@ -17,6 +17,7 @@ import {
receiveUserQuery,
receiveEntityRecords,
receiveThemeSupportsFromIndex,
+ receiveEmbedPreview,
} from './actions';
import { getKindEntities } from './entities';
@@ -84,3 +85,19 @@ export async function* getThemeSupports() {
const index = await apiFetch( { 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 apiFetch( { path: addQueryArgs( '/oembed/1.0/proxy', { 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 );
+ }
+}
diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js
index d01ea3d3b9c7e..1e0fd66f63f11 100644
--- a/packages/core-data/src/selectors.js
+++ b/packages/core-data/src/selectors.js
@@ -25,7 +25,7 @@ import { getQueriedItems } from './queried-data';
* @return {boolean} Whether resolution is in progress.
*/
function isResolving( selectorName, ...args ) {
- return select( 'core/data' ).isResolving( REDUCER_KEY, selectorName, ...args );
+ return select( 'core/data' ).isResolving( REDUCER_KEY, selectorName, args );
}
/**
@@ -76,6 +76,19 @@ export function isRequestingCategories() {
return isResolving( 'getCategories' );
}
+/**
+ * Returns true if a request is in progress for embed preview data, or false
+ * otherwise.
+ *
+ * @param {Object} state Data state.
+ * @param {string} url URL the preview would be for.
+ *
+ * @return {boolean} Whether a request is in progress for an embed preview.
+ */
+export function isRequestingEmbedPreview( state, url ) {
+ return isResolving( 'getEmbedPreview', url );
+}
+
/**
* Returns all available authors.
*
@@ -171,3 +184,36 @@ export function getEntityRecords( state, kind, name, query ) {
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, otherwise, the preview fetched from the embed preview API.
+ */
+export function getEmbedPreview( state, url ) {
+ return state.embedPreviews[ url ];
+}
+
+/**
+ * Determines if the returned preview is an oEmbed link fallback.
+ *
+ * WordPress can be configured to return a simple link to a URL if it is not embeddable.
+ * We need to be able to determine if a URL is embeddable or not, based on what we
+ * get back from the oEmbed preview API.
+ *
+ * @param {Object} state Data state.
+ * @param {string} url Embedded URL.
+ *
+ * @return {booleans} Is the preview for the URL an oEmbed link fallback.
+ */
+export function isPreviewEmbedFallback( state, url ) {
+ const preview = state.embedPreviews[ url ];
+ const oEmbedLinkCheck = '' + url + '';
+ if ( ! preview ) {
+ return false;
+ }
+ return preview.html === oEmbedLinkCheck;
+}
diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js
index f25462746f7ee..90f9b21f1a8cf 100644
--- a/packages/core-data/src/test/reducer.js
+++ b/packages/core-data/src/test/reducer.js
@@ -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', () => {
@@ -96,3 +96,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 },
+ } );
+ } );
+} );
diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js
index ee5d242255d22..581830a04f3f2 100644
--- a/packages/core-data/src/test/resolvers.js
+++ b/packages/core-data/src/test/resolvers.js
@@ -3,11 +3,16 @@
*/
import apiFetch from '@wordpress/api-fetch';
+/**
+ * External dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+
/**
* Internal dependencies
*/
-import { getCategories, getEntityRecord, getEntityRecords } from '../resolvers';
-import { receiveTerms, receiveEntityRecords, addEntities } from '../actions';
+import { getCategories, getEntityRecord, getEntityRecords, getEmbedPreview } from '../resolvers';
+import { receiveTerms, receiveEntityRecords, addEntities, receiveEmbedPreview } from '../actions';
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
@@ -105,3 +110,31 @@ describe( 'getEntityRecords', () => {
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ), {} ) );
} );
} );
+
+describe( 'getEmbedPreview', () => {
+ const SUCCESSFUL_EMBED_RESPONSE = { data: 'some html
' };
+ const UNEMBEDDABLE_RESPONSE = false;
+ const EMBEDDABLE_URL = 'http://twitter.com/notnownikki';
+ const UNEMBEDDABLE_URL = 'http://example.com/';
+
+ beforeAll( () => {
+ apiFetch.mockImplementation( ( options ) => {
+ if ( options.path === addQueryArgs( '/oembed/1.0/proxy', { url: EMBEDDABLE_URL } ) ) {
+ return Promise.resolve( SUCCESSFUL_EMBED_RESPONSE );
+ }
+ throw 404;
+ } );
+ } );
+
+ it( 'yields with fetched embed preview', async () => {
+ const fulfillment = getEmbedPreview( {}, EMBEDDABLE_URL );
+ const received = ( await fulfillment.next() ).value;
+ expect( received ).toEqual( receiveEmbedPreview( EMBEDDABLE_URL, SUCCESSFUL_EMBED_RESPONSE ) );
+ } );
+
+ it( 'yields false if the URL cannot be embedded', async () => {
+ const fulfillment = getEmbedPreview( {}, UNEMBEDDABLE_URL );
+ const received = ( await fulfillment.next() ).value;
+ expect( received ).toEqual( receiveEmbedPreview( UNEMBEDDABLE_URL, UNEMBEDDABLE_RESPONSE ) );
+ } );
+} );
diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js
index a2080f3a4c1bd..d06fdc1fc9c3a 100644
--- a/packages/core-data/src/test/selectors.js
+++ b/packages/core-data/src/test/selectors.js
@@ -6,7 +6,14 @@ import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
-import { getTerms, isRequestingCategories, getEntityRecord, getEntityRecords } from '../selectors';
+import {
+ getTerms,
+ isRequestingCategories,
+ getEntityRecord,
+ getEntityRecords,
+ getEmbedPreview,
+ isPreviewEmbedFallback,
+} from '../selectors';
import { select } from '@wordpress/data';
jest.mock( '@wordpress/data', () => {
@@ -144,3 +151,29 @@ describe( 'getEntityRecords', () => {
} );
} );
+describe( 'getEmbedPreview()', () => {
+ it( 'returns preview stored for url', () => {
+ let state = deepFreeze( {
+ embedPreviews: {},
+ } );
+ expect( getEmbedPreview( state, 'http://example.com/' ) ).toBe( undefined );
+
+ state = deepFreeze( {
+ embedPreviews: {
+ 'http://example.com/': { data: 42 },
+ },
+ } );
+ expect( getEmbedPreview( state, 'http://example.com/' ) ).toEqual( { data: 42 } );
+ } );
+} );
+
+describe( 'isPreviewEmbedFallback()', () => {
+ it( 'returns true if the preview html is just a single link', () => {
+ const state = deepFreeze( {
+ embedPreviews: {
+ 'http://example.com/': { html: 'http://example.com/' },
+ },
+ } );
+ expect( isPreviewEmbedFallback( state, 'http://example.com/' ) ).toEqual( true );
+ } );
+} );