diff --git a/editor/components/autosave-monitor/index.js b/editor/components/autosave-monitor/index.js index 462230dac7da86..28f393c7d150cf 100644 --- a/editor/components/autosave-monitor/index.js +++ b/editor/components/autosave-monitor/index.js @@ -6,10 +6,13 @@ import { withSelect, withDispatch } from '@wordpress/data'; export class AutosaveMonitor extends Component { componentDidUpdate( prevProps ) { - const { isDirty, isSaveable } = this.props; - if ( prevProps.isDirty !== isDirty || - prevProps.isSaveable !== isSaveable ) { - this.toggleTimer( isDirty && isSaveable ); + const { isDirty, isAutosaveable } = this.props; + + if ( + prevProps.isDirty !== isDirty || + prevProps.isAutosaveable !== isAutosaveable + ) { + this.toggleTimer( isDirty && isAutosaveable ); } } @@ -19,11 +22,11 @@ export class AutosaveMonitor extends Component { toggleTimer( isPendingSave ) { clearTimeout( this.pendingSave ); - + const { autosaveInterval } = this.props; if ( isPendingSave ) { this.pendingSave = setTimeout( () => this.props.autosave(), - 10000 + autosaveInterval * 1000 ); } } @@ -35,10 +38,16 @@ export class AutosaveMonitor extends Component { export default compose( [ withSelect( ( select ) => { - const { isEditedPostDirty, isEditedPostSaveable } = select( 'core/editor' ); + const { + isEditedPostDirty, + isEditedPostAutosaveable, + getEditorSettings, + } = select( 'core/editor' ); + const { autosaveInterval } = getEditorSettings(); return { isDirty: isEditedPostDirty(), - isSaveable: isEditedPostSaveable(), + isAutosaveable: isEditedPostAutosaveable(), + autosaveInterval, }; } ), withDispatch( ( dispatch ) => ( { diff --git a/editor/components/autosave-monitor/test/index.js b/editor/components/autosave-monitor/test/index.js index 14c843f4ce5d25..a97f25c6c07b6a 100644 --- a/editor/components/autosave-monitor/test/index.js +++ b/editor/components/autosave-monitor/test/index.js @@ -23,13 +23,19 @@ describe( 'AutosaveMonitor', () => { describe( '#componentDidUpdate()', () => { it( 'should start autosave timer when having become dirty and saveable', () => { - wrapper.setProps( { isDirty: true, isSaveable: true } ); + wrapper.setProps( { isDirty: true, isAutosaveable: true } ); expect( toggleTimer ).toHaveBeenCalledWith( true ); } ); - it( 'should stop autosave timer when having become dirty but not saveable', () => { - wrapper.setProps( { isDirty: true, isSaveable: false } ); + it( 'should stop autosave timer when the autosave is up to date', () => { + wrapper.setProps( { isDirty: true, isAutosaveable: false } ); + + expect( toggleTimer ).toHaveBeenCalledWith( false ); + } ); + + it( 'should stop autosave timer when having become dirty but not autosaveable', () => { + wrapper.setProps( { isDirty: true, isAutosaveable: false } ); expect( toggleTimer ).toHaveBeenCalledWith( false ); } ); @@ -42,10 +48,10 @@ describe( 'AutosaveMonitor', () => { expect( toggleTimer ).toHaveBeenCalledWith( false ); } ); - it( 'should stop autosave timer when having become not saveable', () => { + it( 'should stop autosave timer when having become not autosaveable', () => { wrapper.setProps( { isDirty: true } ); toggleTimer.mockClear(); - wrapper.setProps( { isSaveable: false } ); + wrapper.setProps( { isAutosaveable: false } ); expect( toggleTimer ).toHaveBeenCalledWith( false ); } ); diff --git a/editor/components/editor-global-keyboard-shortcuts/index.js b/editor/components/editor-global-keyboard-shortcuts/index.js index 63947cc6921840..a6d7da92fbab9a 100644 --- a/editor/components/editor-global-keyboard-shortcuts/index.js +++ b/editor/components/editor-global-keyboard-shortcuts/index.js @@ -99,6 +99,7 @@ export default compose( [ getMultiSelectedBlockUids, hasMultiSelection, getEditorSettings, + isEditedPostDirty, } = select( 'core/editor' ); const { templateLock } = getEditorSettings(); @@ -107,25 +108,38 @@ export default compose( [ multiSelectedBlockUids: getMultiSelectedBlockUids(), hasMultiSelection: hasMultiSelection(), isLocked: !! templateLock, + isDirty: isEditedPostDirty(), }; } ), - withDispatch( ( dispatch ) => { + withDispatch( ( dispatch, ownProps ) => { const { clearSelectedBlock, multiSelect, redo, undo, removeBlocks, - autosave, + savePost, } = dispatch( 'core/editor' ); return { + onSave() { + // TODO: This should be handled in the `savePost` effect in + // considering `isSaveable`. See note on `isEditedPostSaveable` + // selector about dirtiness and meta-boxes. When removing, also + // remember to remove `isDirty` prop passing from `withSelect`. + // + // See: `isEditedPostSaveable` + if ( ! ownProps.isDirty ) { + return; + } + + savePost(); + }, clearSelectedBlock, onMultiSelect: multiSelect, onRedo: redo, onUndo: undo, onRemove: removeBlocks, - onSave: autosave, }; } ), ] )( EditorGlobalKeyboardShortcuts ); diff --git a/editor/components/post-publish-button/index.js b/editor/components/post-publish-button/index.js index e3f6ec84ccb836..7bf9edec280458 100644 --- a/editor/components/post-publish-button/index.js +++ b/editor/components/post-publish-button/index.js @@ -27,7 +27,7 @@ export function PostPublishButton( { onSubmit = noop, forceIsSaving, } ) { - const isButtonEnabled = ! isSaving && isPublishable && isSaveable; + const isButtonEnabled = isPublishable && isSaveable; let publishStatus; if ( ! hasPublishAction ) { diff --git a/editor/components/post-saved-state/index.js b/editor/components/post-saved-state/index.js index f5355622690387..047eb643d0b403 100644 --- a/editor/components/post-saved-state/index.js +++ b/editor/components/post-saved-state/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -38,13 +43,20 @@ export class PostSavedState extends Component { } render() { - const { isNew, isPublished, isDirty, isSaving, isSaveable, onSave } = this.props; + const { isNew, isPublished, isDirty, isSaving, isSaveable, onSave, isAutosaving } = this.props; const { forceSavedMessage } = this.state; if ( isSaving ) { + // TODO: Classes generation should be common across all return + // paths of this function, including proper naming convention for + // the "Save Draft" button. + const classes = classnames( 'editor-post-saved-state', 'is-saving', { + 'is-autosaving': isAutosaving, + } ); + return ( - + - { __( 'Saving' ) } + { isAutosaving ? __( 'Autosaving' ) : __( 'Saving' ) } ); } @@ -88,6 +100,7 @@ export default compose( [ isSavingPost, isEditedPostSaveable, getCurrentPost, + isAutosavingPost, } = select( 'core/editor' ); return { post: getCurrentPost(), @@ -96,6 +109,7 @@ export default compose( [ isDirty: forceIsDirty || isEditedPostDirty(), isSaving: forceIsSaving || isSavingPost(), isSaveable: isEditedPostSaveable(), + isAutosaving: isAutosavingPost(), }; } ), withDispatch( ( dispatch ) => ( { diff --git a/editor/store/actions.js b/editor/store/actions.js index 9028f989452dda..a0250399e4d216 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -42,6 +42,21 @@ export function resetPost( post ) { }; } +/** + * Returns an action object used in signalling that the latest autosave of the + * post has been received, by initialization or autosave. + * + * @param {Object} post Autosave post object. + * + * @return {Object} Action object. + */ +export function resetAutosave( post ) { + return { + type: 'RESET_AUTOSAVE', + post, + }; +} + /** * Returns an action object used to setup the editor state when first opening an editor. * @@ -351,9 +366,18 @@ export function editPost( edits ) { }; } -export function savePost() { +/** + * Returns an action object to save the post. + * + * @param {Object} options Options for the save. + * @param {boolean} options.autosave Perform an autosave if true. + * + * @return {Object} Action object. + */ +export function savePost( options ) { return { type: 'REQUEST_POST_UPDATE', + options, }; } @@ -392,9 +416,7 @@ export function mergeBlocks( blockAUid, blockBUid ) { * @return {Object} Action object. */ export function autosave() { - return { - type: 'AUTOSAVE', - }; + return savePost( { autosave: true } ); } /** diff --git a/editor/store/effects.js b/editor/store/effects.js index 3570b8fedf64d4..b67c697b3ba271 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -27,6 +27,7 @@ import { speak } from '@wordpress/a11y'; import { getPostEditUrl, getWPAdminURL } from '../utils/url'; import { setupEditorState, + resetAutosave, resetPost, receiveBlocks, receiveSharedBlocks, @@ -35,7 +36,6 @@ import { createSuccessNotice, createErrorNotice, removeNotice, - savePost, saveSharedBlock, insertBlock, removeBlocks, @@ -49,9 +49,7 @@ import { getCurrentPostType, getEditedPostContent, getPostEdits, - isCurrentPostPublished, - isEditedPostDirty, - isEditedPostNew, + isEditedPostAutosaveable, isEditedPostSaveable, getBlock, getBlockCount, @@ -95,6 +93,14 @@ export default { REQUEST_POST_UPDATE( action, store ) { const { dispatch, getState } = store; const state = getState(); + const isAutosave = action.options && action.options.autosave; + + // Prevent save if not saveable. + const isSaveable = isAutosave ? isEditedPostAutosaveable : isEditedPostSaveable; + if ( ! isSaveable( state ) ) { + return; + } + const post = getCurrentPost( state ); const edits = getPostEdits( state ); const toSend = { @@ -102,41 +108,70 @@ export default { content: getEditedPostContent( state ), id: post.id, }; + const basePath = wp.api.getPostTypeRoute( getCurrentPostType( state ) ); dispatch( { - type: 'UPDATE_POST', - edits: toSend, + type: 'REQUEST_POST_UPDATE_START', optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, + isAutosave, } ); - dispatch( removeNotice( SAVE_POST_NOTICE_ID ) ); - const basePath = wp.api.getPostTypeRoute( getCurrentPostType( state ) ); - wp.apiRequest( { path: `/wp/v2/${ basePath }/${ post.id }`, method: 'PUT', data: toSend } ).then( + + let request; + if ( isAutosave ) { + toSend.parent = post.id; + + request = wp.apiRequest( { + path: `/wp/v2/${ basePath }/${ post.id }/autosaves`, + method: 'POST', + data: toSend, + } ); + } else { + dispatch( { + type: 'UPDATE_POST', + edits: toSend, + optimist: { id: POST_UPDATE_TRANSACTION_ID }, + } ); + + dispatch( removeNotice( SAVE_POST_NOTICE_ID ) ); + + request = wp.apiRequest( { + path: `/wp/v2/${ basePath }/${ post.id }`, + method: 'PUT', + data: toSend, + } ); + } + + request.then( ( newPost ) => { - dispatch( resetPost( newPost ) ); + const reset = isAutosave ? resetAutosave : resetPost; + dispatch( reset( newPost ) ); + dispatch( { type: 'REQUEST_POST_UPDATE_SUCCESS', previousPost: post, post: newPost, - edits: toSend, optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, + isAutosave, } ); }, - ( err ) => { + ( error ) => { + error = get( error, [ 'responseJSON' ], { + code: 'unknown_error', + message: __( 'An unknown error occurred.' ), + } ); + dispatch( { type: 'REQUEST_POST_UPDATE_FAILURE', - error: get( err, [ 'responseJSON' ], { - code: 'unknown_error', - message: __( 'An unknown error occurred.' ), - } ), + optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, post, edits, - optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, + error, } ); - } + }, ); }, REQUEST_POST_UPDATE_SUCCESS( action, store ) { - const { previousPost, post } = action; + const { previousPost, post, isAutosave } = action; const { dispatch } = store; const publishStatus = [ 'publish', 'private', 'future' ]; @@ -145,8 +180,9 @@ export default { let noticeMessage; let shouldShowLink = true; - if ( ! isPublished && ! willPublish ) { - // If saving a non published post, don't show any notice + + if ( isAutosave || ( ! isPublished && ! willPublish ) ) { + // If autosaving or saving a non-published post, don't show notice. noticeMessage = null; } else if ( isPublished && ! willPublish ) { // If undoing publish status, show specific notice @@ -302,28 +338,6 @@ export default { ] ) ); }, - AUTOSAVE( action, store ) { - const { getState, dispatch } = store; - const state = getState(); - if ( ! isEditedPostSaveable( state ) ) { - return; - } - - if ( ! isEditedPostNew( 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; - } - - dispatch( savePost() ); - }, SETUP_EDITOR( action, { getState } ) { const { post } = action; const state = getState(); diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 64d530c74116a8..38c3cf6d352a3f 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -866,11 +866,12 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { */ export function saving( state = {}, action ) { switch ( action.type ) { - case 'REQUEST_POST_UPDATE': + case 'REQUEST_POST_UPDATE_START': return { requesting: true, successful: false, error: null, + isAutosave: action.isAutosave, }; case 'REQUEST_POST_UPDATE_SUCCESS': @@ -1056,6 +1057,30 @@ export const blockListSettings = ( state = {}, action ) => { return state; }; +/** + * Reducer returning the most recent autosave. + * + * @param {Object} state The autosave object. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function autosave( state = null, action ) { + switch ( action.type ) { + case 'RESET_AUTOSAVE': + const { post } = action; + const [ title, excerpt, content ] = [ + 'title', + 'excerpt', + 'content', + ].map( ( field ) => getPostRawValue( post[ field ] ) ); + + return { title, excerpt, content }; + } + + return state; +} + export default optimist( combineReducers( { editor, currentPost, @@ -1070,5 +1095,6 @@ export default optimist( combineReducers( { notices, sharedBlocks, template, + autosave, settings, } ) ); diff --git a/editor/store/selectors.js b/editor/store/selectors.js index 4f192a358b3596..df933be3ca614e 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -273,6 +273,11 @@ export function isCurrentPostScheduled( state ) { export function isEditedPostPublishable( state ) { const post = getCurrentPost( state ); + // TODO: Post being publishable should be superset of condition of post + // being saveable. Currently this restriction is imposed at UI. + // + // See: (`isButtonEnabled` assigned by `isSaveable`) + return isEditedPostDirty( state ) || [ 'publish', 'private', 'future' ].indexOf( post.status ) === -1; } @@ -285,9 +290,23 @@ export function isEditedPostPublishable( state ) { * @return {boolean} Whether the post can be saved. */ export function isEditedPostSaveable( state ) { + if ( isSavingPost( state ) ) { + return false; + } + + // TODO: Post should not be saveable if not dirty. Cannot be added here at + // this time since posts where meta boxes are present can be saved even if + // the post is not dirty. Currently this restriction is imposed at UI, but + // should be moved here. + // + // See: `isEditedPostPublishable` (includes `isEditedPostDirty` condition) + // See: (`forceIsDirty` prop) + // See: (`forceIsDirty` prop) + // See: https://github.com/WordPress/gutenberg/pull/4184 + return ( !! getEditedPostAttribute( state, 'title' ) || - !! getEditedPostExcerpt( state ) || + !! getEditedPostAttribute( state, 'excerpt' ) || ! isEditedPostEmpty( state ) ); } @@ -307,6 +326,41 @@ export function isEditedPostEmpty( state ) { ); } +/** + * Returns true if the post can be autosaved, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the post can be autosaved. + */ +export function isEditedPostAutosaveable( state ) { + // A post must contain a title, an excerpt, or non-empty content to be valid for autosaving. + if ( ! isEditedPostSaveable( state ) ) { + return false; + } + + // If we don't already have an autosave, the post is autosaveable. + if ( ! hasAutosave( state ) ) { + return true; + } + + // If the title, excerpt or content has changed, the post is autosaveable. + return [ 'title', 'excerpt', 'content' ].some( ( field ) => ( + state.autosave[ field ] !== getEditedPostAttribute( state, field ) + ) ); +} + +/** + * Returns the true if there is an existing autosave, otherwise false. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether there is an existing autosave. + */ +export function hasAutosave( state ) { + return !! state.autosave; +} + /** * Return true if the post being edited is being scheduled. Preferring the * unsaved status values. @@ -348,9 +402,13 @@ export function getDocumentTitle( state ) { * @return {string} Raw post excerpt. */ export function getEditedPostExcerpt( state ) { - return state.editor.present.edits.excerpt === undefined ? - state.currentPost.excerpt : - state.editor.present.edits.excerpt; + deprecated( 'getEditedPostExcerpt', { + version: '3.1', + alternative: 'getEditedPostAttribute( state, \'excerpt\' )', + plugin: 'Gutenberg', + } ); + + return getEditedPostAttribute( state, 'excerpt' ); } /** @@ -1119,6 +1177,17 @@ export function didPostSaveRequestFail( state ) { return !! state.saving.error; } +/** + * Returns true if the post is autosaving, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the post is autosaving. + */ +export function isAutosavingPost( state ) { + return isSavingPost( state ) && state.saving.isAutosave; +} + /** * Returns a suggested post format for the current post, inferred only if there * is a single block within the post and it is of a type known to match a diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js index 6e5d8bc828a9f2..5972aa9476491f 100644 --- a/editor/store/test/actions.js +++ b/editor/store/test/actions.js @@ -30,7 +30,6 @@ import { savePost, trashPost, mergeBlocks, - autosave, redo, undo, removeBlocks, @@ -270,14 +269,6 @@ describe( 'actions', () => { } ); } ); - describe( 'autosave', () => { - it( 'should return AUTOSAVE action', () => { - expect( autosave() ).toEqual( { - type: 'AUTOSAVE', - } ); - } ); - } ); - describe( 'redo', () => { it( 'should return REDO action', () => { expect( redo() ).toEqual( { diff --git a/editor/store/test/effects.js b/editor/store/test/effects.js index b80b3893bc60c1..310916655a0b44 100644 --- a/editor/store/test/effects.js +++ b/editor/store/test/effects.js @@ -20,7 +20,6 @@ import { setupEditorState, mergeBlocks, replaceBlocks, - savePost, selectBlock, removeBlock, createErrorNotice, @@ -257,86 +256,6 @@ describe( 'effects', () => { } ); } ); - describe( '.AUTOSAVE', () => { - const handler = effects.AUTOSAVE; - const dispatch = jest.fn(); - const store = { getState: () => {}, dispatch }; - - beforeAll( () => { - selectors.isEditedPostSaveable = jest.spyOn( selectors, 'isEditedPostSaveable' ); - selectors.isEditedPostDirty = jest.spyOn( selectors, 'isEditedPostDirty' ); - selectors.isCurrentPostPublished = jest.spyOn( selectors, 'isCurrentPostPublished' ); - selectors.isEditedPostNew = jest.spyOn( selectors, 'isEditedPostNew' ); - } ); - - beforeEach( () => { - dispatch.mockReset(); - selectors.isEditedPostSaveable.mockReset(); - selectors.isEditedPostDirty.mockReset(); - selectors.isCurrentPostPublished.mockReset(); - selectors.isEditedPostNew.mockReset(); - } ); - - afterAll( () => { - selectors.isEditedPostSaveable.mockRestore(); - selectors.isEditedPostDirty.mockRestore(); - selectors.isCurrentPostPublished.mockRestore(); - selectors.isEditedPostNew.mockRestore(); - } ); - - 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( false ); - - expect( dispatch ).not.toHaveBeenCalled(); - } ); - - it( 'should return autosave action for clean, new, saveable post', () => { - selectors.isEditedPostSaveable.mockReturnValue( true ); - selectors.isEditedPostDirty.mockReturnValue( false ); - selectors.isCurrentPostPublished.mockReturnValue( false ); - selectors.isEditedPostNew.mockReturnValue( true ); - - handler( {}, store ); - - expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( savePost() ); - } ); - - 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 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() ); - } ); - } ); - describe( '.REQUEST_POST_UPDATE_SUCCESS', () => { const handler = effects.REQUEST_POST_UPDATE_SUCCESS; let replaceStateSpy; diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 542b904792d77b..74510861d3454f 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -36,6 +36,7 @@ import { sharedBlocks, template, blockListSettings, + autosave, } from '../reducer'; describe( 'state', () => { @@ -1670,7 +1671,7 @@ describe( 'state', () => { describe( 'saving()', () => { it( 'should update when a request is started', () => { const state = saving( null, { - type: 'REQUEST_POST_UPDATE', + type: 'REQUEST_POST_UPDATE_START', } ); expect( state ).toEqual( { requesting: true, @@ -2257,4 +2258,36 @@ describe( 'state', () => { expect( state ).toEqual( {} ); } ); } ); + + describe( 'autosave', () => { + it( 'returns null by default', () => { + const state = autosave( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'returns subset of received autosave post properties', () => { + const state = autosave( undefined, { + type: 'RESET_AUTOSAVE', + post: { + title: { + raw: 'The Title', + }, + content: { + raw: 'The Content', + }, + excerpt: { + raw: 'The Excerpt', + }, + status: 'draft', + }, + } ); + + expect( state ).toEqual( { + title: 'The Title', + content: 'The Content', + excerpt: 'The Excerpt', + } ); + } ); + } ); } ); diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 734195a03b7f4c..09fd8cc331d7b6 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { filter, property } from 'lodash'; +import { filter, property, without } from 'lodash'; /** * WordPress dependencies @@ -35,6 +35,7 @@ const { isCurrentPostScheduled, isEditedPostPublishable, isEditedPostSaveable, + isEditedPostAutosaveable, isEditedPostEmpty, isEditedPostBeingScheduled, getEditedPostPreviewLink, @@ -589,6 +590,7 @@ describe( 'selectors', () => { }; expect( getEditedPostExcerpt( state ) ).toBe( 'sassel' ); + expect( console ).toHaveWarned(); } ); it( 'should return the edited excerpt', () => { @@ -604,6 +606,7 @@ describe( 'selectors', () => { }; expect( getEditedPostExcerpt( state ) ).toBe( 'youcha' ); + expect( console ).toHaveWarned(); } ); } ); @@ -914,6 +917,27 @@ describe( 'selectors', () => { }, }, currentPost: {}, + saving: {}, + }; + + expect( isEditedPostSaveable( state ) ).toBe( false ); + } ); + + it( 'should return false if the post has a title but save already in progress', () => { + const state = { + editor: { + present: { + blocksByUID: {}, + blockOrder: {}, + edits: {}, + }, + }, + currentPost: { + title: 'sassel', + }, + saving: { + requesting: true, + }, }; expect( isEditedPostSaveable( state ) ).toBe( false ); @@ -931,6 +955,7 @@ describe( 'selectors', () => { currentPost: { title: 'sassel', }, + saving: {}, }; expect( isEditedPostSaveable( state ) ).toBe( true ); @@ -948,6 +973,7 @@ describe( 'selectors', () => { currentPost: { excerpt: 'sassel', }, + saving: {}, }; expect( isEditedPostSaveable( state ) ).toBe( true ); @@ -973,12 +999,114 @@ describe( 'selectors', () => { }, }, currentPost: {}, + saving: {}, }; expect( isEditedPostSaveable( state ) ).toBe( true ); } ); } ); + describe( 'isEditedPostAutosaveable', () => { + it( 'should return false if the post is not saveable', () => { + const state = { + editor: { + present: { + blocksByUID: {}, + blockOrder: {}, + edits: {}, + }, + }, + currentPost: { + title: 'sassel', + }, + saving: { + requesting: true, + }, + autosave: { + title: 'sassel', + }, + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + + it( 'should return true if there is not yet an autosave', () => { + const state = { + editor: { + present: { + blocksByUID: {}, + blockOrder: {}, + edits: {}, + }, + }, + currentPost: { + title: 'sassel', + }, + saving: {}, + autosave: null, + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( true ); + } ); + + it( 'should return false if none of title, excerpt, or content have changed', () => { + const state = { + editor: { + present: { + blocksByUID: {}, + blockOrder: {}, + edits: { + content: 'foo', + }, + }, + }, + currentPost: { + title: 'foo', + content: 'foo', + excerpt: 'foo', + }, + saving: {}, + autosave: { + title: 'foo', + content: 'foo', + excerpt: 'foo', + }, + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + + it( 'should return true if title, excerpt, or content have changed', () => { + for ( const variantField of [ 'title', 'excerpt', 'content' ] ) { + for ( const constantField of without( [ 'title', 'excerpt', 'content' ], variantField ) ) { + const state = { + editor: { + present: { + blocksByUID: {}, + blockOrder: {}, + edits: { + content: 'foo', + }, + }, + }, + currentPost: { + title: 'foo', + content: 'foo', + excerpt: 'foo', + }, + saving: {}, + autosave: { + [ constantField ]: 'foo', + [ variantField ]: 'bar', + }, + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( true ); + } + } + } ); + } ); + describe( 'isEditedPostEmpty', () => { it( 'should return true if no blocks and no content', () => { const state = { diff --git a/lib/class-wp-rest-autosaves-controller.php b/lib/class-wp-rest-autosaves-controller.php new file mode 100644 index 00000000000000..4fcd571d15f143 --- /dev/null +++ b/lib/class-wp-rest-autosaves-controller.php @@ -0,0 +1,355 @@ +parent_post_type = $parent_post_type; + $this->parent_controller = new WP_REST_Posts_Controller( $parent_post_type ); + $this->revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type ); + $this->rest_namespace = 'wp/v2'; + $this->rest_base = 'autosaves'; + $post_type_object = get_post_type_object( $parent_post_type ); + $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; + } + + /** + * Registers routes for autosaves. + * + * @since 5.0.0 + * + * @see register_rest_route() + */ + public function register_routes() { + register_rest_route( + $this->rest_namespace, '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this->revisions_controller, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->rest_namespace, '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base . '/(?P[\d]+)', array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), + 'type' => 'integer', + ), + 'id' => array( + 'description' => __( 'The ID for the object.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this->revisions_controller, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + } + + /** + * Get the parent post. + * + * @since 5.0.0 + * + * @param int $parent_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent( $parent_id ) { + return $this->revisions_controller->get_parent( $parent_id ); + } + + /** + * Checks if a given request has access to create an autosave revision. + * + * Autosave revisions inherit permissions from the parent post, + * check if the current user has permission to edit the post. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create the item, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + $id = $request->get_param( 'id' ); + if ( empty( $id ) ) { + return new WP_Error( 'rest_post_invalid_id', __( 'Invalid item ID.', 'gutenberg' ), array( 'status' => 404 ) ); + } + + return $this->parent_controller->update_item_permissions_check( $request ); + } + + /** + * Creates, updates or deletes an autosave revision. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + + if ( ! defined( 'DOING_AUTOSAVE' ) ) { + define( 'DOING_AUTOSAVE', true ); + } + + $post = get_post( $request->get_param( 'id' ) ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + $prepared_post = $this->parent_controller->prepare_item_for_database( $request ); + $prepared_post->ID = $post->ID; + $user_id = get_current_user_id(); + + if ( ( 'draft' === $post->post_status || 'auto-draft' === $post->post_status ) && $post->post_author == $user_id ) { + // Draft posts for the same author: autosaving updates the post and does not create a revision. + // Convert the post object to an array and add slashes, wp_update_post expects escaped array. + $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); + } else { + // Non-draft posts: create or update the post autosave. + $autosave_id = $this->create_post_autosave( (array) $prepared_post ); + } + + if ( is_wp_error( $autosave_id ) ) { + return $autosave_id; + } + + $autosave = get_post( $autosave_id ); + $request->set_param( 'context', 'edit' ); + + $response = $this->prepare_item_for_response( $autosave, $request ); + $response = rest_ensure_response( $response ); + + return $response; + } + + /** + * Get the autosave, if the ID is valid. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_Post|WP_Error Revision post object if ID is valid, WP_Error otherwise. + */ + public function get_item( $request ) { + $parent_id = (int) $request->get_param( 'parent' ); + + if ( $parent_id <= 0 ) { + return new WP_Error( 'rest_post_invalid_id', __( 'Invalid parent post ID.', 'gutenberg' ), array( 'status' => 404 ) ); + } + + $autosave = wp_get_post_autosave( $parent_id ); + + if ( ! $autosave ) { + return new WP_Error( 'rest_post_no_autosave', __( 'There is no autosave revision for this post.', 'gutenberg' ), array( 'status' => 404 ) ); + } + + $response = $this->prepare_item_for_response( $autosave, $request ); + return $response; + } + + /** + * Gets a collection of autosaves using wp_get_post_autosave. + * + * Contains the user's autosave, for empty if it doesn't exist. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $parent = $this->get_parent( $request->get_param( 'parent' ) ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + + $response = array(); + $parent_id = $parent->ID; + $revisions = wp_get_post_revisions( $parent_id, array( 'check_enabled' => false ) ); + + foreach ( $revisions as $revision ) { + if ( false !== strpos( $revision->post_name, "{$parent_id}-autosave" ) ) { + $data = $this->prepare_item_for_response( $revision, $request ); + $response[] = $this->prepare_response_for_collection( $data ); + } + } + + return rest_ensure_response( $response ); + } + + + /** + * Retrieves the autosave's schema, conforming to JSON Schema. + * + * @since 5.0.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + return $this->revisions_controller->get_item_schema(); + } + + /** + * Creates autosave for the specified post. + * + * From wp-admin/post.php. + * + * @since 5.0.0 + * + * @param mixed $post_data Associative array containing the post data. + * @return mixed The autosave revision ID or WP_Error. + */ + public function create_post_autosave( $post_data ) { + + $post_id = (int) $post_data['ID']; + $post = get_post( $post_id ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + $user_id = get_current_user_id(); + + // Store one autosave per author. If there is already an autosave, overwrite it. + $old_autosave = wp_get_post_autosave( $post_id, $user_id ); + + if ( $old_autosave ) { + $new_autosave = _wp_post_revision_data( $post_data, true ); + $new_autosave['ID'] = $old_autosave->ID; + $new_autosave['post_author'] = $user_id; + + // If the new autosave has the same content as the post, delete the autosave. + $autosave_is_different = false; + + foreach ( array_intersect( array_keys( $new_autosave ), array_keys( _wp_post_revision_fields( $post ) ) ) as $field ) { + if ( normalize_whitespace( $new_autosave[ $field ] ) != normalize_whitespace( $post->$field ) ) { + $autosave_is_different = true; + break; + } + } + + if ( ! $autosave_is_different ) { + wp_delete_post_revision( $old_autosave->ID ); + return new WP_Error( 'rest_autosave_no_changes', __( 'There is nothing to save. The autosave and the post content are the same.', 'gutenberg' ) ); + } + + /** + * This filter is documented in wp-admin/post.php. + */ + do_action( 'wp_creating_autosave', $new_autosave ); + + // wp_update_post expects escaped array. + return wp_update_post( wp_slash( $new_autosave ) ); + } + + // Create the new autosave as a special post revision. + return _wp_put_post_revision( $post_data, true ); + } + + /** + * Prepares the revision for the REST response. + * + * @since 5.0.0 + * + * @param WP_Post $post Post revision object. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $post, $request ) { + + $response = $this->revisions_controller->prepare_item_for_response( $post, $request ); + + /** + * Filters a revision returned from the API. + * + * Allows modification of the revision right before it is returned. + * + * @since 5.0.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post The original revision object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_autosave', $response, $post, $request ); + } +} diff --git a/lib/client-assets.php b/lib/client-assets.php index 71be6a780ef12e..06615f1cbf05a3 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -1069,6 +1069,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) { 'titlePlaceholder' => apply_filters( 'enter_title_here', __( 'Add title', 'gutenberg' ), $post ), 'bodyPlaceholder' => apply_filters( 'write_your_story', __( 'Write your story', 'gutenberg' ), $post ), 'isRTL' => is_rtl(), + 'autosaveInterval' => 10, ); if ( ! empty( $color_palette ) ) { diff --git a/lib/load.php b/lib/load.php index 290135fe5af35f..f1a8b09c66ce80 100644 --- a/lib/load.php +++ b/lib/load.php @@ -13,6 +13,7 @@ // which this class will exist if that is the case. if ( class_exists( 'WP_REST_Controller' ) ) { require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; + require dirname( __FILE__ ) . '/class-wp-rest-autosaves-controller.php'; require dirname( __FILE__ ) . '/class-wp-rest-block-renderer-controller.php'; require dirname( __FILE__ ) . '/rest-api.php'; } diff --git a/lib/rest-api.php b/lib/rest-api.php index c99a15447d93d3..8317cdf1e3a163 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -18,6 +18,20 @@ function gutenberg_register_rest_routes() { $controller = new WP_REST_Block_Renderer_Controller(); $controller->register_routes(); + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) { + $class = ! empty( $post_type->rest_controller_class ) ? $post_type->rest_controller_class : 'WP_REST_Posts_Controller'; + + // Check if the class exists and is a subclass of WP_REST_Controller. + if ( ! is_subclass_of( $class, 'WP_REST_Controller' ) ) { + continue; + } + + if ( post_type_supports( $post_type->name, 'revisions' ) ) { + $autosaves_controller = new WP_REST_Autosaves_Controller( $post_type->name ); + $autosaves_controller->register_routes(); + } + } } add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); diff --git a/phpunit/class-rest-autosaves-controller-test.php b/phpunit/class-rest-autosaves-controller-test.php new file mode 100644 index 00000000000000..20f61f1d2ede49 --- /dev/null +++ b/phpunit/class-rest-autosaves-controller-test.php @@ -0,0 +1,517 @@ + 'Post Title', + 'content' => 'Post content', + 'excerpt' => 'Post excerpt', + 'name' => 'test', + 'author' => get_current_user_id(), + ); + + return wp_parse_args( $args, $defaults ); + } + + protected function check_create_autosave_response( $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = rest_ensure_response( $response ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'content', $data ); + $this->assertArrayHasKey( 'excerpt', $data ); + $this->assertArrayHasKey( 'title', $data ); + } + + public static function wpSetUpBeforeClass( $factory ) { + self::$post_id = $factory->post->create(); + self::$page_id = $factory->post->create( array( 'post_type' => 'page' ) ); + + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + self::$contributor_id = $factory->user->create( + array( + 'role' => 'contributor', + ) + ); + + wp_set_current_user( self::$editor_id ); + + // Create an autosave. + self::$autosave_post_id = wp_create_post_autosave( + array( + 'post_content' => 'This content is better.', + 'post_ID' => self::$post_id, + 'post_type' => 'post', + ) + ); + + self::$autosave_page_id = wp_create_post_autosave( + array( + 'post_content' => 'This content is better.', + 'post_ID' => self::$page_id, + 'post_type' => 'post', + ) + ); + + } + + public static function wpTearDownAfterClass() { + // Also deletes revisions. + wp_delete_post( self::$post_id, true ); + wp_delete_post( self::$page_id, true ); + + self::delete_user( self::$editor_id ); + self::delete_user( self::$contributor_id ); + } + + public function setUp() { + parent::setUp(); + wp_set_current_user( self::$editor_id ); + + $this->post_autosave = wp_get_post_autosave( self::$post_id ); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/posts/(?P[\d]+)/autosaves', $routes ); + $this->assertArrayHasKey( '/wp/v2/posts/(?P[\d]+)/autosaves/(?P[\d]+)', $routes ); + $this->assertArrayHasKey( '/wp/v2/pages/(?P[\d]+)/autosaves', $routes ); + $this->assertArrayHasKey( '/wp/v2/pages/(?P[\d]+)/autosaves/(?P[\d]+)', $routes ); + } + + public function test_context_param() { + + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEqualSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEqualSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + public function test_get_items() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + + $this->assertEquals( self::$autosave_post_id, $data[0]['id'] ); + + $this->check_get_autosave_response( $data[0], $this->post_autosave ); + } + + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + wp_set_current_user( self::$contributor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + public function test_get_items_missing_parent() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + public function test_get_items_invalid_parent_post_type() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$page_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + public function test_get_item() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->check_get_autosave_response( $response, $this->post_autosave ); + $fields = array( + 'author', + 'date', + 'date_gmt', + 'modified', + 'modified_gmt', + 'guid', + 'id', + 'parent', + 'slug', + 'title', + 'excerpt', + 'content', + ); + $this->assertEqualSets( $fields, array_keys( $data ) ); + $this->assertSame( self::$editor_id, $data['author'] ); + } + + public function test_get_item_embed_context() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $request->set_param( 'context', 'embed' ); + $response = rest_get_server()->dispatch( $request ); + $fields = array( + 'author', + 'date', + 'id', + 'parent', + 'slug', + 'title', + 'excerpt', + ); + $data = $response->get_data(); + $this->assertEqualSets( $fields, array_keys( $data ) ); + } + + public function test_get_item_no_permission() { + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + wp_set_current_user( self::$contributor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + public function test_get_item_missing_parent() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + + } + + public function test_get_item_invalid_parent_post_type() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$page_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + public function test_delete_item() { + // Doesn't exist. + } + + public function test_prepare_item() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_get_autosave_response( $response, $this->post_autosave ); + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertEquals( 12, count( $properties ) ); + $this->assertArrayHasKey( 'author', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'date', $properties ); + $this->assertArrayHasKey( 'date_gmt', $properties ); + $this->assertArrayHasKey( 'excerpt', $properties ); + $this->assertArrayHasKey( 'guid', $properties ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'modified', $properties ); + $this->assertArrayHasKey( 'modified_gmt', $properties ); + $this->assertArrayHasKey( 'parent', $properties ); + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + } + + public function test_create_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + + $params = $this->set_post_data( + array( + 'id' => self::$post_id, + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + + $this->check_create_autosave_response( $response ); + } + + public function test_update_item() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + + $params = $this->set_post_data( + array( + 'id' => self::$post_id, + 'author' => self::$contributor_id, + ) + ); + + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + + $this->check_create_autosave_response( $response ); + } + + public function test_update_item_nopriv() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + + $params = $this->set_post_data( + array( + 'id' => self::$post_id, + 'author' => self::$editor_id, + ) + ); + + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_rest_autosave_published_post() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/json' ); + + $current_post = get_post( self::$post_id ); + + $autosave_data = $this->set_post_data( + array( + 'id' => self::$post_id, + 'content' => 'Updated post \ content', + 'excerpt' => $current_post->post_excerpt, + 'title' => $current_post->post_title, + ) + ); + + $request->set_body( wp_json_encode( $autosave_data ) ); + $response = rest_get_server()->dispatch( $request ); + $new_data = $response->get_data(); + + $this->assertEquals( $current_post->ID, $new_data['parent'] ); + $this->assertEquals( $current_post->post_title, $new_data['title']['raw'] ); + $this->assertEquals( $current_post->post_excerpt, $new_data['excerpt']['raw'] ); + + // Updated post_content. + $this->assertNotEquals( $current_post->post_content, $new_data['content']['raw'] ); + + $autosave_post = wp_get_post_autosave( self::$post_id ); + $this->assertEquals( $autosave_data['title'], $autosave_post->post_title ); + $this->assertEquals( $autosave_data['content'], $autosave_post->post_content ); + $this->assertEquals( $autosave_data['excerpt'], $autosave_post->post_excerpt ); + } + + public function test_rest_autosave_draft_post_same_author() { + wp_set_current_user( self::$editor_id ); + + $post_data = array( + 'post_content' => 'Test post content', + 'post_title' => 'Test post title', + 'post_excerpt' => 'Test post excerpt', + ); + $post_id = wp_insert_post( $post_data ); + + $autosave_data = array( + 'id' => $post_id, + 'content' => 'Updated post \ content', + 'title' => 'Updated post title', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $autosave_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $new_data = $response->get_data(); + $post = get_post( $post_id ); + + $this->assertEquals( $post_id, $new_data['id'] ); + // The draft post should be updated. + $this->assertEquals( $autosave_data['content'], $new_data['content']['raw'] ); + $this->assertEquals( $autosave_data['title'], $new_data['title']['raw'] ); + $this->assertEquals( $autosave_data['content'], $post->post_content ); + $this->assertEquals( $autosave_data['title'], $post->post_title ); + + // Not updated. + $this->assertEquals( $post_data['post_excerpt'], $post->post_excerpt ); + + wp_delete_post( $post_id ); + } + + public function test_rest_autosave_draft_post_different_author() { + wp_set_current_user( self::$editor_id ); + + $post_data = array( + 'post_content' => 'Test post content', + 'post_title' => 'Test post title', + 'post_excerpt' => 'Test post excerpt', + 'post_author' => self::$editor_id + 1, + ); + $post_id = wp_insert_post( $post_data ); + + $autosave_data = array( + 'id' => $post_id, + 'content' => 'Updated post content', + 'excerpt' => $post_data['post_excerpt'], + 'title' => $post_data['post_title'], + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $autosave_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $new_data = $response->get_data(); + $current_post = get_post( $post_id ); + + $this->assertEquals( $current_post->ID, $new_data['parent'] ); + + // The draft post shouldn't change. + $this->assertEquals( $current_post->post_title, $post_data['post_title'] ); + $this->assertEquals( $current_post->post_content, $post_data['post_content'] ); + $this->assertEquals( $current_post->post_excerpt, $post_data['post_excerpt'] ); + + $autosave_post = wp_get_post_autosave( $post_id ); + + // No changes. + $this->assertEquals( $current_post->post_title, $autosave_post->post_title ); + $this->assertEquals( $current_post->post_excerpt, $autosave_post->post_excerpt ); + + // Has changes. + $this->assertEquals( $autosave_data['content'], $autosave_post->post_content ); + + wp_delete_post( $post_id ); + } + + public function test_get_additional_field_registration() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( + 'post-revision', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) + ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'my_custom_int', $data['schema']['properties'] ); + $this->assertEquals( $schema, $data['schema']['properties']['my_custom_int'] ); + + wp_set_current_user( 1 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertArrayHasKey( 'my_custom_int', $response->data ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + + public function additional_field_get_callback( $object ) { + return get_post_meta( $object['id'], 'my_custom_int', true ); + } + + public function additional_field_update_callback( $value, $post ) { + update_post_meta( $post->ID, 'my_custom_int', $value ); + } + + protected function check_get_autosave_response( $response, $autosave ) { + if ( $response instanceof WP_REST_Response ) { + $links = $response->get_links(); + $response = $response->get_data(); + } else { + $this->assertArrayHasKey( '_links', $response ); + $links = $response['_links']; + } + + $this->assertEquals( $autosave->post_author, $response['author'] ); + + $rendered_content = apply_filters( 'the_content', $autosave->post_content ); + $this->assertEquals( $rendered_content, $response['content']['rendered'] ); + + $this->assertEquals( mysql_to_rfc3339( $autosave->post_date ), $response['date'] ); //@codingStandardsIgnoreLine + $this->assertEquals( mysql_to_rfc3339( $autosave->post_date_gmt ), $response['date_gmt'] ); //@codingStandardsIgnoreLine + + $rendered_guid = apply_filters( 'get_the_guid', $autosave->guid, $autosave->ID ); + $this->assertEquals( $rendered_guid, $response['guid']['rendered'] ); + + $this->assertEquals( $autosave->ID, $response['id'] ); + $this->assertEquals( mysql_to_rfc3339( $autosave->post_modified ), $response['modified'] ); //@codingStandardsIgnoreLine + $this->assertEquals( mysql_to_rfc3339( $autosave->post_modified_gmt ), $response['modified_gmt'] ); //@codingStandardsIgnoreLine + $this->assertEquals( $autosave->post_name, $response['slug'] ); + + $rendered_title = get_the_title( $autosave->ID ); + $this->assertEquals( $rendered_title, $response['title']['rendered'] ); + + $parent = get_post( $autosave->post_parent ); + $parent_controller = new WP_REST_Posts_Controller( $parent->post_type ); + $parent_object = get_post_type_object( $parent->post_type ); + $parent_base = ! empty( $parent_object->rest_base ) ? $parent_object->rest_base : $parent_object->name; + $this->assertEquals( rest_url( '/wp/v2/' . $parent_base . '/' . $autosave->post_parent ), $links['parent'][0]['href'] ); + } + + public function test_get_item_sets_up_postdata() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + rest_get_server()->dispatch( $request ); + + $post = get_post(); + $parent_post_id = wp_is_post_revision( $post->ID ); + + $this->assertEquals( $post->ID, self::$autosave_post_id ); + $this->assertEquals( $parent_post_id, self::$post_id ); + } + +} diff --git a/test/e2e/specs/change-detection.test.js b/test/e2e/specs/change-detection.test.js index a3c08a51cb784d..944346629f60be 100644 --- a/test/e2e/specs/change-detection.test.js +++ b/test/e2e/specs/change-detection.test.js @@ -5,16 +5,24 @@ import '../support/bootstrap'; import { newPost, newDesktopBrowserPage, pressWithModifier } from '../support/utils'; describe( 'Change detection', () => { - let handleInterceptedRequest; + let handleInterceptedRequest, hadInterceptedSave; beforeAll( async () => { await newDesktopBrowserPage(); } ); beforeEach( async () => { + hadInterceptedSave = false; + await newPost(); } ); + afterEach( () => { + if ( handleInterceptedRequest ) { + releaseSaveIntercept(); + } + } ); + async function assertIsDirty( isDirty ) { let hadDialog = false; @@ -40,7 +48,9 @@ describe( 'Change detection', () => { await page.setRequestInterception( true ); handleInterceptedRequest = ( interceptedRequest ) => { - if ( ! interceptedRequest.url().includes( '/wp/v2/posts' ) ) { + if ( interceptedRequest.url().includes( '/wp/v2/posts' ) ) { + hadInterceptedSave = true; + } else { interceptedRequest.continue(); } }; @@ -50,8 +60,33 @@ describe( 'Change detection', () => { async function releaseSaveIntercept() { page.removeListener( 'request', handleInterceptedRequest ); await page.setRequestInterception( false ); + hadInterceptedSave = false; + handleInterceptedRequest = null; } + it( 'Should not save on new unsaved post', async () => { + await interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pressWithModifier( 'Mod', 'S' ); + + expect( hadInterceptedSave ).toBe( false ); + } ); + + it( 'Should autosave post', async () => { + await page.type( '.editor-post-title__input', 'Hello World' ); + + // Force autosave to occur immediately. + await Promise.all( [ + page.evaluate( () => window.wp.data.dispatch( 'core/editor' ).autosave() ), + page.waitForSelector( '.editor-post-saved-state.is-autosaving' ), + page.waitForSelector( '.editor-post-saved-state.is-saved' ), + ] ); + + // Still dirty after an autosave. + await assertIsDirty( true ); + } ); + it( 'Should not prompt to confirm unsaved changes', async () => { await assertIsDirty( false ); } ); @@ -82,17 +117,38 @@ describe( 'Change detection', () => { await assertIsDirty( false ); } ); - it( 'Should prompt if save failed', async () => { + it( 'Should not save if all changes saved', async () => { await page.type( '.editor-post-title__input', 'Hello World' ); - await page.setOfflineMode( true ); + await Promise.all( [ + // Wait for "Saved" to confirm save complete. + page.waitForSelector( '.editor-post-saved-state.is-saved' ), + + // Keyboard shortcut Ctrl+S save. + pressWithModifier( 'Mod', 'S' ), + ] ); + + await interceptSave(); // Keyboard shortcut Ctrl+S save. await pressWithModifier( 'Mod', 'S' ); - // Ensure save update fails and presents button. - await page.waitForXPath( '//p[contains(text(), \'Updating failed\')]' ); - await page.waitForSelector( '.editor-post-save-draft' ); + expect( hadInterceptedSave ).toBe( false ); + } ); + + it( 'Should prompt if save failed', async () => { + await page.type( '.editor-post-title__input', 'Hello World' ); + + await page.setOfflineMode( true ); + + await Promise.all( [ + // Keyboard shortcut Ctrl+S save. + pressWithModifier( 'Mod', 'S' ), + + // Ensure save update fails and presents button. + page.waitForXPath( '//p[contains(text(), \'Updating failed\')]' ), + page.waitForSelector( '.editor-post-save-draft' ), + ] ); // Need to disable offline to allow reload. await page.setOfflineMode( false );