From a92bb89f4f7bb35bb9d5abad4960a55c49c91a52 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 27 Sep 2017 22:24:37 +0000 Subject: [PATCH] Customize: Introduce drafting and scheduling for Customizer changesets. * Incorporates code from the Customize Snapshots and Customize Posts feature plugins. * Adds a new Publish Settings section for managing the changeset status, scheduled date, and frontend preview link. * Updates Publish button to reflect the status selected in the Publish Settings (including Save Draft and Schedule). * Deactivates the Themes section when a non-publish status selected, and deactivates the Publish Settings section when previewing a theme switch. * Introduces an `outer` section type (`wp.customize.OuterSection` in JS) for the Publish Settings section to use and for available widgets and available nav menu panels to use in the future. These sections can be expanded while other sections are expanded. * Introduces `WP_Customize_Date_Time_Control` in PHP and `wp.customize.DateTimeControl` in JS for managing a date/time value. * Keeps track of scheduled time and proactively publish from the client when the time arrives, as opposed to waiting for WP Cron. * Auto-publishes a scheduled changeset when attempting to access one that missed its schedule. * Starts a new changeset if attempting to save a changeset that was previously publish. * Adds `force` arg to `requestChangesetUpdate()` to force an update request even when there are no pending changes. * Adds utils methods for `getCurrentTimestamp` and `getRemainingTime`. * Adds new state values for `selectedChangesetStatus`, `changesetDate`, `selectedChangesetDate`. * Fixes logic for when to short-circuit check to close Customizer when there are unsaved changes. * Adds getter methods for `autosaved` and `branching` parameters, with the latter applying the `customize_changeset_branching` filter. * Call to `establish_loaded_changeset` on the fly when `changeset_uuid()` is called if no changeset UUID was specififed. * De-duplicates logic for dismissing auto-draft changesets. * Includes unit tests. Builds on [41597]. Props sayedwp, westonruter, melchoyce, JoshuaWold, folletto, stubgo, karmatosed, dlh, paaljoachim, afercia, johnregan3, utkarshpatel, valendesigns. See #30937. Fixes #39896, #28721, #39275. git-svn-id: https://develop.svn.wordpress.org/trunk@41626 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/customize-controls.css | 390 ++++++- src/wp-admin/customize.php | 40 +- src/wp-admin/js/customize-controls.js | 1033 +++++++++++++++-- .../class-wp-customize-control.php | 5 + .../class-wp-customize-manager.php | 245 ++-- .../class-wp-customize-date-time-control.php | 257 ++++ src/wp-includes/js/customize-preview.js | 51 +- src/wp-includes/script-loader.php | 16 +- tests/phpunit/tests/ajax/CustomizeManager.php | 179 ++- tests/phpunit/tests/customize/manager.php | 224 +++- tests/qunit/fixtures/customize-settings.js | 19 +- tests/qunit/index.html | 81 ++ tests/qunit/wp-admin/js/customize-controls.js | 252 ++++ 13 files changed, 2590 insertions(+), 202 deletions(-) create mode 100644 src/wp-includes/customize/class-wp-customize-date-time-control.php diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css index 6c81fa8247..45c49b5fa5 100644 --- a/src/wp-admin/css/customize-controls.css +++ b/src/wp-admin/css/customize-controls.css @@ -24,11 +24,94 @@ body { color: #555d66; } -#customize-header-actions .button-primary { +#customize-save-button-wrapper { float: right; margin-top: 9px; } +#customize-save-button-wrapper .save { + float: left; + border-radius: 3px; + box-shadow: none; /* @todo Adjust box shadow based on the disable states of paired button. */ + display: none; /* Shown when ready. */ + margin-top: 0; +} +#customize-save-button-wrapper .save.has-next-sibling { + border-radius: 3px 0 0 3px; +} + +#customize-outer-theme-controls-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: -301px; + visibility: hidden; + overflow-x: hidden; + overflow-y: auto; + width: 300px; + margin: 0; + z-index: 4; + background: #eee; + transition: left .18s; + border-right: 1px solid #ddd; +} + +.outer-section-open .wp-full-overlay.expanded { + margin-left: 300px; +} + +#customize-theme-controls .control-section-outer { + display: none !important; +} + +#customize-outer-theme-controls .accordion-section-content { + padding: 12px; +} + +#customize-outer-theme-controls .accordion-section-content.open { + display: block; +} + +.outer-section-open .wp-full-overlay.expanded #customize-outer-theme-controls-wrapper { + visibility: visible; + left: 0; + transition: left .18s; +} + +.customize-outer-pane-parent { + margin: 0; +} + +.outer-section-open .wp-full-overlay.expanded #customize-preview { + opacity: 0.4; +} + +body.outer-section-open .wp-full-overlay.expanded .wp-full-overlay-main { + left: 300px; +} + +#customize-outer-theme-controls li.notice { + padding-top: 8px; + padding-bottom: 8px; + margin-left: 0; + margin-bottom: 10px; +} + +#publish-settings { + text-indent: 0; + border-radius: 0 3px 3px 0; + padding-left: 0; + padding-right: 0; + box-shadow: none; /* @todo Adjust box shadow based on the disable states of paired button. */ + font-size: 14px; + width: 30px; + float: left; + display: none; /* Shown when ready. */ + -webkit-transform: none; + transform: none; + margin-top: 0; +} + #customize-header-actions .spinner { margin-top: 13px; margin-right: 4px; @@ -53,10 +136,181 @@ body { margin-bottom: 15px; } +#customize-control-changeset_status label, +#customize-control-changeset_preview_link input { + background-color: #ffffff; + border-bottom: 1px solid #ddd; + box-sizing: content-box; + width: 100%; + margin-left: -12px; + padding-left: 12px; + padding-right: 12px; +} + +#customize-controls .date-input:invalid { + border-color: red; +} + +.date-time-fields .month-field { + width: 79px; +} + +.date-time-fields .day-field, +.date-time-fields .hour-field, +.date-time-fields .minute-field { + width: 46px; +} + +.date-time-fields .year-field { + width: 60px; +} + +.date-time-fields .am-pm-field { + width: 53px; +} + +#customize-control-changeset_status label { + padding-top: 10px; + padding-bottom: 10px; + font-weight: 500; +} + +#customize-control-changeset_status label:first-of-type { + border-top: 1px solid #ddd; +} + +#customize-control-changeset_status .customize-control-title { + margin-bottom: 6px; +} + +#customize-control-changeset_status input { + margin-left: 0; +} + +#customize-control-changeset_preview_link { + position: relative; + display: block; +} + +.customize-copy-preview-link { + position: absolute; + bottom: 9px; + right: 0; +} + +.customize-copy-preview-link:before, +.customize-copy-preview-link:after { + content: ''; + height: 28px; + position: absolute; + background: #ffffff; + top: -1px; +} + +.customize-copy-preview-link:before { + left: -10px; + width: 9px; + opacity: 0.75; +} + +.customize-copy-preview-link:after { + left: -5px; + width: 4px; + opacity: 0.8; +} + +#customize-control-changeset_preview_link input { + line-height: 2.5; + border-top: 1px solid #ddd; + border-left: none; + border-right: none; + text-indent: -999px; + color: white; +} + +#customize-control-changeset_preview_link label { + position: relative; + display: block; +} + +#customize-control-changeset_preview_link a.preview-control-element { + display: inline-block; + position: absolute; + white-space: nowrap; + overflow: hidden; + width: 217px; + bottom: 14px; + font-size: 14px; + text-decoration: none; +} + +#customize-control-changeset_preview_link a.preview-control-element.disabled, +#customize-control-changeset_preview_link a.preview-control-element.disabled:active, +#customize-control-changeset_preview_link a.preview-control-element.disabled:focus, +#customize-control-changeset_preview_link a.preview-control-element.disabled:visited { + color: black; + opacity: 0.4; + cursor: default; + outline: none; + box-shadow: none; +} + +#sub-accordion-section-publish_settings .customize-section-description-container { + display: none; +} + #customize-controls .customize-info.section-meta { margin-bottom: 15px; } +.date-time-fields { + padding-top: 10px; + padding-bottom:10px; +} + +.date-time-fields label, +.date-time-fields .date-time-separator { + float: left; + margin-right:5px; +} + +.date-time-fields .date-time-separator { + line-height: 2; +} + +.date-time-fields .time-row { + padding-top: 12px; +} + +.date-time-fields .date-timezone { + float: left; + line-height: 2.2; + text-decoration: none; +} + +#customize-control-changeset_preview_link { + margin-top: 20px; +} + +#customize-control-changeset_status { + margin-bottom: 0; + padding-bottom: 0; +} + +#customize-control-changeset_scheduled_date { + box-sizing: content-box; + width: 100%; + margin-left: -12px; + padding: 12px 12px 18px; + background: #ffffff; + border-bottom: 1px solid #ddd; + margin-bottom: 0; +} + +#customize-control-changeset_scheduled_date .customize-control-description { + font-style: normal; +} + #customize-controls .customize-info.is-in-view, #customize-controls .customize-section-title.is-in-view { position: absolute; @@ -105,6 +359,8 @@ body { #customize-controls .customize-pane-child .customize-section-title h3, #customize-controls .customize-pane-child h3.customize-section-title, +#customize-outer-theme-controls .customize-pane-child .customize-section-title h3, +#customize-outer-theme-controls .customize-pane-child h3.customize-section-title, #customize-controls .customize-info .panel-title { font-size: 20px; font-weight: 200; @@ -150,6 +406,7 @@ body { #customize-controls .customize-info .customize-panel-description, #customize-controls .customize-info .customize-section-description, +#customize-outer-theme-controls .customize-info .customize-section-description, #customize-controls .no-widget-areas-rendered-notice { color: #555d66; display: none; @@ -171,7 +428,8 @@ body { margin-bottom: 0; } -#customize-controls .customize-info .customize-section-description { +#customize-controls .customize-info .customize-section-description, +#customize-outer-theme-controls .customize-section-description { margin-bottom: 15px; } @@ -189,11 +447,13 @@ body { padding-right: 30px; } -#customize-theme-controls .control-section { +#customize-theme-controls .control-section, +#customize-outer-theme-controls .control-section { border: none; } -#customize-theme-controls .accordion-section-title { +#customize-theme-controls .accordion-section-title, +#customize-outer-theme-controls .accordion-section-title { color: #555d66; background-color: #fff; border-bottom: 1px solid #ddd; @@ -209,12 +469,14 @@ body { border-left: 4px solid #fff; } -#customize-theme-controls .accordion-section-title:after { +#customize-theme-controls .accordion-section-title:after, +#customize-outer-theme-controls .accordion-section-title:after { content: "\f345"; color: #a0a5aa; } -#customize-theme-controls .accordion-section-content { +#customize-theme-controls .accordion-section-content, +#customize-outer-theme-controls .accordion-section-content { color: #555d66; background: transparent; } @@ -222,6 +484,9 @@ body { #customize-controls .control-section:hover > .accordion-section-title, #customize-controls .control-section .accordion-section-title:hover, #customize-controls .control-section.open .accordion-section-title, +#customize-outer-theme-controls .control-section .accordion-section-title:hover, +#customize-outer-theme-controls .control-section.open .accordion-section-title, +#customize-outer-theme-controls .control-section .accordion-section-title:focus, #customize-controls .control-section .accordion-section-title:focus { color: #0073aa; background: #f3f3f5; @@ -242,7 +507,11 @@ body { #customize-theme-controls .control-section:hover > .accordion-section-title:after, #customize-theme-controls .control-section .accordion-section-title:hover:after, #customize-theme-controls .control-section.open .accordion-section-title:after, -#customize-theme-controls .control-section .accordion-section-title:focus:after { +#customize-theme-controls .control-section .accordion-section-title:focus:after, +#customize-outer-theme-controls .control-section:hover > .accordion-section-title:after, +#customize-outer-theme-controls .control-section .accordion-section-title:hover:after, +#customize-outer-theme-controls .control-section.open .accordion-section-title:after, +#customize-outer-theme-controls .control-section .accordion-section-title:focus:after { color: #0073aa; } @@ -250,7 +519,8 @@ body { border-bottom: 1px solid #eee; } -#customize-theme-controls .control-section.open .accordion-section-title { +#customize-theme-controls .control-section.open .accordion-section-title, +#customize-outer-theme-controls .control-section.open .accordion-section-title { border-bottom-color: #eee !important; } @@ -828,6 +1098,10 @@ p.customize-section-description { margin: 0; } +.wp-full-overlay.collapsed #customize-controls #customize-notifications-area { + display: none !important; +} + #customize-controls #customize-notifications-area, #customize-controls .customize-section-title > .customize-control-notifications-container, #customize-controls .panel-meta > .customize-control-notifications-container { @@ -1119,18 +1393,60 @@ p.customize-section-description { animation: dice-color-change 3s infinite; } -@-webkit-keyframes dice-color-change { - 0% { color: #d4b146; } - 50% { color: #ef54b0; } - 75% { color: #7190d3; } - 100% { color: #d4b146; } +.button-see-me { + -webkit-animation: bounce .7s 1; + animation: bounce .7s 1; + -webkit-transform-origin: center bottom; + transform-origin: center bottom; } -@keyframes dice-color-change { - 0% { color: #d4b146; } - 50% { color: #ef54b0; } - 75% { color: #7190d3; } - 100% { color: #d4b146; } +@-webkit-keyframes bounce { + from, 20%, 53%, 80%, to { + -webkit-animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + -webkit-transform: translate3d(0,0,0); + } + + 40%, 43% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -12px, 0); + } + + 70% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -6px, 0); + } + + 90% { + -webkit-transform: translate3d(0,-1px,0); + } +} + +@keyframes bounce { + from, 20%, 53%, 80%, to { + -webkit-animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + -webkit-transform: translate3d(0,0,0); + transform: translate3d(0,0,0); + } + + 40%, 43% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -12px, 0); + transform: translate3d(0, -12px, 0); + } + + 70% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -6px, 0); + transform: translate3d(0, -6px, 0); + } + + 90% { + -webkit-transform: translate3d(0,-1px,0); + transform: translate3d(0,-1px,0); + } } .customize-control-header .choice { @@ -1310,7 +1626,8 @@ p.customize-section-description { } #customize-controls .control-section-themes .accordion-section-title span.customize-action, -#customize-controls .customize-section-title span.customize-action { +#customize-controls .customize-section-title span.customize-action, +#customize-outer-theme-controls .customize-section-title span.customize-action { font-size: 13px; display: block; font-weight: 400; @@ -1843,6 +2160,27 @@ body.adding-widget .add-new-widget:before, line-height: 32px; } + .customize-control .date-time-fields select { + height: 39px; + } + + .date-time-fields .month-field { + width: 79px; + } + + .date-time-fields .day-field, + .date-time-fields .hour-field, + .date-time-fields .minute-field { + width: 55px; + } + + .date-time-fields .year-field { + width: 80px; + } + + .date-time-fields .date-timezone { + line-height: 3.2; + } .wp-core-ui.wp-customizer .button { margin-top: 12px; } @@ -1853,7 +2191,8 @@ body.adding-widget .add-new-widget:before, width: 100%; } - .wp-full-overlay.expanded { + .wp-full-overlay.expanded, + .outer-section-open .wp-full-overlay.expanded { margin-left: 0; } @@ -1931,12 +2270,17 @@ body.adding-widget .add-new-widget:before, margin-top: 12px; } - #customize-header-actions .button-primary { - margin-top: 6px; + #publish-settings { + height: 31px; + } + + #customize-control-changeset_status label { + padding-top: 15px; } body.adding-widget div#available-widgets, - body.adding-menu-items div#available-menu-items { + body.adding-menu-items div#available-menu-items, + body.outer-section-open div#customize-outer-theme-controls-wrapper { top: 46px; left: 0; z-index: 10; diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 81a7ae2741..3fc2b5b6bd 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -27,14 +27,30 @@ if ( ! current_user_can( 'customize' ) ) { global $wp_scripts, $wp_customize; if ( $wp_customize->changeset_post_id() ) { - if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) { + $changeset_post = get_post( $wp_customize->changeset_post_id() ); + + if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post->ID ) ) { wp_die( '

' . __( 'Cheatin’ uh?' ) . '

' . '

' . __( 'Sorry, you are not allowed to edit this changeset.' ) . '

', 403 ); } - if ( in_array( get_post_status( $wp_customize->changeset_post_id() ), array( 'publish', 'trash' ), true ) ) { + + $missed_schedule = ( + 'future' === $changeset_post->post_status && + get_post_time( 'G', true, $changeset_post ) < time() + ); + if ( $missed_schedule ) { + wp_publish_post( $changeset_post->ID ); + wp_die( + '

' . __( 'Your scheduled changes just published' ) . '

' . + '

' . __( 'Customize New Changes' ) . '

', + 200 + ); + } + + if ( in_array( get_post_status( $changeset_post->ID ), array( 'publish', 'trash' ), true ) ) { wp_die( '

' . __( 'Cheatin’ uh?' ) . '

' . '

' . __( 'This changeset has already been published and cannot be further modified.' ) . '

' . @@ -132,14 +148,11 @@ do_action( 'customize_controls_print_scripts' );
- is_theme_active() ? __( 'Save & Publish' ) : __( 'Save & Activate' ); - $save_attrs = array(); - if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) { - $save_attrs['style'] = 'display: none'; - } - submit_button( $save_text, 'primary save', 'save', false, $save_attrs ); - ?> + is_theme_active() ? __( 'Publish' ) : __( 'Activate & Publish' ); ?> +
+ + +
+
+
+
+
+
+
+
= midDayHour ? 'pm' : 'am'; + date.hour = date.hour % midDayHour ? String( date.hour % midDayHour ) : String( midDayHour ); + delete date.second; + } + + return date; + }, + + /** + * Validates if input components have valid date and time. + * + * @since 4.9.0 + * @return {boolean} If date input fields has error. + */ + validateInputs: function validateInputs() { + var control = this, errorMessage; + + control.invalidDate = false; + + _.each( [ 'day', 'hour', 'year', 'minute' ], function( component ) { + var element, el, max, min, maxLength, value; + + if ( ! control.invalidDate ) { + element = control.inputElements[ component ]; + el = element.element.get( 0 ); + max = parseInt( element.element.attr( 'max' ), 10 ); + min = parseInt( element.element.attr( 'min' ), 10 ); + maxLength = parseInt( element.element.attr( 'maxlength' ), 10 ); + value = element(); + control.invalidDate = value > max || value < min || String( value ).length > maxLength; + errorMessage = control.invalidDate ? api.l10n.invalid + ' ' + component : ''; + + el.setCustomValidity( errorMessage ); + _.result( el, 'reportValidity' ); + } + } ); + + return control.invalidDate; + }, + + /** + * Updates number of days according to the month and year selected. + * + * @since 4.9.0 + * @return {void} + */ + updateDaysForMonth: function updateDaysForMonth() { + var control = this, daysInMonth, year, month, day; + + month = control.inputElements.month(); + year = control.inputElements.year(); + day = control.inputElements.day(); + + if ( month && year ) { + daysInMonth = new Date( year, month, 0 ).getDate(); + control.inputElements.day.element.attr( 'max', daysInMonth ); + + if ( day > daysInMonth ) { + control.inputElements.day( daysInMonth ); + } + } + }, + + /** + * Updates number of minutes according to the hour selected. + * + * @since 4.9.0 + * @return {void} + */ + updateMinutesForHour: function updateMinutesForHour() { + var control = this, maxHours = 24, minuteEl; + + if ( control.inputElements.ampm ) { + return; + } + + minuteEl = control.inputElements.minute.element; + + if ( maxHours === control.inputElements.hour() ) { + control.inputElements.minute( 0 ); + minuteEl.data( 'default-max', minuteEl.attr( 'max' ) ); + minuteEl.data( 'default-maxlength', minuteEl.attr( 'maxlength' ) ); + minuteEl.attr( 'max', '0' ); + } else if ( minuteEl.data( 'default-max' ) ) { + minuteEl.attr( 'max', minuteEl.data( 'default-max' ) ); + minuteEl.attr( 'maxlength', minuteEl.data( 'maxlength' ) ); + } + }, + + /** + * Populate setting value from the inputs. + * + * @since 4.9.0 + * @returns {boolean} If setting updated. + */ + populateSetting: function populateSetting() { + var control = this, date; + + if ( control.validateInputs() || ! control.params.allowPastDate && ! control.isFutureDate() ) { + return false; + } + + date = control.convertInputDateToString(); + control.setting.set( date ); + return true; + }, + + /** + * Converts input values to string in Y-m-d H:i:s format. + * + * @since 4.9.0 + * @return {string} Date string. + */ + convertInputDateToString: function convertInputDateToString() { + var control = this, date = '', dateFormat, hourInTwentyFourHourFormat, + getElementValue, pad; + + pad = function( number, padding ) { + var zeros; + if ( String( number ).length < padding ) { + zeros = padding - String( number ).length; + number = Math.pow( 10, zeros ).toString().substr( 1 ) + String( number ); + } + return number; + }; + + getElementValue = function( component ) { + var value = control.inputElements[ component ].get(); + + if ( _.contains( [ 'month', 'day', 'hour', 'minute' ], component ) ) { + value = pad( value, 2 ); + } else if ( 'year' === component ) { + value = pad( value, 4 ); + } + return value; + }; + + hourInTwentyFourHourFormat = control.inputElements.ampm ? control.convertHourToTwentyFourHourFormat( control.inputElements.hour(), control.inputElements.ampm() ) : control.inputElements.hour(); + dateFormat = [ 'year', '-', 'month', '-', 'day', ' ', pad( hourInTwentyFourHourFormat, 2 ), ':', 'minute', ':', '00' ]; + + _.each( dateFormat, function( component ) { + date += control.inputElements[ component ] ? getElementValue( component ) : component; + } ); + + return date; + }, + + /** + * Check if the date is in the future. + * + * @since 4.9.0 + * @returns {boolean} True if future date. + */ + isFutureDate: function isFutureDate() { + var control = this; + return 0 < api.utils.getRemainingTime( control.convertInputDateToString() ); + }, + + /** + * Convert hour in twelve hour format to twenty four hour format. + * + * @since 4.9.0 + * @param {string} hourInTwelveHourFormat Hour in twelve hour format. + * @param {string} ampm am/pm + * @return {string} Hour in twenty four hour format. + */ + convertHourToTwentyFourHourFormat: function convertHour( hourInTwelveHourFormat, ampm ) { + var hourInTwentyFourHourFormat, hour, midDayHour = 12; + + hour = parseInt( hourInTwelveHourFormat, 10 ); + + if ( 'pm' === ampm && hour < midDayHour ) { + hourInTwentyFourHourFormat = hour + midDayHour; + } else if ( 'am' === ampm && midDayHour === hour ) { + hourInTwentyFourHourFormat = hour - midDayHour; + } else { + hourInTwentyFourHourFormat = hour; + } + + return String( hourInTwentyFourHourFormat ); + }, + + /** + * Populates date inputs in date fields. + * + * @since 4.9.0 + * @returns {boolean} Whether the inputs were populated. + */ + populateDateInputs: function populateDateInputs() { + var control = this, parsed; + + parsed = control.parseDateTime( control.setting.get(), control.params.twelveHourFormat ); + + if ( ! parsed ) { + return false; + } + + _.each( control.inputElements, function( element, component ) { + element.set( parsed[ component ] ); + } ); + + return true; + }, + + /** + * Toggle future date notification for date control. + * + * @since 4.9.0 + * @param {boolean} notify Add or remove the notification. + * @return {wp.customize.DateTimeControl} + */ + toggleFutureDateNotification: function toggleFutureDateNotification( notify ) { + var control = this, notificationCode, notification; + + notificationCode = 'not_future_date'; + + if ( notify ) { + notification = new api.Notification( notificationCode, { + type: 'error', + message: api.l10n.futureDateError + } ); + control.notifications.add( notificationCode, notification ); + } else { + control.notifications.remove( notificationCode ); + } + + return control; + } + }); + + /** + * Class PreviewLinkControl. + * + * @since 4.9.0 + * @constructor + * @augments wp.customize.Control + * @augments wp.customize.Class + */ + api.PreviewLinkControl = api.Control.extend({ + + previewElements: {}, + + /** + * Override the templateSelector before embedding the control into the page. + * + * @since 4.9.0 + * @return {void} + */ + embed: function() { + var control = this; + control.templateSelector = 'customize-preview-link-control'; + return api.Control.prototype.embed.apply( control, arguments ); + }, + + /** + * Initialize behaviors. + * + * @since 4.9.0 + * @returns {void} + */ + ready: function ready() { + var control = this, element, component, node, link, input, button; + + _.bindAll( control, 'updatePreviewLink' ); + + if ( ! control.setting ) { + control.setting = new api.Value(); + } + + control.container.find( '.preview-control-element' ).each( function() { + node = $( this ); + component = node.data( 'component' ); + element = new api.Element( node ); + control.previewElements[ component ] = element; + control.elements.push( element ); + } ); + + link = control.previewElements.link; + input = control.previewElements.input; + button = control.previewElements.button; + + input.link( control.setting ); + link.link( control.setting ); + + link.bind( function( value ) { + link.element.attr( 'href', value ); + link.element.attr( 'target', api.settings.changeset.uuid ); + } ); + + api.bind( 'ready', control.updatePreviewLink ); + api.bind( 'change', control.updatePreviewLink ); + api.state( 'saved' ).bind( control.updatePreviewLink ); + + button.element.on( 'click', function( event ) { + event.preventDefault(); + if ( control.setting() ) { + input.element.select(); + document.execCommand( 'copy' ); + button( button.element.data( 'copied-text' ) ); + } + } ); + + link.element.on( 'click', function( event ) { + if ( link.element.hasClass( 'disabled' ) ) { + event.preventDefault(); + } + } ); + + button.element.on( 'mouseenter', function() { + if ( control.setting() ) { + button( button.element.data( 'copy-text' ) ); + } + } ); + }, + + /** + * Updates Preview Link + * + * @since 4.9.0 + * @return {void} + */ + updatePreviewLink: function updatePreviewLink() { + var control = this, unsavedDirtyValues; + + unsavedDirtyValues = ! _.isEmpty( api.dirtyValues( { + unsaved: true + } ) ); + + control.toggleSaveNotification( unsavedDirtyValues ); + control.previewElements.link.element.toggleClass( 'disabled', unsavedDirtyValues ); + control.previewElements.button.element.prop( 'disabled', unsavedDirtyValues ); + control.setting.set( api.previewer.getFrontendPreviewUrl() ); + }, + + /** + * Toggles save notification. + * + * @since 4.9.0 + * @param {boolean} notify Add or remove notification. + * @return {void} + */ + toggleSaveNotification: function toggleSaveNotification( notify ) { + var control = this, notificationCode, notification; + + notificationCode = 'changes_not_saved'; + + if ( notify ) { + notification = new api.Notification( notificationCode, { + type: 'info', + message: api.l10n.saveBeforeShare + } ); + control.notifications.add( notificationCode, notification ); + } else { + control.notifications.remove( notificationCode ); + } + } + }); + // Change objects contained within the main customize object to Settings. api.defaultConstructor = api.Setting; @@ -4059,7 +4676,7 @@ customize_messenger_channel: previewFrame.query.customize_messenger_channel } ); - if ( ! api.state( 'saved' ).get() ) { + if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { params.customize_autosaved = 'on'; } @@ -4660,11 +5277,13 @@ background: api.BackgroundControl, background_position: api.BackgroundPositionControl, theme: api.ThemeControl, + date_time: api.DateTimeControl, code_editor: api.CodeEditorControl }; api.panelConstructor = {}; api.sectionConstructor = { - themes: api.ThemesSection + themes: api.ThemesSection, + outer: api.OuterSection }; /** @@ -4836,6 +5455,28 @@ api.trigger( 'pane-contents-reflowed' ); }, api ); + // Define state values. + api.state = new api.Values(); + _.each( [ + 'saved', + 'autosaved', + 'saving', + 'activated', + 'processing', + 'paneVisible', + 'expandedPanel', + 'expandedSection', + 'changesetDate', + 'selectedChangesetDate', + 'changesetStatus', + 'selectedChangesetStatus', + 'remainingTimeToPublish', + 'previewerAlive', + 'editShortcutVisibility' + ], function( name ) { + api.state.create( name ); + }); + $( function() { api.settings = window._wpCustomizeSettings; api.l10n = window._wpCustomizeControlsL10n; @@ -4863,8 +5504,61 @@ title = $( '#customize-info .panel-title.site-title' ), closeBtn = $( '.customize-controls-close' ), saveBtn = $( '#save' ), + btnWrapper = $( '#customize-save-button-wrapper' ), + publishSettingsBtn = $( '#publish-settings' ), footerActions = $( '#customize-footer-actions' ); + saveBtn.show(); + + api.section( 'publish_settings', function( section ) { + var updateButtonsState, previewLinkControl, previewLinkControlId = 'changeset_preview_link'; + + previewLinkControl = new api.PreviewLinkControl( previewLinkControlId, { + params: { + section: section.id, + active: true, + priority: 100, + content: '
  • ' + } + } ); + + api.control.add( previewLinkControlId, previewLinkControl ); + + // Make sure publish settings are not available until the theme has been activated. + if ( ! api.settings.theme.active ) { + section.active.set( false ); + section.active.link( api.state( 'activated' ) ); + } + + // Bind visibility of the publish settings button to whether the section is active. + updateButtonsState = function() { + publishSettingsBtn.toggle( section.active.get() ); + saveBtn.toggleClass( 'has-next-sibling', section.active.get() ); + }; + updateButtonsState(); + section.active.bind( updateButtonsState ); + + section.contentContainer.find( '.customize-action' ).text( api.l10n.updating ); + section.contentContainer.find( '.customize-section-back' ).removeAttr( 'tabindex' ); + publishSettingsBtn.prop( 'disabled', false ); + + publishSettingsBtn.on( 'click', function( event ) { + event.preventDefault(); + section.expanded.set( ! section.expanded.get() ); + } ); + + section.expanded.bind( function( isExpanded ) { + publishSettingsBtn.attr( 'aria-expanded', String( isExpanded ) ); + publishSettingsBtn.toggleClass( 'active', isExpanded ); + } ); + + api.state( 'changesetStatus' ).bind( function( status ) { + if ( 'publish' === status ) { + section.collapse(); + } + } ); + } ); + // Prevent the form from saving when enter is pressed on an input or select element. $('#customize-controls').on( 'keydown', function( e ) { var isEnter = ( 13 === e.which ), @@ -4923,7 +5617,7 @@ nonce: this.nonce.preview, customize_changeset_uuid: api.settings.changeset.uuid }; - if ( ! api.state( 'saved' ).get() ) { + if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { queryVars.customize_autosaved = 'on'; } @@ -4959,13 +5653,15 @@ save: function( args ) { var previewer = this, deferred = $.Deferred(), - changesetStatus = 'publish', + changesetStatus = api.state( 'selectedChangesetStatus' ).get(), + selectedChangesetDate = api.state( 'selectedChangesetDate' ).get(), processing = api.state( 'processing' ), submitWhenDoneProcessing, submit, modifiedWhileSaving = {}, invalidSettings = [], - invalidControls; + invalidControls = [], + invalidSettingLessControls = []; if ( args && args.status ) { changesetStatus = args.status; @@ -5004,17 +5700,34 @@ } } ); } ); - invalidControls = api.findControlsForSettings( invalidSettings ); + + /** + * Find all invalid setting less controls with notification type error. + */ + api.control.each( function( control ) { + if ( ! control.setting || ! control.setting.id && control.active.get() ) { + control.notifications.each( function( notification ) { + if ( 'error' === notification.type ) { + invalidSettingLessControls.push( [ control ] ); + } + } ); + } + } ); + + invalidControls = _.union( invalidSettingLessControls, _.values( api.findControlsForSettings( invalidSettings ) ) ); if ( ! _.isEmpty( invalidControls ) ) { - _.values( invalidControls )[0][0].focus(); + + invalidControls[0][0].focus(); api.unbind( 'change', captureSettingModifiedDuringSave ); - api.notifications.add( errorCode, new api.Notification( errorCode, { - message: ( 1 === invalidSettings.length ? api.l10n.saveBlockedError.singular : api.l10n.saveBlockedError.plural ).replace( /%s/g, String( invalidSettings.length ) ), - type: 'error', - dismissible: true, - saveFailure: true - } ) ); + if ( invalidSettings.length ) { + api.notifications.add( errorCode, new api.Notification( errorCode, { + message: ( 1 === invalidSettings.length ? api.l10n.saveBlockedError.singular : api.l10n.saveBlockedError.plural ).replace( /%s/g, String( invalidSettings.length ) ), + type: 'error', + dismissible: true, + saveFailure: true + } ) ); + } deferred.rejectWith( previewer, [ { setting_invalidities: settingInvalidities } @@ -5031,9 +5744,13 @@ nonce: previewer.nonce.save, customize_changeset_status: changesetStatus } ); + if ( args && args.date ) { query.customize_changeset_date = args.date; + } else if ( 'future' === changesetStatus && selectedChangesetDate ) { + query.customize_changeset_date = selectedChangesetDate; } + if ( args && args.title ) { query.customize_changeset_title = args.title; } @@ -5070,6 +5787,13 @@ }); request.fail( function ( response ) { + var notification, notificationArgs; + notificationArgs = { + type: 'error', + dismissible: true, + fromServer: true, + saveFailure: true + }; if ( '0' === response ) { response = 'not_logged_in'; @@ -5087,23 +5811,23 @@ previewer.preview.iframe.show(); } ); } else if ( response.code ) { - api.notifications.add( response.code, new api.Notification( response.code, { - message: response.message, - type: 'error', - dismissible: true, - fromServer: true, - saveFailure: true - } ) ); + if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) { + api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus(); + } else { + notification = new api.Notification( response.code, _.extend( notificationArgs, { + message: response.message + } ) ); + } } else { - api.notifications.add( 'unknown_error', new api.Notification( 'unknown_error', { - message: api.l10n.serverSaveError, - type: 'error', - dismissible: true, - fromServer: true, - saveFailure: true + notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, { + message: api.l10n.serverSaveError } ) ); } + if ( notification ) { + api.notifications.add( notification.code, notification ); + } + if ( response.setting_validities ) { api._handleSettingValidities( { settingValidities: response.setting_validities, @@ -5113,6 +5837,14 @@ deferred.rejectWith( previewer, [ response ] ); api.trigger( 'error', response ); + + // Start a new changeset if the underlying changeset was published. + if ( 'changeset_already_published' === response.code && response.next_changeset_uuid ) { + api.settings.changeset.uuid = response.next_changeset_uuid; + api.state( 'changesetStatus' ).set( '' ); + parent.send( 'changeset-uuid', api.settings.changeset.uuid ); + api.previewer.send( 'changeset-uuid', api.settings.changeset.uuid ); + } } ); request.done( function( response ) { @@ -5120,6 +5852,8 @@ previewer.send( 'saved', response ); api.state( 'changesetStatus' ).set( response.changeset_status ); + api.state( 'changesetDate' ).set( response.changeset_date ); + if ( 'publish' === response.changeset_status ) { // Mark all published as clean if they haven't been modified during the request. @@ -5173,6 +5907,28 @@ } return deferred.promise(); + }, + + /** + * Builds the front preview url with the current state of customizer. + * + * @since 4.9 + * + * @return {string} Preview url. + */ + getFrontendPreviewUrl: function() { + var previewer = this, + a = document.createElement( 'a' ), + params = {}; + + if ( api.state( 'changesetStatus' ).get() && 'publish' !== api.state( 'changesetStatus' ).get() ) { + params.customize_changeset_uuid = api.settings.changeset.uuid; + } + + a.href = previewer.previewUrl(); + a.search = $.param( params ); + + return a.href; } }); @@ -5299,47 +6055,90 @@ }); // Save and activated states - (function() { - var state = new api.Values(), - saved = state.create( 'saved' ), - saving = state.create( 'saving' ), - activated = state.create( 'activated' ), - processing = state.create( 'processing' ), - paneVisible = state.create( 'paneVisible' ), - expandedPanel = state.create( 'expandedPanel' ), - expandedSection = state.create( 'expandedSection' ), - changesetStatus = state.create( 'changesetStatus' ), - previewerAlive = state.create( 'previewerAlive' ), - editShortcutVisibility = state.create( 'editShortcutVisibility' ), + (function( state ) { + var saved = state.instance( 'saved' ), + saving = state.instance( 'saving' ), + activated = state.instance( 'activated' ), + processing = state.instance( 'processing' ), + paneVisible = state.instance( 'paneVisible' ), + expandedPanel = state.instance( 'expandedPanel' ), + expandedSection = state.instance( 'expandedSection' ), + changesetStatus = state.instance( 'changesetStatus' ), + selectedChangesetStatus = state.instance( 'selectedChangesetStatus' ), + changesetDate = state.instance( 'changesetDate' ), + selectedChangesetDate = state.instance( 'selectedChangesetDate' ), + previewerAlive = state.instance( 'previewerAlive' ), + editShortcutVisibility = state.instance( 'editShortcutVisibility' ), populateChangesetUuidParam; state.bind( 'change', function() { var canSave; + btnWrapper.removeClass( 'button-see-me' ); + if ( ! activated() ) { saveBtn.val( api.l10n.activate ); closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); + publishSettingsBtn.prop( 'disabled', false ); } else if ( '' === changesetStatus.get() && saved() ) { - saveBtn.val( api.l10n.saved ); + if ( api.settings.changeset.currentUserCanPublish ) { + saveBtn.val( api.l10n.published ); + } else { + saveBtn.val( api.l10n.saved ); + } + publishSettingsBtn.prop( 'disabled', true ); closeBtn.find( '.screen-reader-text' ).text( api.l10n.close ); } else { - saveBtn.val( api.l10n.save ); + if ( 'draft' === selectedChangesetStatus() ) { + if ( saved() && selectedChangesetStatus() === changesetStatus() ) { + saveBtn.val( api.l10n.draftSaved ); + } else { + saveBtn.val( api.l10n.saveDraft ); + } + } else if ( 'future' === selectedChangesetStatus() ) { + if ( saved() && selectedChangesetStatus() === changesetStatus() ) { + if ( changesetDate.get() !== selectedChangesetDate.get() ) { + saveBtn.val( api.l10n.schedule ); + btnWrapper.addClass( 'button-see-me' ); + } else { + saveBtn.val( api.l10n.scheduled ); + } + } else { + btnWrapper.addClass( 'button-see-me' ); + saveBtn.val( api.l10n.schedule ); + } + } else if ( ! api.settings.changeset.currentUserCanPublish ) { + selectedChangesetStatus( 'draft' ); + } else { + saveBtn.val( api.l10n.publish ); + } closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); + publishSettingsBtn.prop( 'disabled', false ); } /* * Save (publish) button should be enabled if saving is not currently happening, * and if the theme is not active or the changeset exists but is not published. */ - canSave = ! saving() && ( ! activated() || ! saved() || ( '' !== changesetStatus() && 'publish' !== changesetStatus() ) ); + canSave = ! saving() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) ); saveBtn.prop( 'disabled', ! canSave ); }); + selectedChangesetStatus.validate = function( status ) { + if ( '' === status || 'auto-draft' === status ) { + return null; + } + return status; + }; + // Set default states. changesetStatus( api.settings.changeset.status ); + changesetDate( api.settings.changeset.publishDate ); + selectedChangesetStatus( '' === api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ? 'publish' : api.settings.changeset.status ); + selectedChangesetStatus.link( changesetStatus ); // Ensure that direct updates to status on server via wp.customizer.previewer.save() will update selection. saved( true ); if ( '' === changesetStatus() ) { // Handle case for loading starter content. api.each( function( setting ) { @@ -5424,18 +6223,32 @@ history.replaceState( {}, document.title, urlParser.href ); }; + /** + * Deactivate themes section if changeset status is not auto-draft + */ + api.section( 'themes', function( section ) { + var canActivate; + + canActivate = function() { + return ! changesetStatus() || 'auto-draft' === changesetStatus(); + }; + + section.active.validate = canActivate; + section.active.set( canActivate() ); + changesetStatus.bind( function() { + section.active.set( canActivate() ); + } ); + } ); + // Show changeset UUID in URL when in branching mode and there is a saved changeset. if ( api.settings.changeset.branching ) { changesetStatus.bind( function( newStatus ) { populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus ); } ); } + }( api.state ) ); - // Expose states to the API. - api.state = state; - }()); - - // Set up autosave prompt. + // Set up initial notifications. (function() { /** @@ -5522,12 +6335,10 @@ // Remove the notification once the user starts making changes. onStateChange = function() { api.notifications.remove( code ); - api.state( 'saved' ).unbind( onStateChange ); - api.state( 'saving' ).unbind( onStateChange ); + api.unbind( 'change', onStateChange ); api.state( 'changesetStatus' ).unbind( onStateChange ); }; - api.state( 'saved' ).bind( onStateChange ); - api.state( 'saving' ).bind( onStateChange ); + api.bind( 'change', onStateChange ); api.state( 'changesetStatus' ).bind( onStateChange ); } @@ -5553,18 +6364,22 @@ api.previewer.save(); event.preventDefault(); }).keydown( function( event ) { - if ( 9 === event.which ) // tab + if ( 9 === event.which ) { // Tab. return; - if ( 13 === event.which ) // enter + } + if ( 13 === event.which ) { // Enter. api.previewer.save(); + } event.preventDefault(); }); closeBtn.keydown( function( event ) { - if ( 9 === event.which ) // tab + if ( 9 === event.which ) { // Tab. return; - if ( 13 === event.which ) // enter + } + if ( 13 === event.which ) { // Enter. this.click(); + } event.preventDefault(); }); @@ -5939,7 +6754,7 @@ * since customize-loader.js will also use one. So autosave restorations are disabled * when customize-loader.js is used. */ - if ( isInsideIframe && isCleanState() ) { + if ( isInsideIframe || isCleanState() ) { clearedToClose.resolve(); } else if ( confirm( api.l10n.saveAlert ) ) { @@ -6221,6 +7036,88 @@ }); })(); + /** + * Publish settings section and controls. + */ + api.control( 'changeset_status', 'changeset_scheduled_date', function( statusControl, dateControl ) { + $.when( statusControl.deferred.embedded, dateControl.deferred.embedded ).done( function() { + var radioNodes, statusElement, toggleDateControl, publishWhenTime, pollInterval, updateTimeArrivedPoller, timeArrivedPollingInterval = 1000; + + radioNodes = statusControl.container.find( 'input[type=radio][name]' ); + statusElement = new api.Element( radioNodes ); + statusControl.elements.push( statusElement ); + + statusElement.sync( api.state( 'selectedChangesetStatus' ) ); + statusElement.set( api.state( 'selectedChangesetStatus' ).get() ); + + dateControl.notifications.alt = true; + dateControl.deferred.embedded.done( function() { + api.state( 'selectedChangesetDate' ).sync( dateControl.setting ); + api.state( 'selectedChangesetDate' ).set( dateControl.setting() ); + } ); + + publishWhenTime = function() { + var publishSettingsSection; + + api.state( 'selectedChangesetStatus' ).set( 'publish' ); + publishSettingsSection = api.section( 'publish_settings' ); + if ( publishSettingsSection ) { + publishSettingsSection.collapse(); + } + api.previewer.save(); + }; + + // Start countdown for when the dateTime arrives, or clear interval when it is . + updateTimeArrivedPoller = function() { + var shouldPoll = ( + 'future' === api.state( 'changesetStatus' ).get() && + 'future' === api.state( 'selectedChangesetStatus' ).get() && + api.state( 'changesetDate' ).get() && + api.state( 'selectedChangesetDate' ).get() === api.state( 'changesetDate' ).get() && + api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ) >= 0 + ); + + if ( shouldPoll && ! pollInterval ) { + pollInterval = setInterval( function() { + var remainingTime = api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ); + api.state( 'remainingTimeToPublish' ).set( remainingTime ); + if ( remainingTime <= 0 ) { + clearInterval( pollInterval ); + pollInterval = 0; + publishWhenTime(); + } + }, timeArrivedPollingInterval ); + } else if ( ! shouldPoll && pollInterval ) { + clearInterval( pollInterval ); + pollInterval = 0; + } + }; + + api.state( 'changesetDate' ).bind( updateTimeArrivedPoller ); + api.state( 'selectedChangesetDate' ).bind( updateTimeArrivedPoller ); + api.state( 'changesetStatus' ).bind( updateTimeArrivedPoller ); + api.state( 'selectedChangesetStatus' ).bind( updateTimeArrivedPoller ); + updateTimeArrivedPoller(); + + // Ensure dateControl only appears when selected status is future. + dateControl.active.validate = function() { + return 'future' === statusElement.get(); + }; + toggleDateControl = function( value ) { + dateControl.active.set( 'future' === value ); + }; + toggleDateControl( statusElement.get() ); + statusElement.bind( toggleDateControl ); + + // Show notification on date control when status is future but it isn't a future date. + api.state( 'saving' ).bind( function( isSaving ) { + if ( isSaving && 'future' === api.state( 'selectedChangesetStatus' ).get() ) { + dateControl.toggleFutureDateNotification( ! dateControl.isFutureDate() ); + } + } ); + } ); + } ); + // Toggle visibility of Header Video notice when active state change. api.control( 'header_video', function( headerVideoControl ) { headerVideoControl.deferred.embedded.done( function() { diff --git a/src/wp-includes/class-wp-customize-control.php b/src/wp-includes/class-wp-customize-control.php index b9c83c7575..6a6069efe5 100644 --- a/src/wp-includes/class-wp-customize-control.php +++ b/src/wp-includes/class-wp-customize-control.php @@ -747,3 +747,8 @@ require_once( ABSPATH . WPINC . '/customize/class-wp-customize-nav-menu-auto-add * WP_Customize_New_Menu_Control class. */ require_once( ABSPATH . WPINC . '/customize/class-wp-customize-new-menu-control.php' ); + +/** + * WP_Customize_Date_Time_Control class. + */ +require_once( ABSPATH . WPINC . '/customize/class-wp-customize-date-time-control.php' ); diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index a37d431dc1..85e225138e 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -596,38 +596,10 @@ final class WP_Customize_Manager { * @since 4.9.0 */ public function establish_loaded_changeset() { - - /** - * Filters whether or not changeset branching is allowed. - * - * By default in core, when changeset branching is not allowed, changesets will operate - * linearly in that only one saved changeset will exist at a time (with a 'draft' or - * 'future' status). This makes the Customizer operate in a way that is similar to going to - * "edit" to one existing post: all users will be making changes to the same post, and autosave - * revisions will be made for that post. - * - * By contrast, when changeset branching is allowed, then the model is like users going - * to "add new" for a page and each user makes changes independently of each other since - * they are all operating on their own separate pages, each getting their own separate - * initial auto-drafts and then once initially saved, autosave revisions on top of that - * user's specific post. - * - * Since linear changesets are deemed to be more suitable for the majority of WordPress users, - * they are the default. For WordPress sites that have heavy site management in the Customizer - * by multiple users then branching changesets should be enabled by means of this filter. - * - * @since 4.9.0 - * - * @param bool $allow_branching Whether branching is allowed. If `false`, the default, - * then only one saved changeset exists at a time. - * @param WP_Customize_Manager $wp_customize Manager instance. - */ - $this->branching = apply_filters( 'customize_changeset_branching', $this->branching, $this ); - if ( empty( $this->_changeset_uuid ) ) { $changeset_uuid = null; - if ( ! $this->branching ) { + if ( ! $this->branching() ) { $unpublished_changeset_posts = $this->get_changeset_posts( array( 'post_status' => array_diff( get_post_stati(), array( 'auto-draft', 'publish', 'trash', 'inherit', 'private' ) ), 'exclude_restore_dismissed' => false, @@ -751,6 +723,58 @@ final class WP_Customize_Manager { return $this->settings_previewed; } + /** + * Gets whether data from a changeset's autosaved revision should be loaded if it exists. + * + * @since 4.9.0 + * @see WP_Customize_Manager::changeset_data() + * + * @return bool Is using autosaved changeset revision. + */ + public function autosaved() { + return $this->autosaved; + } + + /** + * Whether the changeset branching is allowed. + * + * @since 4.9.0 + * @see WP_Customize_Manager::establish_loaded_changeset() + * + * @return bool Is changeset branching. + */ + public function branching() { + + /** + * Filters whether or not changeset branching is allowed. + * + * By default in core, when changeset branching is not allowed, changesets will operate + * linearly in that only one saved changeset will exist at a time (with a 'draft' or + * 'future' status). This makes the Customizer operate in a way that is similar to going to + * "edit" to one existing post: all users will be making changes to the same post, and autosave + * revisions will be made for that post. + * + * By contrast, when changeset branching is allowed, then the model is like users going + * to "add new" for a page and each user makes changes independently of each other since + * they are all operating on their own separate pages, each getting their own separate + * initial auto-drafts and then once initially saved, autosave revisions on top of that + * user's specific post. + * + * Since linear changesets are deemed to be more suitable for the majority of WordPress users, + * they are the default. For WordPress sites that have heavy site management in the Customizer + * by multiple users then branching changesets should be enabled by means of this filter. + * + * @since 4.9.0 + * + * @param bool $allow_branching Whether branching is allowed. If `false`, the default, + * then only one saved changeset exists at a time. + * @param WP_Customize_Manager $wp_customize Manager instance. + */ + $this->branching = apply_filters( 'customize_changeset_branching', $this->branching, $this ); + + return $this->branching; + } + /** * Get the changeset UUID. * @@ -763,7 +787,7 @@ final class WP_Customize_Manager { */ public function changeset_uuid() { if ( empty( $this->_changeset_uuid ) ) { - 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. + $this->establish_loaded_changeset(); } return $this->_changeset_uuid; } @@ -980,6 +1004,30 @@ final class WP_Customize_Manager { return get_posts( $args ); } + /** + * Dismiss all of the current user's auto-drafts (other than the present one). + * + * @since 4.9.0 + * @return int The number of auto-drafts that were dismissed. + */ + protected function dismiss_user_auto_draft_changesets() { + $changeset_autodraft_posts = $this->get_changeset_posts( array( + 'post_status' => 'auto-draft', + 'exclude_restore_dismissed' => true, + 'posts_per_page' => -1, + ) ); + $dismissed = 0; + foreach ( $changeset_autodraft_posts as $autosave_autodraft_post ) { + if ( $autosave_autodraft_post->ID === $this->changeset_post_id() ) { + continue; + } + if ( update_post_meta( $autosave_autodraft_post->ID, '_customize_restore_dismissed', true ) ) { + $dismissed++; + } + } + return $dismissed; + } + /** * Get the changeset post id for the loaded changeset. * @@ -1050,7 +1098,7 @@ final class WP_Customize_Manager { if ( ! $changeset_post_id ) { $this->_changeset_data = array(); } else { - if ( $this->autosaved ) { + if ( $this->autosaved() ) { $autosave_post = wp_get_post_autosave( $changeset_post_id ); if ( $autosave_post ) { $data = $this->get_changeset_post_data( $autosave_post->ID ); @@ -1972,7 +2020,7 @@ final class WP_Customize_Manager { $settings = array( 'changeset' => array( 'uuid' => $this->changeset_uuid(), - 'autosaved' => $this->autosaved, + 'autosaved' => $this->autosaved(), ), 'timeouts' => array( 'selectiveRefresh' => 250, @@ -2345,28 +2393,24 @@ final class WP_Customize_Manager { } } else { $response = $r; + $changeset_post = get_post( $this->changeset_post_id() ); // Dismiss all other auto-draft changeset posts for this user (they serve like autosave revisions), as there should only be one. if ( $is_new_changeset ) { - $changeset_autodraft_posts = $this->get_changeset_posts( array( - 'post_status' => 'auto-draft', - 'exclude_restore_dismissed' => true, - 'posts_per_page' => -1, - ) ); - foreach ( $changeset_autodraft_posts as $autosave_autodraft_post ) { - if ( $autosave_autodraft_post->ID !== $this->changeset_post_id() ) { - update_post_meta( $autosave_autodraft_post->ID, '_customize_restore_dismissed', true ); - } - } + $this->dismiss_user_auto_draft_changesets(); } // Note that if the changeset status was publish, then it will get set to trash if revisions are not supported. - $response['changeset_status'] = get_post_status( $this->changeset_post_id() ); + $response['changeset_status'] = $changeset_post->post_status; if ( $is_publish && 'trash' === $response['changeset_status'] ) { $response['changeset_status'] = 'publish'; } - if ( 'publish' === $response['changeset_status'] ) { + if ( 'future' === $response['changeset_status'] ) { + $response['changeset_date'] = $changeset_post->post_date; + } + + if ( 'publish' === $response['changeset_status'] || 'trash' === $response['changeset_status'] ) { $response['next_changeset_uuid'] = wp_generate_uuid4(); } } @@ -2434,7 +2478,13 @@ final class WP_Customize_Manager { if ( $changeset_post_id ) { $existing_status = get_post_status( $changeset_post_id ); if ( 'publish' === $existing_status || 'trash' === $existing_status ) { - return new WP_Error( 'changeset_already_published' ); + return new WP_Error( + 'changeset_already_published', + __( 'The previous set of changes already been published. Please try saving your current set of changes again.' ), + array( + 'next_changeset_uuid' => wp_generate_uuid4(), + ) + ); } $existing_changeset_data = $this->get_changeset_post_data( $changeset_post_id ); @@ -2453,7 +2503,7 @@ final class WP_Customize_Manager { if ( $args['date_gmt'] ) { $is_future_dated = ( mysql2date( 'U', $args['date_gmt'], false ) > mysql2date( 'U', $now, false ) ); if ( ! $is_future_dated ) { - return new WP_Error( 'not_future_date' ); // Only future dates are allowed. + return new WP_Error( 'not_future_date', __( 'You must supply a future date to schedule.' ) ); // Only future dates are allowed. } if ( ! $this->is_theme_active() && ( 'future' === $args['status'] || $is_future_dated ) ) { @@ -2468,7 +2518,7 @@ final class WP_Customize_Manager { // Fail if the new status is future but the existing post's date is not in the future. $changeset_post = get_post( $changeset_post_id ); if ( mysql2date( 'U', $changeset_post->post_date_gmt, false ) <= mysql2date( 'U', $now, false ) ) { - return new WP_Error( 'not_future_date' ); + return new WP_Error( 'not_future_date', __( 'You must supply a future date to schedule.' ) ); } } @@ -3056,24 +3106,11 @@ final class WP_Customize_Manager { $changeset_post_id = $this->changeset_post_id(); if ( empty( $changeset_post_id ) || 'auto-draft' === get_post_status( $changeset_post_id ) ) { - $changeset_autodraft_posts = $this->get_changeset_posts( array( - 'post_status' => 'auto-draft', - 'exclude_restore_dismissed' => true, - 'posts_per_page' => -1, - ) ); - $dismissed = 0; - foreach ( $changeset_autodraft_posts as $autosave_autodraft_post ) { - if ( $autosave_autodraft_post->ID === $changeset_post_id ) { - continue; - } - if ( update_post_meta( $autosave_autodraft_post->ID, '_customize_restore_dismissed', true ) ) { - $dismissed++; - } - } + $dismissed = $this->dismiss_user_auto_draft_changesets(); if ( $dismissed > 0 ) { wp_send_json_success( 'auto_draft_dismissed' ); } else { - wp_send_json_error( 'no_autosave_to_delete', 404 ); + wp_send_json_error( 'no_auto_draft_to_delete', 404 ); } } else { $revision = wp_get_post_autosave( $changeset_post_id ); @@ -3089,7 +3126,7 @@ final class WP_Customize_Manager { wp_send_json_success( 'autosave_revision_deleted' ); } } else { - wp_send_json_error( 'no_autosave_to_delete', 404 ); + wp_send_json_error( 'no_autosave_revision_to_delete', 404 ); } } wp_send_json_error( 'unknown_error', 500 ); @@ -3516,6 +3553,21 @@ final class WP_Customize_Manager { <# } ); #> + changeset_post_id(); - if ( ! $this->saved_starter_content_changeset && ! $this->autosaved ) { + if ( ! $this->saved_starter_content_changeset && ! $this->autosaved() ) { if ( $changeset_post_id ) { $autosave_revision_post = wp_get_post_autosave( $changeset_post_id ); } else { @@ -3893,15 +3945,25 @@ final class WP_Customize_Manager { } // Prepare Customizer settings to pass to JavaScript. + $changeset_post = null; + if ( $changeset_post_id ) { + $changeset_post = get_post( $changeset_post_id ); + } + $settings = array( 'changeset' => array( 'uuid' => $this->changeset_uuid(), - 'branching' => $this->branching, - 'autosaved' => $this->autosaved, + 'branching' => $this->branching(), + 'autosaved' => $this->autosaved(), 'hasAutosaveRevision' => ! empty( $autosave_revision_post ), 'latestAutoDraftUuid' => $autosave_autodraft_post ? $autosave_autodraft_post->post_name : null, - 'status' => $changeset_post_id ? get_post_status( $changeset_post_id ) : '', + 'status' => $changeset_post ? $changeset_post->post_status : '', + 'currentUserCanPublish' => current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ), + 'publishDate' => $changeset_post ? $changeset_post->post_date : '', // @todo Only if future status? Rename to just date? ), + 'initialServerDate' => current_time( 'mysql', false ), + 'initialServerTimestamp' => floor( microtime( true ) * 1000 ), + 'initialClientTimestamp' => -1, // To be set with JS below. 'timeouts' => array( 'windowRefresh' => 250, 'changesetAutoSave' => AUTOSAVE_INTERVAL * 1000, @@ -3957,6 +4019,7 @@ final class WP_Customize_Manager { ?> + +