WordPress.org

Make WordPress Core

Changeset 37476


Ignore:
Timestamp:
05/20/16 21:09:40 (9 months ago)
Author:
westonruter
Message:

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.

Location:
trunk
Files:
12 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/css/customize-controls.css

    r37442 r37476  
    495495.customize-control input[type="tel"], 
    496496.customize-control input[type="url"] { 
    497     width: 98%; 
     497    width: 100%; 
    498498    line-height: 18px; 
    499499    margin: 0; 
     
    621621    background: #eee; 
    622622    border-right: 1px solid #ddd; 
     623} 
     624 
     625 
     626/** 
     627 * Notifications 
     628 */ 
     629 
     630#customize-controls .customize-control-notifications-container { /* Scoped to #customize-controls for specificity over notification styles in common.css. */ 
     631    margin: 4px 0 8px 0; 
     632    padding: 0; 
     633    display: none; 
     634    cursor: default; 
     635} 
     636 
     637#customize-controls .customize-control-widget_form.has-error .widget .widget-top, 
     638.customize-control-nav_menu_item.has-error .menu-item-bar .menu-item-handle { 
     639    box-shadow: inset 0 0 0 2px #dc3232; 
     640    transition: .15s box-shadow linear; 
     641} 
     642 
     643.customize-control-notifications-container li.notice { 
     644    list-style: none; 
     645    margin: 0 0 6px 0; 
     646    padding: 4px 8px; 
     647} 
     648 
     649.customize-control-notifications-container li.notice:last-child { 
     650    margin-bottom: 0; 
     651} 
     652 
     653#customize-controls .customize-control-nav_menu_item .customize-control-notifications-container { 
     654    margin-top: 0; 
     655} 
     656 
     657#customize-controls .customize-control-widget_form .customize-control-notifications-container { 
     658    margin-top: 8px; 
     659} 
     660 
     661.customize-control-text.has-error input { 
     662    outline: 2px solid #dc3232; 
    623663} 
    624664 
  • trunk/src/wp-admin/js/customize-controls.js

    r37347 r37476  
    2828            this.transport = this.transport || 'refresh'; 
    2929            this._dirty = options.dirty || false; 
     30            this.notifications = new api.Values({ defaultConstructor: api.Notification }); 
    3031 
    3132            // Whenever the setting's value changes, refresh the preview. 
     
    14791480            control.active = new api.Value(); 
    14801481            control.activeArgumentsQueue = []; 
     1482            control.notifications = new api.Values({ defaultConstructor: api.Notification }); 
    14811483 
    14821484            control.elements = []; 
     
    15421544                    control.setting = control.settings['default'] || null; 
    15431545 
     1546                    _.each( control.settings, function( setting ) { 
     1547                        setting.notifications.bind( 'add', function( settingNotification ) { 
     1548                            var controlNotification = new api.Notification( setting.id + ':' + settingNotification.code, settingNotification ); 
     1549                            control.notifications.add( controlNotification.code, controlNotification ); 
     1550                        } ); 
     1551                        setting.notifications.bind( 'remove', function( settingNotification ) { 
     1552                            control.notifications.remove( setting.id + ':' + settingNotification.code ); 
     1553                        } ); 
     1554                    } ); 
     1555 
    15441556                    control.embed(); 
    15451557                }) ); 
     
    15481560            // After the control is embedded on the page, invoke the "ready" method. 
    15491561            control.deferred.embedded.done( function () { 
     1562                /* 
     1563                 * Note that this debounced/deferred rendering is needed for two reasons: 
     1564                 * 1) The 'remove' event is triggered just _before_ the notification is actually removed. 
     1565                 * 2) Improve performance when adding/removing multiple notifications at a time. 
     1566                 */ 
     1567                var debouncedRenderNotifications = _.debounce( function renderNotifications() { 
     1568                    control.renderNotifications(); 
     1569                } ); 
     1570                control.notifications.bind( 'add', function( notification ) { 
     1571                    wp.a11y.speak( notification.message, 'assertive' ); 
     1572                    debouncedRenderNotifications(); 
     1573                } ); 
     1574                control.notifications.bind( 'remove', debouncedRenderNotifications ); 
     1575                control.renderNotifications(); 
     1576 
    15501577                control.ready(); 
    15511578            }); 
     
    15881615         */ 
    15891616        ready: function() {}, 
     1617 
     1618        /** 
     1619         * Get the element inside of a control's container that contains the validation error message. 
     1620         * 
     1621         * Control subclasses may override this to return the proper container to render notifications into. 
     1622         * Injects the notification container for existing controls that lack the necessary container, 
     1623         * including special handling for nav menu items and widgets. 
     1624         * 
     1625         * @since 4.6.0 
     1626         * @returns {jQuery} Setting validation message element. 
     1627         * @this {wp.customize.Control} 
     1628         */ 
     1629        getNotificationsContainerElement: function() { 
     1630            var control = this, controlTitle, notificationsContainer; 
     1631 
     1632            notificationsContainer = control.container.find( '.customize-control-notifications-container:first' ); 
     1633            if ( notificationsContainer.length ) { 
     1634                return notificationsContainer; 
     1635            } 
     1636 
     1637            notificationsContainer = $( '<div class="customize-control-notifications-container"></div>' ); 
     1638 
     1639            if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) { 
     1640                control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer ); 
     1641            } else if ( control.container.hasClass( 'customize-control-widget_form' ) ) { 
     1642                control.container.find( '.widget-inside:first' ).prepend( notificationsContainer ); 
     1643            } else { 
     1644                controlTitle = control.container.find( '.customize-control-title' ); 
     1645                if ( controlTitle.length ) { 
     1646                    controlTitle.after( notificationsContainer ); 
     1647                } else { 
     1648                    control.container.prepend( notificationsContainer ); 
     1649                } 
     1650            } 
     1651            return notificationsContainer; 
     1652        }, 
     1653 
     1654        /** 
     1655         * Render notifications. 
     1656         * 
     1657         * Renders the `control.notifications` into the control's container. 
     1658         * Control subclasses may override this method to do their own handling 
     1659         * of rendering notifications. 
     1660         * 
     1661         * @since 4.6.0 
     1662         * @this {wp.customize.Control} 
     1663         */ 
     1664        renderNotifications: function() { 
     1665            var control = this, container, notifications, hasError = false; 
     1666            container = control.getNotificationsContainerElement(); 
     1667            if ( ! container || ! container.length ) { 
     1668                return; 
     1669            } 
     1670            notifications = []; 
     1671            control.notifications.each( function( notification ) { 
     1672                notifications.push( notification ); 
     1673                if ( 'error' === notification.type ) { 
     1674                    hasError = true; 
     1675                } 
     1676            } ); 
     1677 
     1678            if ( 0 === notifications.length ) { 
     1679                container.stop().slideUp( 'fast' ); 
     1680            } else { 
     1681                container.stop().slideDown( 'fast', null, function() { 
     1682                    $( this ).css( 'height', 'auto' ); 
     1683                } ); 
     1684            } 
     1685 
     1686            if ( ! control.notificationsTemplate ) { 
     1687                control.notificationsTemplate = wp.template( 'customize-control-notifications' ); 
     1688            } 
     1689 
     1690            control.container.toggleClass( 'has-notifications', 0 !== notifications.length ); 
     1691            control.container.toggleClass( 'has-error', hasError ); 
     1692            container.empty().append( $.trim( 
     1693                control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } ) 
     1694            ) ); 
     1695        }, 
    15901696 
    15911697        /** 
     
    32243330    }); 
    32253331 
     3332    api.settingConstructor = {}; 
    32263333    api.controlConstructor = { 
    32273334        color:         api.ColorControl, 
     
    33243431            }, 
    33253432 
     3433            /** 
     3434             * Handle invalid_settings in an error response for the customize-save request. 
     3435             * 
     3436             * Add notifications to the settings and focus on the first control that has an invalid setting. 
     3437             * 
     3438             * @since 4.6.0 
     3439             * @private 
     3440             * 
     3441             * @param {object} response 
     3442             * @param {object} response.invalid_settings 
     3443             * @returns {void} 
     3444             */ 
     3445            _handleInvalidSettingsError: function( response ) { 
     3446                var invalidControls = [], wasFocused = false; 
     3447                if ( _.isEmpty( response.invalid_settings ) ) { 
     3448                    return; 
     3449                } 
     3450 
     3451                // Find the controls that correspond to each invalid setting. 
     3452                _.each( response.invalid_settings, function( notifications, settingId ) { 
     3453                    var setting = api( settingId ); 
     3454                    if ( setting ) { 
     3455                        _.each( notifications, function( notificationParams, code ) { 
     3456                            var notification = new api.Notification( code, notificationParams ); 
     3457                            setting.notifications.add( code, notification ); 
     3458                        } ); 
     3459                    } 
     3460 
     3461                    api.control.each( function( control ) { 
     3462                        _.each( control.settings, function( controlSetting ) { 
     3463                            if ( controlSetting.id === settingId ) { 
     3464                                invalidControls.push( control ); 
     3465                            } 
     3466                        } ); 
     3467                    } ); 
     3468                } ); 
     3469 
     3470                // Focus on the first control that is inside of an expanded section (one that is visible). 
     3471                _( invalidControls ).find( function( control ) { 
     3472                    var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded(); 
     3473                    if ( isExpanded && control.expanded ) { 
     3474                        isExpanded = control.expanded(); 
     3475                    } 
     3476                    if ( isExpanded ) { 
     3477                        control.focus(); 
     3478                        wasFocused = true; 
     3479                    } 
     3480                    return wasFocused; 
     3481                } ); 
     3482 
     3483                // Focus on the first invalid control. 
     3484                if ( ! wasFocused && invalidControls[0] ) { 
     3485                    invalidControls[0].focus(); 
     3486                } 
     3487            }, 
     3488 
    33263489            save: function() { 
    33273490                var self = this, 
     
    33493512 
    33503513                    api.trigger( 'save', request ); 
     3514 
     3515                    /* 
     3516                     * Remove all setting error notifications prior to save, allowing 
     3517                     * server to respond with fresh validation error notifications. 
     3518                     */ 
     3519                    api.each( function( setting ) { 
     3520                        setting.notifications.each( function( notification ) { 
     3521                            if ( 'error' === notification.type ) { 
     3522                                setting.notifications.remove( notification.code ); 
     3523                            } 
     3524                        } ); 
     3525                    } ); 
    33513526 
    33523527                    request.always( function () { 
     
    33733548                            } ); 
    33743549                        } 
     3550 
     3551                        self._handleInvalidSettingsError( response ); 
     3552 
    33753553                        api.trigger( 'error', response ); 
    33763554                    } ); 
     
    34253603        // Create Settings 
    34263604        $.each( api.settings.settings, function( id, data ) { 
    3427             api.create( id, id, data.value, { 
     3605            var constructor = api.settingConstructor[ data.type ] || api.Setting, 
     3606                setting; 
     3607 
     3608            setting = new constructor( id, data.value, { 
    34283609                transport: data.transport, 
    34293610                previewer: api.previewer, 
    34303611                dirty: !! data.dirty 
    34313612            } ); 
     3613            api.add( id, setting ); 
    34323614        }); 
    34333615 
  • trunk/src/wp-admin/js/customize-widgets.js

    r37437 r37476  
    431431                control.onChangeExpanded( expanded, args ); 
    432432            }); 
     433            control.altNotice = true; 
    433434 
    434435            api.Control.prototype.initialize.call( control, id, options ); 
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r37342 r37476  
    655655     * 
    656656     * @since 3.4.0 
    657      * @since 4.1.1 Introduced 'default' parameter. 
    658      * 
    659      * @param WP_Customize_Setting $setting A WP_Customize_Setting derived object 
    660      * @param mixed $default value returned $setting has no post value (added in 4.2.0). 
    661      * @return string|mixed $post_value Sanitized value or the $default provided 
     657     * @since 4.1.1 Introduced `$default` parameter. 
     658     * @since 4.6.0 Return `$default` when setting post value is invalid. 
     659     * @see WP_REST_Server::dispatch() 
     660     * @see WP_Rest_Request::sanitize_params() 
     661     * @see WP_Rest_Request::has_valid_params() 
     662     * 
     663     * @param WP_Customize_Setting $setting A WP_Customize_Setting derived object. 
     664     * @param mixed                $default Value returned $setting has no post value (added in 4.2.0) 
     665     *                                      or the post value is invalid (added in 4.6.0). 
     666     * @return string|mixed $post_value Sanitized value or the $default provided. 
    662667     */ 
    663668    public function post_value( $setting, $default = null ) { 
    664669        $post_values = $this->unsanitized_post_values(); 
    665         if ( array_key_exists( $setting->id, $post_values ) ) { 
    666             return $setting->sanitize( $post_values[ $setting->id ] ); 
    667         } else { 
     670        if ( ! array_key_exists( $setting->id, $post_values ) ) { 
    668671            return $default; 
    669672        } 
     673        $value = $setting->sanitize( $post_values[ $setting->id ] ); 
     674        if ( is_null( $value ) || is_wp_error( $value ) ) { 
     675            return $default; 
     676        } 
     677        $valid = $setting->validate( $value ); 
     678        if ( is_wp_error( $valid ) ) { 
     679            return $default; 
     680        } 
     681        return $value; 
    670682    } 
    671683 
     
    971983 
    972984    /** 
     985     * Validate setting values. 
     986     * 
     987     * Sanitization is applied to the values before being passed for validation. 
     988     * Validation is skipped for unregistered settings or for values that are 
     989     * already null since they will be skipped anyway. 
     990     * 
     991     * @since 4.6.0 
     992     * @access public 
     993     * @see WP_REST_Request::has_valid_params() 
     994     * 
     995     * @param array $setting_values Mapping of setting IDs to values to sanitize and validate. 
     996     * @return array Empty array if all settings were valid. One or more instances of `WP_Error` if any were invalid. 
     997     */ 
     998    public function validate_setting_values( $setting_values ) { 
     999        $validity_errors = array(); 
     1000        foreach ( $setting_values as $setting_id => $unsanitized_value ) { 
     1001            $setting = $this->get_setting( $setting_id ); 
     1002            if ( ! $setting || is_null( $unsanitized_value ) ) { 
     1003                continue; 
     1004            } 
     1005            $validity = $setting->validate( $setting->sanitize( $unsanitized_value ) ); 
     1006            if ( false === $validity || null === $validity ) { 
     1007                $validity = new WP_Error( 'invalid_value', __( 'Invalid value.' ) ); 
     1008            } 
     1009            if ( is_wp_error( $validity ) ) { 
     1010                $validity_errors[ $setting_id ] = $validity; 
     1011            } 
     1012        } 
     1013        return $validity_errors; 
     1014    } 
     1015 
     1016    /** 
    9731017     * Switch the theme and trigger the save() method on each setting. 
    9741018     * 
     
    9831027        if ( ! check_ajax_referer( $action, 'nonce', false ) ) { 
    9841028            wp_send_json_error( 'invalid_nonce' ); 
     1029        } 
     1030 
     1031        /** 
     1032         * Fires before save validation happens. 
     1033         * 
     1034         * Plugins can add just-in-time `customize_validate_{$setting_id}` filters 
     1035         * at this point to catch any settings registered after `customize_register`. 
     1036         * 
     1037         * @since 4.6.0 
     1038         * 
     1039         * @param WP_Customize_Manager $this WP_Customize_Manager instance. 
     1040         */ 
     1041        do_action( 'customize_save_validation_before', $this ); 
     1042 
     1043        // Validate settings. 
     1044        $validity_errors = $this->validate_setting_values( $this->unsanitized_post_values() ); 
     1045        $invalid_count = count( $validity_errors ); 
     1046        if ( $invalid_count > 0 ) { 
     1047            $settings_errors = array(); 
     1048            foreach ( $validity_errors as $setting_id => $validity_error ) { 
     1049                $settings_errors[ $setting_id ] = array(); 
     1050                foreach ( $validity_error->errors as $error_code => $error_messages ) { 
     1051                    $settings_errors[ $setting_id ][ $error_code ] = array( 
     1052                        'message' => join( ' ', $error_messages ), 
     1053                        'data' => $validity_error->get_error_data( $error_code ), 
     1054                    ); 
     1055                } 
     1056            } 
     1057            $response = array( 
     1058                'invalid_settings' => $settings_errors, 
     1059                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_count ), number_format_i18n( $invalid_count ) ), 
     1060            ); 
     1061 
     1062            /** This filter is documented in wp-includes/class-wp-customize-manager.php */ 
     1063            $response = apply_filters( 'customize_save_response', $response, $this ); 
     1064            wp_send_json_error( $response ); 
    9851065        } 
    9861066 
     
    14041484            $control->print_template(); 
    14051485        } 
     1486        ?> 
     1487        <script type="text/html" id="tmpl-customize-control-notifications"> 
     1488            <ul> 
     1489                <# _.each( data.notifications, function( notification ) { #> 
     1490                    <li class="notice notice-{{ notification.type || 'info' }} {{ data.altNotice ? 'notice-alt' : '' }}" data-code="{{ notification.code }}" data-type="{{ notification.type }}">{{ notification.message || notification.code }}</li> 
     1491                <# } ); #> 
     1492            </ul> 
     1493        </script> 
     1494        <?php 
    14061495    } 
    14071496 
     
    17641853                        "s[%s] = %s;\n", 
    17651854                        wp_json_encode( $setting->id ), 
    1766                         wp_json_encode( array( 
    1767                             'value'     => $setting->js_value(), 
    1768                             'transport' => $setting->transport, 
    1769                             'dirty'     => $setting->dirty, 
    1770                         ) ) 
     1855                        wp_json_encode( $setting->json() ) 
    17711856                    ); 
    17721857                } 
  • trunk/src/wp-includes/class-wp-customize-setting.php

    r37350 r37476  
    6060     * @var callback 
    6161     */ 
     62    public $validate_callback    = ''; 
    6263    public $sanitize_callback    = ''; 
    6364    public $sanitize_js_callback = ''; 
     
    143144        } 
    144145 
     146        if ( $this->validate_callback ) { 
     147            add_filter( "customize_validate_{$this->id}", $this->validate_callback, 10, 3 ); 
     148        } 
    145149        if ( $this->sanitize_callback ) { 
    146150            add_filter( "customize_sanitize_{$this->id}", $this->sanitize_callback, 10, 2 ); 
     
    465469     * 
    466470     * @since 3.4.0 
    467      * 
    468      * @return false|void False if cap check fails or value isn't set. 
     471     * @since 4.6.0 Return the result of updating the value. 
     472     * 
     473     * @return false|void False if cap check fails or value isn't set or is invalid. 
    469474     */ 
    470475    final public function save() { 
    471476        $value = $this->post_value(); 
    472477 
    473         if ( ! $this->check_capabilities() || ! isset( $value ) ) 
     478        if ( ! $this->check_capabilities() || ! isset( $value ) ) { 
    474479            return false; 
     480        } 
    475481 
    476482        /** 
     
    484490         * @param WP_Customize_Setting $this WP_Customize_Setting instance. 
    485491         */ 
    486         do_action( 'customize_save_' . $this->id_data[ 'base' ], $this ); 
     492        do_action( 'customize_save_' . $this->id_data['base'], $this ); 
    487493 
    488494        $this->update( $value ); 
     
    495501     * 
    496502     * @param mixed $default A default value which is used as a fallback. Default is null. 
    497      * @return mixed The default value on failure, otherwise the sanitized value. 
     503     * @return mixed The default value on failure, otherwise the sanitized and validated value. 
    498504     */ 
    499505    final public function post_value( $default = null ) { 
     
    506512     * @since 3.4.0 
    507513     * 
    508      * @param string|array $value The value to sanitize. 
    509      * @return string|array|null Null if an input isn't valid, otherwise the sanitized value. 
     514     * @param string|array $value    The value to sanitize. 
     515     * @return string|array|null|WP_Error Sanitized value, or `null`/`WP_Error` if invalid. 
    510516     */ 
    511517    public function sanitize( $value ) { 
     
    520526         */ 
    521527        return apply_filters( "customize_sanitize_{$this->id}", $value, $this ); 
     528    } 
     529 
     530    /** 
     531     * Validate an input. 
     532     * 
     533     * @since 4.6.0 
     534     * @access public 
     535     * @see WP_REST_Request::has_valid_params() 
     536     * 
     537     * @param mixed $value Value to validate. 
     538     * @return true|WP_Error 
     539     */ 
     540    public function validate( $value ) { 
     541        if ( is_wp_error( $value ) ) { 
     542            return $value; 
     543        } 
     544        if ( is_null( $value ) ) { 
     545            return new WP_Error( 'invalid_value', __( 'Invalid value.' ) ); 
     546        } 
     547 
     548        $validity = new WP_Error(); 
     549 
     550        /** 
     551         * Validate a Customize setting value. 
     552         * 
     553         * Plugins should amend the `$validity` object via its `WP_Error::add()` method. 
     554         * 
     555         * @since 4.6.0 
     556         * 
     557         * @param WP_Error             $validity Filtered from `true` to `WP_Error` when invalid. 
     558         * @param mixed                $value    Value of the setting. 
     559         * @param WP_Customize_Setting $this     WP_Customize_Setting instance. 
     560         */ 
     561        $validity = apply_filters( "customize_validate_{$this->id}", $validity, $value, $this ); 
     562 
     563        if ( is_wp_error( $validity ) && empty( $validity->errors ) ) { 
     564            $validity = true; 
     565        } 
     566        return $validity; 
    522567    } 
    523568 
     
    701746 
    702747    /** 
     748     * Get the data to export to the client via JSON. 
     749     * 
     750     * @since 4.6.0 
     751     * 
     752     * @return array Array of parameters passed to JavaScript. 
     753     */ 
     754    public function json() { 
     755        return array( 
     756            'value'     => $this->js_value(), 
     757            'transport' => $this->transport, 
     758            'dirty'     => $this->dirty, 
     759            'type'      => $this->type, 
     760        ); 
     761    } 
     762 
     763    /** 
    703764     * Validate user capabilities whether the theme supports the setting. 
    704765     * 
  • trunk/src/wp-includes/js/customize-base.js

    r36583 r37476  
    756756    $.extend( api.Messenger.prototype, api.Events ); 
    757757 
     758    /** 
     759     * Notification. 
     760     * 
     761     * @class 
     762     * @augments wp.customize.Class 
     763     * @since 4.6.0 
     764     * 
     765     * @param {string} code                The error code. 
     766     * @param {object} params              Params. 
     767     * @param {string} params.message      The error message. 
     768     * @param {string} [params.type=error] The notification type. 
     769     * @param {*}      [params.data]       Any additional data. 
     770     */ 
     771    api.Notification = api.Class.extend({ 
     772        initialize: function( code, params ) { 
     773            this.code = code; 
     774            this.message = params.message; 
     775            this.type = params.type || 'error'; 
     776            this.data = params.data || null; 
     777        } 
     778    }); 
     779 
    758780    // The main API object is also a collection of all customizer settings. 
    759781    api = $.extend( new api.Values(), api ); 
  • trunk/tests/phpunit/tests/customize/manager.php

    r37040 r37476  
    124124        $bar_setting = $manager->get_setting( 'bar' ); 
    125125        $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]' ); 
     126    } 
     127 
     128    /** 
     129     * Test the WP_Customize_Manager::post_value() method for a setting value that fails validation. 
     130     * 
     131     * @ticket 34893 
     132     */ 
     133    function test_invalid_post_value() { 
     134        $default_value = 'foo_default'; 
     135        $setting = $this->manager->add_setting( 'foo', array( 
     136            'validate_callback' => array( $this, 'filter_customize_validate_foo' ), 
     137            'sanitize_callback' => array( $this, 'filter_customize_sanitize_foo' ), 
     138        ) ); 
     139        $this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) ); 
     140        $this->assertEquals( $default_value, $setting->post_value( $default_value ) ); 
     141 
     142        $post_value = 'bar'; 
     143        $this->manager->set_post_value( 'foo', $post_value ); 
     144        $this->assertEquals( strtoupper( $post_value ), $this->manager->post_value( $setting, $default_value ) ); 
     145        $this->assertEquals( strtoupper( $post_value ), $setting->post_value( $default_value ) ); 
     146 
     147        $this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' ); 
     148        $this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) ); 
     149        $this->assertEquals( $default_value, $setting->post_value( $default_value ) ); 
     150 
     151        $this->manager->set_post_value( 'foo', 'return_null_in_sanitize' ); 
     152        $this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) ); 
     153        $this->assertEquals( $default_value, $setting->post_value( $default_value ) ); 
     154 
     155        $post_value = '<script>evil</script>'; 
     156        $this->manager->set_post_value( 'foo', $post_value ); 
     157        $this->assertEquals( $default_value, $this->manager->post_value( $setting, $default_value ) ); 
     158        $this->assertEquals( $default_value, $setting->post_value( $default_value ) ); 
     159    } 
     160 
     161    /** 
     162     * Filter customize_validate callback. 
     163     * 
     164     * @param mixed $value Value. 
     165     * @return string|WP_Error 
     166     */ 
     167    function filter_customize_sanitize_foo( $value ) { 
     168        if ( 'return_null_in_sanitize' === $value ) { 
     169            $value = null; 
     170        } elseif ( is_string( $value ) ) { 
     171            $value = strtoupper( $value ); 
     172            if ( false !== stripos( $value, 'return_wp_error_in_sanitize' ) ) { 
     173                $value = new WP_Error( 'invalid_value_in_sanitize', __( 'Invalid value.' ), array( 'source' => 'filter_customize_sanitize_foo' ) ); 
     174            } 
     175        } 
     176        return $value; 
     177    } 
     178 
     179    /** 
     180     * Filter customize_validate callback. 
     181     * 
     182     * @param WP_Error $validity Validity. 
     183     * @param mixed    $value    Value. 
     184     * @return WP_Error 
     185     */ 
     186    function filter_customize_validate_foo( $validity, $value ) { 
     187        if ( false !== stripos( $value, '<script' ) ) { 
     188            $validity->add( 'invalid_value_in_validate', __( 'Invalid value.' ), array( 'source' => 'filter_customize_validate_foo' ) ); 
     189        } 
     190        return $validity; 
     191    } 
     192 
     193    /** 
     194     * Test WP_Customize_Manager::validate_setting_values(). 
     195     * 
     196     * @see WP_Customize_Manager::validate_setting_values() 
     197     */ 
     198    function test_validate_setting_values() { 
     199        $default_value = 'foo_default'; 
     200        $setting = $this->manager->add_setting( 'foo', array( 
     201            'validate_callback' => array( $this, 'filter_customize_validate_foo' ), 
     202            'sanitize_callback' => array( $this, 'filter_customize_sanitize_foo' ), 
     203        ) ); 
     204 
     205        $post_value = 'bar'; 
     206        $this->manager->set_post_value( 'foo', $post_value ); 
     207        $this->assertEmpty( $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ) ); 
     208 
     209        $this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' ); 
     210        $invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); 
     211        $this->assertCount( 1, $invalid_settings ); 
     212        $this->assertArrayHasKey( $setting->id, $invalid_settings ); 
     213        $this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] ); 
     214        $error = $invalid_settings[ $setting->id ]; 
     215        $this->assertEquals( 'invalid_value_in_sanitize', $error->get_error_code() ); 
     216        $this->assertEquals( array( 'source' => 'filter_customize_sanitize_foo' ), $error->get_error_data() ); 
     217 
     218        $this->manager->set_post_value( 'foo', 'return_null_in_sanitize' ); 
     219        $invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); 
     220        $this->assertCount( 1, $invalid_settings ); 
     221        $this->assertArrayHasKey( $setting->id, $invalid_settings ); 
     222        $this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] ); 
     223        $this->assertNull( $invalid_settings[ $setting->id ]->get_error_data() ); 
     224 
     225        $post_value = '<script>evil</script>'; 
     226        $this->manager->set_post_value( 'foo', $post_value ); 
     227        $invalid_settings = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ); 
     228        $this->assertCount( 1, $invalid_settings ); 
     229        $this->assertArrayHasKey( $setting->id, $invalid_settings ); 
     230        $this->assertInstanceOf( 'WP_Error', $invalid_settings[ $setting->id ] ); 
     231        $error = $invalid_settings[ $setting->id ]; 
     232        $this->assertEquals( 'invalid_value_in_validate', $error->get_error_code() ); 
     233        $this->assertEquals( array( 'source' => 'filter_customize_validate_foo' ), $error->get_error_data() ); 
    126234    } 
    127235 
     
    417525        $this->assertContains( 'var _wpCustomizeSettings =', $content ); 
    418526        $this->assertContains( '"blogname"', $content ); 
     527        $this->assertContains( '"type":"option"', $content ); 
    419528        $this->assertContains( '_wpCustomizeSettings.controls', $content ); 
    420529        $this->assertContains( '_wpCustomizeSettings.settings', $content ); 
  • trunk/tests/phpunit/tests/customize/setting.php

    r37350 r37476  
    4343        $this->assertEquals( '', $setting->sanitize_callback ); 
    4444        $this->assertEquals( '', $setting->sanitize_js_callback ); 
     45        $this->assertFalse( has_filter( "customize_validate_{$setting->id}" ) ); 
    4546        $this->assertFalse( has_filter( "customize_sanitize_{$setting->id}" ) ); 
    4647        $this->assertFalse( has_filter( "customize_sanitize_js_{$setting->id}" ) ); 
     
    5556            'default' => 'barbar', 
    5657            'transport' => 'postMessage', 
     58            'validate_callback' => create_function( '$value', 'return $value . ":validate_callback";' ), 
    5759            'sanitize_callback' => create_function( '$value', 'return $value . ":sanitize_callback";' ), 
    5860            'sanitize_js_callback' => create_function( '$value', 'return $value . ":sanitize_js_callback";' ), 
     
    6365            $this->assertEquals( $value, $setting->$key ); 
    6466        } 
     67        $this->assertEquals( 10, has_filter( "customize_validate_{$setting->id}", $args['validate_callback'] ) ); 
    6568        $this->assertEquals( 10, has_filter( "customize_sanitize_{$setting->id}", $args['sanitize_callback'] ) ); 
    6669        $this->assertEquals( 10, has_filter( "customize_sanitize_js_{$setting->id}" ), $args['sanitize_js_callback'] ); 
     
    9194    /** 
    9295     * Run assertions on non-multidimensional standard settings. 
     96     * 
     97     * @see WP_Customize_Setting::value() 
    9398     */ 
    9499    function test_preview_standard_types_non_multidimensional() { 
     
    168173     * 
    169174     * @see WP_Customize_Setting::preview() 
     175     * @see WP_Customize_Setting::value() 
    170176     */ 
    171177    function test_preview_standard_types_multidimensional() { 
     
    570576        $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' ); 
    571577    } 
     578 
     579    /** 
     580     * Test js_value and json methods. 
     581     * 
     582     * @see WP_Customize_Setting::js_value() 
     583     * @see WP_Customize_Setting::json() 
     584     */ 
     585    public function test_js_value() { 
     586        $default = "\x00"; 
     587        $args = array( 
     588            'type' => 'binary', 
     589            'default' => $default, 
     590            'transport' => 'postMessage', 
     591            'dirty' => true, 
     592            'sanitize_js_callback' => create_function( '$value', 'return base64_encode( $value );' ), 
     593        ); 
     594        $setting = new WP_Customize_Setting( $this->manager, 'name', $args ); 
     595 
     596        $this->assertEquals( $default, $setting->value() ); 
     597        $this->assertEquals( base64_encode( $default ), $setting->js_value() ); 
     598 
     599        $exported = $setting->json(); 
     600        $this->assertArrayHasKey( 'type', $exported ); 
     601        $this->assertArrayHasKey( 'value', $exported ); 
     602        $this->assertArrayHasKey( 'transport', $exported ); 
     603        $this->assertArrayHasKey( 'dirty', $exported ); 
     604        $this->assertEquals( $setting->js_value(), $exported['value'] ); 
     605        $this->assertEquals( $args['type'], $setting->type ); 
     606        $this->assertEquals( $args['transport'], $setting->transport ); 
     607        $this->assertEquals( $args['dirty'], $setting->dirty ); 
     608    } 
     609 
     610    /** 
     611     * Test validate. 
     612     * 
     613     * @see WP_Customize_Setting::validate() 
     614     */ 
     615    public function test_validate() { 
     616        $setting = new WP_Customize_Setting( $this->manager, 'name', array( 
     617            'type' => 'key', 
     618            'validate_callback' => array( $this, 'filter_validate_for_test_validate' ), 
     619        ) ); 
     620        $validity = $setting->validate( 'BAD!' ); 
     621        $this->assertInstanceOf( 'WP_Error', $validity ); 
     622        $this->assertEquals( 'invalid_key', $validity->get_error_code() ); 
     623    } 
     624 
     625    /** 
     626     * Validate callback. 
     627     * 
     628     * @see Tests_WP_Customize_Setting::test_validate() 
     629     * 
     630     * @param WP_Error $validity Validity. 
     631     * @param string   $value    Value. 
     632     * 
     633     * @return WP_Error 
     634     */ 
     635    public function filter_validate_for_test_validate( $validity, $value ) { 
     636        $this->assertInstanceOf( 'WP_Error', $validity ); 
     637        $this->assertInternalType( 'string', $value ); 
     638        if ( sanitize_key( $value ) !== $value ) { 
     639            $validity->add( 'invalid_key', 'Invalid key' ); 
     640        } 
     641        return $validity; 
     642    } 
    572643} 
    573644 
  • trunk/tests/qunit/fixtures/customize-settings.js

    r36532 r37476  
    113113            'transport': 'postMessage', 
    114114            'value': 'Lorem Ipsum' 
     115        }, 
     116        'fixture-setting-abbr': { 
     117            'transport': 'postMessage', 
     118            'value': 'NASA', 
     119            'type': 'abbreviation' 
    115120        } 
    116121    }, 
  • trunk/tests/qunit/index.html

    r34563 r37476  
    1212        <script src="../../src/wp-includes/js/zxcvbn.min.js"></script> 
    1313        <script src="../../src/wp-includes/js/wp-util.js"></script> 
     14        <script src="../../src/wp-includes/js/wp-a11y.js"></script> 
    1415 
    1516        <!-- QUnit --> 
     
    126127                <# } #> 
    127128            </li> 
     129        </script> 
     130        <script type="text/html" id="tmpl-customize-control-notifications"> 
     131            <ul> 
     132                <# _.each( data.notifications, function( notification ) { #> 
     133                    <li data-code="{{ notification.code }}" data-type="{{ notification.type }}">{{ notification.message || notification.code }}</li> 
     134                <# } ); #> 
     135            </ul> 
    128136        </script> 
    129137 
  • trunk/tests/qunit/wp-admin/js/customize-base.js

    r30716 r37476  
    1 /* global wp */ 
     1/* global wp, test, ok, equal, module */ 
    22 
    33jQuery( function( $ ) { 
     
    159159        ok( wasCallbackFired ); 
    160160    }); 
     161 
     162    module( 'Customize Base: Notification' ); 
     163    test( 'Notification object exists and has expected properties', function ( assert ) { 
     164        var notification = new wp.customize.Notification( 'mycode', { 
     165            'message': 'Hello World', 
     166            'type': 'update', 
     167            'data': { 'foo': 'bar' } 
     168        } ); 
     169 
     170        assert.equal( 'mycode', notification.code ); 
     171        assert.equal( 'Hello World', notification.message ); 
     172        assert.equal( 'update', notification.type ); 
     173        assert.deepEqual( { 'foo': 'bar' }, notification.data ); 
     174 
     175        notification = new wp.customize.Notification( 'mycode2', { 
     176            'message': 'Hello Space' 
     177        } ); 
     178        assert.equal( 'mycode2', notification.code ); 
     179        assert.equal( 'Hello Space', notification.message ); 
     180        assert.equal( 'error', notification.type ); 
     181        assert.equal( null, notification.data ); 
     182    } ); 
    161183}); 
  • trunk/tests/qunit/wp-admin/js/customize-controls.js

    r36689 r37476  
    1 /* global wp */ 
     1/* global wp, test, ok, equal, module */ 
     2 
     3wp.customize.settingConstructor.abbreviation = wp.customize.Setting.extend({ 
     4    validate: function( value ) { 
     5        return value.toUpperCase(); 
     6    } 
     7}); 
    28 
    39jQuery( window ).load( function (){ 
     
    8692        equal( wp.customize( 'fixture-setting' )(), 'Lorem Ipsum' ); 
    8793    } ); 
     94    test( 'Setting has notifications', function () { 
     95        var setting = wp.customize( 'fixture-setting' ); 
     96        ok( setting.notifications.extended( wp.customize.Values ) ); 
     97        equal( wp.customize.Notification, setting.notifications.prototype.constructor.defaultConstructor ); 
     98    } ); 
     99    test( 'Setting constructor object exists', function( assert ) { 
     100        assert.ok( _.isObject( wp.customize.settingConstructor ) ); 
     101    } ); 
     102    test( 'Custom setting constructor is used', function( assert ) { 
     103        var setting = wp.customize( 'fixture-setting-abbr' ); 
     104        assert.ok( setting.extended( wp.customize.settingConstructor.abbreviation ) ); 
     105        setting.set( 'usa' ); 
     106        assert.equal( 'USA', setting.get() ); 
     107    } ); 
    88108 
    89109    module( 'Customizer Control in Fixture' ); 
     
    99119        var control = wp.customize.control( 'fixture-control' ); 
    100120        equal( control.section(), 'fixture-section' ); 
     121    } ); 
     122    test( 'Control has notifications', function ( assert ) { 
     123        var control = wp.customize.control( 'fixture-control' ), settingNotification, controlOnlyNotification, doneEmbedded; 
     124        assert.ok( control.notifications.extended( wp.customize.Values ) ); 
     125        assert.equal( wp.customize.Notification, control.notifications.prototype.constructor.defaultConstructor ); 
     126        assert.ok( _.isFunction( control.getNotificationsContainerElement ) ); 
     127        assert.ok( _.isFunction( control.renderNotifications ) ); 
     128 
     129        doneEmbedded = assert.async(); 
     130        control.deferred.embedded.done( function() { 
     131            var notificationContainerElement; 
     132 
     133            assert.equal( 0, _.size( control.notifications._value ) ); 
     134            assert.equal( 0, _.size( control.settings['default'].notifications._value ) ); 
     135 
     136            notificationContainerElement = control.getNotificationsContainerElement(); 
     137            assert.equal( 1, notificationContainerElement.length ); 
     138            assert.ok( notificationContainerElement.is( '.customize-control-notifications-container' ) ); 
     139            assert.equal( 0, notificationContainerElement.find( '> ul > li' ).length ); 
     140            assert.equal( 'none', notificationContainerElement.css( 'display' ) ); 
     141 
     142            settingNotification = new wp.customize.Notification( 'setting_invalidity', 'Invalid setting' ); 
     143            controlOnlyNotification = new wp.customize.Notification( 'control_invalidity', 'Invalid control' ); 
     144            control.settings['default'].notifications.add( settingNotification.code, settingNotification ); 
     145            control.notifications.add( controlOnlyNotification.code, controlOnlyNotification ); 
     146 
     147            // Note that renderNotifications is being called manually here since rendering normally happens asynchronously. 
     148            control.renderNotifications(); 
     149 
     150            assert.equal( 2, notificationContainerElement.find( '> ul > li' ).length ); 
     151            assert.notEqual( 'none', notificationContainerElement.css( 'display' ) ); 
     152            assert.equal( 2, _.size( control.notifications._value ) ); 
     153            assert.equal( 1, _.size( control.settings['default'].notifications._value ) ); 
     154 
     155            control.notifications.remove( controlOnlyNotification.code ); 
     156            control.renderNotifications(); 
     157            assert.equal( 1, notificationContainerElement.find( '> ul > li' ).length ); 
     158            assert.notEqual( 'none', notificationContainerElement.css( 'display' ) ); 
     159 
     160            control.settings['default'].notifications.remove( settingNotification.code ); 
     161            control.renderNotifications(); 
     162            assert.equal( 0, notificationContainerElement.find( '> ul > li' ).length ); 
     163            assert.ok( notificationContainerElement.is( ':animated' ) ); // It is being slid down. 
     164            notificationContainerElement.stop().hide(); // Clean up. 
     165 
     166            doneEmbedded(); 
     167        } ); 
    101168    } ); 
    102169 
Note: See TracChangeset for help on using the changeset viewer.