diff --git a/lib/editor-settings.php b/lib/editor-settings.php index 7e09cd52e6e22..db5ae81802815 100644 --- a/lib/editor-settings.php +++ b/lib/editor-settings.php @@ -75,6 +75,18 @@ function gutenberg_initialize_editor( $editor_name, $editor_script_handle, $sett 'rest_preload_api_request', array() ); + + /** + * Filters the array of data that has been preloaded. + * + * The dynamic portion of the hook name, `$editor_name`, refers to the type of block editor. + * + * @param array $preload_data Array containing the preloaded data. + * @param string $editor_name Current editor name. + * @param array Array containing the filtered preloaded data. + */ + $preload_data = apply_filters( 'block_editor_preload_data', $preload_data, $editor_name ); + wp_add_inline_script( 'wp-api-fetch', sprintf( diff --git a/lib/navigation-page.php b/lib/navigation-page.php index 568fca8cb3bbf..959c5d7386476 100644 --- a/lib/navigation-page.php +++ b/lib/navigation-page.php @@ -21,23 +21,84 @@ class="edit-navigation" } /** - * Initialize the Gutenberg Navigation page. + * This function returns an url for the /__experimental/menus endpoint * - * @since 7.8.0 + * @since 11.8.0 + * + * @param int $results_per_page Results per page. + * @return string + */ +function gutenberg_navigation_get_menus_endpoint( $results_per_page = 100 ) { + return '/__experimental/menus?' . build_query( + array( + 'per_page' => $results_per_page, + 'context' => 'edit', + '_locale' => 'user', + ) + ); +} + +/** + * This function returns an url for the /__experimental/menu-items endpoint + * + * @since 11.8.0 + * + * @param int $menu_id Menu ID. + * @param int $results_per_page Results per page. + * @return string + */ +function gutenberg_navigation_get_menu_items_endpoint( $menu_id, $results_per_page = 100 ) { + return '/__experimental/menu-items?' . build_query( + array( + 'context' => 'edit', + 'menus' => $menu_id, + 'per_page' => $results_per_page, + '_locale' => 'user', + ) + ); +} + +/** + * This function returns an url for the /wp/v2/types endpoint + * + * @since 11.8.0 + * + * @return string + */ +function gutenberg_navigation_get_types_endpoint() { + return '/wp/v2/types?' . build_query( + array( + 'context' => 'edit', + ) + ); +} + +/** + * Initialize the Gutenberg Navigation page. * * @param string $hook Page. + * @since 7.8.0 */ function gutenberg_navigation_init( $hook ) { if ( 'gutenberg_page_gutenberg-navigation' !== $hook ) { - return; + return; } + $menus = wp_get_nav_menus(); + $first_menu_id = ! empty( $menus ) ? $menus[0]->term_id : null; + $preload_paths = array( '/__experimental/menu-locations', array( '/wp/v2/pages', 'OPTIONS' ), array( '/wp/v2/posts', 'OPTIONS' ), + gutenberg_navigation_get_menus_endpoint(), + gutenberg_navigation_get_types_endpoint(), ); + if ( $first_menu_id ) { + $preload_paths[] = gutenberg_navigation_get_menu_items_endpoint( $first_menu_id ); + } + $settings = array_merge( gutenberg_get_default_block_editor_settings(), array( @@ -82,3 +143,41 @@ function gutenberg_navigation_editor_load_block_editor_scripts_and_styles( $is_b } add_filter( 'should_load_block_editor_scripts_and_styles', 'gutenberg_navigation_editor_load_block_editor_scripts_and_styles' ); + +/** + * This function removes menu-related data from the "common" preloading middleware and calls + * createMenuPreloadingMiddleware middleware because we need to use custom preloading logic for menus. + * + * @param Array $preload_data Array containing the preloaded data. + * @param string $context Current editor name. + * @return array Filtered preload data. + */ +function gutenberg_navigation_editor_preload_menus( $preload_data, $context ) { + if ( 'navigation_editor' !== $context ) { + return $preload_data; + } + + $menus_data_path = gutenberg_navigation_get_menus_endpoint(); + $menus_data = array(); + if ( ! empty( $preload_data[ $menus_data_path ] ) ) { + $menus_data = array( $menus_data_path => $preload_data[ $menus_data_path ] ); + } + + if ( ! $menus_data ) { + return $preload_data; + } + + wp_add_inline_script( + 'wp-edit-navigation', + sprintf( + 'wp.apiFetch.use( wp.editNavigation.__unstableCreateMenuPreloadingMiddleware( %s ) );', + wp_json_encode( $menus_data ) + ), + 'after' + ); + + unset( $preload_data[ $menus_data_path ] ); + return $preload_data; +} + +add_filter( 'block_editor_preload_data', 'gutenberg_navigation_editor_preload_menus', 10, 2 ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 92eff202ce743..7ff03976a8b4b 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -85,7 +85,7 @@ export const getEntityRecord = ( kind, name, key = '', query ) => async ( { // for how the request is made to the REST API. // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const path = addQueryArgs( entity.baseURL + '/' + key, { + const path = addQueryArgs( entity.baseURL + ( key ? '/' + key : '' ), { ...entity.baseURLParams, ...query, } ); diff --git a/packages/edit-navigation/src/index.js b/packages/edit-navigation/src/index.js index fe3629e282949..c16264fc619a1 100644 --- a/packages/edit-navigation/src/index.js +++ b/packages/edit-navigation/src/index.js @@ -90,3 +90,5 @@ export function initialize( id, settings ) { document.getElementById( id ) ); } + +export { createMenuPreloadingMiddleware as __unstableCreateMenuPreloadingMiddleware } from './utils'; diff --git a/packages/edit-navigation/src/utils/index.js b/packages/edit-navigation/src/utils/index.js new file mode 100644 index 0000000000000..2229f6a1c8b32 --- /dev/null +++ b/packages/edit-navigation/src/utils/index.js @@ -0,0 +1,122 @@ +/** + * The purpose of this function is to create a middleware that is responsible for preloading menu-related data. + * It uses data that is returned from the /__experimental/menus endpoint for requests + * to the /__experimental/menu/ endpoint, because the data is the same. + * This way, we can avoid making additional REST API requests. + * This middleware can be removed if/when we implement caching at the wordpress/core-data level. + * + * @param {Object} preloadedData + * @return {Function} Preloading middleware. + */ +export function createMenuPreloadingMiddleware( preloadedData ) { + const cache = Object.keys( preloadedData ).reduce( ( result, path ) => { + result[ getStablePath( path ) ] = preloadedData[ path ]; + return result; + }, /** @type {Record} */ ( {} ) ); + + let menusDataLoaded = false; + let menuDataLoaded = false; + + return ( options, next ) => { + const { parse = true } = options; + if ( 'string' !== typeof options.path ) { + return next( options ); + } + + const method = options.method || 'GET'; + if ( 'GET' !== method ) { + return next( options ); + } + + const path = getStablePath( options.path ); + if ( ! menusDataLoaded && cache[ path ] ) { + menusDataLoaded = true; + return sendSuccessResponse( cache[ path ], parse ); + } + + if ( menuDataLoaded ) { + return next( options ); + } + + const matches = path.match( + /^\/__experimental\/menus\/(\d+)\?context=edit$/ + ); + if ( ! matches ) { + return next( options ); + } + + const key = Object.keys( cache )?.[ 0 ]; + const menuData = cache[ key ]?.body; + if ( ! menuData ) { + return next( options ); + } + + const menuId = parseInt( matches[ 1 ] ); + const menu = menuData.filter( ( { id } ) => id === menuId ); + + if ( menu.length > 0 ) { + menuDataLoaded = true; + // We don't have headers because we "emulate" this request + return sendSuccessResponse( + { body: menu[ 0 ], headers: {} }, + parse + ); + } + + return next( options ); + }; +} + +/** + * This is a helper function that sends a success response. + * + * @param {Object} responseData An object with the menu data + * @param {boolean} parse A boolean that controls whether to send a response or just the response data + * @return {Object} Resolved promise + */ +function sendSuccessResponse( responseData, parse ) { + return Promise.resolve( + parse + ? responseData.body + : new window.Response( JSON.stringify( responseData.body ), { + status: 200, + statusText: 'OK', + headers: responseData.headers, + } ) + ); +} + +/** + * Given a path, returns a normalized path where equal query parameter values + * will be treated as identical, regardless of order they appear in the original + * text. + * + * @param {string} path Original path. + * + * @return {string} Normalized path. + */ +export function getStablePath( path ) { + const splitted = path.split( '?' ); + const query = splitted[ 1 ]; + const base = splitted[ 0 ]; + if ( ! query ) { + return base; + } + + // 'b=1&c=2&a=5' + return ( + base + + '?' + + query + // [ 'b=1', 'c=2', 'a=5' ] + .split( '&' ) + // [ [ 'b, '1' ], [ 'c', '2' ], [ 'a', '5' ] ] + .map( ( entry ) => entry.split( '=' ) ) + // [ [ 'a', '5' ], [ 'b, '1' ], [ 'c', '2' ] ] + .sort( ( a, b ) => a[ 0 ].localeCompare( b[ 0 ] ) ) + // [ 'a=5', 'b=1', 'c=2' ] + .map( ( pair ) => pair.join( '=' ) ) + // 'a=5&b=1&c=2' + .join( '&' ) + ); +} diff --git a/phpunit/navigation-page-test.php b/phpunit/navigation-page-test.php new file mode 100644 index 0000000000000..eb1558cbd4518 --- /dev/null +++ b/phpunit/navigation-page-test.php @@ -0,0 +1,85 @@ +callback = $this->createMock( WP_Navigation_Page_Test_Callback::class ); + add_filter( 'navigation_editor_preload_paths', array( $this->callback, 'preload_paths_callback' ) ); + add_filter( 'wp_get_nav_menus', array( $this->callback, 'wp_nav_menus_callback' ) ); + } + + public function tearDown() { + parent::tearDown(); + remove_filter( 'navigation_editor_preload_paths', array( $this->callback, 'preload_paths_callback' ) ); + remove_filter( 'wp_get_nav_menus', array( $this->callback, 'wp_nav_menus_callback' ) ); + } + + public function test_gutenberg_navigation_init_function_generates_correct_preload_paths() { + $menu_id = mt_rand( 1, 1000 ); + $expected_preload_paths = array( + '/__experimental/menu-locations', + array( + '/wp/v2/pages', + 'OPTIONS', + ), + array( + '/wp/v2/posts', + 'OPTIONS', + ), + '/__experimental/menus?per_page=100&context=edit&_locale=user', + '/wp/v2/types?context=edit', + "/__experimental/menu-items?context=edit&menus={$menu_id}&per_page=100&_locale=user", + ); + + $this->callback->expects( $this->once() ) + ->method( 'preload_paths_callback' ) + ->with( $expected_preload_paths ) + ->willReturn( array() ); + + $menu = new stdClass(); + $menu->term_id = $menu_id; + $this->callback->expects( $this->once() ) + ->method( 'wp_nav_menus_callback' ) + ->with( array() ) + ->willReturn( array( new WP_Term( $menu ) ) ); + + set_current_screen( 'gutenberg_page_gutenberg-navigation' ); + gutenberg_navigation_init( 'gutenberg_page_gutenberg-navigation' ); + } + + public function test_gutenberg_navigation_editor_preload_menus_function_returns_correct_data() { + $menus_endpoint = gutenberg_navigation_get_menus_endpoint(); + $preload_data = array( + '/__experimental/menu-locations' => array( 'some menu locations' ), + 'OPTIONS' => array( + array( 'some options requests' ), + ), + $menus_endpoint => ( 'some menus' ), + ); + + $result = gutenberg_navigation_editor_preload_menus( $preload_data, 'navigation_editor' ); + $this->assertArrayHasKey( '/__experimental/menu-locations', $result ); + $this->assertArrayHasKey( 'OPTIONS', $result ); + $this->assertArrayNotHasKey( $menus_endpoint, $result ); + } +} + + +/** + * This is a utility test class for creating mocks of the callback functions + */ +class WP_Navigation_Page_Test_Callback { + + public function preload_paths_callback() {} + public function wp_nav_menus_callback() {} +}