Customize: Add setting validation model and control notifications to augment setting sanitization.

When a setting is invalid, not only will it be blocked from being saved but all other settings will be blocked as well. This ensures that Customizer saves aren't partial but are more transactional. User will be displayed the error in a notification so that they can fix and re-attempt saving.

PHP changes:

* Introduces `WP_Customize_Setting::validate()`, `WP_Customize_Setting::$validate_callback`, and the `customize_validate_{$setting_id}` filter.
* Introduces `WP_Customize_Manager::validate_setting_values()` to do validation (and sanitization) for the setting values supplied, returning a list of `WP_Error` instances for invalid settings.
* Attempting to save settings that are invalid will result in the save being blocked entirely, with the errors being sent in the `customize_save_response`. Modifies `WP_Customize_Manager::save()` to check all settings for validity issues prior to calling their `save` methods.
* Introduces `WP_Customize_Setting::json()` for parity with the other Customizer classes. This includes exporting of the `type`.
* Modifies `WP_Customize_Manager::post_value()` to apply `validate` after `sanitize`, and if validation fails, to return the `$default`.
* Introduces `customize_save_validation_before` action which fires right before the validation checks are made prior to saving.

JS changes:

* Introduces `wp.customize.Notification` in JS which to represent `WP_Error` instances returned from the server when setting validation fails.
* Introduces `wp.customize.Setting.prototype.notifications`.
* Introduces `wp.customize.Control.prototype.notifications`, which are synced with a control's settings' notifications.
* Introduces `wp.customize.Control.prototype.renderNotifications()` to re-render a control's notifications in its notification area. This is called automatically when the notifications collection changes.
* Introduces `wp.customize.settingConstructor`, allowing custom setting types to be used in the same way that custom controls, panels, and sections can be made.
* Injects a notification area into existing controls which is populated in response to the control's `notifications` collection changing. A custom control can customize the placement of the notification area by overriding the new `getNotificationsContainerElement` method.
* When a save fails due to setting invalidity, the invalidity errors will be added to the settings to then populate in the controls' notification areas, and the first such invalid control will be focused.

Props westonruter, celloexpressions, mrahmadawais.
See #35210.
See #30937.
Fixes #34893.


git-svn-id: https://develop.svn.wordpress.org/trunk@37476 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Weston Ruter
2016-05-20 21:09:40 +00:00
parent 757c0767f7
commit c7ff79df64
12 changed files with 695 additions and 22 deletions

View File

@@ -125,6 +125,114 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
$this->assertEquals( 'post_value_bar_default', $manager->post_value( $bar_setting, 'post_value_bar_default' ), 'Expected post_value($bar_setting, $default) to return $default since no value supplied in $_POST[customized][bar]' );
}
/**
* Test the WP_Customize_Manager::post_value() method for a setting value that fails validation.
*
* @ticket 34893
*/
function test_invalid_post_value() {
$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' ),
) );
$this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) );
$this->assertEquals( $default_value, $setting->post_value( $default_value ) );
$post_value = 'bar';
$this->manager->set_post_value( 'foo', $post_value );
$this->assertEquals( strtoupper( $post_value ), $this->manager->post_value( $setting, $default_value ) );
$this->assertEquals( strtoupper( $post_value ), $setting->post_value( $default_value ) );
$this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' );
$this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) );
$this->assertEquals( $default_value, $setting->post_value( $default_value ) );
$this->manager->set_post_value( 'foo', 'return_null_in_sanitize' );
$this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) );
$this->assertEquals( $default_value, $setting->post_value( $default_value ) );
$post_value = '<script>evil</script>';
$this->manager->set_post_value( 'foo', $post_value );
$this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) );
$this->assertEquals( $default_value, $setting->post_value( $default_value ) );
}
/**
* Filter customize_validate callback.
*
* @param mixed $value Value.
* @return string|WP_Error
*/
function filter_customize_sanitize_foo( $value ) {
if ( 'return_null_in_sanitize' === $value ) {
$value = null;
} elseif ( is_string( $value ) ) {
$value = strtoupper( $value );
if ( false !== stripos( $value, 'return_wp_error_in_sanitize' ) ) {
$value = new WP_Error( 'invalid_value_in_sanitize', __( 'Invalid value.' ), array( 'source' => 'filter_customize_sanitize_foo' ) );
}
}
return $value;
}
/**
* Filter customize_validate callback.
*
* @param WP_Error $validity Validity.
* @param mixed $value Value.
* @return WP_Error
*/
function filter_customize_validate_foo( $validity, $value ) {
if ( false !== stripos( $value, '<script' ) ) {
$validity->add( 'invalid_value_in_validate', __( 'Invalid value.' ), array( 'source' => 'filter_customize_validate_foo' ) );
}
return $validity;
}
/**
* Test WP_Customize_Manager::validate_setting_values().
*
* @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' ),
) );
$post_value = 'bar';
$this->manager->set_post_value( 'foo', $post_value );
$this->assertEmpty( $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ) );
$this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' );
$invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() );
$this->assertCount( 1, $invalid_settings );
$this->assertArrayHasKey( $setting->id, $invalid_settings );
$this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] );
$error = $invalid_settings[ $setting->id ];
$this->assertEquals( 'invalid_value_in_sanitize', $error->get_error_code() );
$this->assertEquals( array( 'source' => 'filter_customize_sanitize_foo' ), $error->get_error_data() );
$this->manager->set_post_value( 'foo', 'return_null_in_sanitize' );
$invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() );
$this->assertCount( 1, $invalid_settings );
$this->assertArrayHasKey( $setting->id, $invalid_settings );
$this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] );
$this->assertNull( $invalid_settings[ $setting->id ]->get_error_data() );
$post_value = '<script>evil</script>';
$this->manager->set_post_value( 'foo', $post_value );
$invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() );
$this->assertCount( 1, $invalid_settings );
$this->assertArrayHasKey( $setting->id, $invalid_settings );
$this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] );
$error = $invalid_settings[ $setting->id ];
$this->assertEquals( 'invalid_value_in_validate', $error->get_error_code() );
$this->assertEquals( array( 'source' => 'filter_customize_validate_foo' ), $error->get_error_data() );
}
/**
* Test WP_Customize_Manager::set_post_value().
*
@@ -416,6 +524,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
$this->assertContains( 'var _wpCustomizeSettings =', $content );
$this->assertContains( '"blogname"', $content );
$this->assertContains( '"type":"option"', $content );
$this->assertContains( '_wpCustomizeSettings.controls', $content );
$this->assertContains( '_wpCustomizeSettings.settings', $content );
$this->assertContains( '</script>', $content );

View File

@@ -42,6 +42,7 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
$this->assertEquals( 'refresh', $setting->transport );
$this->assertEquals( '', $setting->sanitize_callback );
$this->assertEquals( '', $setting->sanitize_js_callback );
$this->assertFalse( has_filter( "customize_validate_{$setting->id}" ) );
$this->assertFalse( has_filter( "customize_sanitize_{$setting->id}" ) );
$this->assertFalse( has_filter( "customize_sanitize_js_{$setting->id}" ) );
$this->assertEquals( false, $setting->dirty );
@@ -54,6 +55,7 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
'theme_supports' => 'widgets',
'default' => 'barbar',
'transport' => 'postMessage',
'validate_callback' => create_function( '$value', 'return $value . ":validate_callback";' ),
'sanitize_callback' => create_function( '$value', 'return $value . ":sanitize_callback";' ),
'sanitize_js_callback' => create_function( '$value', 'return $value . ":sanitize_js_callback";' ),
);
@@ -62,6 +64,7 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
foreach ( $args as $key => $value ) {
$this->assertEquals( $value, $setting->$key );
}
$this->assertEquals( 10, has_filter( "customize_validate_{$setting->id}", $args['validate_callback'] ) );
$this->assertEquals( 10, has_filter( "customize_sanitize_{$setting->id}", $args['sanitize_callback'] ) );
$this->assertEquals( 10, has_filter( "customize_sanitize_js_{$setting->id}" ), $args['sanitize_js_callback'] );
}
@@ -90,6 +93,8 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
/**
* Run assertions on non-multidimensional standard settings.
*
* @see WP_Customize_Setting::value()
*/
function test_preview_standard_types_non_multidimensional() {
$_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
@@ -167,6 +172,7 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
* Run assertions on multidimensional standard settings.
*
* @see WP_Customize_Setting::preview()
* @see WP_Customize_Setting::value()
*/
function test_preview_standard_types_multidimensional() {
$_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
@@ -569,5 +575,70 @@ class Tests_WP_Customize_Setting extends WP_UnitTestCase {
$autoload = $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $id_base ) );
$this->assertEquals( 'no', $autoload, 'Even though setting1 did not indicate autoload (thus normally true), since another multidimensional option setting of the base did say autoload=false, it should be autoload=no' );
}
/**
* Test js_value and json methods.
*
* @see WP_Customize_Setting::js_value()
* @see WP_Customize_Setting::json()
*/
public function test_js_value() {
$default = "\x00";
$args = array(
'type' => 'binary',
'default' => $default,
'transport' => 'postMessage',
'dirty' => true,
'sanitize_js_callback' => create_function( '$value', 'return base64_encode( $value );' ),
);
$setting = new WP_Customize_Setting( $this->manager, 'name', $args );
$this->assertEquals( $default, $setting->value() );
$this->assertEquals( base64_encode( $default ), $setting->js_value() );
$exported = $setting->json();
$this->assertArrayHasKey( 'type', $exported );
$this->assertArrayHasKey( 'value', $exported );
$this->assertArrayHasKey( 'transport', $exported );
$this->assertArrayHasKey( 'dirty', $exported );
$this->assertEquals( $setting->js_value(), $exported['value'] );
$this->assertEquals( $args['type'], $setting->type );
$this->assertEquals( $args['transport'], $setting->transport );
$this->assertEquals( $args['dirty'], $setting->dirty );
}
/**
* Test validate.
*
* @see WP_Customize_Setting::validate()
*/
public function test_validate() {
$setting = new WP_Customize_Setting( $this->manager, 'name', array(
'type' => 'key',
'validate_callback' => array( $this, 'filter_validate_for_test_validate' ),
) );
$validity = $setting->validate( 'BAD!' );
$this->assertInstanceOf( 'WP_Error', $validity );
$this->assertEquals( 'invalid_key', $validity->get_error_code() );
}
/**
* Validate callback.
*
* @see Tests_WP_Customize_Setting::test_validate()
*
* @param WP_Error $validity Validity.
* @param string $value Value.
*
* @return WP_Error
*/
public function filter_validate_for_test_validate( $validity, $value ) {
$this->assertInstanceOf( 'WP_Error', $validity );
$this->assertInternalType( 'string', $value );
if ( sanitize_key( $value ) !== $value ) {
$validity->add( 'invalid_key', 'Invalid key' );
}
return $validity;
}
}