diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css index 46d424c245..f125132c29 100644 --- a/src/wp-admin/css/customize-controls.css +++ b/src/wp-admin/css/customize-controls.css @@ -494,7 +494,7 @@ p.customize-section-description { .customize-control input[type="search"], .customize-control input[type="tel"], .customize-control input[type="url"] { - width: 98%; + width: 100%; line-height: 18px; margin: 0; } @@ -622,6 +622,46 @@ p.customize-section-description { border-right: 1px solid #ddd; } + +/** + * Notifications + */ + +#customize-controls .customize-control-notifications-container { /* Scoped to #customize-controls for specificity over notification styles in common.css. */ + margin: 4px 0 8px 0; + padding: 0; + display: none; + cursor: default; +} + +#customize-controls .customize-control-widget_form.has-error .widget .widget-top, +.customize-control-nav_menu_item.has-error .menu-item-bar .menu-item-handle { + box-shadow: inset 0 0 0 2px #dc3232; + transition: .15s box-shadow linear; +} + +.customize-control-notifications-container li.notice { + list-style: none; + margin: 0 0 6px 0; + padding: 4px 8px; +} + +.customize-control-notifications-container li.notice:last-child { + margin-bottom: 0; +} + +#customize-controls .customize-control-nav_menu_item .customize-control-notifications-container { + margin-top: 0; +} + +#customize-controls .customize-control-widget_form .customize-control-notifications-container { + margin-top: 8px; +} + +.customize-control-text.has-error input { + outline: 2px solid #dc3232; +} + /* Style for custom settings */ /** diff --git a/src/wp-admin/js/customize-controls.js b/src/wp-admin/js/customize-controls.js index ef01674a7e..2a5a5b9d1a 100644 --- a/src/wp-admin/js/customize-controls.js +++ b/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,37 @@ 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', function( notification ) { + wp.a11y.speak( notification.message, 'assertive' ); + debouncedRenderNotifications(); + } ); + control.notifications.bind( 'remove', debouncedRenderNotifications ); + control.renderNotifications(); + control.ready(); }); }, @@ -1588,6 +1615,85 @@ */ 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. + * + * @since 4.6.0 + * @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.prepend( 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. + * + * @since 4.6.0 + * @this {wp.customize.Control} + */ + renderNotifications: function() { + var control = this, container, notifications, hasError = false; + container = control.getNotificationsContainerElement(); + if ( ! container || ! container.length ) { + return; + } + notifications = []; + control.notifications.each( function( notification ) { + notifications.push( notification ); + if ( 'error' === notification.type ) { + hasError = true; + } + } ); + + 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( 'has-notifications', 0 !== notifications.length ); + control.container.toggleClass( 'has-error', hasError ); + container.empty().append( $.trim( + control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } ) + ) ); + }, + /** * Normal controls do not expand, so just expand its parent * @@ -3223,6 +3329,7 @@ } }); + api.settingConstructor = {}; api.controlConstructor = { color: api.ColorControl, media: api.MediaControl, @@ -3323,6 +3430,62 @@ }; }, + /** + * 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' ), @@ -3349,6 +3512,18 @@ 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 ); @@ -3372,6 +3547,9 @@ self.preview.iframe.show(); } ); } + + self._handleInvalidSettingsError( response ); + api.trigger( 'error', response ); } ); @@ -3424,11 +3602,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 a/src/wp-admin/js/customize-widgets.js b/src/wp-admin/js/customize-widgets.js index aec773bc36..05028c60f5 100644 --- a/src/wp-admin/js/customize-widgets.js +++ b/src/wp-admin/js/customize-widgets.js @@ -430,6 +430,7 @@ args = $.extend( {}, control.defaultExpandedArguments, args ); control.onChangeExpanded( expanded, args ); }); + control.altNotice = true; api.Control.prototype.initialize.call( control, id, options ); }, diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index 0052a1ad0c..911d2e94d1 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/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; } /** @@ -969,6 +981,38 @@ final class WP_Customize_Manager { return $this->theme()->display('Name'); } + /** + * 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. * @@ -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_messages ) { + $settings_errors[ $setting_id ][ $error_code ] = array( + 'message' => join( ' ', $error_messages ), + 'data' => $validity_error->get_error_data( $error_code ), + ); + } + } + $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 ) ), + ); + + /** 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 a/src/wp-includes/class-wp-customize-setting.php b/src/wp-includes/class-wp-customize-setting.php index 94e7dedb0b..a557b8f288 100644 --- a/src/wp-includes/class-wp-customize-setting.php +++ b/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,6 +143,9 @@ 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 ); } @@ -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 ) { @@ -521,6 +527,45 @@ class WP_Customize_Setting { return apply_filters( "customize_sanitize_{$this->id}", $value, $this ); } + /** + * 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. * @@ -699,6 +744,22 @@ class WP_Customize_Setting { return $value; } + /** + * 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. * diff --git a/src/wp-includes/js/customize-base.js b/src/wp-includes/js/customize-base.js index b4b727951c..e59f926594 100644 --- a/src/wp-includes/js/customize-base.js +++ b/src/wp-includes/js/customize-base.js @@ -755,6 +755,28 @@ window.wp = window.wp || {}; // Add the Events mixin to api.Messenger. $.extend( api.Messenger.prototype, api.Events ); + /** + * Notification. + * + * @class + * @augments wp.customize.Class + * @since 4.6.0 + * + * @param {string} code The error code. + * @param {object} params Params. + * @param {string} params.message The error message. + * @param {string} [params.type=error] 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 || 'error'; + 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 a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 0b86b4ce85..666db6fde8 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -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 = ''; + $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, '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 = ''; + $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( '', $content ); diff --git a/tests/phpunit/tests/customize/setting.php b/tests/phpunit/tests/customize/setting.php index c2ff7f5cb3..98990c53e0 100644 --- a/tests/phpunit/tests/customize/setting.php +++ b/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; + } } diff --git a/tests/qunit/fixtures/customize-settings.js b/tests/qunit/fixtures/customize-settings.js index 85bd1abbb8..670f783df6 100644 --- a/tests/qunit/fixtures/customize-settings.js +++ b/tests/qunit/fixtures/customize-settings.js @@ -112,6 +112,11 @@ window._wpCustomizeSettings = { 'fixture-setting': { 'transport': 'postMessage', 'value': 'Lorem Ipsum' + }, + 'fixture-setting-abbr': { + 'transport': 'postMessage', + 'value': 'NASA', + 'type': 'abbreviation' } }, 'theme': { diff --git a/tests/qunit/index.html b/tests/qunit/index.html index aa454a9a35..ad8a4ebaa7 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -11,6 +11,7 @@ + @@ -126,6 +127,13 @@ <# } #> +