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 @@ + + +