From 2e488f6fdc6941e536f19a93ca09dae7b8ff46c6 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Thu, 28 Feb 2019 14:26:11 -0500 Subject: [PATCH] Refactor to remove usage of post related effects in packages/editor. (#13716) This pull is the first step in moving away from the lingering usage of effects in various data stores among packages. This pull specifically deals with post related effects in the @wordpress/editor package (`core/editor` store). --- .../developers/data/data-core-editor.md | 76 +- packages/editor/CHANGELOG.md | 1 + packages/editor/src/store/actions.js | 396 ++++++++- packages/editor/src/store/constants.js | 12 + packages/editor/src/store/controls.js | 103 ++- packages/editor/src/store/effects.js | 20 - packages/editor/src/store/effects/posts.js | 319 ------- packages/editor/src/store/index.js | 8 +- packages/editor/src/store/selectors.js | 22 +- packages/editor/src/store/test/actions.js | 775 +++++++++++++++++- packages/editor/src/store/test/effects.js | 197 ----- packages/editor/src/store/test/selectors.js | 2 +- .../editor/src/store/utils/notice-builder.js | 123 +++ .../src/store/utils/test/notice-builder.js | 182 ++++ 14 files changed, 1588 insertions(+), 648 deletions(-) delete mode 100644 packages/editor/src/store/effects/posts.js create mode 100644 packages/editor/src/store/utils/notice-builder.js create mode 100644 packages/editor/src/store/utils/test/notice-builder.js 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 ); + } ); + } ); +} );