diff --git a/components/button/index.js b/components/button/index.js index 079038a4297bb..98a076fc3d8d7 100644 --- a/components/button/index.js +++ b/components/button/index.js @@ -37,6 +37,7 @@ class Button extends Component { isLarge, isSmall, isToggled, + isBusy, className, disabled, ...additionalProps @@ -47,6 +48,7 @@ class Button extends Component { 'button-large': isLarge, 'button-small': isSmall, 'is-toggled': isToggled, + 'is-busy': isBusy, } ); const tag = href !== undefined && ! disabled ? 'a' : 'button'; diff --git a/components/button/style.scss b/components/button/style.scss index eb986809e3a6a..821e942699db0 100644 --- a/components/button/style.scss +++ b/components/button/style.scss @@ -21,4 +21,24 @@ &:focus { @include button-style__focus-active; } + + &.is-busy, + &.is-busy[disabled] { + animation: components-button__busy-animation 2500ms infinite linear; + background-size: 100px 100% !important; + background-image: linear-gradient( -45deg, $light-gray-500 28%, $white 28%, $white 72%, $light-gray-500 72%) !important; + opacity: 1; + } + + &.button-primary.is-busy, + &.button-primary.is-busy[disabled] { + color: $white !important; + background-size: 100px 100% !important; + background-image: linear-gradient( -45deg, $blue-medium-500 28%, $blue-dark-900 28%, $blue-dark-900 72%, $blue-medium-500 72%) !important; + border-color: $blue-dark-900 !important; + } +} + +@keyframes components-button__busy-animation { + 0% { background-position: 200px 0; } } diff --git a/editor/components/post-publish-button/label.js b/editor/components/post-publish-button/label.js index 17f3bd861f9c3..33a0c4f6fa964 100644 --- a/editor/components/post-publish-button/label.js +++ b/editor/components/post-publish-button/label.js @@ -17,15 +17,25 @@ import './style.scss'; import { isCurrentPostPublished, isEditedPostBeingScheduled, + isSavingPost, + isPublishingPost, } from '../../selectors'; export function PublishButtonLabel( { isPublished, isBeingScheduled, + isSaving, + isPublishing, user, } ) { const isContributor = user.data && ! user.data.capabilities.publish_posts; + if ( isPublishing ) { + return __( 'Publishing…' ); + } else if ( isSaving ) { + return __( 'Updating…' ); + } + if ( isContributor ) { return __( 'Submit for Review' ); } else if ( isPublished ) { @@ -41,6 +51,9 @@ const applyConnect = connect( ( state ) => ( { isPublished: isCurrentPostPublished( state ), isBeingScheduled: isEditedPostBeingScheduled( state ), + isSaving: isSavingPost( state ), + // Need a selector + isPublishing: isPublishingPost( state ), } ) ); diff --git a/editor/components/post-publish-button/test/label.js b/editor/components/post-publish-button/test/label.js index eadaf36dc6c6f..1b11633f71de0 100644 --- a/editor/components/post-publish-button/test/label.js +++ b/editor/components/post-publish-button/test/label.js @@ -26,6 +26,16 @@ describe( 'PublishButtonLabel', () => { }, } ); + it( 'should show publishing if publishing in progress', () => { + const label = PublishButtonLabel( { user, isPublishing: true } ); + expect( label ).toBe( 'Publishing…' ); + } ); + + it( 'should show updating if saving in progress', () => { + const label = PublishButtonLabel( { user, isSaving: true } ); + expect( label ).toBe( 'Updating…' ); + } ); + it( 'should show publish if user unknown', () => { const label = PublishButtonLabel( { user: {} } ); expect( label ).toBe( 'Publish' ); diff --git a/editor/components/post-publish-with-dropdown/index.js b/editor/components/post-publish-with-dropdown/index.js index b5f5c1832f4c9..c112234a9371c 100644 --- a/editor/components/post-publish-with-dropdown/index.js +++ b/editor/components/post-publish-with-dropdown/index.js @@ -18,9 +18,10 @@ import { isSavingPost, isEditedPostSaveable, isEditedPostPublishable, + isCurrentPostPublished, } from '../../selectors'; -function PostPublishWithDropdown( { isSaving, isPublishable, isSaveable } ) { +function PostPublishWithDropdown( { isSaving, isPublishable, isSaveable, isPublished } ) { const isButtonEnabled = ! isSaving && isPublishable && isSaveable; return ( @@ -34,6 +35,7 @@ function PostPublishWithDropdown( { isSaving, isPublishable, isSaveable } ) { onClick={ onToggle } aria-expanded={ isOpen } disabled={ ! isButtonEnabled } + isBusy={ isSaving && isPublished } > @@ -49,5 +51,6 @@ export default connect( isSaving: isSavingPost( state ), isSaveable: isEditedPostSaveable( state ), isPublishable: isEditedPostPublishable( state ), + isPublished: isCurrentPostPublished( state ), } ), )( PostPublishWithDropdown ); diff --git a/editor/components/post-publish-with-dropdown/style.scss b/editor/components/post-publish-with-dropdown/style.scss index 9a1c1efc332f0..d9fb32e1b110e 100644 --- a/editor/components/post-publish-with-dropdown/style.scss +++ b/editor/components/post-publish-with-dropdown/style.scss @@ -9,4 +9,8 @@ .wp-core-ui .editor-post-publish-with-dropdown__button.button-primary { display: inline-flex; align-items: center; + + &.is-busy .dashicon { + display: none; + } } diff --git a/editor/effects.js b/editor/effects.js index 6a9aeddbda673..942fb5c5675bd 100644 --- a/editor/effects.js +++ b/editor/effects.js @@ -2,7 +2,7 @@ * External dependencies */ import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; -import { get, uniqueId, map, filter, some, castArray } from 'lodash'; +import { get, map, filter, some, castArray } from 'lodash'; /** * WordPress dependencies @@ -54,6 +54,7 @@ import { const SAVE_POST_NOTICE_ID = 'SAVE_POST_NOTICE_ID'; const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; const SAVE_REUSABLE_BLOCK_NOTICE_ID = 'SAVE_REUSABLE_BLOCK_NOTICE_ID'; +export const POST_UPDATE_TRANSACTION_ID = 'post-update'; export default { REQUEST_POST_UPDATE( action, store ) { @@ -66,12 +67,11 @@ export default { content: getEditedPostContent( state ), id: post.id, }; - const transactionId = uniqueId(); dispatch( { type: 'UPDATE_POST', edits: toSend, - optimist: { type: BEGIN, id: transactionId }, + optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, } ); dispatch( removeNotice( SAVE_POST_NOTICE_ID ) ); const Model = wp.api.getPostTypeModel( getCurrentPostType( state ) ); @@ -84,7 +84,7 @@ export default { type: 'REQUEST_POST_UPDATE_SUCCESS', previousPost: post, post: newPost, - optimist: { type: COMMIT, id: transactionId }, + optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, } ); } ).fail( ( err ) => { dispatch( { @@ -95,7 +95,7 @@ export default { } ), post, edits, - optimist: { type: REVERT, id: transactionId }, + optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, } ); } ); }, diff --git a/editor/selectors.js b/editor/selectors.js index a3e231995f062..e631062dd9a30 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -11,6 +11,7 @@ import { keys, without, compact, + find, } from 'lodash'; import createSelector from 'rememo'; @@ -21,6 +22,11 @@ import { serialize, getBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { POST_UPDATE_TRANSACTION_ID } from './effects'; + /*** * Module constants */ @@ -1072,3 +1078,49 @@ export function isSavingReusableBlock( state, ref ) { export function getReusableBlocks( state ) { return Object.values( state.reusableBlocks.data ); } + +/** + * Returns state object prior to a specified optimist transaction ID, or `null` + * if the transaction corresponding to the given ID cannot be found. + * + * @param {Object} state Current global application state + * @param {Object} transactionId Optimist transaction ID + * @return {Object} Global application state prior to transaction + */ +export function getStateBeforeOptimisticTransaction( state, transactionId ) { + const transaction = find( state.optimist, ( entry ) => ( + entry.beforeState && + get( entry.action, [ 'optimist', 'id' ] ) === transactionId + ) ); + + return transaction ? transaction.beforeState : null; +} + +/** + * Returns true if the post is being published, or false otherwise + * + * @param {Object} state Global application state + * @return {Boolean} Whether post is being published + */ +export function isPublishingPost( state ) { + if ( ! isSavingPost( state ) ) { + return false; + } + + // Saving is optimistic, so assume that current post would be marked as + // published if publishing + if ( ! isCurrentPostPublished( state ) ) { + return false; + } + + // Use post update transaction ID to retrieve the state prior to the + // optimistic transaction + const stateBeforeRequest = getStateBeforeOptimisticTransaction( + state, + POST_UPDATE_TRANSACTION_ID + ); + + // Consider as publishing when current post prior to request was not + // considered published + return !! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest ); +} diff --git a/editor/test/selectors.js b/editor/test/selectors.js index 787a22cfb552e..96f2ffdda488f 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -77,7 +77,10 @@ import { getReusableBlock, isSavingReusableBlock, getReusableBlocks, + getStateBeforeOptimisticTransaction, + isPublishingPost, } from '../selectors'; +import { POST_UPDATE_TRANSACTION_ID } from '../effects'; describe( 'selectors', () => { beforeAll( () => { @@ -2187,4 +2190,175 @@ describe( 'selectors', () => { expect( reusableBlocks ).toEqual( [] ); } ); } ); + + describe( 'getStateBeforeOptimisticTransaction', () => { + it( 'should return null if no transaction can be found', () => { + const beforeState = getStateBeforeOptimisticTransaction( { + optimist: [], + }, 'foo' ); + + expect( beforeState ).toBe( null ); + } ); + + it( 'should return null if a transaction with ID can be found, but lacks before state', () => { + const beforeState = getStateBeforeOptimisticTransaction( { + optimist: [ + { + action: { + optimist: { + id: 'foo', + }, + }, + }, + ], + }, 'foo' ); + + expect( beforeState ).toBe( null ); + } ); + + it( 'should return the before state matching the given transaction id', () => { + const expectedBeforeState = {}; + const beforeState = getStateBeforeOptimisticTransaction( { + optimist: [ + { + beforeState: expectedBeforeState, + action: { + optimist: { + id: 'foo', + }, + }, + }, + ], + }, 'foo' ); + + expect( beforeState ).toBe( expectedBeforeState ); + } ); + } ); + + describe( 'isPublishingPost', () => { + it( 'should return false if the post is not being saved', () => { + const isPublishing = isPublishingPost( { + optimist: [], + saving: { + requesting: false, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'publish', + }, + } ); + + expect( isPublishing ).toBe( false ); + } ); + + it( 'should return false if the current post is not considered published', () => { + const isPublishing = isPublishingPost( { + optimist: [], + saving: { + requesting: true, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'draft', + }, + } ); + + expect( isPublishing ).toBe( false ); + } ); + + it( 'should return false if the optimistic transaction cannot be found', () => { + const isPublishing = isPublishingPost( { + optimist: [], + saving: { + requesting: true, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'publish', + }, + } ); + + expect( isPublishing ).toBe( false ); + } ); + + it( 'should return false if the current post prior to request was already published', () => { + const isPublishing = isPublishingPost( { + optimist: [ + { + beforeState: { + saving: { + requesting: false, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'publish', + }, + }, + action: { + optimist: { + id: POST_UPDATE_TRANSACTION_ID, + }, + }, + }, + ], + saving: { + requesting: true, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'publish', + }, + } ); + + expect( isPublishing ).toBe( false ); + } ); + + it( 'should return true if the current post prior to request was not published', () => { + const isPublishing = isPublishingPost( { + optimist: [ + { + beforeState: { + saving: { + requesting: false, + }, + editor: { + edits: { + status: 'publish', + }, + }, + currentPost: { + status: 'draft', + }, + }, + action: { + optimist: { + id: POST_UPDATE_TRANSACTION_ID, + }, + }, + }, + ], + saving: { + requesting: true, + }, + editor: { + edits: {}, + }, + currentPost: { + status: 'publish', + }, + } ); + + expect( isPublishing ).toBe( true ); + } ); + } ); } );