diff --git a/.travis.yml b/.travis.yml index 96dde5da6..5d71f11b6 100755 --- a/.travis.yml +++ b/.travis.yml @@ -20,14 +20,20 @@ matrix: env: WP_VERSION=latest - php: 7.2 env: WP_VERSION=latest + - php: 7.2 + env: WP_VERSION=5.3 - php: 7.1 env: WP_VERSION=latest + - php: 7.1 + env: WP_VERSION=5.2 - php: 7.0 env: WP_VERSION=latest - - php: 5.6 - env: WP_VERSION=latest + - php: 7.0 + env: WP_VERSION=5.1 - php: 5.6 env: WP_VERSION=trunk + - php: 5.6 + env: WP_VERSION=5.0 - php: 5.6 env: WP_TRAVISCI=lint - php: 7.3 diff --git a/js/blocks/components/edit.js b/js/blocks/components/edit.js index bdf2fd76d..31c222ebb 100644 --- a/js/blocks/components/edit.js +++ b/js/blocks/components/edit.js @@ -1,12 +1,7 @@ -/** - * WordPress dependencies - */ -import ServerSideRender from '@wordpress/server-side-render'; - /** * Internal dependencies */ -import { BlockLabInspector, FormControls } from './'; +import { BlockLabInspector, FormControls, ServerSideRender } from './'; import icons from '../../../assets/icons.json'; /** @@ -38,6 +33,7 @@ const Edit = ( { blockProps, block } ) => { block={ `block-lab/${ block.name }` } attributes={ attributes } className="block-lab-editor__ssr" + requestBody={ true } /> ) } diff --git a/js/blocks/components/index.js b/js/blocks/components/index.js index a464122c4..aa92ff43c 100644 --- a/js/blocks/components/index.js +++ b/js/blocks/components/index.js @@ -6,4 +6,5 @@ export { default as Fields } from './fields'; export { default as FormControls } from './form-controls'; export { default as Image } from './image'; export { default as RepeaterRows } from './repeater-rows'; +export { default as ServerSideRender } from './server-side-render'; export { default as TinyMCE } from './tiny-mce'; diff --git a/js/blocks/components/server-side-render.js b/js/blocks/components/server-side-render.js new file mode 100644 index 000000000..fc24ff13e --- /dev/null +++ b/js/blocks/components/server-side-render.js @@ -0,0 +1,204 @@ +/** + * Forked from Gutenberg, with a minor edit to allow using POST requests instead of GET requests. + * Todo: delete if this is merged: https://github.com/WordPress/gutenberg/pull/21068/ + * + * @see https://github.com/WordPress/gutenberg/blob/c72030189017c8aac44453c1386f4251e45e80df/packages/server-side-render/src/index.js + */ + +/** + * External dependencies + */ +import { isEqual, debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { Placeholder, Spinner } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { Component, RawHTML, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Constants + */ +const EMPTY_OBJECT = {}; + +export function rendererPath( block, attributes = null, urlQueryArgs = {} ) { + return addQueryArgs( `/wp/v2/block-renderer/${ block }`, { + context: 'edit', + ...( null !== attributes ? { attributes } : {} ), + ...urlQueryArgs, + } ); +} + +export class ServerSideRender extends Component { + constructor( props ) { + super( props ); + this.state = { + response: null, + }; + } + + componentDidMount() { + this.isStillMounted = true; + this.fetch( this.props ); + // Only debounce once the initial fetch occurs to ensure that the first + // renders show data as soon as possible. + this.fetch = debounce( this.fetch, 500 ); + } + + componentWillUnmount() { + this.isStillMounted = false; + } + + componentDidUpdate( prevProps ) { + if ( ! isEqual( prevProps, this.props ) ) { + this.fetch( this.props ); + } + } + + fetch( props ) { + if ( ! this.isStillMounted ) { + return; + } + if ( null !== this.state.response ) { + this.setState( { response: null } ); + } + + const { + block, + attributes = null, + requestBody, + urlQueryArgs = {}, + } = props; + + // If requestBody, make a POST request, with the attributes in the request body instead of the URL. + // This allows sending a larger attributes object than in a GET request, where the attributes are in the URL. + const urlAttributes = requestBody ? null : attributes; + const path = rendererPath( block, urlAttributes, urlQueryArgs ); + const method = requestBody ? 'POST' : 'GET'; + const data = requestBody ? attributes : null; + + // Store the latest fetch request so that when we process it, we can + // check if it is the current request, to avoid race conditions on slow networks. + const fetchRequest = ( this.currentFetchRequest = apiFetch( { + path, + method, + data, + } ) + .then( ( response ) => { + if ( + this.isStillMounted && + fetchRequest === this.currentFetchRequest && + response + ) { + this.setState( { response: response.rendered } ); + } + } ) + .catch( ( error ) => { + if ( + this.isStillMounted && + fetchRequest === this.currentFetchRequest + ) { + this.setState( { + response: { + error: true, + errorMsg: error.message, + }, + } ); + } + } ) ); + return fetchRequest; + } + + render() { + const response = this.state.response; + const { + className, + EmptyResponsePlaceholder, + ErrorResponsePlaceholder, + LoadingResponsePlaceholder, + } = this.props; + + if ( response === '' ) { + return ( + + ); + } else if ( ! response ) { + return ( + + ); + } else if ( response.error ) { + return ( + + ); + } + + return ( + + { response } + + ); + } +} + +ServerSideRender.defaultProps = { + EmptyResponsePlaceholder: ( { className } ) => ( + + { __( 'Block rendered as empty.', 'block-lab' ) } + + ), + ErrorResponsePlaceholder: ( { response, className } ) => { + const errorMessage = sprintf( + // translators: %s: error message describing the problem + __( 'Error loading block: %s', 'block-lab' ), + response.errorMsg + ); + return ( + { errorMessage } + ); + }, + LoadingResponsePlaceholder: ( { className } ) => { + return ( + + + + ); + }, +}; + +export default withSelect( ( select ) => { + const coreEditorSelect = select( 'core/editor' ); + if ( coreEditorSelect ) { + const currentPostId = coreEditorSelect.getCurrentPostId(); + if ( currentPostId ) { + return { + currentPostId, + }; + } + } + return EMPTY_OBJECT; +} )( ( { urlQueryArgs = EMPTY_OBJECT, currentPostId, ...props } ) => { + const newUrlQueryArgs = useMemo( () => { + if ( ! currentPostId ) { + return urlQueryArgs; + } + return { + post_id: currentPostId, + ...urlQueryArgs, + }; + }, [ currentPostId, urlQueryArgs ] ); + + return ; +} ); diff --git a/package-lock.json b/package-lock.json index a4bbfde1d..ac1949516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "block-lab", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9a2c7d264..a8be4514c 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@wordpress/i18n": "3.7.0", "@wordpress/keycodes": "2.7.0", "@wordpress/scripts": "6.1.1", - "@wordpress/server-side-render": "1.5.0", "@wordpress/url": "2.9.0", "autoprefixer": "9.7.3", "babel-core": "6.26.3", @@ -58,6 +57,7 @@ "gulp-string-replace": "^1.1.2", "husky": "^4.0.10", "jest-silent-reporter": "0.1.2", + "lodash": "4.17.15", "merge-stream": "^2.0.0", "mini-css-extract-plugin": "0.8.2", "node-sass": "^4.13.1", diff --git a/php/blocks/class-rest.php b/php/blocks/class-rest.php new file mode 100644 index 000000000..fe0be4321 --- /dev/null +++ b/php/blocks/class-rest.php @@ -0,0 +1,93 @@ + $handlers. + * @return array The filtered endpoints, with the Block Lab endpoints allowing POST requests. + */ + public function filter_block_endpoints( $endpoints ) { + foreach ( $endpoints as $route => $handler ) { + if ( 0 === strpos( $route, '/wp/v2/block-renderer/(?Pblock-lab/' ) && isset( $endpoints[ $route ][0] ) ) { + $endpoints[ $route ][0]['methods'] = [ \WP_REST_Server::READABLE, \WP_REST_Server::CREATABLE ]; + $endpoints[ $route ][0]['callback'] = [ $this, 'get_item' ]; + } + } + + return $endpoints; + } + + /** + * Returns block output from block's registered render_callback. + * + * Forked from WP_REST_Block_Renderer_Controller::get_item(), with a simple change to process POST requests. + * + * @todo: revert if this has been merged and enough version of Core have passed: https://github.com/WordPress/wordpress-develop/pull/196/ + * @see https://github.com/WordPress/wordpress-develop/blob/dfa959bbd58f13b504e269aad45412a85f74e491/src/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php#L121 + * + * @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_item( $request ) { + $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; + + if ( 0 < $post_id ) { + $GLOBALS['post'] = get_post( $post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + // Set up postdata since this will be needed if post_id was set. + setup_postdata( $GLOBALS['post'] ); + } + $registry = \WP_Block_Type_Registry::get_instance(); + + if ( null === $registry->get_registered( $request['name'] ) ) { + return new WP_Error( + 'block_invalid', + __( 'Invalid block.', 'block-lab' ), + [ + 'status' => 404, + ] + ); + } + + // In a POST request, the attributes appear as JSON in the request body. + $attributes = \WP_REST_Server::CREATABLE === $request->get_method() ? json_decode( $request->get_body(), true ) : $request->get_param( 'attributes' ); + + // Create an array representation simulating the output of parse_blocks. + $block = [ + 'blockName' => $request['name'], + 'attrs' => $attributes, + 'innerHTML' => '', + 'innerContent' => [], + ]; + + // Render using render_block to ensure all relevant filters are used. + $data = [ + 'rendered' => render_block( $block ), + ]; + + return rest_ensure_response( $data ); + } +} diff --git a/php/class-plugin.php b/php/class-plugin.php index 2ee27d81b..0ca1e99de 100644 --- a/php/class-plugin.php +++ b/php/class-plugin.php @@ -50,6 +50,7 @@ public function init() { $this->util = new Util(); $this->register_component( $this->util ); $this->register_component( new Post_Types\Block_Post() ); + $this->register_component( new Blocks\Rest() ); $this->loader = new Blocks\Loader(); $this->register_component( $this->loader ); diff --git a/tests/php/unit/blocks/test-class-rest.php b/tests/php/unit/blocks/test-class-rest.php new file mode 100644 index 000000000..90e929490 --- /dev/null +++ b/tests/php/unit/blocks/test-class-rest.php @@ -0,0 +1,192 @@ + [ + 'methods' => [ 'GET' ], + 'callback' => [ 'example_callback' ], + ], + ]; + + /** + * Sets up each test. + * + * @inheritdoc + */ + public function setUp() { + parent::setUp(); + $this->instance = new Rest(); + $this->register_mock_block(); + } + + /** + * Tears down after each test. + * + * @inheritdoc + */ + public function tearDown() { + unregister_block_type( $this->mock_block_name ); + parent::tearDown(); + } + + /** + * Registers the mock block. + * + * Mainly taken from REST_Block_Renderer_Controller_Test::register_test_block(). + */ + public function register_mock_block() { + register_block_type( + $this->mock_block_name, + [ + 'attributes' => [ + 'example_string' => [ + 'type' => 'string', + 'default' => 'some_default', + ], + 'example_int' => [ + 'type' => 'integer', + ], + ], + 'render_callback' => function( $attributes ) { + return wp_json_encode( $attributes ); + }, + ] + ); + } + + /** + * Test register_hooks. + * + * @covers \Block_Lab\Blocks\Rest::register_hooks() + */ + public function test_register_hooks() { + $this->instance->register_hooks(); + $filtered_endpoints = apply_filters( 'rest_endpoints', rest_get_server()->get_routes() ); + $block_route = $this->rest_api_route . '(?P' . $this->mock_block_name . ')'; + + $this->assertEquals( 10, has_action( 'rest_endpoints', [ $this->instance, 'filter_block_endpoints' ] ) ); + $this->assertEquals( [ 'GET', 'POST' ], $filtered_endpoints[ $block_route ][0]['methods'] ); + $this->assertEquals( [ $this->instance, 'get_item' ], $filtered_endpoints[ $block_route ][0]['callback'] ); + } + + /** + * Test filter_block_endpoints, when there is no Block Lab block. + * + * @covers \Block_Lab\Blocks\Rest::filter_block_endpoints() + */ + public function test_filter_block_endpoints_non_block_lab_block() { + $endpoints = [ + $this->rest_api_route . '/(?Pbaz-plugin/example-block-name)' => $this->mock_handler, + $this->rest_api_route . '/(?Panother-plugin/here-is-a-block-name)' => [], + ]; + + $this->assertEquals( + $endpoints, + $this->instance->filter_block_endpoints( $endpoints ) + ); + } + + /** + * Test filter_block_endpoints, when there is a Block Lab block. + * + * @covers \Block_Lab\Blocks\Rest::filter_block_endpoints() + */ + public function test_filter_block_endpoints_block_lab_block() { + $this->assertEquals( + [ + $this->rest_api_route . '(?Pblock-lab/main-hero)' => [ + 0 => [ + 'methods' => [ 'GET', 'POST' ], + 'callback' => [ $this->instance, 'get_item' ], + ], + ], + ], + $this->instance->filter_block_endpoints( + [ $this->rest_api_route . '(?Pblock-lab/main-hero)' => $this->mock_handler ] + ) + ); + } + + /** + * Gets the test data for test_get_item(). + * + * @return array The test data. + */ + public function get_data_test_get_item() { + return [ + 'get_request' => [ 'GET' ], + 'post_request' => [ 'POST' ], + ]; + } + + /** + * Test get_item. + * + * @dataProvider get_data_test_get_item + * @covers \Block_Lab\Blocks\Rest::get_item() + * + * @param string $request_type The type of request, like 'GET'. + */ + public function test_get_item( $request_type ) { + wp_set_current_user( $this->factory()->user->create( [ 'role' => 'editor' ] ) ); + + $string_attribute = 'Lorem ipsum dolor'; + $int_attribute = 200; + $attributes = [ + 'example_string' => $string_attribute, + 'example_int' => $int_attribute, + ]; + + $request = new WP_REST_Request( $request_type, $this->rest_api_route . $this->mock_block_name ); + $request->set_param( 'context', 'edit' ); + + if ( 'GET' === $request_type ) { + $request->set_param( 'attributes', $attributes ); + } elseif ( 'POST' === $request_type ) { + $request->set_body( wp_json_encode( $attributes ) ); + } + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $actual = $response->get_data()['rendered']; + $this->assertContains( $string_attribute, $actual ); + $this->assertContains( strval( $int_attribute ), $actual ); + } +}