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
This commit is contained in:
Weston Ruter 2016-06-14 19:16:54 +00:00
parent 9decae327d
commit f26ccf6fac
8 changed files with 301 additions and 96 deletions

View File

@ -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<string, wp.customize.Control>} 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;

View File

@ -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 );
}

View File

@ -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.
*

View File

@ -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.

View File

@ -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.

View File

@ -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 );

View File

@ -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'] );
}
/**

View File

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