Make WordPress Core

Changeset 41839


Ignore:
Timestamp:
10/12/2017 04:00:15 AM (7 years ago)
Author:
westonruter
Message:

Customize: Add changeset locking in Customizer to prevent users from overriding each other's changes.

  • Customization locking is checked when changesets are saved and when heartbeat ticks.
  • Lock is lifted immediately upon a user closing the Customizer.
  • Heartbeat is introduced into Customizer.
  • Changes made to user after it was locked by another user are stored as an autosave revision for restoration.
  • Lock notification displays link to preview the other user's changes on the frontend.
  • A user loading a locked Customizer changeset will be presented with an option to take over.
  • Autosave revisions attached to a published changeset are converted into auto-drafts so that they will be presented to users for restoration.
  • Focus constraining is improved in overlay notifications.
  • Escape key is stopped from propagating in overlay notifications, and it dismisses dismissible overlay notifications.
  • Introduces changesetLocked state which is used to disable the Save button and suppress the AYS dialog when leaving the Customizer.
  • Fixes bug where users could be presented with each other's autosave revisions.

Props sayedwp, westonruter, melchoyce.
See #31436, #31897, #39896.
Fixes #42024.

Location:
trunk
Files:
10 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/css/customize-controls.css

    r41837 r41839  
    1919#customize-controls .submit {
    2020    text-align: center;
     21}
     22
     23#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked {
     24    background-color: rgba( 0, 0, 0, 0.7 );
     25    padding: 25px;
     26}
     27
     28#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .customize-changeset-locked-message {
     29    margin-left: auto;
     30    margin-right: auto;
     31    max-width: 366px;
     32    min-height: 64px;
     33    width: auto;
     34    padding: 25px 25px 25px 109px;
     35    position: relative;
     36    background: #fff;
     37    box-shadow: 0 3px 6px rgba( 0, 0, 0, 0.3 );
     38    line-height: 1.5;
     39    overflow-y: auto;
     40    text-align: left;
     41    top: calc( 50% - 100px );
     42}
     43
     44#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .currently-editing {
     45    margin-top: 0;
     46}
     47#customize-controls #customize-notifications-area .notice.notification-overlay.notification-changeset-locked .action-buttons {
     48    margin-bottom: 0;
     49}
     50
     51.customize-changeset-locked-avatar {
     52    width: 64px;
     53    position: absolute;
     54    left: 25px;
     55    top: 25px;
     56}
     57
     58.wp-core-ui.wp-customizer .customize-changeset-locked-message a.button {
     59    margin-right: 10px;
     60    margin-top: 0;
    2161}
    2262
  • trunk/src/wp-admin/customize.php

    r41802 r41839  
    8888do_action( 'customize_controls_init' );
    8989
     90wp_enqueue_script( 'heartbeat' );
    9091wp_enqueue_script( 'customize-controls' );
    9192wp_enqueue_style( 'customize-controls' );
  • trunk/src/wp-admin/js/customize-controls.js

    r41826 r41839  
    3434            if ( notification.loading ) {
    3535                notification.containerClasses += ' notification-loading';
     36            }
     37        },
     38
     39        /**
     40         * Render notification.
     41         *
     42         * @since 4.9.0
     43         *
     44         * @return {jQuery} Notification container.
     45         */
     46        render: function() {
     47            var li = api.Notification.prototype.render.call( this );
     48            li.on( 'keydown', _.bind( this.handleEscape, this ) );
     49            return li;
     50        },
     51
     52        /**
     53         * Stop propagation on escape key presses, but also dismiss notification if it is dismissible.
     54         *
     55         * @since 4.9.0
     56         *
     57         * @param {jQuery.Event} event - Event.
     58         * @returns {void}
     59         */
     60        handleEscape: function( event ) {
     61            var notification = this;
     62            if ( 27 === event.which ) {
     63                event.stopPropagation();
     64                if ( notification.dismissible && notification.parent ) {
     65                    notification.parent.remove( notification.code );
     66                }
    3667            }
    3768        }
     
    283314         */
    284315        constrainFocus: function constrainFocus( event ) {
    285             var collection = this;
    286             if ( ! collection.focusContainer || collection.focusContainer.is( event.target ) || $.contains( collection.focusContainer[0], event.target[0] ) ) {
     316            var collection = this, focusableElements;
     317
     318            // Prevent keys from escaping.
     319            event.stopPropagation();
     320
     321            if ( 9 !== event.which ) { // Tab key.
    287322                return;
    288323            }
    289             collection.focusContainer.focus();
     324
     325            focusableElements = collection.focusContainer.find( ':focusable' );
     326            if ( 0 === focusableElements.length ) {
     327                focusableElements = collection.focusContainer;
     328            }
     329
     330            if ( ! $.contains( collection.focusContainer[0], event.target ) || ! $.contains( collection.focusContainer[0], document.activeElement ) ) {
     331                event.preventDefault();
     332                focusableElements.first().focus();
     333            } else if ( focusableElements.last().is( event.target ) && ! event.shiftKey ) {
     334                event.preventDefault();
     335                focusableElements.first().focus();
     336            } else if ( focusableElements.first().is( event.target ) && event.shiftKey ) {
     337                event.preventDefault();
     338                focusableElements.last().focus();
     339            }
    290340        }
    291341    });
     
    67386788        'remainingTimeToPublish',
    67396789        'previewerAlive',
    6740         'editShortcutVisibility'
     6790        'editShortcutVisibility',
     6791        'changesetLocked'
    67416792    ], function( name ) {
    67426793        api.state.create( name );
     
    71857236                            if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) {
    71867237                                api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus();
    7187                             } else {
     7238                            } else if ( 'changeset_locked' !== response.code ) {
    71887239                                notification = new api.Notification( response.code, _.extend( notificationArgs, {
    71897240                                    message: response.message
     
    71927243                        } else {
    71937244                            notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, {
    7194                                 message: api.l10n.serverSaveError
     7245                                message: api.l10n.unknownRequestFail
    71957246                            } ) );
    71967247                        }
     
    74987549                previewerAlive = state.instance( 'previewerAlive' ),
    74997550                editShortcutVisibility  = state.instance( 'editShortcutVisibility' ),
     7551                changesetLocked = state.instance( 'changesetLocked' ),
    75007552                populateChangesetUuidParam;
    75017553
     
    75487600                 * and if the theme is not active or the changeset exists but is not published.
    75497601                 */
    7550                 canSave = ! saving() && ! trashing() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) );
     7602                canSave = ! saving() && ! trashing() && ! changesetLocked() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) );
    75517603
    75527604                saveBtn.prop( 'disabled', ! canSave );
     
    75627614            // Set default states.
    75637615            changesetStatus( api.settings.changeset.status );
     7616            changesetLocked( Boolean( api.settings.changeset.lockUser ) );
    75647617            changesetDate( api.settings.changeset.publishDate );
    75657618            selectedChangesetDate( api.settings.changeset.publishDate );
     
    76607713            }
    76617714        }( api.state ) );
     7715
     7716        /**
     7717         * Handles lock notice and take over request.
     7718         *
     7719         * @since 4.9.0
     7720         */
     7721        ( function checkAndDisplayLockNotice() {
     7722
     7723            /**
     7724             * A notification that is displayed in a full-screen overlay with information about the locked changeset.
     7725             *
     7726             * @since 4.9.0
     7727             * @class
     7728             * @augments wp.customize.Notification
     7729             * @augments wp.customize.OverlayNotification
     7730             */
     7731            var LockedNotification = api.OverlayNotification.extend({
     7732
     7733                /**
     7734                 * Template ID.
     7735                 *
     7736                 * @type {string}
     7737                 */
     7738                templateId: 'customize-changeset-locked-notification',
     7739
     7740                /**
     7741                 * Lock user.
     7742                 *
     7743                 * @type {object}
     7744                 */
     7745                lockUser: null,
     7746
     7747                /**
     7748                 * Initialize.
     7749                 *
     7750                 * @since 4.9.0
     7751                 *
     7752                 * @param {string} [code] - Code.
     7753                 * @param {object} [params] - Params.
     7754                 */
     7755                initialize: function( code, params ) {
     7756                    var notification = this, _code, _params;
     7757                    _code = code || 'changeset_locked';
     7758                    _params = _.extend(
     7759                        {
     7760                            type: 'warning',
     7761                            containerClasses: '',
     7762                            lockUser: {}
     7763                        },
     7764                        params
     7765                    );
     7766                    _params.containerClasses += ' notification-changeset-locked';
     7767                    api.OverlayNotification.prototype.initialize.call( notification, _code, _params );
     7768                },
     7769
     7770                /**
     7771                 * Render notification.
     7772                 *
     7773                 * @since 4.9.0
     7774                 *
     7775                 * @return {jQuery} Notification container.
     7776                 */
     7777                render: function() {
     7778                    var notification = this, li, data, takeOverButton, request;
     7779                    data = _.extend(
     7780                        {
     7781                            allowOverride: false,
     7782                            returnUrl: api.settings.url['return'],
     7783                            previewUrl: api.previewer.previewUrl.get(),
     7784                            frontendPreviewUrl: api.previewer.getFrontendPreviewUrl()
     7785                        },
     7786                        this
     7787                    );
     7788
     7789                    li = api.OverlayNotification.prototype.render.call( data );
     7790
     7791                    // Try to autosave the changeset now.
     7792                    api.requestChangesetUpdate( {}, { autosave: true } ).fail( function( response ) {
     7793                        if ( ! response.autosaved ) {
     7794                            li.find( '.notice-error' ).prop( 'hidden', false ).text( response.message || api.l10n.unknownRequestFail );
     7795                        }
     7796                    } );
     7797
     7798                    takeOverButton = li.find( '.customize-notice-take-over-button' );
     7799                    takeOverButton.on( 'click', function( event ) {
     7800                        event.preventDefault();
     7801                        if ( request ) {
     7802                            return;
     7803                        }
     7804
     7805                        takeOverButton.addClass( 'disabled' );
     7806                        request = wp.ajax.post( 'customize_override_changeset_lock', {
     7807                            wp_customize: 'on',
     7808                            customize_theme: api.settings.theme.stylesheet,
     7809                            customize_changeset_uuid: api.settings.changeset.uuid,
     7810                            nonce: api.settings.nonce.override_lock
     7811                        } );
     7812
     7813                        request.done( function() {
     7814                            api.notifications.remove( notification.code ); // Remove self.
     7815                            api.state( 'changesetLocked' ).set( false );
     7816                        } );
     7817
     7818                        request.fail( function( response ) {
     7819                            var message = response.message || api.l10n.unknownRequestFail;
     7820                            li.find( '.notice-error' ).prop( 'hidden', false ).text( message );
     7821
     7822                            request.always( function() {
     7823                                takeOverButton.removeClass( 'disabled' );
     7824                            } );
     7825                        } );
     7826
     7827                        request.always( function() {
     7828                            request = null;
     7829                        } );
     7830                    } );
     7831
     7832                    return li;
     7833                }
     7834            });
     7835
     7836            /**
     7837             * Start lock.
     7838             *
     7839             * @since 4.9.0
     7840             *
     7841             * @param {object} [args] - Args.
     7842             * @param {object} [args.lockUser] - Lock user data.
     7843             * @param {boolean} [args.allowOverride=false] - Whether override is allowed.
     7844             * @returns {void}
     7845             */
     7846            function startLock( args ) {
     7847                if ( args && args.lockUser ) {
     7848                    api.settings.changeset.lockUser = args.lockUser;
     7849                }
     7850                api.state( 'changesetLocked' ).set( true );
     7851                api.notifications.add( new LockedNotification( 'changeset_locked', {
     7852                    lockUser: api.settings.changeset.lockUser,
     7853                    allowOverride: Boolean( args && args.allowOverride )
     7854                } ) );
     7855            }
     7856
     7857            // Show initial notification.
     7858            if ( api.settings.changeset.lockUser ) {
     7859                startLock( { allowOverride: true } );
     7860            }
     7861
     7862            // Check for lock when sending heartbeat requests.
     7863            $( document ).on( 'heartbeat-send.update_lock_notice', function( event, data ) {
     7864                data.check_changeset_lock = true;
     7865            } );
     7866
     7867            // Handle heartbeat ticks.
     7868            $( document ).on( 'heartbeat-tick.update_lock_notice', function( event, data ) {
     7869                var notification, code = 'changeset_locked';
     7870                if ( ! data.customize_changeset_lock_user ) {
     7871                    return;
     7872                }
     7873
     7874                // Update notification when a different user takes over.
     7875                notification = api.notifications( code );
     7876                if ( notification && notification.lockUser.id !== api.settings.changeset.lockUser.id ) {
     7877                    api.notifications.remove( code );
     7878                }
     7879
     7880                startLock( {
     7881                    lockUser: data.customize_changeset_lock_user
     7882                } );
     7883            } );
     7884
     7885            // Handle locking in response to changeset save errors.
     7886            api.bind( 'error', function( response ) {
     7887                if ( 'changeset_locked' === response.code && response.lock_user ) {
     7888                    startLock( {
     7889                        lockUser: response.lock_user
     7890                    } );
     7891                }
     7892            } );
     7893        } )();
    76627894
    76637895        // Set up initial notifications.
     
    77347966                        // Handle dismissal of notice.
    77357967                        li.find( '.notice-dismiss' ).on( 'click', function() {
    7736                             wp.ajax.post( 'customize_dismiss_autosave', {
     7968                            wp.ajax.post( 'customize_dismiss_autosave_or_lock', {
    77377969                                wp_customize: 'on',
    77387970                                customize_theme: api.settings.theme.stylesheet,
    77397971                                customize_changeset_uuid: api.settings.changeset.uuid,
    7740                                 nonce: api.settings.nonce.dismiss_autosave
     7972                                nonce: api.settings.nonce.dismiss_autosave_or_lock,
     7973                                dismiss_autosave: true
    77417974                            } );
    77427975                        } );
     
    81688401                // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
    81698402                $( window ).on( 'beforeunload.customize-confirm', function() {
    8170                     if ( ! isCleanState() ) {
     8403                    if ( ! isCleanState() && ! api.state( 'changesetLocked' ).get() ) {
    81718404                        setTimeout( function() {
    81728405                            overlay.removeClass( 'customize-loading' );
     
    81798412
    81808413            function requestClose() {
    8181                 var clearedToClose = $.Deferred();
     8414                var clearedToClose = $.Deferred(), dismissAutoSave = false, dismissLock = false;
     8415
    81828416                if ( isCleanState() ) {
    8183                     clearedToClose.resolve();
     8417                    dismissLock = true;
    81848418                } else if ( confirm( api.l10n.saveAlert ) ) {
     8419
     8420                    dismissLock = true;
    81858421
    81868422                    // Mark all settings as clean to prevent another call to requestChangesetUpdate.
     
    81928428
    81938429                    closeBtn.css( 'cursor', 'progress' );
    8194                     if ( '' === api.state( 'changesetStatus' ).get() ) {
    8195                         clearedToClose.resolve();
    8196                     } else {
    8197                         wp.ajax.send( 'customize_dismiss_autosave', {
    8198                             timeout: 500, // Don't wait too long.
    8199                             data: {
    8200                                 wp_customize: 'on',
    8201                                 customize_theme: api.settings.theme.stylesheet,
    8202                                 customize_changeset_uuid: api.settings.changeset.uuid,
    8203                                 nonce: api.settings.nonce.dismiss_autosave
    8204                             }
    8205                         } ).always( function() {
    8206                             clearedToClose.resolve();
    8207                         } );
     8430                    if ( '' !== api.state( 'changesetStatus' ).get() ) {
     8431                        dismissAutoSave = true;
    82088432                    }
    82098433                } else {
    82108434                    clearedToClose.reject();
    82118435                }
     8436
     8437                if ( dismissLock || dismissAutoSave ) {
     8438                    wp.ajax.send( 'customize_dismiss_autosave_or_lock', {
     8439                        timeout: 500, // Don't wait too long.
     8440                        data: {
     8441                            wp_customize: 'on',
     8442                            customize_theme: api.settings.theme.stylesheet,
     8443                            customize_changeset_uuid: api.settings.changeset.uuid,
     8444                            nonce: api.settings.nonce.dismiss_autosave_or_lock,
     8445                            dismiss_autosave: dismissAutoSave,
     8446                            dismiss_lock: dismissLock
     8447                        }
     8448                    } ).always( function() {
     8449                        clearedToClose.resolve();
     8450                    } );
     8451                }
     8452
    82128453                return clearedToClose.promise();
    82138454            }
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r41825 r41839  
    175175
    176176    /**
    177      * Whether the autosave revision of the changeset should should be loaded.
     177     * Whether the autosave revision of the changeset should be loaded.
    178178     *
    179179     * @since 4.9.0
     
    374374        remove_action( 'admin_init', '_maybe_update_themes' );
    375375
    376         add_action( 'wp_ajax_customize_save',             array( $this, 'save' ) );
    377         add_action( 'wp_ajax_customize_trash',            array( $this, 'handle_changeset_trash_request' ) );
    378         add_action( 'wp_ajax_customize_refresh_nonces',   array( $this, 'refresh_nonces' ) );
    379         add_action( 'wp_ajax_customize_load_themes',      array( $this, 'handle_load_themes_request' ) );
    380         add_action( 'wp_ajax_customize_dismiss_autosave', array( $this, 'handle_dismiss_autosave_request' ) );
     376        add_action( 'wp_ajax_customize_save',                     array( $this, 'save' ) );
     377        add_action( 'wp_ajax_customize_trash',                    array( $this, 'handle_changeset_trash_request' ) );
     378        add_action( 'wp_ajax_customize_refresh_nonces',           array( $this, 'refresh_nonces' ) );
     379        add_action( 'wp_ajax_customize_load_themes',              array( $this, 'handle_load_themes_request' ) );
     380        add_filter( 'heartbeat_settings',                         array( $this, 'add_customize_screen_to_heartbeat_settings' ) );
     381        add_filter( 'heartbeat_received',                         array( $this, 'check_changeset_lock_with_heartbeat' ), 10, 3 );
     382        add_action( 'wp_ajax_customize_override_changeset_lock',  array( $this, 'handle_override_changeset_lock_request' ) );
     383        add_action( 'wp_ajax_customize_dismiss_autosave_or_lock', array( $this, 'handle_dismiss_autosave_or_lock_request' ) );
    381384
    382385        add_action( 'customize_register',                 array( $this, 'register_controls' ) );
     
    630633            $this->_changeset_uuid = $changeset_uuid;
    631634        }
     635
     636        $this->set_changeset_lock( $this->changeset_post_id() );
    632637    }
    633638
     
    11071112        } else {
    11081113            if ( $this->autosaved() ) {
    1109                 $autosave_post = wp_get_post_autosave( $changeset_post_id );
     1114                $autosave_post = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
    11101115                if ( $autosave_post ) {
    11111116                    $data = $this->get_changeset_post_data( $autosave_post->ID );
     
    23772382        }
    23782383
     2384        $lock_user_id = null;
    23792385        $autosave = ! empty( $_POST['customize_changeset_autosave'] );
     2386        if ( ! $is_new_changeset ) {
     2387            $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
     2388        }
     2389
     2390        // Force request to autosave when changeset is locked.
     2391        if ( $lock_user_id && ! $autosave ) {
     2392            $autosave = true;
     2393            $changeset_status = null;
     2394            $changeset_date_gmt = null;
     2395        }
     2396
    23802397        if ( $autosave && ! defined( 'DOING_AUTOSAVE' ) ) { // Back-compat.
    23812398            define( 'DOING_AUTOSAVE', true );
    23822399        }
    23832400
     2401        $autosaved = false;
    23842402        $r = $this->save_changeset_post( array(
    23852403            'status' => $changeset_status,
     
    23892407            'autosave' => $autosave,
    23902408        ) );
     2409        if ( $autosave && ! is_wp_error( $r ) ) {
     2410            $autosaved = true;
     2411        }
     2412
     2413        // If the changeset was locked and an autosave request wasn't itself an error, then now explicitly return with a failure.
     2414        if ( $lock_user_id && ! is_wp_error( $r ) ) {
     2415            $r = new WP_Error(
     2416                'changeset_locked',
     2417                __( 'Changeset is being edited by other user.' ),
     2418                array(
     2419                    'lock_user' => $this->get_lock_user_data( $lock_user_id ),
     2420                )
     2421            );
     2422        }
     2423
    23912424        if ( is_wp_error( $r ) ) {
    23922425            $response = array(
     
    24142447            }
    24152448
     2449            if ( 'publish' !== $response['changeset_status'] ) {
     2450                $this->set_changeset_lock( $changeset_post->ID );
     2451            }
     2452
    24162453            if ( 'future' === $response['changeset_status'] ) {
    24172454                $response['changeset_date'] = $changeset_post->post_date;
     
    24212458                $response['next_changeset_uuid'] = wp_generate_uuid4();
    24222459            }
     2460        }
     2461
     2462        if ( $autosave ) {
     2463            $response['autosaved'] = $autosaved;
    24232464        }
    24242465
     
    26852726                        'type' => $setting->type,
    26862727                        'user_id' => $args['user_id'],
     2728                        'date_modified_gmt' => current_time( 'mysql', true ),
    26872729                    )
    26882730                );
     
    27992841
    28002842                // Delete autosave revision when the changeset is updated.
    2801                 $autosave_draft = wp_get_post_autosave( $changeset_post_id );
     2843                $autosave_draft = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
    28022844                if ( $autosave_draft ) {
    28032845                    wp_delete_post( $autosave_draft->ID, true );
     
    29913033
    29923034    /**
     3035     * Marks the changeset post as being currently edited by the current user.
     3036     *
     3037     * @since 4.9.0
     3038     *
     3039     * @param int  $changeset_post_id Changeset post id.
     3040     * @param bool $take_over Take over the changeset, default is false.
     3041     */
     3042    public function set_changeset_lock( $changeset_post_id, $take_over = false ) {
     3043        if ( $changeset_post_id ) {
     3044            $can_override = ! (bool) get_post_meta( $changeset_post_id, '_edit_lock', true );
     3045
     3046            if ( $take_over ) {
     3047                $can_override = true;
     3048            }
     3049
     3050            if ( $can_override ) {
     3051                $lock = sprintf( '%s:%s', time(), get_current_user_id() );
     3052                update_post_meta( $changeset_post_id, '_edit_lock', $lock );
     3053            } else {
     3054                $this->refresh_changeset_lock( $changeset_post_id );
     3055            }
     3056        }
     3057    }
     3058
     3059    /**
     3060     * Refreshes changeset lock with the current time if current user edited the changeset before.
     3061     *
     3062     * @since 4.9.0
     3063     *
     3064     * @param int $changeset_post_id Changeset post id.
     3065     */
     3066    public function refresh_changeset_lock( $changeset_post_id ) {
     3067        if ( ! $changeset_post_id ) {
     3068            return;
     3069        }
     3070        $lock = get_post_meta( $changeset_post_id, '_edit_lock', true );
     3071        $lock = explode( ':', $lock );
     3072
     3073        if ( $lock && ! empty( $lock[1] ) ) {
     3074            $user_id = intval( $lock[1] );
     3075            $current_user_id = get_current_user_id();
     3076            if ( $user_id === $current_user_id ) {
     3077                $lock = sprintf( '%s:%s', time(), $user_id );
     3078                update_post_meta( $changeset_post_id, '_edit_lock', $lock );
     3079            }
     3080        }
     3081    }
     3082
     3083    /**
     3084     * Filter heartbeat settings for the Customizer.
     3085     *
     3086     * @since 4.9.0
     3087     * @param array $settings Current settings to filter.
     3088     * @return array Heartbeat settings.
     3089     */
     3090    public function add_customize_screen_to_heartbeat_settings( $settings ) {
     3091        global $pagenow;
     3092        if ( 'customize.php' === $pagenow ) {
     3093            $settings['screenId'] = 'customize';
     3094        }
     3095        return $settings;
     3096    }
     3097
     3098    /**
     3099     * Get lock user data.
     3100     *
     3101     * @since 4.9.0
     3102     *
     3103     * @param int $user_id User ID.
     3104     * @return array|null User data formatted for client.
     3105     */
     3106    protected function get_lock_user_data( $user_id ) {
     3107        if ( ! $user_id ) {
     3108            return null;
     3109        }
     3110        $lock_user = get_userdata( $user_id );
     3111        if ( ! $lock_user ) {
     3112            return null;
     3113        }
     3114        return array(
     3115            'id' => $lock_user->ID,
     3116            'name' => $lock_user->display_name,
     3117            'avatar' => get_avatar_url( $lock_user->ID, array( 'size' => 128 ) ),
     3118        );
     3119    }
     3120
     3121    /**
     3122     * Check locked changeset with heartbeat API.
     3123     *
     3124     * @since 4.9.0
     3125     *
     3126     * @param array  $response  The Heartbeat response.
     3127     * @param array  $data      The $_POST data sent.
     3128     * @param string $screen_id The screen id.
     3129     * @return array The Heartbeat response.
     3130     */
     3131    public function check_changeset_lock_with_heartbeat( $response, $data, $screen_id ) {
     3132        if ( array_key_exists( 'check_changeset_lock', $data ) && 'customize' === $screen_id && current_user_can( 'customize' ) && $this->changeset_post_id() ) {
     3133            $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
     3134
     3135            if ( $lock_user_id ) {
     3136                $response['customize_changeset_lock_user'] = $this->get_lock_user_data( $lock_user_id );
     3137            } else {
     3138
     3139                // Refreshing time will ensure that the user is sitting on customizer and has not closed the customizer tab.
     3140                $this->refresh_changeset_lock( $this->changeset_post_id() );
     3141            }
     3142        }
     3143
     3144        return $response;
     3145    }
     3146
     3147    /**
     3148     * Removes changeset lock when take over request is sent via Ajax.
     3149     *
     3150     * @since 4.9.0
     3151     */
     3152    public function handle_override_changeset_lock_request() {
     3153        if ( ! $this->is_preview() ) {
     3154            wp_send_json_error( 'not_preview', 400 );
     3155        }
     3156
     3157        if ( ! check_ajax_referer( 'customize_override_changeset_lock', 'nonce', false ) ) {
     3158            wp_send_json_error( array(
     3159                'code' => 'invalid_nonce',
     3160                'message' => __( 'Security check failed.' ),
     3161            ) );
     3162        }
     3163
     3164        $changeset_post_id = $this->changeset_post_id();
     3165
     3166        if ( empty( $changeset_post_id ) ) {
     3167            wp_send_json_error( array(
     3168                'code' => 'no_changeset_found_to_take_over',
     3169                'message' => __( 'No changeset found to take over' ),
     3170            ) );
     3171        }
     3172
     3173        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
     3174            wp_send_json_error( array(
     3175                'code' => 'cannot_remove_changeset_lock',
     3176                'message' => __( 'Sorry you are not allowed to take over.' ),
     3177            ) );
     3178        }
     3179
     3180        $this->set_changeset_lock( $changeset_post_id, true );
     3181
     3182        wp_send_json_success( 'changeset_taken_over' );
     3183    }
     3184
     3185    /**
    29933186     * Whether a changeset revision should be made.
    29943187     *
     
    30343227     * @since 4.7.0
    30353228     * @see _wp_customize_publish_changeset()
     3229     * @global wpdb $wpdb
    30363230     *
    30373231     * @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
     
    30393233     */
    30403234    public function _publish_changeset_values( $changeset_post_id ) {
     3235        global $wpdb;
     3236
    30413237        $publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
    30423238        if ( is_wp_error( $publishing_changeset_data ) ) {
     
    31763372        $this->_changeset_uuid    = $previous_changeset_uuid;
    31773373
     3374        /*
     3375         * Convert all autosave revisions into their own auto-drafts so that users can be prompted to
     3376         * restore them when a changeset is published, but they had been locked out from including
     3377         * their changes in the changeset.
     3378         */
     3379        $revisions = wp_get_post_revisions( $changeset_post_id, array( 'check_enabled' => false ) );
     3380        foreach ( $revisions as $revision ) {
     3381            if ( false !== strpos( $revision->post_name, "{$changeset_post_id}-autosave" ) ) {
     3382                $wpdb->update(
     3383                    $wpdb->posts,
     3384                    array(
     3385                        'post_status' => 'auto-draft',
     3386                        'post_type' => 'customize_changeset',
     3387                        'post_name' => wp_generate_uuid4(),
     3388                        'post_parent' => 0,
     3389                    ),
     3390                    array(
     3391                        'ID' => $revision->ID,
     3392                    )
     3393                );
     3394                clean_post_cache( $revision->ID );
     3395            }
     3396        }
     3397
    31783398        return true;
    31793399    }
     
    32303450
    32313451    /**
    3232      * Delete a given auto-draft changeset or the autosave revision for a given changeset.
     3452     * Delete a given auto-draft changeset or the autosave revision for a given changeset or delete changeset lock.
    32333453     *
    32343454     * @since 4.9.0
    32353455     */
    3236     public function handle_dismiss_autosave_request() {
     3456    public function handle_dismiss_autosave_or_lock_request() {
    32373457        if ( ! $this->is_preview() ) {
    32383458            wp_send_json_error( 'not_preview', 400 );
    32393459        }
    32403460
    3241         if ( ! check_ajax_referer( 'customize_dismiss_autosave', 'nonce', false ) ) {
     3461        if ( ! check_ajax_referer( 'customize_dismiss_autosave_or_lock', 'nonce', false ) ) {
    32423462            wp_send_json_error( 'invalid_nonce', 403 );
    32433463        }
    32443464
    32453465        $changeset_post_id = $this->changeset_post_id();
    3246 
    3247         if ( empty( $changeset_post_id ) || 'auto-draft' === get_post_status( $changeset_post_id ) ) {
    3248             $dismissed = $this->dismiss_user_auto_draft_changesets();
    3249             if ( $dismissed > 0 ) {
    3250                 wp_send_json_success( 'auto_draft_dismissed' );
    3251             } else {
    3252                 wp_send_json_error( 'no_auto_draft_to_delete', 404 );
    3253             }
    3254         } else {
    3255             $revision = wp_get_post_autosave( $changeset_post_id );
    3256 
    3257             if ( $revision ) {
    3258                 if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
    3259                     wp_send_json_error( 'cannot_delete_autosave_revision', 403 );
    3260                 }
    3261 
    3262                 if ( ! wp_delete_post( $revision->ID, true ) ) {
    3263                     wp_send_json_error( 'autosave_revision_deletion_failure', 500 );
     3466        $dismiss_lock = ! empty( $_POST['dismiss_lock'] );
     3467        $dismiss_autosave = ! empty( $_POST['dismiss_autosave'] );
     3468
     3469        if ( $dismiss_lock ) {
     3470            if ( empty( $changeset_post_id ) && ! $dismiss_autosave ) {
     3471                wp_send_json_error( 'no_changeset_to_dismiss_lock', 404 );
     3472            }
     3473            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) && ! $dismiss_autosave ) {
     3474                wp_send_json_error( 'cannot_remove_changeset_lock', 403 );
     3475            }
     3476
     3477            delete_post_meta( $changeset_post_id, '_edit_lock' );
     3478
     3479            if ( ! $dismiss_autosave ) {
     3480                wp_send_json_success( 'changeset_lock_dismissed' );
     3481            }
     3482        }
     3483
     3484        if ( $dismiss_autosave ) {
     3485            if ( empty( $changeset_post_id ) || 'auto-draft' === get_post_status( $changeset_post_id ) ) {
     3486                $dismissed = $this->dismiss_user_auto_draft_changesets();
     3487                if ( $dismissed > 0 ) {
     3488                    wp_send_json_success( 'auto_draft_dismissed' );
    32643489                } else {
    3265                     wp_send_json_success( 'autosave_revision_deleted' );
     3490                    wp_send_json_error( 'no_auto_draft_to_delete', 404 );
    32663491                }
    32673492            } else {
    3268                 wp_send_json_error( 'no_autosave_revision_to_delete', 404 );
    3269             }
    3270         }
     3493                $revision = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
     3494
     3495                if ( $revision ) {
     3496                    if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
     3497                        wp_send_json_error( 'cannot_delete_autosave_revision', 403 );
     3498                    }
     3499
     3500                    if ( ! wp_delete_post( $revision->ID, true ) ) {
     3501                        wp_send_json_error( 'autosave_revision_deletion_failure', 500 );
     3502                    } else {
     3503                        wp_send_json_success( 'autosave_revision_deleted' );
     3504                    }
     3505                } else {
     3506                    wp_send_json_error( 'no_autosave_revision_to_delete', 404 );
     3507                }
     3508            }
     3509        }
     3510
    32713511        wp_send_json_error( 'unknown_error', 500 );
    32723512    }
     
    38184058        </script>
    38194059
     4060        <script type="text/html" id="tmpl-customize-changeset-locked-notification">
     4061            <li class="notice notice-{{ data.type || 'info' }} {{ data.containerClasses || '' }}" data-code="{{ data.code }}" data-type="{{ data.type }}">
     4062                <div class="notification-message customize-changeset-locked-message">
     4063                    <img class="customize-changeset-locked-avatar" src="{{ data.lockUser.avatar }}" alt="{{ data.lockUser.name }}">
     4064                    <p class="currently-editing">
     4065                        <# if ( data.message ) { #>
     4066                            {{{ data.message }}}
     4067                        <# } else if ( data.allowOverride ) { #>
     4068                            <?php
     4069                            /* translators: %s: User who is customizing the changeset in customizer. */
     4070                            printf( __( '%s is already customizing this site. Do you want to take over?' ), '{{ data.lockUser.name }}' );
     4071                            ?>
     4072                        <# } else { #>
     4073                            <?php
     4074                            /* translators: %s: User who is customizing the changeset in customizer. */
     4075                            printf( __( '%s is already customizing this site. Please wait until they are done to try customizing. Your latest changes have been autosaved.' ), '{{ data.lockUser.name }}' );
     4076                            ?>
     4077                        <# } #>
     4078                    </p>
     4079                    <p class="notice notice-error notice-alt" hidden></p>
     4080                    <p class="action-buttons">
     4081                        <# if ( data.returnUrl !== data.previewUrl ) { #>
     4082                            <a class="button customize-notice-go-back-button" href="{{ data.returnUrl }}"><?php _e( 'Go back' ); ?></a>
     4083                        <# } #>
     4084                        <a class="button customize-notice-preview-button" href="{{ data.frontendPreviewUrl }}"><?php _e( 'Preview' ); ?></a>
     4085                        <# if ( data.allowOverride ) { #>
     4086                            <button class="button button-primary wp-tab-last customize-notice-take-over-button"><?php _e( 'Take over' ); ?></button>
     4087                        <# } #>
     4088                    </p>
     4089                </div>
     4090            </li>
     4091        </script>
     4092
    38204093        <?php
    38214094        /* The following template is obsolete in core but retained for plugins. */
     
    41894462            'preview' => wp_create_nonce( 'preview-customize_' . $this->get_stylesheet() ),
    41904463            'switch_themes' => wp_create_nonce( 'switch_themes' ),
    4191             'dismiss_autosave' => wp_create_nonce( 'customize_dismiss_autosave' ),
     4464            'dismiss_autosave_or_lock' => wp_create_nonce( 'customize_dismiss_autosave_or_lock' ),
     4465            'override_lock' => wp_create_nonce( 'customize_override_changeset_lock' ),
    41924466            'trash' => wp_create_nonce( 'trash_customize_changeset' ),
    41934467        );
     
    42324506        if ( ! $this->saved_starter_content_changeset && ! $this->autosaved() ) {
    42334507            if ( $changeset_post_id ) {
    4234                 $autosave_revision_post = wp_get_post_autosave( $changeset_post_id );
     4508                $autosave_revision_post = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
    42354509            } else {
    42364510                $autosave_autodraft_posts = $this->get_changeset_posts( array(
     
    42764550        } else {
    42774551            $initial_date = current_time( 'mysql', false );
     4552        }
     4553
     4554        $lock_user_id = false;
     4555        if ( $this->changeset_post_id() ) {
     4556            $lock_user_id = wp_check_post_lock( $this->changeset_post_id() );
    42784557        }
    42794558
     
    42894568                'publishDate' => $initial_date,
    42904569                'statusChoices' => $status_choices,
     4570                'lockUser' => $lock_user_id ? $this->get_lock_user_data( $lock_user_id ) : null,
    42914571            ),
    42924572            'initialServerDate' => current_time( 'mysql', false ),
  • trunk/src/wp-includes/js/heartbeat.js

    r41351 r41839  
    368368            };
    369369
     370            if ( 'customize' === settings.screenId  ) {
     371                ajaxData.wp_customize = 'on';
     372            }
     373
    370374            settings.connecting = true;
    371375            settings.xhr = $.ajax({
  • trunk/src/wp-includes/script-loader.php

    r41805 r41839  
    548548    $scripts->add( 'customize-models',   "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 );
    549549    $scripts->add( 'customize-views',    "/wp-includes/js/customize-views.js",  array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 );
    550     $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base', 'wp-a11y', 'wp-util' ), false, 1 );
     550    $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base', 'wp-a11y', 'wp-util', 'jquery-ui-core' ), false, 1 );
    551551    did_action( 'init' ) && $scripts->localize( 'customize-controls', '_wpCustomizeControlsL10n', array(
    552552        'activate'           => __( 'Activate &amp; Publish' ),
     
    575575        'expandSidebar'      => _x( 'Show Controls', 'label for hide controls button without length constraints' ),
    576576        'untitledBlogName'   => __( '(Untitled)' ),
    577         'serverSaveError'    => __( 'Failed connecting to the server. Please try saving again.' ),
     577        'unknownRequestFail' => __( 'Looks like something&#8217;s gone wrong. Wait a couple seconds, and then try again.' ),
    578578        'themeDownloading'   => __( 'Downloading your new theme&hellip;' ),
    579579        'themePreviewWait'   => __( 'Setting up your live preview. This may take a bit.' ),
    580580        'revertingChanges'   => __( 'Reverting unpublished changes&hellip;' ),
    581581        'trashConfirm'       => __( 'Are you sure you&#8217;d like to discard your unpublished changes?' ),
     582        /* translators: %s: Display name of the user who has taken over the changeset in customizer. */
     583        'takenOverMessage'   => __( '%s has taken over and is currently customizing.' ),
    582584        /* translators: %s: URL to the Customizer to load the autosaved version */
    583585        'autosaveNotice'     => __( 'There is a more recent autosave of your changes than the one you are previewing. <a href="%s">Restore the autosave</a>' ),
  • trunk/tests/phpunit/tests/ajax/CustomizeManager.php

    r41667 r41839  
    517517     *
    518518     * @ticket 39896
    519      * @covers WP_Customize_Manager::handle_dismiss_autosave_request()
     519     * @covers WP_Customize_Manager::handle_dismiss_autosave_or_lock_request()
    520520     * @covers WP_Customize_Manager::dismiss_user_auto_draft_changesets()
    521521     */
    522     public function test_handle_dismiss_autosave_request() {
     522    public function test_handle_dismiss_autosave_or_lock_request() {
    523523        $uuid = wp_generate_uuid4();
    524524        $wp_customize = $this->set_up_valid_state( $uuid );
    525525
    526         $this->make_ajax_call( 'customize_dismiss_autosave' );
     526        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    527527        $this->assertFalse( $this->_last_response_parsed['success'] );
    528528        $this->assertEquals( 'invalid_nonce', $this->_last_response_parsed['data'] );
    529529
    530         $nonce = wp_create_nonce( 'customize_dismiss_autosave' );
     530        $nonce = wp_create_nonce( 'customize_dismiss_autosave_or_lock' );
    531531        $_POST['nonce'] = $_GET['nonce'] = $_REQUEST['nonce'] = $nonce;
    532         $this->make_ajax_call( 'customize_dismiss_autosave' );
     532
     533        $_POST['dismiss_lock'] = $_GET['dismiss_lock'] = $_REQUEST['dismiss_lock'] = true;
     534        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
     535        $this->assertFalse( $this->_last_response_parsed['success'] );
     536        $this->assertEquals( 'no_changeset_to_dismiss_lock', $this->_last_response_parsed['data'] );
     537
     538        $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = true;
     539        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    533540        $this->assertFalse( $this->_last_response_parsed['success'] );
    534541        $this->assertEquals( 'no_auto_draft_to_delete', $this->_last_response_parsed['data'] );
     
    560567            $this->assertFalse( (bool) get_post_meta( $post_id, '_customize_restore_dismissed', true ) );
    561568        }
    562         $this->make_ajax_call( 'customize_dismiss_autosave' );
     569        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    563570        $this->assertTrue( $this->_last_response_parsed['success'] );
    564571        $this->assertEquals( 'auto_draft_dismissed', $this->_last_response_parsed['data'] );
     
    573580
    574581        // Subsequent test results in none dismissed.
    575         $this->make_ajax_call( 'customize_dismiss_autosave' );
     582        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    576583        $this->assertFalse( $this->_last_response_parsed['success'] );
    577584        $this->assertEquals( 'no_auto_draft_to_delete', $this->_last_response_parsed['data'] );
     
    586593            'status' => 'draft',
    587594        ) );
     595
     596        $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = false;
     597        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
     598        $this->assertTrue( $this->_last_response_parsed['success'] );
     599        $this->assertEquals( 'changeset_lock_dismissed', $this->_last_response_parsed['data'] );
     600
     601        $_POST['dismiss_autosave'] = $_GET['dismiss_autosave'] = $_REQUEST['dismiss_autosave'] = true;
    588602        $this->assertNotInstanceOf( 'WP_Error', $r );
    589603        $this->assertFalse( wp_get_post_autosave( $wp_customize->changeset_post_id() ) );
     
    591605
    592606        // Since no autosave yet, confirm no action.
    593         $this->make_ajax_call( 'customize_dismiss_autosave' );
     607        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    594608        $this->assertFalse( $this->_last_response_parsed['success'] );
    595609        $this->assertEquals( 'no_autosave_revision_to_delete', $this->_last_response_parsed['data'] );
     
    611625
    612626        // Confirm autosave gets deleted.
    613         $this->make_ajax_call( 'customize_dismiss_autosave' );
     627        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    614628        $this->assertTrue( $this->_last_response_parsed['success'] );
    615629        $this->assertEquals( 'autosave_revision_deleted', $this->_last_response_parsed['data'] );
     
    617631
    618632        // Since no autosave yet, confirm no action.
    619         $this->make_ajax_call( 'customize_dismiss_autosave' );
     633        $this->make_ajax_call( 'customize_dismiss_autosave_or_lock' );
    620634        $this->assertFalse( $this->_last_response_parsed['success'] );
    621635        $this->assertEquals( 'no_autosave_revision_to_delete', $this->_last_response_parsed['data'] );
  • trunk/tests/phpunit/tests/customize/manager.php

    r41824 r41839  
    14561456            'autosave' => true,
    14571457        ) );
    1458         $this->assertFalse( wp_get_post_autosave( $changeset_post_id ) );
     1458        $this->assertFalse( wp_get_post_autosave( $changeset_post_id, get_current_user_id() ) );
    14591459        $this->assertContains( 'Autosaved Auto-draft Title', get_post( $changeset_post_id )->post_content );
    14601460
     
    14941494
    14951495        // Try autosave.
    1496         $this->assertFalse( wp_get_post_autosave( $changeset_post_id ) );
     1496        $this->assertFalse( wp_get_post_autosave( $changeset_post_id, get_current_user_id() ) );
    14971497        $r = $wp_customize->save_changeset_post( array(
    14981498            'data' => array(
     
    15061506
    15071507        // Verify that autosave happened.
    1508         $autosave_revision = wp_get_post_autosave( $changeset_post_id );
     1508        $autosave_revision = wp_get_post_autosave( $changeset_post_id, get_current_user_id() );
    15091509        $this->assertInstanceOf( 'WP_Post', $autosave_revision );
    15101510        $this->assertContains( 'Draft Title', get_post( $changeset_post_id )->post_content );
     
    26362636                'publishDate',
    26372637                'statusChoices',
     2638                'lockUser',
    26382639            ),
    26392640            array_keys( $data['changeset'] )
  • trunk/tests/qunit/fixtures/customize-settings.js

    r41626 r41839  
    168168        hasAutosaveRevision: false,
    169169        latestAutoDraftUuid: '341b06f6-3c1f-454f-96df-3cf197f3e347',
    170         publishDate: ''
     170        publishDate: '',
     171        locked: false
    171172    },
    172173    timeouts: {
  • trunk/tests/qunit/index.html

    r41773 r41839  
    22112211        <script src="wp-includes/js/tinymce/tinymce-obsolete.js"></script>
    22122212
     2213        <!-- Changeset locked notice template -->
     2214        <script type="text/html" id="tmpl-customize-changeset-locked-notice">
     2215            <div id="customize-changeset-lock-dialog" class="notification-dialog-wrap hidden">
     2216                <div class="notification-dialog-background"></div>
     2217                <div class="notification-dialog">
     2218                    <div class="customize-changeset-locked-message">
     2219                        <div class="customize-changeset-locked-avatar"></div>
     2220                        <p class="currently-editing wp-tab-first" tabindex="0">
     2221                            <span class="customize-notice-user-name"></span> <span class="customize-take-over-message">is already customizing this site. Do you want to take over?</span></p>
     2222                        <p>
     2223                            <a class="button customize-notice-go-back-button" href="/wp-admin/post.php?post=505&#038;action=edit">Go back</a>
     2224                            <a class="button customize-notice-preview-button" href="http://example.org/?customize_changeset_uuid=7a796f7a-255c-49f5-9d25-cef0c315a4ba">Preview</a>
     2225                            <a class="button button-primary wp-tab-last customize-notice-take-over-button" href="http://example.org/wp-admin/customize.php?changeset_uuid=7a796f7a-255c-49f5-9d25-cef0c315a4ba&action=customize_take_over_changeset&nonce=e3a1df16d2&return=/wp-admin/post.php?post=505&action=edit">Take over</a>
     2226                        </p>
     2227                    </div>
     2228                </div>
     2229            </div>
     2230        </script>
     2231
    22132232        <!-- Updates templates and HTML fixtures -->
    22142233        <script id="tmpl-wp-updates-admin-notice" type="text/html">
Note: See TracChangeset for help on using the changeset viewer.