From dcbbeffc57747beb87e9cd862470a2a812d40af0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 9 Mar 2016 00:08:51 +0000 Subject: [PATCH] Customize: Fix regressions and harden implementation of selective refresh for nav menus. * Request full refresh if there are nav menu instances that lack partials for a changed setting. * Restore `WP_Customize_Nav_Menus::$preview_nav_menu_instance_args` and `WP_Customize_Nav_Menus::export_preview_data()` from 4.3, and keeping a tally of all `wp_nav_menu()` calls regardless of whether they can use selective refresh. * Ensure that all instances of `wp_nav_menu()` are tallied, regardless of whether they are made during the initial preview call or during subsequent partial renderings. Export `nav_menu_instance_args` with each partial rendering response just as they are returned when rendering the preview as a whole. * Fix issues with Custom Menu widget where nav menu items would fail to render when switching between menus when a menu lacked items to begin with. * Make sure the fallback behavior is invoked when the partial is no longer associated with a menu. * Do fallback behavior to refresh preview when all menu items are removed from a menu. Follows [36586]. See #27355. Fixes #35362. git-svn-id: https://develop.svn.wordpress.org/trunk@36889 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-customize-nav-menus.php | 50 +++- .../js/customize-preview-nav-menus.js | 213 ++++++++++++++++-- tests/phpunit/tests/customize/nav-menus.php | 52 ++++- 3 files changed, 284 insertions(+), 31 deletions(-) diff --git a/src/wp-includes/class-wp-customize-nav-menus.php b/src/wp-includes/class-wp-customize-nav-menus.php index 1197dc1763..cb8994c891 100644 --- a/src/wp-includes/class-wp-customize-nav-menus.php +++ b/src/wp-includes/class-wp-customize-nav-menus.php @@ -821,6 +821,15 @@ final class WP_Customize_Nav_Menus { // Start functionality specific to partial-refresh of menu changes in Customizer preview. // + /** + * Nav menu args used for each instance, keyed by the args HMAC. + * + * @since 4.3.0 + * @access public + * @var array + */ + public $preview_nav_menu_instance_args = array(); + /** * Filter arguments for dynamic nav_menu selective refresh partials. * @@ -862,6 +871,8 @@ final class WP_Customize_Nav_Menus { add_action( 'wp_enqueue_scripts', array( $this, 'customize_preview_enqueue_deps' ) ); add_filter( 'wp_nav_menu_args', array( $this, 'filter_wp_nav_menu_args' ), 1000 ); add_filter( 'wp_nav_menu', array( $this, 'filter_wp_nav_menu' ), 10, 2 ); + add_filter( 'wp_footer', array( $this, 'export_preview_data' ), 1 ); + add_filter( 'customize_render_partials_response', array( $this, 'export_partial_rendered_nav_menu_instances' ) ); } /** @@ -881,7 +892,7 @@ final class WP_Customize_Nav_Menus { * wp_nav_menu() can use selective refreshed. A wp_nav_menu() can be * selective refreshed if... */ - $can_selective_refresh = ( + $can_partial_refresh = ( // ...if wp_nav_menu() is directly echoing out the menu (and thus isn't manipulating the string after generated), ! empty( $args['echo'] ) && @@ -904,13 +915,16 @@ final class WP_Customize_Nav_Menus { ( isset( $args['items_wrap'] ) && '<' === substr( $args['items_wrap'], 0, 1 ) ) ) ); - - if ( ! $can_selective_refresh ) { - return $args; - } + $args['can_partial_refresh'] = $can_partial_refresh; $exported_args = $args; + // Empty out args which may not be JSON-serializable. + if ( ! $can_partial_refresh ) { + $exported_args['fallback_cb'] = ''; + $exported_args['walker'] = ''; + } + /* * Replace object menu arg with a term_id menu arg, as this exports better * to JS and is easier to compare hashes. @@ -923,7 +937,7 @@ final class WP_Customize_Nav_Menus { $exported_args['args_hmac'] = $this->hash_nav_menu_args( $exported_args ); $args['customize_preview_nav_menus_args'] = $exported_args; - + $this->preview_nav_menu_instance_args[ $exported_args['args_hmac'] ] = $exported_args; return $args; } @@ -942,7 +956,7 @@ final class WP_Customize_Nav_Menus { * @return null */ public function filter_wp_nav_menu( $nav_menu_content, $args ) { - if ( ! empty( $args->customize_preview_nav_menus_args ) ) { + if ( isset( $args->customize_preview_nav_menus_args['can_partial_refresh'] ) && $args->customize_preview_nav_menus_args['can_partial_refresh'] ) { $attributes = sprintf( ' data-customize-partial-id="%s"', esc_attr( 'nav_menu_instance[' . $args->customize_preview_nav_menus_args['args_hmac'] . ']' ) ); $attributes .= ' data-customize-partial-type="nav_menu_instance"'; $attributes .= sprintf( ' data-customize-partial-placement-context="%s"', esc_attr( wp_json_encode( $args->customize_preview_nav_menus_args ) ) ); @@ -987,11 +1001,29 @@ final class WP_Customize_Nav_Menus { * Exports data from PHP to JS. * * @since 4.3.0 - * @deprecated 4.5.0 Obsolete * @access public */ public function export_preview_data() { - _deprecated_function( __METHOD__, '4.5.0' ); + + // Why not wp_localize_script? Because we're not localizing, and it forces values into strings. + $exports = array( + 'navMenuInstanceArgs' => $this->preview_nav_menu_instance_args, + ); + printf( '', wp_json_encode( $exports ) ); + } + + /** + * Export any wp_nav_menu() calls during the rendering of any partials. + * + * @since 4.5.0 + * @access public + * + * @param array $response Response. + * @return array Response. + */ + public function export_partial_rendered_nav_menu_instances( $response ) { + $response['nav_menu_instance_args'] = $this->preview_nav_menu_instance_args; + return $response; } /** diff --git a/src/wp-includes/js/customize-preview-nav-menus.js b/src/wp-includes/js/customize-preview-nav-menus.js index 1ba3c87b6a..bd42f5e081 100644 --- a/src/wp-includes/js/customize-preview-nav-menus.js +++ b/src/wp-includes/js/customize-preview-nav-menus.js @@ -1,7 +1,15 @@ +/* global _wpCustomizePreviewNavMenusExports */ wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( $, _, wp, api ) { 'use strict'; - var self = {}; + var self = { + data: { + navMenuInstanceArgs: {} + } + }; + if ( 'undefined' !== typeof _wpCustomizePreviewNavMenusExports ) { + _.extend( self.data, _wpCustomizePreviewNavMenusExports ); + } /** * Initialize nav menus preview. @@ -10,7 +18,26 @@ wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( var self = this; if ( api.selectiveRefresh ) { - self.watchNavMenuLocationChanges(); + // Listen for changes to settings related to nav menus. + api.each( function( setting ) { + self.bindSettingListener( setting ); + } ); + api.bind( 'add', function( setting ) { + self.bindSettingListener( setting, { fire: true } ); + } ); + api.bind( 'remove', function( setting ) { + self.unbindSettingListener( setting ); + } ); + + /* + * Ensure that wp_nav_menu() instances nested inside of other partials + * will be recognized as being present on the page. + */ + api.selectiveRefresh.bind( 'render-partials-response', function( response ) { + if ( response.nav_menu_instance_args ) { + _.extend( self.data.navMenuInstanceArgs, response.nav_menu_instance_args ); + } + } ); } api.preview.bind( 'active', function() { @@ -127,6 +154,31 @@ wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( ); }, + /** + * Make sure that partial fallback behavior is invoked if there is no associated menu. + * + * @since 4.5.0 + * + * @returns {Promise} + */ + refresh: function() { + var partial = this, menuId, deferred = $.Deferred(); + + // Make sure the fallback behavior is invoked when the partial is no longer associated with a menu. + if ( _.isNumber( partial.params.navMenuArgs.menu ) ) { + menuId = partial.params.navMenuArgs.menu; + } else if ( partial.params.navMenuArgs.theme_location && api.has( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ) ) { + menuId = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ).get(); + } + if ( ! menuId ) { + partial.fallback(); + deferred.reject(); + return deferred.promise(); + } + + return api.selectiveRefresh.Partial.prototype.refresh.call( partial ); + }, + /** * Render content. * @@ -135,6 +187,12 @@ wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( */ renderContent: function( placement ) { var partial = this, previousContainer = placement.container; + + // Do fallback behavior to refresh preview if menu is now empty. + if ( '' === placement.addedContent ) { + placement.partial.fallback(); + } + if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) { // Trigger deprecated event. @@ -152,33 +210,148 @@ wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( api.selectiveRefresh.partialConstructor.nav_menu_instance = self.NavMenuInstancePartial; /** - * Watch for changes to nav_menu_locations[] settings. + * Request full refresh if there are nav menu instances that lack partials which also match the supplied args. * - * Refresh partials associated with the given nav_menu_locations[] setting, - * or request an entire preview refresh if there are no containers in the - * document for a partial associated with the theme location. + * @param {object} navMenuInstanceArgs + */ + self.handleUnplacedNavMenuInstances = function( navMenuInstanceArgs ) { + var unplacedNavMenuInstances; + unplacedNavMenuInstances = _.filter( _.values( self.data.navMenuInstanceArgs ), function( args ) { + return ! api.selectiveRefresh.partial.has( 'nav_menu_instance[' + args.args_hmac + ']' ); + } ); + if ( _.findWhere( unplacedNavMenuInstances, navMenuInstanceArgs ) ) { + api.selectiveRefresh.requestFullRefresh(); + return true; + } + return false; + }; + + /** + * Add change listener for a nav_menu[], nav_menu_item[], or nav_menu_locations[] setting. * * @since 4.5.0 + * + * @param {wp.customize.Value} setting + * @param {object} [options] + * @param {boolean} options.fire Whether to invoke the callback after binding. + * This is used when a dynamic setting is added. + * @return {boolean} Whether the setting was bound. */ - self.watchNavMenuLocationChanges = function() { - api.bind( 'change', function( setting ) { - var themeLocation, themeLocationPartialFound = false, matches = setting.id.match( /^nav_menu_locations\[(.+)]$/ ); - if ( ! matches ) { + self.bindSettingListener = function( setting, options ) { + var matches; + options = options || {}; + + matches = setting.id.match( /^nav_menu\[(-?\d+)]$/ ); + if ( matches ) { + setting._navMenuId = parseInt( matches[1], 10 ); + setting.bind( this.onChangeNavMenuSetting ); + if ( options.fire ) { + this.onChangeNavMenuSetting.call( setting, setting(), false ); + } + return true; + } + + matches = setting.id.match( /^nav_menu_item\[(-?\d+)]$/ ); + if ( matches ) { + setting._navMenuItemId = parseInt( matches[1], 10 ); + setting.bind( this.onChangeNavMenuItemSetting ); + if ( options.fire ) { + this.onChangeNavMenuItemSetting.call( setting, setting(), false ); + } + return true; + } + + matches = setting.id.match( /^nav_menu_locations\[(.+?)]/ ); + if ( matches ) { + setting._navMenuThemeLocation = matches[1]; + setting.bind( this.onChangeNavMenuLocationsSetting ); + if ( options.fire ) { + this.onChangeNavMenuLocationsSetting.call( setting, setting(), false ); + } + return true; + } + + return false; + }; + + /** + * Remove change listeners for nav_menu[], nav_menu_item[], or nav_menu_locations[] setting. + * + * @since 4.5.0 + * + * @param {wp.customize.Value} setting + */ + self.unbindSettingListener = function( setting ) { + setting.unbind( this.onChangeNavMenuSetting ); + setting.unbind( this.onChangeNavMenuItemSetting ); + setting.unbind( this.onChangeNavMenuLocationsSetting ); + }; + + /** + * Handle change for nav_menu[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @this {wp.customize.Value} + */ + self.onChangeNavMenuSetting = function() { + var setting = this; + + self.handleUnplacedNavMenuInstances( { + menu: setting._navMenuId + } ); + + // Ensure all nav menu instances with a theme_location assigned to this menu are handled. + api.each( function( otherSetting ) { + if ( ! otherSetting._navMenuThemeLocation ) { return; } - themeLocation = matches[1]; - api.selectiveRefresh.partial.each( function( partial ) { - if ( partial.extended( self.NavMenuInstancePartial ) && partial.params.navMenuArgs.theme_location === themeLocation ) { - partial.refresh(); - themeLocationPartialFound = true; - } - } ); - - if ( ! themeLocationPartialFound ) { - api.selectiveRefresh.requestFullRefresh(); + if ( setting._navMenuId === otherSetting() ) { + self.handleUnplacedNavMenuInstances( { + theme_location: otherSetting._navMenuThemeLocation + } ); } } ); }; + + /** + * Handle change for nav_menu_item[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @param {object} newItem New value for nav_menu_item[] setting. + * @param {object} oldItem Old value for nav_menu_item[] setting. + * @this {wp.customize.Value} + */ + self.onChangeNavMenuItemSetting = function( newItem, oldItem ) { + var item = newItem || oldItem, navMenuSetting; + navMenuSetting = api( 'nav_menu[' + String( item.nav_menu_term_id ) + ']' ); + if ( navMenuSetting ) { + self.onChangeNavMenuSetting.call( navMenuSetting ); + } + }; + + /** + * Handle change for nav_menu_locations[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @this {wp.customize.Value} + */ + self.onChangeNavMenuLocationsSetting = function() { + var setting = this, hasNavMenuInstance; + self.handleUnplacedNavMenuInstances( { + theme_location: setting._navMenuThemeLocation + } ); + + // If there are no wp_nav_menu() instances that refer to the theme location, do full refresh. + hasNavMenuInstance = !! _.findWhere( _.values( self.data.navMenuInstanceArgs ), { + theme_location: setting._navMenuThemeLocation + } ); + if ( ! hasNavMenuInstance ) { + api.selectiveRefresh.requestFullRefresh(); + } + }; } /** diff --git a/tests/phpunit/tests/customize/nav-menus.php b/tests/phpunit/tests/customize/nav-menus.php index fd380bc04c..f5d56b661c 100644 --- a/tests/phpunit/tests/customize/nav-menus.php +++ b/tests/phpunit/tests/customize/nav-menus.php @@ -617,15 +617,17 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { function test_filter_wp_nav_menu_args() { do_action( 'customize_register', $this->wp_customize ); $menus = $this->wp_customize->nav_menus; + $menu_id = wp_create_nav_menu( 'Foo' ); $results = $menus->filter_wp_nav_menu_args( array( 'echo' => true, 'fallback_cb' => 'wp_page_menu', 'walker' => '', - 'menu' => wp_create_nav_menu( 'Foo' ), + 'menu' => $menu_id, 'items_wrap' => '', ) ); $this->assertArrayHasKey( 'customize_preview_nav_menus_args', $results ); + $this->assertTrue( $results['can_partial_refresh'] ); $results = $menus->filter_wp_nav_menu_args( array( 'echo' => false, @@ -633,7 +635,8 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { 'walker' => new Walker_Nav_Menu(), 'items_wrap' => '', ) ); - $this->assertArrayNotHasKey( 'customize_preview_nav_menus_args', $results ); + $this->assertFalse( $results['can_partial_refresh'] ); + $this->assertArrayHasKey( 'customize_preview_nav_menus_args', $results ); $this->assertEquals( 'wp_page_menu', $results['fallback_cb'] ); $nav_menu_term = get_term( wp_create_nav_menu( 'Bar' ) ); @@ -644,8 +647,39 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { 'menu' => $nav_menu_term, 'items_wrap' => '', ) ); + $this->assertTrue( $results['can_partial_refresh'] ); $this->assertArrayHasKey( 'customize_preview_nav_menus_args', $results ); $this->assertEquals( $nav_menu_term->term_id, $results['customize_preview_nav_menus_args']['menu'] ); + + $results = $menus->filter_wp_nav_menu_args( array( + 'echo' => true, + 'fallback_cb' => 'wp_page_menu', + 'walker' => '', + 'menu' => $menu_id, + 'container' => 'div', + 'items_wrap' => '%3$s', + ) ); + $this->assertTrue( $results['can_partial_refresh'] ); + + $results = $menus->filter_wp_nav_menu_args( array( + 'echo' => true, + 'fallback_cb' => 'wp_page_menu', + 'walker' => '', + 'menu' => $menu_id, + 'container' => false, + 'items_wrap' => '', + ) ); + $this->assertTrue( $results['can_partial_refresh'] ); + + $results = $menus->filter_wp_nav_menu_args( array( + 'echo' => true, + 'fallback_cb' => 'wp_page_menu', + 'walker' => '', + 'menu' => $menu_id, + 'container' => false, + 'items_wrap' => '%3$s', + ) ); + $this->assertFalse( $results['can_partial_refresh'] ); } /** @@ -690,6 +724,20 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { $this->assertTrue( wp_script_is( 'customize-preview-nav-menus' ) ); } + /** + * Test WP_Customize_Nav_Menus::export_preview_data() method. + * + * @see WP_Customize_Nav_Menus::export_preview_data() + */ + function test_export_preview_data() { + ob_start(); + $this->wp_customize->nav_menus->export_preview_data(); + $html = ob_get_clean(); + $this->assertTrue( (bool) preg_match( '/_wpCustomizePreviewNavMenusExports = ({.+})/s', $html, $matches ) ); + $exported_data = json_decode( $matches[1], true ); + $this->assertArrayHasKey( 'navMenuInstanceArgs', $exported_data ); + } + /** * Test WP_Customize_Nav_Menus::render_nav_menu_partial() method. *