From f26ccf6fac5fbcd21e99f21a7bc876132ec12d54 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Jun 2016 19:16:54 +0000 Subject: [PATCH] Customize: Update server-sent setting validation notifications as changes are entered. Send back setting validities with full refreshes and selective refreshes so that invalid settings can have notifications displayed immediately before attempting save, and so that these notifications can be cleared as soon as the input is corrected. * Splits out JS logic for listing controls into separate methods `wp.customize.Setting.prototype.findControls()` and `wp.customize.findControlsForSettings()`. * Adds a `setting` property to the `data` on notifications added to controls that are synced from their settings. * Adds `selective-refresh-setting-validities` message sent from preview to pane. * Changes `WP_Customize_Manager::validate_setting_values()` to return when settings are valid as well as invalid. * Adds `WP_Customize_Manager::prepare_setting_validity_for_js()`. * Add setting validities to data exported to JS in Customizer Preview and in selective refresh responses. Fixes #36944. git-svn-id: https://develop.svn.wordpress.org/trunk@37700 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/js/customize-controls.js | 249 +++++++++++++----- .../class-wp-customize-manager.php | 79 ++++-- .../class-wp-customize-selective-refresh.php | 4 + src/wp-includes/js/customize-preview.js | 3 +- .../js/customize-selective-refresh.js | 12 + tests/phpunit/tests/customize/manager.php | 30 ++- .../customize/selective-refresh-ajax.php | 1 + tests/qunit/wp-admin/js/customize-controls.js | 19 ++ 8 files changed, 301 insertions(+), 96 deletions(-) diff --git a/src/wp-admin/js/customize-controls.js b/src/wp-admin/js/customize-controls.js index 2a5a5b9d1a..c5c41c2494 100644 --- a/src/wp-admin/js/customize-controls.js +++ b/src/wp-admin/js/customize-controls.js @@ -43,6 +43,24 @@ case 'postMessage': return this.previewer.send( 'setting', [ this.id, this() ] ); } + }, + + /** + * Find controls associated with this setting. + * + * @since 4.6.0 + * @returns {wp.customize.Control[]} Controls associated with setting. + */ + findControls: function() { + var setting = this, controls = []; + api.control.each( function( control ) { + _.each( control.settings, function( controlSetting ) { + if ( controlSetting.id === setting.id ) { + controls.push( control ); + } + } ); + } ); + return controls; } }); @@ -1543,9 +1561,19 @@ control.setting = control.settings['default'] || null; + // Add setting notifications to the control notification. _.each( control.settings, function( setting ) { setting.notifications.bind( 'add', function( settingNotification ) { - var controlNotification = new api.Notification( setting.id + ':' + settingNotification.code, settingNotification ); + var controlNotification, code, params; + code = setting.id + ':' + settingNotification.code; + params = _.extend( + {}, + settingNotification, + { + setting: setting.id + } + ); + controlNotification = new api.Notification( code, params ); control.notifications.add( controlNotification.code, controlNotification ); } ); setting.notifications.bind( 'remove', function( settingNotification ) { @@ -2908,6 +2936,13 @@ } } ); } ); + + if ( data.settingValidities ) { + api._handleSettingValidities( { + settingValidities: data.settingValidities, + focusInvalidControl: false + } ); + } } ); this.request = $.ajax( this.previewUrl(), { @@ -3430,68 +3465,14 @@ }; }, - /** - * Handle invalid_settings in an error response for the customize-save request. - * - * Add notifications to the settings and focus on the first control that has an invalid setting. - * - * @since 4.6.0 - * @private - * - * @param {object} response - * @param {object} response.invalid_settings - * @returns {void} - */ - _handleInvalidSettingsError: function( response ) { - var invalidControls = [], wasFocused = false; - if ( _.isEmpty( response.invalid_settings ) ) { - return; - } - - // Find the controls that correspond to each invalid setting. - _.each( response.invalid_settings, function( notifications, settingId ) { - var setting = api( settingId ); - if ( setting ) { - _.each( notifications, function( notificationParams, code ) { - var notification = new api.Notification( code, notificationParams ); - setting.notifications.add( code, notification ); - } ); - } - - api.control.each( function( control ) { - _.each( control.settings, function( controlSetting ) { - if ( controlSetting.id === settingId ) { - invalidControls.push( control ); - } - } ); - } ); - } ); - - // Focus on the first control that is inside of an expanded section (one that is visible). - _( invalidControls ).find( function( control ) { - var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded(); - if ( isExpanded && control.expanded ) { - isExpanded = control.expanded(); - } - if ( isExpanded ) { - control.focus(); - wasFocused = true; - } - return wasFocused; - } ); - - // Focus on the first invalid control. - if ( ! wasFocused && invalidControls[0] ) { - invalidControls[0].focus(); - } - }, - save: function() { var self = this, processing = api.state( 'processing' ), submitWhenDoneProcessing, submit, - modifiedWhileSaving = {}; + modifiedWhileSaving = {}, + invalidSettings = [], + invalidControls; body.addClass( 'saving' ); @@ -3502,6 +3483,27 @@ submit = function () { var request, query; + + /* + * Block saving if there are any settings that are marked as + * invalid from the client (not from the server). Focus on + * the control. + */ + api.each( function( setting ) { + setting.notifications.each( function( notification ) { + if ( 'error' === notification.type && ( ! notification.data || ! notification.data.from_server ) ) { + invalidSettings.push( setting.id ); + } + } ); + } ); + invalidControls = api.findControlsForSettings( invalidSettings ); + if ( ! _.isEmpty( invalidControls ) ) { + _.values( invalidControls )[0][0].focus(); + body.removeClass( 'saving' ); + api.unbind( 'change', captureSettingModifiedDuringSave ); + return; + } + query = $.extend( self.query(), { nonce: self.nonce.save } ); @@ -3512,18 +3514,6 @@ api.trigger( 'save', request ); - /* - * Remove all setting error notifications prior to save, allowing - * server to respond with fresh validation error notifications. - */ - api.each( function( setting ) { - setting.notifications.each( function( notification ) { - if ( 'error' === notification.type ) { - setting.notifications.remove( notification.code ); - } - } ); - } ); - request.always( function () { body.removeClass( 'saving' ); saveBtn.prop( 'disabled', false ); @@ -3548,7 +3538,12 @@ } ); } - self._handleInvalidSettingsError( response ); + if ( response.setting_validities ) { + api._handleSettingValidities( { + settingValidities: response.setting_validities, + focusInvalidControl: true + } ); + } api.trigger( 'error', response ); } ); @@ -3564,6 +3559,13 @@ api.previewer.send( 'saved', response ); + if ( response.setting_validities ) { + api._handleSettingValidities( { + settingValidities: response.setting_validities, + focusInvalidControl: true + } ); + } + api.trigger( 'saved', response ); // Restore the global dirty state if any settings were modified during save. @@ -3669,6 +3671,103 @@ }); }); + /** + * Handle setting_validities in an error response for the customize-save request. + * + * Add notifications to the settings and focus on the first control that has an invalid setting. + * + * @since 4.6.0 + * @private + * + * @param {object} args + * @param {object} args.settingValidities + * @param {boolean} [args.focusInvalidControl=false] + * @returns {void} + */ + api._handleSettingValidities = function handleSettingValidities( args ) { + var invalidSettingControls, invalidSettings = [], wasFocused = false; + + // Find the controls that correspond to each invalid setting. + _.each( args.settingValidities, function( validity, settingId ) { + var setting = api( settingId ); + if ( setting ) { + + // Add notifications for invalidities. + if ( _.isObject( validity ) ) { + _.each( validity, function( params, code ) { + var notification = new api.Notification( code, params ), existingNotification, needsReplacement = false; + + // Remove existing notification if already exists for code but differs in parameters. + existingNotification = setting.notifications( notification.code ); + if ( existingNotification ) { + needsReplacement = ( notification.type !== existingNotification.type ) || ! _.isEqual( notification.data, existingNotification.data ); + } + if ( needsReplacement ) { + setting.notifications.remove( code ); + } + + if ( ! setting.notifications.has( notification.code ) ) { + setting.notifications.add( code, notification ); + } + invalidSettings.push( setting.id ); + } ); + } + + // Remove notification errors that are no longer valid. + setting.notifications.each( function( notification ) { + if ( 'error' === notification.type && ( true === validity || ! validity[ notification.code ] ) ) { + setting.notifications.remove( notification.code ); + } + } ); + } + } ); + + if ( args.focusInvalidControl ) { + invalidSettingControls = api.findControlsForSettings( invalidSettings ); + + // Focus on the first control that is inside of an expanded section (one that is visible). + _( _.values( invalidSettingControls ) ).find( function( controls ) { + return _( controls ).find( function( control ) { + var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded(); + if ( isExpanded && control.expanded ) { + isExpanded = control.expanded(); + } + if ( isExpanded ) { + control.focus(); + wasFocused = true; + } + return wasFocused; + } ); + } ); + + // Focus on the first invalid control. + if ( ! wasFocused && ! _.isEmpty( invalidSettingControls ) ) { + _.values( invalidSettingControls )[0][0].focus(); + } + } + }; + + /** + * Find all controls associated with the given settings. + * + * @since 4.6.0 + * @param {string[]} settingIds Setting IDs. + * @returns {object} Mapping setting ids to arrays of controls. + */ + api.findControlsForSettings = function findControlsForSettings( settingIds ) { + var controls = {}, settingControls; + _.each( _.unique( settingIds ), function( settingId ) { + var setting = api( settingId ); + if ( setting ) { + settingControls = setting.findControls(); + if ( settingControls && settingControls.length > 0 ) { + controls[ settingId ] = settingControls; + } + } + } ); + return controls; + }; + /** * Sort panels, sections, controls by priorities. Hide empty sections and panels. * @@ -4040,6 +4139,14 @@ }); }); + // Update the setting validities. + api.previewer.bind( 'selective-refresh-setting-validities', function handleSelectiveRefreshedSettingValidities( settingValidities ) { + api._handleSettingValidities( { + settingValidities: settingValidities, + focusInvalidControl: false + } ); + } ); + // Focus on the control that is associated with the given setting. api.previewer.bind( 'focus-control-for-setting', function( settingId ) { var matchedControl; diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index f89887eb9e..63d9f1d6ab 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -825,6 +825,9 @@ final class WP_Customize_Manager { * @since 3.4.0 */ public function customize_preview_settings() { + $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() ); + $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities ); + $settings = array( 'theme' => array( 'stylesheet' => $this->get_stylesheet(), @@ -837,6 +840,7 @@ final class WP_Customize_Manager { 'activePanels' => array(), 'activeSections' => array(), 'activeControls' => array(), + 'settingValidities' => $exported_setting_validities, 'nonce' => $this->get_nonces(), 'l10n' => array( 'shiftClickToEdit' => __( 'Shift-click to edit this element.' ), @@ -991,12 +995,13 @@ final class WP_Customize_Manager { * @since 4.6.0 * @access public * @see WP_REST_Request::has_valid_params() + * @see WP_Customize_Setting::validate() * * @param array $setting_values Mapping of setting IDs to values to sanitize and validate. - * @return array Empty array if all settings were valid. One or more instances of `WP_Error` if any were invalid. + * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`. */ public function validate_setting_values( $setting_values ) { - $validity_errors = array(); + $validities = array(); foreach ( $setting_values as $setting_id => $unsanitized_value ) { $setting = $this->get_setting( $setting_id ); if ( ! $setting || is_null( $unsanitized_value ) ) { @@ -1006,11 +1011,46 @@ final class WP_Customize_Manager { if ( false === $validity || null === $validity ) { $validity = new WP_Error( 'invalid_value', __( 'Invalid value.' ) ); } - if ( is_wp_error( $validity ) ) { - $validity_errors[ $setting_id ] = $validity; - } + $validities[ $setting_id ] = $validity; + } + return $validities; + } + + /** + * Prepare setting validity for exporting to the client (JS). + * + * Converts `WP_Error` instance into array suitable for passing into the + * `wp.customize.Notification` JS model. + * + * @since 4.6.0 + * @access public + * + * @param true|WP_Error $validity Setting validity. + * @return true|array If `$validity` was `WP_Error` then array mapping the error + * codes to their respective `message` and `data` to pass + * into the `wp.customize.Notification` JS model. + */ + public function prepare_setting_validity_for_js( $validity ) { + if ( is_wp_error( $validity ) ) { + $notification = array(); + foreach ( $validity->errors as $error_code => $error_messages ) { + $error_data = $validity->get_error_data( $error_code ); + if ( is_null( $error_data ) ) { + $error_data = array(); + } + $error_data = array_merge( + $error_data, + array( 'from_server' => true ) + ); + $notification[ $error_code ] = array( + 'message' => join( ' ', $error_messages ), + 'data' => $error_data, + ); + } + return $notification; + } else { + return true; } - return $validity_errors; } /** @@ -1041,22 +1081,13 @@ final class WP_Customize_Manager { do_action( 'customize_save_validation_before', $this ); // Validate settings. - $validity_errors = $this->validate_setting_values( $this->unsanitized_post_values() ); - $invalid_count = count( $validity_errors ); - if ( $invalid_count > 0 ) { - $settings_errors = array(); - foreach ( $validity_errors as $setting_id => $validity_error ) { - $settings_errors[ $setting_id ] = array(); - foreach ( $validity_error->errors as $error_code => $error_messages ) { - $settings_errors[ $setting_id ][ $error_code ] = array( - 'message' => join( ' ', $error_messages ), - 'data' => $validity_error->get_error_data( $error_code ), - ); - } - } + $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() ); + $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) ); + $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities ); + if ( $invalid_setting_count > 0 ) { $response = array( - 'invalid_settings' => $settings_errors, - 'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_count ), number_format_i18n( $invalid_count ) ), + 'setting_validities' => $exported_setting_validities, + 'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ), ); /** This filter is documented in wp-includes/class-wp-customize-manager.php */ @@ -1097,6 +1128,10 @@ final class WP_Customize_Manager { */ do_action( 'customize_save_after', $this ); + $data = array( + 'setting_validities' => $exported_setting_validities, + ); + /** * Filters response data for a successful customize_save AJAX request. * @@ -1108,7 +1143,7 @@ final class WP_Customize_Manager { * event on `wp.customize`. * @param WP_Customize_Manager $this WP_Customize_Manager instance. */ - $response = apply_filters( 'customize_save_response', array(), $this ); + $response = apply_filters( 'customize_save_response', $data, $this ); wp_send_json_success( $response ); } diff --git a/src/wp-includes/customize/class-wp-customize-selective-refresh.php b/src/wp-includes/customize/class-wp-customize-selective-refresh.php index f90f0f988b..245d32a27b 100644 --- a/src/wp-includes/customize/class-wp-customize-selective-refresh.php +++ b/src/wp-includes/customize/class-wp-customize-selective-refresh.php @@ -402,6 +402,10 @@ final class WP_Customize_Selective_Refresh { $response['errors'] = $this->triggered_errors; } + $setting_validities = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); + $exported_setting_validities = array_map( array( $this->manager, 'prepare_setting_validity_for_js' ), $setting_validities ); + $response['setting_validities'] = $exported_setting_validities; + /** * Filters the response from rendering the partials. * diff --git a/src/wp-includes/js/customize-preview.js b/src/wp-includes/js/customize-preview.js index ac77551bfd..f5569ed13d 100644 --- a/src/wp-includes/js/customize-preview.js +++ b/src/wp-includes/js/customize-preview.js @@ -172,7 +172,8 @@ api.preview.send( 'ready', { activePanels: api.settings.activePanels, activeSections: api.settings.activeSections, - activeControls: api.settings.activeControls + activeControls: api.settings.activeControls, + settingValidities: api.settings.settingValidities } ); // Display a loading indicator when preview is reloading, and remove on failure. diff --git a/src/wp-includes/js/customize-selective-refresh.js b/src/wp-includes/js/customize-selective-refresh.js index 7efee3d5f2..ec51058ec4 100644 --- a/src/wp-includes/js/customize-selective-refresh.js +++ b/src/wp-includes/js/customize-selective-refresh.js @@ -847,6 +847,18 @@ wp.customize.selectiveRefresh = ( function( $, api ) { } } ); + /** + * Handle setting validities in partial refresh response. + * + * @param {object} data Response data. + * @param {object} data.setting_validities Setting validities. + */ + api.selectiveRefresh.bind( 'render-partials-response', function handleSettingValiditiesResponse( data ) { + if ( data.setting_validities ) { + api.preview.send( 'selective-refresh-setting-validities', data.setting_validities ); + } + } ); + api.preview.bind( 'active', function() { // Make all partials ready. diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 666db6fde8..437f4101fc 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -196,7 +196,6 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { * @see WP_Customize_Manager::validate_setting_values() */ function test_validate_setting_values() { - $default_value = 'foo_default'; $setting = $this->manager->add_setting( 'foo', array( 'validate_callback' => array( $this, 'filter_customize_validate_foo' ), 'sanitize_callback' => array( $this, 'filter_customize_sanitize_foo' ), @@ -204,7 +203,9 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $post_value = 'bar'; $this->manager->set_post_value( 'foo', $post_value ); - $this->assertEmpty( $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ) ); + $validities = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); + $this->assertCount( 1, $validities ); + $this->assertEquals( array( 'foo' => true ), $validities ); $this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' ); $invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); @@ -233,6 +234,30 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $this->assertEquals( array( 'source' => 'filter_customize_validate_foo' ), $error->get_error_data() ); } + /** + * Test WP_Customize_Manager::prepare_setting_validity_for_js(). + * + * @see WP_Customize_Manager::prepare_setting_validity_for_js() + */ + function test_prepare_setting_validity_for_js() { + $this->assertTrue( $this->manager->prepare_setting_validity_for_js( true ) ); + $error = new WP_Error(); + $error->add( 'bad_letter', 'Bad letter' ); + $error->add( 'bad_letter', 'Bad letra' ); + $error->add( 'bad_number', 'Bad number', array( 'number' => 123 ) ); + $validity = $this->manager->prepare_setting_validity_for_js( $error ); + $this->assertInternalType( 'array', $validity ); + foreach ( $error->errors as $code => $messages ) { + $this->assertArrayHasKey( $code, $validity ); + $this->assertInternalType( 'array', $validity[ $code ] ); + $this->assertEquals( join( ' ', $messages ), $validity[ $code ]['message'] ); + $this->assertArrayHasKey( 'data', $validity[ $code ] ); + $this->assertArrayHasKey( 'from_server', $validity[ $code ]['data'] ); + } + $this->assertArrayHasKey( 'number', $validity['bad_number']['data'] ); + $this->assertEquals( 123, $validity['bad_number']['data']['number'] ); + } + /** * Test WP_Customize_Manager::set_post_value(). * @@ -565,6 +590,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $this->assertArrayHasKey( 'activePanels', $settings ); $this->assertArrayHasKey( 'activeSections', $settings ); $this->assertArrayHasKey( 'activeControls', $settings ); + $this->assertArrayHasKey( 'settingValidities', $settings ); $this->assertArrayHasKey( 'nonce', $settings ); $this->assertArrayHasKey( '_dirty', $settings ); diff --git a/tests/phpunit/tests/customize/selective-refresh-ajax.php b/tests/phpunit/tests/customize/selective-refresh-ajax.php index b60f23aa2f..d2a9cdf7b0 100644 --- a/tests/phpunit/tests/customize/selective-refresh-ajax.php +++ b/tests/phpunit/tests/customize/selective-refresh-ajax.php @@ -344,6 +344,7 @@ class Test_WP_Customize_Selective_Refresh_Ajax extends WP_UnitTestCase { $this->assertEquals( $count_customize_render_partials_after + 1, has_action( 'customize_render_partials_after' ) ); $output = json_decode( ob_get_clean(), true ); $this->assertEquals( array( get_bloginfo( 'name', 'display' ) ), $output['data']['contents']['test_blogname'] ); + $this->assertArrayHasKey( 'setting_validities', $output['data'] ); } /** diff --git a/tests/qunit/wp-admin/js/customize-controls.js b/tests/qunit/wp-admin/js/customize-controls.js index 23ca9ac317..d806e4680e 100644 --- a/tests/qunit/wp-admin/js/customize-controls.js +++ b/tests/qunit/wp-admin/js/customize-controls.js @@ -96,6 +96,13 @@ jQuery( window ).load( function (){ ok( setting.notifications.extended( wp.customize.Values ) ); equal( wp.customize.Notification, setting.notifications.prototype.constructor.defaultConstructor ); } ); + test( 'Setting has findControls method', function() { + var controls, setting = wp.customize( 'fixture-setting' ); + equal( 'function', typeof setting.findControls ); + controls = setting.findControls(); + equal( 1, controls.length ); + equal( 'fixture-control', controls[0].id ); + } ); test( 'Setting constructor object exists', function( assert ) { assert.ok( _.isObject( wp.customize.settingConstructor ) ); } ); @@ -505,4 +512,16 @@ jQuery( window ).load( function (){ test( 'Panel instance is not contextuallyActive', function () { equal( mockPanel.isContextuallyActive(), false ); }); + + module( 'Test wp.customize.findControlsForSettings' ); + test( 'findControlsForSettings(blogname)', function() { + var controlsForSettings, settingId = 'fixture-setting', controlId = 'fixture-control'; + ok( wp.customize.control.has( controlId ) ); + ok( wp.customize.has( settingId ) ); + controlsForSettings = wp.customize.findControlsForSettings( [ settingId ] ); + ok( _.isObject( controlsForSettings ), 'Response is object' ); + ok( _.isArray( controlsForSettings['fixture-setting'] ), 'Response has a fixture-setting array' ); + equal( 1, controlsForSettings['fixture-setting'].length ); + equal( wp.customize.control( controlId ), controlsForSettings['fixture-setting'][0] ); + } ); });