diff --git a/components/index.js b/components/index.js index 38731f65401287..0de7d42929df45 100644 --- a/components/index.js +++ b/components/index.js @@ -43,6 +43,7 @@ export { default as ResponsiveWrapper } from './responsive-wrapper'; export { default as SandBox } from './sandbox'; export { default as SelectControl } from './select-control'; export { default as Spinner } from './spinner'; +export { default as ServerSideRender } from './server-side-render'; export { default as TabPanel } from './tab-panel'; export { default as TextControl } from './text-control'; export { default as TextareaControl } from './textarea-control'; diff --git a/components/server-side-render/README.md b/components/server-side-render/README.md new file mode 100644 index 00000000000000..02c507ca3819db --- /dev/null +++ b/components/server-side-render/README.md @@ -0,0 +1,30 @@ +ServerSideRender +======= + +ServerSideRender is a component used for server-side rendering a preview of dynamic blocks to display in the editor. Server-side rendering in a block's `edit` function should be limited to blocks that are heavily dependent on existing PHP rendering logic that is heavily intertwined with data, particularly when there are no endpoints available. + +ServerSideRender may also be used when a legacy block is provided as a backwards compatibility measure, rather than needing to re-write the deprecated code that the block may depend on. + +ServerSideRender should be regarded as a fallback or legacy mechanism, it is not appropriate for developing new features against. + +New blocks should be built in conjunction with any necessary REST API endpoints, so that JavaScript can be used for rendering client-side in the `edit` function. This gives the best user experience, instead of relying on using the PHP `render_callback`. The logic necessary for rendering should be included in the endpoint, so that both the client-side JavaScript and server-side PHP logic should require a mininal amount of differences. + +## Usage + +Render core/archives preview. + +```jsx + +``` + +## Output + +Output uses the block's `render_callback` function, set when defining the block. + +## API Endpoint + +The API endpoint for getting the output for ServerSideRender is `/gutenberg/v1/block-renderer/:block`. It accepts any params, which are used as `attributes` for the block's `render_callback` method. + diff --git a/components/server-side-render/index.js b/components/server-side-render/index.js new file mode 100644 index 00000000000000..d0128f53c1b413 --- /dev/null +++ b/components/server-side-render/index.js @@ -0,0 +1,72 @@ +/** + * External dependencies. + */ +import { isEqual, isObject, map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + RawHTML, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +export class ServerSideRender extends Component { + constructor( props ) { + super( props ); + this.state = { + response: null, + }; + } + + componentDidMount() { + this.fetch( this.props ); + } + + componentWillReceiveProps( nextProps ) { + if ( ! isEqual( nextProps, this.props ) ) { + this.fetch( nextProps ); + } + } + + fetch( props ) { + this.setState( { response: null } ); + const { block, attributes } = props; + + const path = '/gutenberg/v1/block-renderer/' + block + '?context=edit&' + this.getQueryUrlFromObject( { attributes } ); + + return wp.apiRequest( { path: path } ).then( ( response ) => { + if ( response && response.rendered ) { + this.setState( { response: response.rendered } ); + } + } ); + } + + getQueryUrlFromObject( obj, prefix ) { + return map( obj, ( paramValue, paramName ) => { + const key = prefix ? prefix + '[' + paramName + ']' : paramName, + value = obj[ paramName ]; + return isObject( paramValue ) ? this.getQueryUrlFromObject( value, key ) : + encodeURIComponent( key ) + '=' + encodeURIComponent( value ); + } ).join( '&' ); + } + + render() { + const response = this.state.response; + if ( ! response || ! response.length ) { + return ( +
+ +

{ __( 'Loading...' ) }

+
+ ); + } + + return ( + { response } + ); + } +} + +export default ServerSideRender; diff --git a/lib/class-wp-block-type-registry.php b/lib/class-wp-block-type-registry.php index 2e91b53933a72b..f95dec6ce4b9d1 100644 --- a/lib/class-wp-block-type-registry.php +++ b/lib/class-wp-block-type-registry.php @@ -17,7 +17,7 @@ final class WP_Block_Type_Registry { * * @since 0.6.0 * @access private - * @var array + * @var WP_Block_Type[] */ private $registered_block_types = array(); @@ -142,7 +142,7 @@ public function get_registered( $name ) { * @since 0.6.0 * @access public * - * @return array Associative array of `$block_type_name => $block_type` pairs. + * @return WP_Block_Type[] Associative array of `$block_type_name => $block_type` pairs. */ public function get_all_registered() { return $this->registered_block_types; diff --git a/lib/class-wp-block-type.php b/lib/class-wp-block-type.php index 39f08e54fddc28..84aa40aed4e477 100644 --- a/lib/class-wp-block-type.php +++ b/lib/class-wp-block-type.php @@ -137,7 +137,7 @@ public function prepare_attributes_for_render( $attributes ) { if ( isset( $attributes[ $attribute_name ] ) ) { $is_valid = rest_validate_value_from_schema( $attributes[ $attribute_name ], $schema ); if ( ! is_wp_error( $is_valid ) ) { - $value = $attributes[ $attribute_name ]; + $value = rest_sanitize_value_from_schema( $attributes[ $attribute_name ], $schema ); } } diff --git a/lib/class-wp-rest-block-renderer-controller.php b/lib/class-wp-rest-block-renderer-controller.php new file mode 100644 index 00000000000000..43f381e80170a5 --- /dev/null +++ b/lib/class-wp-rest-block-renderer-controller.php @@ -0,0 +1,161 @@ +namespace = 'gutenberg/v1'; + $this->rest_base = 'block-renderer'; + } + + /** + * Registers the necessary REST API routes, one for each dynamic block. + * + * @access public + */ + public function register_routes() { + $block_types = WP_Block_Type_Registry::get_instance()->get_all_registered(); + foreach ( $block_types as $block_type ) { + if ( ! $block_type->is_dynamic() ) { + continue; + } + + register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P' . $block_type->name . ')', array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique registered name for the block.', 'gutenberg' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'attributes' => array( + /* translators: %s is the name of the block */ + 'description' => sprintf( __( 'Attributes for %s block', 'gutenberg' ), $block_type->name ), + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $block_type->attributes, + ), + 'post_id' => array( + 'description' => __( 'ID of the post context.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) ); + } + } + + /** + * Checks if a given request has access to read blocks. + * + * @since 2.8.0 + * @access public + * + * @param WP_REST_Request $request Request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + global $post; + + $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; + + if ( 0 < $post_id ) { + $post = get_post( $post_id ); + if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( 'gutenberg_block_cannot_read', __( 'Sorry, you are not allowed to read Gutenberg blocks of this post', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } else { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'gutenberg_block_cannot_read', __( 'Sorry, you are not allowed to read Gutenberg blocks as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + return true; + } + + /** + * Returns block output from block's registered render_callback. + * + * @since 2.8.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_item( $request ) { + global $post; + + $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; + + if ( 0 < $post_id ) { + $post = get_post( $post_id ); + + // Set up postdata since this will be needed if post_id was set. + setup_postdata( $post ); + } + $registry = WP_Block_Type_Registry::get_instance(); + $block = $registry->get_registered( $request['name'] ); + + if ( null === $block ) { + return new WP_Error( 'gutenberg_block_invalid', __( 'Invalid block.', 'gutenberg' ), array( + 'status' => 404, + ) ); + } + + $data = array( + 'rendered' => $block->render( $request->get_param( 'attributes' ) ), + ); + return rest_ensure_response( $data ); + } + + /** + * Retrieves block's output schema, conforming to JSON Schema. + * + * @since 2.8.0 + * @access public + * + * @return array Item schema data. + */ + public function get_item_schema() { + return array( + '$schema' => 'http://json-schema.org/schema#', + 'title' => 'rendered-block', + 'type' => 'object', + 'properties' => array( + 'rendered' => array( + 'description' => __( 'The rendered block.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + 'context' => array( 'edit' ), + ), + ), + ); + } +} diff --git a/lib/load.php b/lib/load.php index 1cafeba9472abf..e196610c848ba7 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-block-renderer-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 4866441eb1f1a4..0103fd9ff9e0cb 100644 --- a/lib/register.php +++ b/lib/register.php @@ -431,6 +431,17 @@ function gutenberg_register_post_types() { } add_action( 'init', 'gutenberg_register_post_types' ); +/** + * Registers the REST API routes needed by the Gutenberg editor. + * + * @since 2.8.0 + */ +function gutenberg_register_rest_routes() { + $controller = new WP_REST_Block_Renderer_Controller(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); + /** * Gets revisions details for the selected post. * diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 27b7a13c084242..581632857259cb 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -20,6 +20,9 @@ ./phpunit gutenberg.php + + lib/class-wp-rest-block-renderer-controller.php + gutenberg.php diff --git a/phpunit/class-rest-block-renderer-controller-test.php b/phpunit/class-rest-block-renderer-controller-test.php new file mode 100644 index 00000000000000..338736c394834c --- /dev/null +++ b/phpunit/class-rest-block-renderer-controller-test.php @@ -0,0 +1,412 @@ +user->create( + array( + 'role' => 'editor', + ) + ); + + self::$author_id = $factory->user->create( + array( + 'role' => 'author', + ) + ); + + self::$post_id = $factory->post->create( array( + 'post_title' => 'Test Post', + ) ); + } + + /** + * Delete test data after our tests run. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$user_id ); + } + + /** + * Set up. + * + * @see gutenberg_register_rest_routes() + */ + public function setUp() { + $this->register_test_block(); + $this->register_post_context_test_block(); + parent::setUp(); + } + + /** + * Tear down. + */ + public function tearDown() { + WP_Block_Type_Registry::get_instance()->unregister( self::$block_name ); + WP_Block_Type_Registry::get_instance()->unregister( self::$context_block_name ); + parent::tearDown(); + } + + /** + * Register test block. + */ + public function register_test_block() { + register_block_type( self::$block_name, array( + 'attributes' => array( + 'some_string' => array( + 'type' => 'string', + 'default' => 'some_default', + ), + 'some_int' => array( + 'type' => 'integer', + ), + 'some_array' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ), + ), + 'render_callback' => array( $this, 'render_test_block' ), + ) ); + } + + /** + * Register test block with post_id as attribute for post context test. + */ + public function register_post_context_test_block() { + register_block_type( self::$context_block_name, array( + 'attributes' => array(), + 'render_callback' => array( $this, 'render_post_context_test_block' ), + ) ); + } + + /** + * Test render callback. + * + * @param array $attributes Props. + * @return string Rendered attributes, which is here just JSON. + */ + public function render_test_block( $attributes ) { + return wp_json_encode( $attributes ); + } + + /** + * Test render callback for testing post context. + * + * @return string + */ + public function render_post_context_test_block() { + return get_the_title(); + } + + /** + * Check that the route was registered properly. + * + * @covers WP_REST_Block_Renderer_Controller::register_routes() + */ + public function test_register_routes() { + $dynamic_block_names = get_dynamic_block_names(); + $this->assertContains( self::$block_name, $dynamic_block_names ); + + $routes = $this->server->get_routes(); + foreach ( $dynamic_block_names as $dynamic_block_name ) { + $this->assertArrayHasKey( "/gutenberg/v1/block-renderer/(?P$dynamic_block_name)", $routes ); + } + } + + /** + * Test getting item without permissions. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item_without_permissions() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, rest_authorization_required_code() ); + } + + /** + * Test getting item without 'edit' context. + */ + public function test_get_item_with_invalid_context() { + wp_set_current_user( self::$user_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Test getting item with invalid block name. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item_invalid_block_name() { + wp_set_current_user( self::$user_id ); + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/core/123' ); + + $request->set_param( 'context', 'edit' ); + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_no_route', $response, 404 ); + } + + /** + * Check getting item with an invalid param provided. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item_invalid_attribute() { + wp_set_current_user( self::$user_id ); + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'attributes', array( + 'some_string' => array( 'no!' ), + ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Check getting item with an invalid param provided. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item_unrecognized_attribute() { + wp_set_current_user( self::$user_id ); + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'attributes', array( + 'unrecognized' => 'yes', + ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Check getting item with default attributes provided. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item_default_attributes() { + wp_set_current_user( self::$user_id ); + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( self::$block_name ); + $defaults = array(); + foreach ( $block_type->attributes as $key => $attribute ) { + $defaults[ $key ] = isset( $attribute['default'] ) ? $attribute['default'] : null; + } + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'attributes', array() ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertEquals( $defaults, json_decode( $data['rendered'], true ) ); + $this->assertEquals( + json_decode( $block_type->render( $defaults ) ), + json_decode( $data['rendered'] ) + ); + } + + /** + * Check getting item with attributes provided. + * + * @covers WP_REST_Block_Renderer_Controller::get_item() + */ + public function test_get_item() { + wp_set_current_user( self::$user_id ); + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( self::$block_name ); + $attributes = array( + 'some_int' => '123', + 'some_string' => 'foo', + 'some_array' => array( 1, '2', 3 ), + ); + + $expected_attributes = $attributes; + $expected_attributes['some_int'] = (int) $expected_attributes['some_int']; + $expected_attributes['some_array'] = array_map( 'intval', $expected_attributes['some_array'] ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'attributes', $attributes ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertEquals( $expected_attributes, json_decode( $data['rendered'], true ) ); + $this->assertEquals( + json_decode( $block_type->render( $attributes ), true ), + json_decode( $data['rendered'], true ) + ); + } + + /** + * Test getting item with post context. + */ + public function test_get_item_with_post_context() { + wp_set_current_user( self::$user_id ); + + $expected_title = 'Test Post'; + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name ); + $request->set_param( 'context', 'edit' ); + + // Test without post ID. + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertTrue( empty( $data['rendered'] ) ); + + // Now test with post ID. + $request->set_param( 'post_id', self::$post_id ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertEquals( $expected_title, $data['rendered'] ); + } + + /** + * Test getting item with invalid post ID. + */ + public function test_get_item_without_permissions_invalid_post() { + wp_set_current_user( self::$user_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name ); + $request->set_param( 'context', 'edit' ); + + // Test with invalid post ID. + $request->set_param( 'post_id', PHP_INT_MAX ); + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, 403 ); + } + + /** + * Test getting item without permissions to edit context post. + */ + public function test_get_item_without_permissions_cannot_edit_post() { + wp_set_current_user( self::$author_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/block-renderer/' . self::$context_block_name ); + $request->set_param( 'context', 'edit' ); + + // Test with private post ID. + $request->set_param( 'post_id', self::$post_id ); + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'gutenberg_block_cannot_read', $response, 403 ); + } + + /** + * Get item schema. + * + * @covers WP_REST_Block_Renderer_Controller::get_item_schema() + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/gutenberg/v1/block-renderer/' . self::$block_name ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEqualSets( array( 'GET' ), $data['endpoints'][0]['methods'] ); + $this->assertEqualSets( + array( 'name', 'context', 'attributes', 'post_id' ), + array_keys( $data['endpoints'][0]['args'] ) + ); + $this->assertEquals( 'object', $data['endpoints'][0]['args']['attributes']['type'] ); + + $this->assertArrayHasKey( 'schema', $data ); + $this->assertEquals( 'rendered-block', $data['schema']['title'] ); + $this->assertEquals( 'object', $data['schema']['type'] ); + $this->arrayHasKey( 'rendered', $data['schema']['properties'] ); + $this->arrayHasKey( 'string', $data['schema']['properties']['rendered']['type'] ); + $this->assertEquals( array( 'edit' ), $data['schema']['properties']['rendered']['context'] ); + } + + public function test_update_item() { + $this->markTestSkipped( 'Controller doesn\'t implement update_item().' ); + } + + 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_get_items() { + $this->markTestSkipped( 'Controller doesn\'t implement get_items().' ); + } + + public function test_context_param() { + $this->markTestSkipped( 'Controller doesn\'t implement context_param().' ); + } + + public function test_prepare_item() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } +}