Make WordPress Core

Changeset 37476


Ignore:
Timestamp:
05/20/2016 09:09:40 PM (8 years 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.