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 83b09c976ebe47..1ff0bb2b2114ea 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -472,3 +472,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 https://core.trac.wordpress.org/ticket/50544 + * + * @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', wp_slash( $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 https://core.trac.wordpress.org/ticket/50544 + * + * @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 https://core.trac.wordpress.org/ticket/50544 + * + * @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 9663d471788c95..1d3b4f227882df 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -193,7 +193,10 @@ function Navigation( { > { await showBlockToolbar(); - // Add another Link block. + // Add another block. // Using 'click' here checks for regressions of https://github.com/WordPress/gutenberg/issues/18329, // an issue where the block appender requires two clicks. await page.click( '.wp-block-navigation .block-list-appender' ); + // Select a Link block. + const [ linkButton ] = await page.$x( + "//*[contains(@class, 'block-editor-inserter__quick-inserter')]//*[text()='Link']" + ); + await linkButton.click(); + // After adding a new block, search input should be shown immediately. // Verify that Escape would close the popover. // Regression: https://github.com/WordPress/gutenberg/pull/19885 diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index d89fd3f5c80b7f..4f8d49fc66eecb 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -8,6 +8,7 @@ import { v4 as uuid } from 'uuid'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { serialize } from '@wordpress/blocks'; /** * Internal dependencies @@ -167,7 +168,7 @@ function* batchSave( menuId, menuItemsByClientId, navigationBlock ) { function computeCustomizedAttribute( blocks, menuId, menuItemsByClientId ) { const blocksList = blocksTreeToFlatList( blocks ); const dataList = blocksList.map( ( { block, parentId, position } ) => - linkBlockToRequestItem( block, parentId, position ) + blockToRequestItem( block, parentId, position ) ); // Create an object like { "nav_menu_item[12]": {...}} } @@ -195,14 +196,29 @@ function computeCustomizedAttribute( blocks, menuId, menuItemsByClientId ) { ); } - function linkBlockToRequestItem( block, parentId, position ) { + function blockToRequestItem( block, parentId, position ) { const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); + + let attributes; + + if ( block.name === 'core/navigation-link' ) { + attributes = { + type: 'custom', + title: block.attributes?.label, + original_title: '', + url: block.attributes.url, + }; + } else { + attributes = { + type: 'html', + content: serialize( block ), + }; + } + return { ...menuItem, + ...attributes, position, - title: block.attributes?.label, - url: block.attributes.url, - original_title: '', classes: ( menuItem.classes || [] ).join( ' ' ), xfn: ( menuItem.xfn || [] ).join( ' ' ), nav_menu_term_id: menuId, diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js index bc828105a34d51..50b9aa411c3ad2 100644 --- a/packages/edit-navigation/src/store/resolvers.js +++ b/packages/edit-navigation/src/store/resolvers.js @@ -6,7 +6,7 @@ import { groupBy, sortBy } from 'lodash'; /** * WordPress dependencies */ -import { createBlock } from '@wordpress/blocks'; +import { parse, createBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -99,23 +99,32 @@ function createNavigationBlock( menuItems ) { itemsByParentID[ item.id ] ); } - const linkBlock = convertMenuItemToLinkBlock( - item, - menuItemInnerBlocks - ); - menuItemIdToClientId[ item.id ] = linkBlock.clientId; - innerBlocks.push( linkBlock ); + const block = convertMenuItemToBlock( item, menuItemInnerBlocks ); + menuItemIdToClientId[ item.id ] = block.clientId; + innerBlocks.push( block ); } return innerBlocks; }; - // menuItemsToTreeOfLinkBlocks takes an array of top-level menu items and recursively creates all their innerBlocks + // menuItemsToTreeOfBlocks takes an array of top-level menu items and recursively creates all their innerBlocks const innerBlocks = menuItemsToTreeOfBlocks( itemsByParentID[ 0 ] || [] ); const navigationBlock = createBlock( 'core/navigation', {}, innerBlocks ); return [ navigationBlock, menuItemIdToClientId ]; } -function convertMenuItemToLinkBlock( menuItem, innerBlocks = [] ) { +function convertMenuItemToBlock( menuItem, innerBlocks = [] ) { + if ( menuItem.type === 'html' ) { + const parsedBlocks = parse( menuItem.content.raw ); + + if ( parsedBlocks.length !== 1 ) { + return createBlock( 'core/freeform', { + originalContent: menuItem.content.raw, + } ); + } + + return parsedBlocks[ 0 ]; + } + const attributes = { label: menuItem.title.rendered, url: menuItem.url, 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 ) {