From 60bbbb8739a82faa753ce30368eeb4213248e555 Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Wed, 27 May 2020 15:30:22 +1000 Subject: [PATCH] Navigation: Allow Search block to be added alongside links Adds support for nav menu items with `type` set to `'html'`. These are nav menu items that can contain HTML or block markup which is rendered in place of the link. This allows the Navigation screen to use `'html`' menu items to support the Search block alongside links. --- lib/class-wp-rest-menu-items-controller.php | 63 ++++++++- lib/compat.php | 120 +++++++++++++++++- packages/block-library/src/navigation/edit.js | 5 +- .../components/menu-editor/use-menu-items.js | 20 ++- .../menu-editor/use-navigation-blocks.js | 36 ++++-- ...ss-rest-nav-menu-items-controller-test.php | 52 +++++++- 6 files changed, 278 insertions(+), 18 deletions(-) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index 1e1b84975abeb0..7eee6c533cfe9d 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -317,6 +317,7 @@ protected function prepare_item_for_database( $request ) { 'menu-item-title' => $menu_item_obj->title, 'menu-item-url' => $menu_item_obj->url, 'menu-item-description' => $menu_item_obj->description, + 'menu-item-content' => $menu_item_obj->menu_item_content, 'menu-item-attr-title' => $menu_item_obj->attr_title, 'menu-item-target' => $menu_item_obj->target, // Stored in the database as a string. @@ -337,6 +338,7 @@ protected function prepare_item_for_database( $request ) { 'menu-item-title' => '', 'menu-item-url' => '', 'menu-item-description' => '', + 'menu-item-content' => '', 'menu-item-attr-title' => '', 'menu-item-target' => '', 'menu-item-classes' => '', @@ -390,6 +392,15 @@ protected function prepare_item_for_database( $request ) { } } + // Nav menu content. + if ( ! empty( $schema['properties']['content'] ) && isset( $request['content'] ) ) { + if ( is_string( $request['content'] ) ) { + $prepared_nav_item['menu-item-content'] = $request['content']; + } elseif ( isset( $request['content']['raw'] ) ) { + $prepared_nav_item['menu-item-content'] = $request['content']['raw']; + } + } + // Check if object id exists before saving. if ( ! $prepared_nav_item['menu-item-object'] ) { // If taxonony, check if term exists. @@ -429,6 +440,13 @@ protected function prepare_item_for_database( $request ) { } } + // If menu item is type html, then content is required. + if ( 'html' === $prepared_nav_item['menu-item-type'] ) { + if ( empty( $prepared_nav_item['menu-item-content'] ) ) { + return new WP_Error( 'rest_content_required', __( 'Content required if menu item of type html.', 'gutenberg' ), array( 'status' => 400 ) ); + } + } + // If menu id is set, valid the value of menu item position and parent id. if ( ! empty( $prepared_nav_item['menu-id'] ) ) { // Check if nav menu is valid. @@ -596,6 +614,20 @@ public function prepare_item_for_response( $post, $request ) { $data['object_id'] = absint( $menu_item->object_id ); } + if ( rest_is_field_included( 'content', $fields ) ) { + $data['content'] = array(); + } + if ( rest_is_field_included( 'content.raw', $fields ) ) { + $data['content']['raw'] = $menu_item->content; + } + if ( rest_is_field_included( 'content.rendered', $fields ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['content']['rendered'] = apply_filters( 'the_content', $menu_item->content ); + } + if ( rest_is_field_included( 'content.block_version', $fields ) ) { + $data['content']['block_version'] = block_version( $menu_item->content ); + } + if ( in_array( 'parent', $fields, true ) ) { // Same as post_parent, expose as integer. $data['parent'] = absint( $menu_item->menu_item_parent ); @@ -787,7 +819,7 @@ public function get_item_schema() { $schema['properties']['type'] = array( 'description' => __( 'The family of objects originally represented, such as "post_type" or "taxonomy".', 'gutenberg' ), 'type' => 'string', - 'enum' => array( 'taxonomy', 'post_type', 'post_type_archive', 'custom' ), + 'enum' => array( 'taxonomy', 'post_type', 'post_type_archive', 'custom', 'html' ), 'context' => array( 'view', 'edit', 'embed' ), 'default' => 'custom', ); @@ -861,6 +893,35 @@ public function get_item_schema() { 'default' => 0, ); + $schema['properties']['content'] = array( + 'description' => __( 'HTML content to display for this menu item. May contain blocks.', 'gutenberg' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'type' => 'object', + 'arg_options' => array( + 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database(). + 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database(). + ), + 'properties' => array( + 'raw' => array( + 'description' => __( 'HTML content, as it exists in the database.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'edit' ), + ), + 'rendered' => array( + 'description' => __( 'HTML content, transformed for display.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'block_version' => array( + 'description' => __( 'Version of the block format used in the HTML content.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ); + $schema['properties']['target'] = array( 'description' => __( 'The target attribute of the link element for this menu item.', 'gutenberg' ), 'type' => 'string', diff --git a/lib/compat.php b/lib/compat.php index 202c1345c2a8aa..c395ba58c7bf5e 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -415,7 +415,11 @@ function gutenberg_render_block_with_assigned_block_context( $pre_render, $parse $parsed_block = apply_filters( 'render_block_data', $parsed_block, $source_block ); $context = array( - 'postId' => $post->ID, + 'query' => array( 'categoryIds' => array() ), + ); + + if ( isset( $post ) ) { + $context['postId'] = $post->ID; /* * The `postType` context is largely unnecessary server-side, since the @@ -423,10 +427,8 @@ function gutenberg_render_block_with_assigned_block_context( $pre_render, $parse * manifest is expected to be shared between the server and the client, * it should be included to consistently fulfill the expectation. */ - 'postType' => $post->post_type, - - 'query' => array( 'categoryIds' => array() ), - ); + $context['postType'] = $post->post_type; + } if ( isset( $wp_query->tax_query->queried_terms['category'] ) ) { foreach ( $wp_query->tax_query->queried_terms['category']['terms'] as $category_slug_or_id ) { @@ -461,3 +463,111 @@ function gutenberg_render_block_with_assigned_block_context( $pre_render, $parse * @see WP_Block::render */ remove_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); + +/** + * Shim that hooks into `wp_update_nav_menu_item` and makes it so that nav menu + * items support a 'content' field. This field contains HTML and is used by nav + * menu items with `type` set to `'html'`. + * + * Specifically, this shim makes it so that: + * + * 1) The `wp_update_nav_menu_item()` function supports setting + * `'menu-item-content'` on a menu item. When merged to Core, this functionality + * should exist in `wp_update_nav_menu_item()`. + * + * 2) The `customize_save` ajax action supports setting `'content'` on a nav + * menu item. When merged to Core, this functionality should exist in + * `WP_Customize_Manager::save()`. + * + * This shim can be removed when the Gutenberg plugin requires a WordPress + * version that has the ticket below. + * + * @see + * + * @param int $menu_id ID of the updated menu. + * @param int $menu_item_db_id ID of the new menu item. + * @param array $args An array of arguments used to update/add the menu item. + */ +function gutenberg_update_nav_menu_item_content( $menu_id, $menu_item_db_id, $args ) { + global $wp_customize; + + // Support setting content in customize_save admin-ajax.php requests by + // grabbing the unsanitized $_POST values. + if ( isset( $wp_customize ) ) { + $values = $wp_customize->unsanitized_post_values(); + if ( isset( $values[ "nav_menu_item[$menu_item_db_id]" ]['content'] ) ) { + if ( is_string( $values[ "nav_menu_item[$menu_item_db_id]" ]['content'] ) ) { + $args['menu-item-content'] = $values[ "nav_menu_item[$menu_item_db_id]" ]['content']; + } elseif ( isset( $values[ "nav_menu_item[$menu_item_db_id]" ]['content']['raw'] ) ) { + $args['menu-item-content'] = $values[ "nav_menu_item[$menu_item_db_id]" ]['content']['raw']; + } + } + } + + $defaults = array( + 'menu-item-content' => '', + ); + + $args = wp_parse_args( $args, $defaults ); + + update_post_meta( $menu_item_db_id, '_menu_item_content', $args['menu-item-content'] ); +} +add_action( 'wp_update_nav_menu_item', 'gutenberg_update_nav_menu_item_content', 10, 3 ); + +/** + * Shim that hooks into `wp_setup_nav_menu_items` and makes it so that nav menu + * items have a 'content' field. This field contains HTML and is used by nav + * menu items with `type` set to `'html'`. + * + * Specifically, this shim makes it so that the `wp_setup_nav_menu_item()` + * function sets `content` on the returned menu item. When merged to Core, this + * functionality should exist in `wp_setup_nav_menu_item()`. + * + * This shim can be removed when the Gutenberg plugin requires a WordPress + * version that has the ticket below. + * + * @see + * + * @param object $menu_item The menu item object. + */ +function gutenberg_setup_html_nav_menu_item( $menu_item ) { + if ( 'html' === $menu_item->type ) { + $menu_item->type_label = __( 'HTML', 'gutenberg' ); + $menu_item->content = ! isset( $menu_item->content ) ? get_post_meta( $menu_item->db_id, '_menu_item_content', true ) : $menu_item->content; + } + + return $menu_item; +} +add_filter( 'wp_setup_nav_menu_item', 'gutenberg_setup_html_nav_menu_item' ); + +/** + * Shim that hooks into `walker_nav_menu_start_el` and makes it so that the + * default walker which renders a menu will correctly render the HTML associated + * with any navigation menu item that has `type` set to `'html`'. + * + * Specifically, this shim makes it so that `Walker_Nav_Menu::start_el()` + * renders the `content` of a nav menu item when its `type` is `'html'`. When + * merged to Core, this functionality should exist in + * `Walker_Nav_Menu::start_el()`. + * + * This shim can be removed when the Gutenberg plugin requires a WordPress + * version that has the ticket below. + * + * @see + * + * @param string $item_output The menu item's starting HTML output. + * @param WP_Post $item Menu item data object. + * @param int $depth Depth of menu item. Used for padding. + * @param stdClass $args An object of wp_nav_menu() arguments. + */ +function gutenberg_output_html_nav_menu_item( $item_output, $item, $depth, $args ) { + if ( 'html' === $item->type ) { + $item_output = $args->before; + /** This filter is documented in wp-includes/post-template.php */ + $item_output .= apply_filters( 'the_content', $item->content ); + $item_output .= $args->after; + } + + return $item_output; +} +add_filter( 'walker_nav_menu_start_el', 'gutenberg_output_html_nav_menu_item', 10, 4 ); diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js index c52ba5b9581cfc..f885008f37e29d 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -206,7 +206,10 @@ function Navigation( { > { diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index a9c5ff444a7790..233114d5a074fe 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -450,6 +450,41 @@ public function test_create_item_invalid_custom_link_url() { $this->assertErrorResponse( 'rest_url_required', $response, 400 ); } + /** + * Tests that a HTML menu item can be created. + */ + public function test_create_item_html() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = $this->set_menu_item_data( + array( + 'type' => 'html', + 'content' => '

HTML content

', + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->check_create_menu_item_response( $response ); + } + + /** + * Tests that a HTML menu item can be created. + */ + public function test_create_item_invalid_html_content() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = $this->set_menu_item_data( + array( + 'type' => 'html', + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_content_required', $response, 400 ); + } + /** * */ @@ -571,11 +606,12 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 18, count( $properties ) ); + $this->assertEquals( 19, count( $properties ) ); $this->assertArrayHasKey( 'type_label', $properties ); $this->assertArrayHasKey( 'attr_title', $properties ); $this->assertArrayHasKey( 'classes', $properties ); $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'content', $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'url', $properties ); $this->assertArrayHasKey( 'meta', $properties ); @@ -698,6 +734,20 @@ protected function check_menu_item_data( $post, $data, $context, $links ) { $this->assertFalse( isset( $data['title'] ) ); } + // Check content. + if ( 'html' === $data['type'] ) { + $menu_item_content = get_post_meta( $post->ID, '_menu_item_content', true ); + $this->assertEquals( apply_filters( 'the_content', $menu_item_content ), $data['content']['rendered'] ); + if ( 'edit' === $context ) { + $this->assertEquals( $menu_item_content, $data['content']['raw'] ); + } else { + $this->assertFalse( isset( $data['title']['raw'] ) ); + } + $this->assertEquals( 1, $data['content']['block_version'] ); + } else { + $this->assertEmpty( $data['content']['rendered'] ); + } + // post_parent. $this->assertArrayHasKey( 'parent', $data ); if ( $post->post_parent ) {