Make WordPress Core

Changeset 37700


Ignore:
Timestamp:
06/14/2016 07:16:54 PM (10 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.