diff --git a/editor/actions.js b/editor/actions.js index b607097f2c2bf0..e4d0fa0b241f79 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -110,6 +110,29 @@ export function mergeBlocks( blockA, blockB ) { }; } +/** + * Returns an action object used in signalling that the post should autosave. + * + * @return {Object} Action object + */ +export function autosave() { + return { + type: 'AUTOSAVE', + }; +} + +/** + * Returns an action object used in signalling that the post should be queued + * for autosave after a delay. + * + * @return {Object} Action object + */ +export function queueAutosave() { + return { + type: 'QUEUE_AUTOSAVE', + }; +} + /** * Returns an action object used in signalling that the blocks * corresponding to the specified UID set are to be removed. diff --git a/editor/effects.js b/editor/effects.js index 7e6df9e024ae79..5b665b13a19f5a 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 } from 'lodash'; +import { get, uniqueId, debounce } from 'lodash'; /** * WordPress dependencies @@ -14,12 +14,25 @@ import { __ } from 'i18n'; * Internal dependencies */ import { getGutenbergURL, getWPAdminURL } from './utils/url'; -import { focusBlock, replaceBlocks, createSuccessNotice, createErrorNotice } from './actions'; +import { + focusBlock, + replaceBlocks, + createSuccessNotice, + createErrorNotice, + autosave, + queueAutosave, + savePost, + editPost, +} from './actions'; import { getCurrentPost, getCurrentPostType, getBlocks, getPostEdits, + isCurrentPostPublished, + isEditedPostDirty, + isEditedPostNew, + isEditedPostSaveable, } from './selectors'; export default { @@ -207,4 +220,38 @@ export default { ] ) ); }, + AUTOSAVE( action, store ) { + const { getState, dispatch } = store; + const state = getState(); + if ( ! isEditedPostSaveable( state ) || ! isEditedPostDirty( state ) ) { + return; + } + + if ( isCurrentPostPublished( state ) ) { + // TODO: Publish autosave. + // - Autosaves are created as revisions for published posts, but + // the necessary REST API behavior does not yet exist + // - May need to check for whether the status of the edited post + // has changed from the saved copy (i.e. published -> pending) + return; + } + + // Change status from auto-draft to draft + if ( isEditedPostNew( state ) ) { + dispatch( editPost( { status: 'draft' } ) ); + } + + dispatch( savePost() ); + }, + QUEUE_AUTOSAVE: debounce( ( action, store ) => { + store.dispatch( autosave() ); + }, 10000 ), + UPDATE_BLOCK_ATTRIBUTES: () => queueAutosave(), + INSERT_BLOCKS: () => queueAutosave(), + MOVE_BLOCKS_DOWN: () => queueAutosave(), + MOVE_BLOCKS_UP: () => queueAutosave(), + REPLACE_BLOCKS: () => queueAutosave(), + REMOVE_BLOCKS: () => queueAutosave(), + EDIT_POST: () => queueAutosave(), + MARK_DIRTY: () => queueAutosave(), }; diff --git a/editor/header/tools/publish-button.js b/editor/header/tools/publish-button.js index dff17dc9b04745..78a8fbcaf7fdc0 100644 --- a/editor/header/tools/publish-button.js +++ b/editor/header/tools/publish-button.js @@ -16,7 +16,7 @@ import { Button } from 'components'; import { editPost, savePost } from '../../actions'; import { isSavingPost, - isEditedPostPublished, + isCurrentPostPublished, isEditedPostBeingScheduled, getEditedPostVisibility, isEditedPostSaveable, @@ -75,7 +75,7 @@ function PublishButton( { export default connect( ( state ) => ( { isSaving: isSavingPost( state ), - isPublished: isEditedPostPublished( state ), + isPublished: isCurrentPostPublished( state ), isBeingScheduled: isEditedPostBeingScheduled( state ), visibility: getEditedPostVisibility( state ), isSaveable: isEditedPostSaveable( state ), diff --git a/editor/selectors.js b/editor/selectors.js index 9223d4a695f1e0..84cb2124bb7bb4 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -184,12 +184,12 @@ export function getEditedPostVisibility( state ) { } /** - * Return true if the post being edited has already been published. + * Return true if the current post has already been published. * * @param {Object} state Global application state * @return {Boolean} Whether the post has been published */ -export function isEditedPostPublished( state ) { +export function isCurrentPostPublished( state ) { const post = getCurrentPost( state ); return [ 'publish', 'private' ].indexOf( post.status ) !== -1 || diff --git a/editor/sidebar/post-status/index.js b/editor/sidebar/post-status/index.js index d91ba764a57199..7b4a9e18c9a45d 100644 --- a/editor/sidebar/post-status/index.js +++ b/editor/sidebar/post-status/index.js @@ -20,7 +20,7 @@ import PostSticky from '../post-sticky'; import { getEditedPostAttribute, getSuggestedPostFormat, - isEditedPostPublished, + isCurrentPostPublished, } from '../../selectors'; import { editPost } from '../../actions'; @@ -74,7 +74,7 @@ export default connect( ( state ) => ( { status: getEditedPostAttribute( state, 'status' ), suggestedFormat: getSuggestedPostFormat( state ), - isPublished: isEditedPostPublished( state ), + isPublished: isCurrentPostPublished( state ), } ), ( dispatch ) => { return { diff --git a/editor/test/effects.js b/editor/test/effects.js index 62eecdeabf9814..ec2cef6215aee4 100644 --- a/editor/test/effects.js +++ b/editor/test/effects.js @@ -11,12 +11,17 @@ import { getBlockTypes, unregisterBlockType, registerBlockType, createBlock } fr /** * Internal dependencies */ -import { mergeBlocks, focusBlock, replaceBlocks } from '../actions'; +import { mergeBlocks, focusBlock, replaceBlocks, editPost, savePost } from '../actions'; import effects from '../effects'; +import * as selectors from '../selectors'; + +jest.mock( '../selectors' ); describe( 'effects', () => { const defaultBlockSettings = { save: noop }; + beforeEach( () => jest.resetAllMocks() ); + describe( '.MERGE_BLOCKS', () => { const handler = effects.MERGE_BLOCKS; @@ -145,4 +150,63 @@ describe( 'effects', () => { } ] ) ); } ); } ); + + describe( '.AUTOSAVE', () => { + const handler = effects.AUTOSAVE; + const dispatch = jest.fn(); + const store = { getState: () => {}, dispatch }; + + it( 'should do nothing for unsaveable', () => { + selectors.isEditedPostSaveable.mockReturnValue( false ); + selectors.isEditedPostDirty.mockReturnValue( true ); + selectors.isCurrentPostPublished.mockReturnValue( false ); + selectors.isEditedPostNew.mockReturnValue( true ); + + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should do nothing for clean', () => { + selectors.isEditedPostSaveable.mockReturnValue( true ); + selectors.isEditedPostDirty.mockReturnValue( false ); + selectors.isCurrentPostPublished.mockReturnValue( false ); + selectors.isEditedPostNew.mockReturnValue( true ); + + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should return autosave action for saveable, dirty, published post', () => { + selectors.isEditedPostSaveable.mockReturnValue( true ); + selectors.isEditedPostDirty.mockReturnValue( true ); + selectors.isCurrentPostPublished.mockReturnValue( true ); + selectors.isEditedPostNew.mockReturnValue( true ); + + // TODO: Publish autosave + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should set auto-draft to draft before save', () => { + selectors.isEditedPostSaveable.mockReturnValue( true ); + selectors.isEditedPostDirty.mockReturnValue( true ); + selectors.isCurrentPostPublished.mockReturnValue( false ); + selectors.isEditedPostNew.mockReturnValue( true ); + + handler( {}, store ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( editPost( { status: 'draft' } ) ); + expect( dispatch ).toHaveBeenCalledWith( savePost() ); + } ); + + it( 'should return update action for saveable, dirty draft', () => { + selectors.isEditedPostSaveable.mockReturnValue( true ); + selectors.isEditedPostDirty.mockReturnValue( true ); + selectors.isCurrentPostPublished.mockReturnValue( false ); + selectors.isEditedPostNew.mockReturnValue( false ); + + handler( {}, store ); + + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( savePost() ); + } ); + } ); } ); diff --git a/editor/test/selectors.js b/editor/test/selectors.js index e5471adc18ab37..f266367b35e09f 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -22,7 +22,7 @@ import { getDocumentTitle, getEditedPostExcerpt, getEditedPostVisibility, - isEditedPostPublished, + isCurrentPostPublished, isEditedPostPublishable, isEditedPostSaveable, isEditedPostBeingScheduled, @@ -490,7 +490,7 @@ describe( 'selectors', () => { } ); } ); - describe( 'isEditedPostPublished', () => { + describe( 'isCurrentPostPublished', () => { it( 'should return true for public posts', () => { const state = { currentPost: { @@ -498,7 +498,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostPublished( state ) ).toBe( true ); + expect( isCurrentPostPublished( state ) ).toBe( true ); } ); it( 'should return true for private posts', () => { @@ -508,7 +508,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostPublished( state ) ).toBe( true ); + expect( isCurrentPostPublished( state ) ).toBe( true ); } ); it( 'should return false for draft posts', () => { @@ -518,7 +518,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostPublished( state ) ).toBe( false ); + expect( isCurrentPostPublished( state ) ).toBe( false ); } ); it( 'should return true for old scheduled posts', () => { @@ -529,7 +529,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostPublished( state ) ).toBe( true ); + expect( isCurrentPostPublished( state ) ).toBe( true ); } ); } );