From 3d551af3f27a70e581ce06ea5bc7a6e6dee4a3f8 Mon Sep 17 00:00:00 2001 From: Isabel Brison Date: Tue, 27 Jun 2023 05:52:06 +0000 Subject: [PATCH] Editor: add navigation fallback. Creates a fallback menu for the Navigation block including an API endpoint to retrieve it. Props get_dave, spacedmonkey, kebbet, flixos90, mikeschroder, ramonopoly, audrasjb. Fixes 58557. git-svn-id: https://develop.svn.wordpress.org/trunk@56052 602fd350-edb4-49c9-b593-d223f7449a82 --- ...ass-wp-classic-to-block-menu-converter.php | 130 +++++++ .../class-wp-navigation-fallback.php | 247 +++++++++++++ src/wp-includes/navigation-fallback.php | 41 +++ src/wp-includes/rest-api.php | 4 + ...wp-rest-navigation-fallback-controller.php | 193 ++++++++++ src/wp-settings.php | 3 + .../classic-to-block-menu-converter.php | 221 +++++++++++ .../tests/editor/navigation-fallback.php | 348 ++++++++++++++++++ .../rest-navigation-fallback-controller.php | 201 ++++++++++ tests/qunit/fixtures/wp-api-generated.js | 21 ++ 10 files changed, 1409 insertions(+) create mode 100644 src/wp-includes/class-wp-classic-to-block-menu-converter.php create mode 100644 src/wp-includes/class-wp-navigation-fallback.php create mode 100644 src/wp-includes/navigation-fallback.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php create mode 100644 tests/phpunit/tests/editor/classic-to-block-menu-converter.php create mode 100644 tests/phpunit/tests/editor/navigation-fallback.php create mode 100644 tests/phpunit/tests/rest-api/rest-navigation-fallback-controller.php diff --git a/src/wp-includes/class-wp-classic-to-block-menu-converter.php b/src/wp-includes/class-wp-classic-to-block-menu-converter.php new file mode 100644 index 0000000000..0f72b93514 --- /dev/null +++ b/src/wp-includes/class-wp-classic-to-block-menu-converter.php @@ -0,0 +1,130 @@ +term_id, array( 'update_post_term_cache' => false ) ); + + if ( empty( $menu_items ) ) { + return array(); + } + + // Set up the $menu_item variables. + // Adds the class property classes for the current context, if applicable. + _wp_menu_item_classes_by_context( $menu_items ); + + $menu_items_by_parent_id = static::group_by_parent_id( $menu_items ); + + $first_menu_item = isset( $menu_items_by_parent_id[0] ) + ? $menu_items_by_parent_id[0] + : array(); + + $inner_blocks = static::to_blocks( + $first_menu_item, + $menu_items_by_parent_id + ); + + return serialize_blocks( $inner_blocks ); + } + + /** + * Returns an array of menu items grouped by the id of the parent menu item. + * + * @since 6.3.0. + * + * @param array $menu_items An array of menu items. + * @return array + */ + private static function group_by_parent_id( $menu_items ) { + $menu_items_by_parent_id = array(); + + foreach ( $menu_items as $menu_item ) { + $menu_items_by_parent_id[ $menu_item->menu_item_parent ][] = $menu_item; + } + + return $menu_items_by_parent_id; + } + + /** + * Turns menu item data into a nested array of parsed blocks + * + * @since 6.3.0. + * + * @param array $menu_items An array of menu items that represent + * an individual level of a menu. + * @param array $menu_items_by_parent_id An array keyed by the id of the + * parent menu where each element is an + * array of menu items that belong to + * that parent. + * @return array An array of parsed block data. + */ + private static function to_blocks( $menu_items, $menu_items_by_parent_id ) { + + if ( empty( $menu_items ) ) { + return array(); + } + + $blocks = array(); + + foreach ( $menu_items as $menu_item ) { + $class_name = ! empty( $menu_item->classes ) ? implode( ' ', (array) $menu_item->classes ) : null; + $id = ( null !== $menu_item->object_id && 'custom' !== $menu_item->object ) ? $menu_item->object_id : null; + $opens_in_new_tab = null !== $menu_item->target && '_blank' === $menu_item->target; + $rel = ( null !== $menu_item->xfn && '' !== $menu_item->xfn ) ? $menu_item->xfn : null; + $kind = null !== $menu_item->type ? str_replace( '_', '-', $menu_item->type ) : 'custom'; + + $block = array( + 'blockName' => isset( $menu_items_by_parent_id[ $menu_item->ID ] ) ? 'core/navigation-submenu' : 'core/navigation-link', + 'attrs' => array( + 'className' => $class_name, + 'description' => $menu_item->description, + 'id' => $id, + 'kind' => $kind, + 'label' => $menu_item->title, + 'opensInNewTab' => $opens_in_new_tab, + 'rel' => $rel, + 'title' => $menu_item->attr_title, + 'type' => $menu_item->object, + 'url' => $menu_item->url, + ), + ); + + $block['innerBlocks'] = isset( $menu_items_by_parent_id[ $menu_item->ID ] ) + ? static::to_blocks( $menu_items_by_parent_id[ $menu_item->ID ], $menu_items_by_parent_id ) + : array(); + $block['innerContent'] = array_map( 'serialize_block', $block['innerBlocks'] ); + + $blocks[] = $block; + } + + return $blocks; + } +} diff --git a/src/wp-includes/class-wp-navigation-fallback.php b/src/wp-includes/class-wp-navigation-fallback.php new file mode 100644 index 0000000000..6df4993a0f --- /dev/null +++ b/src/wp-includes/class-wp-navigation-fallback.php @@ -0,0 +1,247 @@ + 'wp_navigation', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'order' => 'DESC', + 'orderby' => 'date', + 'post_status' => 'publish', + 'posts_per_page' => 1, + ); + + $navigation_post = new WP_Query( $parsed_args ); + + if ( count( $navigation_post->posts ) > 0 ) { + return $navigation_post->posts[0]; + } + + return null; + } + + /** + * Creates a Navigation Menu post from a Classic Menu. + * + * @since 6.3.0. + * + * @return int|WP_Error The post ID of the default fallback menu or a WP_Error object. + */ + private static function create_classic_menu_fallback() { + // See if we have a classic menu. + $classic_nav_menu = static::get_fallback_classic_menu(); + + if ( ! $classic_nav_menu ) { + return new WP_Error( 'no_classic_menus', __( 'No Classic Menus found.' ) ); + } + + // If there is a classic menu then convert it to blocks. + $classic_nav_menu_blocks = WP_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + if ( empty( $classic_nav_menu_blocks ) ) { + return new WP_Error( 'cannot_convert_classic_menu', __( 'Unable to convert Classic Menu to blocks.' ) ); + } + + // Create a new navigation menu from the classic menu. + $classic_menu_fallback = wp_insert_post( + array( + 'post_content' => $classic_nav_menu_blocks, + 'post_title' => $classic_nav_menu->name, + 'post_name' => $classic_nav_menu->slug, + 'post_status' => 'publish', + 'post_type' => 'wp_navigation', + ), + true // So that we can check whether the result is an error. + ); + + return $classic_menu_fallback; + } + + /** + * Determine the most appropriate classic navigation menu to use as a fallback. + * + * @since 6.3.0. + * + * @return WP_Term|null The most appropriate classic navigation menu to use as a fallback. + */ + private static function get_fallback_classic_menu() { + $classic_nav_menus = wp_get_nav_menus(); + + if ( ! $classic_nav_menus || is_wp_error( $classic_nav_menus ) ) { + return null; + } + + $nav_menu = static::get_nav_menu_at_primary_location(); + + if ( $nav_menu ) { + return $nav_menu; + } + + $nav_menu = static::get_nav_menu_with_primary_slug( $classic_nav_menus ); + + if ( $nav_menu ) { + return $nav_menu; + } + + return static::get_most_recently_created_nav_menu( $classic_nav_menus ); + } + + + /** + * Sorts the classic menus and returns the most recently created one. + * + * @since 6.3.0. + * + * @param WP_Term[] $classic_nav_menus Array of classic nav menu term objects. + * @return WP_Term The most recently created classic nav menu. + */ + private static function get_most_recently_created_nav_menu( $classic_nav_menus ) { + usort( + $classic_nav_menus, + static function( $a, $b ) { + return $b->term_id - $a->term_id; + } + ); + + return $classic_nav_menus[0]; + } + + /** + * Returns the classic menu with the slug `primary` if it exists. + * + * @since 6.3.0. + * + * @param WP_Term[] $classic_nav_menus Array of classic nav menu term objects. + * @return WP_Term|null The classic nav menu with the slug `primary` or null. + */ + private static function get_nav_menu_with_primary_slug( $classic_nav_menus ) { + foreach ( $classic_nav_menus as $classic_nav_menu ) { + if ( 'primary' === $classic_nav_menu->slug ) { + return $classic_nav_menu; + } + } + + return null; + } + + + /** + * Gets the classic menu assigned to the `primary` navigation menu location + * if it exists. + * + * @since 6.3.0. + * + * @return WP_Term|null The classic nav menu assigned to the `primary` location or null. + */ + private static function get_nav_menu_at_primary_location() { + $locations = get_nav_menu_locations(); + + if ( isset( $locations['primary'] ) ) { + $primary_menu = wp_get_nav_menu_object( $locations['primary'] ); + + if ( $primary_menu ) { + return $primary_menu; + } + } + + return null; + } + + /** + * Creates a default Navigation Block Menu fallback. + * + * @since 6.3.0. + * + * @return int|WP_Error The post ID of the default fallback menu or a WP_Error object. + */ + private static function create_default_fallback() { + + $default_blocks = static::get_default_fallback_blocks(); + + // Create a new navigation menu from the fallback blocks. + $default_fallback = wp_insert_post( + array( + 'post_content' => $default_blocks, + 'post_title' => _x( 'Navigation', 'Title of a Navigation menu' ), + 'post_name' => 'navigation', + 'post_status' => 'publish', + 'post_type' => 'wp_navigation', + ), + true // So that we can check whether the result is an error. + ); + + return $default_fallback; + } + + /** + * Gets the rendered markup for the default fallback blocks. + * + * @since 6.3.0. + * + * @return string default blocks markup to use a the fallback. + */ + private static function get_default_fallback_blocks() { + $registry = WP_Block_Type_Registry::get_instance(); + + // If `core/page-list` is not registered then use empty blocks. + return $registry->is_registered( 'core/page-list' ) ? '' : ''; + } +} diff --git a/src/wp-includes/navigation-fallback.php b/src/wp-includes/navigation-fallback.php new file mode 100644 index 0000000000..e5bcc16801 --- /dev/null +++ b/src/wp-includes/navigation-fallback.php @@ -0,0 +1,41 @@ +register_routes(); + + // Navigation Fallback. + $controller = new WP_REST_Navigation_Fallback_Controller(); + $controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php new file mode 100644 index 0000000000..eafec4a91f --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php @@ -0,0 +1,193 @@ +namespace = 'wp-block-editor/v1'; + $this->rest_base = 'navigation-fallback'; + $this->post_type = 'wp_navigation'; + } + + /** + * Registers the controllers routes. + * + * @since 6.3.0. + * + * @return void + */ + public function register_routes() { + + // Lists a single nav item based on the given id or slug. + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::READABLE ), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read fallbacks. + * + * @since 6.3.0. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + + $post_type = get_post_type_object( $this->post_type ); + + // Getting fallbacks requires creating and reading `wp_navigation` posts. + if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( 'edit_theme_options' ) || ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to create Navigation Menus as this user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( 'edit' === $request['context'] && ! current_user_can( $post_type->cap->edit_posts ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit Navigation Menus as this user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Gets the most appropriate fallback Navigation Menu. + * + * @since 6.3.0. + * + * @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 = WP_Navigation_Fallback::get_fallback(); + + if ( empty( $post ) ) { + return rest_ensure_response( new WP_Error( 'no_fallback_menu', __( 'No fallback menu found.' ), array( 'status' => 404 ) ) ); + } + + $response = $this->prepare_item_for_response( $post, $request ); + + return $response; + } + + /** + * Retrieves the fallbacks' schema, conforming to JSON Schema. + * + * @since 6.3.0. + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'navigation-fallback', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'The unique identifier for the Navigation Menu.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Matches the post data to the schema we want. + * + * @since 6.3.0. + * + * @param WP_Post $item The wp_navigation Post object whose response is being prepared. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response $response The response data. + */ + public function prepare_item_for_response( $item, $request ) { + $data = array(); + + $fields = $this->get_fields_for_response( $request ); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = (int) $item->ID; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + return $response; + } + + /** + * Prepares the links for the request. + * + * @since 6.3.0. + * + * @param WP_Post $post the Navigation Menu post object. + * @return array Links for the given request. + */ + private function prepare_links( $post ) { + return array( + 'self' => array( + 'href' => rest_url( rest_get_route_for_post( $post->ID ) ), + 'embeddable' => true, + ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 3b59004ada..26d2662f6b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -291,6 +291,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widget-types-contro require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widgets-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-templates-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-url-details-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; @@ -321,6 +322,8 @@ require ABSPATH . WPINC . '/class-wp-block-list.php'; require ABSPATH . WPINC . '/class-wp-block-parser-block.php'; require ABSPATH . WPINC . '/class-wp-block-parser-frame.php'; require ABSPATH . WPINC . '/class-wp-block-parser.php'; +require ABSPATH . WPINC . '/class-wp-classic-to-block-menu-converter.php'; +require ABSPATH . WPINC . '/class-wp-navigation-fallback.php'; require ABSPATH . WPINC . '/blocks.php'; require ABSPATH . WPINC . '/blocks/index.php'; require ABSPATH . WPINC . '/block-editor.php'; diff --git a/tests/phpunit/tests/editor/classic-to-block-menu-converter.php b/tests/phpunit/tests/editor/classic-to-block-menu-converter.php new file mode 100644 index 0000000000..e3bbce74a7 --- /dev/null +++ b/tests/phpunit/tests/editor/classic-to-block-menu-converter.php @@ -0,0 +1,221 @@ +assertTrue( class_exists( 'WP_Classic_To_Block_Menu_Converter' ) ); + } + + /** + * @ticket 58557 + * @covers WP_Classic_To_Block_Menu_Converter::convert + * @dataProvider provider_test_passing_non_menu_object_to_converter_returns_wp_error + */ + public function test_passing_non_menu_object_to_converter_returns_wp_error( $data ) { + + $result = WP_Classic_To_Block_Menu_Converter::convert( $data ); + + $this->assertTrue( is_wp_error( $result ), 'Should be a WP_Error instance' ); + + $this->assertEquals( 'invalid_menu', $result->get_error_code(), 'Error code should indicate invalidity of menu argument.' ); + + $this->assertEquals( 'The menu provided is not a valid menu.', $result->get_error_message(), 'Error message should communicate invalidity of menu argument.' ); + } + + /** + * @ticket 58557 + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function provider_test_passing_non_menu_object_to_converter_returns_wp_error() { + return array( + array( 1 ), + array( -1 ), + array( '1' ), + array( 'not a menu object' ), + array( true ), + array( false ), + array( array() ), + array( new stdClass() ), + ); + } + + /** + * @ticket 58557 + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_can_convert_classic_menu_to_blocks() { + + $menu_id = wp_create_nav_menu( 'Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + $second_menu_item_id = wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 2', + 'menu-item-url' => '/classic-menu-item-2', + 'menu-item-status' => 'publish', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Nested Menu Item 1', + 'menu-item-url' => '/nested-menu-item-1', + 'menu-item-status' => 'publish', + 'menu-item-parent-id' => $second_menu_item_id, + ) + ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = WP_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertNotEmpty( $blocks ); + + $parsed_blocks = parse_blocks( $blocks ); + + $first_block = $parsed_blocks[0]; + $second_block = $parsed_blocks[1]; + $nested_block = $parsed_blocks[1]['innerBlocks'][0]; + + $this->assertEquals( 'core/navigation-link', $first_block['blockName'], 'First block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Classic Menu Item 1', $first_block['attrs']['label'], 'First block label should match.' ); + + $this->assertEquals( '/classic-menu-item-1', $first_block['attrs']['url'], 'First block URL should match.' ); + + // Assert parent of nested menu item is a submenu block. + $this->assertEquals( 'core/navigation-submenu', $second_block['blockName'], 'Second block name should be "core/navigation-submenu"' ); + + $this->assertEquals( 'Classic Menu Item 2', $second_block['attrs']['label'], 'Second block label should match.' ); + + $this->assertEquals( '/classic-menu-item-2', $second_block['attrs']['url'], 'Second block URL should match.' ); + + $this->assertEquals( 'core/navigation-link', $nested_block['blockName'], 'Nested block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Nested Menu Item 1', $nested_block['attrs']['label'], 'Nested block label should match.' ); + + $this->assertEquals( '/nested-menu-item-1', $nested_block['attrs']['url'], 'Nested block URL should match.' ); + + wp_delete_nav_menu( $menu_id ); + } + + /** + * @ticket 58557 + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_does_not_convert_menu_items_with_non_publish_status() { + + $menu_id = wp_create_nav_menu( 'Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'draft', + 'menu-item-title' => 'Draft Menu Item', + 'menu-item-url' => '/draft-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'private', + 'menu-item-title' => 'Private Item', + 'menu-item-url' => '/private-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'pending', + 'menu-item-title' => 'Pending Menu Item', + 'menu-item-url' => '/pending-menu-item', + ) + ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-status' => 'future', + 'menu-item-title' => 'Future Menu Item', + 'menu-item-url' => '/future-menu-item', + ) + ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = WP_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertNotEmpty( $blocks ); + + $parsed_blocks = parse_blocks( $blocks ); + + $this->assertCount( 1, $parsed_blocks, 'Should only be one block in the array.' ); + + $this->assertEquals( 'core/navigation-link', $parsed_blocks[0]['blockName'], 'First block name should be "core/navigation-link"' ); + + $this->assertEquals( 'Classic Menu Item 1', $parsed_blocks[0]['attrs']['label'], 'First block label should match.' ); + + $this->assertEquals( '/classic-menu-item-1', $parsed_blocks[0]['attrs']['url'], 'First block URL should match.' ); + + wp_delete_nav_menu( $menu_id ); + } + + /** + * @ticket 58557 + * @covers WP_Classic_To_Block_Menu_Converter::convert + */ + public function test_returns_empty_array_for_menus_with_no_items() { + $menu_id = wp_create_nav_menu( 'Empty Menu' ); + + $classic_nav_menu = wp_get_nav_menu_object( $menu_id ); + + $blocks = WP_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu ); + + $this->assertEmpty( $blocks, 'Result should be empty.' ); + + $this->assertIsArray( $blocks, 'Result should be empty array.' ); + + wp_delete_nav_menu( $menu_id ); + } +} diff --git a/tests/phpunit/tests/editor/navigation-fallback.php b/tests/phpunit/tests/editor/navigation-fallback.php new file mode 100644 index 0000000000..3d73b72a4d --- /dev/null +++ b/tests/phpunit/tests/editor/navigation-fallback.php @@ -0,0 +1,348 @@ +user->create( array( 'role' => 'administrator' ) ); + + self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_user ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller + */ + public function test_it_exists() { + $this->assertTrue( class_exists( 'WP_Navigation_Fallback' ), 'WP_Navigation_Fallback class should exist.' ); + } + + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_a_default_fallback_navigation_menu_in_absence_of_other_fallbacks() { + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'wp_navigation', $data->post_type, 'Fallback menu type should be `wp_navigation`' ); + + $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default fallback title' ); + + $this->assertEquals( 'navigation', $data->post_name, 'Fallback menu slug (post_name) should be the default slug' ); + + $this->assertEquals( '', $data->post_content ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_a_default_fallback_navigation_menu_with_no_blocks_if_page_list_block_is_not_registered() { + + $original_page_list_block = WP_Block_Type_Registry::get_instance()->get_registered( 'core/page-list' ); + + unregister_block_type( 'core/page-list' ); + + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertNotEquals( '', $data->post_content, 'Navigation Menu should not contain a Page List block.' ); + + $this->assertEmpty( $data->post_content, 'Menu should be empty.' ); + + register_block_type( 'core/page-list', $original_page_list_block ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_handle_consecutive_invocations() { + // Invoke the method multiple times to ensure that it doesn't create a new fallback menu on each invocation. + WP_Navigation_Fallback::get_fallback(); + WP_Navigation_Fallback::get_fallback(); + + // Assert on the final invocation. + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Navigation', $data->post_title, 'Fallback menu title should be the default title' ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_the_most_recently_created_navigation_menu() { + + self::factory()->post->create_and_get( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Existing Navigation Menu 1', + 'post_content' => '', + ) + ); + + $most_recently_published_nav = self::factory()->post->create_and_get( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Existing Navigation Menu 2', + 'post_content' => '', + ) + ); + + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( $most_recently_published_nav->post_title, $data->post_title, 'Fallback menu title should be the same as the most recently created menu.' ); + + $this->assertEquals( $most_recently_published_nav->post_name, $data->post_name, 'Post name should be the same as the most recently created menu.' ); + + $this->assertEquals( $most_recently_published_nav->post_content, $data->post_content, 'Post content should be the same as the most recently created menu.' ); + + // Check that no new Navigation menu was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 2, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_return_fallback_navigation_from_existing_classic_menu_if_no_navigation_menus_exist() { + $menu_id = wp_create_nav_menu( 'Existing Classic Menu' ); + + wp_update_nav_menu_item( + $menu_id, + 0, + array( + 'menu-item-title' => 'Classic Menu Item 1', + 'menu-item-url' => '/classic-menu-item-1', + 'menu-item-status' => 'publish', + ) + ); + + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should be the same as the classic menu.' ); + + // Assert that the fallback contains a navigation-link block. + $this->assertStringContainsString( '', + ) + ); + + $data = WP_Navigation_Fallback::get_fallback(); + + $this->assertInstanceOf( 'WP_Post', $data, 'Response should be of the correct type.' ); + + $this->assertEquals( $existing_navigation_menu->post_title, $data->post_title, 'Fallback menu title should be the same as the existing Navigation menu.' ); + + $this->assertNotEquals( 'Existing Classic Menu', $data->post_title, 'Fallback menu title should not be the same as the Classic Menu.' ); + + // Check that only a single Navigation fallback was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'Only the existing Navigation menus should be present in the database.' ); + + } + + private function get_navigations_in_database() { + $navs_in_db = new WP_Query( + array( + 'post_type' => 'wp_navigation', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $navs_in_db->posts ? $navs_in_db->posts : array(); + } + +} diff --git a/tests/phpunit/tests/rest-api/rest-navigation-fallback-controller.php b/tests/phpunit/tests/rest-api/rest-navigation-fallback-controller.php new file mode 100644 index 0000000000..263547da5d --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-navigation-fallback-controller.php @@ -0,0 +1,201 @@ +user->create( array( 'role' => 'administrator' ) ); + + self::$editor_user = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_user ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller::register_routes + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp-block-editor/v1/navigation-fallback', $routes, 'Fallback route should be registered.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_should_not_return_menus_for_users_without_permissions() { + + wp_set_current_user( self::$editor_user ); + + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 403, $response->get_status(), 'Response should indicate user does not have permission.' ); + + $this->assertEquals( 'rest_cannot_create', $data['code'], 'Response should indicate user cannot create.' ); + + $this->assertEquals( 'Sorry, you are not allowed to create Navigation Menus as this user.', $data['message'], 'Response should indicate failed request status.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_get_item() { + + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); + + $this->assertIsArray( $data, 'Response should be of correct type.' ); + + $this->assertArrayHasKey( 'id', $data, 'Response should contain expected fields.' ); + + $this->assertEquals( 'wp_navigation', get_post_type( $data['id'] ), '"id" field should represent a post of type "wp_navigation"' ); + + // Check that only a single Navigation fallback was created. + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 1, $navs_in_db, 'Only a single Navigation menu should be present in the database.' ); + + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status(), 'Status should indicate successful request.' ); + + $this->assertArrayHasKey( 'schema', $data, '"schema" key should exist in response.' ); + + $schema = $data['schema']; + + $this->assertEquals( 'object', $schema['type'], 'The schema type should match the expected type.' ); + + $this->assertArrayHasKey( 'id', $schema['properties'], 'Schema should have an "id" property.' ); + $this->assertEquals( 'integer', $schema['properties']['id']['type'], 'Schema "id" property should be an integer.' ); + $this->assertTrue( $schema['properties']['id']['readonly'], 'Schema "id" property should be readonly.' ); + } + + /** + * @ticket 58557 + * @covers WP_REST_Navigation_Fallback_Controller + * + * @since 6.3.0 Added Navigation Fallbacks endpoint. + */ + public function test_adds_links() { + $request = new WP_REST_Request( 'GET', '/wp-block-editor/v1/navigation-fallback' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $navigation_post_id = $data['id']; + + $links = $response->get_links(); + + $this->assertNotEmpty( $links, 'Response should contain links.' ); + + $this->assertArrayHasKey( 'self', $links, 'Response should contain a "self" link.' ); + + $this->assertStringContainsString( 'wp/v2/navigation/' . $navigation_post_id, $links['self'][0]['href'], 'Self link should reference the correct Navigation Menu post resource url.' ); + + $this->assertTrue( $links['self'][0]['attributes']['embeddable'], 'Self link should be embeddable.' ); + } + + private function get_navigations_in_database() { + $navs_in_db = new WP_Query( + array( + 'post_type' => 'wp_navigation', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ) + ); + + return $navs_in_db->posts ? $navs_in_db->posts : array(); + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Covered by the core test. + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not implement update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index f0de044b8b..9b6bd97c49 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11021,6 +11021,27 @@ mockedApiResponse.Schema = { } ] } + }, + "/wp-block-editor/v1/navigation-fallback": { + "namespace": "wp-block-editor/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": [] + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1/navigation-fallback" + } + ] + } } }, "site_logo": 0,