WordPress.org

Make WordPress Core

Changeset 41597


Ignore:
Timestamp:
09/26/2017 07:37:02 AM (3 years ago)
Author:
westonruter
Message:

Customize: Extend changesets to support autosave revisions with restoration notifications, and introduce a new default linear history mode for saved changesets (with a filter for opt-in to changeset branching).

  • Autosaved changes made on top of auto-draft changesets get written on top of the auto-draft itself, similar to how autosaves for posts will overwrite post drafts.
  • Autosaved changes made to saved changesets (e.g. draft, future) will be placed into an autosave revision for that changeset and that user.
  • Opening the Customizer will now prompt the user to restore their most recent auto-draft changeset; if notification is dismissed or ignored then the auto-draft will be marked as dismissed and will not be prompted to user in a notification again.
  • Customizer will no longer automatically supply the changeset_uuid param in the customize.php URL when branching changesets are not active.
  • If user closes Customizer explicitly via clicking on X link, then autosave auto-draft/autosave will be dismissed so as to not be prompted again.
  • If there is a changeset already saved as a draft or future (UI is forthcoming) then this changeset will now be autoloaded for the user to keep making additional changes. This is the linear model for changesets.
  • To restore the previous behavior of the Customizer where each session started a new changeset, regardless of whether or not there was an existing changeset saved, there is now a customize_changeset_branching hook which can be filtered to return true.
  • wp.customize.requestChangesetUpdate() now supports a second with options including autosave, title, and date.
  • The window blur event for customize.php has been replaced with a visibilitychange event to reduce autosave requests when clicking into preview window.
  • Adds autosaved and branching args to WP_Customize_Manager.
  • The changeset_uuid param for WP_Customize_Manager is extended to recognize a false value which causes the Customizer to defer identifying the UUID until after_setup_theme in the new WP_Customize_Manager::establish_loaded_changeset() method.
  • A new customize_autosaved query parameter can now be supplied which is passed into the autosaved arg in WP_Customize_Manager; this option is an opt-in to source data from the autosave revision, allowing a user to restore autosaved changes.

Props westonruter, dlh, sayedwp, JoshuaWold, melchoyce.
See #39896.

Location:
trunk/src
Files:
5 edited

Legend:

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

    r41586 r41597  
    1 /* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console */
     1/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console, confirm */
    22(function( exports, $ ){
    33    var Container, focus, normalizedTransitionendEventName, api = wp.customize;
     
    356356     * @access public
    357357     *
    358      * @param {object} [changes] Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
    359      *                           If not provided, then the changes will still be obtained from unsaved dirty settings.
     358     * @param {object}  [changes] - Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
     359     *                             If not provided, then the changes will still be obtained from unsaved dirty settings.
     360     * @param {object}  [args] - Additional options for the save request.
     361     * @param {boolean} [args.autosave=false] - Whether changes will be stored in autosave revision if the changeset has been promoted from an auto-draft.
     362     * @param {string}  [args.title] - Title to update in the changeset. Optional.
     363     * @param {string}  [args.date] - Date to update in the changeset. Optional.
    360364     * @returns {jQuery.Promise} Promise resolving with the response data.
    361365     */
    362     api.requestChangesetUpdate = function requestChangesetUpdate( changes ) {
    363         var deferred, request, submittedChanges = {}, data;
     366    api.requestChangesetUpdate = function requestChangesetUpdate( changes, args ) {
     367        var deferred, request, submittedChanges = {}, data, submittedArgs;
    364368        deferred = new $.Deferred();
     369
     370        submittedArgs = _.extend( {
     371            title: null,
     372            date: null,
     373            autosave: false
     374        }, args );
    365375
    366376        if ( changes ) {
     
    380390
    381391        // Short-circuit when there are no pending changes.
    382         if ( _.isEmpty( submittedChanges ) ) {
     392        if ( _.isEmpty( submittedChanges ) && null === submittedArgs.title && null === submittedArgs.date ) {
    383393            deferred.resolve( {} );
    384394            return deferred.promise();
     395        }
     396
     397        // Allow plugins to attach additional params to the settings.
     398        api.trigger( 'changeset-save', submittedChanges, submittedArgs );
     399
     400        // A status would cause a revision to be made, and for this wp.customize.previewer.save() should be used. Status is also disallowed for revisions regardless.
     401        if ( submittedArgs.status ) {
     402            return deferred.reject( { code: 'illegal_status_in_changeset_update' } ).promise();
     403        }
     404
     405        // Dates not beung allowed for revisions are is a technical limitation of post revisions.
     406        if ( submittedArgs.date && submittedArgs.autosave ) {
     407            return deferred.reject( { code: 'illegal_autosave_with_date_gmt' } ).promise();
    385408        }
    386409
     
    390413            api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
    391414        } );
    392 
    393         // Allow plugins to attach additional params to the settings.
    394         api.trigger( 'changeset-save', submittedChanges );
    395415
    396416        // Ensure that if any plugins add data to save requests by extending query() that they get included here.
     
    402422            customize_changeset_data: JSON.stringify( submittedChanges )
    403423        } );
     424        if ( null !== submittedArgs.title ) {
     425            data.customize_changeset_title = submittedArgs.title;
     426        }
     427        if ( null !== submittedArgs.date ) {
     428            data.customize_changeset_date = submittedArgs.date;
     429        }
     430        if ( false !== submittedArgs.autosave ) {
     431            data.customize_changeset_autosave = 'true';
     432        }
    404433
    405434        request = wp.ajax.post( 'customize_save', data );
     
    17061735                api.state( 'processing' ).unbind( onceProcessingComplete );
    17071736
    1708                 request = api.requestChangesetUpdate();
     1737                request = api.requestChangesetUpdate( {}, { autosave: true } );
    17091738                request.done( function() {
    17101739                    $( window ).off( 'beforeunload.customize-confirm' );
     1740
     1741                    // Include autosaved param to load autosave revision without prompting user to restore it.
     1742                    if ( ! api.state( 'saved' ).get() ) {
     1743                        urlParser.search += '&customize_autosaved=on';
     1744                    }
     1745
    17111746                    top.location.href = urlParser.href;
    17121747                    deferred.resolve();
     
    40254060                }
    40264061            );
     4062            if ( ! api.state( 'saved' ).get() ) {
     4063                params.customize_autosaved = 'on';
     4064            }
    40274065
    40284066            urlParser.search = $.param( params );
     
    42614299                    delete queryParams.customize_theme;
    42624300                    delete queryParams.customize_messenger_channel;
     4301                    delete queryParams.customize_autosaved;
    42634302                    if ( _.isEmpty( queryParams ) ) {
    42644303                        urlParser.search = '';
     
    48854924                    customize_changeset_uuid: api.settings.changeset.uuid
    48864925                };
     4926                if ( ! api.state( 'saved' ).get() ) {
     4927                    queryVars.customize_autosaved = 'on';
     4928                }
    48874929
    48884930                /*
     
    50995141                        }
    51005142
     5143                        // Prevent subsequent requestChangesetUpdate() calls from including the settings that have been saved.
     5144                        api._lastSavedRevision = Math.max( latestRevision, api._lastSavedRevision );
     5145
    51015146                        if ( response.setting_validities ) {
    51025147                            api._handleSettingValidities( {
     
    53165361                if ( state( 'saved' ).get() ) {
    53175362                    state( 'saved' ).set( false );
    5318                     populateChangesetUuidParam( true );
    5319                 }
    5320             });
     5363                }
     5364            });
     5365
     5366            // Populate changeset UUID param when state becomes dirty.
     5367            if ( api.settings.changeset.branching ) {
     5368                saved.bind( function( isSaved ) {
     5369                    if ( ! isSaved ) {
     5370                        populateChangesetUuidParam( true );
     5371                    }
     5372                });
     5373            }
    53215374
    53225375            saving.bind( function( isSaving ) {
     
    53725425            };
    53735426
    5374             changesetStatus.bind( function( newStatus ) {
    5375                 populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus );
    5376             } );
     5427            // Show changeset UUID in URL when in branching mode and there is a saved changeset.
     5428            if ( api.settings.changeset.branching ) {
     5429                changesetStatus.bind( function( newStatus ) {
     5430                    populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus );
     5431                } );
     5432            }
    53775433
    53785434            // Expose states to the API.
    53795435            api.state = state;
    53805436        }());
     5437
     5438        // Set up autosave prompt.
     5439        (function() {
     5440
     5441            /**
     5442             * Obtain the URL to restore the autosave.
     5443             *
     5444             * @returns {string} Customizer URL.
     5445             */
     5446            function getAutosaveRestorationUrl() {
     5447                var urlParser, queryParams;
     5448                urlParser = document.createElement( 'a' );
     5449                urlParser.href = location.href;
     5450                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     5451                if ( api.settings.changeset.latestAutoDraftUuid ) {
     5452                    queryParams.changeset_uuid = api.settings.changeset.latestAutoDraftUuid;
     5453                } else {
     5454                    queryParams.customize_autosaved = 'on';
     5455                }
     5456                urlParser.search = $.param( queryParams );
     5457                return urlParser.href;
     5458            }
     5459
     5460            /**
     5461             * Remove parameter from the URL.
     5462             *
     5463             * @param {Array} params - Parameter names to remove.
     5464             * @returns {void}
     5465             */
     5466            function stripParamsFromLocation( params ) {
     5467                var urlParser = document.createElement( 'a' ), queryParams, strippedParams = 0;
     5468                urlParser.href = location.href;
     5469                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     5470                _.each( params, function( param ) {
     5471                    if ( 'undefined' !== typeof queryParams[ param ] ) {
     5472                        strippedParams += 1;
     5473                        delete queryParams[ param ];
     5474                    }
     5475                } );
     5476                if ( 0 === strippedParams ) {
     5477                    return;
     5478                }
     5479
     5480                urlParser.search = $.param( queryParams );
     5481                history.replaceState( {}, document.title, urlParser.href );
     5482            }
     5483
     5484            /**
     5485             * Add notification regarding the availability of an autosave to restore.
     5486             *
     5487             * @returns {void}
     5488             */
     5489            function addAutosaveRestoreNotification() {
     5490                var code = 'autosave_available', onStateChange;
     5491
     5492                // Since there is an autosave revision and the user hasn't loaded with autosaved, add notification to prompt to load autosaved version.
     5493                api.notifications.add( code, new api.Notification( code, {
     5494                    message: api.l10n.autosaveNotice,
     5495                    type: 'warning',
     5496                    dismissible: true,
     5497                    render: function() {
     5498                        var li = api.Notification.prototype.render.call( this ), link;
     5499
     5500                        // Handle clicking on restoration link.
     5501                        link = li.find( 'a' );
     5502                        link.prop( 'href', getAutosaveRestorationUrl() );
     5503                        link.on( 'click', function( event ) {
     5504                            event.preventDefault();
     5505                            location.replace( getAutosaveRestorationUrl() );
     5506                        } );
     5507
     5508                        // Handle dismissal of notice.
     5509                        li.find( '.notice-dismiss' ).on( 'click', function() {
     5510                            wp.ajax.post( 'dismiss_customize_changeset_autosave', {
     5511                                wp_customize: 'on',
     5512                                customize_theme: api.settings.theme.stylesheet,
     5513                                customize_changeset_uuid: api.settings.changeset.latestAutoDraftUuid || api.settings.changeset.uuid,
     5514                                nonce: api.settings.nonce.dismiss_autosave
     5515                            } );
     5516                        } );
     5517
     5518                        return li;
     5519                    }
     5520                } ) );
     5521
     5522                // Remove the notification once the user starts making changes.
     5523                onStateChange = function() {
     5524                    api.notifications.remove( code );
     5525                    api.state( 'saved' ).unbind( onStateChange );
     5526                    api.state( 'saving' ).unbind( onStateChange );
     5527                    api.state( 'changesetStatus' ).unbind( onStateChange );
     5528                };
     5529                api.state( 'saved' ).bind( onStateChange );
     5530                api.state( 'saving' ).bind( onStateChange );
     5531                api.state( 'changesetStatus' ).bind( onStateChange );
     5532            }
     5533
     5534            if ( api.settings.changeset.autosaved ) {
     5535                stripParamsFromLocation( [ 'customize_autosaved' ] ); // Remove param when restoring autosave revision.
     5536            } else if ( ! api.settings.changeset.branching && 'auto-draft' === api.settings.changeset.status ) {
     5537                stripParamsFromLocation( [ 'changeset_uuid' ] ); // Remove UUID when restoring autosave auto-draft.
     5538            }
     5539            if ( api.settings.changeset.latestAutoDraftUuid || api.settings.changeset.hasAutosaveRevision ) {
     5540                addAutosaveRestoreNotification();
     5541            }
     5542        })();
    53815543
    53825544        // Check if preview url is valid and load the preview frame.
     
    57435905        });
    57445906
    5745         /*
    5746          * If we receive a 'back' event, we're inside an iframe.
    5747          * Send any clicks to the 'Return' link to the parent page.
    5748          */
    5749         parent.bind( 'back', function() {
     5907        // Handle exiting of Customizer.
     5908        (function() {
     5909            var isInsideIframe = false;
     5910
     5911            function isCleanState() {
     5912                return api.state( 'saved' ).get() && 'auto-draft' !== api.state( 'changesetStatus' ).get();
     5913            }
     5914
     5915            /*
     5916             * If we receive a 'back' event, we're inside an iframe.
     5917             * Send any clicks to the 'Return' link to the parent page.
     5918             */
     5919            parent.bind( 'back', function() {
     5920                isInsideIframe = true;
     5921            });
     5922
     5923            // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
     5924            $( window ).on( 'beforeunload.customize-confirm', function() {
     5925                if ( ! isCleanState() ) {
     5926                    setTimeout( function() {
     5927                        overlay.removeClass( 'customize-loading' );
     5928                    }, 1 );
     5929                    return api.l10n.saveAlert;
     5930                }
     5931            });
     5932
    57505933            closeBtn.on( 'click.customize-controls-close', function( event ) {
     5934                var clearedToClose = $.Deferred();
    57515935                event.preventDefault();
    5752                 parent.send( 'close' );
    5753             });
    5754         });
    5755 
    5756         // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
    5757         $( window ).on( 'beforeunload.customize-confirm', function () {
    5758             if ( ! api.state( 'saved' )() ) {
    5759                 setTimeout( function() {
    5760                     overlay.removeClass( 'customize-loading' );
    5761                 }, 1 );
    5762                 return api.l10n.saveAlert;
    5763             }
    5764         } );
     5936
     5937                /*
     5938                 * The isInsideIframe condition is because Customizer is not able to use a confirm()
     5939                 * since customize-loader.js will also use one. So autosave restorations are disabled
     5940                 * when customize-loader.js is used.
     5941                 */
     5942                if ( isInsideIframe && isCleanState() ) {
     5943                    clearedToClose.resolve();
     5944                } else if ( confirm( api.l10n.saveAlert ) ) {
     5945
     5946                    // Mark all settings as clean to prevent another call to requestChangesetUpdate.
     5947                    api.each( function( setting ) {
     5948                        setting._dirty = false;
     5949                    });
     5950                    $( document ).off( 'visibilitychange.wp-customize-changeset-update' );
     5951                    $( window ).off( 'beforeunload.wp-customize-changeset-update' );
     5952
     5953                    closeBtn.css( 'cursor', 'progress' );
     5954                    if ( '' === api.state( 'changesetStatus' ).get() ) {
     5955                        clearedToClose.resolve();
     5956                    } else {
     5957                        wp.ajax.send( 'dismiss_customize_changeset_autosave', {
     5958                            timeout: 500, // Don't wait too long.
     5959                            data: {
     5960                                wp_customize: 'on',
     5961                                customize_theme: api.settings.theme.stylesheet,
     5962                                customize_changeset_uuid: api.settings.changeset.uuid,
     5963                                nonce: api.settings.nonce.dismiss_autosave
     5964                            }
     5965                        } ).always( function() {
     5966                            clearedToClose.resolve();
     5967                        } );
     5968                    }
     5969                } else {
     5970                    clearedToClose.reject();
     5971                }
     5972
     5973                clearedToClose.done( function() {
     5974                    $( window ).off( 'beforeunload.customize-confirm' );
     5975                    if ( isInsideIframe ) {
     5976                        parent.send( 'close' );
     5977                    } else {
     5978                        window.location.href = closeBtn.prop( 'href' );
     5979                    }
     5980                } );
     5981            });
     5982        })();
    57655983
    57665984        // Pass events through to the parent.
     
    60856303            var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false;
    60866304
     6305            api.state( 'saved' ).bind( function( isSaved ) {
     6306                if ( ! isSaved && ! api.settings.changeset.autosaved ) {
     6307                    api.settings.changeset.autosaved = true; // Once a change is made then autosaving kicks in.
     6308                    api.previewer.send( 'autosaving' );
     6309                }
     6310            } );
     6311
    60876312            /**
    60886313             * Request changeset update and then re-schedule the next changeset update time.
     
    60946319                if ( ! updatePending ) {
    60956320                    updatePending = true;
    6096                     api.requestChangesetUpdate().always( function() {
     6321                    api.requestChangesetUpdate( {}, { autosave: true } ).always( function() {
    60976322                        updatePending = false;
    60986323                    } );
     
    61186343
    61196344            // Save changeset when focus removed from window.
    6120             $( window ).on( 'blur.wp-customize-changeset-update', function() {
    6121                 updateChangesetWithReschedule();
     6345            $( document ).on( 'visibilitychange.wp-customize-changeset-update', function() {
     6346                if ( document.hidden ) {
     6347                    updateChangesetWithReschedule();
     6348                }
    61226349            } );
    61236350
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r41586 r41597  
    175175
    176176    /**
    177      * Whether settings should be previewed.
     177     * Whether the autosave revision of the changeset should should be loaded.
    178178     *
    179179     * @since 4.9.0
    180180     * @var bool
    181181     */
    182     protected $settings_previewed;
     182    protected $autosaved = false;
     183
     184    /**
     185     * Whether the changeset branching is allowed.
     186     *
     187     * @since 4.9.0
     188     * @var bool
     189     */
     190    protected $branching = true;
     191
     192    /**
     193     * Whether settings should be previewed.
     194     *
     195     * @since 4.9.0
     196     * @var bool
     197     */
     198    protected $settings_previewed = true;
     199
     200    /**
     201     * Whether a starter content changeset was saved.
     202     *
     203     * @since 4.9.0
     204     * @var bool
     205     */
     206    protected $saved_starter_content_changeset = false;
    183207
    184208    /**
     
    222246     *     Args.
    223247     *
    224      *     @type string $changeset_uuid     Changeset UUID, the post_name for the customize_changeset post containing the customized state. Defaults to new UUID.
    225      *     @type string $theme              Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params.
    226      *     @type string $messenger_channel  Messenger channel. Defaults to customize_messenger_channel query param.
    227      *     @type bool   $settings_previewed If settings should be previewed. Defaults to true.
     248     *     @type null|string|false $changeset_uuid     Changeset UUID, the `post_name` for the customize_changeset post containing the customized state.
     249     *                                                 Defaults to `null` resulting in a UUID to be immediately generated. If `false` is provided, then
     250     *                                                 then the changeset UUID will be determined during `after_setup_theme`: when the
     251     *                                                 `customize_changeset_branching` filter returns false, then the default UUID will be that
     252     *                                                 of the most recent `customize_changeset` post that has a status other than 'auto-draft',
     253     *                                                 'publish', or 'trash'. Otherwise, if changeset branching is enabled, then a random UUID will be used.
     254     *     @type string            $theme              Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params.
     255     *     @type string            $messenger_channel  Messenger channel. Defaults to customize_messenger_channel query param.
     256     *     @type bool              $settings_previewed If settings should be previewed. Defaults to true.
     257     *     @type bool              $branching          If changeset branching is allowed; otherwise, changesets are linear. Defaults to true.
     258     *     @type bool              $autosaved          If data from a changeset's autosaved revision should be loaded if it exists. Defaults to false.
    228259     * }
    229260     */
     
    231262
    232263        $args = array_merge(
    233             array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel', 'settings_previewed' ), null ),
     264            array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel', 'settings_previewed', 'autosaved', 'branching' ), null ),
    234265            $args
    235266        );
     
    252283        }
    253284
    254         if ( ! isset( $args['settings_previewed'] ) ) {
    255             $args['settings_previewed'] = true;
    256         }
    257 
    258285        $this->original_stylesheet = get_stylesheet();
    259286        $this->theme = wp_get_theme( 0 === validate_file( $args['theme'] ) ? $args['theme'] : null );
    260287        $this->messenger_channel = $args['messenger_channel'];
    261         $this->settings_previewed = ! empty( $args['settings_previewed'] );
    262288        $this->_changeset_uuid = $args['changeset_uuid'];
     289
     290        foreach ( array( 'settings_previewed', 'autosaved', 'branching' ) as $key ) {
     291            if ( isset( $args[ $key ] ) ) {
     292                $this->$key = (bool) $args[ $key ];
     293            }
     294        }
    263295
    264296        require_once( ABSPATH . WPINC . '/class-wp-customize-setting.php' );
     
    344376        add_action( 'wp_ajax_customize_save',           array( $this, 'save' ) );
    345377        add_action( 'wp_ajax_customize_refresh_nonces', array( $this, 'refresh_nonces' ) );
     378        add_action( 'wp_ajax_dismiss_customize_changeset_autosave', array( $this, 'handle_dismiss_changeset_autosave_request' ) );
    346379
    347380        add_action( 'customize_register',                 array( $this, 'register_controls' ) );
     
    475508        }
    476509
    477         if ( ! wp_is_uuid( $this->_changeset_uuid ) ) {
     510        // If a changeset was provided is invalid.
     511        if ( isset( $this->_changeset_uuid ) && false !== $this->_changeset_uuid && ! wp_is_uuid( $this->_changeset_uuid ) ) {
    478512            $this->wp_die( -1, __( 'Invalid changeset UUID' ) );
    479513        }
     
    536570        }
    537571
     572        // Make sure changeset UUID is established immediately after the theme is loaded.
     573        add_action( 'after_setup_theme', array( $this, 'establish_loaded_changeset' ), 5 );
     574
    538575        /*
    539576         * Import theme starter content for fresh installations when landing in the customizer.
     
    546583
    547584        $this->start_previewing_theme();
     585    }
     586
     587    /**
     588     * Establish the loaded changeset.
     589     *
     590     * This method runs right at after_setup_theme and applies the 'customize_changeset_branching' filter to determine
     591     * whether concurrent changesets are allowed. Then if the Customizer is not initialized with a `changeset_uuid` param,
     592     * this method will determine which UUID should be used. If changeset branching is disabled, then the most saved
     593     * changeset will be loaded by default. Otherwise, if there are no existing saved changesets or if changeset branching is
     594     * enabled, then a new UUID will be generated.
     595     *
     596     * @since 4.9.0
     597     */
     598    public function establish_loaded_changeset() {
     599
     600        /**
     601         * Filters whether or not changeset branching is allowed.
     602         *
     603         * By default in core, when changeset branching is not allowed, changesets will operate
     604         * linearly in that only one saved changeset will exist at a time (with a 'draft' or
     605         * 'future' status). This makes the Customizer operate in a way that is similar to going to
     606         * "edit" to one existing post: all users will be making changes to the same post, and autosave
     607         * revisions will be made for that post.
     608         *
     609         * By contrast, when changeset branching is allowed, then the model is like users going
     610         * to "add new" for a page and each user makes changes independently of each other since
     611         * they are all operating on their own separate pages, each getting their own separate
     612         * initial auto-drafts and then once initially saved, autosave revisions on top of that
     613         * user's specific post.
     614         *
     615         * Since linear changesets are deemed to be more suitable for the majority of WordPress users,
     616         * they are the default. For WordPress sites that have heavy site management in the Customizer
     617         * by multiple users then branching changesets should be enabled by means of this filter.
     618         *
     619         * @since 4.9.0
     620         *
     621         * @param bool                 $allow_branching Whether branching is allowed. If `false`, the default,
     622         *                                              then only one saved changeset exists at a time.
     623         * @param WP_Customize_Manager $wp_customize    Manager instance.
     624         */
     625        $this->branching = apply_filters( 'customize_changeset_branching', $this->branching, $this );
     626
     627        if ( empty( $this->_changeset_uuid ) ) {
     628            $changeset_uuid = null;
     629
     630            if ( ! $this->branching ) {
     631                $unpublished_changeset_posts = $this->get_changeset_posts( array(
     632                    'post_status' => array_diff( get_post_stati(), array( 'auto-draft', 'publish', 'trash', 'inherit', 'private' ) ),
     633                    'exclude_restore_dismissed' => false,
     634                    'posts_per_page' => 1,
     635                    'order' => 'DESC',
     636                    'orderby' => 'date',
     637                ) );
     638                $unpublished_changeset_post = array_shift( $unpublished_changeset_posts );
     639                if ( ! empty( $unpublished_changeset_post ) && wp_is_uuid( $unpublished_changeset_post->post_name ) ) {
     640                    $changeset_uuid = $unpublished_changeset_post->post_name;
     641                }
     642            }
     643
     644            // If no changeset UUID has been set yet, then generate a new one.
     645            if ( empty( $changeset_uuid ) ) {
     646                $changeset_uuid = wp_generate_uuid4();
     647            }
     648
     649            $this->_changeset_uuid = $changeset_uuid;
     650        }
    548651    }
    549652
     
    653756     *
    654757     * @since 4.7.0
    655      *
     758     * @since 4.9.0 An exception is thrown if the changeset UUID has not been established yet.
     759     * @see WP_Customize_Manager::establish_loaded_changeset()
     760     *
     761     * @throws Exception When the UUID has not been set yet.
    656762     * @return string UUID.
    657763     */
    658764    public function changeset_uuid() {
     765        if ( empty( $this->_changeset_uuid ) ) {
     766            throw new Exception( 'Changeset UUID has not been set.' ); // @todo Replace this with a call to `WP_Customize_Manager::establish_loaded_changeset()` during 4.9-beta2.
     767        }
    659768        return $this->_changeset_uuid;
    660769    }
     
    826935
    827936    /**
     937     * Get changeset posts.
     938     *
     939     * @since 4.9.0
     940     *
     941     * @param array $args {
     942     *     Args to pass into `get_posts()` to query changesets.
     943     *
     944     *     @type int    $posts_per_page             Number of posts to return. Defaults to -1 (all posts).
     945     *     @type int    $author                     Post author. Defaults to current user.
     946     *     @type string $post_status                Status of changeset. Defaults to 'auto-draft'.
     947     *     @type bool   $exclude_restore_dismissed  Whether to exclude changeset auto-drafts that have been dismissed. Defaults to true.
     948     * }
     949     * @return WP_Post[] Auto-draft changesets.
     950     */
     951    protected function get_changeset_posts( $args = array() ) {
     952        $default_args = array(
     953            'exclude_restore_dismissed' => true,
     954            'posts_per_page' => -1,
     955            'post_type' => 'customize_changeset',
     956            'post_status' => 'auto-draft',
     957            'order' => 'DESC',
     958            'orderby' => 'date',
     959            'no_found_rows' => true,
     960            'cache_results' => true,
     961            'update_post_meta_cache' => false,
     962            'update_post_term_cache' => false,
     963            'lazy_load_term_meta' => false,
     964        );
     965        if ( get_current_user_id() ) {
     966            $default_args['author'] = get_current_user_id();
     967        }
     968        $args = array_merge( $default_args, $args );
     969
     970        if ( ! empty( $args['exclude_restore_dismissed'] ) ) {
     971            unset( $args['exclude_restore_dismissed'] );
     972            $args['meta_query'] = array(
     973                array(
     974                    'key' => '_customize_restore_dismissed',
     975                    'compare' => 'NOT EXISTS',
     976                ),
     977            );
     978        }
     979
     980        return get_posts( $args );
     981    }
     982
     983    /**
    828984     * Get the changeset post id for the loaded changeset.
    829985     *
     
    834990    public function changeset_post_id() {
    835991        if ( ! isset( $this->_changeset_post_id ) ) {
    836             $post_id = $this->find_changeset_post_id( $this->_changeset_uuid );
     992            $post_id = $this->find_changeset_post_id( $this->changeset_uuid() );
    837993            if ( ! $post_id ) {
    838994                $post_id = false;
     
    8621018            return new WP_Error( 'missing_post' );
    8631019        }
    864         if ( 'customize_changeset' !== $changeset_post->post_type ) {
     1020        if ( 'revision' === $changeset_post->post_type ) {
     1021            if ( 'customize_changeset' !== get_post_type( $changeset_post->post_parent ) ) {
     1022                return new WP_Error( 'wrong_post_type' );
     1023            }
     1024        } elseif ( 'customize_changeset' !== $changeset_post->post_type ) {
    8651025            return new WP_Error( 'wrong_post_type' );
    8661026        }
     
    8791039     *
    8801040     * @since 4.7.0
     1041     * @since 4.9.0 This will return the changeset's data with a user's autosave revision merged on top, if one exists and $autosaved is true.
    8811042     *
    8821043     * @return array Changeset data.
     
    8901051            $this->_changeset_data = array();
    8911052        } else {
    892             $data = $this->get_changeset_post_data( $changeset_post_id );
    893             if ( ! is_wp_error( $data ) ) {
    894                 $this->_changeset_data = $data;
    895             } else {
    896                 $this->_changeset_data = array();
     1053            if ( $this->autosaved ) {
     1054                $autosave_post = wp_get_post_autosave( $changeset_post_id );
     1055                if ( $autosave_post ) {
     1056                    $data = $this->get_changeset_post_data( $autosave_post->ID );
     1057                    if ( ! is_wp_error( $data ) ) {
     1058                        $this->_changeset_data = $data;
     1059                    }
     1060                }
     1061            }
     1062
     1063            // Load data from the changeset if it was not loaded from an autosave.
     1064            if ( ! isset( $this->_changeset_data ) ) {
     1065                $data = $this->get_changeset_post_data( $changeset_post_id );
     1066                if ( ! is_wp_error( $data ) ) {
     1067                    $this->_changeset_data = $data;
     1068                } else {
     1069                    $this->_changeset_data = array();
     1070                }
    8971071            }
    8981072        }
     
    13751549            'starter_content' => true,
    13761550        ) );
     1551        $this->saved_starter_content_changeset = true;
    13771552
    13781553        $this->pending_starter_content_settings_ids = array();
     
    17971972        $settings = array(
    17981973            'changeset' => array(
    1799                 'uuid' => $this->_changeset_uuid,
     1974                'uuid' => $this->changeset_uuid(),
     1975                'autosaved' => $this->autosaved,
    18001976            ),
    18011977            'timeouts' => array(
     
    20792255
    20802256        $changeset_post_id = $this->changeset_post_id();
    2081         if ( empty( $changeset_post_id ) ) {
     2257        $is_new_changeset = empty( $changeset_post_id );
     2258        if ( $is_new_changeset ) {
    20822259            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->create_posts ) ) {
    20832260                wp_send_json_error( 'cannot_create_changeset_post' );
     
    21452322        }
    21462323
     2324        $autosave = ! empty( $_POST['customize_changeset_autosave'] );
     2325        if ( $autosave && ! defined( 'DOING_AUTOSAVE' ) ) { // Back-compat.
     2326            define( 'DOING_AUTOSAVE', true );
     2327        }
     2328
    21472329        $r = $this->save_changeset_post( array(
    21482330            'status' => $changeset_status,
     
    21502332            'date_gmt' => $changeset_date_gmt,
    21512333            'data' => $input_changeset_data,
     2334            'autosave' => $autosave,
    21522335        ) );
    21532336        if ( is_wp_error( $r ) ) {
     
    21632346        } else {
    21642347            $response = $r;
     2348
     2349            // Dismiss all other auto-draft changeset posts for this user (they serve like autosave revisions), as there should only be one.
     2350            if ( $is_new_changeset ) {
     2351                $changeset_autodraft_posts = $this->get_changeset_posts( array(
     2352                    'post_status' => 'auto-draft',
     2353                    'exclude_restore_dismissed' => true,
     2354                    'posts_per_page' => -1,
     2355                ) );
     2356                foreach ( $changeset_autodraft_posts as $autosave_autodraft_post ) {
     2357                    if ( $autosave_autodraft_post->ID !== $this->changeset_post_id() ) {
     2358                        update_post_meta( $autosave_autodraft_post->ID, '_customize_restore_dismissed', true );
     2359                    }
     2360                }
     2361            }
    21652362
    21662363            // Note that if the changeset status was publish, then it will get set to trash if revisions are not supported.
     
    22132410     *     @type int    $user_id         ID for user who is saving the changeset. Optional, defaults to the current user ID.
    22142411     *     @type bool   $starter_content Whether the data is starter content. If false (default), then $starter_content will be cleared for any $data being saved.
     2412     *     @type bool   $autosave        Whether this is a request to create an autosave revision.
    22152413     * }
    22162414     *
     
    22272425                'user_id' => get_current_user_id(),
    22282426                'starter_content' => false,
     2427                'autosave' => false,
    22292428            ),
    22302429            $args
     
    22762475        if ( ! empty( $is_future_dated ) && 'publish' === $args['status'] ) {
    22772476            $args['status'] = 'future';
     2477        }
     2478
     2479        // Validate autosave param. See _wp_post_revision_fields() for why these fields are disallowed.
     2480        if ( $args['autosave'] ) {
     2481            if ( $args['date_gmt'] ) {
     2482                return new WP_Error( 'illegal_autosave_with_date_gmt' );
     2483            } elseif ( $args['status'] ) {
     2484                return new WP_Error( 'illegal_autosave_with_status' );
     2485            } elseif ( $args['user_id'] && get_current_user_id() !== $args['user_id'] ) {
     2486                return new WP_Error( 'illegal_autosave_with_non_current_user' );
     2487            }
    22782488        }
    22792489
     
    25202730        // Note that updating a post with publish status will trigger WP_Customize_Manager::publish_changeset_values().
    25212731        if ( $changeset_post_id ) {
    2522             $post_array['edit_date'] = true; // Prevent date clearing.
    2523             $r = wp_update_post( wp_slash( $post_array ), true );
     2732            if ( $args['autosave'] && 'auto-draft' !== get_post_status( $changeset_post_id ) ) {
     2733                // See _wp_translate_postdata() for why this is required as it will use the edit_post meta capability.
     2734                add_filter( 'map_meta_cap', array( $this, 'grant_edit_post_capability_for_changeset' ), 10, 4 );
     2735                $post_array['post_ID'] = $post_array['ID'];
     2736                $post_array['post_type'] = 'customize_changeset';
     2737                $r = wp_create_post_autosave( wp_slash( $post_array ) );
     2738                remove_filter( 'map_meta_cap', array( $this, 'grant_edit_post_capability_for_changeset' ), 10 );
     2739            } else {
     2740                $post_array['edit_date'] = true; // Prevent date clearing.
     2741                $r = wp_update_post( wp_slash( $post_array ), true );
     2742
     2743                // Delete autosave revision when the changeset is updated.
     2744                $autosave_draft = wp_get_post_autosave( $changeset_post_id );
     2745                if ( $autosave_draft ) {
     2746                    wp_delete_post( $autosave_draft->ID, true );
     2747                }
     2748            }
    25242749        } else {
    25252750            $r = wp_insert_post( wp_slash( $post_array ), true );
     
    25452770
    25462771        return $response;
     2772    }
     2773
     2774    /**
     2775     * Re-map 'edit_post' meta cap for a customize_changeset post to be the same as 'customize' maps.
     2776     *
     2777     * There is essentially a "meta meta" cap in play here, where 'edit_post' meta cap maps to
     2778     * the 'customize' meta cap which then maps to 'edit_theme_options'. This is currently
     2779     * required in core for `wp_create_post_autosave()` because it will call
     2780     * `_wp_translate_postdata()` which in turn will check if a user can 'edit_post', but the
     2781     * the caps for the customize_changeset post type are all mapping to the meta capability.
     2782     * This should be able to be removed once #40922 is addressed in core.
     2783     *
     2784     * @since 4.9.0
     2785     * @link https://core.trac.wordpress.org/ticket/40922
     2786     * @see WP_Customize_Manager::save_changeset_post()
     2787     * @see _wp_translate_postdata()
     2788     *
     2789     * @param array  $caps    Returns the user's actual capabilities.
     2790     * @param string $cap     Capability name.
     2791     * @param int    $user_id The user ID.
     2792     * @param array  $args    Adds the context to the cap. Typically the object ID.
     2793     * @return array Capabilities.
     2794     */
     2795    public function grant_edit_post_capability_for_changeset( $caps, $cap, $user_id, $args ) {
     2796        if ( 'edit_post' === $cap && ! empty( $args[0] ) && 'customize_changeset' === get_post_type( $args[0] ) ) {
     2797            $post_type_obj = get_post_type_object( 'customize_changeset' );
     2798            $caps = map_meta_cap( $post_type_obj->cap->$cap, $user_id );
     2799        }
     2800        return $caps;
    25472801    }
    25482802
     
    27873041
    27883042    /**
     3043     * Delete a given auto-draft changeset or the autosave revision for a given changeset.
     3044     *
     3045     * @since 4.9.0
     3046     */
     3047    public function handle_dismiss_changeset_autosave_request() {
     3048        if ( ! $this->is_preview() ) {
     3049            wp_send_json_error( 'not_preview', 400 );
     3050        }
     3051
     3052        if ( ! check_ajax_referer( 'dismiss_customize_changeset_autosave', 'nonce', false ) ) {
     3053            wp_send_json_error( 'invalid_nonce', 403 );
     3054        }
     3055
     3056        $changeset_post_id = $this->changeset_post_id();
     3057        if ( empty( $changeset_post_id ) ) {
     3058            wp_send_json_error( 'missing_changeset', 404 );
     3059        }
     3060
     3061        if ( 'auto-draft' === get_post_status( $changeset_post_id ) ) {
     3062            if ( ! update_post_meta( $changeset_post_id, '_customize_restore_dismissed', true ) ) {
     3063                wp_send_json_error( 'auto_draft_dismissal_failure', 500 );
     3064            } else {
     3065                wp_send_json_success( 'auto_draft_dismissed' );
     3066            }
     3067        } else {
     3068            $revision = wp_get_post_autosave( $changeset_post_id );
     3069
     3070            if ( $revision ) {
     3071                if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->delete_post, $changeset_post_id ) ) {
     3072                    wp_send_json_error( 'cannot_delete_autosave_revision', 403 );
     3073                }
     3074
     3075                if ( ! wp_delete_post( $revision->ID, true ) ) {
     3076                    wp_send_json_error( 'autosave_revision_deletion_failure', 500 );
     3077                } else {
     3078                    wp_send_json_success( 'autosave_revision_deleted' );
     3079                }
     3080            } else {
     3081                wp_send_json_error( 'no_autosave_to_delete', 404 );
     3082            }
     3083        }
     3084        wp_send_json_error( 'unknown_error', 500 );
     3085    }
     3086
     3087    /**
    27893088     * Add a customize setting.
    27903089     *
     
    35283827            'save' => wp_create_nonce( 'save-customize_' . $this->get_stylesheet() ),
    35293828            'preview' => wp_create_nonce( 'preview-customize_' . $this->get_stylesheet() ),
     3829            'dismiss_autosave' => wp_create_nonce( 'dismiss_customize_changeset_autosave' ),
    35303830        );
    35313831
     
    35643864        }
    35653865
     3866        $autosave_revision_post = null;
     3867        $autosave_autodraft_post = null;
     3868        $changeset_post_id = $this->changeset_post_id();
     3869        if ( ! $this->saved_starter_content_changeset && ! $this->autosaved ) {
     3870            if ( $changeset_post_id ) {
     3871                $autosave_revision_post = wp_get_post_autosave( $changeset_post_id );
     3872            } else {
     3873                $autosave_autodraft_posts = $this->get_changeset_posts( array(
     3874                    'posts_per_page' => 1,
     3875                    'post_status' => 'auto-draft',
     3876                    'exclude_restore_dismissed' => true,
     3877                ) );
     3878                if ( ! empty( $autosave_autodraft_posts ) ) {
     3879                    $autosave_autodraft_post = array_shift( $autosave_autodraft_posts );
     3880                }
     3881            }
     3882        }
     3883
    35663884        // Prepare Customizer settings to pass to JavaScript.
    35673885        $settings = array(
    35683886            'changeset' => array(
    35693887                'uuid' => $this->changeset_uuid(),
    3570                 'status' => $this->changeset_post_id() ? get_post_status( $this->changeset_post_id() ) : '',
     3888                'branching' => $this->branching,
     3889                'autosaved' => $this->autosaved,
     3890                'hasAutosaveRevision' => ! empty( $autosave_revision_post ),
     3891                'latestAutoDraftUuid' => $autosave_autodraft_post ? $autosave_autodraft_post->post_name : null,
     3892                'status' => $changeset_post_id ? get_post_status( $changeset_post_id ) : '',
    35713893            ),
    35723894            'timeouts' => array(
  • trunk/src/wp-includes/js/customize-preview.js

    r41351 r41597  
    3737
    3838            newQueryParams.customize_changeset_uuid = oldQueryParams.customize_changeset_uuid;
     39            if ( api.settings.changeset.autosaved ) {
     40                newQueryParams.customize_autosaved = 'on';
     41            }
    3942            if ( oldQueryParams.customize_theme ) {
    4043                newQueryParams.customize_theme = oldQueryParams.customize_theme;
     
    365368        queryParams = api.utils.parseQueryString( element.search.substring( 1 ) );
    366369        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     370        if ( api.settings.changeset.autosaved ) {
     371            queryParams.customize_autosaved = 'on';
     372        }
    367373        if ( ! api.settings.theme.active ) {
    368374            queryParams.customize_theme = api.settings.theme.stylesheet;
     
    440446            // Include customized state query params in URL.
    441447            queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     448            if ( api.settings.changeset.autosaved ) {
     449                queryParams.customize_autosaved = 'on';
     450            }
    442451            if ( ! api.settings.theme.active ) {
    443452                queryParams.customize_theme = api.settings.theme.stylesheet;
     
    517526
    518527        stateParams.customize_changeset_uuid = api.settings.changeset.uuid;
     528        if ( api.settings.changeset.autosaved ) {
     529            stateParams.customize_autosaved = 'on';
     530        }
    519531        if ( ! api.settings.theme.active ) {
    520532            stateParams.customize_theme = api.settings.theme.stylesheet;
     
    556568            previousQueryString = location.search.substr( 1 ),
    557569            previousQueryParams = null,
    558             stateQueryParams = [ 'customize_theme', 'customize_changeset_uuid', 'customize_messenger_channel' ];
     570            stateQueryParams = [ 'customize_theme', 'customize_changeset_uuid', 'customize_messenger_channel', 'customize_autosaved' ];
    559571
    560572        return function keepAliveCurrentUrl() {
     
    755767
    756768        api.preview.bind( 'saved', function( response ) {
    757 
    758769            if ( response.next_changeset_uuid ) {
    759770                api.settings.changeset.uuid = response.next_changeset_uuid;
     
    780791        } );
    781792
     793        // Update the URLs to reflect the fact we've started autosaving.
     794        api.preview.bind( 'autosaving', function() {
     795            if ( api.settings.changeset.autosaved ) {
     796                return;
     797            }
     798
     799            api.settings.changeset.autosaved = true; // Start deferring to any autosave once changeset is updated.
     800
     801            $( document.body ).find( 'a[href], area' ).each( function() {
     802                api.prepareLinkPreview( this );
     803            } );
     804            $( document.body ).find( 'form' ).each( function() {
     805                api.prepareFormPreview( this );
     806            } );
     807            if ( history.replaceState ) {
     808                history.replaceState( currentHistoryState, '', location.href );
     809            }
     810        } );
     811
    782812        /*
    783813         * Clear dirty flag for settings when saved to changeset so that they
  • trunk/src/wp-includes/script-loader.php

    r41590 r41597  
    562562        'untitledBlogName'   => __( '(Untitled)' ),
    563563        'serverSaveError'    => __( 'Failed connecting to the server. Please try saving again.' ),
     564        /* translators: placeholder is URL to the Customizer to load the autosaved version */
     565        'autosaveNotice'     => __( 'There is a more recent autosave of your changes than the one you are previewing. <a href="%s">Restore the autosave</a>' ),
    564566        'videoHeaderNotice'   => __( 'This theme doesn\'t support video headers on this page. Navigate to the front page or another page that supports video headers.' ),
    565567        // Used for overriding the file types allowed in plupload.
  • trunk/src/wp-includes/theme.php

    r41555 r41597  
    27882788     * the values should contain any characters needing slashes anyway.
    27892789     */
    2790     $keys = array( 'changeset_uuid', 'customize_changeset_uuid', 'customize_theme', 'theme', 'customize_messenger_channel' );
     2790    $keys = array( 'changeset_uuid', 'customize_changeset_uuid', 'customize_theme', 'theme', 'customize_messenger_channel', 'customize_autosaved' );
    27912791    $input_vars = array_merge(
    27922792        wp_array_slice_assoc( $_GET, $keys ),
     
    27952795
    27962796    $theme = null;
    2797     $changeset_uuid = null;
     2797    $changeset_uuid = false; // Value false indicates UUID should be determined after_setup_theme to either re-use existing saved changeset or else generate a new UUID if none exists.
    27982798    $messenger_channel = null;
     2799    $autosaved = null;
     2800    $branching = false; // Set initially fo false since defaults to true for back-compat; can be overridden via the customize_changeset_branching filter.
    27992801
    28002802    if ( $is_customize_admin_page && isset( $input_vars['changeset_uuid'] ) ) {
     
    28102812        $theme = $input_vars['customize_theme'];
    28112813    }
     2814
     2815    if ( ! empty( $input_vars['customize_autosaved'] ) ) {
     2816        $autosaved = true;
     2817    }
     2818
    28122819    if ( isset( $input_vars['customize_messenger_channel'] ) ) {
    28132820        $messenger_channel = sanitize_key( $input_vars['customize_messenger_channel'] );
     
    28312838
    28322839    require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
    2833     $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel', 'settings_previewed' ) );
     2840    $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel', 'settings_previewed', 'autosaved', 'branching' ) );
    28342841}
    28352842
Note: See TracChangeset for help on using the changeset viewer.