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] ); + } ); });