Customize: Add global notifications area.

* Displays an error notification in the global area when a save attempt is rejected due to invalid settings. An error notification is also displayed when saving fails due to a network error or server error.
* Introduces `wp.customize.Notifications` subclass of `wp.customize.Values` to contain instances of `wp.customize.Notification` and manage their rendering into a container.
* Exposes the global notification area as `wp.customize.notifications` collection instance.
* Updates the `notifications` object on `Control` to use `Notifications` rather than `Values` and to re-use the rendering logic from the former. The old `Control#renderNotifications` method is deprecated.
* Allows notifications to be dismissed by instantiating them with a `dismissible` property.
* Allows `wp.customize.Notification` to be extended with custom templates and `render` functions.
* Triggers a `removed` event on `wp.customize.Values` instances _after_ a value has been removed from the collection.

Props delawski, westonruter, karmatosed, celloexpressions, Fab1en, melchoyce, Kelderic, afercia, adamsilverstein.
See #34893, #39896.
Fixes #35210, #31582, #37727, #37269.

Built from https://develop.svn.wordpress.org/trunk@41374


git-svn-id: http://core.svn.wordpress.org/trunk@41207 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Weston Ruter 2017-09-12 07:03:46 +00:00
parent 560d705b00
commit d8f445bf0f
14 changed files with 491 additions and 62 deletions

View File

@ -766,7 +766,6 @@ p.customize-section-description {
#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;
}
@ -798,6 +797,33 @@ p.customize-section-description {
outline: 2px solid #dc3232;
}
#customize-controls #customize-notifications-area {
position: absolute;
top: 46px;
width: 100%;
max-height: 210px;
overflow-x: hidden;
overflow-y: auto;
border-bottom: 1px solid #ddd;
display: block;
padding: 0;
margin: 0;
}
#customize-controls #customize-notifications-area > ul,
#customize-controls #customize-notifications-area .notice {
margin: 0;
}
#customize-controls #customize-notifications-area .notice {
padding: 9px 14px;
}
#customize-controls #customize-notifications-area .notice.is-dismissible {
padding-left: 38px;
}
#customize-controls #customize-notifications-area .notice + .notice {
margin-top: 1px;
}
/* Style for custom settings */
/**

File diff suppressed because one or more lines are too long

View File

@ -766,7 +766,6 @@ p.customize-section-description {
#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;
}
@ -798,6 +797,33 @@ p.customize-section-description {
outline: 2px solid #dc3232;
}
#customize-controls #customize-notifications-area {
position: absolute;
top: 46px;
width: 100%;
max-height: 210px;
overflow-x: hidden;
overflow-y: auto;
border-bottom: 1px solid #ddd;
display: block;
padding: 0;
margin: 0;
}
#customize-controls #customize-notifications-area > ul,
#customize-controls #customize-notifications-area .notice {
margin: 0;
}
#customize-controls #customize-notifications-area .notice {
padding: 9px 14px;
}
#customize-controls #customize-notifications-area .notice.is-dismissible {
padding-right: 38px;
}
#customize-controls #customize-notifications-area .notice + .notice {
margin-top: 1px;
}
/* Style for custom settings */
/**

File diff suppressed because one or more lines are too long

View File

@ -151,6 +151,9 @@ do_action( 'customize_controls_print_scripts' );
</div>
<div id="widgets-right" class="wp-clearfix"><!-- For Widget Customizer, many widgets try to look for instances under div#widgets-right, so we have to add that ID to a container div in the Customizer for compat -->
<div id="customize-notifications-area" class="customize-control-notifications-container">
<ul></ul>
</div>
<div class="wp-full-overlay-sidebar-content" tabindex="-1">
<div id="customize-info" class="accordion-section customize-info">
<div class="accordion-section-title">

View File

@ -1,7 +1,213 @@
/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer */
/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console */
(function( exports, $ ){
var Container, focus, normalizedTransitionendEventName, api = wp.customize;
/**
* A collection of observable notifications.
*
* @since 4.9.0
* @class
* @augments wp.customize.Values
*/
api.Notifications = api.Values.extend({
/**
* Whether the alternative style should be used.
*
* @since 4.9.0
* @type {boolean}
*/
alt: false,
/**
* The default constructor for items of the collection.
*
* @since 4.9.0
* @type {object}
*/
defaultConstructor: api.Notification,
/**
* Initialize notifications area.
*
* @since 4.9.0
* @constructor
* @param {object} options - Options.
* @param {jQuery} [options.container] - Container element for notifications. This can be injected later.
* @param {boolean} [options.alt] - Whether alternative style should be used when rendering notifications.
* @returns {void}
* @this {wp.customize.Notifications}
*/
initialize: function( options ) {
var collection = this;
api.Values.prototype.initialize.call( collection, options );
// Keep track of the order in which the notifications were added for sorting purposes.
collection._addedIncrement = 0;
collection._addedOrder = {};
// Trigger change event when notification is added or removed.
collection.bind( 'add', function( notification ) {
collection.trigger( 'change', notification );
});
collection.bind( 'removed', function( notification ) {
collection.trigger( 'change', notification );
});
},
/**
* Get the number of notifications added.
*
* @since 4.9.0
* @return {number} Count of notifications.
*/
count: function() {
return _.size( this._value );
},
/**
* Add notification to the collection.
*
* @since 4.9.0
* @param {string} code - Notification code.
* @param {object} params - Notification params.
* @return {api.Notification} Added instance (or existing instance if it was already added).
*/
add: function( code, params ) {
var collection = this;
if ( ! collection.has( code ) ) {
collection._addedIncrement += 1;
collection._addedOrder[ code ] = collection._addedIncrement;
}
return api.Values.prototype.add.call( this, code, params );
},
/**
* Add notification to the collection.
*
* @since 4.9.0
* @param {string} code - Notification code to remove.
* @return {api.Notification} Added instance (or existing instance if it was already added).
*/
remove: function( code ) {
var collection = this;
delete collection._addedOrder[ code ];
return api.Values.prototype.remove.call( this, code );
},
/**
* Get list of notifications.
*
* Notifications may be sorted by type followed by added time.
*
* @since 4.9.0
* @param {object} args - Args.
* @param {boolean} [args.sort=false] - Whether to return the notifications sorted.
* @return {Array.<wp.customize.Notification>} Notifications.
* @this {wp.customize.Notifications}
*/
get: function( args ) {
var collection = this, notifications, errorTypePriorities, params;
notifications = _.values( collection._value );
params = _.extend(
{ sort: false },
args
);
if ( params.sort ) {
errorTypePriorities = { error: 4, warning: 3, success: 2, info: 1 };
notifications.sort( function( a, b ) {
var aPriority = 0, bPriority = 0;
if ( ! _.isUndefined( errorTypePriorities[ a.type ] ) ) {
aPriority = errorTypePriorities[ a.type ];
}
if ( ! _.isUndefined( errorTypePriorities[ b.type ] ) ) {
bPriority = errorTypePriorities[ b.type ];
}
if ( aPriority !== bPriority ) {
return bPriority - aPriority; // Show errors first.
}
return collection._addedOrder[ b.code ] - collection._addedOrder[ a.code ]; // Show newer notifications higher.
});
}
return notifications;
},
/**
* Render notifications area.
*
* @since 4.9.0
* @returns {void}
* @this {wp.customize.Notifications}
*/
render: function() {
var collection = this,
notifications,
renderedNotificationContainers,
prevRenderedCodes,
nextRenderedCodes,
addedCodes,
removedCodes,
listElement;
// Short-circuit if there are no container to render into.
if ( ! collection.container || ! collection.container.length ) {
return;
}
listElement = collection.container.children( 'ul' ).first();
if ( ! listElement.length ) {
listElement = $( '<ul></ul>' );
collection.container.append( listElement );
}
notifications = collection.get( { sort: true } );
renderedNotificationContainers = {};
listElement.find( '> [data-code]' ).each( function() {
renderedNotificationContainers[ $( this ).data( 'code' ) ] = $( this );
});
collection.container.toggle( 0 !== notifications.length );
nextRenderedCodes = _.pluck( notifications, 'code' );
prevRenderedCodes = _.keys( renderedNotificationContainers );
// Short-circuit if there are no notifications added.
if ( _.isEqual( nextRenderedCodes, prevRenderedCodes ) ) {
return;
}
addedCodes = _.difference( nextRenderedCodes, prevRenderedCodes );
removedCodes = _.difference( prevRenderedCodes, nextRenderedCodes );
// Remove notifications that have been removed.
_.each( renderedNotificationContainers, function( renderedContainer, code ) {
if ( -1 !== _.indexOf( removedCodes, code ) ) {
renderedContainer.remove(); // @todo Consider slideUp as enhancement.
}
});
// Add all notifications in the sorted order.
_.each( notifications, function( notification ) {
var notificationContainer = renderedNotificationContainers[ notification.code ];
if ( notificationContainer ) {
listElement.append( notificationContainer );
} else {
notificationContainer = $( notification.render() );
listElement.append( notificationContainer ); // @todo Consider slideDown() as enhancement.
if ( wp.a11y ) {
wp.a11y.speak( notification.message, 'assertive' );
}
}
});
collection.trigger( 'rendered' );
}
});
/**
* A Customizer Setting.
*
@ -1883,7 +2089,9 @@
control.priority = new api.Value();
control.active = new api.Value();
control.activeArgumentsQueue = [];
control.notifications = new api.Values({ defaultConstructor: api.Notification });
control.notifications = new api.Notifications({
alt: control.altNotice
});
control.elements = [];
@ -1973,21 +2181,17 @@
// 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();
var renderNotifications = function() {
control.notifications.render();
};
control.notifications.container = control.getNotificationsContainerElement();
control.notifications.bind( 'rendered', function() {
var notifications = control.notifications.get();
control.container.toggleClass( 'has-notifications', 0 !== notifications.length );
control.container.toggleClass( 'has-error', 0 !== _.where( notifications, { type: 'error' } ).length );
} );
control.notifications.bind( 'add', function( notification ) {
wp.a11y.speak( notification.message, 'assertive' );
debouncedRenderNotifications();
} );
control.notifications.bind( 'remove', debouncedRenderNotifications );
control.renderNotifications();
renderNotifications();
control.notifications.bind( 'change', _.debounce( renderNotifications ) );
control.ready();
});
},
@ -2091,11 +2295,17 @@
* Control subclasses may override this method to do their own handling
* of rendering notifications.
*
* @deprecated in favor of `control.notifications.render()`
* @since 4.6.0
* @this {wp.customize.Control}
*/
renderNotifications: function() {
var control = this, container, notifications, hasError = false;
if ( 'undefined' !== typeof console && console.warn ) {
console.warn( '[DEPRECATED] wp.customize.Control.prototype.renderNotifications() is deprecated in favor of instantating a wp.customize.Notifications and calling its render() method.' );
}
container = control.getNotificationsContainerElement();
if ( ! container || ! container.length ) {
return;
@ -3427,6 +3637,9 @@
api.section = new api.Values({ defaultConstructor: api.Section });
api.panel = new api.Values({ defaultConstructor: api.Panel });
// Create the collection for global Notifications.
api.notifications = new api.Notifications();
/**
* An object that fetches a preview in the background of the document, which
* allows for seamless replacement of an existing preview.
@ -4501,6 +4714,13 @@
api.unbind( 'change', captureSettingModifiedDuringSave );
} );
// Remove notifications that were added due to save failures.
api.notifications.each( function( notification ) {
if ( notification.saveFailure ) {
api.notifications.remove( notification.code );
}
});
request.fail( function ( response ) {
if ( '0' === response ) {
@ -4518,6 +4738,22 @@
previewer.save();
previewer.preview.iframe.show();
} );
} else if ( response.code ) {
api.notifications.add( response.code, new api.Notification( response.code, {
message: response.message,
type: 'error',
dismissible: true,
fromServer: true,
saveFailure: true
} ) );
} else {
api.notifications.add( 'unknown_error', new api.Notification( 'unknown_error', {
message: api.l10n.serverSaveError,
type: 'error',
dismissible: true,
fromServer: true,
saveFailure: true
} ) );
}
if ( response.setting_validities ) {
@ -4688,6 +4924,29 @@
values.bind( 'remove', debouncedReflowPaneContents );
} );
// Set up global notifications area.
api.bind( 'ready', function setUpGlobalNotificationsArea() {
var sidebar, containerHeight, containerInitialTop;
api.notifications.container = $( '#customize-notifications-area' );
api.notifications.bind( 'change', _.debounce( function() {
api.notifications.render();
} ) );
sidebar = $( '.wp-full-overlay-sidebar-content' );
api.notifications.bind( 'rendered', function updateSidebarTop() {
sidebar.css( 'top', '' );
if ( 0 !== api.notifications.count() ) {
containerHeight = api.notifications.container.outerHeight() + 1;
containerInitialTop = parseInt( sidebar.css( 'top' ), 10 );
sidebar.css( 'top', containerInitialTop + containerHeight + 'px' );
}
api.notifications.trigger( 'sidebarTopUpdated' );
});
api.notifications.render();
});
// Save and activated states
(function() {
var state = new api.Values(),
@ -4971,12 +5230,32 @@
}
var scrollTop = parentContainer.scrollTop(),
isScrollingUp = ( lastScrollTop ) ? scrollTop <= lastScrollTop : true;
scrollDirection;
if ( ! lastScrollTop ) {
scrollDirection = 1;
} else {
if ( scrollTop === lastScrollTop ) {
scrollDirection = 0;
} else if ( scrollTop > lastScrollTop ) {
scrollDirection = 1;
} else {
scrollDirection = -1;
}
}
lastScrollTop = scrollTop;
positionStickyHeader( activeHeader, scrollTop, isScrollingUp );
if ( 0 !== scrollDirection ) {
positionStickyHeader( activeHeader, scrollTop, scrollDirection );
}
}, 8 ) );
// Update header position on sidebar layout change.
api.notifications.bind( 'sidebarTopUpdated', function() {
if ( activeHeader && activeHeader.element.hasClass( 'is-sticky' ) ) {
activeHeader.element.css( 'top', parentContainer.css( 'top' ) );
}
});
// Release header element if it is sticky.
releaseStickyHeader = function( headerElement ) {
if ( ! headerElement.hasClass( 'is-sticky' ) ) {
@ -4990,6 +5269,7 @@
// Reset position of the sticky header.
resetStickyHeader = function( headerElement, headerParent ) {
if ( headerElement.hasClass( 'is-in-view' ) ) {
headerElement
.removeClass( 'maybe-sticky is-in-view' )
.css( {
@ -4997,6 +5277,7 @@
top: ''
} );
headerParent.css( 'padding-top', '' );
}
};
/**
@ -5023,19 +5304,20 @@
* @since 4.7.0
* @access private
*
* @param {object} header Header.
* @param {number} scrollTop Scroll top.
* @param {boolean} isScrollingUp Is scrolling up?
* @param {object} header - Header.
* @param {number} scrollTop - Scroll top.
* @param {number} scrollDirection - Scroll direction, negative number being up and positive being down.
* @returns {void}
*/
positionStickyHeader = function( header, scrollTop, isScrollingUp ) {
positionStickyHeader = function( header, scrollTop, scrollDirection ) {
var headerElement = header.element,
headerParent = header.parent,
headerHeight = header.height,
headerTop = parseInt( headerElement.css( 'top' ), 10 ),
maybeSticky = headerElement.hasClass( 'maybe-sticky' ),
isSticky = headerElement.hasClass( 'is-sticky' ),
isInView = headerElement.hasClass( 'is-in-view' );
isInView = headerElement.hasClass( 'is-in-view' ),
isScrollingUp = ( -1 === scrollDirection );
// When scrolling down, gradually hide sticky header.
if ( ! isScrollingUp ) {
@ -5078,7 +5360,7 @@
headerElement
.addClass( 'is-sticky' )
.css( {
top: '',
top: parentContainer.css( 'top' ),
width: headerParent.outerWidth() + 'px'
} );
}

File diff suppressed because one or more lines are too long

View File

@ -550,6 +550,10 @@
}
control.widgetContentEmbedded = true;
// Update the notification container element now that the widget content has been embedded.
control.notifications.container = control.getNotificationsContainerElement();
control.notifications.render();
widgetContent = $( control.params.widget_content );
control.container.find( '.widget-content:first' ).append( widgetContent );

File diff suppressed because one or more lines are too long

View File

@ -348,7 +348,7 @@ final class WP_Customize_Manager {
add_action( 'customize_controls_init', array( $this, 'prepare_controls' ) );
add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_control_scripts' ) );
// Render Panel, Section, and Control templates.
// Render Common, Panel, Section, and Control templates.
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_panel_templates' ), 1 );
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_section_templates' ), 1 );
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_control_templates' ), 1 );
@ -2355,7 +2355,8 @@ final class WP_Customize_Manager {
if ( $update_transactionally && $invalid_setting_count > 0 ) {
$response = array(
'setting_validities' => $setting_validities,
'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
/* translators: placeholder is number of invalid settings */
'message' => sprintf( _n( 'Unable to save due to %s invalid setting.', 'Unable to save due to %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
);
return new WP_Error( 'transaction_fail', '', $response );
}
@ -3183,6 +3184,19 @@ final class WP_Customize_Manager {
) );
$control->print_template();
}
?>
<script type="text/html" id="tmpl-customize-notification">
<li class="notice notice-{{ data.type || 'info' }} {{ data.alt ? 'notice-alt' : '' }} {{ data.dismissible ? 'is-dismissible' : '' }}" data-code="{{ data.code }}" data-type="{{ data.type }}">
{{{ data.message || data.code }}}
<# if ( data.dismissible ) { #>
<button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php _e( 'Dismiss' ); ?></span></button>
<# } #>
</li>
</script>
<?php
/* The following template is obsolete in core but retained for plugins. */
?>
<script type="text/html" id="tmpl-customize-control-notifications">
<ul>

View File

@ -433,18 +433,26 @@ window.wp = window.wp || {};
* @param {string} id The ID of the item to remove.
*/
remove: function( id ) {
var value;
var value = this.value( id );
if ( this.has( id ) ) {
value = this.value( id );
if ( value ) {
// Trigger event right before the element is removed from the collection.
this.trigger( 'remove', value );
if ( value.extended( api.Value ) )
if ( value.extended( api.Value ) ) {
value.unbind( this._change );
}
delete value.parent;
}
delete this._value[ id ];
delete this._deferreds[ id ];
// Trigger removed event after the item has been eliminated from the collection.
if ( value ) {
this.trigger( 'removed', value );
}
},
/**
@ -790,6 +798,39 @@ window.wp = window.wp || {};
* @param {*} [params.data=null] - Any additional data.
*/
api.Notification = api.Class.extend(/** @lends wp.customize.Notification.prototype */{
/**
* Template function for rendering the notification.
*
* This will be populated with template option or else it will be populated with template from the ID.
*
* @since 4.9.0
* @var {Function}
*/
template: null,
/**
* ID for the template to render the notification.
*
* @since 4.9.0
* @var {string}
*/
templateId: 'customize-notification',
/**
* Initialize notification.
*
* @since 4.9.0
*
* @param {string} code - Notification code.
* @param {object} params - Notification parameters.
* @param {string} params.message - Message.
* @param {string} [params.type=error] - Type.
* @param {string} [params.setting] - Related setting ID.
* @param {Function} [params.template] - Function for rendering template. If not provided, this will come from templateId.
* @param {string} [params.templateId] - ID for template to render the notification.
* @param {boolean} [params.dismissible] - Whether the notification can be dismissed.
*/
initialize: function( code, params ) {
var _params;
this.code = code;
@ -799,12 +840,44 @@ window.wp = window.wp || {};
type: 'error',
fromServer: false,
data: null,
setting: null
setting: null,
template: null,
dismissible: false
},
params
);
delete _params.code;
_.extend( this, _params );
},
/**
* Render the notification.
*
* @since 4.9.0
*
* @returns {jQuery} Notification container element.
*/
render: function() {
var notification = this, container, data;
if ( ! notification.template ) {
notification.template = wp.template( notification.templateId );
}
data = _.extend( {}, notification, {
alt: notification.parent && notification.parent.alt
} );
container = $( notification.template( data ) );
if ( notification.dismissible ) {
container.find( '.notice-dismiss' ).on( 'click', function() {
if ( notification.parent ) {
notification.parent.remove( notification.code );
} else {
container.remove();
}
});
}
return container;
}
});

File diff suppressed because one or more lines are too long

View File

@ -546,6 +546,7 @@ function wp_default_scripts( &$scripts ) {
'collapseSidebar' => _x( 'Hide Controls', 'label for hide controls button without length constraints' ),
'expandSidebar' => _x( 'Show Controls', 'label for hide controls button without length constraints' ),
'untitledBlogName' => __( '(Untitled)' ),
'serverSaveError' => __( 'Failed connecting to the server. Please try saving again.' ),
// Used for overriding the file types allowed in plupload.
'allowedFiles' => __( 'Allowed Files' ),
) );

View File

@ -4,7 +4,7 @@
*
* @global string $wp_version
*/
$wp_version = '4.9-alpha-41373';
$wp_version = '4.9-alpha-41374';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.