diff --git src/wp-admin/css/customize-controls.css src/wp-admin/css/customize-controls.css index 0a62611..3747b0d 100644 --- src/wp-admin/css/customize-controls.css +++ src/wp-admin/css/customize-controls.css @@ -601,6 +601,31 @@ p.customize-section-description { cursor: pointer; } + +.customize-control-notifications-container { + padding-top: 0.5em; + padding-bottom: 0.5em; + display: none; +} + +.customize-control-widget_form.customize-control-has-notifications .widget .widget-top { + background-color: #FFE0E0; +} +.customize-control-nav_menu_item.customize-control-has-notifications .menu-item-bar .menu-item-handle { + background-color: #FFE0E0; +} + +.customize-control-notifications-container li { + list-style: disc; + margin-bottom: 0; + margin-left: 1em; +} +.customize-control-notifications-container li:only-child { + list-style: none; + margin-left: 0; +} + + #customize-controls .thumbnail-audio .thumbnail { max-width: 64px; max-height: 64px; diff --git src/wp-admin/js/customize-controls.js src/wp-admin/js/customize-controls.js index ef01674..e2567cc 100644 --- src/wp-admin/js/customize-controls.js +++ src/wp-admin/js/customize-controls.js @@ -27,6 +27,7 @@ this.id = id; this.transport = this.transport || 'refresh'; this._dirty = options.dirty || false; + this.notifications = new api.Values({ defaultConstructor: api.Notification }); // Whenever the setting's value changes, refresh the preview. this.bind( this.preview ); @@ -1478,6 +1479,7 @@ control.priority = new api.Value(); control.active = new api.Value(); control.activeArgumentsQueue = []; + control.notifications = new api.Values({ defaultConstructor: api.Notification }); control.elements = []; @@ -1541,12 +1543,34 @@ control.setting = control.settings['default'] || null; + _.each( control.settings, function( setting ) { + setting.notifications.bind( 'add', function( settingNotification ) { + var controlNotification = new api.Notification( setting.id + ':' + settingNotification.code, settingNotification ); + control.notifications.add( controlNotification.code, controlNotification ); + } ); + setting.notifications.bind( 'remove', function( settingNotification ) { + control.notifications.remove( setting.id + ':' + settingNotification.code ); + } ); + } ); + control.embed(); }) ); } // After the control is embedded on the page, invoke the "ready" method. control.deferred.embedded.done( function () { + /* + * Note that this debounced/deferred rendering is needed for two reasons: + * 1) The 'remove' event is triggered just _before_ the notification is actually removed. + * 2) Improve performance when adding/removing multiple notifications at a time. + */ + var debouncedRenderNotifications = _.debounce( function renderNotifications() { + control.renderNotifications(); + } ); + control.notifications.bind( 'add', debouncedRenderNotifications ); + control.notifications.bind( 'remove', debouncedRenderNotifications ); + control.renderNotifications(); + control.ready(); }); }, @@ -1589,6 +1613,77 @@ ready: function() {}, /** + * Get the element inside of a control's container that contains the validation error message. + * + * Control subclasses may override this to return the proper container to render notifications into. + * Injects the notification container for existing controls that lack the necessary container, + * including special handling for nav menu items and widgets. + * + * @returns {jQuery} Setting validation message element. + * @this {wp.customize.Control} + */ + getNotificationsContainerElement: function() { + var control = this, controlTitle, notificationsContainer; + + notificationsContainer = control.container.find( '.customize-control-notifications-container:first' ); + if ( notificationsContainer.length ) { + return notificationsContainer; + } + + notificationsContainer = $( '
' ); + + if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) { + control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer ); + } else if ( control.container.hasClass( 'customize-control-widget_form' ) ) { + control.container.find( '.widget-inside:first' ).prepend( notificationsContainer ); + } else { + controlTitle = control.container.find( '.customize-control-title' ); + if ( controlTitle.length ) { + controlTitle.after( notificationsContainer ); + } else { + control.container.append( notificationsContainer ); + } + } + return notificationsContainer; + }, + + /** + * Render notifications. + * + * Renders the `control.notifications` into the control's container. + * Control subclasses may override this method to do their own handling + * of rendering notifications. + */ + renderNotifications: function() { + var control = this, container, notifications; + container = control.getNotificationsContainerElement(); + if ( ! container || ! container.length ) { + return; + } + notifications = []; + control.notifications.each( function( notification ) { + notifications.push( notification ); + } ); + + if ( 0 === notifications.length ) { + container.stop().slideUp( 'fast' ); + } else { + container.stop().slideDown( 'fast', null, function() { + $( this ).css( 'height', 'auto' ); + } ); + } + + if ( ! control.notificationsTemplate ) { + control.notificationsTemplate = wp.template( 'customize-control-notifications' ); + } + + control.container.toggleClass( 'customize-control-has-notifications', 0 !== notifications.length ); + container.empty().append( $.trim( + control.notificationsTemplate( { notifications: notifications } ) + ) ); + }, + + /** * Normal controls do not expand, so just expand its parent * * @param {Object} [params] @@ -3223,6 +3318,7 @@ } }); + api.settingConstructor = {}; api.controlConstructor = { color: api.ColorControl, media: api.MediaControl, @@ -3323,6 +3419,50 @@ }; }, + _handleInvalidSettingsError: function( response ) { + var invalidControls = [], wasFocused = false; + if ( ! response.invalid_settings || 0 === response.invalid_settings.length ) { + 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' ), @@ -3349,6 +3489,13 @@ api.trigger( 'save', request ); + // Remove all setting notifications prior to save, allowing server to respond with new notifications. + api.each( function( setting ) { + setting.notifications.each( function( notification ) { + setting.notifications.remove( notification.code ); + } ); + } ); + request.always( function () { body.removeClass( 'saving' ); saveBtn.prop( 'disabled', false ); @@ -3372,6 +3519,9 @@ self.preview.iframe.show(); } ); } + + self._handleInvalidSettingsError( response ); + api.trigger( 'error', response ); } ); @@ -3424,11 +3574,15 @@ // Create Settings $.each( api.settings.settings, function( id, data ) { - api.create( id, id, data.value, { + var constructor = api.settingConstructor[ data.type ] || api.Setting, + setting; + + setting = new constructor( id, data.value, { transport: data.transport, previewer: api.previewer, dirty: !! data.dirty } ); + api.add( id, setting ); }); // Create Panels diff --git src/wp-includes/class-wp-customize-manager.php src/wp-includes/class-wp-customize-manager.php index 0052a1a..20968be 100644 --- src/wp-includes/class-wp-customize-manager.php +++ src/wp-includes/class-wp-customize-manager.php @@ -654,19 +654,31 @@ final class WP_Customize_Manager { * Return the sanitized value for a given setting from the request's POST data. * * @since 3.4.0 - * @since 4.1.1 Introduced 'default' parameter. + * @since 4.1.1 Introduced `$default` parameter. + * @since 4.6.0 Return `$default` when setting post value is invalid. + * @see WP_REST_Server::dispatch() + * @see WP_Rest_Request::sanitize_params() + * @see WP_Rest_Request::has_valid_params() * - * @param WP_Customize_Setting $setting A WP_Customize_Setting derived object - * @param mixed $default value returned $setting has no post value (added in 4.2.0). - * @return string|mixed $post_value Sanitized value or the $default provided + * @param WP_Customize_Setting $setting A WP_Customize_Setting derived object. + * @param mixed $default Value returned $setting has no post value (added in 4.2.0) + * or the post value is invalid (added in 4.6.0). + * @return string|mixed $post_value Sanitized value or the $default provided. */ public function post_value( $setting, $default = null ) { $post_values = $this->unsanitized_post_values(); - if ( array_key_exists( $setting->id, $post_values ) ) { - return $setting->sanitize( $post_values[ $setting->id ] ); - } else { + if ( ! array_key_exists( $setting->id, $post_values ) ) { + return $default; + } + $value = $setting->sanitize( $post_values[ $setting->id ] ); + if ( is_null( $value ) || is_wp_error( $value ) ) { + return $default; + } + $valid = $setting->validate( $value ); + if ( is_wp_error( $valid ) ) { return $default; } + return $value; } /** @@ -970,6 +982,38 @@ final class WP_Customize_Manager { } /** + * Validate setting values. + * + * Sanitization is applied to the values before being passed for validation. + * Validation is skipped for unregistered settings or for values that are + * already null since they will be skipped anyway. + * + * @since 4.6.0 + * @access public + * @see WP_REST_Request::has_valid_params() + * + * @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. + */ + public function validate_setting_values( $setting_values ) { + $validity_errors = array(); + foreach ( $setting_values as $setting_id => $unsanitized_value ) { + $setting = $this->get_setting( $setting_id ); + if ( ! $setting || is_null( $unsanitized_value ) ) { + continue; + } + $validity = $setting->validate( $setting->sanitize( $unsanitized_value ) ); + if ( false === $validity || null === $validity ) { + $validity = new WP_Error( 'invalid_value', __( 'Invalid value.' ) ); + } + if ( is_wp_error( $validity ) ) { + $validity_errors[ $setting_id ] = $validity; + } + } + return $validity_errors; + } + + /** * Switch the theme and trigger the save() method on each setting. * * @since 3.4.0 @@ -984,6 +1028,42 @@ final class WP_Customize_Manager { wp_send_json_error( 'invalid_nonce' ); } + /** + * Fires before save validation happens. + * + * Plugins can add just-in-time `customize_validate_{$setting_id}` filters + * at this point to catch any settings registered after `customize_register`. + * + * @since 4.6.0 + * + * @param WP_Customize_Manager $this WP_Customize_Manager instance. + */ + 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_message ) { + $settings_errors[ $setting_id ][ $error_code ] = array( + 'message' => join( ' ', $error_message ), + 'data' => $validity_error->get_error_data( $error_code ), + ); + } + } + $response = array( + 'invalid_settings' => $settings_errors, + 'message' => sprintf( _n( 'There is %d invalid setting.', 'There are %d invalid settings.', $invalid_count ), $invalid_count ), + ); + + /** This filter is documented in wp-includes/class-wp-customize-manager.php */ + $response = apply_filters( 'customize_save_response', $response, $this ); + wp_send_json_error( $response ); + } + // Do we have to switch themes? if ( ! $this->is_theme_active() ) { // Temporarily stop previewing the theme to allow switch_themes() @@ -1403,6 +1483,15 @@ final class WP_Customize_Manager { ) ); $control->print_template(); } + ?> + + id ), - wp_json_encode( array( - 'value' => $setting->js_value(), - 'transport' => $setting->transport, - 'dirty' => $setting->dirty, - ) ) + wp_json_encode( $setting->json() ) ); } } diff --git src/wp-includes/class-wp-customize-setting.php src/wp-includes/class-wp-customize-setting.php index 94e7ded..9679883 100644 --- src/wp-includes/class-wp-customize-setting.php +++ src/wp-includes/class-wp-customize-setting.php @@ -59,6 +59,7 @@ class WP_Customize_Setting { * * @var callback */ + public $validate_callback = ''; public $sanitize_callback = ''; public $sanitize_js_callback = ''; @@ -142,8 +143,11 @@ class WP_Customize_Setting { $this->id .= '[' . implode( '][', $this->id_data['keys'] ) . ']'; } + if ( $this->validate_callback ) { + add_filter( "customize_validate_{$this->id}", $this->validate_callback, 10, 3 ); + } if ( $this->sanitize_callback ) { - add_filter( "customize_sanitize_{$this->id}", $this->sanitize_callback, 10, 2 ); + add_filter( "customize_sanitize_{$this->id}", $this->sanitize_callback, 10, 3 ); } if ( $this->sanitize_js_callback ) { add_filter( "customize_sanitize_js_{$this->id}", $this->sanitize_js_callback, 10, 2 ); @@ -464,14 +468,16 @@ class WP_Customize_Setting { * the value of the setting. * * @since 3.4.0 + * @since 4.6.0 Return the result of updating the value. * - * @return false|void False if cap check fails or value isn't set. + * @return false|void False if cap check fails or value isn't set or is invalid. */ final public function save() { $value = $this->post_value(); - if ( ! $this->check_capabilities() || ! isset( $value ) ) + if ( ! $this->check_capabilities() || ! isset( $value ) ) { return false; + } /** * Fires when the WP_Customize_Setting::save() method is called. @@ -483,7 +489,7 @@ class WP_Customize_Setting { * * @param WP_Customize_Setting $this WP_Customize_Setting instance. */ - do_action( 'customize_save_' . $this->id_data[ 'base' ], $this ); + do_action( 'customize_save_' . $this->id_data['base'], $this ); $this->update( $value ); } @@ -494,7 +500,7 @@ class WP_Customize_Setting { * @since 3.4.0 * * @param mixed $default A default value which is used as a fallback. Default is null. - * @return mixed The default value on failure, otherwise the sanitized value. + * @return mixed The default value on failure, otherwise the sanitized and validated value. */ final public function post_value( $default = null ) { return $this->manager->post_value( $this, $default ); @@ -505,8 +511,8 @@ class WP_Customize_Setting { * * @since 3.4.0 * - * @param string|array $value The value to sanitize. - * @return string|array|null Null if an input isn't valid, otherwise the sanitized value. + * @param string|array $value The value to sanitize. + * @return string|array|null|WP_Error Sanitized value, or `null`/`WP_Error` if invalid. */ public function sanitize( $value ) { @@ -522,6 +528,45 @@ class WP_Customize_Setting { } /** + * Validate an input. + * + * @since 4.6.0 + * @access public + * @see WP_REST_Request::has_valid_params() + * + * @param mixed $value Value to validate. + * @return true|WP_Error + */ + public function validate( $value ) { + if ( is_wp_error( $value ) ) { + return $value; + } + if ( is_null( $value ) ) { + return new WP_Error( 'invalid_value', __( 'Invalid value.' ) ); + } + + $validity = new WP_Error(); + + /** + * Validate a Customize setting value. + * + * Plugins should amend the `$validity` object via its `WP_Error::add()` method. + * + * @since 4.6.0 + * + * @param WP_Error $validity Filtered from `true` to `WP_Error` when invalid. + * @param mixed $value Value of the setting. + * @param WP_Customize_Setting $this WP_Customize_Setting instance. + */ + $validity = apply_filters( "customize_validate_{$this->id}", $validity, $value, $this ); + + if ( is_wp_error( $validity ) && empty( $validity->errors ) ) { + $validity = true; + } + return $validity; + } + + /** * Get the root value for a setting, especially for multidimensional ones. * * @since 4.4.0 @@ -700,6 +745,22 @@ class WP_Customize_Setting { } /** + * Get the data to export to the client via JSON. + * + * @since 4.6.0 + * + * @return array Array of parameters passed to JavaScript. + */ + public function json() { + return array( + 'value' => $this->js_value(), + 'transport' => $this->transport, + 'dirty' => $this->dirty, + 'type' => $this->type, + ); + } + + /** * Validate user capabilities whether the theme supports the setting. * * @since 3.4.0 diff --git src/wp-includes/js/customize-base.js src/wp-includes/js/customize-base.js index b4b7279..5569659 100644 --- src/wp-includes/js/customize-base.js +++ src/wp-includes/js/customize-base.js @@ -755,6 +755,27 @@ window.wp = window.wp || {}; // Add the Events mixin to api.Messenger. $.extend( api.Messenger.prototype, api.Events ); + /** + * Notification. + * + * @class + * @augments wp.customize.Class + * + * @param {string} code The error code. + * @param {object} params Params. + * @param {string} params.message The error message. + * @param {string} [params.type] The notification type. + * @param {*} [params.data] Any additional data. + */ + api.Notification = api.Class.extend({ + initialize: function( code, params ) { + this.code = code; + this.message = params.message; + this.type = params.type || null; + this.data = params.data || null; + } + }); + // The main API object is also a collection of all customizer settings. api = $.extend( new api.Values(), api ); diff --git tests/phpunit/tests/customize/setting.php tests/phpunit/tests/customize/setting.php index c2ff7f5..98990c5 100644 --- tests/phpunit/tests/customize/setting.php +++ tests/phpunit/tests/customize/setting.php @@ -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; + } }