diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 7e95832dc5bdbe..3a22e11c901214 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -749,6 +749,43 @@ post has been received, by initialization or autosave. * post: Autosave post object. +### __experimentalRequestPostUpdateStart + +Optimistic action for dispatching that a post update request has started. + +*Parameters* + + * options: null + +### __experimentalRequestPostUpdateSuccess + +Optimistic action for indicating that the request post update has completed +successfully. + +*Parameters* + + * data: The data for the action. + * data.previousPost: The previous post prior to update. + * data.post: The new post after update + * data.isRevision: Whether the post is a revision or not. + * data.options: Options passed through from the original + action dispatch. + * data.postType: The post type object. + +### __experimentalRequestPostUpdateFailure + +Optimistic action for indicating that the request post update has completed +with a failure. + +*Parameters* + + * data: The data for the action + * data.post: The post that failed updating. + * data.edits: The fields that were being updated. + * data.error: The error from the failed call. + * data.options: Options passed through from the original + action dispatch. + ### updatePost Returns an action object used in signalling that a patch of updates for the @@ -760,7 +797,8 @@ latest version of the post have been received. ### setupEditorState -Returns an action object used to setup the editor state when first opening an editor. +Returns an action object used to setup the editor state when first opening +an editor. *Parameters* @@ -775,18 +813,34 @@ been edited. * edits: Post attributes to edit. +### __experimentalOptimisticUpdatePost + +Returns action object produced by the updatePost creator augmented by +an optimist option that signals optimistically applying updates. + +*Parameters* + + * edits: Updated post fields. + ### savePost -Returns an action object to save the post. +Action generator for saving the current post in the editor. *Parameters* - * options: Options for the save. - * options.isAutosave: Perform an autosave if true. + * options: null + +### refreshPost + +Action generator for handling refreshing the current post. + +### trashPost + +Action generator for trashing the current post in the editor. ### autosave -Returns an action object used in signalling that the post should autosave. +Action generator used in signalling that the post should autosave. *Parameters* @@ -864,7 +918,8 @@ to be updated. ### __experimentalConvertBlockToStatic -Returns an action object used to convert a reusable block into a static block. +Returns an action object used to convert a reusable block into a static +block. *Parameters* @@ -872,7 +927,8 @@ Returns an action object used to convert a reusable block into a static block. ### __experimentalConvertBlockToReusable -Returns an action object used to convert a static block into a reusable block. +Returns an action object used to convert a static block into a reusable +block. *Parameters* @@ -880,11 +936,13 @@ Returns an action object used to convert a static block into a reusable block. ### enablePublishSidebar -Returns an action object used in signalling that the user has enabled the publish sidebar. +Returns an action object used in signalling that the user has enabled the +publish sidebar. ### disablePublishSidebar -Returns an action object used in signalling that the user has disabled the publish sidebar. +Returns an action object used in signalling that the user has disabled the +publish sidebar. ### lockPostSaving diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 8b64a350c46acd..7c9eb8c623ed92 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -18,6 +18,7 @@ - Removed `jQuery` dependency. - Removed `TinyMCE` dependency. - RichText: improve format boundaries. +- Refactor all post effects to action-generators using controls ([#13716](https://github.com/WordPress/gutenberg/pull/13716)) ## 9.0.7 (2019-01-03) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 0d508375c37c84..f1271ed3d96d14 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,12 +1,29 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, pick } from 'lodash'; +import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; /** * Internal dependencies */ -import { dispatch } from './controls'; +import { + dispatch, + select, + resolveSelect, + apiFetch, +} from './controls'; +import { + STORE_KEY, + POST_UPDATE_TRANSACTION_ID, + SAVE_POST_NOTICE_ID, + TRASH_POST_NOTICE_ID, +} from './constants'; +import { + getNotificationArgumentsForSaveSuccess, + getNotificationArgumentsForSaveFail, + getNotificationArgumentsForTrashFail, +} from './utils/notice-builder'; /** * Returns an action object used in signalling that editor has initialized with @@ -57,6 +74,87 @@ export function resetAutosave( post ) { }; } +/** + * Optimistic action for dispatching that a post update request has started. + * + * @param {Object} options + * + * @return {Object} An action object + */ +export function __experimentalRequestPostUpdateStart( options = {} ) { + return { + type: 'REQUEST_POST_UPDATE_START', + optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, + options, + }; +} + +/** + * Optimistic action for indicating that the request post update has completed + * successfully. + * + * @param {Object} data The data for the action. + * @param {Object} data.previousPost The previous post prior to update. + * @param {Object} data.post The new post after update + * @param {boolean} data.isRevision Whether the post is a revision or not. + * @param {Object} data.options Options passed through from the original + * action dispatch. + * @param {Object} data.postType The post type object. + * + * @return {Object} Action object. + */ +export function __experimentalRequestPostUpdateSuccess( { + previousPost, + post, + isRevision, + options, + postType, +} ) { + return { + type: 'REQUEST_POST_UPDATE_SUCCESS', + previousPost, + post, + optimist: { + // Note: REVERT is not a failure case here. Rather, it + // is simply reversing the assumption that the updates + // were applied to the post proper, such that the post + // treated as having unsaved changes. + type: isRevision ? REVERT : COMMIT, + id: POST_UPDATE_TRANSACTION_ID, + }, + options, + postType, + }; +} + +/** + * Optimistic action for indicating that the request post update has completed + * with a failure. + * + * @param {Object} data The data for the action + * @param {Object} data.post The post that failed updating. + * @param {Object} data.edits The fields that were being updated. + * @param {*} data.error The error from the failed call. + * @param {Object} data.options Options passed through from the original + * action dispatch. + * @return {Object} An action object + */ +export function __experimentalRequestPostUpdateFailure( { + post, + edits, + error, + options, +} ) { + return { + type: 'REQUEST_POST_UPDATE_FAILURE', + optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, + post, + edits, + error, + options, + }; +} + /** * Returns an action object used in signalling that a patch of updates for the * latest version of the post have been received. @@ -73,7 +171,8 @@ export function updatePost( edits ) { } /** - * Returns an action object used to setup the editor state when first opening an editor. + * Returns an action object used to setup the editor state when first opening + * an editor. * * @param {Object} post Post object. * @@ -102,43 +201,282 @@ export function editPost( edits ) { } /** - * Returns an action object to save the post. + * Returns action object produced by the updatePost creator augmented by + * an optimist option that signals optimistically applying updates. * - * @param {Object} options Options for the save. - * @param {boolean} options.isAutosave Perform an autosave if true. + * @param {Object} edits Updated post fields. * * @return {Object} Action object. */ -export function savePost( options = {} ) { +export function __experimentalOptimisticUpdatePost( edits ) { return { - type: 'REQUEST_POST_UPDATE', - options, + ...updatePost( edits ), + optimist: { id: POST_UPDATE_TRANSACTION_ID }, }; } -export function refreshPost() { - return { - type: 'REFRESH_POST', +/** + * Action generator for saving the current post in the editor. + * + * @param {Object} options + */ +export function* savePost( options = {} ) { + const isEditedPostSaveable = yield select( + STORE_KEY, + 'isEditedPostSaveable' + ); + if ( ! isEditedPostSaveable ) { + return; + } + let edits = yield select( + STORE_KEY, + 'getPostEdits' + ); + const isAutosave = !! options.isAutosave; + + if ( isAutosave ) { + edits = pick( edits, [ 'title', 'content', 'excerpt' ] ); + } + + const isEditedPostNew = yield select( + STORE_KEY, + 'isEditedPostNew', + ); + + // New posts (with auto-draft status) must be explicitly assigned draft + // status if there is not already a status assigned in edits (publish). + // Otherwise, they are wrongly left as auto-draft. Status is not always + // respected for autosaves, so it cannot simply be included in the pick + // above. This behavior relies on an assumption that an auto-draft post + // would never be saved by anyone other than the owner of the post, per + // logic within autosaves REST controller to save status field only for + // draft/auto-draft by current user. + // + // See: https://core.trac.wordpress.org/ticket/43316#comment:88 + // See: https://core.trac.wordpress.org/ticket/43316#comment:89 + if ( isEditedPostNew ) { + edits = { status: 'draft', ...edits }; + } + + const post = yield select( + STORE_KEY, + 'getCurrentPost' + ); + + const editedPostContent = yield select( + STORE_KEY, + 'getEditedPostContent' + ); + + let toSend = { + ...edits, + content: editedPostContent, + id: post.id, }; + + const currentPostType = yield select( + STORE_KEY, + 'getCurrentPostType' + ); + + const postType = yield resolveSelect( + 'core', + 'getPostType', + currentPostType + ); + + yield dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateStart', + options, + ); + + // Optimistically apply updates under the assumption that the post + // will be updated. See below logic in success resolution for revert + // if the autosave is applied as a revision. + yield dispatch( + STORE_KEY, + '__experimentalOptimisticUpdatePost', + toSend + ); + + let path = `/wp/v2/${ postType.rest_base }/${ post.id }`; + let method = 'PUT'; + if ( isAutosave ) { + const autoSavePost = yield select( + STORE_KEY, + 'getAutosave', + ); + // Ensure autosaves contain all expected fields, using autosave or + // post values as fallback if not otherwise included in edits. + toSend = { + ...pick( post, [ 'title', 'content', 'excerpt' ] ), + ...autoSavePost, + ...toSend, + }; + path += '/autosaves'; + method = 'POST'; + } else { + yield dispatch( + 'core/notices', + 'removeNotice', + SAVE_POST_NOTICE_ID, + ); + yield dispatch( + 'core/notices', + 'removeNotice', + 'autosave-exists', + ); + } + + try { + const newPost = yield apiFetch( { + path, + method, + data: toSend, + } ); + const resetAction = isAutosave ? 'resetAutosave' : 'resetPost'; + + yield dispatch( STORE_KEY, resetAction, newPost ); + + yield dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateSuccess', + { + previousPost: post, + post: newPost, + options, + postType, + // An autosave may be processed by the server as a regular save + // when its update is requested by the author and the post was + // draft or auto-draft. + isRevision: newPost.id !== post.id, + } + ); + + const notifySuccessArgs = getNotificationArgumentsForSaveSuccess( { + previousPost: post, + post: newPost, + postType, + options, + } ); + if ( notifySuccessArgs.length > 0 ) { + yield dispatch( + 'core/notices', + 'createSuccessNotice', + ...notifySuccessArgs + ); + } + } catch ( error ) { + yield dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateFailure', + { post, edits, error, options } + ); + const notifyFailArgs = getNotificationArgumentsForSaveFail( { + post, + edits, + error, + } ); + if ( notifyFailArgs.length > 0 ) { + yield dispatch( + 'core/notices', + 'createErrorNotice', + ...notifyFailArgs + ); + } + } } -export function trashPost( postId, postType ) { - return { - type: 'TRASH_POST', - postId, - postType, - }; +/** + * Action generator for handling refreshing the current post. + */ +export function* refreshPost() { + const post = yield select( + STORE_KEY, + 'getCurrentPost' + ); + const postTypeSlug = yield select( + STORE_KEY, + 'getCurrentPostType' + ); + const postType = yield resolveSelect( + 'core', + 'getPostType', + postTypeSlug + ); + const newPost = yield apiFetch( + { + // Timestamp arg allows caller to bypass browser caching, which is + // expected for this specific function. + path: `/wp/v2/${ postType.rest_base }/${ post.id }` + + `?context=edit&_timestamp=${ Date.now() }`, + } + ); + yield dispatch( + STORE_KEY, + 'resetPost', + newPost + ); } /** - * Returns an action object used in signalling that the post should autosave. + * Action generator for trashing the current post in the editor. + */ +export function* trashPost() { + const postTypeSlug = yield select( + STORE_KEY, + 'getCurrentPostType' + ); + const postType = yield resolveSelect( + 'core', + 'getPostType', + postTypeSlug + ); + yield dispatch( + 'core/notices', + 'removeNotice', + TRASH_POST_NOTICE_ID + ); + try { + const post = yield select( + STORE_KEY, + 'getCurrentPost' + ); + yield apiFetch( + { + path: `/wp/v2/${ postType.rest_base }/${ post.id }`, + method: 'DELETE', + } + ); + + // TODO: This should be an updatePost action (updating subsets of post + // properties), but right now editPost is tied with change detection. + yield dispatch( + STORE_KEY, + 'resetPost', + { ...post, status: 'trash' } + ); + } catch ( error ) { + yield dispatch( + 'core/notices', + 'createErrorNotice', + ...getNotificationArgumentsForTrashFail( { error } ), + ); + } +} + +/** + * Action generator used in signalling that the post should autosave. * * @param {Object?} options Extra flags to identify the autosave. - * - * @return {Object} Action object. */ -export function autosave( options ) { - return savePost( { isAutosave: true, ...options } ); +export function* autosave( options ) { + yield dispatch( + STORE_KEY, + 'savePost', + { isAutosave: true, ...options } + ); } /** @@ -264,7 +602,8 @@ export function __experimentalUpdateReusableBlockTitle( id, title ) { } /** - * Returns an action object used to convert a reusable block into a static block. + * Returns an action object used to convert a reusable block into a static + * block. * * @param {string} clientId The client ID of the block to attach. * @@ -278,7 +617,8 @@ export function __experimentalConvertBlockToStatic( clientId ) { } /** - * Returns an action object used to convert a static block into a reusable block. + * Returns an action object used to convert a static block into a reusable + * block. * * @param {string} clientIds The client IDs of the block to detach. * @@ -292,7 +632,8 @@ export function __experimentalConvertBlockToReusable( clientIds ) { } /** - * Returns an action object used in signalling that the user has enabled the publish sidebar. + * Returns an action object used in signalling that the user has enabled the + * publish sidebar. * * @return {Object} Action object */ @@ -303,7 +644,8 @@ export function enablePublishSidebar() { } /** - * Returns an action object used in signalling that the user has disabled the publish sidebar. + * Returns an action object used in signalling that the user has disabled the + * publish sidebar. * * @return {Object} Action object */ diff --git a/packages/editor/src/store/constants.js b/packages/editor/src/store/constants.js index f07ca417f9d6eb..8f8f1bd0afcef6 100644 --- a/packages/editor/src/store/constants.js +++ b/packages/editor/src/store/constants.js @@ -7,3 +7,15 @@ export const EDIT_MERGE_PROPERTIES = new Set( [ 'meta', ] ); + +/** + * Constant for the store module (or reducer) key. + * @type {string} + */ +export const STORE_KEY = 'core/editor'; + +export const POST_UPDATE_TRANSACTION_ID = 'post-update'; +export const SAVE_POST_NOTICE_ID = 'SAVE_POST_NOTICE_ID'; +export const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; +export const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; +export const ONE_MINUTE_IN_MS = 60 * 1000; diff --git a/packages/editor/src/store/controls.js b/packages/editor/src/store/controls.js index fc873ad43aa395..597a5f726145b5 100644 --- a/packages/editor/src/store/controls.js +++ b/packages/editor/src/store/controls.js @@ -1,17 +1,69 @@ /** * WordPress dependencies */ +import triggerFetch from '@wordpress/api-fetch'; import { createRegistryControl } from '@wordpress/data'; /** - * Dispatches an action. + * Dispatches a control action for triggering an api fetch call. * - * @param {string} storeKey Store key. - * @param {string} actionName Action name. - * @param {Array} args Action arguments. + * @param {Object} request Arguments for the fetch request. * * @return {Object} control descriptor. */ +export function apiFetch( request ) { + return { + type: 'API_FETCH', + request, + }; +} + +/** + * Dispatches a control action for triggering a registry select. + * + * @param {string} storeKey + * @param {string} selectorName + * @param {Array} args Arguments for the select. + * + * @return {Object} control descriptor. + */ +export function select( storeKey, selectorName, ...args ) { + return { + type: 'SELECT', + storeKey, + selectorName, + args, + }; +} + +/** + * Dispatches a control action for triggering a registry select that has a + * resolver. + * + * @param {string} storeKey + * @param {string} selectorName + * @param {Array} args Arguments for the select. + * + * @return {Object} control descriptor. + */ +export function resolveSelect( storeKey, selectorName, ...args ) { + return { + type: 'RESOLVE_SELECT', + storeKey, + selectorName, + args, + }; +} + +/** + * Dispatches a control action for triggering a registry dispatch. + * + * @param {string} storeKey + * @param {string} actionName + * @param {Array} args Arguments for the dispatch action. + * + * @return {Object} control descriptor. + */ export function dispatch( storeKey, actionName, ...args ) { return { type: 'DISPATCH', @@ -21,10 +73,41 @@ export function dispatch( storeKey, actionName, ...args ) { }; } -const controls = { - DISPATCH: createRegistryControl( ( registry ) => ( { storeKey, actionName, args } ) => { - return registry.dispatch( storeKey )[ actionName ]( ...args ); - } ), -}; +export default { + API_FETCH( { request } ) { + return triggerFetch( request ); + }, + SELECT: createRegistryControl( + ( registry ) => ( { storeKey, selectorName, args } ) => { + return registry.select( storeKey )[ selectorName ]( ...args ); + } + ), + DISPATCH: createRegistryControl( + ( registry ) => ( { storeKey, actionName, args } ) => { + return registry.dispatch( storeKey )[ actionName ]( ...args ); + } + ), + RESOLVE_SELECT: createRegistryControl( + ( registry ) => ( { storeKey, selectorName, args } ) => { + return new Promise( ( resolve ) => { + const hasFinished = () => registry.select( 'core/data' ) + .hasFinishedResolution( storeKey, selectorName, args ); + const getResult = () => registry.select( storeKey )[ selectorName ] + .apply( null, args ); -export default controls; + // trigger the selector (to trigger the resolver) + const result = getResult(); + if ( hasFinished() ) { + return resolve( result ); + } + + const unsubscribe = registry.subscribe( () => { + if ( hasFinished() ) { + unsubscribe(); + resolve( getResult() ); + } + } ); + } ); + } + ), +}; diff --git a/packages/editor/src/store/effects.js b/packages/editor/src/store/effects.js index de77fbc45aab59..84f51151137667 100644 --- a/packages/editor/src/store/effects.js +++ b/packages/editor/src/store/effects.js @@ -26,28 +26,8 @@ import { convertBlockToStatic, receiveReusableBlocks, } from './effects/reusable-blocks'; -import { - requestPostUpdate, - requestPostUpdateSuccess, - requestPostUpdateFailure, - trashPost, - trashPostFailure, - refreshPost, -} from './effects/posts'; export default { - REQUEST_POST_UPDATE: ( action, store ) => { - requestPostUpdate( action, store ); - }, - REQUEST_POST_UPDATE_SUCCESS: requestPostUpdateSuccess, - REQUEST_POST_UPDATE_FAILURE: requestPostUpdateFailure, - TRASH_POST: ( action, store ) => { - trashPost( action, store ); - }, - TRASH_POST_FAILURE: trashPostFailure, - REFRESH_POST: ( action, store ) => { - refreshPost( action, store ); - }, SETUP_EDITOR( action ) { const { post, edits, template } = action; diff --git a/packages/editor/src/store/effects/posts.js b/packages/editor/src/store/effects/posts.js deleted file mode 100644 index f9cd28fd0adbb4..00000000000000 --- a/packages/editor/src/store/effects/posts.js +++ /dev/null @@ -1,319 +0,0 @@ -/** - * External dependencies - */ -import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; -import { get, pick, includes } from 'lodash'; - -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; -// TODO: Ideally this would be the only dispatch in scope. This requires either -// refactoring editor actions to yielded controls, or replacing direct dispatch -// on the editor store with action creators (e.g. `REQUEST_POST_UPDATE_START`). -import { dispatch as dataDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { - resetAutosave, - resetPost, - updatePost, -} from '../actions'; -import { - getCurrentPost, - getPostEdits, - getEditedPostContent, - getAutosave, - getCurrentPostType, - isEditedPostSaveable, - isEditedPostNew, - POST_UPDATE_TRANSACTION_ID, -} from '../selectors'; -import { resolveSelector } from './utils'; - -/** - * Module Constants - */ -export const SAVE_POST_NOTICE_ID = 'SAVE_POST_NOTICE_ID'; -const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; - -/** - * Request Post Update Effect handler - * - * @param {Object} action the fetchReusableBlocks action object. - * @param {Object} store Redux Store. - */ -export const requestPostUpdate = async ( action, store ) => { - const { dispatch, getState } = store; - const state = getState(); - - // Prevent save if not saveable. - // We don't check for dirtiness here as this can be overridden in the UI. - if ( ! isEditedPostSaveable( state ) ) { - return; - } - - let edits = getPostEdits( state ); - const isAutosave = !! action.options.isAutosave; - if ( isAutosave ) { - edits = pick( edits, [ 'title', 'content', 'excerpt' ] ); - } - - // New posts (with auto-draft status) must be explicitly assigned draft - // status if there is not already a status assigned in edits (publish). - // Otherwise, they are wrongly left as auto-draft. Status is not always - // respected for autosaves, so it cannot simply be included in the pick - // above. This behavior relies on an assumption that an auto-draft post - // would never be saved by anyone other than the owner of the post, per - // logic within autosaves REST controller to save status field only for - // draft/auto-draft by current user. - // - // See: https://core.trac.wordpress.org/ticket/43316#comment:88 - // See: https://core.trac.wordpress.org/ticket/43316#comment:89 - if ( isEditedPostNew( state ) ) { - edits = { status: 'draft', ...edits }; - } - - const post = getCurrentPost( state ); - - let toSend = { - ...edits, - content: getEditedPostContent( state ), - id: post.id, - }; - - const postType = await resolveSelector( 'core', 'getPostType', getCurrentPostType( state ) ); - - dispatch( { - type: 'REQUEST_POST_UPDATE_START', - optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, - options: action.options, - } ); - - // Optimistically apply updates under the assumption that the post - // will be updated. See below logic in success resolution for revert - // if the autosave is applied as a revision. - dispatch( { - ...updatePost( toSend ), - optimist: { id: POST_UPDATE_TRANSACTION_ID }, - } ); - - let request; - if ( isAutosave ) { - // Ensure autosaves contain all expected fields, using autosave or - // post values as fallback if not otherwise included in edits. - toSend = { - ...pick( post, [ 'title', 'content', 'excerpt' ] ), - ...getAutosave( state ), - ...toSend, - }; - - request = apiFetch( { - path: `/wp/v2/${ postType.rest_base }/${ post.id }/autosaves`, - method: 'POST', - data: toSend, - } ); - } else { - dataDispatch( 'core/notices' ).removeNotice( SAVE_POST_NOTICE_ID ); - dataDispatch( 'core/notices' ).removeNotice( 'autosave-exists' ); - - request = apiFetch( { - path: `/wp/v2/${ postType.rest_base }/${ post.id }`, - method: 'PUT', - data: toSend, - } ); - } - - try { - const newPost = await request; - const reset = isAutosave ? resetAutosave : resetPost; - dispatch( reset( newPost ) ); - - // An autosave may be processed by the server as a regular save - // when its update is requested by the author and the post was - // draft or auto-draft. - const isRevision = newPost.id !== post.id; - - dispatch( { - type: 'REQUEST_POST_UPDATE_SUCCESS', - previousPost: post, - post: newPost, - optimist: { - // Note: REVERT is not a failure case here. Rather, it - // is simply reversing the assumption that the updates - // were applied to the post proper, such that the post - // treated as having unsaved changes. - type: isRevision ? REVERT : COMMIT, - id: POST_UPDATE_TRANSACTION_ID, - }, - options: action.options, - postType, - } ); - } catch ( error ) { - dispatch( { - type: 'REQUEST_POST_UPDATE_FAILURE', - optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, - post, - edits, - error, - options: action.options, - } ); - } -}; - -/** - * Request Post Update Success Effect handler - * - * @param {Object} action action object. - * @param {Object} store Redux Store. - */ -export const requestPostUpdateSuccess = ( action ) => { - const { previousPost, post, postType } = action; - - // Autosaves are neither shown a notice nor redirected. - if ( get( action.options, [ 'isAutosave' ] ) ) { - return; - } - - const publishStatus = [ 'publish', 'private', 'future' ]; - const isPublished = includes( publishStatus, previousPost.status ); - const willPublish = includes( publishStatus, post.status ); - - let noticeMessage; - let shouldShowLink = get( postType, [ 'viewable' ], false ); - - if ( ! isPublished && ! willPublish ) { - // If saving a non-published post, don't show notice. - noticeMessage = null; - } else if ( isPublished && ! willPublish ) { - // If undoing publish status, show specific notice - noticeMessage = postType.labels.item_reverted_to_draft; - shouldShowLink = false; - } else if ( ! isPublished && willPublish ) { - // If publishing or scheduling a post, show the corresponding - // publish message - noticeMessage = { - publish: postType.labels.item_published, - private: postType.labels.item_published_privately, - future: postType.labels.item_scheduled, - }[ post.status ]; - } else { - // Generic fallback notice - noticeMessage = postType.labels.item_updated; - } - - if ( noticeMessage ) { - const actions = []; - if ( shouldShowLink ) { - actions.push( { - label: postType.labels.view_item, - url: post.link, - } ); - } - - dataDispatch( 'core/notices' ).createSuccessNotice( - noticeMessage, - { - id: SAVE_POST_NOTICE_ID, - actions, - } - ); - } -}; - -/** - * Request Post Update Failure Effect handler - * - * @param {Object} action action object. - */ -export const requestPostUpdateFailure = ( action ) => { - const { post, edits, error } = action; - - if ( error && 'rest_autosave_no_changes' === error.code ) { - // Autosave requested a new autosave, but there were no changes. This shouldn't - // result in an error notice for the user. - return; - } - - const publishStatus = [ 'publish', 'private', 'future' ]; - const isPublished = publishStatus.indexOf( post.status ) !== -1; - // If the post was being published, we show the corresponding publish error message - // Unless we publish an "updating failed" message - const messages = { - publish: __( 'Publishing failed' ), - private: __( 'Publishing failed' ), - future: __( 'Scheduling failed' ), - }; - const noticeMessage = ! isPublished && publishStatus.indexOf( edits.status ) !== -1 ? - messages[ edits.status ] : - __( 'Updating failed' ); - - dataDispatch( 'core/notices' ).createErrorNotice( noticeMessage, { - id: SAVE_POST_NOTICE_ID, - } ); -}; - -/** - * Trash Post Effect handler - * - * @param {Object} action action object. - * @param {Object} store Redux Store. - */ -export const trashPost = async ( action, store ) => { - const { dispatch, getState } = store; - const { postId } = action; - const postTypeSlug = getCurrentPostType( getState() ); - const postType = await resolveSelector( 'core', 'getPostType', postTypeSlug ); - - dataDispatch( 'core/notices' ).removeNotice( TRASH_POST_NOTICE_ID ); - try { - await apiFetch( { path: `/wp/v2/${ postType.rest_base }/${ postId }`, method: 'DELETE' } ); - const post = getCurrentPost( getState() ); - - // TODO: This should be an updatePost action (updating subsets of post properties), - // But right now editPost is tied with change detection. - dispatch( resetPost( { ...post, status: 'trash' } ) ); - } catch ( error ) { - dispatch( { - ...action, - type: 'TRASH_POST_FAILURE', - error, - } ); - } -}; - -/** - * Trash Post Failure Effect handler - * - * @param {Object} action action object. - * @param {Object} store Redux Store. - */ -export const trashPostFailure = ( action ) => { - const message = action.error.message && action.error.code !== 'unknown_error' ? action.error.message : __( 'Trashing failed' ); - dataDispatch( 'core/notices' ).createErrorNotice( message, { - id: TRASH_POST_NOTICE_ID, - } ); -}; - -/** - * Refresh Post Effect handler - * - * @param {Object} action action object. - * @param {Object} store Redux Store. - */ -export const refreshPost = async ( action, store ) => { - const { dispatch, getState } = store; - - const state = getState(); - const post = getCurrentPost( state ); - const postTypeSlug = getCurrentPostType( getState() ); - const postType = await resolveSelector( 'core', 'getPostType', postTypeSlug ); - const newPost = await apiFetch( { - // Timestamp arg allows caller to bypass browser caching, which is expected for this specific function. - path: `/wp/v2/${ postType.rest_base }/${ post.id }?context=edit&_timestamp=${ Date.now() }`, - } ); - dispatch( resetPost( newPost ) ); -}; diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index bc7b51a604fad5..42af629bcce0d3 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -11,13 +11,9 @@ import applyMiddlewares from './middlewares'; import * as selectors from './selectors'; import * as actions from './actions'; import controls from './controls'; +import { STORE_KEY } from './constants'; -/** - * Module Constants - */ -const MODULE_KEY = 'core/editor'; - -const store = registerStore( MODULE_KEY, { +const store = registerStore( STORE_KEY, { reducer, selectors, actions, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index c3147abda70631..c4d97cbd741baf 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -27,18 +27,12 @@ import { createRegistrySelector } from '@wordpress/data'; * Internal dependencies */ import { PREFERENCES_DEFAULTS } from './defaults'; -import { EDIT_MERGE_PROPERTIES } from './constants'; - -/*** - * Module constants - */ -export const POST_UPDATE_TRANSACTION_ID = 'post-update'; -const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; -export const INSERTER_UTILITY_HIGH = 3; -export const INSERTER_UTILITY_MEDIUM = 2; -export const INSERTER_UTILITY_LOW = 1; -export const INSERTER_UTILITY_NONE = 0; -const ONE_MINUTE_IN_MS = 60 * 1000; +import { + EDIT_MERGE_PROPERTIES, + POST_UPDATE_TRANSACTION_ID, + PERMALINK_POSTNAME_REGEX, + ONE_MINUTE_IN_MS, +} from './constants'; /** * Shared reference to an empty object for cases where it is important to avoid @@ -124,7 +118,7 @@ export function isEditedPostDirty( state ) { return true; } - // Edits and change detectiona are reset at the start of a save, but a post + // Edits and change detection are reset at the start of a save, but a post // is still considered dirty until the point at which the save completes. // Because the save is performed optimistically, the prior states are held // until committed. These can be referenced to determine whether there's a @@ -264,7 +258,7 @@ export function getCurrentPostAttribute( state, attributeName ) { /** * Returns a single attribute of the post being edited, preferring the unsaved - * edit if one exists, but mergiging with the attribute value for the last known + * edit if one exists, but merging with the attribute value for the last known * saved state of the post (this is needed for some nested attributes like meta). * * @param {Object} state Global application state. diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index ec46b287c394e9..296a365fe4c490 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -1,26 +1,635 @@ +/** + * External dependencies + */ +import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; + /** * Internal dependencies */ +import * as actions from '../actions'; +import { select, dispatch, apiFetch, resolveSelect } from '../controls'; import { - __experimentalFetchReusableBlocks as fetchReusableBlocks, - __experimentalSaveReusableBlock as saveReusableBlock, - __experimentalDeleteReusableBlock as deleteReusableBlock, - __experimentalConvertBlockToStatic as convertBlockToStatic, - __experimentalConvertBlockToReusable as convertBlockToReusable, - setupEditor, - resetPost, - editPost, - savePost, - trashPost, - redo, - undo, -} from '../actions'; + STORE_KEY, + SAVE_POST_NOTICE_ID, + TRASH_POST_NOTICE_ID, + POST_UPDATE_TRANSACTION_ID, +} from '../constants'; + +jest.mock( '../controls' ); + +select.mockImplementation( ( ...args ) => { + const { select: actualSelect } = jest.requireActual( '../controls' ); + return actualSelect( ...args ); +} ); + +dispatch.mockImplementation( ( ...args ) => { + const { dispatch: actualDispatch } = jest.requireActual( '../controls' ); + return actualDispatch( ...args ); +} ); + +resolveSelect.mockImplementation( ( ...args ) => { + const { resolveSelect: selectResolver } = jest + .requireActual( '../controls' ); + return selectResolver( ...args ); +} ); + +const apiFetchThrowError = ( error ) => { + apiFetch.mockClear(); + apiFetch.mockImplementation( () => { + throw error; + } ); +}; + +const apiFetchDoActual = () => { + apiFetch.mockClear(); + apiFetch.mockImplementation( ( ...args ) => { + const { apiFetch: fetch } = jest.requireActual( '../controls' ); + return fetch( ...args ); + } ); +}; + +const postType = { + rest_base: 'posts', + labels: { + item_updated: 'Updated Post', + item_published: 'Post published', + }, +}; +const postTypeSlug = 'post'; + +describe( 'Post generator actions', () => { + describe( 'savePost()', () => { + let fulfillment, + edits, + currentPost, + currentPostStatus, + editPostToSendOptimistic, + autoSavePost, + autoSavePostToSend, + savedPost, + savedPostStatus, + isAutosave, + isEditedPostNew, + savedPostMessage; + beforeEach( () => { + edits = ( defaultStatus = null ) => { + const postObject = { + title: 'foo', + content: 'bar', + excerpt: 'cheese', + foo: 'bar', + }; + if ( defaultStatus !== null ) { + postObject.status = defaultStatus; + } + return postObject; + }; + currentPost = () => ( { + id: 44, + title: 'bar', + content: 'bar', + excerpt: 'crackers', + status: currentPostStatus, + } ); + editPostToSendOptimistic = () => { + const postObject = { + ...edits(), + content: editedPostContent, + id: currentPost().id, + }; + if ( ! postObject.status && isEditedPostNew ) { + postObject.status = 'draft'; + } + if ( isAutosave ) { + delete postObject.foo; + } + return postObject; + }; + autoSavePost = { status: 'autosave', bar: 'foo' }; + autoSavePostToSend = () => ( + { + ...editPostToSendOptimistic(), + bar: 'foo', + status: 'autosave', + } + ); + savedPost = () => ( + { + ...currentPost(), + ...editPostToSendOptimistic(), + content: editedPostContent, + status: savedPostStatus, + } + ); + } ); + const editedPostContent = 'to infinity and beyond'; + const reset = ( isAutosaving ) => fulfillment = actions.savePost( + { isAutosave: isAutosaving } + ); + const rewind = ( isAutosaving, isNewPost ) => { + reset( isAutosaving ); + fulfillment.next(); + fulfillment.next( true ); + fulfillment.next( edits() ); + fulfillment.next( isNewPost ); + fulfillment.next( currentPost() ); + fulfillment.next( editedPostContent ); + fulfillment.next( postTypeSlug ); + fulfillment.next( postType ); + fulfillment.next(); + if ( isAutosaving ) { + fulfillment.next(); + } else { + fulfillment.next(); + fulfillment.next(); + } + }; + const initialTestConditions = [ + [ + 'yields action for selecting if edited post is saveable', + () => true, + () => { + reset( isAutosave ); + const { value } = fulfillment.next(); + expect( value ).toEqual( + select( STORE_KEY, 'isEditedPostSaveable' ) + ); + }, + ], + [ + 'yields action for selecting the post edits done', + () => true, + () => { + const { value } = fulfillment.next( true ); + expect( value ).toEqual( + select( STORE_KEY, 'getPostEdits' ) + ); + }, + ], + [ + 'yields action for selecting whether the edited post is new', + () => true, + () => { + const { value } = fulfillment.next( edits() ); + expect( value ).toEqual( + select( STORE_KEY, 'isEditedPostNew' ) + ); + }, + ], + [ + 'yields action for selecting the current post', + () => true, + () => { + const { value } = fulfillment.next( isEditedPostNew ); + expect( value ).toEqual( + select( STORE_KEY, 'getCurrentPost' ) + ); + }, + ], + [ + 'yields action for selecting the edited post content', + () => true, + () => { + const { value } = fulfillment.next( currentPost() ); + expect( value ).toEqual( + select( STORE_KEY, 'getEditedPostContent' ) + ); + }, + ], + [ + 'yields action for selecting current post type slug', + () => true, + () => { + const { value } = fulfillment.next( editedPostContent ); + expect( value ).toEqual( + select( STORE_KEY, 'getCurrentPostType' ) + ); + }, + ], + [ + 'yields action for selecting the post type object', + () => true, + () => { + const { value } = fulfillment.next( postTypeSlug ); + expect( value ).toEqual( + resolveSelect( 'core', 'getPostType', postTypeSlug ) + ); + }, + ], + [ + 'yields action for dispatching request post update start', + () => true, + () => { + const { value } = fulfillment.next( postType ); + expect( value ).toEqual( + dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateStart', + { isAutosave } + ) + ); + }, + ], + [ + 'yields action for dispatching optimistic update of post', + () => true, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( + dispatch( + STORE_KEY, + '__experimentalOptimisticUpdatePost', + editPostToSendOptimistic() + ) + ); + }, + ], + [ + 'yields action for dispatching the removal of save post notice', + ( isAutosaving ) => ! isAutosaving, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( + dispatch( + 'core/notices', + 'removeNotice', + SAVE_POST_NOTICE_ID, + ) + ); + }, + ], + [ + 'yields action for dispatching the removal of autosave notice', + ( isAutosaving ) => ! isAutosaving, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( + dispatch( + 'core/notices', + 'removeNotice', + 'autosave-exists' + ) + ); + }, + ], + [ + 'yield action for selecting the autoSavePost', + ( isAutosaving ) => isAutosaving, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( + select( + STORE_KEY, + 'getAutosave' + ) + ); + }, + ], + ]; + const fetchErrorConditions = [ + [ + 'yields action for dispatching post update failure', + () => { + const error = { foo: 'bar', code: 'fail' }; + apiFetchThrowError( error ); + const editsObject = edits(); + const { value } = isAutosave ? + fulfillment.next( autoSavePost ) : + fulfillment.next(); + if ( isAutosave ) { + delete editsObject.foo; + } + expect( value ).toEqual( + dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateFailure', + { + post: currentPost(), + edits: isEditedPostNew ? + { ...editsObject, status: 'draft' } : + editsObject, + error, + options: { isAutosave }, + } + ) + ); + }, + ], + [ + 'yields action for dispatching an appropriate error notice', + () => { + const { value } = fulfillment.next( [ 'foo', 'bar' ] ); + expect( value ).toEqual( + dispatch( + 'core/notices', + 'createErrorNotice', + ...[ 'Updating failed', { id: 'SAVE_POST_NOTICE_ID' } ] + ) + ); + }, + ], + ]; + const fetchSuccessConditions = [ + [ + 'yields action for updating the post via the api', + () => { + apiFetchDoActual(); + rewind( isAutosave, isEditedPostNew ); + const { value } = isAutosave ? + fulfillment.next( autoSavePost ) : + fulfillment.next(); + const data = isAutosave ? + autoSavePostToSend() : + editPostToSendOptimistic(); + const path = isAutosave ? '/autosaves' : ''; + expect( value ).toEqual( + apiFetch( + { + path: `/wp/v2/${ postType.rest_base }/${ editPostToSendOptimistic().id }${ path }`, + method: isAutosave ? 'POST' : 'PUT', + data, + } + ) + ); + }, + ], + [ + 'yields action for dispatch the appropriate reset action', + () => { + const { value } = fulfillment.next( savedPost() ); + expect( value ).toEqual( + dispatch( + STORE_KEY, + isAutosave ? 'resetAutosave' : 'resetPost', + savedPost() + ) + ); + }, + ], + [ + 'yields action for dispatching the post update success', + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( + dispatch( + STORE_KEY, + '__experimentalRequestPostUpdateSuccess', + { + previousPost: currentPost(), + post: savedPost(), + options: { isAutosave }, + postType, + isRevision: false, + } + ) + ); + }, + ], + [ + 'yields dispatch action for success notification', + () => { + const { value } = fulfillment.next( [ 'foo', 'bar' ] ); + const expected = isAutosave ? + undefined : + dispatch( + 'core/notices', + 'createSuccessNotice', + ...[ + savedPostMessage, + { actions: [], id: 'SAVE_POST_NOTICE_ID' }, + ] + ); + expect( value ).toEqual( expected ); + }, + ], + ]; + + const conditionalRunTestRoutine = ( isAutosaving ) => ( [ + testDescription, + shouldRun, + testRoutine, + ] ) => { + if ( shouldRun( isAutosaving ) ) { + it( testDescription, () => { + testRoutine(); + } ); + } + }; + + const testRunRoutine = ( [ testDescription, testRoutine ] ) => { + it( testDescription, () => { + testRoutine(); + } ); + }; + + describe( 'yields with expected responses when edited post is not ' + + 'saveable', () => { + it( 'yields action for selecting if edited post is saveable', () => { + reset( false ); + const { value } = fulfillment.next(); + expect( value ).toEqual( + select( STORE_KEY, 'isEditedPostSaveable' ) + ); + } ); + it( 'if edited post is not saveable then bails', () => { + const { value, done } = fulfillment.next( false ); + expect( done ).toBe( true ); + expect( value ).toBeUndefined(); + } ); + } ); + describe( 'yields with expected responses for when not autosaving ' + + 'and edited post is new', () => { + beforeEach( () => { + isAutosave = false; + isEditedPostNew = true; + savedPostStatus = 'publish'; + currentPostStatus = 'draft'; + savedPostMessage = 'Post published'; + } ); + initialTestConditions.forEach( conditionalRunTestRoutine( false ) ); + describe( 'fetch action throwing an error', () => { + fetchErrorConditions.forEach( testRunRoutine ); + } ); + describe( 'fetch action not throwing an error', () => { + fetchSuccessConditions.forEach( testRunRoutine ); + } ); + } ); + + describe( 'yields with expected responses for when not autosaving ' + + 'and edited post is not new', () => { + beforeEach( () => { + isAutosave = false; + isEditedPostNew = false; + currentPostStatus = 'publish'; + savedPostStatus = 'publish'; + savedPostMessage = 'Updated Post'; + } ); + initialTestConditions.forEach( conditionalRunTestRoutine( false ) ); + describe( 'fetch action throwing error', () => { + fetchErrorConditions.forEach( testRunRoutine ); + } ); + describe( 'fetch action not throwing error', () => { + fetchSuccessConditions.forEach( testRunRoutine ); + } ); + } ); + describe( 'yields with expected responses for when autosaving is true ' + + 'and edited post is not new', () => { + beforeEach( () => { + isAutosave = true; + isEditedPostNew = false; + currentPostStatus = 'autosave'; + savedPostStatus = 'publish'; + savedPostMessage = 'Post published'; + } ); + initialTestConditions.forEach( conditionalRunTestRoutine( true ) ); + describe( 'fetch action throwing error', () => { + fetchErrorConditions.forEach( testRunRoutine ); + } ); + describe( 'fetch action not throwing error', () => { + fetchSuccessConditions.forEach( testRunRoutine ); + } ); + } ); + } ); + describe( 'autosave()', () => { + it( 'dispatches savePost with the correct arguments', () => { + const fulfillment = actions.autosave(); + const { value } = fulfillment.next(); + expect( value.actionName ).toBe( 'savePost' ); + expect( value.args ).toEqual( [ { isAutosave: true } ] ); + } ); + } ); + describe( 'trashPost()', () => { + let fulfillment; + const currentPost = { id: 10, content: 'foo', status: 'publish' }; + const reset = () => fulfillment = actions.trashPost(); + const rewind = () => { + reset(); + fulfillment.next(); + fulfillment.next( postTypeSlug ); + fulfillment.next( postType ); + fulfillment.next(); + }; + it( 'yields expected action for selecting the current post type slug', + () => { + reset(); + const { value } = fulfillment.next(); + expect( value ).toEqual( select( + STORE_KEY, + 'getCurrentPostType', + ) ); + } + ); + it( 'yields expected action for selecting the post type object', () => { + const { value } = fulfillment.next( postTypeSlug ); + expect( value ).toEqual( resolveSelect( + 'core', + 'getPostType', + postTypeSlug + ) ); + } ); + it( 'yields expected action for dispatching removing the trash notice ' + + 'for the post', () => { + const { value } = fulfillment.next( postType ); + expect( value ).toEqual( dispatch( + 'core/notices', + 'removeNotice', + TRASH_POST_NOTICE_ID + ) ); + } ); + it( 'yields expected action for selecting the currentPost', () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( select( + STORE_KEY, + 'getCurrentPost' + ) ); + } ); + describe( 'expected yields when fetch throws an error', () => { + it( 'yields expected action for dispatching an error notice', () => { + const error = { foo: 'bar', code: 'fail' }; + apiFetchThrowError( error ); + const { value } = fulfillment.next( currentPost ); + expect( value ).toEqual( dispatch( + 'core/notices', + 'createErrorNotice', + 'Trashing failed', + { id: TRASH_POST_NOTICE_ID }, + ) ); + } ); + } ); + describe( 'expected yields when fetch does not throw an error', () => { + it( 'yields expected action object for the api fetch', () => { + apiFetchDoActual(); + rewind(); + const { value } = fulfillment.next( currentPost ); + expect( value ).toEqual( apiFetch( + { + path: `/wp/v2/${ postType.rest_base }/${ currentPost.id }`, + method: 'DELETE', + } + ) ); + } ); + it( 'yields expected dispatch action for resetting the post', () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( dispatch( + STORE_KEY, + 'resetPost', + { ...currentPost, status: 'trash' } + ) ); + } ); + } ); + } ); + describe( 'refreshPost()', () => { + let fulfillment; + const currentPost = { id: 10, content: 'foo' }; + const reset = () => fulfillment = actions.refreshPost(); + it( 'yields expected action for selecting the currentPost', () => { + reset(); + const { value } = fulfillment.next(); + expect( value ).toEqual( select( + STORE_KEY, + 'getCurrentPost', + ) ); + } ); + it( 'yields expected action for selecting the current post type', () => { + const { value } = fulfillment.next( currentPost ); + expect( value ).toEqual( select( + STORE_KEY, + 'getCurrentPostType' + ) ); + } ); + it( 'yields expected action for selecting the post type object', () => { + const { value } = fulfillment.next( postTypeSlug ); + expect( value ).toEqual( resolveSelect( + 'core', + 'getPostType', + postTypeSlug + ) ); + } ); + it( 'yields expected action for the api fetch call', () => { + const { value } = fulfillment.next( postType ); + apiFetchDoActual(); + // since the timestamp is a computed value we can't do a direct comparison. + // so we'll just see if the path has most of the value. + expect( value.request.path ).toEqual( expect.stringContaining( + `/wp/v2/${ postType.rest_base }/${ currentPost.id }?context=edit&_timestamp=` + ) ); + } ); + it( 'yields expected action for dispatching the reset of the post', () => { + const { value } = fulfillment.next( currentPost ); + expect( value ).toEqual( dispatch( + STORE_KEY, + 'resetPost', + currentPost + ) ); + } ); + } ); +} ); describe( 'actions', () => { describe( 'setupEditor', () => { it( 'should return the SETUP_EDITOR action', () => { const post = {}; - const result = setupEditor( post ); + const result = actions.setupEditor( post ); expect( result ).toEqual( { type: 'SETUP_EDITOR', post, @@ -31,7 +640,7 @@ describe( 'actions', () => { describe( 'resetPost', () => { it( 'should return the RESET_POST action', () => { const post = {}; - const result = resetPost( post ); + const result = actions.resetPost( post ); expect( result ).toEqual( { type: 'RESET_POST', post, @@ -39,47 +648,103 @@ describe( 'actions', () => { } ); } ); - describe( 'editPost', () => { - it( 'should return EDIT_POST action', () => { - const edits = { format: 'sample' }; - expect( editPost( edits ) ).toEqual( { - type: 'EDIT_POST', - edits, + describe( 'resetAutosave', () => { + it( 'should return the RESET_AUTOSAVE action', () => { + const post = {}; + const result = actions.resetAutosave( post ); + expect( result ).toEqual( { + type: 'RESET_AUTOSAVE', + post, } ); } ); } ); - describe( 'savePost', () => { - it( 'should return REQUEST_POST_UPDATE action', () => { - expect( savePost() ).toEqual( { - type: 'REQUEST_POST_UPDATE', + describe( 'requestPostUpdateStart', () => { + it( 'should return the REQUEST_POST_UPDATE_START action', () => { + const result = actions.__experimentalRequestPostUpdateStart(); + expect( result ).toEqual( { + type: 'REQUEST_POST_UPDATE_START', + optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, options: {}, } ); } ); + } ); - it( 'should pass through options argument', () => { - expect( savePost( { autosave: true } ) ).toEqual( { - type: 'REQUEST_POST_UPDATE', - options: { autosave: true }, + describe( 'requestPostUpdateSuccess', () => { + it( 'should return the REQUEST_POST_UPDATE_SUCCESS action', () => { + const testActionData = { + previousPost: {}, + post: {}, + options: {}, + postType: 'post', + }; + const result = actions.__experimentalRequestPostUpdateSuccess( { + ...testActionData, + isRevision: false, + } ); + expect( result ).toEqual( { + ...testActionData, + type: 'REQUEST_POST_UPDATE_SUCCESS', + optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, } ); } ); } ); - describe( 'trashPost', () => { - it( 'should return TRASH_POST action', () => { - const postId = 1; - const postType = 'post'; - expect( trashPost( postId, postType ) ).toEqual( { - type: 'TRASH_POST', - postId, - postType, + describe( 'requestPostUpdateFailure', () => { + it( 'should return the REQUEST_POST_UPDATE_FAILURE action', () => { + const testActionData = { + post: {}, + options: {}, + edits: {}, + error: {}, + }; + const result = actions.__experimentalRequestPostUpdateFailure( + testActionData + ); + expect( result ).toEqual( { + ...testActionData, + type: 'REQUEST_POST_UPDATE_FAILURE', + optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, + } ); + } ); + } ); + + describe( 'updatePost', () => { + it( 'should return the UPDATE_POST action', () => { + const edits = {}; + const result = actions.updatePost( edits ); + expect( result ).toEqual( { + type: 'UPDATE_POST', + edits, + } ); + } ); + } ); + + describe( 'editPost', () => { + it( 'should return EDIT_POST action', () => { + const edits = { format: 'sample' }; + expect( actions.editPost( edits ) ).toEqual( { + type: 'EDIT_POST', + edits, + } ); + } ); + } ); + + describe( 'optimisticUpdatePost', () => { + it( 'should return the UPDATE_POST action with optimist property', () => { + const edits = {}; + const result = actions.__experimentalOptimisticUpdatePost( edits ); + expect( result ).toEqual( { + type: 'UPDATE_POST', + edits, + optimist: { id: POST_UPDATE_TRANSACTION_ID }, } ); } ); } ); describe( 'redo', () => { it( 'should return REDO action', () => { - expect( redo() ).toEqual( { + expect( actions.redo() ).toEqual( { type: 'REDO', } ); } ); @@ -87,7 +752,7 @@ describe( 'actions', () => { describe( 'undo', () => { it( 'should return UNDO action', () => { - expect( undo() ).toEqual( { + expect( actions.undo() ).toEqual( { type: 'UNDO', } ); } ); @@ -95,13 +760,13 @@ describe( 'actions', () => { describe( 'fetchReusableBlocks', () => { it( 'should return the FETCH_REUSABLE_BLOCKS action', () => { - expect( fetchReusableBlocks() ).toEqual( { + expect( actions.__experimentalFetchReusableBlocks() ).toEqual( { type: 'FETCH_REUSABLE_BLOCKS', } ); } ); it( 'should take an optional id argument', () => { - expect( fetchReusableBlocks( 123 ) ).toEqual( { + expect( actions.__experimentalFetchReusableBlocks( 123 ) ).toEqual( { type: 'FETCH_REUSABLE_BLOCKS', id: 123, } ); @@ -110,7 +775,7 @@ describe( 'actions', () => { describe( 'saveReusableBlock', () => { it( 'should return the SAVE_REUSABLE_BLOCK action', () => { - expect( saveReusableBlock( 123 ) ).toEqual( { + expect( actions.__experimentalSaveReusableBlock( 123 ) ).toEqual( { type: 'SAVE_REUSABLE_BLOCK', id: 123, } ); @@ -119,7 +784,7 @@ describe( 'actions', () => { describe( 'deleteReusableBlock', () => { it( 'should return the DELETE_REUSABLE_BLOCK action', () => { - expect( deleteReusableBlock( 123 ) ).toEqual( { + expect( actions.__experimentalDeleteReusableBlock( 123 ) ).toEqual( { type: 'DELETE_REUSABLE_BLOCK', id: 123, } ); @@ -129,7 +794,7 @@ describe( 'actions', () => { describe( 'convertBlockToStatic', () => { it( 'should return the CONVERT_BLOCK_TO_STATIC action', () => { const clientId = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( convertBlockToStatic( clientId ) ).toEqual( { + expect( actions.__experimentalConvertBlockToStatic( clientId ) ).toEqual( { type: 'CONVERT_BLOCK_TO_STATIC', clientId, } ); @@ -139,10 +804,30 @@ describe( 'actions', () => { describe( 'convertBlockToReusable', () => { it( 'should return the CONVERT_BLOCK_TO_REUSABLE action', () => { const clientId = '358b59ee-bab3-4d6f-8445-e8c6971a5605'; - expect( convertBlockToReusable( clientId ) ).toEqual( { + expect( actions.__experimentalConvertBlockToReusable( clientId ) ).toEqual( { type: 'CONVERT_BLOCK_TO_REUSABLE', clientIds: [ clientId ], } ); } ); } ); + + describe( 'lockPostSaving', () => { + it( 'should return the LOCK_POST_SAVING action', () => { + const result = actions.lockPostSaving( 'test' ); + expect( result ).toEqual( { + type: 'LOCK_POST_SAVING', + lockName: 'test', + } ); + } ); + } ); + + describe( 'unlockPostSaving', () => { + it( 'should return the UNLOCK_POST_SAVING action', () => { + const result = actions.unlockPostSaving( 'test' ); + expect( result ).toEqual( { + type: 'UNLOCK_POST_SAVING', + lockName: 'test', + } ); + } ); + } ); } ); diff --git a/packages/editor/src/store/test/effects.js b/packages/editor/src/store/test/effects.js index 2e9170d2175a0a..cdc7223858e0fe 100644 --- a/packages/editor/src/store/test/effects.js +++ b/packages/editor/src/store/test/effects.js @@ -6,214 +6,17 @@ import { unregisterBlockType, registerBlockType, } from '@wordpress/blocks'; -import { dispatch as dataDispatch } from '@wordpress/data'; /** * Internal dependencies */ import { setupEditorState, resetEditorBlocks } from '../actions'; import effects from '../effects'; -import { SAVE_POST_NOTICE_ID } from '../effects/posts'; import '../../'; describe( 'effects', () => { - beforeAll( () => { - jest.spyOn( dataDispatch( 'core/notices' ), 'createErrorNotice' ); - jest.spyOn( dataDispatch( 'core/notices' ), 'createSuccessNotice' ); - } ); - - beforeEach( () => { - dataDispatch( 'core/notices' ).createErrorNotice.mockReset(); - dataDispatch( 'core/notices' ).createSuccessNotice.mockReset(); - } ); - const defaultBlockSettings = { save: () => 'Saved', category: 'common', title: 'block title' }; - describe( '.REQUEST_POST_UPDATE_SUCCESS', () => { - const handler = effects.REQUEST_POST_UPDATE_SUCCESS; - - const defaultPost = { - id: 1, - title: { - raw: 'A History of Pork', - }, - content: { - raw: '', - }, - }; - const getDraftPost = () => ( { - ...defaultPost, - status: 'draft', - } ); - const getPublishedPost = () => ( { - ...defaultPost, - status: 'publish', - } ); - const getPostType = () => ( { - labels: { - view_item: 'View post', - item_published: 'Post published.', - item_reverted_to_draft: 'Post reverted to draft.', - item_updated: 'Post updated.', - }, - viewable: true, - } ); - - it( 'should dispatch notices when publishing or scheduling a post', () => { - const previousPost = getDraftPost(); - const post = getPublishedPost(); - const postType = getPostType(); - - handler( { post, previousPost, postType } ); - - expect( dataDispatch( 'core/notices' ).createSuccessNotice ).toHaveBeenCalledWith( - 'Post published.', - { - id: SAVE_POST_NOTICE_ID, - actions: [ - { label: 'View post', url: undefined }, - ], - } - ); - } ); - - it( 'should dispatch notices when publishing or scheduling an unviewable post', () => { - const previousPost = getDraftPost(); - const post = getPublishedPost(); - const postType = { ...getPostType(), viewable: false }; - - handler( { post, previousPost, postType } ); - - expect( dataDispatch( 'core/notices' ).createSuccessNotice ).toHaveBeenCalledWith( - 'Post published.', - { - id: SAVE_POST_NOTICE_ID, - actions: [], - } - ); - } ); - - it( 'should dispatch notices when reverting a published post to a draft', () => { - const previousPost = getPublishedPost(); - const post = getDraftPost(); - const postType = getPostType(); - - handler( { post, previousPost, postType } ); - - expect( dataDispatch( 'core/notices' ).createSuccessNotice ).toHaveBeenCalledWith( - 'Post reverted to draft.', - { - id: SAVE_POST_NOTICE_ID, - actions: [], - } - ); - } ); - - it( 'should dispatch notices when just updating a published post again', () => { - const previousPost = getPublishedPost(); - const post = getPublishedPost(); - const postType = getPostType(); - - handler( { post, previousPost, postType } ); - - expect( dataDispatch( 'core/notices' ).createSuccessNotice ).toHaveBeenCalledWith( - 'Post updated.', - { - id: SAVE_POST_NOTICE_ID, - actions: [ - { label: 'View post', url: undefined }, - ], - } - ); - } ); - - it( 'should do nothing if the updated post was autosaved', () => { - const previousPost = getPublishedPost(); - const post = { ...getPublishedPost(), id: defaultPost.id + 1 }; - - handler( { post, previousPost, options: { isAutosave: true } } ); - - expect( dataDispatch( 'core/notices' ).createSuccessNotice ).not.toHaveBeenCalled(); - } ); - } ); - - describe( '.REQUEST_POST_UPDATE_FAILURE', () => { - it( 'should dispatch a notice on failure when publishing a draft fails.', () => { - const handler = effects.REQUEST_POST_UPDATE_FAILURE; - - const action = { - post: { - id: 1, - title: { - raw: 'A History of Pork', - }, - content: { - raw: '', - }, - status: 'draft', - }, - edits: { - status: 'publish', - }, - }; - - handler( action ); - - expect( dataDispatch( 'core/notices' ).createErrorNotice ).toHaveBeenCalledWith( 'Publishing failed', { id: SAVE_POST_NOTICE_ID } ); - } ); - - it( 'should not dispatch a notice when there were no changes for autosave to save.', () => { - const handler = effects.REQUEST_POST_UPDATE_FAILURE; - - const action = { - post: { - id: 1, - title: { - raw: 'A History of Pork', - }, - content: { - raw: '', - }, - status: 'draft', - }, - edits: { - status: 'publish', - }, - error: { - code: 'rest_autosave_no_changes', - }, - }; - - handler( action ); - - expect( dataDispatch( 'core/notices' ).createErrorNotice ).not.toHaveBeenCalled(); - } ); - - it( 'should dispatch a notice on failure when trying to update a draft.', () => { - const handler = effects.REQUEST_POST_UPDATE_FAILURE; - - const action = { - post: { - id: 1, - title: { - raw: 'A History of Pork', - }, - content: { - raw: '', - }, - status: 'draft', - }, - edits: { - status: 'draft', - }, - }; - - handler( action ); - - expect( dataDispatch( 'core/notices' ).createErrorNotice ).toHaveBeenCalledWith( 'Updating failed', { id: SAVE_POST_NOTICE_ID } ); - } ); - } ); - describe( '.SETUP_EDITOR', () => { const handler = effects.SETUP_EDITOR; diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 459161ee038014..01f2d09199e52b 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -22,6 +22,7 @@ import { RawHTML } from '@wordpress/element'; */ import * as selectors from '../selectors'; import { PREFERENCES_DEFAULTS } from '../defaults'; +import { POST_UPDATE_TRANSACTION_ID } from '../constants'; const { hasEditorUndo, @@ -64,7 +65,6 @@ const { getStateBeforeOptimisticTransaction, isPublishingPost, isPublishSidebarEnabled, - POST_UPDATE_TRANSACTION_ID, isPermalinkEditable, getPermalink, getPermalinkParts, diff --git a/packages/editor/src/store/utils/notice-builder.js b/packages/editor/src/store/utils/notice-builder.js new file mode 100644 index 00000000000000..4ef98c74e3a548 --- /dev/null +++ b/packages/editor/src/store/utils/notice-builder.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { SAVE_POST_NOTICE_ID, TRASH_POST_NOTICE_ID } from '../constants'; + +/** + * External dependencies + */ +import { get, includes } from 'lodash'; + +/** + * Builds the arguments for a success notification dispatch. + * + * @param {Object} data Incoming data to build the arguments from. + * + * @return {Array} Arguments for dispatch. An empty array signals no + * notification should be sent. + */ +export function getNotificationArgumentsForSaveSuccess( data ) { + const { previousPost, post, postType } = data; + // Autosaves are neither shown a notice nor redirected. + if ( get( data.options, [ 'isAutosave' ] ) ) { + return []; + } + + const publishStatus = [ 'publish', 'private', 'future' ]; + const isPublished = includes( publishStatus, previousPost.status ); + const willPublish = includes( publishStatus, post.status ); + + let noticeMessage; + let shouldShowLink = get( postType, [ 'viewable' ], false ); + + if ( ! isPublished && ! willPublish ) { + // If saving a non-published post, don't show notice. + noticeMessage = null; + } else if ( isPublished && ! willPublish ) { + // If undoing publish status, show specific notice + noticeMessage = postType.labels.item_reverted_to_draft; + shouldShowLink = false; + } else if ( ! isPublished && willPublish ) { + // If publishing or scheduling a post, show the corresponding + // publish message + noticeMessage = { + publish: postType.labels.item_published, + private: postType.labels.item_published_privately, + future: postType.labels.item_scheduled, + }[ post.status ]; + } else { + // Generic fallback notice + noticeMessage = postType.labels.item_updated; + } + + if ( noticeMessage ) { + const actions = []; + if ( shouldShowLink ) { + actions.push( { + label: postType.labels.view_item, + url: post.link, + } ); + } + return [ + noticeMessage, + { + id: SAVE_POST_NOTICE_ID, + actions, + }, + ]; + } + return []; +} + +/** + * Builds the fail notification arguments for dispatch. + * + * @param {Object} data Incoming data to build the arguments with. + * + * @return {Array} Arguments for dispatch. An empty array signals no + * notification should be sent. + */ +export function getNotificationArgumentsForSaveFail( data ) { + const { post, edits, error } = data; + if ( error && 'rest_autosave_no_changes' === error.code ) { + // Autosave requested a new autosave, but there were no changes. This shouldn't + // result in an error notice for the user. + return []; + } + + const publishStatus = [ 'publish', 'private', 'future' ]; + const isPublished = publishStatus.indexOf( post.status ) !== -1; + // If the post was being published, we show the corresponding publish error message + // Unless we publish an "updating failed" message + const messages = { + publish: __( 'Publishing failed' ), + private: __( 'Publishing failed' ), + future: __( 'Scheduling failed' ), + }; + const noticeMessage = ! isPublished && publishStatus.indexOf( edits.status ) !== -1 ? + messages[ edits.status ] : + __( 'Updating failed' ); + + return [ noticeMessage, { id: SAVE_POST_NOTICE_ID } ]; +} + +/** + * Builds the trash fail notification arguments for dispatch. + * + * @param {Object} data + * + * @return {Array} Arguments for dispatch. + */ +export function getNotificationArgumentsForTrashFail( data ) { + return [ + data.error.message && data.error.code !== 'unknown_error' ? + data.error.message : + __( 'Trashing failed' ), + { id: TRASH_POST_NOTICE_ID }, + ]; +} diff --git a/packages/editor/src/store/utils/test/notice-builder.js b/packages/editor/src/store/utils/test/notice-builder.js new file mode 100644 index 00000000000000..a78d03f81fad79 --- /dev/null +++ b/packages/editor/src/store/utils/test/notice-builder.js @@ -0,0 +1,182 @@ +/** + * Internal dependencies + */ +import { + getNotificationArgumentsForSaveSuccess, + getNotificationArgumentsForSaveFail, + getNotificationArgumentsForTrashFail, +} from '../notice-builder'; +import { + SAVE_POST_NOTICE_ID, + TRASH_POST_NOTICE_ID, +} from '../../constants'; + +describe( 'getNotificationArgumentsForSaveSuccess()', () => { + const postType = { + labels: { + item_reverted_to_draft: 'draft', + item_published: 'publish', + item_published_privately: 'private', + item_scheduled: 'scheduled', + item_updated: 'updated', + view_item: 'view', + }, + viewable: false, + }; + const previousPost = { + status: 'publish', + link: 'some_link', + }; + const post = { ...previousPost }; + const defaultExpectedAction = { id: SAVE_POST_NOTICE_ID, actions: [] }; + [ + [ + 'when previous post is not published and post will not be published', + [ 'draft', 'draft', false ], + [], + ], + [ + 'when previous post is published and post will be unpublished', + [ 'publish', 'draft', false ], + [ 'draft', defaultExpectedAction ], + ], + [ + 'when previous post is not published and post will be published', + [ 'draft', 'publish', false ], + [ 'publish', defaultExpectedAction ], + ], + [ + 'when previous post is not published and post will be privately ' + + 'published', + [ 'draft', 'private', false ], + [ 'private', defaultExpectedAction ], + ], + [ + 'when previous post is not published and post will be scheduled for ' + + 'publishing', + [ 'draft', 'future', false ], + [ 'scheduled', defaultExpectedAction ], + ], + [ + 'when both are considered published', + [ 'private', 'publish', false ], + [ 'updated', defaultExpectedAction ], + ], + [ + 'when both are considered published and the post type is viewable', + [ 'private', 'publish', true ], + [ + 'updated', + { + ...defaultExpectedAction, + actions: [ { label: 'view', url: 'some_link' } ], + }, + ], + ], + ].forEach( ( [ + description, + [ previousPostStatus, postStatus, isViewable ], + expectedValue, + ] ) => { + it( description, () => { + previousPost.status = previousPostStatus; + post.status = postStatus; + postType.viewable = isViewable; + expect( getNotificationArgumentsForSaveSuccess( + { + previousPost, + post, + postType, + } + ) ).toEqual( expectedValue ); + } ); + } ); +} ); +describe( 'getNotificationArgumentsForSaveFail()', () => { + const error = { code: '42' }; + const post = { status: 'publish' }; + const edits = { status: 'publish' }; + const defaultExpectedAction = { id: SAVE_POST_NOTICE_ID }; + [ + [ + 'when error code is `rest_autosave_no_changes`', + 'rest_autosave_no_changes', + [ 'publish', 'publish' ], + [], + ], + [ + 'when post is not published and edits is published', + '', + [ 'draft', 'publish' ], + [ 'Publishing failed', defaultExpectedAction ], + ], + [ + 'when post is published and edits is privately published', + '', + [ 'draft', 'private' ], + [ 'Publishing failed', defaultExpectedAction ], + ], + [ + 'when post is published and edits is scheduled to be published', + '', + [ 'draft', 'future' ], + [ 'Scheduling failed', defaultExpectedAction ], + ], + [ + 'when post is published and edits is published', + '', + [ 'publish', 'publish' ], + [ 'Updating failed', defaultExpectedAction ], + ], + ].forEach( ( [ + description, + errorCode, + [ postStatus, editsStatus ], + expectedValue, + ] ) => { + it( description, () => { + post.status = postStatus; + error.code = errorCode; + edits.status = editsStatus; + expect( getNotificationArgumentsForSaveFail( + { + post, + edits, + error, + } + ) ).toEqual( expectedValue ); + } ); + } ); +} ); +describe( 'getNotificationArgumentsForTrashFail()', () => { + [ + [ + 'when there is an error message and the error code is not "unknown_error"', + { message: 'foo', code: '' }, + 'foo', + ], + [ + 'when there is an error message and the error code is "unknown error"', + { message: 'foo', code: 'unknown_error' }, + 'Trashing failed', + ], + [ + 'when there is not an error message', + { code: 42 }, + 'Trashing failed', + ], + ].forEach( ( [ + description, + error, + message, + ] ) => { + it( description, () => { + const expectedValue = [ + message, + { id: TRASH_POST_NOTICE_ID }, + ]; + expect( getNotificationArgumentsForTrashFail( { error } ) ) + .toEqual( expectedValue ); + } ); + } ); +} );