mirror of
https://github.com/gosticks/wordpress-develop.git
synced 2026-08-13 21:30:26 +00:00
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.
git-svn-id: https://develop.svn.wordpress.org/trunk@37476 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
@@ -494,7 +494,7 @@ p.customize-section-description {
|
||||
.customize-control input[type="search"],
|
||||
.customize-control input[type="tel"],
|
||||
.customize-control input[type="url"] {
|
||||
width: 98%;
|
||||
width: 100%;
|
||||
line-height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -622,6 +622,46 @@ p.customize-section-description {
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifications
|
||||
*/
|
||||
|
||||
#customize-controls .customize-control-notifications-container { /* Scoped to #customize-controls for specificity over notification styles in common.css. */
|
||||
margin: 4px 0 8px 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#customize-controls .customize-control-widget_form.has-error .widget .widget-top,
|
||||
.customize-control-nav_menu_item.has-error .menu-item-bar .menu-item-handle {
|
||||
box-shadow: inset 0 0 0 2px #dc3232;
|
||||
transition: .15s box-shadow linear;
|
||||
}
|
||||
|
||||
.customize-control-notifications-container li.notice {
|
||||
list-style: none;
|
||||
margin: 0 0 6px 0;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.customize-control-notifications-container li.notice:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#customize-controls .customize-control-nav_menu_item .customize-control-notifications-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#customize-controls .customize-control-widget_form .customize-control-notifications-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.customize-control-text.has-error input {
|
||||
outline: 2px solid #dc3232;
|
||||
}
|
||||
|
||||
/* Style for custom settings */
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
this.id = id;
|
||||
this.transport = this.transport || 'refresh';
|
||||
this._dirty = options.dirty || false;
|
||||
this.notifications = new api.Values({ defaultConstructor: api.Notification });
|
||||
|
||||
// Whenever the setting's value changes, refresh the preview.
|
||||
this.bind( this.preview );
|
||||
@@ -1478,6 +1479,7 @@
|
||||
control.priority = new api.Value();
|
||||
control.active = new api.Value();
|
||||
control.activeArgumentsQueue = [];
|
||||
control.notifications = new api.Values({ defaultConstructor: api.Notification });
|
||||
|
||||
control.elements = [];
|
||||
|
||||
@@ -1541,12 +1543,37 @@
|
||||
|
||||
control.setting = control.settings['default'] || null;
|
||||
|
||||
_.each( control.settings, function( setting ) {
|
||||
setting.notifications.bind( 'add', function( settingNotification ) {
|
||||
var controlNotification = new api.Notification( setting.id + ':' + settingNotification.code, settingNotification );
|
||||
control.notifications.add( controlNotification.code, controlNotification );
|
||||
} );
|
||||
setting.notifications.bind( 'remove', function( settingNotification ) {
|
||||
control.notifications.remove( setting.id + ':' + settingNotification.code );
|
||||
} );
|
||||
} );
|
||||
|
||||
control.embed();
|
||||
}) );
|
||||
}
|
||||
|
||||
// After the control is embedded on the page, invoke the "ready" method.
|
||||
control.deferred.embedded.done( function () {
|
||||
/*
|
||||
* Note that this debounced/deferred rendering is needed for two reasons:
|
||||
* 1) The 'remove' event is triggered just _before_ the notification is actually removed.
|
||||
* 2) Improve performance when adding/removing multiple notifications at a time.
|
||||
*/
|
||||
var debouncedRenderNotifications = _.debounce( function renderNotifications() {
|
||||
control.renderNotifications();
|
||||
} );
|
||||
control.notifications.bind( 'add', function( notification ) {
|
||||
wp.a11y.speak( notification.message, 'assertive' );
|
||||
debouncedRenderNotifications();
|
||||
} );
|
||||
control.notifications.bind( 'remove', debouncedRenderNotifications );
|
||||
control.renderNotifications();
|
||||
|
||||
control.ready();
|
||||
});
|
||||
},
|
||||
@@ -1588,6 +1615,85 @@
|
||||
*/
|
||||
ready: function() {},
|
||||
|
||||
/**
|
||||
* Get the element inside of a control's container that contains the validation error message.
|
||||
*
|
||||
* Control subclasses may override this to return the proper container to render notifications into.
|
||||
* Injects the notification container for existing controls that lack the necessary container,
|
||||
* including special handling for nav menu items and widgets.
|
||||
*
|
||||
* @since 4.6.0
|
||||
* @returns {jQuery} Setting validation message element.
|
||||
* @this {wp.customize.Control}
|
||||
*/
|
||||
getNotificationsContainerElement: function() {
|
||||
var control = this, controlTitle, notificationsContainer;
|
||||
|
||||
notificationsContainer = control.container.find( '.customize-control-notifications-container:first' );
|
||||
if ( notificationsContainer.length ) {
|
||||
return notificationsContainer;
|
||||
}
|
||||
|
||||
notificationsContainer = $( '<div class="customize-control-notifications-container"></div>' );
|
||||
|
||||
if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) {
|
||||
control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer );
|
||||
} else if ( control.container.hasClass( 'customize-control-widget_form' ) ) {
|
||||
control.container.find( '.widget-inside:first' ).prepend( notificationsContainer );
|
||||
} else {
|
||||
controlTitle = control.container.find( '.customize-control-title' );
|
||||
if ( controlTitle.length ) {
|
||||
controlTitle.after( notificationsContainer );
|
||||
} else {
|
||||
control.container.prepend( notificationsContainer );
|
||||
}
|
||||
}
|
||||
return notificationsContainer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render notifications.
|
||||
*
|
||||
* Renders the `control.notifications` into the control's container.
|
||||
* Control subclasses may override this method to do their own handling
|
||||
* of rendering notifications.
|
||||
*
|
||||
* @since 4.6.0
|
||||
* @this {wp.customize.Control}
|
||||
*/
|
||||
renderNotifications: function() {
|
||||
var control = this, container, notifications, hasError = false;
|
||||
container = control.getNotificationsContainerElement();
|
||||
if ( ! container || ! container.length ) {
|
||||
return;
|
||||
}
|
||||
notifications = [];
|
||||
control.notifications.each( function( notification ) {
|
||||
notifications.push( notification );
|
||||
if ( 'error' === notification.type ) {
|
||||
hasError = true;
|
||||
}
|
||||
} );
|
||||
|
||||
if ( 0 === notifications.length ) {
|
||||
container.stop().slideUp( 'fast' );
|
||||
} else {
|
||||
container.stop().slideDown( 'fast', null, function() {
|
||||
$( this ).css( 'height', 'auto' );
|
||||
} );
|
||||
}
|
||||
|
||||
if ( ! control.notificationsTemplate ) {
|
||||
control.notificationsTemplate = wp.template( 'customize-control-notifications' );
|
||||
}
|
||||
|
||||
control.container.toggleClass( 'has-notifications', 0 !== notifications.length );
|
||||
control.container.toggleClass( 'has-error', hasError );
|
||||
container.empty().append( $.trim(
|
||||
control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } )
|
||||
) );
|
||||
},
|
||||
|
||||
/**
|
||||
* Normal controls do not expand, so just expand its parent
|
||||
*
|
||||
@@ -3223,6 +3329,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
api.settingConstructor = {};
|
||||
api.controlConstructor = {
|
||||
color: api.ColorControl,
|
||||
media: api.MediaControl,
|
||||
@@ -3323,6 +3430,62 @@
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle invalid_settings in an error response for the customize-save request.
|
||||
*
|
||||
* Add notifications to the settings and focus on the first control that has an invalid setting.
|
||||
*
|
||||
* @since 4.6.0
|
||||
* @private
|
||||
*
|
||||
* @param {object} response
|
||||
* @param {object} response.invalid_settings
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleInvalidSettingsError: function( response ) {
|
||||
var invalidControls = [], wasFocused = false;
|
||||
if ( _.isEmpty( response.invalid_settings ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the controls that correspond to each invalid setting.
|
||||
_.each( response.invalid_settings, function( notifications, settingId ) {
|
||||
var setting = api( settingId );
|
||||
if ( setting ) {
|
||||
_.each( notifications, function( notificationParams, code ) {
|
||||
var notification = new api.Notification( code, notificationParams );
|
||||
setting.notifications.add( code, notification );
|
||||
} );
|
||||
}
|
||||
|
||||
api.control.each( function( control ) {
|
||||
_.each( control.settings, function( controlSetting ) {
|
||||
if ( controlSetting.id === settingId ) {
|
||||
invalidControls.push( control );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
// Focus on the first control that is inside of an expanded section (one that is visible).
|
||||
_( invalidControls ).find( function( control ) {
|
||||
var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded();
|
||||
if ( isExpanded && control.expanded ) {
|
||||
isExpanded = control.expanded();
|
||||
}
|
||||
if ( isExpanded ) {
|
||||
control.focus();
|
||||
wasFocused = true;
|
||||
}
|
||||
return wasFocused;
|
||||
} );
|
||||
|
||||
// Focus on the first invalid control.
|
||||
if ( ! wasFocused && invalidControls[0] ) {
|
||||
invalidControls[0].focus();
|
||||
}
|
||||
},
|
||||
|
||||
save: function() {
|
||||
var self = this,
|
||||
processing = api.state( 'processing' ),
|
||||
@@ -3349,6 +3512,18 @@
|
||||
|
||||
api.trigger( 'save', request );
|
||||
|
||||
/*
|
||||
* Remove all setting error notifications prior to save, allowing
|
||||
* server to respond with fresh validation error notifications.
|
||||
*/
|
||||
api.each( function( setting ) {
|
||||
setting.notifications.each( function( notification ) {
|
||||
if ( 'error' === notification.type ) {
|
||||
setting.notifications.remove( notification.code );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
|
||||
request.always( function () {
|
||||
body.removeClass( 'saving' );
|
||||
saveBtn.prop( 'disabled', false );
|
||||
@@ -3372,6 +3547,9 @@
|
||||
self.preview.iframe.show();
|
||||
} );
|
||||
}
|
||||
|
||||
self._handleInvalidSettingsError( response );
|
||||
|
||||
api.trigger( 'error', response );
|
||||
} );
|
||||
|
||||
@@ -3424,11 +3602,15 @@
|
||||
|
||||
// Create Settings
|
||||
$.each( api.settings.settings, function( id, data ) {
|
||||
api.create( id, id, data.value, {
|
||||
var constructor = api.settingConstructor[ data.type ] || api.Setting,
|
||||
setting;
|
||||
|
||||
setting = new constructor( id, data.value, {
|
||||
transport: data.transport,
|
||||
previewer: api.previewer,
|
||||
dirty: !! data.dirty
|
||||
} );
|
||||
api.add( id, setting );
|
||||
});
|
||||
|
||||
// Create Panels
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
args = $.extend( {}, control.defaultExpandedArguments, args );
|
||||
control.onChangeExpanded( expanded, args );
|
||||
});
|
||||
control.altNotice = true;
|
||||
|
||||
api.Control.prototype.initialize.call( control, id, options );
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user