diff --git a/blocks/library/shortcode/block.js b/blocks/library/shortcode/block.js new file mode 100644 index 0000000000000..621ec1213e68d --- /dev/null +++ b/blocks/library/shortcode/block.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { withInstanceId, Dashicon } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ShortcodePreview from './preview'; +import BlockControls from '../../block-controls'; +import PlainText from '../../plain-text'; + +export class Shortcode extends Component { + constructor() { + super(); + this.state = { + preview: false, + }; + } + + render() { + const { preview } = this.state; + const { instanceId, postId, setAttributes, attributes, isSelected } = this.props; + const inputId = `blocks-shortcode-input-${ instanceId }`; + const shortcodeContent = ( attributes.text || '' ).trim(); + + const controls = isSelected && ( + +
+ + +
+
+ ); + + if ( preview ) { + return [ + controls, + , + ]; + } + + return [ + controls, +
+ + setAttributes( { text } ) } + /> + </div>, + ]; + } +} + +export default withInstanceId( Shortcode ); diff --git a/blocks/library/shortcode/index.js b/blocks/library/shortcode/index.js index 4f95bf4acc8bc..18e284b4ae969 100644 --- a/blocks/library/shortcode/index.js +++ b/blocks/library/shortcode/index.js @@ -3,13 +3,12 @@ */ import { RawHTML } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { withInstanceId, Dashicon } from '@wordpress/components'; /** * Internal dependencies */ import './editor.scss'; -import PlainText from '../../plain-text'; +import Shortcode from './block'; export const name = 'core/shortcode'; @@ -60,27 +59,7 @@ export const settings = { html: false, }, - edit: withInstanceId( - ( { attributes, setAttributes, instanceId } ) => { - const inputId = `blocks-shortcode-input-${ instanceId }`; - - return ( - <div className="wp-block-shortcode"> - <label htmlFor={ inputId }> - <Dashicon icon="shortcode" /> - { __( 'Shortcode' ) } - </label> - <PlainText - className="input-control" - id={ inputId } - value={ attributes.text } - placeholder={ __( 'Write shortcode here…' ) } - onChange={ ( text ) => setAttributes( { text } ) } - /> - </div> - ); - } - ), + edit: Shortcode, save( { attributes } ) { return <RawHTML>{ attributes.text }</RawHTML>; diff --git a/blocks/library/shortcode/preview.js b/blocks/library/shortcode/preview.js new file mode 100644 index 0000000000000..28feb4e44300f --- /dev/null +++ b/blocks/library/shortcode/preview.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; + +/** + * WordPress dependencies + */ +import { withAPIData, Spinner, SandBox } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/element'; + +function ShortcodePreview( { response } ) { + if ( response.isLoading || ! response.data ) { + return ( + <div key="loading" className="wp-block-embed is-loading"> + <Spinner /> + <p>{ __( 'Loading...' ) }</p> + </div> + ); + } + + const html = response.data.head_scripts_styles + ' ' + response.data.html + ' ' + response.data.footer_scripts_styles; + return ( + <figure className="wp-block-embed" key="embed"> + <SandBox + html={ html } + title="Preview" + type={ response.data.type } + /> + </figure> + ); +} + +const applyConnect = connect( + ( state ) => { + return { + postId: state.currentPost.id, + }; + }, +); + +const applyWithAPIData = withAPIData( ( props ) => { + const { shortcode, postId } = props; + return { + response: `/gutenberg/v1/shortcodes?shortcode=${ encodeURIComponent( shortcode ) }&postId=${ postId }`, + }; +} ); + +export default compose( [ + applyConnect, + applyWithAPIData, +] )( ShortcodePreview ); diff --git a/blocks/library/shortcode/test/index.js b/blocks/library/shortcode/test/index.js index 5892b6e57a1ad..6c56ce58218f8 100644 --- a/blocks/library/shortcode/test/index.js +++ b/blocks/library/shortcode/test/index.js @@ -7,7 +7,6 @@ import { blockEditRender } from 'blocks/test/helpers'; describe( 'core/shortcode', () => { test( 'block edit matches snapshot', () => { const wrapper = blockEditRender( name, settings ); - expect( wrapper ).toMatchSnapshot(); } ); } ); diff --git a/components/sandbox/index.js b/components/sandbox/index.js index 840bbaa94f721..ffddaa4c115f9 100644 --- a/components/sandbox/index.js +++ b/components/sandbox/index.js @@ -136,12 +136,18 @@ class Sandbox extends Component { body { margin: 0; } + + body.html { + width: 100%; + } + body.video, body.video > div, body.video > div > iframe { width: 100%; height: 100%; } + body > div > * { margin-top: 0 !important; /* has to have !important to override inline styles */ margin-bottom: 0 !important; @@ -157,8 +163,9 @@ class Sandbox extends Component { <style dangerouslySetInnerHTML={ { __html: style } } /> </head> <body data-resizable-iframe-connected="data-resizable-iframe-connected" className={ this.props.type }> - <div dangerouslySetInnerHTML={ { __html: this.props.html } } /> + <div id="content" dangerouslySetInnerHTML={ { __html: this.props.html } } /> <script type="text/javascript" dangerouslySetInnerHTML={ { __html: observeAndResizeJS } } /> + </body> </html> ); diff --git a/lib/class-wp-rest-shortcodes-controller.php b/lib/class-wp-rest-shortcodes-controller.php new file mode 100755 index 0000000000000..f07c01bba511d --- /dev/null +++ b/lib/class-wp-rest-shortcodes-controller.php @@ -0,0 +1,189 @@ +<?php +/** + * Shortcode Blocks REST API: WP_REST_Shortcodes_Controller class + * + * @package gutenberg + * @since 2.0.0 + */ + +/** + * Controller which provides a REST endpoint for Gutenberg to preview shortcode blocks. + * + * @since 2.0.0 + * + * @see WP_REST_Controller + */ +class WP_REST_Shortcodes_Controller extends WP_REST_Controller { + /** + * Constructs the controller. + * + * @since 2.0.0 + * @access public + */ + public function __construct() { + // @codingStandardsIgnoreLine - PHPCS mistakes $this->namespace for the namespace keyword + $this->namespace = 'gutenberg/v1'; + $this->rest_base = 'shortcodes'; + } + + /** + * Registers the necessary REST API routes. + * + * @since 0.10.0 + * @access public + */ + public function register_routes() { + // @codingStandardsIgnoreLine - PHPCS mistakes $this->namespace for the namespace keyword + $namespace = $this->namespace; + + register_rest_route( $namespace, '/' . $this->rest_base, array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_shortcode_output' ), + 'permission_callback' => array( $this, 'get_shortcode_output_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) ); + } + + /** + * Checks if a given request has access to read shortcode blocks. + * + * @since 2.0.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_shortcode_output_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'gutenberg_shortcode_block_cannot_read', __( 'Sorry, you are not allowed to read shortcode blocks as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Filters shortcode content through their hooks. + * + * @since 2.0.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_shortcode_output( $request ) { + global $post; + global $wp_embed; + $head_scripts_styles = ''; + $footer_scripts_styles = ''; + $type = 'html'; + $output = ''; + $args = $request->get_params(); + $post = isset( $args['postId'] ) ? get_post( $args['postId'] ) : null; + $shortcode = isset( $args['shortcode'] ) ? trim( $args['shortcode'] ) : ''; + + // Initialize $data. + $data = array( + 'html' => $output, + 'type' => $type, + 'head_scripts_styles' => $head_scripts_styles, + 'footer_scripts_styles' => $footer_scripts_styles, + ); + + if ( empty( $shortcode ) ) { + $data['html'] = __( 'Enter something to preview', 'gutenberg' ); + return rest_ensure_response( $data ); + } + + if ( ! empty( $post ) ) { + setup_postdata( $post ); + } + + // Since the [embed] shortcode needs to be run earlier than other shortcodes. + if ( has_shortcode( $shortcode, 'embed' ) ) { + $output = $wp_embed->run_shortcode( $shortcode ); + } else { + $output = do_shortcode( $shortcode ); + } + + if ( empty( $output ) ) { + $data['html'] = __( 'Sorry, couldn\'t render a preview', 'gutenberg' ); + return rest_ensure_response( $data ); + } + + ob_start(); + wp_head(); + $head_scripts_styles = ob_get_clean(); + + ob_start(); + wp_footer(); + $footer_scripts_styles = ob_get_clean(); + + // Check if shortcode is returning a video. The video type will be used by the frontend to maintain 16:9 aspect ratio. + if ( has_shortcode( $shortcode, 'video' ) ) { + $type = 'video'; + } elseif ( has_shortcode( $shortcode, 'embed' ) ) { + $embed_request = new WP_REST_Request( 'GET', '/oembed/1.0/proxy' ); + $pattern = get_shortcode_regex(); + if ( preg_match_all( '/' . $pattern . '/s', $shortcode, $matches ) ) { + $embed_request['url'] = $matches[5][0]; + $embed_response = rest_do_request( $embed_request ); + if ( $embed_response->is_error() ) { + $data['html'] = __( 'Sorry, couldn\'t render a preview', 'gutenberg' ); + return rest_ensure_response( $data ); + } + $embed_data = $embed_response->get_data(); + } + $type = $embed_data->type; + } + $data = array( + 'html' => $output, + 'head_scripts_styles' => $head_scripts_styles, + 'footer_scripts_styles' => $footer_scripts_styles, + 'type' => $type, + ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves a shortcode block's schema, conforming to JSON Schema. + * + * @since 0.10.0 + * @access public + * + * @return array Item schema data. + */ + public function get_item_schema() { + return array( + '$schema' => 'http://json-schema.org/schema#', + 'title' => 'shortcode-block', + 'type' => 'object', + 'properties' => array( + 'html' => array( + 'description' => __( 'The block\'s content with shortcodes filtered through hooks.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + ), + 'type' => array( + 'description' => __( 'The filtered content type - video or otherwise', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + ), + 'head_scripts_styles' => array( + 'description' => __( 'Links to style sheets and scripts to render the shortcode', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + ), + 'footer_scripts_styles' => array( + 'description' => __( 'Links to style sheets and scripts to render the shortcode', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + ), + ), + ); + } +} diff --git a/lib/load.php b/lib/load.php index 0da31fddf577c..3cb075dc23cfe 100644 --- a/lib/load.php +++ b/lib/load.php @@ -13,6 +13,7 @@ require dirname( __FILE__ ) . '/class-wp-block-type.php'; require dirname( __FILE__ ) . '/class-wp-block-type-registry.php'; require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; +require dirname( __FILE__ ) . '/class-wp-rest-shortcodes-controller.php'; require dirname( __FILE__ ) . '/blocks.php'; require dirname( __FILE__ ) . '/client-assets.php'; require dirname( __FILE__ ) . '/compat.php'; diff --git a/lib/register.php b/lib/register.php index 600b9a5ad5a85..8ede8f89a6477 100644 --- a/lib/register.php +++ b/lib/register.php @@ -433,6 +433,17 @@ function gutenberg_register_post_types() { } add_action( 'init', 'gutenberg_register_post_types' ); +/** + * Registers the REST API routes needed by the Gutenberg editor. + * + * @since 0.10.0 + */ +function gutenberg_register_rest_routes() { + $controller = new WP_REST_Shortcodes_Controller(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); + /** * Gets revisions details for the selected post. * diff --git a/phpunit/class-rest-shortcodes-controller-test.php b/phpunit/class-rest-shortcodes-controller-test.php new file mode 100644 index 0000000000000..2373366044662 --- /dev/null +++ b/phpunit/class-rest-shortcodes-controller-test.php @@ -0,0 +1,205 @@ +<?php +/** + * Shortcode block preview rendering tests. + * + * @package Gutenberg + */ + +/** + * Tests shortcode block preview rendering. + */ +class REST_Shortcodes_Controller_Test extends WP_Test_REST_Controller_Testcase { + /** + * Fake user ID. + * + * @var int + */ + protected static $user_id; + + /** + * Our fake subscriber's user ID. + * + * @var int + */ + protected static $subscriber_id; + + /** + * Fake post ID. + * + * @var int + */ + protected static $post_id; + + /** + * Create fake data before tests run. + * + * @param WP_UnitTest_Factory $factory Helper that creates fake data. + */ + public static function wpSetUpBeforeClass( $factory ) { + self::$user_id = $factory->user->create( array( + 'role' => 'editor', + ) ); + self::$subscriber_id = $factory->user->create( array( + 'role' => 'subscriber', + ) ); + + self::$post_id = $factory->post->create( array( + 'post_author' => self::$user_id, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Test Post', + 'post_content' => '<p>Hello world!</p>', + ) ); + } + + /** + * Delete fake data after tests run. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + self::delete_user( self::$user_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Check that our routes get set up properly. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/gutenberg/v1/shortcodes', $routes ); + $this->assertCount( 1, $routes['/gutenberg/v1/shortcodes'] ); + } + + /** + * Check that users without permission can't GET the shortcode content. + */ + public function test_get_items_when_not_allowed() { + wp_set_current_user( self::$subscriber_id ); + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/shortcodes' ); + $request->set_query_params( + array( + 'shortcode' => '', + ) + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 'gutenberg_shortcode_block_cannot_read', $data['code'] ); + } + + /** + * Test cases for test_update_item_with_invalid_fields(). + * + * @return array + */ + public function data_get_item_with_field_combinations() { + return array( + array( + array( + 'shortcode' => 'Any Random Text', + ), + array( + 'html' => 'Any Random Text', + 'type' => 'html', + ), + ), + // [caption] default shortcode will also return the default theme style sheet. + array( + array( + 'shortcode' => '[caption]My Caption[/caption]', + ), + array( + 'html' => 'My Caption', + 'type' => 'html', + ), + ), + // Sending an empty string. + array( + array( + 'shortcode' => ' ', + 'postId' => self::$post_id, + ), + array( + 'html' => 'Enter something to preview', + 'type' => 'html', + ), + ), + // Sending invalid shortcode attribute. + array( + array( + 'shortcode' => '[audio ids="1"]', + 'postId' => self::$post_id, + ), + array( + 'html' => 'Sorry, couldn\'t render a preview', + 'type' => 'html', + ), + ), + // Not sending shortcode attribute. + array( + array(), + array( + 'html' => 'Enter something to preview', + 'type' => 'html', + ), + ), + // Sending valid UTF-8. + array( + array( + 'shortcode' => '\xe2\x82\xa1', + 'postId' => self::$post_id, + ), + array( + 'html' => '\xe2\x82\xa1', + 'type' => 'html', + ), + ), + ); + } + + /** + * Check that attributes are validated correctly when we GET shortcode content. + * + * @dataProvider data_get_item_with_field_combinations + */ + public function test_get_item_with_field_combinations( $body_params, $expected_message ) { + wp_set_current_user( self::$user_id ); + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/shortcodes' ); + $request->set_query_params( $body_params ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $expected_message['html'], $data['html'] ); + $this->assertEquals( $expected_message['type'], $data['type'] ); + } + public function test_context_param() { + $this->markTestSkipped( 'Controller doesn\'t implement get_context_param().' ); + } + public function test_create_item() { + $this->markTestSkipped( 'Controller doesn\'t implement create_item().' ); + } + public function test_delete_item() { + $this->markTestSkipped( 'Controller doesn\'t implement delete_item().' ); + } + public function test_prepare_item() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } + public function test_get_item() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } + public function test_get_items() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } + public function test_update_item() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/gutenberg/v1/shortcodes' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertEquals( 4, count( $properties ) ); + $this->assertArrayHasKey( 'html', $properties ); + $this->assertArrayHasKey( 'type', $properties ); + } +}