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 );
+ }
+}