From dbcb95c022828ae516ae8141ae2b00b42e03c9b1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 29 Jul 2015 16:02:08 +0000 Subject: [PATCH] Customizer: Ensure that all existing menus are shown in the Custom Menu widget's dropdown. * Ensure that a Custom Menu widget selecting a newly-inserted menu gets updated to use the new menu ID upon Save & Publish. * Dynamically update the visibility of the Custom Menu widget's "no menus" message when the number of menus changes between 0 and 1+. * Send all dirty Customized settings in `update-widget` Ajax request and `preview()` them so that the widget update/form callbacks have access to any data dependencies in the current Customizer session (such as newly created unsaved menus). * Update link in Custom Menu widget to point to Menus panel as opposed to Menus admin page, when in the Customizer. * Fix an issue with extra space at top immediately after creating new menu. * Fix doubled `update-widget` Ajax requests when changing select dropdown; prevent initial from being aborted. * Add missing `wp_get_nav_menus()` hooks to preview Customizer updates/inserts for `nav_menu` settings; includes tests. * Update `wp_get_nav_menu_object()` to allow a menu object to be passed in (and thus passed through). Props westonruter, adamsilverstein. Fixes #32814. git-svn-id: https://develop.svn.wordpress.org/trunk@33488 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/js/customize-nav-menus.js | 138 +++++++++++++++--- src/wp-admin/js/customize-widgets.js | 12 +- .../class-wp-customize-setting.php | 136 ++++++++++++++++- .../class-wp-customize-widgets.php | 15 +- src/wp-includes/default-widgets.php | 45 +++--- src/wp-includes/nav-menu.php | 9 +- .../tests/customize/nav-menu-setting.php | 14 ++ 7 files changed, 314 insertions(+), 55 deletions(-) diff --git a/src/wp-admin/js/customize-nav-menus.js b/src/wp-admin/js/customize-nav-menus.js index 3734a79b99..185491d91e 100644 --- a/src/wp-admin/js/customize-nav-menus.js +++ b/src/wp-admin/js/customize-nav-menus.js @@ -957,7 +957,13 @@ var control = this; api.Control.prototype.initialize.call( control, id, options ); control.active.validate = function() { - return api.section( control.section() ).active(); + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; }; }, @@ -1604,7 +1610,13 @@ * being deactivated. */ control.active.validate = function() { - return api.section( control.section() ).active(); + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; }; control.nameElement = new api.Element( control.container.find( '.menu-name-field' ) ); @@ -1650,7 +1662,13 @@ * being deactivated. */ control.active.validate = function() { - return api.section( control.section() ).active(); + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; }; control.autoAddElement = new api.Element( control.container.find( 'input[type=checkbox].auto_add' ) ); @@ -1693,7 +1711,9 @@ var control = this, menuId = control.params.menu_id, menu = control.setting(), - name; + name, + widgetTemplate, + select; if ( 'undefined' === typeof this.params.menu_id ) { throw new Error( 'params.menu_id was not defined' ); @@ -1705,7 +1725,13 @@ * being deactivated. */ control.active.validate = function() { - return api.section( control.section() ).active(); + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; }; control.$controlSection = control.container.closest( '.control-section' ); @@ -1727,16 +1753,28 @@ if ( menu ) { name = displayNavMenuName( menu.name ); + // Add the menu to the existing controls. api.control.each( function( widgetControl ) { if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) { return; } - var select = widgetControl.container.find( 'select' ); - if ( select.find( 'option[value=' + String( menuId ) + ']' ).length === 0 ) { + widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).show(); + widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).hide(); + + select = widgetControl.container.find( 'select' ); + if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) { select.append( new Option( name, menuId ) ); } } ); - $( '#available-widgets-list .widget-inside:has(input.id_base[value=nav_menu]) select:first' ).append( new Option( name, menuId ) ); + + // Add the menu to the widget template. + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).show(); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).hide(); + select = widgetTemplate.find( '.widget-inside select:first' ); + if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) { + select.append( new Option( name, menuId ) ); + } } }, @@ -1761,7 +1799,6 @@ var select = widgetControl.container.find( 'select' ); select.find( 'option[value=' + String( menuId ) + ']' ).text( name ); }); - $( '#available-widgets-list .widget-inside:has(input.id_base[value=nav_menu]) select:first option[value=' + String( menuId ) + ']' ).text( name ); } } ); @@ -1833,8 +1870,8 @@ menuItemControl.setting.set( setting ); }); }); - }); + }); control.isReordering = false; /** @@ -1871,7 +1908,9 @@ var control = this, section, menuId = control.params.menu_id, - removeSection; + removeSection, + widgetTemplate, + navMenuCount = 0; section = api.section( control.section() ); removeSection = function() { section.container.remove(); @@ -1890,6 +1929,12 @@ removeSection(); } + api.each(function( setting ) { + if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) { + navMenuCount += 1; + } + }); + // Remove the menu from any Custom Menu widgets. api.control.each(function( widgetControl ) { if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) { @@ -1899,9 +1944,17 @@ if ( select.val() === String( menuId ) ) { select.prop( 'selectedIndex', 0 ).trigger( 'change' ); } - select.find( 'option[value=' + String( menuId ) + ']' ).remove(); + + widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetControl.container.find( 'option[value=' + String( menuId ) + ']' ).remove(); }); - $( '#available-widgets-list .widget-inside:has(input.id_base[value=nav_menu]) select:first option[value=' + String( menuId ) + ']' ).remove(); + + // Remove the menu to the nav menu widget template. + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetTemplate.find( 'option[value=' + String( menuId ) + ']' ).remove(); }, // Setup theme location checkboxes. @@ -2294,6 +2347,9 @@ // Focus on the new menu section. api.section( customizeId ).focus(); // @todo should we focus on the new menu's control and open the add-items panel? Thinking user flow... + + // Fix an issue with extra space at top immediately after creating new menu. + $( '#menu-to-edit' ).css( 'margin-top', 0 ); } }); @@ -2359,7 +2415,7 @@ var insertedMenuIdMapping = {}; _( data.nav_menu_updates ).each(function( update ) { - var oldCustomizeId, newCustomizeId, customizeId, oldSetting, newSetting, setting, settingValue, oldSection, newSection, wasSaved; + var oldCustomizeId, newCustomizeId, customizeId, oldSetting, newSetting, setting, settingValue, oldSection, newSection, wasSaved, widgetTemplate, navMenuCount; if ( 'inserted' === update.status ) { if ( ! update.previous_term_id ) { throw new Error( 'Expected previous_term_id' ); @@ -2409,18 +2465,44 @@ } } ); - // Remove old setting and control. - oldSection.container.remove(); - api.section.remove( oldCustomizeId ); - - // Add new control to take its place. + // Add new control for the new menu. api.section.add( newCustomizeId, newSection ); - // Delete the placeholder and preview the new setting. + // Update the values for nav menus in Custom Menu controls. + api.control.each( function( setting ) { + if ( ! setting.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== setting.params.widget_id_base ) { + return; + } + var select, oldMenuOption, newMenuOption; + select = setting.container.find( 'select' ); + oldMenuOption = select.find( 'option[value=' + String( update.previous_term_id ) + ']' ); + newMenuOption = select.find( 'option[value=' + String( update.term_id ) + ']' ); + newMenuOption.prop( 'selected', oldMenuOption.prop( 'selected' ) ); + oldMenuOption.remove(); + } ); + + // Delete the old placeholder nav_menu. oldSetting.callbacks.disable(); // Prevent setting triggering Customizer dirty state when set. oldSetting.set( false ); oldSetting.preview(); newSetting.preview(); + oldSetting._dirty = false; + + // Remove nav_menu section. + oldSection.container.remove(); + api.section.remove( oldCustomizeId ); + + // Remove the menu to the nav menu widget template. + navMenuCount = 0; + api.each(function( setting ) { + if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) { + navMenuCount += 1; + } + }); + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetTemplate.find( 'option[value=' + String( update.previous_term_id ) + ']' ).remove(); // Update nav_menu_locations to reference the new ID. api.each( function( setting ) { @@ -2437,8 +2519,6 @@ // @todo This doesn't seem to be working. newSection.expand(); } - - // @todo Update the Custom Menu selects, ensuring the newly-inserted IDs are used for any that have selected a placeholder menu. } else if ( 'updated' === update.status ) { customizeId = 'nav_menu[' + String( update.term_id ) + ']'; if ( ! api.has( customizeId ) ) { @@ -2510,7 +2590,7 @@ previewer: api.previewer } ); - // Remove old setting and control. + // Remove old control. oldControl.container.remove(); api.control.remove( oldCustomizeId ); @@ -2522,12 +2602,22 @@ oldSetting.set( false ); oldSetting.preview(); newSetting.preview(); + oldSetting._dirty = false; newControl.container.toggleClass( 'menu-item-edit-inactive', oldControl.container.hasClass( 'menu-item-edit-inactive' ) ); } }); - // @todo trigger change event for each Custom Menu widget that was modified. + /* + * Update the settings for any nav_menu widgets that had selected a placeholder ID. + */ + _.each( data.widget_nav_menu_updates, function( widgetSettingValue, widgetSettingId ) { + var setting = api( widgetSettingId ); + if ( setting ) { + setting._value = widgetSettingValue; + setting.preview(); // Send to the preview now so that menu refresh will use the inserted menu. + } + }); }; /** diff --git a/src/wp-admin/js/customize-widgets.js b/src/wp-admin/js/customize-widgets.js index 2907b4f1b8..8aa603a76d 100644 --- a/src/wp-admin/js/customize-widgets.js +++ b/src/wp-admin/js/customize-widgets.js @@ -786,12 +786,11 @@ // Handle widgets that support live previews $widgetContent.on( 'change input propertychange', ':input', function( e ) { - if ( self.liveUpdateMode ) { - if ( e.type === 'change' ) { - self.updateWidget(); - } else if ( this.checkValidity && this.checkValidity() ) { - updateWidgetDebounced(); - } + if ( ! self.liveUpdateMode ) { + return; + } + if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) { + updateWidgetDebounced(); } } ); @@ -1041,6 +1040,7 @@ params.wp_customize = 'on'; params.nonce = api.Widgets.data.nonce; params.theme = api.settings.theme.stylesheet; + params.customized = wp.customize.previewer.query().customized; data = $.param( params ); $inputs = this._getInputs( $widgetContent ); diff --git a/src/wp-includes/class-wp-customize-setting.php b/src/wp-includes/class-wp-customize-setting.php index fa943464a1..d007965e8a 100644 --- a/src/wp-includes/class-wp-customize-setting.php +++ b/src/wp-includes/class-wp-customize-setting.php @@ -1625,11 +1625,101 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { $this->_original_value = $this->value(); $this->_previewed_blog_id = get_current_blog_id(); + add_filter( 'wp_get_nav_menus', array( $this, 'filter_wp_get_nav_menus' ), 10, 2 ); add_filter( 'wp_get_nav_menu_object', array( $this, 'filter_wp_get_nav_menu_object' ), 10, 2 ); add_filter( 'default_option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) ); add_filter( 'option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) ); } + /** + * Filter the wp_get_nav_menus() result to ensure the inserted menu object is included, and the deleted one is removed. + * + * @since 4.3.0 + * @access public + * + * @see wp_get_nav_menus() + * + * @param array $menus An array of menu objects. + * @param array $args An array of arguments used to retrieve menu objects. + * @return array + */ + public function filter_wp_get_nav_menus( $menus, $args ) { + if ( get_current_blog_id() !== $this->_previewed_blog_id ) { + return $menus; + } + + $setting_value = $this->value(); + $is_delete = ( false === $setting_value ); + $index = -1; + + // Find the existing menu item's position in the list. + foreach ( $menus as $i => $menu ) { + if ( (int) $this->term_id === (int) $menu->term_id || (int) $this->previous_term_id === (int) $menu->term_id ) { + $index = $i; + break; + } + } + + if ( $is_delete ) { + // Handle deleted menu by removing it from the list. + if ( -1 !== $index ) { + array_splice( $menus, $index, 1 ); + } + } else { + // Handle menus being updated or inserted. + $menu_obj = (object) array_merge( array( + 'term_id' => $this->term_id, + 'term_taxonomy_id' => $this->term_id, + 'slug' => sanitize_title( $setting_value['name'] ), + 'count' => 0, + 'term_group' => 0, + 'taxonomy' => self::TAXONOMY, + 'filter' => 'raw', + ), $setting_value ); + + array_splice( $menus, $index, ( -1 === $index ? 0 : 1 ), array( $menu_obj ) ); + } + + // Make sure the menu objects get re-sorted after an update/insert. + if ( ! $is_delete && ! empty( $args['orderby'] ) ) { + $this->_current_menus_sort_orderby = $args['orderby']; + usort( $menus, array( $this, '_sort_menus_by_orderby' ) ); + } + // @todo add support for $args['hide_empty'] === true + + return $menus; + } + + /** + * Temporary non-closure passing of orderby value to function. + * + * @since 4.3.0 + * @access protected + * @var string + * + * @see WP_Customize_Nav_Menu_Setting::filter_wp_get_nav_menus() + * @see WP_Customize_Nav_Menu_Setting::_sort_menus_by_orderby() + */ + protected $_current_menus_sort_orderby; + + /** + * Sort menu objects by the class-supplied orderby property. + * + * This is a workaround for a lack of closures. + * + * @since 4.3.0 + * @access protected + * @param object $menu1 + * @param object $menu2 + * @return int + * + * @see WP_Customize_Nav_Menu_Setting::filter_wp_get_nav_menus() + */ + protected function _sort_menus_by_orderby( $menu1, $menu2 ) { + $key = $this->_current_menus_sort_orderby; + return strcmp( $menu1->$key, $menu2->$key ); + } + /** * Filter the wp_get_nav_menu_object() result to supply the previewed menu object. * @@ -1751,6 +1841,17 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { return apply_filters( "customize_sanitize_{$this->id}", $value, $this ); } + /** + * Storage for data to be sent back to client in customize_save_response filter. + * + * @access protected + * @since 4.3.0 + * @var array + * + * @see WP_Customize_Nav_Menu_Setting::amend_customize_save_response() + */ + protected $_widget_nav_menu_updates = array(); + /** * Create/update the nav_menu term for this setting. * @@ -1761,7 +1862,7 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { * To delete a menu, the client can send false as the value. * * @since 4.3.0 - * @access public + * @access protected * * @see wp_update_nav_menu_object() * @@ -1844,8 +1945,8 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { update_option( 'nav_menu_options', $nav_menu_options ); } - // Make sure that new menus assigned to nav menu locations use their new IDs. if ( 'inserted' === $this->update_status ) { + // Make sure that new menus assigned to nav menu locations use their new IDs. foreach ( $this->manager->settings() as $setting ) { if ( ! preg_match( '/^nav_menu_locations\[/', $setting->id ) ) { continue; @@ -1857,6 +1958,26 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { $setting->save(); } } + + // Make sure that any nav_menu widgets referencing the placeholder nav menu get updated and sent back to client. + foreach ( array_keys( $this->manager->unsanitized_post_values() ) as $setting_id ) { + $nav_menu_widget_setting = $this->manager->get_setting( $setting_id ); + if ( ! $nav_menu_widget_setting || ! preg_match( '/^widget_nav_menu\[/', $nav_menu_widget_setting->id ) ) { + continue; + } + + $widget_instance = $nav_menu_widget_setting->post_value(); // Note that this calls WP_Customize_Widgets::sanitize_widget_instance(). + if ( empty( $widget_instance['nav_menu'] ) || intval( $widget_instance['nav_menu'] ) !== $this->previous_term_id ) { + continue; + } + + $widget_instance['nav_menu'] = $this->term_id; + $updated_widget_instance = $this->manager->widgets->sanitize_widget_js_instance( $widget_instance ); + $this->manager->set_post_value( $nav_menu_widget_setting->id, $updated_widget_instance ); + $nav_menu_widget_setting->save(); + + $this->_widget_nav_menu_updates[ $nav_menu_widget_setting->id ] = $updated_widget_instance; + } } } @@ -1864,7 +1985,7 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { * Updates a nav_menu_options array. * * @since 4.3.0 - * @access public + * @access protected * * @see WP_Customize_Nav_Menu_Setting::filter_nav_menu_options() * @see WP_Customize_Nav_Menu_Setting::update() @@ -1905,6 +2026,9 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { if ( ! isset( $data['nav_menu_updates'] ) ) { $data['nav_menu_updates'] = array(); } + if ( ! isset( $data['widget_nav_menu_updates'] ) ) { + $data['widget_nav_menu_updates'] = array(); + } $data['nav_menu_updates'][] = array( 'term_id' => $this->term_id, @@ -1914,6 +2038,12 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting { 'saved_value' => 'deleted' === $this->update_status ? null : $this->value(), ); + $data['widget_nav_menu_updates'] = array_merge( + $data['widget_nav_menu_updates'], + $this->_widget_nav_menu_updates + ); + $this->_widget_nav_menu_updates = array(); + return $data; } } diff --git a/src/wp-includes/class-wp-customize-widgets.php b/src/wp-includes/class-wp-customize-widgets.php index cf93bb50c9..775e8bbe7f 100644 --- a/src/wp-includes/class-wp-customize-widgets.php +++ b/src/wp-includes/class-wp-customize-widgets.php @@ -1241,6 +1241,20 @@ final class WP_Customize_Widgets { public function call_widget_update( $widget_id ) { global $wp_registered_widget_updates, $wp_registered_widget_controls; + $setting_id = $this->get_setting_id( $widget_id ); + + /* + * Make sure that other setting changes have previewed since this widget + * may depend on them (e.g. Menus being present for Custom Menu widget). + */ + if ( ! did_action( 'customize_preview_init' ) ) { + foreach ( $this->manager->settings() as $setting ) { + if ( $setting->id !== $setting_id ) { + $setting->preview(); + } + } + } + $this->start_capturing_option_updates(); $parsed_id = $this->parse_widget_id( $widget_id ); $option_name = 'widget_' . $parsed_id['id_base']; @@ -1321,7 +1335,6 @@ final class WP_Customize_Widgets { * in place from WP_Customize_Setting::preview() will use this value * instead of the default widget instance value (an empty array). */ - $setting_id = $this->get_setting_id( $widget_id ); $this->manager->set_post_value( $setting_id, $instance ); // Obtain the widget control with the updated instance in place. diff --git a/src/wp-includes/default-widgets.php b/src/wp-includes/default-widgets.php index cb6c2bbd41..22e61e532c 100644 --- a/src/wp-includes/default-widgets.php +++ b/src/wp-includes/default-widgets.php @@ -1570,28 +1570,35 @@ class WP_Widget_Tag_Cloud extends WP_Widget { $menus = wp_get_nav_menus(); // If no menus exists, direct the user to go and create some. - if ( !$menus ) { - echo '

'. sprintf( __('No menus have been created yet. Create some.'), admin_url('nav-menus.php') ) .'

'; - return; - } ?> -

- - -

-

- - + ?> + Create some.' ), esc_attr( $url ) ); ?>

+ array() ) ); $this->assertContains( $menu_id, $nav_menu_options['auto_add'] ); + + $menus = wp_get_nav_menus(); + $menus_ids = wp_list_pluck( $menus, 'term_id' ); + $i = array_search( $menu_id, $menus_ids ); + $this->assertNotFalse( $i, 'Update-previewed menu does not appear in wp_get_nav_menus()' ); + $filtered_menu = $menus[ $i ]; + $this->assertEquals( 'Name 2', $filtered_menu->name ); } /** @@ -249,6 +256,13 @@ class Test_WP_Customize_Nav_Menu_Setting extends WP_UnitTestCase { $nav_menu_options = $this->get_nav_menu_items_option(); $this->assertNotContains( $menu_id, $nav_menu_options['auto_add'] ); + + $menus = wp_get_nav_menus(); + $menus_ids = wp_list_pluck( $menus, 'term_id' ); + $i = array_search( $menu_id, $menus_ids ); + $this->assertNotFalse( $i, 'Insert-previewed menu was not injected into wp_get_nav_menus()' ); + $filtered_menu = $menus[ $i ]; + $this->assertEquals( 'New Menu Name 1', $filtered_menu->name ); } /**