diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css
index da7c5786e6..b2f0a2d992 100644
--- a/src/wp-admin/css/customize-controls.css
+++ b/src/wp-admin/css/customize-controls.css
@@ -20,6 +20,46 @@ body {
text-align: center;
}
+#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked {
+ background-color: rgba( 0, 0, 0, 0.7 );
+ padding: 25px;
+}
+
+#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .customize-changeset-locked-message {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 366px;
+ min-height: 64px;
+ width: auto;
+ padding: 25px 25px 25px 109px;
+ position: relative;
+ background: #fff;
+ box-shadow: 0 3px 6px rgba( 0, 0, 0, 0.3 );
+ line-height: 1.5;
+ overflow-y: auto;
+ text-align: left;
+ top: calc( 50% - 100px );
+}
+
+#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .currently-editing {
+ margin-top: 0;
+}
+#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .action-buttons {
+ margin-bottom: 0;
+}
+
+.customize-changeset-locked-avatar {
+ width: 64px;
+ position: absolute;
+ left: 25px;
+ top: 25px;
+}
+
+.wp-core-ui.wp-customizer .customize-changeset-locked-message a.button {
+ margin-right: 10px;
+ margin-top: 0;
+}
+
#customize-controls .description {
color: #555d66;
}
diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php
index d441acaeae..7f9e5a9dc7 100644
--- a/src/wp-admin/customize.php
+++ b/src/wp-admin/customize.php
@@ -87,6 +87,7 @@ add_action( 'customize_controls_print_styles', 'print_admin_styles', 20
*/
do_action( 'customize_controls_init' );
+wp_enqueue_script( 'heartbeat' );
wp_enqueue_script( 'customize-controls' );
wp_enqueue_style( 'customize-controls' );
diff --git a/src/wp-admin/js/customize-controls.js b/src/wp-admin/js/customize-controls.js
index c9e7b67886..d13c9d17c6 100644
--- a/src/wp-admin/js/customize-controls.js
+++ b/src/wp-admin/js/customize-controls.js
@@ -34,6 +34,37 @@
if ( notification.loading ) {
notification.containerClasses += ' notification-loading';
}
+ },
+
+ /**
+ * Render notification.
+ *
+ * @since 4.9.0
+ *
+ * @return {jQuery} Notification container.
+ */
+ render: function() {
+ var li = api.Notification.prototype.render.call( this );
+ li.on( 'keydown', _.bind( this.handleEscape, this ) );
+ return li;
+ },
+
+ /**
+ * Stop propagation on escape key presses, but also dismiss notification if it is dismissible.
+ *
+ * @since 4.9.0
+ *
+ * @param {jQuery.Event} event - Event.
+ * @returns {void}
+ */
+ handleEscape: function( event ) {
+ var notification = this;
+ if ( 27 === event.which ) {
+ event.stopPropagation();
+ if ( notification.dismissible && notification.parent ) {
+ notification.parent.remove( notification.code );
+ }
+ }
}
});
@@ -282,11 +313,30 @@
* @returns {void}
*/
constrainFocus: function constrainFocus( event ) {
- var collection = this;
- if ( ! collection.focusContainer || collection.focusContainer.is( event.target ) || $.contains( collection.focusContainer[0], event.target[0] ) ) {
+ var collection = this, focusableElements;
+
+ // Prevent keys from escaping.
+ event.stopPropagation();
+
+ if ( 9 !== event.which ) { // Tab key.
return;
}
- collection.focusContainer.focus();
+
+ focusableElements = collection.focusContainer.find( ':focusable' );
+ if ( 0 === focusableElements.length ) {
+ focusableElements = collection.focusContainer;
+ }
+
+ if ( ! $.contains( collection.focusContainer[0], event.target ) || ! $.contains( collection.focusContainer[0], document.activeElement ) ) {
+ event.preventDefault();
+ focusableElements.first().focus();
+ } else if ( focusableElements.last().is( event.target ) && ! event.shiftKey ) {
+ event.preventDefault();
+ focusableElements.first().focus();
+ } else if ( focusableElements.first().is( event.target ) && event.shiftKey ) {
+ event.preventDefault();
+ focusableElements.last().focus();
+ }
}
});
@@ -6737,7 +6787,8 @@
'selectedChangesetStatus',
'remainingTimeToPublish',
'previewerAlive',
- 'editShortcutVisibility'
+ 'editShortcutVisibility',
+ 'changesetLocked'
], function( name ) {
api.state.create( name );
});
@@ -7184,14 +7235,14 @@
} else if ( response.code ) {
if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) {
api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus();
- } else {
+ } else if ( 'changeset_locked' !== response.code ) {
notification = new api.Notification( response.code, _.extend( notificationArgs, {
message: response.message
} ) );
}
} else {
notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, {
- message: api.l10n.serverSaveError
+ message: api.l10n.unknownRequestFail
} ) );
}
@@ -7497,6 +7548,7 @@
selectedChangesetDate = state.instance( 'selectedChangesetDate' ),
previewerAlive = state.instance( 'previewerAlive' ),
editShortcutVisibility = state.instance( 'editShortcutVisibility' ),
+ changesetLocked = state.instance( 'changesetLocked' ),
populateChangesetUuidParam;
state.bind( 'change', function() {
@@ -7547,7 +7599,7 @@
* Save (publish) button should be enabled if saving is not currently happening,
* and if the theme is not active or the changeset exists but is not published.
*/
- canSave = ! saving() && ! trashing() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) );
+ canSave = ! saving() && ! trashing() && ! changesetLocked() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) );
saveBtn.prop( 'disabled', ! canSave );
});
@@ -7561,6 +7613,7 @@
// Set default states.
changesetStatus( api.settings.changeset.status );
+ changesetLocked( Boolean( api.settings.changeset.lockUser ) );
changesetDate( api.settings.changeset.publishDate );
selectedChangesetDate( api.settings.changeset.publishDate );
selectedChangesetStatus( '' === api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ? 'publish' : api.settings.changeset.status );
@@ -7660,6 +7713,185 @@
}
}( api.state ) );
+ /**
+ * Handles lock notice and take over request.
+ *
+ * @since 4.9.0
+ */
+ ( function checkAndDisplayLockNotice() {
+
+ /**
+ * A notification that is displayed in a full-screen overlay with information about the locked changeset.
+ *
+ * @since 4.9.0
+ * @class
+ * @augments wp.customize.Notification
+ * @augments wp.customize.OverlayNotification
+ */
+ var LockedNotification = api.OverlayNotification.extend({
+
+ /**
+ * Template ID.
+ *
+ * @type {string}
+ */
+ templateId: 'customize-changeset-locked-notification',
+
+ /**
+ * Lock user.
+ *
+ * @type {object}
+ */
+ lockUser: null,
+
+ /**
+ * Initialize.
+ *
+ * @since 4.9.0
+ *
+ * @param {string} [code] - Code.
+ * @param {object} [params] - Params.
+ */
+ initialize: function( code, params ) {
+ var notification = this, _code, _params;
+ _code = code || 'changeset_locked';
+ _params = _.extend(
+ {
+ type: 'warning',
+ containerClasses: '',
+ lockUser: {}
+ },
+ params
+ );
+ _params.containerClasses += ' notification-changeset-locked';
+ api.OverlayNotification.prototype.initialize.call( notification, _code, _params );
+ },
+
+ /**
+ * Render notification.
+ *
+ * @since 4.9.0
+ *
+ * @return {jQuery} Notification container.
+ */
+ render: function() {
+ var notification = this, li, data, takeOverButton, request;
+ data = _.extend(
+ {
+ allowOverride: false,
+ returnUrl: api.settings.url['return'],
+ previewUrl: api.previewer.previewUrl.get(),
+ frontendPreviewUrl: api.previewer.getFrontendPreviewUrl()
+ },
+ this
+ );
+
+ li = api.OverlayNotification.prototype.render.call( data );
+
+ // Try to autosave the changeset now.
+ api.requestChangesetUpdate( {}, { autosave: true } ).fail( function( response ) {
+ if ( ! response.autosaved ) {
+ li.find( '.notice-error' ).prop( 'hidden', false ).text( response.message || api.l10n.unknownRequestFail );
+ }
+ } );
+
+ takeOverButton = li.find( '.customize-notice-take-over-button' );
+ takeOverButton.on( 'click', function( event ) {
+ event.preventDefault();
+ if ( request ) {
+ return;
+ }
+
+ takeOverButton.addClass( 'disabled' );
+ request = wp.ajax.post( 'customize_override_changeset_lock', {
+ wp_customize: 'on',
+ customize_theme: api.settings.theme.stylesheet,
+ customize_changeset_uuid: api.settings.changeset.uuid,
+ nonce: api.settings.nonce.override_lock
+ } );
+
+ request.done( function() {
+ api.notifications.remove( notification.code ); // Remove self.
+ api.state( 'changesetLocked' ).set( false );
+ } );
+
+ request.fail( function( response ) {
+ var message = response.message || api.l10n.unknownRequestFail;
+ li.find( '.notice-error' ).prop( 'hidden', false ).text( message );
+
+ request.always( function() {
+ takeOverButton.removeClass( 'disabled' );
+ } );
+ } );
+
+ request.always( function() {
+ request = null;
+ } );
+ } );
+
+ return li;
+ }
+ });
+
+ /**
+ * Start lock.
+ *
+ * @since 4.9.0
+ *
+ * @param {object} [args] - Args.
+ * @param {object} [args.lockUser] - Lock user data.
+ * @param {boolean} [args.allowOverride=false] - Whether override is allowed.
+ * @returns {void}
+ */
+ function startLock( args ) {
+ if ( args && args.lockUser ) {
+ api.settings.changeset.lockUser = args.lockUser;
+ }
+ api.state( 'changesetLocked' ).set( true );
+ api.notifications.add( new LockedNotification( 'changeset_locked', {
+ lockUser: api.settings.changeset.lockUser,
+ allowOverride: Boolean( args && args.allowOverride )
+ } ) );
+ }
+
+ // Show initial notification.
+ if ( api.settings.changeset.lockUser ) {
+ startLock( { allowOverride: true } );
+ }
+
+ // Check for lock when sending heartbeat requests.
+ $( document ).on( 'heartbeat-send.update_lock_notice', function( event, data ) {
+ data.check_changeset_lock = true;
+ } );
+
+ // Handle heartbeat ticks.
+ $( document ).on( 'heartbeat-tick.update_lock_notice', function( event, data ) {
+ var notification, code = 'changeset_locked';
+ if ( ! data.customize_changeset_lock_user ) {
+ return;
+ }
+
+ // Update notification when a different user takes over.
+ notification = api.notifications( code );
+ if ( notification && notification.lockUser.id !== api.settings.changeset.lockUser.id ) {
+ api.notifications.remove( code );
+ }
+
+ startLock( {
+ lockUser: data.customize_changeset_lock_user
+ } );
+ } );
+
+ // Handle locking in response to changeset save errors.
+ api.bind( 'error', function( response ) {
+ if ( 'changeset_locked' === response.code && response.lock_user ) {
+ startLock( {
+ lockUser: response.lock_user
+ } );
+ }
+ } );
+ } )();
+
// Set up initial notifications.
(function() {
@@ -7733,11 +7965,12 @@
// Handle dismissal of notice.
li.find( '.notice-dismiss' ).on( 'click', function() {
- wp.ajax.post( 'customize_dismiss_autosave', {
+ wp.ajax.post( 'customize_dismiss_autosave_or_lock', {
wp_customize: 'on',
customize_theme: api.settings.theme.stylesheet,
customize_changeset_uuid: api.settings.changeset.uuid,
- nonce: api.settings.nonce.dismiss_autosave
+ nonce: api.settings.nonce.dismiss_autosave_or_lock,
+ dismiss_autosave: true
} );
} );
@@ -8167,7 +8400,7 @@
// Prompt user with AYS dialog if leaving the Customizer with unsaved changes
$( window ).on( 'beforeunload.customize-confirm', function() {
- if ( ! isCleanState() ) {
+ if ( ! isCleanState() && ! api.state( 'changesetLocked' ).get() ) {
setTimeout( function() {
overlay.removeClass( 'customize-loading' );
}, 1 );
@@ -8178,11 +8411,14 @@
api.bind( 'change', startPromptingBeforeUnload );
function requestClose() {
- var clearedToClose = $.Deferred();
+ var clearedToClose = $.Deferred(), dismissAutoSave = false, dismissLock = false;
+
if ( isCleanState() ) {
- clearedToClose.resolve();
+ dismissLock = true;
} else if ( confirm( api.l10n.saveAlert ) ) {
+ dismissLock = true;
+
// Mark all settings as clean to prevent another call to requestChangesetUpdate.
api.each( function( setting ) {
setting._dirty = false;
@@ -8191,24 +8427,29 @@
$( window ).off( 'beforeunload.wp-customize-changeset-update' );
closeBtn.css( 'cursor', 'progress' );
- if ( '' === api.state( 'changesetStatus' ).get() ) {
- clearedToClose.resolve();
- } else {
- wp.ajax.send( 'customize_dismiss_autosave', {
- timeout: 500, // Don't wait too long.
- data: {
- wp_customize: 'on',
- customize_theme: api.settings.theme.stylesheet,
- customize_changeset_uuid: api.settings.changeset.uuid,
- nonce: api.settings.nonce.dismiss_autosave
- }
- } ).always( function() {
- clearedToClose.resolve();
- } );
+ if ( '' !== api.state( 'changesetStatus' ).get() ) {
+ dismissAutoSave = true;
}
} else {
clearedToClose.reject();
}
+
+ if ( dismissLock || dismissAutoSave ) {
+ wp.ajax.send( 'customize_dismiss_autosave_or_lock', {
+ timeout: 500, // Don't wait too long.
+ data: {
+ wp_customize: 'on',
+ customize_theme: api.settings.theme.stylesheet,
+ customize_changeset_uuid: api.settings.changeset.uuid,
+ nonce: api.settings.nonce.dismiss_autosave_or_lock,
+ dismiss_autosave: dismissAutoSave,
+ dismiss_lock: dismissLock
+ }
+ } ).always( function() {
+ clearedToClose.resolve();
+ } );
+ }
+
return clearedToClose.promise();
}
diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php
index b0eedc5b63..654ab8104e 100644
--- a/src/wp-includes/class-wp-customize-manager.php
+++ b/src/wp-includes/class-wp-customize-manager.php
@@ -174,7 +174,7 @@ final class WP_Customize_Manager {
protected $messenger_channel;
/**
- * Whether the autosave revision of the changeset should should be loaded.
+ * Whether the autosave revision of the changeset should be loaded.
*
* @since 4.9.0
* @var bool
@@ -373,11 +373,14 @@ final class WP_Customize_Manager {
remove_action( 'admin_init', '_maybe_update_plugins' );
remove_action( 'admin_init', '_maybe_update_themes' );
- add_action( 'wp_ajax_customize_save', array( $this, 'save' ) );
- add_action( 'wp_ajax_customize_trash', array( $this, 'handle_changeset_trash_request' ) );
- add_action( 'wp_ajax_customize_refresh_nonces', array( $this, 'refresh_nonces' ) );
- add_action( 'wp_ajax_customize_load_themes', array( $this, 'handle_load_themes_request' ) );
- add_action( 'wp_ajax_customize_dismiss_autosave', array( $this, 'handle_dismiss_autosave_request' ) );
+ add_action( 'wp_ajax_customize_save', array( $this, 'save' ) );
+ add_action( 'wp_ajax_customize_trash', array( $this, 'handle_changeset_trash_request' ) );
+ add_action( 'wp_ajax_customize_refresh_nonces', array( $this, 'refresh_nonces' ) );
+ add_action( 'wp_ajax_customize_load_themes', array( $this, 'handle_load_themes_request' ) );
+ add_filter( 'heartbeat_settings', array( $this, 'add_customize_screen_to_heartbeat_settings' ) );
+ add_filter( 'heartbeat_received', array( $this, 'check_changeset_lock_with_heartbeat' ), 10, 3 );
+ add_action( 'wp_ajax_customize_override_changeset_lock', array( $this, 'handle_override_changeset_lock_request' ) );
+ add_action( 'wp_ajax_customize_dismiss_autosave_or_lock', array( $this, 'handle_dismiss_autosave_or_lock_request' ) );
add_action( 'customize_register', array( $this, 'register_controls' ) );
add_action( 'customize_register', array( $this, 'register_dynamic_settings' ), 11 ); // allow code to create settings first
@@ -629,6 +632,8 @@ final class WP_Customize_Manager {
$this->_changeset_uuid = $changeset_uuid;
}
+
+ $this->set_changeset_lock( $this->changeset_post_id() );
}
/**
@@ -1106,7 +1111,7 @@ final class WP_Customize_Manager {
$this->_changeset_data = array();
} else {
if ( $this->autosaved() ) {
- $autosave_post = wp_get_post_autosave( $changeset_post_id );
+ $autosave_post = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
if ( $autosave_post ) {
$data = $this->get_changeset_post_data( $autosave_post->ID );
if ( ! is_wp_error( $data ) ) {
@@ -2376,11 +2381,24 @@ final class WP_Customize_Manager {
}
}
+ $lock_user_id = null;
$autosave = ! empty( $_POST['customize_changeset_autosave'] );
+ if ( ! $is_new_changeset ) {
+ $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
+ }
+
+ // Force request to autosave when changeset is locked.
+ if ( $lock_user_id && ! $autosave ) {
+ $autosave = true;
+ $changeset_status = null;
+ $changeset_date_gmt = null;
+ }
+
if ( $autosave && ! defined( 'DOING_AUTOSAVE' ) ) { // Back-compat.
define( 'DOING_AUTOSAVE', true );
}
+ $autosaved = false;
$r = $this->save_changeset_post( array(
'status' => $changeset_status,
'title' => $changeset_title,
@@ -2388,6 +2406,21 @@ final class WP_Customize_Manager {
'data' => $input_changeset_data,
'autosave' => $autosave,
) );
+ if ( $autosave && ! is_wp_error( $r ) ) {
+ $autosaved = true;
+ }
+
+ // If the changeset was locked and an autosave request wasn't itself an error, then now explicitly return with a failure.
+ if ( $lock_user_id && ! is_wp_error( $r ) ) {
+ $r = new WP_Error(
+ 'changeset_locked',
+ __( 'Changeset is being edited by other user.' ),
+ array(
+ 'lock_user' => $this->get_lock_user_data( $lock_user_id ),
+ )
+ );
+ }
+
if ( is_wp_error( $r ) ) {
$response = array(
'message' => $r->get_error_message(),
@@ -2413,6 +2446,10 @@ final class WP_Customize_Manager {
$response['changeset_status'] = 'publish';
}
+ if ( 'publish' !== $response['changeset_status'] ) {
+ $this->set_changeset_lock( $changeset_post->ID );
+ }
+
if ( 'future' === $response['changeset_status'] ) {
$response['changeset_date'] = $changeset_post->post_date;
}
@@ -2422,6 +2459,10 @@ final class WP_Customize_Manager {
}
}
+ if ( $autosave ) {
+ $response['autosaved'] = $autosaved;
+ }
+
if ( isset( $response['setting_validities'] ) ) {
$response['setting_validities'] = array_map( array( $this, 'prepare_setting_validity_for_js' ), $response['setting_validities'] );
}
@@ -2684,6 +2725,7 @@ final class WP_Customize_Manager {
array(
'type' => $setting->type,
'user_id' => $args['user_id'],
+ 'date_modified_gmt' => current_time( 'mysql', true ),
)
);
@@ -2798,7 +2840,7 @@ final class WP_Customize_Manager {
$r = wp_update_post( wp_slash( $post_array ), true );
// Delete autosave revision when the changeset is updated.
- $autosave_draft = wp_get_post_autosave( $changeset_post_id );
+ $autosave_draft = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
if ( $autosave_draft ) {
wp_delete_post( $autosave_draft->ID, true );
}
@@ -2989,6 +3031,157 @@ final class WP_Customize_Manager {
return $caps;
}
+ /**
+ * Marks the changeset post as being currently edited by the current user.
+ *
+ * @since 4.9.0
+ *
+ * @param int $changeset_post_id Changeset post id.
+ * @param bool $take_over Take over the changeset, default is false.
+ */
+ public function set_changeset_lock( $changeset_post_id, $take_over = false ) {
+ if ( $changeset_post_id ) {
+ $can_override = ! (bool) get_post_meta( $changeset_post_id, '_edit_lock', true );
+
+ if ( $take_over ) {
+ $can_override = true;
+ }
+
+ if ( $can_override ) {
+ $lock = sprintf( '%s:%s', time(), get_current_user_id() );
+ update_post_meta( $changeset_post_id, '_edit_lock', $lock );
+ } else {
+ $this->refresh_changeset_lock( $changeset_post_id );
+ }
+ }
+ }
+
+ /**
+ * Refreshes changeset lock with the current time if current user edited the changeset before.
+ *
+ * @since 4.9.0
+ *
+ * @param int $changeset_post_id Changeset post id.
+ */
+ public function refresh_changeset_lock( $changeset_post_id ) {
+ if ( ! $changeset_post_id ) {
+ return;
+ }
+ $lock = get_post_meta( $changeset_post_id, '_edit_lock', true );
+ $lock = explode( ':', $lock );
+
+ if ( $lock && ! empty( $lock[1] ) ) {
+ $user_id = intval( $lock[1] );
+ $current_user_id = get_current_user_id();
+ if ( $user_id === $current_user_id ) {
+ $lock = sprintf( '%s:%s', time(), $user_id );
+ update_post_meta( $changeset_post_id, '_edit_lock', $lock );
+ }
+ }
+ }
+
+ /**
+ * Filter heartbeat settings for the Customizer.
+ *
+ * @since 4.9.0
+ * @param array $settings Current settings to filter.
+ * @return array Heartbeat settings.
+ */
+ public function add_customize_screen_to_heartbeat_settings( $settings ) {
+ global $pagenow;
+ if ( 'customize.php' === $pagenow ) {
+ $settings['screenId'] = 'customize';
+ }
+ return $settings;
+ }
+
+ /**
+ * Get lock user data.
+ *
+ * @since 4.9.0
+ *
+ * @param int $user_id User ID.
+ * @return array|null User data formatted for client.
+ */
+ protected function get_lock_user_data( $user_id ) {
+ if ( ! $user_id ) {
+ return null;
+ }
+ $lock_user = get_userdata( $user_id );
+ if ( ! $lock_user ) {
+ return null;
+ }
+ return array(
+ 'id' => $lock_user->ID,
+ 'name' => $lock_user->display_name,
+ 'avatar' => get_avatar_url( $lock_user->ID, array( 'size' => 128 ) ),
+ );
+ }
+
+ /**
+ * Check locked changeset with heartbeat API.
+ *
+ * @since 4.9.0
+ *
+ * @param array $response The Heartbeat response.
+ * @param array $data The $_POST data sent.
+ * @param string $screen_id The screen id.
+ * @return array The Heartbeat response.
+ */
+ public function check_changeset_lock_with_heartbeat( $response, $data, $screen_id ) {
+ if ( array_key_exists( 'check_changeset_lock', $data ) && 'customize' === $screen_id && current_user_can( 'customize' ) && $this->changeset_post_id() ) {
+ $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
+
+ if ( $lock_user_id ) {
+ $response['customize_changeset_lock_user'] = $this->get_lock_user_data( $lock_user_id );
+ } else {
+
+ // Refreshing time will ensure that the user is sitting on customizer and has not closed the customizer tab.
+ $this->refresh_changeset_lock( $this->changeset_post_id() );
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Removes changeset lock when take over request is sent via Ajax.
+ *
+ * @since 4.9.0
+ */
+ public function handle_override_changeset_lock_request() {
+ if ( ! $this->is_preview() ) {
+ wp_send_json_error( 'not_preview', 400 );
+ }
+
+ if ( ! check_ajax_referer( 'customize_override_changeset_lock', 'nonce', false ) ) {
+ wp_send_json_error( array(
+ 'code' => 'invalid_nonce',
+ 'message' => __( 'Security check failed.' ),
+ ) );
+ }
+
+ $changeset_post_id = $this->changeset_post_id();
+
+ if ( empty( $changeset_post_id ) ) {
+ wp_send_json_error( array(
+ 'code' => 'no_changeset_found_to_take_over',
+ 'message' => __( 'No changeset found to take over' ),
+ ) );
+ }
+
+ if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
+ wp_send_json_error( array(
+ 'code' => 'cannot_remove_changeset_lock',
+ 'message' => __( 'Sorry you are not allowed to take over.' ),
+ ) );
+ }
+
+ $this->set_changeset_lock( $changeset_post_id, true );
+
+ wp_send_json_success( 'changeset_taken_over' );
+ }
+
/**
* Whether a changeset revision should be made.
*
@@ -3033,11 +3226,14 @@ final class WP_Customize_Manager {
*
* @since 4.7.0
* @see _wp_customize_publish_changeset()
+ * @global wpdb $wpdb
*
* @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
* @return true|WP_Error True or error info.
*/
public function _publish_changeset_values( $changeset_post_id ) {
+ global $wpdb;
+
$publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
if ( is_wp_error( $publishing_changeset_data ) ) {
return $publishing_changeset_data;
@@ -3175,6 +3371,30 @@ final class WP_Customize_Manager {
$this->_changeset_post_id = $previous_changeset_post_id;
$this->_changeset_uuid = $previous_changeset_uuid;
+ /*
+ * Convert all autosave revisions into their own auto-drafts so that users can be prompted to
+ * restore them when a changeset is published, but they had been locked out from including
+ * their changes in the changeset.
+ */
+ $revisions = wp_get_post_revisions( $changeset_post_id, array( 'check_enabled' => false ) );
+ foreach ( $revisions as $revision ) {
+ if ( false !== strpos( $revision->post_name, "{$changeset_post_id}-autosave" ) ) {
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_status' => 'auto-draft',
+ 'post_type' => 'customize_changeset',
+ 'post_name' => wp_generate_uuid4(),
+ 'post_parent' => 0,
+ ),
+ array(
+ 'ID' => $revision->ID,
+ )
+ );
+ clean_post_cache( $revision->ID );
+ }
+ }
+
return true;
}
@@ -3229,45 +3449,65 @@ final class WP_Customize_Manager {
}
/**
- * Delete a given auto-draft changeset or the autosave revision for a given changeset.
+ * Delete a given auto-draft changeset or the autosave revision for a given changeset or delete changeset lock.
*
* @since 4.9.0
*/
- public function handle_dismiss_autosave_request() {
+ public function handle_dismiss_autosave_or_lock_request() {
if ( ! $this->is_preview() ) {
wp_send_json_error( 'not_preview', 400 );
}
- if ( ! check_ajax_referer( 'customize_dismiss_autosave', 'nonce', false ) ) {
+ if ( ! check_ajax_referer( 'customize_dismiss_autosave_or_lock', 'nonce', false ) ) {
wp_send_json_error( 'invalid_nonce', 403 );
}
$changeset_post_id = $this->changeset_post_id();
+ $dismiss_lock = ! empty( $_POST['dismiss_lock'] );
+ $dismiss_autosave = ! empty( $_POST['dismiss_autosave'] );
- if ( empty( $changeset_post_id ) || 'auto-draft' === get_post_status( $changeset_post_id ) ) {
- $dismissed = $this->dismiss_user_auto_draft_changesets();
- if ( $dismissed > 0 ) {
- wp_send_json_success( 'auto_draft_dismissed' );
- } else {
- wp_send_json_error( 'no_auto_draft_to_delete', 404 );
+ if ( $dismiss_lock ) {
+ if ( empty( $changeset_post_id ) && ! $dismiss_autosave ) {
+ wp_send_json_error( 'no_changeset_to_dismiss_lock', 404 );
+ }
+ if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) && ! $dismiss_autosave ) {
+ wp_send_json_error( 'cannot_remove_changeset_lock', 403 );
}
- } else {
- $revision = wp_get_post_autosave( $changeset_post_id );
- if ( $revision ) {
- if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
- wp_send_json_error( 'cannot_delete_autosave_revision', 403 );
- }
+ delete_post_meta( $changeset_post_id, '_edit_lock' );
- if ( ! wp_delete_post( $revision->ID, true ) ) {
- wp_send_json_error( 'autosave_revision_deletion_failure', 500 );
- } else {
- wp_send_json_success( 'autosave_revision_deleted' );
- }
- } else {
- wp_send_json_error( 'no_autosave_revision_to_delete', 404 );
+ if ( ! $dismiss_autosave ) {
+ wp_send_json_success( 'changeset_lock_dismissed' );
}
}
+
+ if ( $dismiss_autosave ) {
+ if ( empty( $changeset_post_id ) || 'auto-draft' === get_post_status( $changeset_post_id ) ) {
+ $dismissed = $this->dismiss_user_auto_draft_changesets();
+ if ( $dismissed > 0 ) {
+ wp_send_json_success( 'auto_draft_dismissed' );
+ } else {
+ wp_send_json_error( 'no_auto_draft_to_delete', 404 );
+ }
+ } else {
+ $revision = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
+
+ if ( $revision ) {
+ if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
+ wp_send_json_error( 'cannot_delete_autosave_revision', 403 );
+ }
+
+ if ( ! wp_delete_post( $revision->ID, true ) ) {
+ wp_send_json_error( 'autosave_revision_deletion_failure', 500 );
+ } else {
+ wp_send_json_success( 'autosave_revision_deleted' );
+ }
+ } else {
+ wp_send_json_error( 'no_autosave_revision_to_delete', 404 );
+ }
+ }
+ }
+
wp_send_json_error( 'unknown_error', 500 );
}
@@ -3817,6 +4057,39 @@ final class WP_Customize_Manager {
+
+
@@ -4188,7 +4461,8 @@ final class WP_Customize_Manager {
'save' => wp_create_nonce( 'save-customize_' . $this->get_stylesheet() ),
'preview' => wp_create_nonce( 'preview-customize_' . $this->get_stylesheet() ),
'switch_themes' => wp_create_nonce( 'switch_themes' ),
- 'dismiss_autosave' => wp_create_nonce( 'customize_dismiss_autosave' ),
+ 'dismiss_autosave_or_lock' => wp_create_nonce( 'customize_dismiss_autosave_or_lock' ),
+ 'override_lock' => wp_create_nonce( 'customize_override_changeset_lock' ),
'trash' => wp_create_nonce( 'trash_customize_changeset' ),
);
@@ -4231,7 +4505,7 @@ final class WP_Customize_Manager {
$changeset_post_id = $this->changeset_post_id();
if ( ! $this->saved_starter_content_changeset && ! $this->autosaved() ) {
if ( $changeset_post_id ) {
- $autosave_revision_post = wp_get_post_autosave( $changeset_post_id );
+ $autosave_revision_post = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
} else {
$autosave_autodraft_posts = $this->get_changeset_posts( array(
'posts_per_page' => 1,
@@ -4277,6 +4551,11 @@ final class WP_Customize_Manager {
$initial_date = current_time( 'mysql', false );
}
+ $lock_user_id = false;
+ if ( $this->changeset_post_id() ) {
+ $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
+ }
+
$settings = array(
'changeset' => array(
'uuid' => $this->changeset_uuid(),
@@ -4288,6 +4567,7 @@ final class WP_Customize_Manager {
'currentUserCanPublish' => $current_user_can_publish,
'publishDate' => $initial_date,
'statusChoices' => $status_choices,
+ 'lockUser' => $lock_user_id ? $this->get_lock_user_data( $lock_user_id ) : null,
),
'initialServerDate' => current_time( 'mysql', false ),
'dateFormat' => get_option( 'date_format' ),
diff --git a/src/wp-includes/js/heartbeat.js b/src/wp-includes/js/heartbeat.js
index c555806659..0f93ff36d1 100644
--- a/src/wp-includes/js/heartbeat.js
+++ b/src/wp-includes/js/heartbeat.js
@@ -367,6 +367,10 @@
has_focus: settings.hasFocus
};
+ if ( 'customize' === settings.screenId ) {
+ ajaxData.wp_customize = 'on';
+ }
+
settings.connecting = true;
settings.xhr = $.ajax({
url: settings.url,
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index 067b941e6d..c1f69b6e0e 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -547,7 +547,7 @@ function wp_default_scripts( &$scripts ) {
$scripts->add( 'customize-preview', "/wp-includes/js/customize-preview$suffix.js", array( 'wp-a11y', 'customize-base' ), false, 1 );
$scripts->add( 'customize-models', "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 );
$scripts->add( 'customize-views', "/wp-includes/js/customize-views.js", array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 );
- $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base', 'wp-a11y', 'wp-util' ), false, 1 );
+ $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base', 'wp-a11y', 'wp-util', 'jquery-ui-core' ), false, 1 );
did_action( 'init' ) && $scripts->localize( 'customize-controls', '_wpCustomizeControlsL10n', array(
'activate' => __( 'Activate & Publish' ),
'save' => __( 'Save & Publish' ), // @todo Remove as not required.
@@ -574,11 +574,13 @@ 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.' ),
+ 'unknownRequestFail' => __( 'Looks like something’s gone wrong. Wait a couple seconds, and then try again.' ),
'themeDownloading' => __( 'Downloading your new theme…' ),
'themePreviewWait' => __( 'Setting up your live preview. This may take a bit.' ),
'revertingChanges' => __( 'Reverting unpublished changes…' ),
'trashConfirm' => __( 'Are you sure you’d like to discard your unpublished changes?' ),
+ /* translators: %s: Display name of the user who has taken over the changeset in customizer. */
+ 'takenOverMessage' => __( '%s has taken over and is currently customizing.' ),
/* translators: %s: URL to the Customizer to load the autosaved version */
'autosaveNotice' => __( 'There is a more recent autosave of your changes than the one you are previewing. Restore the autosave' ),
'videoHeaderNotice' => __( 'This theme doesn’t support video headers on this page. Navigate to the front page or another page that supports video headers.' ),
diff --git a/tests/phpunit/tests/ajax/CustomizeManager.php b/tests/phpunit/tests/ajax/CustomizeManager.php
index a5e1b96cd2..55d84eafe1 100644
--- a/tests/phpunit/tests/ajax/CustomizeManager.php
+++ b/tests/phpunit/tests/ajax/CustomizeManager.php
@@ -516,20 +516,27 @@ class Tests_Ajax_CustomizeManager extends WP_Ajax_UnitTestCase {
* Test request for dismissing autosave changesets.
*
* @ticket 39896
- * @covers WP_Customize_Manager::handle_dismiss_autosave_request()
+ * @covers WP_Customize_Manager::handle_dismiss_autosave_or_lock_request()
* @covers WP_Customize_Manager::dismiss_user_auto_draft_changesets()
*/
- public function test_handle_dismiss_autosave_request() {
+ public function test_handle_dismiss_autosave_or_lock_request() {
$uuid = wp_generate_uuid4();
$wp_customize = $this->set_up_valid_state( $uuid );
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertFalse( $this->_last_response_parsed['success'] );
$this->assertEquals( 'invalid_nonce', $this->_last_response_parsed['data'] );
- $nonce = wp_create_nonce( 'customize_dismiss_autosave' );
+ $nonce = wp_create_nonce( 'customize_dismiss_autosave_or_lock' );
$_POST['nonce'] = $_GET['nonce'] = $_REQUEST['nonce'] = $nonce;
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+
+ $_POST['dismiss_lock'] = $_GET['dismiss_lock'] = $_REQUEST['dismiss_lock'] = true;
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
+ $this->assertFalse( $this->_last_response_parsed['success'] );
+ $this->assertEquals( 'no_changeset_to_dismiss_lock', $this->_last_response_parsed['data'] );
+
+ $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = true;
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertFalse( $this->_last_response_parsed['success'] );
$this->assertEquals( 'no_auto_draft_to_delete', $this->_last_response_parsed['data'] );
@@ -559,7 +566,7 @@ class Tests_Ajax_CustomizeManager extends WP_Ajax_UnitTestCase {
foreach ( array_merge( $user_auto_draft_ids, $other_user_auto_draft_ids ) as $post_id ) {
$this->assertFalse( (bool) get_post_meta( $post_id, '_customize_restore_dismissed', true ) );
}
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertTrue( $this->_last_response_parsed['success'] );
$this->assertEquals( 'auto_draft_dismissed', $this->_last_response_parsed['data'] );
foreach ( $user_auto_draft_ids as $post_id ) {
@@ -572,7 +579,7 @@ class Tests_Ajax_CustomizeManager extends WP_Ajax_UnitTestCase {
}
// Subsequent test results in none dismissed.
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertFalse( $this->_last_response_parsed['success'] );
$this->assertEquals( 'no_auto_draft_to_delete', $this->_last_response_parsed['data'] );
@@ -585,12 +592,19 @@ class Tests_Ajax_CustomizeManager extends WP_Ajax_UnitTestCase {
),
'status' => 'draft',
) );
+
+ $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = false;
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
+ $this->assertTrue( $this->_last_response_parsed['success'] );
+ $this->assertEquals( 'changeset_lock_dismissed', $this->_last_response_parsed['data'] );
+
+ $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = true;
$this->assertNotInstanceOf( 'WP_Error', $r );
$this->assertFalse( wp_get_post_autosave( $wp_customize->changeset_post_id() ) );
$this->assertContains( 'Foo', get_post( $wp_customize->changeset_post_id() )->post_content );
// Since no autosave yet, confirm no action.
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertFalse( $this->_last_response_parsed['success'] );
$this->assertEquals( 'no_autosave_revision_to_delete', $this->_last_response_parsed['data'] );
@@ -610,13 +624,13 @@ class Tests_Ajax_CustomizeManager extends WP_Ajax_UnitTestCase {
$this->assertContains( 'Bar', $autosave_revision->post_content );
// Confirm autosave gets deleted.
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertTrue( $this->_last_response_parsed['success'] );
$this->assertEquals( 'autosave_revision_deleted', $this->_last_response_parsed['data'] );
$this->assertFalse( wp_get_post_autosave( $wp_customize->changeset_post_id() ) );
// Since no autosave yet, confirm no action.
- $this->make_ajax_call( 'customize_dismiss_autosave' );
+ $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
$this->assertFalse( $this->_last_response_parsed['success'] );
$this->assertEquals( 'no_autosave_revision_to_delete', $this->_last_response_parsed['data'] );
}
diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php
index e28f1944ee..1152efd983 100644
--- a/tests/phpunit/tests/customize/manager.php
+++ b/tests/phpunit/tests/customize/manager.php
@@ -1455,7 +1455,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
),
'autosave' => true,
) );
- $this->assertFalse( wp_get_post_autosave( $changeset_post_id ) );
+ $this->assertFalse( wp_get_post_autosave( $changeset_post_id, get_current_user_id() ) );
$this->assertContains( 'Autosaved Auto-draft Title', get_post( $changeset_post_id )->post_content );
// Update status to draft for subsequent tests.
@@ -1493,7 +1493,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
$this->assertEquals( 'illegal_autosave_with_non_current_user', $r->get_error_code() );
// Try autosave.
- $this->assertFalse( wp_get_post_autosave( $changeset_post_id ) );
+ $this->assertFalse( wp_get_post_autosave( $changeset_post_id, get_current_user_id() ) );
$r = $wp_customize->save_changeset_post( array(
'data' => array(
'blogname' => array(
@@ -1505,7 +1505,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
$this->assertInternalType( 'array', $r );
// Verify that autosave happened.
- $autosave_revision = wp_get_post_autosave( $changeset_post_id );
+ $autosave_revision = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
$this->assertInstanceOf( 'WP_Post', $autosave_revision );
$this->assertContains( 'Draft Title', get_post( $changeset_post_id )->post_content );
$this->assertContains( 'Autosave Title', $autosave_revision->post_content );
@@ -2635,6 +2635,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
'currentUserCanPublish',
'publishDate',
'statusChoices',
+ 'lockUser',
),
array_keys( $data['changeset'] )
);
diff --git a/tests/qunit/fixtures/customize-settings.js b/tests/qunit/fixtures/customize-settings.js
index ce15200183..74a35379d8 100644
--- a/tests/qunit/fixtures/customize-settings.js
+++ b/tests/qunit/fixtures/customize-settings.js
@@ -167,7 +167,8 @@ window._wpCustomizeSettings = {
currentUserCanPublish: false,
hasAutosaveRevision: false,
latestAutoDraftUuid: '341b06f6-3c1f-454f-96df-3cf197f3e347',
- publishDate: ''
+ publishDate: '',
+ locked: false
},
timeouts: {
windowRefresh: 250,
diff --git a/tests/qunit/index.html b/tests/qunit/index.html
index 1527db6d22..115195e34d 100644
--- a/tests/qunit/index.html
+++ b/tests/qunit/index.html
@@ -2210,6 +2210,25 @@
+
+
+