diff --git a/lib/compat/wordpress-6.2/class-gutenberg-rest-navigation-controller.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-navigation-controller.php new file mode 100644 index 00000000000000..62b2fedb933ea3 --- /dev/null +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-navigation-controller.php @@ -0,0 +1,117 @@ +namespace, + '/' . $this->rest_base . '/((?P[\d]+)|(?P[\w\-]+))', + array( + 'args' => array( + 'slug' => array( + 'description' => __( 'The slug identifier for a Navigation', 'gutenberg' ), + 'type' => 'string', + ), + ), + 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 ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass Trash and force deletion.' ), + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Overide WP_REST_Posts_Controller parent function to query for + * `wp_navigation` posts by slug (post_name) instead of ID. + * + * This is required to successfully process OPTIONS requests as with + * these the `rest_request_before_callbacks` method which is used to + * map slug to postId does not run which means `get_post` will not + * behave as expected. + * + * The function will continue to delegate to the parent implementation + * if the $id argument is ID-based, thereby ensuring backwards + * compatbility. + * + * It may be possible to remove this implemenation in future releases. + * + * See: https://github.com/WordPress/gutenberg/pull/43703. + * + * @param string $id the slug of the Navigation post. + * @return WP_Post|null + */ + protected function get_post( $id ) { + + // Handle ID based $id param. + if ( is_numeric( $id ) ) { + return parent::get_post( $id ); + } + + // For string based $id the argument is a "slug". + // Lookup Post using `post_name` query. + $slug = $id; + + $args = array( + 'name' => $slug, + 'post_type' => 'wp_navigation', + 'nopaging' => true, + 'posts_per_page' => '1', + 'update_post_term_cache' => false, + 'no_found_rows' => true, + ); + + // Query for the Navigation Post by slug (post_name). + $query = new WP_Query( $args ); + + if ( empty( $query->posts ) ) { + return new WP_Error( + 'rest_post_not_found', + __( 'No navigation found.' ), + array( 'status' => 404 ) + ); + } + + return $query->posts[0]; + } +} diff --git a/lib/compat/wordpress-6.2/rest-api.php b/lib/compat/wordpress-6.2/rest-api.php new file mode 100644 index 00000000000000..8b8f2ec56dcec2 --- /dev/null +++ b/lib/compat/wordpress-6.2/rest-api.php @@ -0,0 +1,112 @@ +get_route(), $route ) ) { + return $response; + } + + // Get the slug from the request **URL** (not the request body). + // PUT requests will have a param of `slug` in both the URL and (potentially) + // in the body. In all cases only the slug in the URL should be mapped to a + // postId in order that the correct post to be updated can be retrived. + // The `slug` within the body should be preserved "as is". + $slug = isset( $request->get_url_params()['slug'] ) ? $request->get_url_params()['slug'] : null; + + // If no slug provided assume ID and exit early. + if ( empty( $slug ) ) { + return $response; + } + + $args = array( + 'name' => $slug, // query by slug + 'post_type' => 'wp_navigation', + 'nopaging' => true, + 'posts_per_page' => '1', + 'update_post_term_cache' => false, + 'no_found_rows' => true, + ); + + // Query for the Navigation Post by slug (post_name). + $query = new WP_Query( $args ); + + if ( empty( $query->posts ) ) { + return new WP_Error( + 'rest_post_not_found', + __( 'No navigation found.' ), + array( 'status' => 404 ) + ); + } + + // Set the post ID of the request based on the slug. + $request['id'] = $query->posts[0]->ID; + + return $response; +} +add_filter( 'rest_request_before_callbacks', 'gutenberg_transform_slug_to_post_id', 10, 3 ); + +/** + * Updates the REST route for Navigation posts to use the post slug + * instead of the postId. + * + * @param string $route the route path. + * @param WP_Post $post the post object. + * @return string the updated route. + */ +function gutenberg_update_navigation_rest_route_for_post( $route, WP_Post $post ) { + + if ( $post->post_type !== 'wp_navigation' ) { + return $route; + } + + $post_type_route = rest_get_route_for_post_type_items( $post->post_type ); + + if ( ! $post_type_route ) { + return ''; + } + + // Replace Post ID in route with Post "Slug" (post_name). + return sprintf( '%s/%s', $post_type_route, $post->post_name ); +} +add_filter( 'rest_route_for_post', 'gutenberg_update_navigation_rest_route_for_post', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index 65e8e3e2cb220b..a32b4d25e57a6d 100644 --- a/lib/load.php +++ b/lib/load.php @@ -49,6 +49,10 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php'; require_once __DIR__ . '/compat/wordpress-6.1/rest-api.php'; + // WordPress 6.2 compat. + require_once __DIR__ . '/compat/wordpress-6.2/class-gutenberg-rest-navigation-controller.php'; + require_once __DIR__ . '/compat/wordpress-6.2/rest-api.php'; + // Experimental. if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) { require_once __DIR__ . '/experimental/class-wp-rest-customizer-nonces.php'; diff --git a/phpunit/class-gutenberg-rest-navigation-controller-test.php b/phpunit/class-gutenberg-rest-navigation-controller-test.php new file mode 100644 index 00000000000000..54836abe6433ed --- /dev/null +++ b/phpunit/class-gutenberg-rest-navigation-controller-test.php @@ -0,0 +1,253 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + // This creates the global styles for the current theme. + self::$test_navigation_post_id = wp_insert_post( + array( + 'post_content' => '', + 'post_status' => 'publish', + 'post_title' => __( 'Test Navigation Menu', 'default' ), + 'post_type' => 'wp_navigation', + 'post_name' => self::$test_navigation_post_slug, + ), + true + ); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( + '/wp/v2/navigation/((?P[\d]+)|(?P[\w\-]+))', + $routes, + 'Navigation based on (post) ID or slug route does not exist' + ); + } + + public function test_rest_route_for_post() { + $route = \rest_get_route_for_post( self::$test_navigation_post_id ); + + $this->assertEquals( '/wp/v2/navigation/' . self::$test_navigation_post_slug, $route ); + } + + public function test_context_param() { + $this->markTestIncomplete(); + } + + public function test_get_items() { + $this->markTestIncomplete(); + } + + public function test_get_item_by_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/navigation/' . self::$test_navigation_post_slug ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( + self::$test_navigation_post_id, + $data['id'] + ); + + $this->assertEquals( + 'Test Navigation Menu', + $data['title']['rendered'] + ); + + $this->assertEquals( + 'test-navigation-post-slug', + $data['slug'] + ); + + $this->assertEquals( + 'wp_navigation', + $data['type'] + ); + } + + public function test_get_item_by_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/navigation/' . self::$test_navigation_post_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( + self::$test_navigation_post_id, + $data['id'] + ); + + $this->assertEquals( + 'Test Navigation Menu', + $data['title']['rendered'] + ); + + $this->assertEquals( + 'test-navigation-post-slug', + $data['slug'] + ); + + $this->assertEquals( + 'wp_navigation', + $data['type'] + ); + } + + public function test_get_item() { + $this->markTestIncomplete(); + } + + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/navigation' ); + $request->set_body_params( + array( + 'title' => 'Freshly created Navigation', + 'slug' => 'freshly-created-navigation', + 'status' => 'publish', + 'content' => '', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( + 'Freshly created Navigation', + $data['title']['rendered'] + ); + + $this->assertEquals( + 'freshly-created-navigation', + $data['slug'] + ); + + $this->assertEquals( + 'wp_navigation', + $data['type'] + ); + } + + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/navigation/' . self::$test_navigation_post_slug ); + $request->set_body_params( + array( + 'title' => 'New Nav title', + 'slug' => 'the-new-navigation-slug', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( + self::$test_navigation_post_id, + $data['id'] + ); + + $this->assertEquals( + 'New Nav title', + $data['title']['rendered'] + ); + + $this->assertEquals( + 'the-new-navigation-slug', + $data['slug'] + ); + + $this->assertEquals( + 'wp_navigation', + $data['type'] + ); + } + + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/navigation/' . self::$test_navigation_post_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( + self::$test_navigation_post_id, + $data['id'] + ); + + $this->assertEquals( + 'trash', + $data['status'] + ); + + $this->assertEquals( + 'Test Navigation Menu', + $data['title']['rendered'] + ); + + $this->assertEquals( + 'test-navigation-post-slug__trashed', + $data['slug'] + ); + } + + public function test_permissions_requests_for_item() { + $this->markTestSkipped( 'Skipped as OPTIONS requests do not contain headers when running phpunit' ); + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/navigation/' . self::$test_navigation_post_slug ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $headers = $response->get_headers(); + + $this->assertEquals( + 'GET, POST, PUT, PATCH, DELETE', + $headers['Allow'] + ); + } + + public function test_prepare_item() { + $this->markTestIncomplete(); + } + + public function test_get_item_schema() { + $this->markTestIncomplete(); + } +}