Make WordPress Core

Changeset 37700


Ignore:
Timestamp:
06/14/2016 07:16:54 PM (9 years ago)
Author:
westonruter
Message:

Customize: Update server-sent setting validation notifications as changes are entered.

Send back setting validities with full refreshes and selective refreshes so that invalid settings can have notifications displayed immediately before attempting save, and so that these notifications can be cleared as soon as the input is corrected.

  • Splits out JS logic for listing controls into separate methods wp.customize.Setting.prototype.findControls() and wp.customize.findControlsForSettings().
  • Adds a setting property to the data on notifications added to controls that are synced from their settings.
  • Adds selective-refresh-setting-validities message sent from preview to pane.
  • Changes WP_Customize_Manager::validate_setting_values() to return when settings are valid as well as invalid.
  • Adds WP_Customize_Manager::prepare_setting_validity_for_js().
  • Add setting validities to data exported to JS in Customizer Preview and in selective refresh responses.

Fixes #36944.

Location:
trunk
Files:
8 edited

Legend:

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

    r37476 r37700  
    4444                    return this.previewer.send( 'setting', [ this.id, this() ] );
    4545            }
     46        },
     47
     48        /**
     49         * Find controls associated with this setting.
     50         *
     51         * @since 4.6.0
     52         * @returns {wp.customize.Control[]} Controls associated with setting.
     53         */
     54        findControls: function() {
     55            var setting = this, controls = [];
     56            api.control.each( function( control ) {
     57                _.each( control.settings, function( controlSetting ) {
     58                    if ( controlSetting.id === setting.id ) {
     59                        controls.push( control );
     60                    }
     61                } );
     62            } );
     63            return controls;
    4664        }
    4765    });
     
    15441562                    control.setting = control.settings['default'] || null;
    15451563
     1564                    // Add setting notifications to the control notification.
    15461565                    _.each( control.settings, function( setting ) {
    15471566                        setting.notifications.bind( 'add', function( settingNotification ) {
    1548                             var controlNotification = new api.Notification( setting.id + ':' + settingNotification.code, settingNotification );
     1567                            var controlNotification, code, params;
     1568                            code = setting.id + ':' + settingNotification.code;
     1569                            params = _.extend(
     1570                                {},
     1571                                settingNotification,
     1572                                {
     1573                                    setting: setting.id
     1574                                }
     1575                            );
     1576                            controlNotification = new api.Notification( code, params );
    15491577                            control.notifications.add( controlNotification.code, controlNotification );
    15501578                        } );
     
    29092937                    } );
    29102938                } );
     2939
     2940                if ( data.settingValidities ) {
     2941                    api._handleSettingValidities( {
     2942                        settingValidities: data.settingValidities,
     2943                        focusInvalidControl: false
     2944                    } );
     2945                }
    29112946            } );
    29122947
     
    34313466            },
    34323467
    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 
    34893468            save: function() {
    34903469                var self = this,
     
    34923471                    submitWhenDoneProcessing,
    34933472                    submit,
    3494                     modifiedWhileSaving = {};
     3473                    modifiedWhileSaving = {},
     3474                    invalidSettings = [],
     3475                    invalidControls;
    34953476
    34963477                body.addClass( 'saving' );
     
    35033484                submit = function () {
    35043485                    var request, query;
     3486
     3487                    /*
     3488                     * Block saving if there are any settings that are marked as
     3489                     * invalid from the client (not from the server). Focus on
     3490                     * the control.
     3491                     */
     3492                    api.each( function( setting ) {
     3493                        setting.notifications.each( function( notification ) {
     3494                            if ( 'error' === notification.type && ( ! notification.data || ! notification.data.from_server ) ) {
     3495                                invalidSettings.push( setting.id );
     3496                            }
     3497                        } );
     3498                    } );
     3499                    invalidControls = api.findControlsForSettings( invalidSettings );
     3500                    if ( ! _.isEmpty( invalidControls ) ) {
     3501                        _.values( invalidControls )[0][0].focus();
     3502                        body.removeClass( 'saving' );
     3503                        api.unbind( 'change', captureSettingModifiedDuringSave );
     3504                        return;
     3505                    }
     3506
    35053507                    query = $.extend( self.query(), {
    35063508                        nonce:  self.nonce.save
     
    35123514
    35133515                    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                     } );
    35263516
    35273517                    request.always( function () {
     
    35493539                        }
    35503540
    3551                         self._handleInvalidSettingsError( response );
     3541                        if ( response.setting_validities ) {
     3542                            api._handleSettingValidities( {
     3543                                settingValidities: response.setting_validities,
     3544                                focusInvalidControl: true
     3545                            } );
     3546                        }
    35523547
    35533548                        api.trigger( 'error', response );
     
    35643559
    35653560                        api.previewer.send( 'saved', response );
     3561
     3562                        if ( response.setting_validities ) {
     3563                            api._handleSettingValidities( {
     3564                                settingValidities: response.setting_validities,
     3565                                focusInvalidControl: true
     3566                            } );
     3567                        }
    35663568
    35673569                        api.trigger( 'saved', response );
     
    36693671            });
    36703672        });
     3673
     3674        /**
     3675         * Handle setting_validities in an error response for the customize-save request.
     3676         *
     3677         * Add notifications to the settings and focus on the first control that has an invalid setting.
     3678         *
     3679         * @since 4.6.0
     3680         * @private
     3681         *
     3682         * @param {object}  args
     3683         * @param {object}  args.settingValidities
     3684         * @param {boolean} [args.focusInvalidControl=false]
     3685         * @returns {void}
     3686         */
     3687        api._handleSettingValidities = function handleSettingValidities( args ) {
     3688            var invalidSettingControls, invalidSettings = [], wasFocused = false;
     3689
     3690            // Find the controls that correspond to each invalid setting.
     3691            _.each( args.settingValidities, function( validity, settingId ) {
     3692                var setting = api( settingId );
     3693                if ( setting ) {
     3694
     3695                    // Add notifications for invalidities.
     3696                    if ( _.isObject( validity ) ) {
     3697                        _.each( validity, function( params, code ) {
     3698                            var notification = new api.Notification( code, params ), existingNotification, needsReplacement = false;
     3699
     3700                            // Remove existing notification if already exists for code but differs in parameters.
     3701                            existingNotification = setting.notifications( notification.code );
     3702                            if ( existingNotification ) {
     3703                                needsReplacement = ( notification.type !== existingNotification.type ) || ! _.isEqual( notification.data, existingNotification.data );
     3704                            }
     3705                            if ( needsReplacement ) {
     3706                                setting.notifications.remove( code );
     3707                            }
     3708
     3709                            if ( ! setting.notifications.has( notification.code ) ) {
     3710                                setting.notifications.add( code, notification );
     3711                            }
     3712                            invalidSettings.push( setting.id );
     3713                        } );
     3714                    }
     3715
     3716                    // Remove notification errors that are no longer valid.
     3717                    setting.notifications.each( function( notification ) {
     3718                        if ( 'error' === notification.type && ( true === validity || ! validity[ notification.code ] ) ) {
     3719                            setting.notifications.remove( notification.code );
     3720                        }
     3721                    } );
     3722                }
     3723            } );
     3724
     3725            if ( args.focusInvalidControl ) {
     3726                invalidSettingControls = api.findControlsForSettings( invalidSettings );
     3727
     3728                // Focus on the first control that is inside of an expanded section (one that is visible).
     3729                _( _.values( invalidSettingControls ) ).find( function( controls ) {
     3730                    return _( controls ).find( function( control ) {
     3731                        var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded();
     3732                        if ( isExpanded && control.expanded ) {
     3733                            isExpanded = control.expanded();
     3734                        }
     3735                        if ( isExpanded ) {
     3736                            control.focus();
     3737                            wasFocused = true;
     3738                        }
     3739                        return wasFocused;
     3740                    } );
     3741                } );
     3742
     3743                // Focus on the first invalid control.
     3744                if ( ! wasFocused && ! _.isEmpty( invalidSettingControls ) ) {
     3745                    _.values( invalidSettingControls )[0][0].focus();
     3746                }
     3747            }
     3748        };
     3749
     3750        /**
     3751         * Find all controls associated with the given settings.
     3752         *
     3753         * @since 4.6.0
     3754         * @param {string[]} settingIds Setting IDs.
     3755         * @returns {object<string, wp.customize.Control>} Mapping setting ids to arrays of controls.
     3756         */
     3757        api.findControlsForSettings = function findControlsForSettings( settingIds ) {
     3758            var controls = {}, settingControls;
     3759            _.each( _.unique( settingIds ), function( settingId ) {
     3760                var setting = api( settingId );
     3761                if ( setting ) {
     3762                    settingControls = setting.findControls();
     3763                    if ( settingControls && settingControls.length > 0 ) {
     3764                        controls[ settingId ] = settingControls;
     3765                    }
     3766                }
     3767            } );
     3768            return controls;
     3769        };
    36713770
    36723771        /**
     
    40414140        });
    40424141
     4142        // Update the setting validities.
     4143        api.previewer.bind( 'selective-refresh-setting-validities', function handleSelectiveRefreshedSettingValidities( settingValidities ) {
     4144            api._handleSettingValidities( {
     4145                settingValidities: settingValidities,
     4146                focusInvalidControl: false
     4147            } );
     4148        } );
     4149
    40434150        // Focus on the control that is associated with the given setting.
    40444151        api.previewer.bind( 'focus-control-for-setting', function( settingId ) {
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r37539 r37700  
    826826     */
    827827    public function customize_preview_settings() {
     828        $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     829        $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
     830
    828831        $settings = array(
    829832            'theme' => array(
     
    838841            'activeSections' => array(),
    839842            'activeControls' => array(),
     843            'settingValidities' => $exported_setting_validities,
    840844            'nonce' => $this->get_nonces(),
    841845            'l10n' => array(
     
    992996     * @access public
    993997     * @see WP_REST_Request::has_valid_params()
     998     * @see WP_Customize_Setting::validate()
    994999     *
    9951000     * @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.
     1001     * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`.
    9971002     */
    9981003    public function validate_setting_values( $setting_values ) {
    999         $validity_errors = array();
     1004        $validities = array();
    10001005        foreach ( $setting_values as $setting_id => $unsanitized_value ) {
    10011006            $setting = $this->get_setting( $setting_id );
     
    10071012                $validity = new WP_Error( 'invalid_value', __( 'Invalid value.' ) );
    10081013            }
    1009             if ( is_wp_error( $validity ) ) {
    1010                 $validity_errors[ $setting_id ] = $validity;
    1011             }
    1012         }
    1013         return $validity_errors;
     1014            $validities[ $setting_id ] = $validity;
     1015        }
     1016        return $validities;
     1017    }
     1018
     1019    /**
     1020     * Prepare setting validity for exporting to the client (JS).
     1021     *
     1022     * Converts `WP_Error` instance into array suitable for passing into the
     1023     * `wp.customize.Notification` JS model.
     1024     *
     1025     * @since 4.6.0
     1026     * @access public
     1027     *
     1028     * @param true|WP_Error $validity Setting validity.
     1029     * @return true|array If `$validity` was `WP_Error` then array mapping the error
     1030     *                    codes to their respective `message` and `data` to pass
     1031     *                    into the `wp.customize.Notification` JS model.
     1032     */
     1033    public function prepare_setting_validity_for_js( $validity ) {
     1034        if ( is_wp_error( $validity ) ) {
     1035            $notification = array();
     1036            foreach ( $validity->errors as $error_code => $error_messages ) {
     1037                $error_data = $validity->get_error_data( $error_code );
     1038                if ( is_null( $error_data ) ) {
     1039                    $error_data = array();
     1040                }
     1041                $error_data = array_merge(
     1042                    $error_data,
     1043                    array( 'from_server' => true )
     1044                );
     1045                $notification[ $error_code ] = array(
     1046                    'message' => join( ' ', $error_messages ),
     1047                    'data' => $error_data,
     1048                );
     1049            }
     1050            return $notification;
     1051        } else {
     1052            return true;
     1053        }
    10141054    }
    10151055
     
    10421082
    10431083        // 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             }
     1084        $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1085        $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) );
     1086        $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
     1087        if ( $invalid_setting_count > 0 ) {
    10571088            $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 ) ),
     1089                'setting_validities' => $exported_setting_validities,
     1090                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
    10601091            );
    10611092
     
    10981129        do_action( 'customize_save_after', $this );
    10991130
     1131        $data = array(
     1132            'setting_validities' => $exported_setting_validities,
     1133        );
     1134
    11001135        /**
    11011136         * Filters response data for a successful customize_save AJAX request.
     
    11091144         * @param WP_Customize_Manager $this WP_Customize_Manager instance.
    11101145         */
    1111         $response = apply_filters( 'customize_save_response', array(), $this );
     1146        $response = apply_filters( 'customize_save_response', $data, $this );
    11121147        wp_send_json_success( $response );
    11131148    }
  • trunk/src/wp-includes/customize/class-wp-customize-selective-refresh.php

    r37011 r37700  
    403403        }
    404404
     405        $setting_validities = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() );
     406        $exported_setting_validities = array_map( array( $this->manager, 'prepare_setting_validity_for_js' ), $setting_validities );
     407        $response['setting_validities'] = $exported_setting_validities;
     408
    405409        /**
    406410         * Filters the response from rendering the partials.
  • trunk/src/wp-includes/js/customize-preview.js

    r36990 r37700  
    173173            activePanels: api.settings.activePanels,
    174174            activeSections: api.settings.activeSections,
    175             activeControls: api.settings.activeControls
     175            activeControls: api.settings.activeControls,
     176            settingValidities: api.settings.settingValidities
    176177        } );
    177178
  • trunk/src/wp-includes/js/customize-selective-refresh.js

    r37040 r37700  
    848848        } );
    849849
     850        /**
     851         * Handle setting validities in partial refresh response.
     852         *
     853         * @param {object} data Response data.
     854         * @param {object} data.setting_validities Setting validities.
     855         */
     856        api.selectiveRefresh.bind( 'render-partials-response', function handleSettingValiditiesResponse( data ) {
     857            if ( data.setting_validities ) {
     858                api.preview.send( 'selective-refresh-setting-validities', data.setting_validities );
     859            }
     860        } );
     861
    850862        api.preview.bind( 'active', function() {
    851863
  • trunk/tests/phpunit/tests/customize/manager.php

    r37476 r37700  
    197197     */
    198198    function test_validate_setting_values() {
    199         $default_value = 'foo_default';
    200199        $setting = $this->manager->add_setting( 'foo', array(
    201200            'validate_callback' => array( $this, 'filter_customize_validate_foo' ),
     
    205204        $post_value = 'bar';
    206205        $this->manager->set_post_value( 'foo', $post_value );
    207         $this->assertEmpty( $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() ) );
     206        $validities = $this->manager->validate_setting_values( $this->manager->unsanitized_post_values() );
     207        $this->assertCount( 1, $validities );
     208        $this->assertEquals( array( 'foo' => true ), $validities );
    208209
    209210        $this->manager->set_post_value( 'foo', 'return_wp_error_in_sanitize' );
     
    235236
    236237    /**
     238     * Test WP_Customize_Manager::prepare_setting_validity_for_js().
     239     *
     240     * @see WP_Customize_Manager::prepare_setting_validity_for_js()
     241     */
     242    function test_prepare_setting_validity_for_js() {
     243        $this->assertTrue( $this->manager->prepare_setting_validity_for_js( true ) );
     244        $error = new WP_Error();
     245        $error->add( 'bad_letter', 'Bad letter' );
     246        $error->add( 'bad_letter', 'Bad letra' );
     247        $error->add( 'bad_number', 'Bad number', array( 'number' => 123 ) );
     248        $validity = $this->manager->prepare_setting_validity_for_js( $error );
     249        $this->assertInternalType( 'array', $validity );
     250        foreach ( $error->errors as $code => $messages ) {
     251            $this->assertArrayHasKey( $code, $validity );
     252            $this->assertInternalType( 'array', $validity[ $code ] );
     253            $this->assertEquals( join( ' ', $messages ), $validity[ $code ]['message'] );
     254            $this->assertArrayHasKey( 'data', $validity[ $code ] );
     255            $this->assertArrayHasKey( 'from_server', $validity[ $code ]['data'] );
     256        }
     257        $this->assertArrayHasKey( 'number', $validity['bad_number']['data'] );
     258        $this->assertEquals( 123, $validity['bad_number']['data']['number'] );
     259    }
     260
     261    /**
    237262     * Test WP_Customize_Manager::set_post_value().
    238263     *
     
    566591        $this->assertArrayHasKey( 'activeSections', $settings );
    567592        $this->assertArrayHasKey( 'activeControls', $settings );
     593        $this->assertArrayHasKey( 'settingValidities', $settings );
    568594        $this->assertArrayHasKey( 'nonce', $settings );
    569595        $this->assertArrayHasKey( '_dirty', $settings );
  • trunk/tests/phpunit/tests/customize/selective-refresh-ajax.php

    r36650 r37700  
    345345        $output = json_decode( ob_get_clean(), true );
    346346        $this->assertEquals( array( get_bloginfo( 'name', 'display' ) ), $output['data']['contents']['test_blogname'] );
     347        $this->assertArrayHasKey( 'setting_validities', $output['data'] );
    347348    }
    348349
  • trunk/tests/qunit/wp-admin/js/customize-controls.js

    r37476 r37700  
    9696        ok( setting.notifications.extended( wp.customize.Values ) );
    9797        equal( wp.customize.Notification, setting.notifications.prototype.constructor.defaultConstructor );
     98    } );
     99    test( 'Setting has findControls method', function() {
     100        var controls, setting = wp.customize( 'fixture-setting' );
     101        equal( 'function', typeof setting.findControls );
     102        controls = setting.findControls();
     103        equal( 1, controls.length );
     104        equal( 'fixture-control', controls[0].id );
    98105    } );
    99106    test( 'Setting constructor object exists', function( assert ) {
     
    506513        equal( mockPanel.isContextuallyActive(), false );
    507514    });
     515
     516    module( 'Test wp.customize.findControlsForSettings' );
     517    test( 'findControlsForSettings(blogname)', function() {
     518        var controlsForSettings, settingId = 'fixture-setting', controlId = 'fixture-control';
     519        ok( wp.customize.control.has( controlId ) );
     520        ok( wp.customize.has( settingId ) );
     521        controlsForSettings = wp.customize.findControlsForSettings( [ settingId ] );
     522        ok( _.isObject( controlsForSettings ), 'Response is object' );
     523        ok( _.isArray( controlsForSettings['fixture-setting'] ), 'Response has a fixture-setting array' );
     524        equal( 1, controlsForSettings['fixture-setting'].length );
     525        equal( wp.customize.control( controlId ), controlsForSettings['fixture-setting'][0] );
     526    } );
    508527});
Note: See TracChangeset for help on using the changeset viewer.